From 36b7e4a70552c047a6466a529a3844d140141005 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 26 Apr 2021 11:13:08 +0530 Subject: [PATCH] feat: add support for timestamps in many to many relationship --- adonis-typings/model.ts | 5 + adonis-typings/relations.ts | 6 + src/Orm/QueryBuilder/index.ts | 44 +- src/Orm/Relations/ManyToMany/QueryBuilder.ts | 43 +- src/Orm/Relations/ManyToMany/QueryClient.ts | 31 +- src/Orm/Relations/ManyToMany/index.ts | 63 +++ src/utils/index.ts | 4 +- test/orm/model-many-to-many.spec.ts | 416 +++++++++++++++++++ 8 files changed, 594 insertions(+), 18 deletions(-) diff --git a/adonis-typings/model.ts b/adonis-typings/model.ts index 442f57e9..ae5494a9 100644 --- a/adonis-typings/model.ts +++ b/adonis-typings/model.ts @@ -323,6 +323,11 @@ declare module '@ioc:Adonis/Lucid/Model' { ExcutableQueryBuilderContract { model: Model + /** + * Define a callback to transform a row + */ + rowTransformer(callback: (row: LucidRow) => void): this + /** * Define a custom preloader for the current query */ diff --git a/adonis-typings/relations.ts b/adonis-typings/relations.ts index 6f3048b9..d8422446 100644 --- a/adonis-typings/relations.ts +++ b/adonis-typings/relations.ts @@ -78,6 +78,12 @@ declare module '@ioc:Adonis/Lucid/Relations' { relatedKey?: string pivotRelatedForeignKey?: string pivotColumns?: string[] + pivotTimestamps?: + | boolean + | { + createdAt: string | boolean + updatedAt: string | boolean + } serializeAs?: string | null onQuery?(query: Related['builder'] | Related['subQuery']): void } diff --git a/src/Orm/QueryBuilder/index.ts b/src/Orm/QueryBuilder/index.ts index 988a2136..68408ceb 100644 --- a/src/Orm/QueryBuilder/index.ts +++ b/src/Orm/QueryBuilder/index.ts @@ -33,6 +33,7 @@ import { TransactionClientContract, } from '@ioc:Adonis/Lucid/Database' +import { isObject } from '../../utils' import { Preloader } from '../Preloader' import { QueryRunner } from '../../QueryRunner' import { Chainable } from '../../Database/QueryBuilder/Chainable' @@ -78,6 +79,11 @@ export class ModelQueryBuilder extends Chainable implements ModelQueryBuilderCon */ protected preloader: PreloaderContract = new Preloader(this.model) + /** + * A custom callback to transform each model row + */ + protected rowTransformerCallback: (row: LucidRow) => void + /** * Required by macroable */ @@ -169,13 +175,27 @@ export class ModelQueryBuilder extends Chainable implements ModelQueryBuilderCon } /** - * Convert fetch results to an array of model instances + * Convert fetched results to an array of model instances */ - const modelInstances = this.model.$createMultipleFromAdapterResult( - rows, - this.sideloaded, - this.clientOptions - ) + const modelInstances = rows.reduce((models: LucidRow[], row: ModelObject) => { + if (isObject(row)) { + const modelInstance = this.model.$createFromAdapterResult( + row, + this.sideloaded, + this.clientOptions + )! + + /** + * Transform row when row transformer is defined + */ + if (this.rowTransformerCallback) { + this.rowTransformerCallback(modelInstance) + } + + models.push(modelInstance) + } + return models + }, []) /** * Preload for model instances @@ -314,6 +334,14 @@ export class ModelQueryBuilder extends Chainable implements ModelQueryBuilderCon return this } + /** + * Define a custom callback to transform rows + */ + public rowTransformer(callback: (row: LucidRow) => void): this { + this.rowTransformerCallback = callback + return this + } + /** * Clone the current query builder */ @@ -321,6 +349,7 @@ export class ModelQueryBuilder extends Chainable implements ModelQueryBuilderCon const clonedQuery = new ModelQueryBuilder(this.knexQuery.clone(), this.model, this.client) this.applyQueryFlags(clonedQuery) clonedQuery.sideloaded = Object.assign({}, this.sideloaded) + this.rowTransformerCallback && this.rowTransformer(this.rowTransformerCallback) return clonedQuery } @@ -372,6 +401,9 @@ export class ModelQueryBuilder extends Chainable implements ModelQueryBuilderCon return this } + /** + * Define a custom preloader instance for preloading relationships + */ public usePreloader(preloader: PreloaderContract) { this.preloader = preloader return this diff --git a/src/Orm/Relations/ManyToMany/QueryBuilder.ts b/src/Orm/Relations/ManyToMany/QueryBuilder.ts index 6663724d..b6391c46 100644 --- a/src/Orm/Relations/ManyToMany/QueryBuilder.ts +++ b/src/Orm/Relations/ManyToMany/QueryBuilder.ts @@ -8,6 +8,7 @@ */ import { Knex } from 'knex' +import { DateTime } from 'luxon' import { LucidModel, LucidRow } from '@ioc:Adonis/Lucid/Model' import { QueryClientContract } from '@ioc:Adonis/Lucid/Database' import { ManyToManyQueryBuilderContract } from '@ioc:Adonis/Lucid/Relations' @@ -177,9 +178,9 @@ export class ManyToManyQueryBuilder * Select columns from the pivot table */ this.pivotColumns( - [this.relation.pivotForeignKey, this.relation.pivotRelatedForeignKey].concat( - this.relation.pivotColumns - ) + [this.relation.pivotForeignKey, this.relation.pivotRelatedForeignKey] + .concat(this.relation.pivotColumns) + .concat(this.relation.pivotTimestamps) ) } @@ -387,6 +388,42 @@ export class ManyToManyQueryBuilder return super.paginate(page, perPage) } + public async exec() { + const pivotTimestamps = this.relation.pivotTimestamps.map((timestamp) => + this.relation.pivotAlias(timestamp) + ) + + /** + * Transform pivot timestamps + */ + if (pivotTimestamps.length) { + this.rowTransformer((row) => { + pivotTimestamps.forEach((timestamp) => { + const timestampValue = row.$extras[timestamp] + if (!timestampValue) { + return + } + + /** + * Convert from string + */ + if (typeof timestampValue === 'string') { + row.$extras[timestamp] = DateTime.fromSQL(timestampValue) + } + + /** + * Convert from date + */ + if (timestampValue instanceof Date) { + row.$extras[timestamp] = DateTime.fromJSDate(timestampValue) + } + }) + }) + } + + return super.exec() + } + /** * Returns the group limit query */ diff --git a/src/Orm/Relations/ManyToMany/QueryClient.ts b/src/Orm/Relations/ManyToMany/QueryClient.ts index 00d1a3e5..278f1d5a 100644 --- a/src/Orm/Relations/ManyToMany/QueryClient.ts +++ b/src/Orm/Relations/ManyToMany/QueryClient.ts @@ -7,6 +7,7 @@ * file that was distributed with this source code. */ +import { DateTime } from 'luxon' import { OneOrMany } from '@ioc:Adonis/Lucid/DatabaseQueryBuilder' import { ManyToManyClientContract } from '@ioc:Adonis/Lucid/Relations' import { LucidModel, LucidRow, ModelObject } from '@ioc:Adonis/Lucid/Model' @@ -50,6 +51,27 @@ export class ManyToManyQueryClient implements ManyToManyClientContract { - return Object.assign({}, hasAttributes ? ids[id] : {}, { + return Object.assign(this.getPivotTimestamps(false), hasAttributes ? ids[id] : {}, { [this.relation.pivotForeignKey]: foreignKeyValue, [this.relation.pivotRelatedForeignKey]: id, }) @@ -389,8 +411,6 @@ export class ManyToManyQueryClient implements ManyToManyClientContract LucidModel, @@ -150,6 +206,13 @@ export class ManyToMany implements ManyToManyRelationContract incomingRow[key] !== originalRow[key])) { /** * If any of the row attributes are different, then we must * update that row */ - result.updated[incomingRowId] = incoming[incomingRowId] + result.updated[incomingRowId] = incomingRow } return result diff --git a/test/orm/model-many-to-many.spec.ts b/test/orm/model-many-to-many.spec.ts index 931c43a8..8f3d8f19 100644 --- a/test/orm/model-many-to-many.spec.ts +++ b/test/orm/model-many-to-many.spec.ts @@ -26,11 +26,14 @@ import { setupApplication, } from '../../test-helpers' import { ApplicationContract } from '@ioc:Adonis/Core/Application' +import { DateTime } from 'luxon' let db: ReturnType let app: ApplicationContract let BaseModel: ReturnType +const sleep = (time: number) => new Promise((resolve) => setTimeout(resolve, time)) + test.group('Model | ManyToMany | Options', (group) => { group.before(async () => { app = await setupApplication() @@ -601,6 +604,56 @@ test.group('Model | ManyToMany | bulk operations', (group) => { assert.equal(sql, knexSql) assert.deepEqual(bindings, knexBindings) }) + + test('convert timestamps instance of Luxon', async (assert) => { + class Skill extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @column() + public name: string + } + + class User extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @manyToMany(() => Skill, { + pivotTimestamps: true, + }) + public skills: ManyToMany + } + + 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, + created_at: DateTime.local().toFormat(db.connection().dialect.dateTimeFormat), + updated_at: DateTime.local().toFormat(db.connection().dialect.dateTimeFormat), + }, + ]) + + const user = await User.find(1) + const skills = await user!.related('skills').query() + + assert.lengthOf(skills, 1) + assert.equal(skills[0].name, 'Programming') + assert.equal(skills[0].$extras.pivot_user_id, 1) + assert.equal(skills[0].$extras.pivot_skill_id, 1) + assert.instanceOf(skills[0].$extras.pivot_created_at, DateTime) + assert.instanceOf(skills[0].$extras.pivot_updated_at, DateTime) + }) }) test.group('Model | ManyToMany | sub queries', (group) => { @@ -1053,6 +1106,56 @@ test.group('Model | ManyToMany | preload', (group) => { assert.equal(users[0].skills[0].$extras.pivot_skill_id, 1) }) + test('convert dates to luxon datetime instance during preload', async (assert) => { + class Skill extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @column() + public name: string + } + + class User extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @manyToMany(() => Skill, { + pivotTimestamps: true, + }) + public skills: ManyToMany + } + + User.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, + created_at: DateTime.local().toFormat(db.connection().dialect.dateTimeFormat), + updated_at: DateTime.local().toFormat(db.connection().dialect.dateTimeFormat), + }, + ]) + + 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) + assert.instanceOf(users[0].skills[0].$extras.pivot_created_at, DateTime) + assert.instanceOf(users[0].skills[0].$extras.pivot_updated_at, DateTime) + }) + test('preload relation for many', async (assert) => { class Skill extends BaseModel { @column({ isPrimary: true }) @@ -1183,6 +1286,58 @@ test.group('Model | ManyToMany | preload', (group) => { assert.equal(users[1].skills[0].$extras.pivot_skill_id, 2) }) + test('convert dates to luxon datetime instance when preload using model instance', async (assert) => { + class Skill extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @column() + public name: string + } + + class User extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @manyToMany(() => Skill, { + pivotTimestamps: true, + }) + public skills: ManyToMany + } + + User.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, + created_at: DateTime.local().toFormat(db.connection().dialect.dateTimeFormat), + updated_at: DateTime.local().toFormat(db.connection().dialect.dateTimeFormat), + }, + ]) + + const users = await User.query() + await users[0].load('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) + assert.instanceOf(users[0].skills[0].$extras.pivot_created_at, DateTime) + assert.instanceOf(users[0].skills[0].$extras.pivot_updated_at, DateTime) + }) + test('select extra pivot columns', async (assert) => { class Skill extends BaseModel { @column({ isPrimary: true }) @@ -4514,6 +4669,267 @@ test.group('Model | ManyToMany | save', (group) => { assert.isUndefined(user1.$trx) assert.isUndefined(skill.$trx) }) + + test('save related instance with timestamps', async (assert) => { + class Skill extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @column() + public name: string + } + + class User extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @column() + public username: string + + @manyToMany(() => Skill, { + pivotTimestamps: true, + }) + public skills: ManyToMany + } + + const user = new User() + user.username = 'virk' + await user.save() + + const skill = new Skill() + skill.name = 'Programming' + + await user.related('skills').save(skill) + + assert.isTrue(user.$isPersisted) + assert.isTrue(skill.$isPersisted) + + const totalUsers = await db.query().from('users').count('*', 'total') + const totalPosts = await db.query().from('skills').count('*', 'total') + const skillUsers = await db.query().from('skill_user') + + assert.equal(totalUsers[0].total, 1) + assert.equal(totalPosts[0].total, 1) + + assert.lengthOf(skillUsers, 1) + assert.equal(skillUsers[0].user_id, user.id) + assert.equal(skillUsers[0].skill_id, skill.id) + assert.isNotNull(skillUsers[0].created_at) + assert.isNotNull(skillUsers[0].updated_at) + assert.deepEqual(skillUsers[0].created_at, skillUsers[0].updated_at) + assert.isUndefined(user.$trx) + assert.isUndefined(skill.$trx) + }) + + test('do not set created_at on update', async (assert) => { + class Skill extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @column() + public name: string + } + + class User extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @column() + public username: string + + @manyToMany(() => Skill, { + pivotTimestamps: true, + }) + public skills: ManyToMany + } + + const user = new User() + user.username = 'virk' + await user.save() + + const skill = new Skill() + skill.name = 'Programming' + + await user.related('skills').save(skill) + + assert.isTrue(user.$isPersisted) + assert.isTrue(skill.$isPersisted) + + await sleep(1000) + await user.related('skills').save(skill, true, { + proficiency: 'Master', + }) + + const totalUsers = await db.query().from('users').count('*', 'total') + const totalPosts = await db.query().from('skills').count('*', 'total') + const skillUsers = await db.query().from('skill_user') + + assert.equal(totalUsers[0].total, 1) + assert.equal(totalPosts[0].total, 1) + + assert.lengthOf(skillUsers, 1) + assert.equal(skillUsers[0].user_id, user.id) + assert.equal(skillUsers[0].skill_id, skill.id) + assert.isNotNull(skillUsers[0].created_at) + assert.isNotNull(skillUsers[0].updated_at) + assert.notEqual(skillUsers[0].created_at, skillUsers[0].updated_at) + assert.isUndefined(user.$trx) + assert.isUndefined(skill.$trx) + }) + + test('do not set updated_at when disabled', async (assert) => { + class Skill extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @column() + public name: string + } + + class User extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @column() + public username: string + + @manyToMany(() => Skill, { + pivotTimestamps: { + createdAt: true, + updatedAt: false, + }, + }) + public skills: ManyToMany + } + + const user = new User() + user.username = 'virk' + await user.save() + + const skill = new Skill() + skill.name = 'Programming' + + await user.related('skills').save(skill) + + assert.isTrue(user.$isPersisted) + assert.isTrue(skill.$isPersisted) + + const totalUsers = await db.query().from('users').count('*', 'total') + const totalPosts = await db.query().from('skills').count('*', 'total') + const skillUsers = await db.query().from('skill_user') + + assert.equal(totalUsers[0].total, 1) + assert.equal(totalPosts[0].total, 1) + + assert.lengthOf(skillUsers, 1) + assert.equal(skillUsers[0].user_id, user.id) + assert.equal(skillUsers[0].skill_id, skill.id) + assert.isNotNull(skillUsers[0].created_at) + assert.isNull(skillUsers[0].updated_at) + assert.isUndefined(user.$trx) + assert.isUndefined(skill.$trx) + }) + + test('do not set created_at when disabled', async (assert) => { + class Skill extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @column() + public name: string + } + + class User extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @column() + public username: string + + @manyToMany(() => Skill, { + pivotTimestamps: { + createdAt: false, + updatedAt: true, + }, + }) + public skills: ManyToMany + } + + const user = new User() + user.username = 'virk' + await user.save() + + const skill = new Skill() + skill.name = 'Programming' + + await user.related('skills').save(skill) + + assert.isTrue(user.$isPersisted) + assert.isTrue(skill.$isPersisted) + + const totalUsers = await db.query().from('users').count('*', 'total') + const totalPosts = await db.query().from('skills').count('*', 'total') + const skillUsers = await db.query().from('skill_user') + + assert.equal(totalUsers[0].total, 1) + assert.equal(totalPosts[0].total, 1) + + assert.lengthOf(skillUsers, 1) + assert.equal(skillUsers[0].user_id, user.id) + assert.equal(skillUsers[0].skill_id, skill.id) + assert.isNull(skillUsers[0].created_at) + assert.isNotNull(skillUsers[0].updated_at) + assert.isUndefined(user.$trx) + assert.isUndefined(skill.$trx) + }) + + test('do not set timestamps when disabled', async (assert) => { + class Skill extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @column() + public name: string + } + + class User extends BaseModel { + @column({ isPrimary: true }) + public id: number + + @column() + public username: string + + @manyToMany(() => Skill) + public skills: ManyToMany + } + + const user = new User() + user.username = 'virk' + await user.save() + + const skill = new Skill() + skill.name = 'Programming' + + await user.related('skills').save(skill) + + assert.isTrue(user.$isPersisted) + assert.isTrue(skill.$isPersisted) + + const totalUsers = await db.query().from('users').count('*', 'total') + const totalPosts = await db.query().from('skills').count('*', 'total') + const skillUsers = await db.query().from('skill_user') + + assert.equal(totalUsers[0].total, 1) + assert.equal(totalPosts[0].total, 1) + + assert.lengthOf(skillUsers, 1) + assert.equal(skillUsers[0].user_id, user.id) + assert.equal(skillUsers[0].skill_id, skill.id) + assert.isNull(skillUsers[0].created_at) + assert.isNull(skillUsers[0].updated_at) + assert.isUndefined(user.$trx) + assert.isUndefined(skill.$trx) + }) }) test.group('Model | ManyToMany | saveMany', (group) => {