From bca9eff3074b52657bdf50a695863805a796cbd7 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 22 Aug 2026 06:17:00 -0600 Subject: [PATCH 1/2] Add built-in soft delete support Closes #54 --- models/BaseEntity.cfc | 125 ++++++++++++++++-- models/QuickBuilder.cfc | 63 +++++++++ tests/resources/app/models/SoftDeleteUser.cfc | 13 ++ .../BaseEntity/SoftDeletesSpec.cfc | 37 ++++++ 4 files changed, 230 insertions(+), 8 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 c900d867..3c5c2606 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -89,6 +89,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. */ @@ -308,6 +324,8 @@ component accessors="true" { param variables._discriminatorValue = ""; param variables._hasDiscriminatorValue = false; param variables._singleTableInheritance = false; + param variables._softDeletes = false; + param variables._softDeleteColumn = "deletedAt"; variables._saving = false; return this; } @@ -1537,14 +1555,43 @@ component accessors="true" { "Did you maybe mean to use `deleteAll`?" ); - var deleteQuery = newQuery(); - var entityKeyNames = keyNames(); - var entityKeyValues = keyValues(); - var deleteConstraints = deleteQuery.getQB().forNestedWhere(); - for ( var i = 1; i <= entityKeyNames.len(); i++ ) { - deleteConstraints.where( entityKeyNames[ i ], entityKeyValues[ i ] ); + if ( usesSoftDeletes() ) { + var column = getSoftDeleteColumn(); + var deletedAt = now(); + var deleteQuery = newQuery().withoutGlobalScope( "softDeletes" ); + var entityKeys = keyNames(); + var entityValues = keyValues(); + for ( var i = 1; i <= entityKeys.len(); i++ ) { + deleteQuery.where( entityKeys[ i ], entityValues[ i ] ); + } + deleteQuery.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 } ); + } + + var deleteQuery = newQuery().withoutGlobalScope( "softDeletes" ); + var entityKeys = keyNames(); + var entityValues = keyValues(); + for ( var i = 1; i <= entityKeys.len(); i++ ) { + deleteQuery.where( entityKeys[ i ], entityValues[ i ] ); } - deleteQuery.getQB().addNestedWhereQuery( deleteConstraints ); deleteQuery.delete(); if ( hasParentEntity() ) { @@ -1559,7 +1606,9 @@ component accessors="true" { } variables._loaded = false; - fireEvent( "postDelete", { entity : this } ); + if ( arguments.fireEvents ) { + fireEvent( "postDelete", { entity : this } ); + } return this; } @@ -3114,6 +3163,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 @@ -3259,6 +3354,12 @@ component accessors="true" { 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; @@ -3375,6 +3476,8 @@ component accessors="true" { variables._queryOptions = { datasource : variables._meta.originalMetadata.datasource }; } variables._readonly = variables._meta.readonly; + variables._softDeletes = variables._meta.softDeletes; + variables._softDeleteColumn = variables._meta.softDeleteColumn; variables._attributes = variables._meta.attributes; variables._columns = variables._meta.columns; variables._functionNames = variables._meta.functionNames; @@ -3420,6 +3523,12 @@ component accessors="true" { } } } + 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 16f392d6..ce69cab0 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -596,6 +596,47 @@ component accessors="true" transientCache="false" { } variables.qb.addNestedWhereQuery( idConstraints ); } + 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 ) { + 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 ] ); + } ); + } ); + } ); + } ); + } return variables.qb.delete(); } @@ -1655,6 +1696,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 ); } @@ -1664,6 +1711,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(); + } ); + } ); + } + +} From 314de6060cc528e074a26e364b305faccf9c8acc Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 25 Aug 2026 13:45:19 -0600 Subject: [PATCH 2/2] refactor: avoid internal closures --- models/BaseEntity.cfc | 17 ++++++++--------- tests/resources/app/models/SoftDeleteUser.cfc | 5 ++++- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 3c5c2606..8ecd2d00 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -3195,15 +3195,14 @@ component accessors="true" { ); } 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#" : "" } ); + var column = getSoftDeleteColumn(); + var restoreQuery = newQuery().withoutGlobalScope( "softDeletes" ); + var entityKeys = keyNames(); + var entityValues = keyValues(); + for ( var i = 1; i <= entityKeys.len(); i++ ) { + restoreQuery.where( entityKeys[ i ], entityValues[ i ] ); + } + restoreQuery.updateAll( { "#column#" : "" } ); clearAttribute( column ); assignOriginalAttributes( retrieveAttributesData() ); return this; diff --git a/tests/resources/app/models/SoftDeleteUser.cfc b/tests/resources/app/models/SoftDeleteUser.cfc index 4fd1c77f..d4fa4d73 100644 --- a/tests/resources/app/models/SoftDeleteUser.cfc +++ b/tests/resources/app/models/SoftDeleteUser.cfc @@ -8,6 +8,9 @@ component property name="id"; property name="username"; - property name="deletedAt" column="email" insert="false"; + property + name ="deletedAt" + column="email" + insert="false"; }