From 66bc92e574c93ea1803b037152a9ff83c16e4a6f Mon Sep 17 00:00:00 2001 From: Oussama Benhamed Date: Wed, 22 Jul 2026 23:15:00 +0100 Subject: [PATCH 1/5] feat: add RelationRegistry API for custom relations --- src/factories/factory_model.ts | 8 +- src/orm/base_model/index.ts | 64 +++- .../decorators/create_relation_decorator.ts | 50 +++ src/orm/main.ts | 3 + src/orm/relations/main.ts | 3 + src/orm/relations/relation_registry.ts | 82 +++++ src/types/model.ts | 3 +- src/types/relations.ts | 107 ++++++- test/orm/relation_registry.spec.ts | 302 ++++++++++++++++++ 9 files changed, 604 insertions(+), 18 deletions(-) create mode 100644 src/orm/decorators/create_relation_decorator.ts create mode 100644 src/orm/relations/relation_registry.ts create mode 100644 test/orm/relation_registry.spec.ts diff --git a/src/factories/factory_model.ts b/src/factories/factory_model.ts index d9cabfa13..f2a11a53d 100644 --- a/src/factories/factory_model.ts +++ b/src/factories/factory_model.ts @@ -170,16 +170,16 @@ export class FactoryModel implements FactoryModelContr switch (modelRelation.type) { case 'belongsTo': - this.relations[relation as string] = new BelongsTo(modelRelation, callback as any) + this.relations[relation as string] = new BelongsTo(modelRelation as any, callback as any) break case 'hasOne': - this.relations[relation as string] = new HasOne(modelRelation, callback as any) + this.relations[relation as string] = new HasOne(modelRelation as any, callback as any) break case 'hasMany': - this.relations[relation as string] = new HasMany(modelRelation, callback as any) + this.relations[relation as string] = new HasMany(modelRelation as any, callback as any) break case 'manyToMany': - this.relations[relation as string] = new ManyToMany(modelRelation, callback as any) + this.relations[relation as string] = new ManyToMany(modelRelation as any, callback as any) break case 'hasManyThrough': throw new Error( diff --git a/src/orm/base_model/index.ts b/src/orm/base_model/index.ts index 7b602b60d..9799cd72c 100644 --- a/src/orm/base_model/index.ts +++ b/src/orm/base_model/index.ts @@ -46,6 +46,7 @@ import { type ModelRelations, type RelationOptions, type RelationshipsContract, + type BaseRelationContract, type ThroughRelationOptions, type ManyToManyRelationOptions, } from '../../types/relations.js' @@ -59,6 +60,7 @@ import { HasMany } from '../relations/has_many/index.js' import { BelongsTo } from '../relations/belongs_to/index.js' import { ManyToMany } from '../relations/many_to_many/index.js' import { HasManyThrough } from '../relations/has_many_through/index.js' +import { RelationRegistry } from '../relations/relation_registry.js' import { CamelCaseNamingStrategy } from '../naming_strategies/camel_case.js' import { LazyLoadAggregates } from '../relations/aggregates_loader/lazy_load.js' import { @@ -71,7 +73,16 @@ import { compareValues, } from '../../utils/index.js' -const MANY_RELATIONS = ['hasMany', 'manyToMany', 'hasManyThrough'] +/** + * Returns all relation types that handle multiple related records. + * Includes both built-in relations and custom relations registered via RelationRegistry. + * + * Note: This is called dynamically (not cached) to ensure newly registered + * custom relations are included (important for testing scenarios). + */ +function getManyRelations(): string[] { + return ['hasMany', 'manyToMany', 'hasManyThrough', ...RelationRegistry.getManyRelationTypes()] +} const DATE_TIME_TYPES = { date: 'date', datetime: 'datetime', @@ -158,7 +169,7 @@ class BaseModelImpl implements LucidRow { /** * Registered relationships for the given model */ - static $relationsDefinitions: Map + static $relationsDefinitions: Map> /** * The name of database table. It is auto generated from the model name, unless @@ -543,24 +554,53 @@ class BaseModelImpl implements LucidRow { type: ModelRelations['__opaque_type'], relatedModel: () => LucidModel, options: ModelRelationOptions - ) { + ): BaseRelationContract { + // Try built-in relations first + const builtInRelation = this.$addBuiltInRelation(name, type, relatedModel, options) + if (builtInRelation) { + return builtInRelation + } + + // Check for custom relations in the registry + const factory = RelationRegistry.get(type as string) + if (!factory) { + throw new Error( + `"${type}" is not a supported relation type. Did you forget to register it with RelationRegistry.register()?` + ) + } + + // Create the custom relation + const relation = factory.create(name, relatedModel, options, this) + this.$relationsDefinitions.set(name, relation) + return relation + } + + /** + * Handles built-in relation types + */ + private static $addBuiltInRelation( + name: string, + type: string, + relatedModel: () => LucidModel, + options: ModelRelationOptions + ): BaseRelationContract | null { switch (type) { case 'hasOne': this.$addHasOne(name, relatedModel, options) - break + return this.$relationsDefinitions.get(name)! case 'hasMany': this.$addHasMany(name, relatedModel, options) - break + return this.$relationsDefinitions.get(name)! case 'belongsTo': this.$addBelongsTo(name, relatedModel, options) - break + return this.$relationsDefinitions.get(name)! case 'manyToMany': this.$addManyToMany( name, relatedModel, options as ManyToManyRelationOptions> ) - break + return this.$relationsDefinitions.get(name)! case 'hasManyThrough': this.$addHasManyThrough( name, @@ -571,9 +611,9 @@ class BaseModelImpl implements LucidRow { ModelRelations > ) - break + return this.$relationsDefinitions.get(name)! default: - throw new Error(`${type} is not a supported relation type`) + return null } } @@ -695,7 +735,7 @@ class BaseModelImpl implements LucidRow { * Define relationships */ this.$defineProperty('$relationsDefinitions', new Map(), (value) => { - const relations = new Map() + const relations = new Map>() value.forEach((relation, key) => { const relationClone = relation.clone(this) relations.set(key, relationClone) @@ -1721,7 +1761,7 @@ class BaseModelImpl implements LucidRow { /** * Reset array before invoking $pushRelated */ - if (MANY_RELATIONS.includes(relation.type)) { + if (getManyRelations().includes(relation.type)) { if (!Array.isArray(models)) { throw new Exception( `"${Model.name}.${key}" must be an array when setting "${relation.type}" relationship` @@ -1750,7 +1790,7 @@ class BaseModelImpl implements LucidRow { /** * Create multiple for `hasMany` `manyToMany` and `hasManyThrough` */ - if (MANY_RELATIONS.includes(relation.type)) { + if (getManyRelations().includes(relation.type)) { this.$preloaded[key] = ((this.$preloaded[key] || []) as LucidRow[]).concat(models) return } diff --git a/src/orm/decorators/create_relation_decorator.ts b/src/orm/decorators/create_relation_decorator.ts new file mode 100644 index 000000000..46c558ded --- /dev/null +++ b/src/orm/decorators/create_relation_decorator.ts @@ -0,0 +1,50 @@ +/* + * @adonisjs/lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { LucidModel } from '../../types/model.js' +import type { ModelRelationTypes } from '../../types/relations.js' + +/** + * Utility to create a custom relation decorator. + * Useful for third-party packages that want to add custom relation types. + * + * @example + * ```ts + * // In your package + * export const myRelation: MyRelationDecoratorType = createRelationDecorator( + * 'myRelation' + * ) + * + * // Usage + * class SomeModel extends BaseModel { + * @myRelation(() => SomeModel, { ...someOptions }) + * declare someRelation: MyRelation + * } + * ``` + */ +export function createRelationDecorator< + TRelationType extends ModelRelationTypes['__opaque_type'] = ModelRelationTypes['__opaque_type'], + TOptions = any, +>( + relationType: TRelationType +): (relatedModel: () => LucidModel, options?: TOptions) => PropertyDecorator { + return function decorator(relatedModel, options?) { + return function decorateAsRelation(target, property: string | symbol) { + const Model = target.constructor as LucidModel + Model.boot() + const propertyName = typeof property === 'symbol' ? property.toString() : property + Model.$addRelation( + propertyName, + relationType, + relatedModel, + Object.assign({ relatedModel }, options) as any + ) + } + } +} diff --git a/src/orm/main.ts b/src/orm/main.ts index f740ff868..8d5505779 100644 --- a/src/orm/main.ts +++ b/src/orm/main.ts @@ -10,6 +10,9 @@ export * from './decorators/index.js' export * from './decorators/date.js' export * from './decorators/date_time.js' +export { createRelationDecorator } from './decorators/create_relation_decorator.js' +export { RelationRegistry } from './relations/relation_registry.js' +export type { RelationFactory, RelationFactoryConfig } from '../types/relations.js' export { BaseModel, scope } from './base_model/index.js' export { ModelQueryBuilder } from './query_builder/index.js' export { ModelPaginator } from './paginator/index.js' diff --git a/src/orm/relations/main.ts b/src/orm/relations/main.ts index c44ddda35..b26d7dc52 100644 --- a/src/orm/relations/main.ts +++ b/src/orm/relations/main.ts @@ -11,3 +11,6 @@ export { BelongsToQueryClient } from './belongs_to/query_client.js' export { HasManyQueryClient } from './has_many/query_client.js' export { HasOneQueryClient } from './has_one/query_client.js' export { ManyToManyQueryClient } from './many_to_many/query_client.js' +export { BaseQueryBuilder as RelationBaseQueryBuilder } from './base/query_builder.js' +export { BaseSubQueryBuilder as RelationBaseSubQueryBuilder } from './base/sub_query_builder.ts' +export { KeysExtractor } from './keys_extractor.js' diff --git a/src/orm/relations/relation_registry.ts b/src/orm/relations/relation_registry.ts new file mode 100644 index 000000000..b2a372280 --- /dev/null +++ b/src/orm/relations/relation_registry.ts @@ -0,0 +1,82 @@ +/* + * @adonisjs/lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { RelationFactory, RelationFactoryConfig } from '../../types/relations.js' + +/** + * Registry for managing custom relation types. + * Allows third-party packages to register new relation types. + */ +export class RelationRegistry { + private static relations = new Map() + + /** + * Registers a new relation type + * + * @example + * ```ts + * RelationRegistry.register('myRelation', { + * isMany: true, + * create(relationName, relatedModel, options, model) { + * return new MyRelation(relationName, relatedModel, options, model) + * } + * }) + * ``` + */ + static register(type: string, factoryConfig: RelationFactoryConfig): void { + if (this.relations.has(type)) { + throw new Error(`Relation type "${type}" is already registered`) + } + + // Inject the type into the factory config + const factory: RelationFactory = { + ...factoryConfig, + type, + } + + this.relations.set(type, factory) + } + + /** + * Retrieves a relation factory by type + */ + static get(type: string): RelationFactory | undefined { + return this.relations.get(type) + } + + /** + * Checks if a relation type is registered + */ + static has(type: string): boolean { + return this.relations.has(type) + } + + /** + * Returns all registered "many" relation types + */ + static getManyRelationTypes(): string[] { + return Array.from(this.relations.entries()) + .filter(([_, factory]) => factory.isMany) + .map(([type]) => type) + } + + /** + * Unregisters a relation type (mainly for testing) + */ + static unregister(type: string): void { + this.relations.delete(type) + } + + /** + * Clears all registered relations (mainly for testing) + */ + static clear(): void { + this.relations.clear() + } +} diff --git a/src/types/model.ts b/src/types/model.ts index f4b17ff7f..0e16481f0 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -37,6 +37,7 @@ import { type PreloaderContract, type RelationOptions, type RelationshipsContract, + type BaseRelationContract, type ThroughRelationOptions, type WhereHas, type WithAggregate, @@ -802,7 +803,7 @@ export interface LucidModel { /** * A map of defined relationships */ - $relationsDefinitions: Map + $relationsDefinitions: Map> /** * A map of computed properties diff --git a/src/types/relations.ts b/src/types/relations.ts index fc67cb1d0..2179eb824 100644 --- a/src/types/relations.ts +++ b/src/types/relations.ts @@ -52,6 +52,41 @@ export type GetRelationModelInstance = BaseRelationContract< + LucidModel, + LucidModel + >, +> { + /** + * Creates a new instance of the relation + */ + create(relationName: string, relatedModel: () => LucidModel, options: any, model: LucidModel): T + + /** + * Indicates if this is a "many" relation (for the preloader) + */ + isMany: boolean +} + +/** + * Factory interface for creating custom relation instances + */ +export interface RelationFactory< + T extends BaseRelationContract = BaseRelationContract< + LucidModel, + LucidModel + >, +> extends RelationFactoryConfig { + /** + * Type of relation (used for identification) + */ + type: string +} + /** * ------------------------------------------------------ * Options @@ -109,6 +144,18 @@ export type ThroughRelationOptions< meta?: any } +/** + * Type alias for model relation options used internally + */ +export type ModelRelationOptions< + RelatedModel extends LucidModel, + ParentModel extends LucidModel, + Related extends ModelRelations, +> = + | RelationOptions + | ManyToManyRelationOptions + | ThroughRelationOptions + /** * ------------------------------------------------------ * Decorators @@ -167,8 +214,39 @@ export type HasManyThroughDecorator = ( * between standard model properties and relationships * */ + +/** + * Interface that can be augmented by third-party packages to register custom relation types. + * + * @example + * ```ts + * // In your package + * declare module '@adonisjs/lucid/types/relations' { + * interface KnownCustomRelations { + * myRelation: MyRelationContract + * } + * + * interface KnownCustomOpaqueRelations { + * myRelation: MyOpaqueRelationType + * } + * } + * ``` + */ +export interface KnownCustomRelations {} + +/** + * Interface for custom opaque relation types (for type-safe relation properties on models) + */ +export interface KnownCustomOpaqueRelations {} + export type ModelRelationTypes = { - readonly __opaque_type: 'hasOne' | 'hasMany' | 'belongsTo' | 'manyToMany' | 'hasManyThrough' + readonly __opaque_type: + | 'hasOne' + | 'hasMany' + | 'belongsTo' + | 'manyToMany' + | 'hasManyThrough' + | keyof KnownCustomRelations } /** @@ -269,6 +347,7 @@ export type ModelRelations< | BelongsTo | ManyToMany | HasManyThrough + | KnownCustomOpaqueRelations[keyof KnownCustomOpaqueRelations] /** * ------------------------------------------------------ @@ -307,6 +386,31 @@ export interface BaseRelationContract< ): RelationQueryBuilderContract> subQuery(client: QueryClientContract): RelationSubQueryBuilderContract + + /** + * Set related model(s) as a relationship on the parent model + */ + setRelated( + parent: InstanceType, + related: InstanceType | InstanceType[] | null + ): void + + /** + * Push related model(s) to the existing relationship on the parent model + */ + pushRelated( + parent: InstanceType, + related: InstanceType | InstanceType[] | null + ): void + + /** + * Set multiple related instances on multiple parent models. + * This method is generally invoked during eager load. + */ + setRelatedForMany( + parent: InstanceType[], + related: InstanceType[] + ): void } /** @@ -590,6 +694,7 @@ export type RelationshipsContract = | BelongsToRelationContract | ManyToManyRelationContract | HasManyThroughRelationContract + | KnownCustomRelations[keyof KnownCustomRelations] /** * ------------------------------------------------------ diff --git a/test/orm/relation_registry.spec.ts b/test/orm/relation_registry.spec.ts new file mode 100644 index 000000000..19bf3c529 --- /dev/null +++ b/test/orm/relation_registry.spec.ts @@ -0,0 +1,302 @@ +/* + * @adonisjs/lucid + * + * (c) Harminder Virk + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { AppFactory } from '@adonisjs/core/factories/app' +import type { LucidModel, LucidRow } from '../../src/types/model.js' +import type { BaseRelationContract } from '../../src/types/relations.js' +import type { QueryClientContract } from '../../src/types/database.js' +import { RelationRegistry, createRelationDecorator, column } from '../../src/orm/main.js' +import { getDb, setup, cleanup, getBaseModel, ormAdapter } from '../../test-helpers/index.js' + +// Augment KnownCustomRelations for test relations +declare module '../../src/types/relations.js' { + interface KnownCustomRelations { + testRelation: BaseRelationContract + } +} + +test.group('RelationRegistry', (group) => { + group.setup(async () => { + await setup() + }) + + group.teardown(async () => { + await cleanup() + }) + + group.each.teardown(() => { + // Clean up any test relations + RelationRegistry.unregister('customRelation') + }) + + test('register a custom relation type', ({ assert }) => { + const factory = { + isMany: false, + create() { + return {} as any + }, + } + + RelationRegistry.register('customRelation', factory) + + assert.isTrue(RelationRegistry.has('customRelation')) + const registeredFactory = RelationRegistry.get('customRelation') + assert.equal(registeredFactory?.type, 'customRelation') + assert.equal(registeredFactory?.isMany, false) + }) + + test('throw error when registering duplicate relation type', ({ assert }) => { + const factory = { + isMany: false, + create() { + return {} as any + }, + } + + RelationRegistry.register('customRelation', factory) + + assert.throws( + () => RelationRegistry.register('customRelation', factory), + 'Relation type "customRelation" is already registered' + ) + }) + + test('return undefined for unknown relation type', ({ assert }) => { + assert.isUndefined(RelationRegistry.get('unknownRelation')) + }) + + test('getManyRelationTypes returns only many relations', ({ assert }) => { + RelationRegistry.register('customMany', { + isMany: true, + create() { + return {} as any + }, + }) + + RelationRegistry.register('customOne', { + isMany: false, + create() { + return {} as any + }, + }) + + const manyTypes = RelationRegistry.getManyRelationTypes() + + // Should include custom custom many relation and not include custom one relation + assert.include(manyTypes, 'customMany') + assert.notInclude(manyTypes, 'customOne') + + RelationRegistry.unregister('customMany') + RelationRegistry.unregister('customOne') + }) + + test('unregister removes a relation type', ({ assert }) => { + const factory = { + isMany: false, + create() { + return {} as any + }, + } + + RelationRegistry.register('customRelation', factory) + assert.isTrue(RelationRegistry.has('customRelation')) + + RelationRegistry.unregister('customRelation') + assert.isFalse(RelationRegistry.has('customRelation')) + }) +}) + +test.group('RelationRegistry | Integration with BaseModel', (group) => { + group.setup(async () => { + await setup() + }) + + group.teardown(async () => { + await cleanup() + }) + + group.each.teardown(() => { + RelationRegistry.unregister('testRelation') + }) + + test('use custom relation decorator created with createRelationDecorator', async ({ + fs, + assert, + }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + // Create a simple test relation + class TestRelation implements BaseRelationContract { + type = 'testRelation' as const + booted = false + relationName: string + serializeAs: any + model: LucidModel + + constructor( + relationName: string, + public relatedModel: () => LucidModel, + private options: any, + model: LucidModel + ) { + this.relationName = relationName + this.model = model + } + + boot() { + this.booted = true + } + + clone(): any { + return new TestRelation(this.relationName, this.relatedModel, this.options, this.model) + } + + setRelated() {} + pushRelated() {} + setRelatedForMany() {} + + client(_parent: LucidRow, _client: QueryClientContract): any { + return null + } + + eagerQuery(): any { + return null + } + + subQuery(): any { + return null + } + } + + // Register the relation + RelationRegistry.register('testRelation', { + isMany: true, + create(relationName, relatedModel, options, model) { + return new TestRelation(relationName, relatedModel, options, model) + }, + }) + + // Create decorator + const testRelation = createRelationDecorator('testRelation') + + // Use it on a model + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @testRelation(() => User) + declare related: any + } + + // Verify the relation was added + assert.isTrue(User.$hasRelation('related')) + assert.equal(User.$getRelation('related')!.type, 'testRelation') + assert.instanceOf(User.$getRelation('related'), TestRelation) + }) + + test('throw error when using unregistered relation type', async ({ fs, assert }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + const unknownRelation = createRelationDecorator('unknownRelationType' as any) + + assert.throws(() => { + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @unknownRelation(() => User) + declare related: any + } + + // Trigger boot + User.boot() + }, '"unknownRelationType" is not a supported relation type. Did you forget to register it with RelationRegistry.register()?') + }) + + test('custom relation included in MANY_RELATIONS when isMany is true', async ({ fs, assert }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + class TestManyRelation implements BaseRelationContract { + type = 'testRelation' as const + booted = false + relationName: string + serializeAs: any + model: LucidModel + + constructor( + relationName: string, + public relatedModel: () => LucidModel, + private options: any, + model: LucidModel + ) { + this.relationName = relationName + this.model = model + } + + boot() { + this.booted = true + } + clone(): any { + return new TestManyRelation(this.relationName, this.relatedModel, this.options, this.model) + } + setRelated() {} + pushRelated() {} + setRelatedForMany() {} + client(): any { + return null + } + eagerQuery(): any { + return null + } + subQuery(): any { + return null + } + } + + RelationRegistry.register('testRelation', { + isMany: true, + create(relationName, relatedModel, options, model) { + return new TestManyRelation(relationName, relatedModel, options, model) + }, + }) + + const testRelation = createRelationDecorator('testRelation') + + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @testRelation(() => User) + declare related: any + } + + User.boot() + + // Test that $setRelated accepts arrays for many relations + const user = new User() + const related1 = new User() + const related2 = new User() + + user.$setRelated('related', [related1, related2]) + assert.lengthOf(user.related, 2) + }) +}) From 0296b582912ec812db48522135765de3744bf312 Mon Sep 17 00:00:00 2001 From: Oussama Benhamed Date: Thu, 23 Jul 2026 01:12:43 +0100 Subject: [PATCH 2/5] fix: use js extension --- src/orm/relations/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/orm/relations/main.ts b/src/orm/relations/main.ts index b26d7dc52..8faab80e3 100644 --- a/src/orm/relations/main.ts +++ b/src/orm/relations/main.ts @@ -12,5 +12,5 @@ export { HasManyQueryClient } from './has_many/query_client.js' export { HasOneQueryClient } from './has_one/query_client.js' export { ManyToManyQueryClient } from './many_to_many/query_client.js' export { BaseQueryBuilder as RelationBaseQueryBuilder } from './base/query_builder.js' -export { BaseSubQueryBuilder as RelationBaseSubQueryBuilder } from './base/sub_query_builder.ts' +export { BaseSubQueryBuilder as RelationBaseSubQueryBuilder } from './base/sub_query_builder.js' export { KeysExtractor } from './keys_extractor.js' From 23f278f3facc25ee5dad74983a883bdce7057077 Mon Sep 17 00:00:00 2001 From: Oussama Benhamed Date: Thu, 23 Jul 2026 18:49:02 +0100 Subject: [PATCH 3/5] fix: lint errors --- test/orm/orm_schema_builder.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/orm/orm_schema_builder.spec.ts b/test/orm/orm_schema_builder.spec.ts index 6acfc9138..97e888636 100644 --- a/test/orm/orm_schema_builder.spec.ts +++ b/test/orm/orm_schema_builder.spec.ts @@ -1419,8 +1419,8 @@ test.group('OrmSchemaBuilder | Name Conversion', (group) => { const generator = new OrmSchemaBuilder(connection) const columns = { - 'id': { type: 'integer', nullable: false }, - 'player1_id': { type: 'varchar', nullable: false }, + id: { type: 'integer', nullable: false }, + player1_id: { type: 'varchar', nullable: false }, } const schemas = generator.generateSchemas([{ name: 'games', columns, primaryKeys: ['id'] }]) @@ -1455,8 +1455,8 @@ test.group('OrmSchemaBuilder | Name Conversion', (group) => { ]) const columns = { - 'id': { type: 'integer', nullable: false }, - 'player1_id': { type: 'varchar', nullable: false }, + id: { type: 'integer', nullable: false }, + player1_id: { type: 'varchar', nullable: false }, } const schemas = generator.generateSchemas([{ name: 'games', columns, primaryKeys: ['id'] }]) @@ -1500,8 +1500,8 @@ test.group('OrmSchemaBuilder | Name Conversion', (group) => { ]) const columns = { - 'id': { type: 'integer', nullable: false }, - 'player1_id': { type: 'varchar', nullable: false }, + id: { type: 'integer', nullable: false }, + player1_id: { type: 'varchar', nullable: false }, } const schemas = generator.generateSchemas([{ name: 'games', columns, primaryKeys: ['id'] }]) From 7da07a606f679ca091cf8e723828582d5b21db64 Mon Sep 17 00:00:00 2001 From: Oussama Benhamed Date: Thu, 23 Jul 2026 18:55:25 +0100 Subject: [PATCH 4/5] fix: store relation multiplicity on instances instead of registry lookup Relations now retain their isMany property on the relation instance itself rather than deriving it from mutable RelationRegistry state at assignment time. Previously, if a custom relation was unregistered after model boot, $setRelated would incorrectly reject arrays because it looked up isMany from the registry. Now each relation instance stores its multiplicity, making it independent of registry state changes. Changes: - Add readonly isMany property to BaseRelationContract - Add isMany = true/false to all built-in relation classes - Replace getManyRelations().includes() checks with relation.isMany - Remove isMany from RelationFactoryConfig (no longer needed) - Remove RelationRegistry.getManyRelationTypes() (no longer needed) - Add test verifying relations retain multiplicity after unregister This ensures RelationRegistry.unregister() cannot change the behavior of existing relation instances or cause valid arrays to be rejected. --- src/orm/base_model/index.ts | 14 +- src/orm/relations/belongs_to/index.ts | 1 + src/orm/relations/has_many/index.ts | 1 + src/orm/relations/has_many_through/index.ts | 1 + src/orm/relations/has_one/index.ts | 1 + src/orm/relations/many_to_many/index.ts | 1 + src/orm/relations/relation_registry.ts | 10 -- src/types/relations.ts | 6 +- test/orm/relation_registry.spec.ts | 169 +++++++++++++++----- 9 files changed, 136 insertions(+), 68 deletions(-) diff --git a/src/orm/base_model/index.ts b/src/orm/base_model/index.ts index 9799cd72c..3faf68786 100644 --- a/src/orm/base_model/index.ts +++ b/src/orm/base_model/index.ts @@ -73,16 +73,6 @@ import { compareValues, } from '../../utils/index.js' -/** - * Returns all relation types that handle multiple related records. - * Includes both built-in relations and custom relations registered via RelationRegistry. - * - * Note: This is called dynamically (not cached) to ensure newly registered - * custom relations are included (important for testing scenarios). - */ -function getManyRelations(): string[] { - return ['hasMany', 'manyToMany', 'hasManyThrough', ...RelationRegistry.getManyRelationTypes()] -} const DATE_TIME_TYPES = { date: 'date', datetime: 'datetime', @@ -1761,7 +1751,7 @@ class BaseModelImpl implements LucidRow { /** * Reset array before invoking $pushRelated */ - if (getManyRelations().includes(relation.type)) { + if (relation.isMany) { if (!Array.isArray(models)) { throw new Exception( `"${Model.name}.${key}" must be an array when setting "${relation.type}" relationship` @@ -1790,7 +1780,7 @@ class BaseModelImpl implements LucidRow { /** * Create multiple for `hasMany` `manyToMany` and `hasManyThrough` */ - if (getManyRelations().includes(relation.type)) { + if (relation.isMany) { this.$preloaded[key] = ((this.$preloaded[key] || []) as LucidRow[]).concat(models) return } diff --git a/src/orm/relations/belongs_to/index.ts b/src/orm/relations/belongs_to/index.ts index e83d989c7..117b7e6dd 100644 --- a/src/orm/relations/belongs_to/index.ts +++ b/src/orm/relations/belongs_to/index.ts @@ -29,6 +29,7 @@ export class BelongsTo implements BelongsToRelationContract * The relationship name */ readonly type = 'hasMany' + readonly isMany = true /** * Whether or not the relationship instance has been diff --git a/src/orm/relations/has_many_through/index.ts b/src/orm/relations/has_many_through/index.ts index a732b4088..ee28a0bb1 100644 --- a/src/orm/relations/has_many_through/index.ts +++ b/src/orm/relations/has_many_through/index.ts @@ -25,6 +25,7 @@ import { ensureRelationIsBooted } from '../../../utils/index.js' */ export class HasManyThrough implements HasManyThroughRelationContract { type = 'hasManyThrough' as const + readonly isMany = true booted: boolean = false serializeAs diff --git a/src/orm/relations/has_one/index.ts b/src/orm/relations/has_one/index.ts index 8b366a1c6..e75c933e2 100644 --- a/src/orm/relations/has_one/index.ts +++ b/src/orm/relations/has_one/index.ts @@ -25,6 +25,7 @@ import { ensureRelationIsBooted, getValue } from '../../../utils/index.js' */ export class HasOne implements HasOneRelationContract { readonly type = 'hasOne' + readonly isMany = false booted: boolean = false serializeAs diff --git a/src/orm/relations/many_to_many/index.ts b/src/orm/relations/many_to_many/index.ts index 414fa582d..f668d8a6b 100644 --- a/src/orm/relations/many_to_many/index.ts +++ b/src/orm/relations/many_to_many/index.ts @@ -24,6 +24,7 @@ import { ensureRelationIsBooted, getValue } from '../../../utils/index.js' */ export class ManyToMany implements ManyToManyRelationContract { type = 'manyToMany' as const + readonly isMany = true booted: boolean = false serializeAs diff --git a/src/orm/relations/relation_registry.ts b/src/orm/relations/relation_registry.ts index b2a372280..b7a4f8b24 100644 --- a/src/orm/relations/relation_registry.ts +++ b/src/orm/relations/relation_registry.ts @@ -22,7 +22,6 @@ export class RelationRegistry { * @example * ```ts * RelationRegistry.register('myRelation', { - * isMany: true, * create(relationName, relatedModel, options, model) { * return new MyRelation(relationName, relatedModel, options, model) * } @@ -57,15 +56,6 @@ export class RelationRegistry { return this.relations.has(type) } - /** - * Returns all registered "many" relation types - */ - static getManyRelationTypes(): string[] { - return Array.from(this.relations.entries()) - .filter(([_, factory]) => factory.isMany) - .map(([type]) => type) - } - /** * Unregisters a relation type (mainly for testing) */ diff --git a/src/types/relations.ts b/src/types/relations.ts index 2179eb824..24c820589 100644 --- a/src/types/relations.ts +++ b/src/types/relations.ts @@ -65,11 +65,6 @@ export interface RelationFactoryConfig< * Creates a new instance of the relation */ create(relationName: string, relatedModel: () => LucidModel, options: any, model: LucidModel): T - - /** - * Indicates if this is a "many" relation (for the preloader) - */ - isMany: boolean } /** @@ -363,6 +358,7 @@ export interface BaseRelationContract< RelatedModel extends LucidModel, > { readonly type: ModelRelationTypes['__opaque_type'] + readonly isMany: boolean readonly relationName: string readonly serializeAs: string | null readonly booted: boolean diff --git a/test/orm/relation_registry.spec.ts b/test/orm/relation_registry.spec.ts index 19bf3c529..557ec35bd 100644 --- a/test/orm/relation_registry.spec.ts +++ b/test/orm/relation_registry.spec.ts @@ -15,10 +15,13 @@ import type { QueryClientContract } from '../../src/types/database.js' import { RelationRegistry, createRelationDecorator, column } from '../../src/orm/main.js' import { getDb, setup, cleanup, getBaseModel, ormAdapter } from '../../test-helpers/index.js' -// Augment KnownCustomRelations for test relations +/** + * Augment KnownCustomRelations for test relations + */ declare module '../../src/types/relations.js' { interface KnownCustomRelations { testRelation: BaseRelationContract + testManyRelation: BaseRelationContract } } @@ -32,13 +35,14 @@ test.group('RelationRegistry', (group) => { }) group.each.teardown(() => { - // Clean up any test relations + /** + * Clean up any test relations + */ RelationRegistry.unregister('customRelation') }) test('register a custom relation type', ({ assert }) => { const factory = { - isMany: false, create() { return {} as any }, @@ -49,12 +53,11 @@ test.group('RelationRegistry', (group) => { assert.isTrue(RelationRegistry.has('customRelation')) const registeredFactory = RelationRegistry.get('customRelation') assert.equal(registeredFactory?.type, 'customRelation') - assert.equal(registeredFactory?.isMany, false) + }) }) test('throw error when registering duplicate relation type', ({ assert }) => { const factory = { - isMany: false, create() { return {} as any }, @@ -72,34 +75,8 @@ test.group('RelationRegistry', (group) => { assert.isUndefined(RelationRegistry.get('unknownRelation')) }) - test('getManyRelationTypes returns only many relations', ({ assert }) => { - RelationRegistry.register('customMany', { - isMany: true, - create() { - return {} as any - }, - }) - - RelationRegistry.register('customOne', { - isMany: false, - create() { - return {} as any - }, - }) - - const manyTypes = RelationRegistry.getManyRelationTypes() - - // Should include custom custom many relation and not include custom one relation - assert.include(manyTypes, 'customMany') - assert.notInclude(manyTypes, 'customOne') - - RelationRegistry.unregister('customMany') - RelationRegistry.unregister('customOne') - }) - test('unregister removes a relation type', ({ assert }) => { const factory = { - isMany: false, create() { return {} as any }, @@ -136,9 +113,9 @@ test.group('RelationRegistry | Integration with BaseModel', (group) => { const adapter = ormAdapter(db) const BaseModel = getBaseModel(adapter) - // Create a simple test relation class TestRelation implements BaseRelationContract { type = 'testRelation' as const + isMany = false booted = false relationName: string serializeAs: any @@ -179,18 +156,14 @@ test.group('RelationRegistry | Integration with BaseModel', (group) => { } } - // Register the relation RelationRegistry.register('testRelation', { - isMany: true, create(relationName, relatedModel, options, model) { return new TestRelation(relationName, relatedModel, options, model) }, }) - // Create decorator const testRelation = createRelationDecorator('testRelation') - // Use it on a model class User extends BaseModel { @column({ isPrimary: true }) declare id: number @@ -199,7 +172,9 @@ test.group('RelationRegistry | Integration with BaseModel', (group) => { declare related: any } - // Verify the relation was added + /** + * Verify the relation was added + */ assert.isTrue(User.$hasRelation('related')) assert.equal(User.$getRelation('related')!.type, 'testRelation') assert.instanceOf(User.$getRelation('related'), TestRelation) @@ -223,20 +198,23 @@ test.group('RelationRegistry | Integration with BaseModel', (group) => { declare related: any } - // Trigger boot User.boot() }, '"unknownRelationType" is not a supported relation type. Did you forget to register it with RelationRegistry.register()?') }) - test('custom relation included in MANY_RELATIONS when isMany is true', async ({ fs, assert }) => { + test('custom many relation accepts arrays in $setRelated', async ({ fs, assert }) => { const app = new AppFactory().create(fs.baseUrl, () => {}) await app.init() const db = getDb() const adapter = ormAdapter(db) const BaseModel = getBaseModel(adapter) + /** + * A custom "many" relation implementation + */ class TestManyRelation implements BaseRelationContract { type = 'testRelation' as const + isMany = true booted = false relationName: string serializeAs: any @@ -273,7 +251,6 @@ test.group('RelationRegistry | Integration with BaseModel', (group) => { } RelationRegistry.register('testRelation', { - isMany: true, create(relationName, relatedModel, options, model) { return new TestManyRelation(relationName, relatedModel, options, model) }, @@ -291,7 +268,9 @@ test.group('RelationRegistry | Integration with BaseModel', (group) => { User.boot() - // Test that $setRelated accepts arrays for many relations + /** + * Verify that $setRelated accepts arrays for relations with isMany = true + */ const user = new User() const related1 = new User() const related2 = new User() @@ -299,4 +278,112 @@ test.group('RelationRegistry | Integration with BaseModel', (group) => { user.$setRelated('related', [related1, related2]) assert.lengthOf(user.related, 2) }) + + test('relation retains isMany multiplicity after unregister', async ({ fs, assert }) => { + const app = new AppFactory().create(fs.baseUrl, () => {}) + await app.init() + const db = getDb() + const adapter = ormAdapter(db) + const BaseModel = getBaseModel(adapter) + + /** + * Define a custom "many" relation + */ + class TestManyRelation implements BaseRelationContract { + type = 'testManyRelation' as any + isMany = true + booted = false + relationName: string + serializeAs: any + model: LucidModel + + constructor( + relationName: string, + public relatedModel: () => LucidModel, + private options: any, + model: LucidModel + ) { + this.relationName = relationName + this.model = model + } + + boot() { + this.booted = true + } + + clone(): any { + return new TestManyRelation(this.relationName, this.relatedModel, this.options, this.model) + } + + setRelated() {} + pushRelated() {} + setRelatedForMany() {} + + client(_parent: LucidRow, _client: QueryClientContract): any { + return null + } + + eagerQuery(): any { + return null + } + + subQuery(): any { + return null + } + } + + /** + * Register the relation + */ + RelationRegistry.register('testManyRelation', { + create(relationName, relatedModel, options, model) { + return new TestManyRelation(relationName, relatedModel, options, model) + }, + }) + + const testManyRelation = createRelationDecorator('testManyRelation') + + /** + * Create a model with the relation + */ + class User extends BaseModel { + @column({ isPrimary: true }) + declare id: number + + @testManyRelation(() => User) + declare friends: any + } + + /** + * Boot the model (this creates and stores the relation instance) + */ + User.boot() + + /** + * Verify the relation works with arrays before unregistering + */ + const user1 = new User() + const friend1 = new User() + const friend2 = new User() + + user1.$setRelated('friends', [friend1, friend2]) + assert.lengthOf(user1.friends, 2) + + /** + * Unregister the relation from the registry + * This simulates a package being unloaded or registry state changing + */ + RelationRegistry.unregister('testManyRelation') + + const user2 = new User() + const friend3 = new User() + const friend4 = new User() + + /*** + * This works because the relation class has isMany = true as a property. + * The relation instance retains its multiplicity independent of the registry + */ + user2.$setRelated('friends', [friend3, friend4]) + assert.lengthOf(user2.friends, 2) + }) }) From 4edf3cf7ce066b4cf7d7ee15a53646d1eaec6c6c Mon Sep 17 00:00:00 2001 From: Oussama Benhamed Date: Thu, 23 Jul 2026 18:56:33 +0100 Subject: [PATCH 5/5] fix: reject registration of built-in relation type names RelationRegistry.register() now rejects reserved built-in relation type names (hasOne, hasMany, belongsTo, manyToMany, hasManyThrough). Attempting to register these names is ineffective because BaseModel.$addRelation() resolves built-ins first, so the registry entry would never be used. This prevents confusion and registry pollution. Changes: - Add BUILT_IN_TYPES constant in RelationRegistry.register() - Throw clear error when attempting to register reserved names - Add test verifying all built-in types are rejected --- src/orm/relations/relation_registry.ts | 10 ++++++++++ test/orm/relation_registry.spec.ts | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/orm/relations/relation_registry.ts b/src/orm/relations/relation_registry.ts index b7a4f8b24..ba7b98a60 100644 --- a/src/orm/relations/relation_registry.ts +++ b/src/orm/relations/relation_registry.ts @@ -29,6 +29,16 @@ export class RelationRegistry { * ``` */ static register(type: string, factoryConfig: RelationFactoryConfig): void { + const BUILT_IN_TYPES = ['hasOne', 'hasMany', 'belongsTo', 'manyToMany', 'hasManyThrough'] + + if (BUILT_IN_TYPES.includes(type)) { + throw new Error( + `Cannot register "${type}": it is a built-in relation type. ` + + `Built-in types (${BUILT_IN_TYPES.join(', ')}) cannot be overridden. ` + + `Please use a different name for your custom relation.` + ) + } + if (this.relations.has(type)) { throw new Error(`Relation type "${type}" is already registered`) } diff --git a/test/orm/relation_registry.spec.ts b/test/orm/relation_registry.spec.ts index 557ec35bd..a12b4a323 100644 --- a/test/orm/relation_registry.spec.ts +++ b/test/orm/relation_registry.spec.ts @@ -54,6 +54,21 @@ test.group('RelationRegistry', (group) => { const registeredFactory = RelationRegistry.get('customRelation') assert.equal(registeredFactory?.type, 'customRelation') }) + + test('throw error when registering built-in relation types', ({ assert }) => { + const builtInTypes = ['hasOne', 'hasMany', 'belongsTo', 'manyToMany', 'hasManyThrough'] + + builtInTypes.forEach((type) => { + assert.throws( + () => + RelationRegistry.register(type, { + create() { + return {} as any + }, + }), + `Cannot register "${type}": it is a built-in relation type` + ) + }) }) test('throw error when registering duplicate relation type', ({ assert }) => {