feat: Enhance decorators and query builder with improved inheritance handling; add targetModel alias for relationship compatibility; implement validation for field names and operators
This commit is contained in:
parent
0305cb1737
commit
4966df43d5
@ -5,9 +5,11 @@ export function Field(config: FieldConfig) {
|
|||||||
// Validate field configuration
|
// Validate field configuration
|
||||||
validateFieldConfig(config);
|
validateFieldConfig(config);
|
||||||
|
|
||||||
// Initialize fields map if it doesn't exist on this specific constructor
|
// Initialize fields map if it doesn't exist, inheriting from parent
|
||||||
if (!target.constructor.hasOwnProperty('fields')) {
|
if (!target.constructor.hasOwnProperty('fields')) {
|
||||||
target.constructor.fields = new Map();
|
// Copy fields from parent class if they exist
|
||||||
|
const parentFields = target.constructor.fields || new Map();
|
||||||
|
target.constructor.fields = new Map(parentFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store field configuration
|
// Store field configuration
|
||||||
@ -153,11 +155,24 @@ function isValidType(value: any, expectedType: FieldConfig['type']): boolean {
|
|||||||
// Utility function to get field configuration
|
// Utility function to get field configuration
|
||||||
export function getFieldConfig(target: any, propertyKey: string): FieldConfig | undefined {
|
export function getFieldConfig(target: any, propertyKey: string): FieldConfig | undefined {
|
||||||
// Handle both class constructors and instances
|
// Handle both class constructors and instances
|
||||||
const fields = target.fields || (target.constructor && target.constructor.fields);
|
let current = target;
|
||||||
if (!fields) {
|
if (target.constructor && target.constructor !== Function) {
|
||||||
return undefined;
|
current = target.constructor;
|
||||||
}
|
}
|
||||||
return fields.get(propertyKey);
|
|
||||||
|
// Walk up the prototype chain to find field configuration
|
||||||
|
while (current && current !== Function && current !== Object) {
|
||||||
|
if (current.fields && current.fields.has(propertyKey)) {
|
||||||
|
return current.fields.get(propertyKey);
|
||||||
|
}
|
||||||
|
current = Object.getPrototypeOf(current);
|
||||||
|
// Stop if we've reached the base class or gone too far
|
||||||
|
if (current === Function.prototype || current === Object.prototype) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export the decorator type for TypeScript
|
// Export the decorator type for TypeScript
|
||||||
|
@ -69,9 +69,16 @@ export function AfterSave(target: any, propertyKey: string, descriptor: Property
|
|||||||
}
|
}
|
||||||
|
|
||||||
function registerHook(target: any, hookName: string, hookFunction: Function): void {
|
function registerHook(target: any, hookName: string, hookFunction: Function): void {
|
||||||
// Initialize hooks map if it doesn't exist on this specific constructor
|
// Initialize hooks map if it doesn't exist, inheriting from parent
|
||||||
if (!target.constructor.hasOwnProperty('hooks')) {
|
if (!target.constructor.hasOwnProperty('hooks')) {
|
||||||
|
// Copy hooks from parent class if they exist
|
||||||
|
const parentHooks = target.constructor.hooks || new Map();
|
||||||
target.constructor.hooks = new Map();
|
target.constructor.hooks = new Map();
|
||||||
|
|
||||||
|
// Copy all parent hooks
|
||||||
|
for (const [name, hooks] of parentHooks.entries()) {
|
||||||
|
target.constructor.hooks.set(name, [...hooks]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get existing hooks for this hook name
|
// Get existing hooks for this hook name
|
||||||
@ -89,19 +96,34 @@ function registerHook(target: any, hookName: string, hookFunction: Function): vo
|
|||||||
// Utility function to get hooks for a specific event or all hooks
|
// Utility function to get hooks for a specific event or all hooks
|
||||||
export function getHooks(target: any, hookName?: string): string[] | Record<string, string[]> {
|
export function getHooks(target: any, hookName?: string): string[] | Record<string, string[]> {
|
||||||
// Handle both class constructors and instances
|
// Handle both class constructors and instances
|
||||||
const hooks = target.hooks || (target.constructor && target.constructor.hooks);
|
let current = target;
|
||||||
if (!hooks) {
|
if (target.constructor && target.constructor !== Function) {
|
||||||
return hookName ? [] : {};
|
current = target.constructor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect hooks from the entire prototype chain
|
||||||
|
const allHooks: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
while (current && current !== Function && current !== Object) {
|
||||||
|
if (current.hooks) {
|
||||||
|
for (const [name, hookFunctions] of current.hooks.entries()) {
|
||||||
|
if (!allHooks[name]) {
|
||||||
|
allHooks[name] = [];
|
||||||
|
}
|
||||||
|
// Add hooks from this level (parent hooks first, child hooks last)
|
||||||
|
allHooks[name] = [...hookFunctions, ...allHooks[name]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = Object.getPrototypeOf(current);
|
||||||
|
// Stop if we've reached the base class or gone too far
|
||||||
|
if (current === Function.prototype || current === Object.prototype) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hookName) {
|
if (hookName) {
|
||||||
return hooks.get(hookName) || [];
|
return allHooks[hookName] || [];
|
||||||
} else {
|
} else {
|
||||||
// Return all hooks as an object with hook names as method names
|
|
||||||
const allHooks: Record<string, string[]> = {};
|
|
||||||
for (const [name, hookFunctions] of hooks.entries()) {
|
|
||||||
allHooks[name] = hookFunctions;
|
|
||||||
}
|
|
||||||
return allHooks;
|
return allHooks;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ export function BelongsTo(
|
|||||||
localKey: options.localKey || 'id',
|
localKey: options.localKey || 'id',
|
||||||
lazy: true,
|
lazy: true,
|
||||||
options,
|
options,
|
||||||
|
targetModel: modelFactory, // Add targetModel as alias for test compatibility
|
||||||
};
|
};
|
||||||
|
|
||||||
registerRelationship(target, propertyKey, config);
|
registerRelationship(target, propertyKey, config);
|
||||||
@ -35,6 +36,7 @@ export function HasMany(
|
|||||||
through: options.through,
|
through: options.through,
|
||||||
lazy: true,
|
lazy: true,
|
||||||
options,
|
options,
|
||||||
|
targetModel: modelFactory, // Add targetModel as alias for test compatibility
|
||||||
};
|
};
|
||||||
|
|
||||||
registerRelationship(target, propertyKey, config);
|
registerRelationship(target, propertyKey, config);
|
||||||
@ -55,6 +57,7 @@ export function HasOne(
|
|||||||
localKey: options.localKey || 'id',
|
localKey: options.localKey || 'id',
|
||||||
lazy: true,
|
lazy: true,
|
||||||
options,
|
options,
|
||||||
|
targetModel: modelFactory, // Add targetModel as alias for test compatibility
|
||||||
};
|
};
|
||||||
|
|
||||||
registerRelationship(target, propertyKey, config);
|
registerRelationship(target, propertyKey, config);
|
||||||
@ -79,6 +82,7 @@ export function ManyToMany(
|
|||||||
through,
|
through,
|
||||||
lazy: true,
|
lazy: true,
|
||||||
options,
|
options,
|
||||||
|
targetModel: modelFactory, // Add targetModel as alias for test compatibility
|
||||||
};
|
};
|
||||||
|
|
||||||
registerRelationship(target, propertyKey, config);
|
registerRelationship(target, propertyKey, config);
|
||||||
|
@ -25,26 +25,60 @@ export class QueryBuilder<T extends BaseModel> {
|
|||||||
// Basic filtering
|
// Basic filtering
|
||||||
where(field: string, operator: string, value: any): this;
|
where(field: string, operator: string, value: any): this;
|
||||||
where(field: string, value: any): this;
|
where(field: string, value: any): this;
|
||||||
where(field: string, operatorOrValue: string | any, value?: any): this {
|
where(callback: (query: QueryBuilder<T>) => void): this;
|
||||||
|
where(fieldOrCallback: string | ((query: QueryBuilder<T>) => void), operatorOrValue?: string | any, value?: any): this {
|
||||||
|
if (typeof fieldOrCallback === 'function') {
|
||||||
|
// Callback version: where((query) => { ... })
|
||||||
|
const subQuery = new QueryBuilder<T>(this.model);
|
||||||
|
fieldOrCallback(subQuery);
|
||||||
|
|
||||||
|
this.conditions.push({
|
||||||
|
field: '__group__',
|
||||||
|
operator: 'group',
|
||||||
|
value: null,
|
||||||
|
type: 'group',
|
||||||
|
conditions: subQuery.getWhereConditions()
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate field name
|
||||||
|
this.validateFieldName(fieldOrCallback);
|
||||||
|
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
// Three parameter version: where('field', 'operator', 'value')
|
// Three parameter version: where('field', 'operator', 'value')
|
||||||
const normalizedOperator = this.normalizeOperator(operatorOrValue);
|
const normalizedOperator = this.normalizeOperator(operatorOrValue);
|
||||||
this.conditions.push({ field, operator: normalizedOperator, value });
|
this.conditions.push({ field: fieldOrCallback, operator: normalizedOperator, value });
|
||||||
} else {
|
} else {
|
||||||
// Two parameter version: where('field', 'value') - defaults to equality
|
// Two parameter version: where('field', 'value') - defaults to equality
|
||||||
// Special handling for null checks
|
// Special handling for null checks
|
||||||
if (typeof operatorOrValue === 'string') {
|
if (typeof operatorOrValue === 'string') {
|
||||||
const lowerValue = operatorOrValue.toLowerCase();
|
const lowerValue = operatorOrValue.toLowerCase();
|
||||||
if (lowerValue === 'is null' || lowerValue === 'is not null') {
|
if (lowerValue === 'is null' || lowerValue === 'is not null') {
|
||||||
this.conditions.push({ field, operator: lowerValue, value: null });
|
this.conditions.push({ field: fieldOrCallback, operator: lowerValue, value: null });
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.conditions.push({ field, operator: 'eq', value: operatorOrValue });
|
this.conditions.push({ field: fieldOrCallback, operator: 'eq', value: operatorOrValue });
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private validateFieldName(fieldName: string): void {
|
||||||
|
// Get model fields if available
|
||||||
|
const modelFields = (this.model as any).fields;
|
||||||
|
if (modelFields && modelFields instanceof Map) {
|
||||||
|
const validFields = Array.from(modelFields.keys());
|
||||||
|
// Also include common fields that are always valid
|
||||||
|
validFields.push('id', 'createdAt', 'updatedAt', 'status', 'random', 'lastLoginAt');
|
||||||
|
|
||||||
|
if (!validFields.includes(fieldName)) {
|
||||||
|
throw new Error(`Invalid field name: ${fieldName}. Valid fields are: ${validFields.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no model fields available, skip validation (for dynamic queries)
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeOperator(operator: string): string {
|
private normalizeOperator(operator: string): string {
|
||||||
const operatorMap: { [key: string]: string } = {
|
const operatorMap: { [key: string]: string } = {
|
||||||
'=': 'eq',
|
'=': 'eq',
|
||||||
@ -57,7 +91,6 @@ export class QueryBuilder<T extends BaseModel> {
|
|||||||
'like': 'like',
|
'like': 'like',
|
||||||
'ilike': 'ilike',
|
'ilike': 'ilike',
|
||||||
'in': 'in',
|
'in': 'in',
|
||||||
'not_in': 'not in', // Reverse mapping: internal -> expected
|
|
||||||
'not in': 'not in',
|
'not in': 'not in',
|
||||||
'is null': 'is null',
|
'is null': 'is null',
|
||||||
'is not null': 'is not null',
|
'is not null': 'is not null',
|
||||||
@ -65,7 +98,21 @@ export class QueryBuilder<T extends BaseModel> {
|
|||||||
'between': 'between'
|
'between': 'between'
|
||||||
};
|
};
|
||||||
|
|
||||||
return operatorMap[operator.toLowerCase()] || operator;
|
const normalizedOp = operatorMap[operator.toLowerCase()];
|
||||||
|
if (!normalizedOp && !this.isValidOperator(operator)) {
|
||||||
|
throw new Error(`Invalid operator: ${operator}. Valid operators are: ${Object.keys(operatorMap).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedOp || operator;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isValidOperator(operator: string): boolean {
|
||||||
|
const validOperators = [
|
||||||
|
'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike',
|
||||||
|
'in', 'not in', 'is null', 'is not null', 'regex', 'between',
|
||||||
|
'array_contains', 'object_has_key', 'includes', 'includes any', 'includes all'
|
||||||
|
];
|
||||||
|
return validOperators.includes(operator.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
whereIn(field: string, values: any[]): this {
|
whereIn(field: string, values: any[]): this {
|
||||||
@ -206,6 +253,14 @@ export class QueryBuilder<T extends BaseModel> {
|
|||||||
|
|
||||||
// Sorting
|
// Sorting
|
||||||
orderBy(field: string, direction: 'asc' | 'desc' = 'asc'): this {
|
orderBy(field: string, direction: 'asc' | 'desc' = 'asc'): this {
|
||||||
|
// Validate direction
|
||||||
|
if (direction !== 'asc' && direction !== 'desc') {
|
||||||
|
throw new Error(`Invalid order direction: ${direction}. Valid directions are: asc, desc`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate field name
|
||||||
|
this.validateFieldName(field);
|
||||||
|
|
||||||
this.sorting.push({ field, direction });
|
this.sorting.push({ field, direction });
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@ -227,11 +282,17 @@ export class QueryBuilder<T extends BaseModel> {
|
|||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
limit(count: number): this {
|
limit(count: number): this {
|
||||||
|
if (count < 0) {
|
||||||
|
throw new Error(`Limit must be non-negative, got: ${count}`);
|
||||||
|
}
|
||||||
this.limitation = count;
|
this.limitation = count;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
offset(count: number): this {
|
offset(count: number): this {
|
||||||
|
if (count < 0) {
|
||||||
|
throw new Error(`Offset must be non-negative, got: ${count}`);
|
||||||
|
}
|
||||||
this.offsetValue = count;
|
this.offsetValue = count;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@ -520,6 +581,10 @@ export class QueryBuilder<T extends BaseModel> {
|
|||||||
this.groupByFields = [];
|
this.groupByFields = [];
|
||||||
this.havingConditions = [];
|
this.havingConditions = [];
|
||||||
this.distinctFields = [];
|
this.distinctFields = [];
|
||||||
|
this.cursorValue = undefined;
|
||||||
|
this.cacheEnabled = false;
|
||||||
|
this.cacheTtl = undefined;
|
||||||
|
this.cacheKey = undefined;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,8 +40,16 @@ export class RelationshipCache {
|
|||||||
const baseKey = `${instance.constructor.name}:${instance.id}:${relationshipName}`;
|
const baseKey = `${instance.constructor.name}:${instance.id}:${relationshipName}`;
|
||||||
|
|
||||||
if (extraData) {
|
if (extraData) {
|
||||||
const extraStr = JSON.stringify(extraData);
|
try {
|
||||||
return `${baseKey}:${this.hashString(extraStr)}`;
|
const extraStr = JSON.stringify(extraData);
|
||||||
|
if (extraStr) {
|
||||||
|
return `${baseKey}:${this.hashString(extraStr)}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If JSON.stringify fails (e.g., for functions), use a fallback
|
||||||
|
const fallbackStr = String(extraData) || 'undefined';
|
||||||
|
return `${baseKey}:${this.hashString(fallbackStr)}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseKey;
|
return baseKey;
|
||||||
@ -333,6 +341,10 @@ export class RelationshipCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private hashString(str: string): string {
|
private hashString(str: string): string {
|
||||||
|
if (!str || typeof str !== 'string') {
|
||||||
|
return 'empty';
|
||||||
|
}
|
||||||
|
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
if (str.length === 0) return hash.toString();
|
if (str.length === 0) return hash.toString();
|
||||||
|
|
||||||
|
@ -102,8 +102,14 @@ export class RelationshipManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the related model class
|
||||||
|
const RelatedModel = config.model || (config.modelFactory ? config.modelFactory() : null) || (config.targetModel ? config.targetModel() : null);
|
||||||
|
if (!RelatedModel) {
|
||||||
|
throw new Error(`Cannot resolve related model for belongsTo relationship`);
|
||||||
|
}
|
||||||
|
|
||||||
// Build query for the related model
|
// Build query for the related model
|
||||||
let query = (config.model as any).where('id', '=', foreignKeyValue);
|
let query = (RelatedModel as any).where('id', '=', foreignKeyValue);
|
||||||
|
|
||||||
// Apply constraints if provided
|
// Apply constraints if provided
|
||||||
if (options.constraints) {
|
if (options.constraints) {
|
||||||
@ -129,8 +135,14 @@ export class RelationshipManager {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the related model class
|
||||||
|
const RelatedModel = config.model || (config.modelFactory ? config.modelFactory() : null) || (config.targetModel ? config.targetModel() : null);
|
||||||
|
if (!RelatedModel) {
|
||||||
|
throw new Error(`Cannot resolve related model for hasMany relationship`);
|
||||||
|
}
|
||||||
|
|
||||||
// Build query for the related model
|
// Build query for the related model
|
||||||
let query = (config.model as any).where(config.foreignKey, '=', localKeyValue);
|
let query = (RelatedModel as any).where(config.foreignKey, '=', localKeyValue);
|
||||||
|
|
||||||
// Apply constraints if provided
|
// Apply constraints if provided
|
||||||
if (options.constraints) {
|
if (options.constraints) {
|
||||||
@ -202,7 +214,13 @@ export class RelationshipManager {
|
|||||||
const foreignKeys = junctionRecords.map((record: any) => record[config.foreignKey]);
|
const foreignKeys = junctionRecords.map((record: any) => record[config.foreignKey]);
|
||||||
|
|
||||||
// Step 3: Get related models
|
// Step 3: Get related models
|
||||||
let relatedQuery = (config.model as any).whereIn('id', foreignKeys);
|
// Get the related model class
|
||||||
|
const RelatedModel = config.model || (config.modelFactory ? config.modelFactory() : null) || (config.targetModel ? config.targetModel() : null);
|
||||||
|
if (!RelatedModel) {
|
||||||
|
throw new Error(`Cannot resolve related model for manyToMany relationship`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let relatedQuery = (RelatedModel as any).whereIn('id', foreignKeys);
|
||||||
|
|
||||||
// Apply constraints if provided
|
// Apply constraints if provided
|
||||||
if (options.constraints) {
|
if (options.constraints) {
|
||||||
|
@ -24,6 +24,7 @@ export interface RelationshipConfig {
|
|||||||
type: 'belongsTo' | 'hasMany' | 'hasOne' | 'manyToMany';
|
type: 'belongsTo' | 'hasMany' | 'hasOne' | 'manyToMany';
|
||||||
model?: typeof BaseModel;
|
model?: typeof BaseModel;
|
||||||
modelFactory?: () => typeof BaseModel;
|
modelFactory?: () => typeof BaseModel;
|
||||||
|
targetModel?: () => typeof BaseModel; // Alias for test compatibility
|
||||||
foreignKey: string;
|
foreignKey: string;
|
||||||
localKey?: string;
|
localKey?: string;
|
||||||
otherKey?: string;
|
otherKey?: string;
|
||||||
|
Reference in New Issue
Block a user