From 1ea384cd6a706a6a4a5a590f4c00fa7fb07ffdef Mon Sep 17 00:00:00 2001 From: Harminder virk Date: Thu, 26 Sep 2019 16:38:23 +0530 Subject: [PATCH] feat: add support for nested preloads --- adonis-typings/model.ts | 7 +- src/Orm/QueryBuilder/index.ts | 28 +++-- src/Orm/Relations/HasOne.ts | 19 +++ test-helpers/index.ts | 12 ++ test/model-has-one.spec.ts | 223 ++++++++++++++++++++++++++++++++++ 5 files changed, 278 insertions(+), 11 deletions(-) diff --git a/adonis-typings/model.ts b/adonis-typings/model.ts index a58e025c..486b2bff 100644 --- a/adonis-typings/model.ts +++ b/adonis-typings/model.ts @@ -92,9 +92,10 @@ declare module '@ioc:Adonis/Lucid/Model' { * Interface to be implemented by all relationship types */ export interface RelationContract { - type: AvailableRelations, - serializeAs: string, - relatedModel (): ModelConstructorContract, + type: AvailableRelations + serializeAs: string + relatedModel (): ModelConstructorContract + preload (relation: string, callback?: (builder: ModelQueryBuilderContract) => void): this exec ( model: ModelContract | ModelContract[], options?: ModelOptions, diff --git a/src/Orm/QueryBuilder/index.ts b/src/Orm/QueryBuilder/index.ts index 8cf3d2d4..68882c21 100644 --- a/src/Orm/QueryBuilder/index.ts +++ b/src/Orm/QueryBuilder/index.ts @@ -41,9 +41,11 @@ export class ModelQueryBuilder extends Chainable implements ModelQueryBuilderCon * A copy of defined preloads on the model instance */ private _preloads: { - relation: RelationContract, - callback?: (builder: ModelQueryBuilderContract) => void, - }[] = [] + [name: string]: { + relation: RelationContract, + callback?: (builder: ModelQueryBuilderContract) => void, + }, + } = {} constructor ( builder: knex.QueryBuilder, @@ -70,8 +72,9 @@ export class ModelQueryBuilder extends Chainable implements ModelQueryBuilderCon this.options, ) - await Promise.all(this._preloads.map((one) => { - return one.relation.exec(modelInstances, this.options, one.callback) + await Promise.all(Object.keys(this._preloads).map((name) => { + const relation = this._preloads[name] + return relation.relation.exec(modelInstances, this.options, relation.callback) })) return modelInstances } @@ -107,16 +110,25 @@ export class ModelQueryBuilder extends Chainable implements ModelQueryBuilderCon relationName: string, callback?: (builder: ModelQueryBuilderContract) => void, ): this { - const relation = this.model.$getRelation(relationName) + const relations = relationName.split('.') + const primary = relations.shift()! + const relation = this.model.$getRelation(primary) /** * Undefined relationship */ if (!relation) { - throw new Exception(`${relationName} is not defined as a relationship on ${this.model.name} model`) + throw new Exception(`${primary} is not defined as a relationship on ${this.model.name} model`) + } + + const payload = this._preloads[primary] || { relation } + if (!relations.length) { + payload.callback = callback + } else { + payload.relation.preload(relations.join('.'), callback) } - this._preloads.push({ relation, callback }) + this._preloads[primary] = payload return this } diff --git a/src/Orm/Relations/HasOne.ts b/src/Orm/Relations/HasOne.ts index a6166bf0..7c2db883 100644 --- a/src/Orm/Relations/HasOne.ts +++ b/src/Orm/Relations/HasOne.ts @@ -62,6 +62,11 @@ export class HasOne implements RelationContract { */ private _isValid: boolean = false + /** + * Preloads to pass to the query builder + */ + private _preloads: { relationName: string, callback?: any }[] = [] + constructor ( private _relationName: string, private _options: Partial, @@ -162,6 +167,14 @@ export class HasOne implements RelationContract { return value } + /** + * Takes preloads that we want to pass to the related query builder + */ + public preload (relationName: string, callback?: (builder: ModelQueryBuilderContract) => void) { + this._preloads.push({ relationName, callback }) + return this + } + /** * Execute hasOne and set the relationship on model(s) */ @@ -171,6 +184,12 @@ export class HasOne implements RelationContract { userCallback?: (builder: ModelQueryBuilderContract) => void, ) { const query = this.relatedModel().query(options) + + /** + * Pass preloads to the query builder + */ + this._preloads.forEach(({ relationName, callback }) => query.preload(relationName, callback)) + if (typeof (userCallback) === 'function') { userCallback(query) } diff --git a/test-helpers/index.ts b/test-helpers/index.ts index f3a3de66..b3a25ca1 100644 --- a/test-helpers/index.ts +++ b/test-helpers/index.ts @@ -113,6 +113,16 @@ export async function setup () { }) } + const hasIdentitiesTable = await db.schema.hasTable('identities') + if (!hasIdentitiesTable) { + await db.schema.createTable('identities', (table) => { + table.increments() + table.integer('profile_id') + table.string('identity_name') + table.timestamps() + }) + } + await db.destroy() } @@ -128,6 +138,7 @@ export async function cleanup () { const db = knex(getConfig()) await db.schema.dropTableIfExists('users') await db.schema.dropTableIfExists('profiles') + await db.schema.dropTableIfExists('identities') await db.destroy() } @@ -138,6 +149,7 @@ export async function resetTables () { const db = knex(getConfig()) await db.table('users').truncate() await db.table('profiles').truncate() + await db.table('identities').truncate() } /** diff --git a/test/model-has-one.spec.ts b/test/model-has-one.spec.ts index fee66988..8f16728b 100644 --- a/test/model-has-one.spec.ts +++ b/test/model-has-one.spec.ts @@ -291,4 +291,227 @@ test.group('Model | Has one', (group) => { assert.isNull(user!.profile) }) + + test('preload nested relations', async (assert) => { + const BaseModel = getBaseModel(ormAdapter()) + + class Identity extends BaseModel { + @column({ primary: true }) + public id: number + + @column() + public profileId: number + + @column() + public identityName: string + } + + class Profile extends BaseModel { + @column({ primary: true }) + public id: number + + @column() + public userId: number + + @column() + public displayName: string + + @hasOne({ relatedModel: () => Identity }) + public identity: Identity + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @hasOne({ relatedModel: () => Profile }) + public profile: Profile + } + + const db = getDb() + await db.insertQuery().table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await db.insertQuery().table('profiles').insert([ + { + user_id: 1, + display_name: 'virk', + }, + { + user_id: 2, + display_name: 'nikk', + }, + ]) + + await db.insertQuery().table('identities').insert([ + { + profile_id: 1, + identity_name: 'virk', + }, + { + profile_id: 2, + identity_name: 'nikk', + }, + ]) + + User.$boot() + + const user = await User.query() + .preload('profile.identity') + .where('username', 'virk') + .first() + + assert.instanceOf(user!.profile, Profile) + assert.instanceOf(user!.profile!.identity, Identity) + }) + + test('preload nested relations with primary relation repeating twice', async (assert) => { + const BaseModel = getBaseModel(ormAdapter()) + + class Identity extends BaseModel { + @column({ primary: true }) + public id: number + + @column() + public profileId: number + + @column() + public identityName: string + } + + class Profile extends BaseModel { + @column({ primary: true }) + public id: number + + @column() + public userId: number + + @column() + public displayName: string + + @hasOne({ relatedModel: () => Identity }) + public identity: Identity + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @hasOne({ relatedModel: () => Profile }) + public profile: Profile + } + + const db = getDb() + await db.insertQuery().table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await db.insertQuery().table('profiles').insert([ + { + user_id: 1, + display_name: 'virk', + }, + { + user_id: 2, + display_name: 'nikk', + }, + ]) + + await db.insertQuery().table('identities').insert([ + { + profile_id: 1, + identity_name: 'virk', + }, + { + profile_id: 2, + identity_name: 'nikk', + }, + ]) + + User.$boot() + + const query = User.query() + .preload('profile') + .preload('profile.identity') + .where('username', 'virk') + + const user = await query.first() + assert.instanceOf(user!.profile, Profile) + assert.instanceOf(user!.profile!.identity, Identity) + assert.lengthOf(Object.keys(query['_preloads']), 1) + assert.property(query['_preloads'], 'profile') + assert.lengthOf(query['_preloads'].profile.relation._preloads, 1) + assert.equal(query['_preloads'].profile.relation._preloads[0].relationName, 'identity') + }) + + test('pass main query options down the chain', async (assert) => { + const BaseModel = getBaseModel(ormAdapter()) + + class Identity extends BaseModel { + @column({ primary: true }) + public id: number + + @column() + public profileId: number + + @column() + public identityName: string + } + + class Profile extends BaseModel { + @column({ primary: true }) + public id: number + + @column() + public userId: number + + @column() + public displayName: string + + @hasOne({ relatedModel: () => Identity }) + public identity: Identity + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @hasOne({ relatedModel: () => Profile }) + public profile: Profile + } + + const db = getDb() + await db.insertQuery().table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await db.insertQuery().table('profiles').insert([ + { + user_id: 1, + display_name: 'virk', + }, + { + user_id: 2, + display_name: 'nikk', + }, + ]) + + await db.insertQuery().table('identities').insert([ + { + profile_id: 1, + identity_name: 'virk', + }, + { + profile_id: 2, + identity_name: 'nikk', + }, + ]) + + User.$boot() + + const query = User.query({ connection: 'secondary' }) + .preload('profile') + .preload('profile.identity') + .where('username', 'virk') + + const user = await query.first() + assert.instanceOf(user!.profile, Profile) + assert.instanceOf(user!.profile!.identity, Identity) + + assert.equal(user!.$options!.connection, 'secondary') + assert.equal(user!.profile.$options!.connection, 'secondary') + assert.equal(user!.profile.identity.$options!.connection, 'secondary') + }) })