RESTier icon indicating copy to clipboard operation
RESTier copied to clipboard

Is there a way to perform a "soft delete"?

Open peder opened this issue 8 years ago • 16 comments

I'm looking for guidance on overriding the default delete behavior to perform a soft delete / updating a "Deleted" flag instead of executing a DELETE command.

peder avatar Jan 25 '17 16:01 peder

There might be a better way than this, but I would think that one way to do it would be the following:

  • (Optional) Hide the "Deleted" flag from the model with the ModelBuilder.
  • Create an OnDeletingXXXXX function that does the following:   - Sets the Deleted flag on the incoming object.   - Saves the object to the database.
    • Throws an informative exception that says the operation completed.
  • (Optional) Create an OnFilterXXXXX function that does .Where(c => c.IsDeleted == false);

Sure, the exception kinda sucks, but it could be documented in your API. to look for a message in the return saying the operation completed successfully.

I actually need to make one of my APIs do this too, so if I figure out a better way, I'll post it here. And maybe someone else from the team can jump in.  

robertmclaws avatar Jan 25 '17 17:01 robertmclaws

You might be able to proxy the delete calls and handle the exception. I'm thinking something like set up a generic route /api/ that routes to /odata/, where /api/ = proxy and /odata/ = RESTier. The proxy could use something simple like Server.Transfer(toProxy) to direct the calls to the real delete and catch the custom exception.

There might also be a simple way to trap the exceptions in Global.asax by clearing the exception and changing the response status code.

peder avatar Feb 01 '17 02:02 peder

So, I hate myself for this, but I got the exception-handling piece of this wired up.

In my DatabaseDomain.cs file, I have something like:

protected void OnDeletingRelatedDocuments(RelatedDocument entity)
{
	HttpContext.Current.Response.StatusCode = 204;
	HttpContext.Current.Response.End();
	throw new SoftDeleteException();
}

That SoftDeleteException type can be added to the Visual Studio ignored exceptions list.

Then in my Global.asax file (what is this, 2005?), I suppress the resulting HttpException:

public void Application_Error(object sender, EventArgs e)
{
	var exception = Server.GetLastError();

	if (exception != null && exception is HttpException && exception.Message == "Server cannot set status after HTTP headers have been sent.")
			Server.ClearError();
}

Fairly simple. Now I can implement all sorts of business rules on inserts, updates, or deletes without RESTier executing any SQL queries. Good for soft deletes or silently failing if a user tries to insert a duplicate record.

peder avatar Mar 07 '17 23:03 peder

So I kept digging on this. Apparently RESTier has already deleted the entity by the time the OnDeletingEntity method has been called, but you can revert the DbContext's changes then add your own soft-delete logic. The revised OnDeletingEntity method:

protected void OnDeletingRelatedDocuments(RelatedDocument entity)
{
	this.RejectChanges(this.DbContext);
	entity.Deleted = true;
}

RejectChanges is an implementation borrowed from StackOverflow.

private void RejectChanges(DbContext dbContext)
{
	RejectScalarChanges(dbContext);
	RejectNavigationChanges(dbContext);
}

private void RejectScalarChanges(DbContext dbContext)
{
	foreach (var entry in dbContext.ChangeTracker.Entries())
	{
		switch (entry.State)
		{
			case EntityState.Modified:
			case EntityState.Deleted:
				entry.State = EntityState.Modified; //Revert changes made to deleted entity.
				entry.State = EntityState.Unchanged;
				break;
			case EntityState.Added:
				entry.State = EntityState.Detached;
				break;
		}
	}
}

private void RejectNavigationChanges(DbContext dbContext)
{
	var objectContext = ((IObjectContextAdapter)dbContext).ObjectContext;
	var deletedRelationships = objectContext.ObjectStateManager.GetObjectStateEntries(EntityState.Deleted).Where(e => e.IsRelationship && !this.RelationshipContainsKeyEntry(e));
	var addedRelationships = objectContext.ObjectStateManager.GetObjectStateEntries(EntityState.Added).Where(e => e.IsRelationship);

	foreach (var relationship in addedRelationships)
		relationship.Delete();

	foreach (var relationship in deletedRelationships)
		relationship.ChangeState(EntityState.Unchanged);
}

