From 5a74699fbc282014d7bd4d855a4c4a6bfaadc135 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 | 137 ++++++++++++++++-- models/QuickBuilder.cfc | 63 ++++++++ tests/resources/app/models/SoftDeleteUser.cfc | 13 ++ .../BaseEntity/SoftDeletesSpec.cfc | 37 +++++ 4 files changed, 238 insertions(+), 12 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 8900ec48..1488ebb6 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. */ @@ -290,6 +306,8 @@ component accessors="true" { param variables._loadChildren = true; param variables._queryOptions = {}; param variables._dispatchesEvents = {}; + param variables._softDeletes = false; + param variables._softDeleteColumn = "deletedAt"; param variables._attributes = {}; param variables._columns = {}; param variables._virtualAttributes = []; @@ -1377,7 +1395,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 ); @@ -1397,7 +1448,9 @@ component accessors="true" { } variables._loaded = false; - fireEvent( "postDelete", { entity : this } ); + if ( arguments.fireEvents ) { + fireEvent( "postDelete", { entity : this } ); + } return this; } @@ -2872,6 +2925,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 @@ -3007,17 +3106,23 @@ 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.localMetadata.properties = []; + 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.localMetadata.properties = []; guardDuplicatePropertyNames( meta.localMetadata, meta.mapping ); - 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; @@ -3130,7 +3235,9 @@ 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 ( server.keyExists( "boxlang" ) ) { for ( @@ -3144,6 +3251,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 3ae54ed1..71f48f49 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -547,6 +547,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 ) { @@ -1611,6 +1652,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 ); } @@ -1620,6 +1667,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 f2bb9800b6651419ffc57a0bbf357d0f92f12f56 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 | 58 +++++++++---------- models/QuickBuilder.cfc | 25 ++++---- tests/resources/app/models/SoftDeleteUser.cfc | 5 +- 3 files changed, 44 insertions(+), 44 deletions(-) diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 1488ebb6..4a9a604b 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -1396,16 +1396,15 @@ component accessors="true" { ); 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 } ); + 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 } ); @@ -2957,15 +2956,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; @@ -3106,23 +3104,23 @@ 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; - param meta.localMetadata.properties = []; + 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.localMetadata.properties = []; guardDuplicatePropertyNames( meta.localMetadata, meta.mapping ); 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; + meta[ "softDeleteColumn" ] = meta.originalMetadata.softDeleteColumn; param meta.originalMetadata.joincolumn = ""; param meta.originalMetadata.discriminatorValue = ""; param meta.originalMetadata.singleTableInheritance = false; diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 71f48f49..88450335 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -549,19 +549,18 @@ component accessors="true" transientCache="false" { 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 ] ); - } ); - } ); - } ); - } ); + var idsQuery = variables.qb.forNestedWhere(); + var keyNames = getEntity().keyNames(); + for ( var idIndex = 1; idIndex <= arguments.ids.len(); idIndex++ ) { + var values = arrayWrap( arguments.ids[ idIndex ] ); + getEntity().guardAgainstKeyLengthMismatch( values ); + var idQuery = variables.qb.forNestedWhere(); + for ( var keyIndex = 1; keyIndex <= keyNames.len(); keyIndex++ ) { + idQuery.where( keyNames[ keyIndex ], values[ keyIndex ] ); + } + idsQuery.addNestedWhereQuery( idQuery, idIndex == 1 ? "and" : "or" ); + } + variables.qb.addNestedWhereQuery( idsQuery, "and" ); } if ( getEntity().usesSoftDeletes() ) { activateGlobalScopes(); 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"; }