diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index 4836503f..39de7573 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 ="deletedDate" + persistent="false"; + /** * The primary key name for the entity. */ @@ -318,6 +334,8 @@ component accessors="true" { param variables._discriminatorValue = ""; param variables._hasDiscriminatorValue = false; param variables._singleTableInheritance = false; + param variables._softDeletes = false; + param variables._softDeleteColumn = "deletedDate"; variables._saving = false; return this; } @@ -1791,14 +1809,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 = retrieveSoftDeleteColumn(); + var deletedDate = 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#" : deletedDate } ); + assignAttribute( column, deletedDate ); + 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() ) { @@ -1813,7 +1860,9 @@ component accessors="true" { } variables._loaded = false; - fireEvent( "postDelete", { entity : this } ); + if ( arguments.fireEvents ) { + fireEvent( "postDelete", { entity : this } ); + } return this; } @@ -3458,6 +3507,42 @@ 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 retrieveSoftDeleteColumn() { + return variables._softDeleteColumn; + } + + /** + * Returns whether this entity has been soft deleted. + */ + public boolean function isTrashed() { + return usesSoftDeletes() && !isNullAttribute( retrieveSoftDeleteColumn() ); + } + + /** + * 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 = retrieveSoftDeleteColumn(); + return update( { "#column#" : "" } ); + } + /** * If the quickbuilder instance exists return it, else create it, cache it and return it @@ -3599,10 +3684,16 @@ component accessors="true" { meta[ "entityName" ] = meta.originalMetadata.entityName; 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.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 = "deletedDate"; + 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; @@ -3719,6 +3810,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; @@ -3767,6 +3860,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 23983303..4d6b0308 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -701,6 +701,47 @@ component accessors="true" transientCache="false" { } variables.qb.addNestedWhereQuery( idConstraints ); } + if ( getEntity().usesSoftDeletes() ) { + activateGlobalScopes(); + return updateAll( { "#getEntity().retrieveSoftDeleteColumn()#" : 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().retrieveSoftDeleteColumn()#" : "" } ); + } + + /** + * 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(); } @@ -1766,6 +1807,12 @@ component accessors="true" transientCache="false" { variables._applyingGlobalScopes = true; if ( !variables._globalScopeExcludeAll ) { + if ( + getEntity().usesSoftDeletes() && + !variables._globalScopeExclusions.contains( "softdeletes" ) + ) { + variables.qb.whereNull( getEntity().retrieveSoftDeleteColumn() ); + } getEntity().applyGlobalScopes( this ); } @@ -1775,6 +1822,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().retrieveSoftDeleteColumn() ); + 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..37bbbd95 --- /dev/null +++ b/tests/resources/app/models/SoftDeleteUser.cfc @@ -0,0 +1,19 @@ +component + extends ="quick.models.BaseEntity" + accessors ="true" + table ="users" + softDeletes ="true" +{ + + property name="id"; + property name="username"; + property + name ="deletedDate" + column="email" + insert="false"; + + function postUpdate() { + request.softDeleteUserPostUpdateCalled = true; + } + +} diff --git a/tests/specs/integration/BaseEntity/SoftDeletesSpec.cfc b/tests/specs/integration/BaseEntity/SoftDeletesSpec.cfc new file mode 100644 index 00000000..2b30768c --- /dev/null +++ b/tests/specs/integration/BaseEntity/SoftDeletesSpec.cfc @@ -0,0 +1,40 @@ +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 ); + expect( user.retrieveSoftDeleteColumn() ).toBe( "deletedDate" ); + + user.delete(); + + expect( user.isLoaded() ).toBeTrue(); + expect( user.isTrashed() ).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.isTrashed() ).toBeTrue(); + structDelete( request, "softDeleteUserPostUpdateCalled" ); + trashedUser.restore(); + + expect( trashedUser.isTrashed() ).toBeFalse(); + expect( request ).toHaveKey( "softDeleteUserPostUpdateCalled" ); + 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(); + } ); + } ); + } + +}