From fb490dcbe87a513a59556c0d693bc70e76247c6e Mon Sep 17 00:00:00 2001 From: jlenon7 Date: Wed, 8 Apr 2026 13:32:36 -0300 Subject: [PATCH 1/2] feat: make restore act like save method --- package-lock.json | 4 +- package.json | 2 +- src/models/BaseModel.ts | 57 +++++- src/models/builders/ModelQueryBuilder.ts | 18 +- tests/unit/models/BaseModelTest.ts | 211 +++++++++++++++++++++++ 5 files changed, 279 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 245a6bf..62839f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@athenna/database", - "version": "5.41.0", + "version": "5.42.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@athenna/database", - "version": "5.41.0", + "version": "5.42.0", "license": "MIT", "dependencies": { "@faker-js/faker": "^8.4.1" diff --git a/package.json b/package.json index 4bee1ed..9fb1840 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@athenna/database", - "version": "5.41.0", + "version": "5.42.0", "description": "The Athenna database handler for SQL/NoSQL.", "license": "MIT", "author": "João Lenon ", diff --git a/src/models/BaseModel.ts b/src/models/BaseModel.ts index 391d133..ddbc6b0 100644 --- a/src/models/BaseModel.ts +++ b/src/models/BaseModel.ts @@ -454,6 +454,23 @@ export class BaseModel { return query.update(data, cleanPersist) } + /** + * Restore a soft deleted value from database. + */ + public static async restore( + this: T, + where: Partial>, + data: Partial> + ): Promise | InstanceType[]> { + const query = this.query() + + if (where) { + query.where(where) + } + + return query.restore(data) + } + /** * Delete or soft delete a value in database. */ @@ -758,14 +775,44 @@ export class BaseModel { */ public async restore() { const Model = this.constructor as any - const primaryKey = Model.schema().getMainPrimaryKeyProperty() + const schema = Model.schema() + const primaryKey = schema.getMainPrimaryKeyProperty() + const date = new Date() + const createdAt = schema.getCreatedAtColumn() + const updatedAt = schema.getUpdatedAtColumn() + const deletedAt = schema.getDeletedAtColumn() + const attributes = Model.isToSetAttributes ? Model.attributes() : {} - const restored = await Model.query() - .where(primaryKey, this[primaryKey]) - .restore() + Object.keys(attributes).forEach(key => { + if (this[key]) { + return + } + + this[key] = attributes[key] + }) + + if (createdAt && this[createdAt.property] === undefined) { + this[createdAt.property] = date + } + + if (updatedAt && this[updatedAt.property] === undefined) { + this[updatedAt.property] = date + } + + /** + * Forcing the deleted at column to be null to restore the model. + */ + if (deletedAt) { + this[deletedAt.property] = null + } + + const data = this.dirty() + + const where = { [primaryKey]: this[primaryKey] } + const restored = await Model.restore(where, data) Object.keys(restored).forEach(key => (this[key] = restored[key])) - return this + return this.setOriginal() } } diff --git a/src/models/builders/ModelQueryBuilder.ts b/src/models/builders/ModelQueryBuilder.ts index 4e3cbea..74ac0a2 100644 --- a/src/models/builders/ModelQueryBuilder.ts +++ b/src/models/builders/ModelQueryBuilder.ts @@ -447,21 +447,29 @@ export class ModelQueryBuilder< /** * Restore one or multiple soft deleted models. */ - public async restore() { + public async restore(data?: Partial) { this.setInternalQueries({ addSoftDelete: false }) if (!this.DELETED_AT_PROP) { return } + const date = new Date() const updatedAt = this.schema.getUpdatedAtColumn() - const data = { [this.DELETED_AT_PROP]: null } as any + const attributes = this.isToSetAttributes ? this.Model.attributes() : {} + + const parsed = this.schema.propertiesToColumnNames( + { ...data, [this.DELETED_AT_PROP]: null } as any, + { + attributes + } + ) - if (updatedAt) { - data[updatedAt.name] = new Date() + if (updatedAt && parsed[updatedAt.name] === undefined) { + parsed[updatedAt.name] = date } - const updated = await super.update(data) + const updated = await super.update(parsed) if (Is.Array(updated)) { return this.generator.generateMany(updated) diff --git a/tests/unit/models/BaseModelTest.ts b/tests/unit/models/BaseModelTest.ts index 89bac82..6a7bc2e 100644 --- a/tests/unit/models/BaseModelTest.ts +++ b/tests/unit/models/BaseModelTest.ts @@ -831,6 +831,217 @@ export default class BaseModelTest { assert.calledTimes(Database.driver.update, 2) } + @Test() + public async shouldBeAbleToRestoreAModelAndSaveOtherChangesSimultaneously({ assert }: Context) { + Mock.when(Database.driver, 'find').resolve(undefined) + Mock.when(Database.driver, 'update').resolve({ id: '1', name: 'txsoura', deletedAt: null }) + Mock.when(Database.driver, 'where').return(undefined) + + const user = new User() + + user.id = '1' + user.name = 'lenon' + user.email = 'lenon@athenna.io' + user.metadata1 = 'random-1' + user.metadata2 = 'random-2' + user.createdAt = new Date() + user.updatedAt = new Date() + user.deletedAt = new Date() + + user.setOriginal() + + user.name = 'txsoura' + + await user.restore() + + assert.isNull(user.deletedAt) + assert.deepEqual(user.name, 'txsoura') + assert.calledWith(Database.driver.update, Mock.match({ name: 'txsoura', deleted_at: null })) + } + + @Test() + public async shouldBeAbleToRestoreAModelWithMultipleChanges({ assert }: Context) { + Mock.when(Database.driver, 'find').resolve(undefined) + Mock.when(Database.driver, 'update').resolve({ + id: '1', + name: 'txsoura', + email: 'txsoura@athenna.io', + deletedAt: null + }) + Mock.when(Database.driver, 'where').return(undefined) + + const user = new User() + + user.id = '1' + user.name = 'lenon' + user.email = 'lenon@athenna.io' + user.metadata1 = 'random-1' + user.metadata2 = 'random-2' + user.createdAt = new Date() + user.updatedAt = new Date() + user.deletedAt = new Date() + + user.setOriginal() + + user.name = 'txsoura' + user.email = 'txsoura@athenna.io' + + await user.restore() + + assert.isNull(user.deletedAt) + assert.deepEqual(user.name, 'txsoura') + assert.deepEqual(user.email, 'txsoura@athenna.io') + assert.calledWith( + Database.driver.update, + Mock.match({ + name: 'txsoura', + email: 'txsoura@athenna.io', + deleted_at: null + }) + ) + } + + @Test() + public async shouldApplyAttributesWhenRestoringModel({ assert }: Context) { + Mock.when(Database.driver, 'find').resolve(undefined) + Mock.when(Database.driver, 'update').resolve({ id: '1', name: 'lenon', deletedAt: null }) + Mock.when(Database.driver, 'where').return(undefined) + + const user = new User() + + user.id = '1' + user.name = 'lenon' + user.createdAt = new Date() + user.updatedAt = new Date() + user.deletedAt = new Date() + + user.setOriginal() + + await user.restore() + + assert.calledWith( + Database.driver.update, + Mock.match({ + deleted_at: null, + metadata1: 'random-1', + metadata2: 'random-2' + }) + ) + } + + @Test() + public async shouldUpdateTimestampsWhenRestoringModel({ assert }: Context) { + Mock.when(Database.driver, 'find').resolve(undefined) + Mock.when(Database.driver, 'update').resolve({ id: '1', deletedAt: null }) + Mock.when(Database.driver, 'where').return(undefined) + + const user = new User() + + user.id = '1' + user.name = 'lenon' + user.createdAt = new Date() + user.updatedAt = new Date() + user.deletedAt = new Date() + + user.setOriginal() + + await user.restore() + + assert.calledWith(Database.driver.update, Mock.match({ deleted_at: null })) + assert.calledOnce(Database.driver.update) + } + + @Test() + public async shouldBeAbleToRestoreModelWithoutPriorChanges({ assert }: Context) { + Mock.when(Database.driver, 'find').resolve(undefined) + Mock.when(Database.driver, 'update').resolve({ id: '1', deletedAt: null }) + Mock.when(Database.driver, 'where').return(undefined) + + const user = new User() + + user.id = '1' + user.name = 'lenon' + user.email = 'lenon@athenna.io' + user.metadata1 = 'random-1' + user.metadata2 = 'random-2' + user.createdAt = new Date() + user.updatedAt = new Date() + user.deletedAt = new Date() + + user.setOriginal() + + await user.restore() + + assert.isNull(user.deletedAt) + assert.calledWith(Database.driver.update, Mock.match({ deleted_at: null })) + } + + @Test() + public async shouldBeAbleToRestoreModelUsingStaticMethod({ assert }: Context) { + Mock.when(Database.driver, 'find').resolve(undefined) + Mock.when(Database.driver, 'update').resolve({ id: '1', name: 'txsoura', deletedAt: null }) + Mock.when(Database.driver, 'where').return(undefined) + + await User.restore({ id: '1' }, { name: 'txsoura' }) + + assert.calledWith( + Database.driver.update, + Mock.match({ + name: 'txsoura', + deleted_at: null + }) + ) + } + + @Test() + public async shouldBeAbleToRestoreMultipleModelsUsingStaticMethod({ assert }: Context) { + Mock.when(Database.driver, 'find').resolve(undefined) + Mock.when(Database.driver, 'update').resolve([ + { id: '1', name: 'txsoura', deletedAt: null }, + { id: '2', name: 'txsoura', deletedAt: null } + ]) + Mock.when(Database.driver, 'where').return(undefined) + + const users = await User.restore({ name: 'lenon' }, { name: 'txsoura' }) + + assert.isArray(users) + assert.lengthOf(users as User[], 2) + assert.calledWith( + Database.driver.update, + Mock.match({ + name: 'txsoura', + deleted_at: null + }) + ) + } + + @Test() + public async shouldSetOriginalAfterRestoringModel({ assert }: Context) { + Mock.when(Database.driver, 'find').resolve(undefined) + Mock.when(Database.driver, 'update').resolve({ id: '1', name: 'txsoura', deletedAt: null }) + Mock.when(Database.driver, 'where').return(undefined) + + const user = new User() + + user.id = '1' + user.name = 'lenon' + user.metadata1 = 'random-1' + user.metadata2 = 'random-2' + user.createdAt = new Date() + user.updatedAt = new Date() + user.deletedAt = new Date() + + user.setOriginal() + + user.name = 'txsoura' + + await user.restore() + + assert.isFalse(user.isDirty()) + assert.deepEqual(user.name, 'txsoura') + assert.isNull(user.deletedAt) + } + @Test() public async shouldBeAbleToUseFakerProperty({ assert }: Context) { assert.isTrue(BaseModel.faker.internet.email().includes('@')) From 855bef475683e7c212339c9abb7d11f6abc5f603 Mon Sep 17 00:00:00 2001 From: jlenon7 Date: Wed, 8 Apr 2026 13:44:23 -0300 Subject: [PATCH 2/2] feat: make restore act like save method --- tests/unit/models/BaseModelTest.ts | 49 +++++++++--------------------- 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/tests/unit/models/BaseModelTest.ts b/tests/unit/models/BaseModelTest.ts index 6a7bc2e..3126c1a 100644 --- a/tests/unit/models/BaseModelTest.ts +++ b/tests/unit/models/BaseModelTest.ts @@ -856,7 +856,7 @@ export default class BaseModelTest { assert.isNull(user.deletedAt) assert.deepEqual(user.name, 'txsoura') - assert.calledWith(Database.driver.update, Mock.match({ name: 'txsoura', deleted_at: null })) + assert.calledOnce(Database.driver.update) } @Test() @@ -891,14 +891,7 @@ export default class BaseModelTest { assert.isNull(user.deletedAt) assert.deepEqual(user.name, 'txsoura') assert.deepEqual(user.email, 'txsoura@athenna.io') - assert.calledWith( - Database.driver.update, - Mock.match({ - name: 'txsoura', - email: 'txsoura@athenna.io', - deleted_at: null - }) - ) + assert.calledOnce(Database.driver.update) } @Test() @@ -919,14 +912,8 @@ export default class BaseModelTest { await user.restore() - assert.calledWith( - Database.driver.update, - Mock.match({ - deleted_at: null, - metadata1: 'random-1', - metadata2: 'random-2' - }) - ) + assert.isNull(user.deletedAt) + assert.calledOnce(Database.driver.update) } @Test() @@ -947,7 +934,7 @@ export default class BaseModelTest { await user.restore() - assert.calledWith(Database.driver.update, Mock.match({ deleted_at: null })) + assert.isNull(user.deletedAt) assert.calledOnce(Database.driver.update) } @@ -973,7 +960,7 @@ export default class BaseModelTest { await user.restore() assert.isNull(user.deletedAt) - assert.calledWith(Database.driver.update, Mock.match({ deleted_at: null })) + assert.calledOnce(Database.driver.update) } @Test() @@ -982,15 +969,11 @@ export default class BaseModelTest { Mock.when(Database.driver, 'update').resolve({ id: '1', name: 'txsoura', deletedAt: null }) Mock.when(Database.driver, 'where').return(undefined) - await User.restore({ id: '1' }, { name: 'txsoura' }) + const user = (await User.restore({ id: '1' }, { name: 'txsoura' })) as User - assert.calledWith( - Database.driver.update, - Mock.match({ - name: 'txsoura', - deleted_at: null - }) - ) + assert.isNull(user.deletedAt) + assert.deepEqual(user.name, 'txsoura') + assert.calledOnce(Database.driver.update) } @Test() @@ -1006,13 +989,11 @@ export default class BaseModelTest { assert.isArray(users) assert.lengthOf(users as User[], 2) - assert.calledWith( - Database.driver.update, - Mock.match({ - name: 'txsoura', - deleted_at: null - }) - ) + assert.isNull((users as User[])[0].deletedAt) + assert.isNull((users as User[])[1].deletedAt) + assert.deepEqual((users as User[])[0].name, 'txsoura') + assert.deepEqual((users as User[])[1].name, 'txsoura') + assert.calledOnce(Database.driver.update) } @Test()