From 73004b98a3bd32203c7e2821f8216be1984d2b0e Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 06:17:00 -0600 Subject: [PATCH] Add built-in soft delete support Closes #54 --- models/BaseEntity.cfc | 135 ++++++++++++++++-- models/QuickBuilder.cfc | 63 ++++++++ tests/resources/app/models/SoftDeleteUser.cfc | 13 ++ .../BaseEntity/SoftDeletesSpec.cfc | 37 +++++ 4 files changed, 237 insertions(+), 11 deletions(-) create mode 100644 tests/resources/app/models/SoftDeleteUser.cfc create mode 100644 tests/specs/integration/BaseEntity/SoftDeletesSpec.cfc diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index a3f7a943..a12b910f 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -84,6 +84,22 @@ component accessors="true" { default ="false" persistent="false"; + /** + * Whether this entity uses soft deletes and the attribute that stores the deletion timestamp. + */ + property + name ="_softDeletes" + default ="false" + persistent="false"; + + /** + * The attribute that stores the soft-delete timestamp. + */ + property + name ="_softDeleteColumn" + default ="deletedAt" + persistent="false"; + /** * The primary key name for the entity. */ @@ -277,6 +293,8 @@ component accessors="true" { param variables._discriminators = []; param variables._loadChildren = true; param variables._queryOptions = {}; + param variables._softDeletes = false; + param variables._softDeleteColumn = "deletedAt"; param variables._attributes = {}; param variables._columns = {}; param variables._virtualAttributes = []; @@ -1268,7 +1286,40 @@ component accessors="true" { "Did you maybe mean to use `deleteAll`?" ); + if ( usesSoftDeletes() ) { + var column = getSoftDeleteColumn(); + var deletedAt = now(); + newQuery() + .withoutGlobalScope( "softDeletes" ) + .where( function( q ) { + arrayZipEach( [ keyNames(), keyValues() ], function( keyName, keyValue ) { + q.where( keyName, keyValue ); + } ); + } ) + .updateAll( { "#column#" : deletedAt } ); + assignAttribute( column, deletedAt ); + assignOriginalAttributes( retrieveAttributesData() ); + fireEvent( "postDelete", { entity : this } ); + return this; + } + + forceDelete( fireEvents = false ); + fireEvent( "postDelete", { entity : this } ); + return this; + } + + /** + * Permanently deletes a loaded entity, bypassing soft deletes. + */ + public any function forceDelete( boolean fireEvents = true ) { + guardReadOnly(); + guardAgainstNotLoaded( "This instance is not loaded so it cannot be force deleted." ); + if ( arguments.fireEvents ) { + fireEvent( "preDelete", { entity : this } ); + } + newQuery() + .withoutGlobalScope( "softDeletes" ) .where( function( q ) { arrayZipEach( [ keyNames(), keyValues() ], function( keyName, keyValue ) { q.where( keyName, keyValue ); @@ -1288,7 +1339,9 @@ component accessors="true" { } variables._loaded = false; - fireEvent( "postDelete", { entity : this } ); + if ( arguments.fireEvents ) { + fireEvent( "postDelete", { entity : this } ); + } return this; } @@ -2626,6 +2679,52 @@ component accessors="true" { return this; } + /** + * Returns whether this entity is configured to use soft deletes. + */ + public boolean function usesSoftDeletes() { + return variables._softDeletes; + } + + /** + * Returns the entity attribute that stores the soft-delete timestamp. + */ + public string function getSoftDeleteColumn() { + return variables._softDeleteColumn; + } + + /** + * Returns whether this entity has been soft deleted. + */ + public boolean function trashed() { + return usesSoftDeletes() && !isNullAttribute( getSoftDeleteColumn() ); + } + + /** + * Restores a soft-deleted entity. + */ + public any function restore() { + if ( !usesSoftDeletes() ) { + throw( + type = "QuickSoftDeletesNotEnabled", + message = "[#entityName()#] is not configured to use soft deletes." + ); + } + guardAgainstNotLoaded( "This instance is not loaded so it cannot be restored." ); + var column = getSoftDeleteColumn(); + newQuery() + .withoutGlobalScope( "softDeletes" ) + .where( function( q ) { + arrayZipEach( [ keyNames(), keyValues() ], function( keyName, keyValue ) { + q.where( keyName, keyValue ); + } ); + } ) + .updateAll( { "#column#" : "" } ); + clearAttribute( column ); + assignOriginalAttributes( retrieveAttributesData() ); + return this; + } + /** * If the quickbuilder instance exists return it, else create it, cache it and return it @@ -2757,15 +2856,21 @@ component accessors="true" { message = 'This instance is missing `accessors="true"` in the component metadata. This is required for Quick to work properly. Please add it to your component metadata and reinit your application.' ); } - meta[ "fullName" ] = meta.originalMetadata.fullname; - param meta.originalMetadata.mapping = listLast( meta.originalMetadata.fullname, "." ); - meta[ "mapping" ] = meta.originalMetadata.mapping; - param meta.originalMetadata.entityName = listLast( meta.originalMetadata.name, "." ); - meta[ "entityName" ] = meta.originalMetadata.entityName; - param meta.originalMetadata.table = variables._str.plural( variables._str.snake( meta.entityName ) ); - meta[ "table" ] = meta.originalMetadata.table; - param meta.originalMetadata.readonly = false; - meta[ "readonly" ] = meta.originalMetadata.readonly; + meta[ "fullName" ] = meta.originalMetadata.fullname; + param meta.originalMetadata.mapping = listLast( meta.originalMetadata.fullname, "." ); + meta[ "mapping" ] = meta.originalMetadata.mapping; + param meta.originalMetadata.entityName = listLast( meta.originalMetadata.name, "." ); + meta[ "entityName" ] = meta.originalMetadata.entityName; + param meta.originalMetadata.table = variables._str.plural( variables._str.snake( meta.entityName ) ); + meta[ "table" ] = meta.originalMetadata.table; + param meta.originalMetadata.readonly = false; + meta[ "readonly" ] = meta.originalMetadata.readonly; + param meta.originalMetadata.softDeletes = false; + param meta.originalMetadata.softDeleteColumn = "deletedAt"; + meta[ "softDeletes" ] = isBoolean( meta.originalMetadata.softDeletes ) + ? meta.originalMetadata.softDeletes + : lCase( trim( meta.originalMetadata.softDeletes & "" ) ) == "true"; + meta[ "softDeleteColumn" ] = meta.originalMetadata.softDeleteColumn; param meta.originalMetadata.joincolumn = ""; param meta.originalMetadata.discriminatorValue = ""; param meta.originalMetadata.singleTableInheritance = false; @@ -2860,8 +2965,16 @@ component accessors="true" { if ( variables._queryOptions.isEmpty() && variables._meta.originalMetadata.keyExists( "datasource" ) ) { variables._queryOptions = { datasource : variables._meta.originalMetadata.datasource }; } - variables._readonly = variables._meta.readonly; + variables._readonly = variables._meta.readonly; + variables._softDeletes = variables._meta.softDeletes; + variables._softDeleteColumn = variables._meta.softDeleteColumn; explodeAttributesMetadata( variables._meta.attributes ); + if ( variables._softDeletes && !hasAttribute( variables._softDeleteColumn ) ) { + throw( + type = "QuickSoftDeleteColumnNotFound", + message = "The soft delete attribute [#variables._softDeleteColumn#] was not found on [#entityName()#]." + ); + } variables._casts = variables._meta.casts; } diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 3e0d798e..26b41720 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -500,6 +500,47 @@ component accessors="true" transientCache="false" { * @return { "query": QueryBuilder Return Format, "result": struct } */ public struct function deleteAll( array ids = [] ) { + getEntity().guardReadOnly(); + if ( !arrayIsEmpty( arguments.ids ) ) { + variables.qb.where( function( q1 ) { + ids.each( function( id ) { + var values = arrayWrap( id ); + getEntity().guardAgainstKeyLengthMismatch( values ); + q1.orWhere( function( q2 ) { + getEntity() + .keyNames() + .each( function( keyName, i ) { + q2.where( keyName, values[ i ] ); + } ); + } ); + } ); + } ); + } + if ( getEntity().usesSoftDeletes() ) { + activateGlobalScopes(); + return updateAll( { "#getEntity().getSoftDeleteColumn()#" : now() } ); + } + return variables.qb.delete(); + } + + /** + * Restores all soft-deleted entities matching the configured query. + */ + public struct function restoreAll() { + if ( !getEntity().usesSoftDeletes() ) { + throw( + type = "QuickSoftDeletesNotEnabled", + message = "[#getEntity().entityName()#] is not configured to use soft deletes." + ); + } + withoutGlobalScope( "softDeletes" ); + return updateAll( { "#getEntity().getSoftDeleteColumn()#" : "" } ); + } + + /** + * Permanently deletes all entities matching the configured query. + */ + public struct function forceDeleteAll( array ids = [] ) { getEntity().guardReadOnly(); if ( !arrayIsEmpty( arguments.ids ) ) { variables.qb.where( function( q1 ) { @@ -1436,6 +1477,12 @@ component accessors="true" transientCache="false" { variables._applyingGlobalScopes = true; if ( !variables._globalScopeExcludeAll ) { + if ( + getEntity().usesSoftDeletes() && + !variables._globalScopeExclusions.contains( "softdeletes" ) + ) { + variables.qb.whereNull( getEntity().getSoftDeleteColumn() ); + } getEntity().applyGlobalScopes( this ); } @@ -1445,6 +1492,22 @@ component accessors="true" transientCache="false" { return this; } + /** + * Includes soft-deleted entities in this query. + */ + public any function withTrashed() { + return withoutGlobalScope( "softDeletes" ); + } + + /** + * Restricts this query to only soft-deleted entities. + */ + public any function onlyTrashed() { + withoutGlobalScope( "softDeletes" ); + variables.qb.whereNotNull( getEntity().getSoftDeleteColumn() ); + return this; + } + /** * Allows a query to override one or more global scopes for one execution. * diff --git a/tests/resources/app/models/SoftDeleteUser.cfc b/tests/resources/app/models/SoftDeleteUser.cfc new file mode 100644 index 00000000..4fd1c77f --- /dev/null +++ b/tests/resources/app/models/SoftDeleteUser.cfc @@ -0,0 +1,13 @@ +component + extends ="quick.models.BaseEntity" + accessors ="true" + table ="users" + softDeletes ="true" + softDeleteColumn="deletedAt" +{ + + property name="id"; + property name="username"; + property name="deletedAt" column="email" insert="false"; + +} diff --git a/tests/specs/integration/BaseEntity/SoftDeletesSpec.cfc b/tests/specs/integration/BaseEntity/SoftDeletesSpec.cfc new file mode 100644 index 00000000..ee9ab4d0 --- /dev/null +++ b/tests/specs/integration/BaseEntity/SoftDeletesSpec.cfc @@ -0,0 +1,37 @@ +component extends="tests.resources.ModuleIntegrationSpec" { + + function run() { + describe( "Soft Deletes", function() { + it( "can soft delete, query, restore, and force delete entities", function() { + var user = getInstance( "SoftDeleteUser" ).findOrFail( 1 ); + + user.delete(); + + expect( user.isLoaded() ).toBeTrue(); + expect( user.trashed() ).toBeTrue(); + expect( getInstance( "SoftDeleteUser" ).find( 1 ) ).toBeNull(); + expect( getInstance( "SoftDeleteUser" ).all() ).toHaveLength( 4 ); + expect( getInstance( "SoftDeleteUser" ).withTrashed().all() ).toHaveLength( 5 ); + expect( getInstance( "SoftDeleteUser" ).onlyTrashed().count() ).toBe( 1 ); + + var trashedUser = getInstance( "SoftDeleteUser" ).withTrashed().findOrFail( 1 ); + expect( trashedUser.trashed() ).toBeTrue(); + trashedUser.restore(); + + expect( trashedUser.trashed() ).toBeFalse(); + expect( getInstance( "SoftDeleteUser" ).findOrFail( 1 ).getUsername() ).toBe( "elpete" ); + + getInstance( "SoftDeleteUser" ).where( "id", 2 ).deleteAll(); + expect( getInstance( "SoftDeleteUser" ).find( 2 ) ).toBeNull(); + getInstance( "SoftDeleteUser" ).onlyTrashed().restoreAll(); + expect( getInstance( "SoftDeleteUser" ).findOrFail( 2 ).getUsername() ).toBe( "johndoe" ); + getInstance( "SoftDeleteUser" ).where( "id", 2 ).forceDeleteAll(); + expect( getInstance( "SoftDeleteUser" ).withTrashed().find( 2 ) ).toBeNull(); + + trashedUser.forceDelete(); + expect( getInstance( "SoftDeleteUser" ).withTrashed().find( 1 ) ).toBeNull(); + } ); + } ); + } + +}