diff --git a/src/factories/factory_model.ts b/src/factories/factory_model.ts index d9cabfa1..f2a11a53 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 7b602b60..3faf6878 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,6 @@ import { compareValues, } from '../../utils/index.js' -const MANY_RELATIONS = ['hasMany', 'manyToMany', 'hasManyThrough'] const DATE_TIME_TYPES = { date: 'date', datetime: 'datetime', @@ -158,7 +159,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 +544,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 +601,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 +725,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 +1751,7 @@ class BaseModelImpl implements LucidRow { /** * Reset array before invoking $pushRelated */ - if (MANY_RELATIONS.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` @@ -1750,7 +1780,7 @@ class BaseModelImpl implements LucidRow { /** * Create multiple for `hasMany` `manyToMany` and `hasManyThrough` */ - if (MANY_RELATIONS.includes(relation.type)) { + if (relation.isMany) { 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 00000000..46c558de --- /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 f740ff86..8d550577 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/belongs_to/index.ts b/src/orm/relations/belongs_to/index.ts index e83d989c..117b7e6d 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 a732b408..ee28a0bb 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 8b366a1c..e75c933e 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/main.ts b/src/orm/relations/main.ts index c44ddda3..8faab80e 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.js' +export { KeysExtractor } from './keys_extractor.js' diff --git a/src/orm/relations/many_to_many/index.ts b/src/orm/relations/many_to_many/index.ts index 414fa582..f668d8a6 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 new file mode 100644 index 00000000..ba7b98a6 --- /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', { + * create(relationName, relatedModel, options, model) { + * return new MyRelation(relationName, relatedModel, options, model) + * } + * }) + * ``` + */ + 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`) + } + + // 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) + } + + /** + * 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 f4b17ff7..0e16481f 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 fc67cb1d..24c82058 100644 --- a/src/types/relations.ts +++ b/src/types/relations.ts @@ -52,6 +52,36 @@ export type GetRelationModelInstance = BaseRelationContract< + LucidModel, + LucidModel + >, +> { + /** + * Creates a new instance of the relation + */ + create(relationName: string, relatedModel: () => LucidModel, options: any, model: LucidModel): T +} + +/** + * 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 +139,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 +209,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 +342,7 @@ export type ModelRelations< | BelongsTo | ManyToMany | HasManyThrough + | KnownCustomOpaqueRelations[keyof KnownCustomOpaqueRelations] /** * ------------------------------------------------------ @@ -284,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 @@ -307,6 +382,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 +690,7 @@ export type RelationshipsContract = | BelongsToRelationContract | ManyToManyRelationContract | HasManyThroughRelationContract + | KnownCustomRelations[keyof KnownCustomRelations] /** * ------------------------------------------------------ diff --git a/test/orm/orm_schema_builder.spec.ts b/test/orm/orm_schema_builder.spec.ts index 6acfc913..97e88863 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'] }]) diff --git a/test/orm/relation_registry.spec.ts b/test/orm/relation_registry.spec.ts new file mode 100644 index 00000000..a12b4a32 --- /dev/null +++ b/test/orm/relation_registry.spec.ts @@ -0,0 +1,404 @@ +/* + * @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 + testManyRelation: 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 = { + create() { + return {} as any + }, + } + + RelationRegistry.register('customRelation', factory) + + assert.isTrue(RelationRegistry.has('customRelation')) + 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 }) => { + const factory = { + 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('unregister removes a relation type', ({ assert }) => { + const factory = { + 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) + + class TestRelation implements BaseRelationContract { + type = 'testRelation' as const + isMany = false + 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 + } + } + + RelationRegistry.register('testRelation', { + create(relationName, relatedModel, options, model) { + return new TestRelation(relationName, relatedModel, options, model) + }, + }) + + const testRelation = createRelationDecorator('testRelation') + + 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 + } + + User.boot() + }, '"unknownRelationType" is not a supported relation type. Did you forget to register it with RelationRegistry.register()?') + }) + + 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 + 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', { + 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() + + /** + * Verify that $setRelated accepts arrays for relations with isMany = true + */ + const user = new User() + const related1 = new User() + const related2 = new User() + + 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) + }) +})