From 0a4f4fc41ceef47d088e07a512c3c5bdb1328b0c Mon Sep 17 00:00:00 2001 From: Harminder virk Date: Sun, 13 Oct 2019 18:18:57 +0530 Subject: [PATCH] feat: implement sync and detach methods on many to many query builder --- adonis-typings/model.ts | 22 +- src/Orm/Relations/ManyToMany/QueryBuilder.ts | 118 +++++++++ src/utils/index.ts | 7 +- test-helpers/index.ts | 1 + test/orm/model-many-to-many.spec.ts | 264 +++++++++++++++++++ 5 files changed, 402 insertions(+), 10 deletions(-) diff --git a/adonis-typings/model.ts b/adonis-typings/model.ts index e8ec146a..dfc96d5d 100644 --- a/adonis-typings/model.ts +++ b/adonis-typings/model.ts @@ -361,15 +361,19 @@ declare module '@ioc:Adonis/Lucid/Model' { checkExisting?: boolean, ): Promise - // /** - // * Attach related - // */ - // detach (ids: any[]): Promise - - // /** - // * Attach related - // */ - // sync (ids: any[], detach: boolean): Promise + /** + * Detach from pivot table + */ + detach (ids: (string | number)[]): Promise + + /** + * Sync related ids + */ + sync ( + ids: (string | number)[] | { [key: string]: any }, + wrapInTransaction?: boolean, + checkExisting?: boolean, + ): Promise } /** diff --git a/src/Orm/Relations/ManyToMany/QueryBuilder.ts b/src/Orm/Relations/ManyToMany/QueryBuilder.ts index 60164794..887044c2 100644 --- a/src/Orm/Relations/ManyToMany/QueryBuilder.ts +++ b/src/Orm/Relations/ManyToMany/QueryBuilder.ts @@ -420,6 +420,82 @@ export class ManyToManyQueryBuilder })) } + /** + * Remove related records from the pivot table. The `inverse` flag + * will remove all except given ids + */ + private async _detach ( + parent: ModelContract, + client: QueryClientContract, + ids: (string | number)[], + inverse: boolean = false, + ) { + const query = client + .query() + .from(this._relation.pivotTable) + .where( + this._relation.pivotForeignKey, + this.$getRelatedValue(parent, this._relation.localKey, 'detach'), + ) + + if (inverse) { + query.whereNotIn(this._relation.pivotRelatedForeignKey, ids) + } else { + query.whereIn(this._relation.pivotRelatedForeignKey, ids) + } + + return query.del() + } + + /** + * Sync related ids inside the pivot table. + */ + private async _sync ( + parent: ModelContract, + ids: (string | number)[] | { [key: string]: any }, + checkExisting: boolean, + ) { + const client = this._relation.model.$adapter.modelClient(parent) + + /** + * Remove except given ids + */ + const detachIds = Array.isArray(ids) ? ids : Object.keys(ids) + await this._detach(parent, client, detachIds, true) + + /** + * Add new ids + */ + await this._attach(parent, client, ids, checkExisting) + } + + /** + * Sync related ids inside the pivot table within a transaction + */ + private async _syncInTransaction ( + parent: ModelContract, + trx: TransactionClientContract, + ids: (string | number)[] | { [key: string]: any }, + checkExisting: boolean, + ) { + try { + /** + * Remove except given ids + */ + const detachIds = Array.isArray(ids) ? ids : Object.keys(ids) + await this._detach(parent, trx, detachIds, true) + + /** + * Add new ids + */ + await this._attach(parent, trx, ids, checkExisting) + await trx.commit() + } catch (error) { + await trx.rollback() + throw error + } + } + /** * Save related model instance with entry in the pivot table */ @@ -494,4 +570,46 @@ export class ManyToManyQueryBuilder const client = this._relation.model.$adapter.modelClient(this._parent) await this._attach(this._parent, client, ids, checkExisting) } + + /** + * Remove one of more related instances + */ + public async detach (ids: (string | number)[]) { + if (Array.isArray(this._parent)) { + throw new Error('Cannot save with multiple parents') + return + } + + const client = this._relation.model.$adapter.modelClient(this._parent) + await this._detach(this._parent, client, ids) + } + + /** + * Sync related ids + */ + public async sync ( + ids: (string | number)[] | { [key: string]: any }, + wrapInTransaction: boolean = true, + checkExisting: boolean = true, + ) { + if (Array.isArray(this._parent)) { + throw new Error('Cannot save with multiple parents') + return + } + + /** + * Wrap in transaction when wrapInTransaction is not set to false. So that + * we rollback to initial state, when one or more fails + */ + let trx: TransactionClientContract | undefined + if (wrapInTransaction) { + trx = await this.client.transaction() + } + + if (trx) { + await this._syncInTransaction(this._parent, trx, ids, checkExisting) + } else { + await this._sync(this._parent, ids, checkExisting) + } + } } diff --git a/src/utils/index.ts b/src/utils/index.ts index c9107a07..bb799a7f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -60,5 +60,10 @@ export function unique (value: any[]) { } export function difference (main: any[], other: []) { - return [main, other].reduce((a, b) => a.filter(c => !b.includes(c))) + return [main, other].reduce((a, b) => { + return a.filter(c => { + /* tslint:disable triple-equals */ + return !b.find((one) => c == one) + }) + }) } diff --git a/test-helpers/index.ts b/test-helpers/index.ts index 966b3a2b..d49bc537 100644 --- a/test-helpers/index.ts +++ b/test-helpers/index.ts @@ -69,6 +69,7 @@ export function getConfig (): ConnectionConfigContract { filename: join(fs.basePath, 'db.sqlite'), }, useNullAsDefault: true, + debug: true, } case 'mysql': return { diff --git a/test/orm/model-many-to-many.spec.ts b/test/orm/model-many-to-many.spec.ts index ba44cf90..f3b09883 100644 --- a/test/orm/model-many-to-many.spec.ts +++ b/test/orm/model-many-to-many.spec.ts @@ -3007,3 +3007,267 @@ test.group('Model | ManyToMany | bulk operation', (group) => { assert.deepEqual(bindings, knexBindings) }) }) + +test.group('Model | ManyToMany | detach', (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('detach existing pivot ids', 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 + + @column() + public username: string + + @manyToMany(() => Skill) + public skills: Skill[] + } + + const user = new User() + user.username = 'virk' + await user.save() + + await user.related<'manyToMany', 'skills'>('skills').attach([1, 2]) + await user.related<'manyToMany', 'skills'>('skills').detach([1]) + + const totalUsers = await db.query().from('users').count('*', 'total') + const skillUsers = await db.query().from('skill_user') + + assert.equal(totalUsers[0].total, 1) + + assert.lengthOf(skillUsers, 1) + assert.equal(skillUsers[0].user_id, user.id) + assert.equal(skillUsers[0].skill_id, 2) + + assert.isUndefined(user.$trx) + }) + + test('fail detach when parent is not persisted', 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 + + @column() + public username: string + + @manyToMany(() => Skill) + public skills: Skill[] + } + + const user = new User() + user.username = 'virk' + + try { + await user.related<'manyToMany', 'skills'>('skills').detach([1]) + } catch ({ message }) { + assert.equal(message, 'Cannot detach skills, value of User.id is undefined') + } + }) +}) + +test.group('Model | ManyToMany | sync', (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('do not perform deletes when not removing any ids', 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 + + @column() + public username: string + + @manyToMany(() => Skill) + public skills: Skill[] + } + + const user = new User() + user.username = 'virk' + await user.save() + + await user.related<'manyToMany', 'skills'>('skills').attach([1, 2]) + const skillUsers = await db.query().from('skill_user') + + await user.related<'manyToMany', 'skills'>('skills').sync([1, 2]) + const skillUsersAfterSync = await db.query().from('skill_user') + + assert.equal(skillUsers[0].id, skillUsersAfterSync[0].id) + assert.equal(skillUsers[1].id, skillUsersAfterSync[1].id) + }) + + test('remove ids except one defined in the sync method', 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 + + @column() + public username: string + + @manyToMany(() => Skill) + public skills: Skill[] + } + + const user = new User() + user.username = 'virk' + await user.save() + + await user.related<'manyToMany', 'skills'>('skills').attach([1, 2]) + const skillUsers = await db.query().from('skill_user') + + await user.related<'manyToMany', 'skills'>('skills').sync([2]) + const skillUsersAfterSync = await db.query().from('skill_user') + + assert.lengthOf(skillUsers, 2) + assert.lengthOf(skillUsersAfterSync, 1) + + assert.equal(skillUsers[1].id, skillUsersAfterSync[0].id) + assert.equal(skillUsersAfterSync[0].user_id, user.id) + assert.equal(skillUsersAfterSync[0].skill_id, 2) + }) + + test('insert new ids metioned in sync', 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 + + @column() + public username: string + + @manyToMany(() => Skill) + public skills: Skill[] + } + + const user = new User() + user.username = 'virk' + await user.save() + + await user.related<'manyToMany', 'skills'>('skills').attach([1]) + const skillUsers = await db.query().from('skill_user') + + await user.related<'manyToMany', 'skills'>('skills').sync([1, 2]) + const skillUsersAfterSync = await db.query().from('skill_user') + + assert.lengthOf(skillUsers, 1) + assert.lengthOf(skillUsersAfterSync, 2) + + assert.equal(skillUsers[0].id, skillUsersAfterSync[0].id) + assert.equal(skillUsersAfterSync[0].user_id, user.id) + assert.equal(skillUsersAfterSync[0].skill_id, 1) + + assert.equal(skillUsersAfterSync[1].user_id, user.id) + assert.equal(skillUsersAfterSync[1].skill_id, 2) + }) + + test('sync with extra properties', 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 + + @column() + public username: string + + @manyToMany(() => Skill) + public skills: Skill[] + } + + const user = new User() + user.username = 'virk' + await user.save() + + await user.related<'manyToMany', 'skills'>('skills').attach([1]) + const skillUsers = await db.query().from('skill_user') + + await user.related<'manyToMany', 'skills'>('skills').sync({ + 1: { proficiency: 'master' }, + 2: { proficiency: 'beginner' }, + }) + const skillUsersAfterSync = await db.query().from('skill_user') + + assert.lengthOf(skillUsers, 1) + assert.lengthOf(skillUsersAfterSync, 2) + + assert.equal(skillUsers[0].id, skillUsersAfterSync[0].id) + assert.equal(skillUsersAfterSync[0].user_id, user.id) + assert.equal(skillUsersAfterSync[0].skill_id, 1) + assert.isNull(skillUsersAfterSync[0].proficiency) + + assert.equal(skillUsersAfterSync[1].user_id, user.id) + assert.equal(skillUsersAfterSync[1].skill_id, 2) + assert.equal(skillUsersAfterSync[1].proficiency, 'beginner') + }) +})