diff --git a/adonis-typings/model.ts b/adonis-typings/model.ts index e35ac61d..c6a5b3bd 100644 --- a/adonis-typings/model.ts +++ b/adonis-typings/model.ts @@ -54,6 +54,20 @@ declare module '@ioc:Adonis/Lucid/Model' { serializeAs?: string, } + /** + * Shape of many to many relationship + */ + export interface ManyToManyRelationNode { + relatedModel: (() => ModelConstructorContract), + pivotTable?: string, + localKey?: string, + pivotForeignKey?: string, + relatedKey?: string, + pivotRelatedForeignKey?: string, + pivotColumns?: string[], + serializeAs?: string, + } + /** * Shape of hasOneThrough relationship */ @@ -86,7 +100,9 @@ declare module '@ioc:Adonis/Lucid/Model' { type DecoratorFn = (target, property) => void type BaseRelationDecoratorNode = Omit type ThroughRelationDecoratorNode = Omit + type ManyToManyRelationDecoratorNode = Omit type ModelExecuteableQueryBuilder = ModelQueryBuilderContract & ExcutableQueryBuilderContract + type ManyToManyExecutableQueryBuilder = ManyToManyQueryBuilderContract & ExcutableQueryBuilderContract /** * Types for decorators @@ -110,31 +126,33 @@ declare module '@ioc:Adonis/Lucid/Model' { ) => DecoratorFn export type ManyToManyFn = ( - model: BaseRelationNode['relatedModel'], - column?: BaseRelationDecoratorNode, + model: ManyToManyRelationNode['relatedModel'], + column?: ManyToManyRelationDecoratorNode, ) => DecoratorFn export type HasOneThroughFn = ( - model: BaseRelationNode['relatedModel'], + model: ThroughRelationNode['relatedModel'], column?: ThroughRelationDecoratorNode, ) => DecoratorFn export type HasManyThroughFn = ( - model: BaseRelationNode['relatedModel'], + model: ThroughRelationNode['relatedModel'], column?: ThroughRelationDecoratorNode, ) => DecoratorFn - export type AvailableRelations = 'hasOne' | 'hasMany' | 'belongsTo' | 'manyToMany' - /** - * Callback accepted by the preload method + * List of available relations */ - export type PreloadCallback = (builder: ModelExecuteableQueryBuilder) => void + export type AvailableRelations = 'hasOne' | 'hasMany' | 'belongsTo' | 'manyToMany' + + type ManyToManyPreloadCallback = (builder: ManyToManyExecutableQueryBuilder) => void + type BasePreloadCallback = (builder: ModelExecuteableQueryBuilder) => void + type PreloadCallback = ManyToManyPreloadCallback | BasePreloadCallback /** * Interface to be implemented by all relationship types */ - export interface RelationContract { + export interface BaseRelationContract { type: AvailableRelations serializeAs: string booted: boolean @@ -146,6 +164,28 @@ declare module '@ioc:Adonis/Lucid/Model' { setRelatedMany (models: ModelContract[], related: ModelContract[]): void } + /** + * Shape of many to many relationship contract + */ + export interface ManyToManyRelationContract extends BaseRelationContract { + pivotTable: string + getQuery (model: ModelContract, client: QueryClientContract): ManyToManyExecutableQueryBuilder + getEagerQuery (models: ModelContract[], client: QueryClientContract): ManyToManyExecutableQueryBuilder + } + + /** + * Relationships type + */ + export type RelationContract = BaseRelationContract | ManyToManyRelationContract + + /** + * Shape of many to many query builder. It has few methods over the standard + * model query builder + */ + export interface ManyToManyQueryBuilderContract extends ModelQueryBuilderContract { + pivotColumns (columns: string[]): this + } + /** * Model query builder will have extras methods on top of Database query builder */ @@ -181,7 +221,14 @@ declare module '@ioc:Adonis/Lucid/Model' { /** * Define relationships to be preloaded */ - preload (relation: string, callback?: PreloadCallback): this + preload ( + relation: string, + callback?: ManyToManyPreloadCallback, + ): this + preload ( + relation: string, + callback?: BasePreloadCallback, + ): this } /** diff --git a/src/Orm/BaseModel/index.ts b/src/Orm/BaseModel/index.ts index 47491bf4..e67acb55 100644 --- a/src/Orm/BaseModel/index.ts +++ b/src/Orm/BaseModel/index.ts @@ -34,6 +34,7 @@ import { HasOne } from '../Relations/HasOne' import { proxyHandler } from './proxyHandler' import { HasMany } from '../Relations/HasMany' import { BelongsTo } from '../Relations/BelongsTo' +import { ManyToMany } from '../Relations/ManyToMany' function StaticImplements () { return (_t: T) => {} @@ -273,6 +274,9 @@ export class BaseModel implements ModelContract { case 'belongsTo': this.$relations.set(name, new BelongsTo(name, options, this)) break + case 'manyToMany': + this.$relations.set(name, new ManyToMany(name, options, this)) + break default: throw new Error(`${type} relationship has not been implemented yet`) } diff --git a/src/Orm/QueryBuilder/index.ts b/src/Orm/QueryBuilder/index.ts index eac652c4..e854c405 100644 --- a/src/Orm/QueryBuilder/index.ts +++ b/src/Orm/QueryBuilder/index.ts @@ -20,6 +20,7 @@ import { RelationContract, ModelConstructorContract, ModelQueryBuilderContract, + ManyToManyExecutableQueryBuilder, } from '@ioc:Adonis/Lucid/Model' import { QueryClientContract } from '@ioc:Adonis/Lucid/Database' @@ -100,6 +101,7 @@ export class ModelQueryBuilder extends Chainable implements ModelQueryBuilderCon */ private async _processRelation (models: ModelContract[], name: string) { const relation = this._preloads[name] + relation.relation.boot() const query = relation.relation.getEagerQuery(models, this.client) @@ -112,7 +114,10 @@ export class ModelQueryBuilder extends Chainable implements ModelQueryBuilderCon * Invoke callback when defined */ if (typeof (relation.callback) === 'function') { - relation.callback(query) + /** + * Type casting to superior type. + */ + relation.callback(query as ManyToManyExecutableQueryBuilder) } /** diff --git a/src/Orm/Relations/BelongsTo.ts b/src/Orm/Relations/BelongsTo.ts index 50cc9b7d..d0032e60 100644 --- a/src/Orm/Relations/BelongsTo.ts +++ b/src/Orm/Relations/BelongsTo.ts @@ -15,13 +15,16 @@ import { camelCase, snakeCase, uniq } from 'lodash' import { ModelContract, BaseRelationNode, - RelationContract, + BaseRelationContract, ModelConstructorContract, } from '@ioc:Adonis/Lucid/Model' import { QueryClientContract } from '@ioc:Adonis/Lucid/Database' -export class BelongsTo implements RelationContract { +/** + * Exposes the API to construct belongs to relationship. + */ +export class BelongsTo implements BaseRelationContract { /** * Relationship type */ diff --git a/src/Orm/Relations/HasOneOrMany.ts b/src/Orm/Relations/HasOneOrMany.ts index 8215a0ea..55a5aed2 100644 --- a/src/Orm/Relations/HasOneOrMany.ts +++ b/src/Orm/Relations/HasOneOrMany.ts @@ -15,13 +15,13 @@ import { camelCase, snakeCase, uniq } from 'lodash' import { ModelContract, BaseRelationNode, - RelationContract, + BaseRelationContract, ModelConstructorContract, } from '@ioc:Adonis/Lucid/Model' import { QueryClientContract } from '@ioc:Adonis/Lucid/Database' -export abstract class HasOneOrMany implements RelationContract { +export abstract class HasOneOrMany implements BaseRelationContract { /** * Relationship type */ diff --git a/src/Orm/Relations/ManyToMany/QueryBuilder.ts b/src/Orm/Relations/ManyToMany/QueryBuilder.ts new file mode 100644 index 00000000..2a39a83a --- /dev/null +++ b/src/Orm/Relations/ManyToMany/QueryBuilder.ts @@ -0,0 +1,33 @@ +/* + * @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 knex from 'knex' +import { QueryClientContract } from '@ioc:Adonis/Lucid/Database' +import { ManyToManyRelationContract, ManyToManyQueryBuilderContract } from '@ioc:Adonis/Lucid/Model' + +import { ModelQueryBuilder } from '../../QueryBuilder' + +export class ManyToManyQueryBuilder extends ModelQueryBuilder implements ManyToManyQueryBuilderContract { + constructor ( + builder: knex.QueryBuilder, + private _relation: ManyToManyRelationContract, + client: QueryClientContract, + ) { + super(builder, _relation.relatedModel(), client) + } + + public pivotColumns (columns: string[]): this { + this.$knexBuilder.select(columns.map((column) => { + return `${this._relation.pivotTable}.${column} as pivot_${column}` + })) + return this + } +} diff --git a/src/Orm/Relations/ManyToMany/index.ts b/src/Orm/Relations/ManyToMany/index.ts new file mode 100644 index 00000000..f38dd298 --- /dev/null +++ b/src/Orm/Relations/ManyToMany/index.ts @@ -0,0 +1,279 @@ +/* + * @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 { Exception } from '@poppinss/utils' +import { snakeCase, uniq, sortBy } from 'lodash' + +import { + ModelContract, + ManyToManyRelationNode, + ModelConstructorContract, + ManyToManyRelationContract, + ManyToManyQueryBuilderContract, +} from '@ioc:Adonis/Lucid/Model' + +import { QueryClientContract } from '@ioc:Adonis/Lucid/Database' +import { ManyToManyQueryBuilder } from './QueryBuilder' + +/** + * Exposes the API to construct many to many relationship. This also comes + * with it's own query builder + */ +export class ManyToMany implements ManyToManyRelationContract { + /** + * Relationship type + */ + public type = 'manyToMany' as const + + /** + * The related model from which, we want to construct the relationship + */ + public relatedModel = this._options.relatedModel! + + /** + * Local key to use for constructing the relationship + */ + public localKey: string + + /** + * Adapter local key + */ + public localAdapterKey: string + + /** + * Primary key on the related model + */ + public relatedKey: string + + /** + * Primary adapter key on the related model + */ + public relatedAdapterKey: string + + /** + * Foreign key referenced on the pivot table by the current model. + * It is the adapter key, since there is no model in play + */ + public pivotForeignKey: string + + /** + * Alias for the select column for `pivotForeignKey` + */ + public pivotForeignKeyAlias: string + + /** + * Foreign key referenced on the pivot table by the related model. + * It is the adapter key, since there is no model in play + */ + public pivotRelatedForeignKey: string + + /** + * Alias for the select column for `pivotRelatedForeignKey` + */ + public pivotRelatedForeignKeyAlias: string + + /** + * Pivot table for joining relationships + */ + public pivotTable: string + + /** + * Key to be used for serializing the relationship + */ + public serializeAs = this._options.serializeAs || snakeCase(this._relationName) + + /** + * A flag to know if model keys are valid for executing database queries or not + */ + public booted: boolean = false + + constructor ( + private _relationName: string, + private _options: ManyToManyRelationNode, + private _model: ModelConstructorContract, + ) { + this._ensureRelatedModel() + } + + /** + * Ensure that related model is defined, otherwise raise an exception, since + * a relationship cannot work with a single model. + */ + private _ensureRelatedModel () { + if (!this._options.relatedModel) { + throw new Exception( + 'Related model reference is required to construct the relationship', + 500, + 'E_MISSING_RELATED_MODEL', + ) + } + } + + /** + * Validating the keys to ensure we are avoiding runtime `undefined` errors. We defer + * the keys validation, since they may be added after defining the relationship. + */ + private _validateKeys () { + const relationRef = `${this._model.name}.${this._relationName}` + + if (!this._model.$hasColumn(this.localKey)) { + const ref = `${this._model.name}.${this.localKey}` + throw new Exception( + `${ref} required by ${relationRef} relation is missing`, + 500, + 'E_MISSING_RELATED_LOCAL_KEY', + ) + } + + if (!this.relatedModel().$hasColumn(this.relatedKey)) { + const ref = `${this.relatedModel().name}.${this.relatedKey}` + throw new Exception( + `${ref} required by ${relationRef} relation is missing`, + 500, + 'E_MISSING_RELATED_FOREIGN_KEY', + ) + } + } + + /** + * Raises exception when value for the foreign key is missing on the model instance. This will + * make the query fail + */ + private _ensureValue (value: any) { + if (value === undefined) { + throw new Exception( + `Cannot preload ${this._relationName}, value of ${this._model.name}.${this.localKey} is undefined`, + 500, + ) + } + + return value + } + + /** + * Adds necessary select columns for the select query + */ + private _addSelect (query: ManyToManyQueryBuilderContract) { + query.select(`${this.relatedModel().$table}.*`) + query.pivotColumns( + [this.pivotForeignKey, this.pivotRelatedForeignKey].concat(this._options.pivotColumns || []), + ) + } + + /** + * Adds neccessary joins for the select query + */ + private _addJoin (query: ManyToManyQueryBuilderContract) { + query.innerJoin( + this.pivotTable, + `${this.relatedModel().$table}.${this.relatedAdapterKey}`, + `${this.pivotTable}.${this.pivotRelatedForeignKey}`, + ) + } + + /** + * Compute keys + */ + public boot () { + if (this.booted) { + return + } + + this.pivotTable = this._options.pivotTable || snakeCase( + sortBy([this.relatedModel().name, this._model.name]).join('_'), + ) + + /** + * Parent model and it's foreign key in pivot table + */ + this.localKey = this._options.localKey || this._model.$primaryKey + this.pivotForeignKey = this._options.pivotForeignKey || snakeCase( + `${this._model.name}_${this._model.$primaryKey}`, + ) + this.pivotForeignKeyAlias = `pivot_${this.pivotForeignKey}` + + /** + * Related model and it's foreign key in pivot table + */ + this.relatedKey = this._options.relatedKey || this.relatedModel().$primaryKey + this.pivotRelatedForeignKey = this._options.pivotRelatedForeignKey || snakeCase( + `${this.relatedModel().name}_${this.relatedModel().$primaryKey}`, + ) + this.pivotRelatedForeignKeyAlias = `pivot_${this.pivotRelatedForeignKey}` + + /** + * Validate computed keys to ensure they are valid + */ + this._validateKeys() + + /** + * Keys for the adapter + */ + this.localAdapterKey = this._model.$getColumn(this.localKey)!.castAs + this.relatedAdapterKey = this.relatedModel().$getColumn(this.relatedKey)!.castAs + this.booted = true + } + + /** + * Must be implemented by main class + */ + public getQuery (parent: ModelContract, client: QueryClientContract): any { + const value = parent[this.localKey] + + const query = new ManyToManyQueryBuilder(client.knexQuery(), this, client) + this._addSelect(query) + this._addJoin(query) + + return query.where(`${this.pivotTable}.${this.pivotForeignKey}`, value) + } + + /** + * Returns query for the relationship with applied constraints for + * eagerloading + */ + public getEagerQuery (parents: ModelContract[], client: QueryClientContract): any { + const values = uniq(parents.map((parentInstance) => { + return this._ensureValue(parentInstance[this.localKey]) + })) + + const query = new ManyToManyQueryBuilder(client.knexQuery(), this, client) + this._addSelect(query) + this._addJoin(query) + + return query.whereIn(`${this.pivotTable}.${this.pivotForeignKey}`, values) + } + + /** + * Sets the related model instance + */ + public setRelated (model: ModelContract, related?: ModelContract | ModelContract[] | null) { + if (!related) { + return + } + + model.$setRelated(this._relationName as keyof typeof model, related) + } + + /** + * Must be implemented by parent class + */ + public setRelatedMany (parents: ModelContract[], related: ModelContract[]) { + parents.forEach((parent) => { + const relation = related.filter((model) => { + return parent[this.localKey] === model.$extras[this.pivotForeignKeyAlias] + }) + + if (relation) { + this.setRelated(parent, relation) + } + }) + } +} diff --git a/test-helpers/index.ts b/test-helpers/index.ts index c262f7a1..b9b9a160 100644 --- a/test-helpers/index.ts +++ b/test-helpers/index.ts @@ -105,6 +105,26 @@ export async function setup () { }) } + const hasSkillsTable = await db.schema.hasTable('skills') + if (!hasSkillsTable) { + await db.schema.createTable('skills', (table) => { + table.increments() + table.string('name') + table.timestamps() + }) + } + + const hasUserSkillsTable = await db.schema.hasTable('skill_user') + if (!hasUserSkillsTable) { + await db.schema.createTable('skill_user', (table) => { + table.increments() + table.integer('user_id') + table.integer('skill_id') + table.string('proficiency') + table.timestamps() + }) + } + const hasPostsTable = await db.schema.hasTable('posts') if (!hasPostsTable) { await db.schema.createTable('posts', (table) => { @@ -154,6 +174,8 @@ export async function setup () { export async function cleanup () { const db = knex(getConfig()) await db.schema.dropTableIfExists('users') + await db.schema.dropTableIfExists('skills') + await db.schema.dropTableIfExists('skill_user') await db.schema.dropTableIfExists('profiles') await db.schema.dropTableIfExists('posts') await db.schema.dropTableIfExists('comments') @@ -167,6 +189,8 @@ export async function cleanup () { export async function resetTables () { const db = knex(getConfig()) await db.table('users').truncate() + await db.table('skills').truncate() + await db.table('skill_user').truncate() await db.table('profiles').truncate() await db.table('posts').truncate() await db.table('comments').truncate() diff --git a/test-helpers/tmp/db.sqlite b/test-helpers/tmp/db.sqlite deleted file mode 100644 index f5d06bae..00000000 Binary files a/test-helpers/tmp/db.sqlite and /dev/null differ diff --git a/test/orm/model-many-to-many.spec.ts b/test/orm/model-many-to-many.spec.ts new file mode 100644 index 00000000..7107b77c --- /dev/null +++ b/test/orm/model-many-to-many.spec.ts @@ -0,0 +1,594 @@ +/* +* @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' +import { manyToMany, column } from '../../src/Orm/Decorators' +import { ormAdapter, getBaseModel, setup, cleanup, resetTables, getDb } from '../../test-helpers' + +let db: ReturnType +let BaseModel: ReturnType + +test.group('Model | Many To Many', (group) => { + group.before(async () => { + db = getDb() + BaseModel = getBaseModel(ormAdapter(db)) + await setup() + }) + + group.after(async () => { + await cleanup() + await db.manager.closeAll() + }) + + group.afterEach(async () => { + await resetTables() + }) + + test('raise error when localKey is missing', (assert) => { + assert.plan(1) + + try { + class Skill extends BaseModel { + } + + class User extends BaseModel { + @manyToMany(() => Skill) + public skills: Skill[] + } + + User.$boot() + User.$getRelation('skills')!.boot() + } catch ({ message }) { + assert.equal( + message, + 'E_MISSING_RELATED_LOCAL_KEY: User.id required by User.skills relation is missing', + ) + } + }) + + test('raise error when foreignKey is missing', (assert) => { + assert.plan(1) + + try { + class Skill extends BaseModel { + } + Skill.$boot() + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @manyToMany(() => Skill) + public skills: Skill[] + } + + User.$boot() + User.$getRelation('skills')!.boot() + } catch ({ message }) { + assert.equal( + message, + 'E_MISSING_RELATED_FOREIGN_KEY: Skill.id required by User.skills relation is missing', + ) + } + }) + + test('use primary key is as the local key', (assert) => { + class Skill extends BaseModel { + @column({ primary: true }) + public id: number + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @manyToMany(() => Skill) + public skills: Skill[] + } + + User.$getRelation('skills')!.boot() + + assert.equal(User.$getRelation('skills')!['localKey'], 'id') + assert.equal(User.$getRelation('skills')!['localAdapterKey'], 'id') + }) + + test('use custom defined primary key', (assert) => { + class Skill extends BaseModel { + @column({ primary: true }) + public id: number + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @column() + public uid: number + + @manyToMany(() => Skill, { localKey: 'uid' }) + public skills: Skill[] + } + + User.$boot() + User.$getRelation('skills')!.boot() + + assert.equal(User.$getRelation('skills')!['localKey'], 'uid') + assert.equal(User.$getRelation('skills')!['localAdapterKey'], 'uid') + }) + + test('compute pivotForeignKey from table name + primary key', (assert) => { + class Skill extends BaseModel { + @column({ primary: true }) + public id: number + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @manyToMany(() => Skill) + public skills: Skill[] + } + + User.$getRelation('skills')!.boot() + + assert.equal(User.$getRelation('skills')!['pivotForeignKey'], 'user_id') + assert.equal(User.$getRelation('skills')!['pivotForeignKeyAlias'], 'pivot_user_id') + }) + + test('use custom defined pivotForeignKey', (assert) => { + class Skill extends BaseModel { + @column({ primary: true }) + public id: number + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @manyToMany(() => Skill, { pivotForeignKey: 'user_uid' }) + public skills: Skill[] + } + + User.$getRelation('skills')!.boot() + + assert.equal(User.$getRelation('skills')!['pivotForeignKey'], 'user_uid') + assert.equal(User.$getRelation('skills')!['pivotForeignKeyAlias'], 'pivot_user_uid') + }) + + test('use primary key of the related model as relatedKey', (assert) => { + class Skill extends BaseModel { + @column({ primary: true }) + public id: number + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @manyToMany(() => Skill) + public skills: Skill[] + } + + User.$boot() + User.$getRelation('skills')!.boot() + + assert.equal(User.$getRelation('skills')!['relatedKey'], 'id') + assert.equal(User.$getRelation('skills')!['relatedAdapterKey'], 'id') + }) + + test('use custom defined related key', (assert) => { + class Skill extends BaseModel { + @column({ primary: true }) + public id: number + + @column() + public uid: number + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @manyToMany(() => Skill, { relatedKey: 'uid' }) + public skills: Skill[] + } + + User.$boot() + User.$getRelation('skills')!.boot() + + assert.equal(User.$getRelation('skills')!['relatedKey'], 'uid') + assert.equal(User.$getRelation('skills')!['relatedAdapterKey'], 'uid') + }) + + test('compute relatedPivotForeignKey from related model name + primary key', (assert) => { + class Skill extends BaseModel { + @column({ primary: true }) + public id: number + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @manyToMany(() => Skill) + public skills: Skill[] + } + + User.$boot() + User.$getRelation('skills')!.boot() + + assert.equal(User.$getRelation('skills')!['pivotRelatedForeignKey'], 'skill_id') + assert.equal(User.$getRelation('skills')!['pivotRelatedForeignKeyAlias'], 'pivot_skill_id') + }) + + test('use predefined relatedPivotForeignKey', (assert) => { + class Skill extends BaseModel { + @column({ primary: true }) + public id: number + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @manyToMany(() => Skill, { pivotRelatedForeignKey: 'skill_uid' }) + public skills: Skill[] + } + + User.$boot() + User.$getRelation('skills')!.boot() + + assert.equal(User.$getRelation('skills')!['pivotRelatedForeignKey'], 'skill_uid') + assert.equal(User.$getRelation('skills')!['pivotRelatedForeignKeyAlias'], 'pivot_skill_uid') + }) + + test('get eager query', (assert) => { + class Skill extends BaseModel { + @column({ primary: true }) + public id: number + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @manyToMany(() => Skill) + public skills: Skill[] + } + + User.$boot() + User.$getRelation('skills')!.boot() + + const user = new User() + user.id = 1 + + const { sql, bindings } = User.$getRelation('skills')!.getEagerQuery([user], User.query().client).toSQL() + const { sql: knexSql, bindings: knexBindings } = db.query() + .from('skills') + .select([ + 'skills.*', + 'skill_user.user_id as pivot_user_id', + 'skill_user.skill_id as pivot_skill_id', + ]) + .innerJoin('skill_user', 'skills.id', 'skill_user.skill_id') + .whereIn('skill_user.user_id', [1]) + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('get query for single parent', (assert) => { + class Skill extends BaseModel { + @column({ primary: true }) + public id: number + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @manyToMany(() => Skill) + public skills: Skill[] + } + + User.$boot() + User.$getRelation('skills')!.boot() + + const user = new User() + user.id = 1 + + const { sql, bindings } = User.$getRelation('skills')!.getQuery(user, User.query().client).toSQL() + const { sql: knexSql, bindings: knexBindings } = db.query() + .from('skills') + .select([ + 'skills.*', + 'skill_user.user_id as pivot_user_id', + 'skill_user.skill_id as pivot_skill_id', + ]) + .innerJoin('skill_user', 'skills.id', 'skill_user.skill_id') + .where('skill_user.user_id', 1) + .toSQL() + + assert.equal(sql, knexSql) + assert.deepEqual(bindings, knexBindings) + }) + + test('preload relation', async (assert) => { + class Skill extends BaseModel { + @column({ primary: true }) + public id: number + + @column() + public name: string + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @manyToMany(() => Skill) + public skills: Skill[] + } + + User.$boot() + User.$getRelation('skills')!.boot() + + await db.insertQuery().table('users').insert([{ username: 'virk' }]) + await db.insertQuery().table('skills').insert([{ name: 'Programming' }, { name: 'Dancing' }]) + await db.insertQuery().table('skill_user').insert([ + { + user_id: 1, + skill_id: 1, + }, + ]) + + const users = await User.query().preload('skills') + assert.lengthOf(users, 1) + assert.lengthOf(users[0].skills, 1) + assert.equal(users[0].skills[0].name, 'Programming') + assert.equal(users[0].skills[0].$extras.pivot_user_id, 1) + assert.equal(users[0].skills[0].$extras.pivot_skill_id, 1) + }) + + test('preload relation for many', async (assert) => { + class Skill extends BaseModel { + @column({ primary: true }) + public id: number + + @column() + public name: string + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @manyToMany(() => Skill) + public skills: Skill[] + } + + User.$boot() + User.$getRelation('skills')!.boot() + + await db.insertQuery().table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await db.insertQuery().table('skills').insert([{ name: 'Programming' }, { name: 'Dancing' }]) + await db.insertQuery().table('skill_user').insert([ + { + user_id: 1, + skill_id: 1, + }, + { + user_id: 1, + skill_id: 2, + }, + { + user_id: 2, + skill_id: 2, + }, + ]) + + const users = await User.query().preload('skills') + assert.lengthOf(users, 2) + assert.lengthOf(users[0].skills, 2) + assert.lengthOf(users[1].skills, 1) + + assert.equal(users[0].skills[0].name, 'Programming') + assert.equal(users[0].skills[0].$extras.pivot_user_id, 1) + assert.equal(users[0].skills[0].$extras.pivot_skill_id, 1) + + assert.equal(users[0].skills[1].name, 'Dancing') + assert.equal(users[0].skills[1].$extras.pivot_user_id, 1) + assert.equal(users[0].skills[1].$extras.pivot_skill_id, 2) + + assert.equal(users[1].skills[0].name, 'Dancing') + assert.equal(users[1].skills[0].$extras.pivot_user_id, 2) + assert.equal(users[1].skills[0].$extras.pivot_skill_id, 2) + }) + + test('raise error when local is not selected', async (assert) => { + assert.plan(1) + + class Skill extends BaseModel { + @column({ primary: true }) + public id: number + + @column() + public name: string + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @manyToMany(() => Skill) + public skills: Skill[] + } + + User.$boot() + User.$getRelation('skills')!.boot() + + await db.insertQuery().table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await db.insertQuery().table('skills').insert([{ name: 'Programming' }, { name: 'Dancing' }]) + await db.insertQuery().table('skill_user').insert([ + { + user_id: 1, + skill_id: 1, + }, + { + user_id: 1, + skill_id: 2, + }, + { + user_id: 2, + skill_id: 2, + }, + ]) + + try { + await User.query().select('username').preload('skills') + } catch ({ message }) { + assert.equal(message, 'Cannot preload skills, value of User.id is undefined') + } + }) + + test('select extra pivot columns', async (assert) => { + class Skill extends BaseModel { + @column({ primary: true }) + public id: number + + @column() + public name: string + + @column() + public proficiency: string + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @manyToMany(() => Skill, { pivotColumns: ['proficiency'] }) + public skills: Skill[] + } + + User.$boot() + User.$getRelation('skills')!.boot() + + await db.insertQuery().table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await db.insertQuery().table('skills').insert([{ name: 'Programming' }, { name: 'Dancing' }]) + await db.insertQuery().table('skill_user').insert([ + { + user_id: 1, + skill_id: 1, + proficiency: 'expert', + }, + { + user_id: 1, + skill_id: 2, + proficiency: 'beginner', + }, + { + user_id: 2, + skill_id: 2, + proficiency: 'beginner', + }, + ]) + + const users = await User.query().preload('skills') + assert.lengthOf(users, 2) + assert.lengthOf(users[0].skills, 2) + assert.lengthOf(users[1].skills, 1) + + assert.equal(users[0].skills[0].name, 'Programming') + assert.equal(users[0].skills[0].$extras.pivot_user_id, 1) + assert.equal(users[0].skills[0].$extras.pivot_skill_id, 1) + assert.equal(users[0].skills[0].$extras.pivot_proficiency, 'expert') + + assert.equal(users[0].skills[1].name, 'Dancing') + assert.equal(users[0].skills[1].$extras.pivot_user_id, 1) + assert.equal(users[0].skills[1].$extras.pivot_skill_id, 2) + assert.equal(users[0].skills[1].$extras.pivot_proficiency, 'beginner') + + assert.equal(users[1].skills[0].name, 'Dancing') + assert.equal(users[1].skills[0].$extras.pivot_user_id, 2) + assert.equal(users[1].skills[0].$extras.pivot_skill_id, 2) + assert.equal(users[1].skills[0].$extras.pivot_proficiency, 'beginner') + }) + + test('select extra pivot columns at runtime', async (assert) => { + class Skill extends BaseModel { + @column({ primary: true }) + public id: number + + @column() + public name: string + + @column() + public proficiency: string + } + + class User extends BaseModel { + @column({ primary: true }) + public id: number + + @manyToMany(() => Skill) + public skills: Skill[] + } + + User.$boot() + User.$getRelation('skills')!.boot() + + await db.insertQuery().table('users').insert([{ username: 'virk' }, { username: 'nikk' }]) + await db.insertQuery().table('skills').insert([{ name: 'Programming' }, { name: 'Dancing' }]) + await db.insertQuery().table('skill_user').insert([ + { + user_id: 1, + skill_id: 1, + proficiency: 'expert', + }, + { + user_id: 1, + skill_id: 2, + proficiency: 'beginner', + }, + { + user_id: 2, + skill_id: 2, + proficiency: 'beginner', + }, + ]) + + const users = await User.query().preload<'manyToMany'>('skills', (builder) => { + builder.pivotColumns(['proficiency']) + }) + + assert.lengthOf(users, 2) + assert.lengthOf(users[0].skills, 2) + assert.lengthOf(users[1].skills, 1) + + assert.equal(users[0].skills[0].name, 'Programming') + assert.equal(users[0].skills[0].$extras.pivot_user_id, 1) + assert.equal(users[0].skills[0].$extras.pivot_skill_id, 1) + assert.equal(users[0].skills[0].$extras.pivot_proficiency, 'expert') + + assert.equal(users[0].skills[1].name, 'Dancing') + assert.equal(users[0].skills[1].$extras.pivot_user_id, 1) + assert.equal(users[0].skills[1].$extras.pivot_skill_id, 2) + assert.equal(users[0].skills[1].$extras.pivot_proficiency, 'beginner') + + assert.equal(users[1].skills[0].name, 'Dancing') + assert.equal(users[1].skills[0].$extras.pivot_user_id, 2) + assert.equal(users[1].skills[0].$extras.pivot_skill_id, 2) + assert.equal(users[1].skills[0].$extras.pivot_proficiency, 'beginner') + }) +})