private bool RelationshipContainsKeyEntry(System.Data.Entity.Core.Objects.ObjectStateEntry stateEntry)
{
	//prevent exception: "Cannot change state of a relationship if one of the ends of the relationship is a KeyEntry"
	//I haven't been able to find the conditions under which this happens, but it sometimes does.
	var objectContext = ((IObjectContextAdapter)this).ObjectContext;
	var keys = new[] { stateEntry.OriginalValues[0], stateEntry.OriginalValues[1] };
	return keys.Any(key => objectContext.ObjectStateManager.GetObjectStateEntry(key).Entity == null);
}

This solution has no throw exceptions, it's repeatable without a ton of domain-specific code, and it still returns the expected HTTP response codes!

peder avatar Mar 07 '17 23:03 peder

I created a gist for all the reject-changes that creates an extension method on the DbContext object, allowing a RESTier API object to be even cleaner.

peder avatar Mar 07 '17 23:03 peder

AFAIK you need to be careful on that RejectNavigationChanges method. If you're in the context of a transaction, there may be more entities in there than you'd like. I don't think you can just assume all of the entities are going back to the "unchanged" state.

robertmclaws avatar Mar 08 '17 02:03 robertmclaws

Hi, you can use custom SubmitExecutor, documentation is lacking some features, but you can find something similar here.

In my case I've used a very basic, but working class, code in this gist

fileman avatar Mar 19 '17 11:03 fileman

So I've built on @fileman's work and enhanced his version with some fluent configuration options. You can check it out here. If @fileman is OK with it, I'll publish it to NuGet ASAP.

robertmclaws avatar May 07 '17 02:05 robertmclaws

Hi, in my project even admin execute only softdelete and there is foreach entity an action that execute harddelete of softdeleted entities in context. With your version if no "adminRole" was passed in config no hard delete is execute even if the user is admin, right? I think is a good enhancement to publish

fileman avatar May 09 '17 11:05 fileman

Right, if you don't pass an admin role into the config, then admins can still soft-delete. If you DO specify a role, then admins will need to soft delete by passing a PUT and changing IsDeleted themselves, instead of a DELETE.

robertmclaws avatar May 09 '17 17:05 robertmclaws

@robertmclaws what do you think about SoftDelete feature, built-in or add as example in documentation?

fileman avatar Dec 16 '18 10:12 fileman

@fileman I used your initial code a while back and fleshed it out some more. You can find it here. I was planning on bringing it into the Restier codebase before RTM. Do you have any objections to that?

robertmclaws avatar Dec 16 '18 11:12 robertmclaws

For first release it can be ok, but for future instead of "adminRole" why not use permissions? Since even other role can have permissions to hard delete, I can try to work on it and submit a PR but not soon… can be usefull?

fileman avatar Dec 16 '18 12:12 fileman

Let's elaborate on this a little bit. What role would be able to hard-delete an item that wouldn't be handled already by another method?

robertmclaws avatar Dec 16 '18 16:12 robertmclaws

I'll try to explain what I mean Since I'm coming from LightSwitch experience where multiple roles can have permission to hard-delete, could be better to use something similar is in your CloudNimble.RestierEssentials.Authorization Example, if the user doesn't have "HardDelete" permission SubmitExecutor use softdelete … we should check if entity has a conventional "Deleted" or "SoftDeleted" property.

fileman avatar Dec 16 '18 16:12 fileman

I'm thinking that it may be better to make this more generic. I have some ideas... once I'm finished getting Beta 2 locked down, I'll see if I can get some ideas down and we can put a quick spec together.

Thanks!

robertmclaws avatar Dec 17 '18 01:12 robertmclaws

I added this feature to RestierEssentials a number of years ago and shipped a corresponding NuGet package. I haven't updated it for ASP.NET Core, but if it's needed, just let me know.

We'll likely build this into Restier 2.0.

Thanks everyone for your contribution to the discussion!

robertmclaws avatar Dec 05 '23 18:12 robertmclaws