diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 8900ec48..4a9a604b 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,39 @@ component accessors="true" { "Did you maybe mean to use `deleteAll`?" ); + 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 } ); + } + newQuery() + .withoutGlobalScope( "softDeletes" ) .where( function( q ) { arrayZipEach( [ keyNames(), keyValues() ], function( keyName, keyValue ) { q.where( keyName, keyValue ); @@ -1397,7 +1447,9 @@ component accessors="true" { } variables._loaded = false; - fireEvent( "postDelete", { entity : this } ); + if ( arguments.fireEvents ) { + fireEvent( "postDelete", { entity : this } ); + } return this; } @@ -2872,6 +2924,51 @@ 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(); + 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; + } + /** * If the quickbuilder instance exists return it, else create it, cache it and return it @@ -3012,12 +3109,18 @@ component accessors="true" { 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 +3233,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 +3249,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..88450335 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -547,6 +547,46 @@ component accessors="true" transientCache="false" { * @return { "query": QueryBuilder Return Format, "result": struct } */ public struct function deleteAll( array ids = [] ) { + getEntity().guardReadOnly(); + if ( !arrayIsEmpty( arguments.ids ) ) { + 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(); + 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 +1651,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 +1666,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..d4fa4d73 --- /dev/null +++ b/tests/resources/app/models/SoftDeleteUser.cfc @@ -0,0 +1,16 @@ +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(); + } ); + } ); + } + +}