Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 124 additions & 11 deletions models/BaseEntity.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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 );
Expand All @@ -1288,7 +1339,9 @@ component accessors="true" {
}

variables._loaded = false;
fireEvent( "postDelete", { entity : this } );
if ( arguments.fireEvents ) {
fireEvent( "postDelete", { entity : this } );
}
return this;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
63 changes: 63 additions & 0 deletions models/QuickBuilder.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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 );
}

Expand All @@ -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.
*
Expand Down
13 changes: 13 additions & 0 deletions tests/resources/app/models/SoftDeleteUser.cfc
Original file line number Diff line number Diff line change
@@ -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";

}
37 changes: 37 additions & 0 deletions tests/specs/integration/BaseEntity/SoftDeletesSpec.cfc
Original file line number Diff line number Diff line change
@@ -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();
} );
} );
}

}
Loading