Skip to content

Commit 2a4d129

Browse files
author
Bart Koelman
committed
Improved support for soft-deletion
1 parent dac0c89 commit 2a4d129

File tree

9 files changed

+977
-240
lines changed

9 files changed

+977
-240
lines changed

src/JsonApiDotNetCore/Services/JsonApiResourceService.cs

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,7 @@ public virtual async Task<TResource> GetAsync(TId id, CancellationToken cancella
103103

104104
_hookExecutor.BeforeReadSingle<TResource, TId>(id, ResourcePipeline.GetSingle);
105105

106-
TResource primaryResource = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.PreserveExisting, cancellationToken);
107-
AssertPrimaryResourceExists(primaryResource);
106+
TResource primaryResource = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.PreserveExisting, cancellationToken);
108107

109108
_hookExecutor.AfterReadSingle(primaryResource, ResourcePipeline.GetSingle);
110109
_hookExecutor.OnReturnSingle(primaryResource, ResourcePipeline.GetSingle);
@@ -225,10 +224,7 @@ public virtual async Task<TResource> CreateAsync(TResource resource, Cancellatio
225224
throw;
226225
}
227226

228-
TResource resourceFromDatabase =
229-
await TryGetPrimaryResourceByIdAsync(resourceForDatabase.Id, TopFieldSelection.WithAllAttributes, cancellationToken);
230-
231-
AssertPrimaryResourceExists(resourceFromDatabase);
227+
TResource resourceFromDatabase = await GetPrimaryResourceByIdAsync(resourceForDatabase.Id, TopFieldSelection.WithAllAttributes, cancellationToken);
232228

233229
await _resourceDefinitionAccessor.OnAfterCreateResourceAsync(resourceFromDatabase, cancellationToken);
234230

@@ -247,13 +243,14 @@ public virtual async Task<TResource> CreateAsync(TResource resource, Cancellatio
247243
return resourceFromDatabase;
248244
}
249245

250-
private async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource resource, CancellationToken cancellationToken)
246+
protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource primaryResource, CancellationToken cancellationToken)
251247
{
252248
var missingResources = new List<MissingResourceInRelationship>();
253249

254-
foreach ((QueryLayer queryLayer, RelationshipAttribute relationship) in _queryLayerComposer.ComposeForGetTargetedSecondaryResourceIds(resource))
250+
foreach ((QueryLayer queryLayer, RelationshipAttribute relationship) in _queryLayerComposer.ComposeForGetTargetedSecondaryResourceIds(
251+
primaryResource))
255252
{
256-
object rightValue = relationship.GetValue(resource);
253+
object rightValue = relationship.GetValue(primaryResource);
257254
ICollection<IIdentifiable> rightResourceIds = _collectionConverter.ExtractResources(rightValue);
258255

259256
IAsyncEnumerable<MissingResourceInRelationship> missingResourcesInRelationship =
@@ -287,7 +284,7 @@ private async IAsyncEnumerable<MissingResourceInRelationship> GetMissingRightRes
287284
}
288285

289286
/// <inheritdoc />
290-
public async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet<IIdentifiable> secondaryResourceIds,
287+
public virtual async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet<IIdentifiable> secondaryResourceIds,
291288
CancellationToken cancellationToken)
292289
{
293290
_traceWriter.LogMethodStart(new
@@ -316,10 +313,8 @@ public async Task AddToToManyRelationshipAsync(TId primaryId, string relationshi
316313
}
317314
catch (DataStoreUpdateException)
318315
{
319-
TResource primaryResource = await TryGetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute, cancellationToken);
320-
AssertPrimaryResourceExists(primaryResource);
321-
322-
await AssertResourcesExistAsync(secondaryResourceIds, cancellationToken);
316+
_ = await GetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute, cancellationToken);
317+
await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken);
323318
throw;
324319
}
325320
}
@@ -340,16 +335,22 @@ private async Task RemoveExistingIdsFromSecondarySetAsync(TId primaryId, ISet<II
340335
secondaryResourceIds.ExceptWith(existingRightResourceIds);
341336
}
342337

343-
private async Task AssertResourcesExistAsync(ICollection<IIdentifiable> secondaryResourceIds, CancellationToken cancellationToken)
338+
protected async Task AssertRightResourcesExistAsync(object rightResourceIds, CancellationToken cancellationToken)
344339
{
345-
QueryLayer queryLayer = _queryLayerComposer.ComposeForGetRelationshipRightIds(_request.Relationship, secondaryResourceIds);
340+
ICollection<IIdentifiable> secondaryResourceIds = _collectionConverter.ExtractResources(rightResourceIds);
346341

347-
List<MissingResourceInRelationship> missingResources =
348-
await GetMissingRightResourcesAsync(queryLayer, _request.Relationship, secondaryResourceIds, cancellationToken).ToListAsync(cancellationToken);
349-
350-
if (missingResources.Any())
342+
if (secondaryResourceIds.Any())
351343
{
352-
throw new ResourcesInRelationshipsNotFoundException(missingResources);
344+
QueryLayer queryLayer = _queryLayerComposer.ComposeForGetRelationshipRightIds(_request.Relationship, secondaryResourceIds);
345+
346+
List<MissingResourceInRelationship> missingResources =
347+
await GetMissingRightResourcesAsync(queryLayer, _request.Relationship, secondaryResourceIds, cancellationToken)
348+
.ToListAsync(cancellationToken);
349+
350+
if (missingResources.Any())
351+
{
352+
throw new ResourcesInRelationshipsNotFoundException(missingResources);
353+
}
353354
}
354355
}
355356

@@ -385,8 +386,7 @@ public virtual async Task<TResource> UpdateAsync(TId id, TResource resource, Can
385386
throw;
386387
}
387388

388-
TResource afterResourceFromDatabase = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken);
389-
AssertPrimaryResourceExists(afterResourceFromDatabase);
389+
TResource afterResourceFromDatabase = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken);
390390

391391
await _resourceDefinitionAccessor.OnAfterUpdateResourceAsync(afterResourceFromDatabase, cancellationToken);
392392

@@ -429,7 +429,7 @@ public virtual async Task SetRelationshipAsync(TId primaryId, string relationshi
429429
}
430430
catch (DataStoreUpdateException)
431431
{
432-
await AssertResourcesExistAsync(_collectionConverter.ExtractResources(secondaryResourceIds), cancellationToken);
432+
await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken);
433433
throw;
434434
}
435435

@@ -454,8 +454,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke
454454
}
455455
catch (DataStoreUpdateException)
456456
{
457-
TResource primaryResource = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken);
458-
AssertPrimaryResourceExists(primaryResource);
457+
_ = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken);
459458
throw;
460459
}
461460

@@ -465,7 +464,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke
465464
}
466465

467466
/// <inheritdoc />
468-
public async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relationshipName, ISet<IIdentifiable> secondaryResourceIds,
467+
public virtual async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relationshipName, ISet<IIdentifiable> secondaryResourceIds,
469468
CancellationToken cancellationToken)
470469
{
471470
_traceWriter.LogMethodStart(new
@@ -481,14 +480,22 @@ public async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relati
481480
AssertHasRelationship(_request.Relationship, relationshipName);
482481

483482
TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(primaryId, cancellationToken);
484-
await AssertResourcesExistAsync(secondaryResourceIds, cancellationToken);
483+
await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken);
485484

486485
if (secondaryResourceIds.Any())
487486
{
488487
await _repositoryAccessor.RemoveFromToManyRelationshipAsync(resourceFromDatabase, secondaryResourceIds, cancellationToken);
489488
}
490489
}
491490

491+
protected async Task<TResource> GetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken)
492+
{
493+
TResource primaryResource = await TryGetPrimaryResourceByIdAsync(id, fieldSelection, cancellationToken);
494+
AssertPrimaryResourceExists(primaryResource);
495+
496+
return primaryResource;
497+
}
498+
492499
private async Task<TResource> TryGetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken)
493500
{
494501
QueryLayer primaryLayer = _queryLayerComposer.ComposeForGetById(id, _request.PrimaryResource, fieldSelection);
@@ -497,7 +504,7 @@ private async Task<TResource> TryGetPrimaryResourceByIdAsync(TId id, TopFieldSel
497504
return primaryResources.SingleOrDefault();
498505
}
499506

500-
private async Task<TResource> GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken)
507+
protected async Task<TResource> GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken)
501508
{
502509
QueryLayer queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResource);
503510
var resource = await _repositoryAccessor.GetForUpdateAsync<TResource>(queryLayer, cancellationToken);

test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Company.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Collections.Generic;
23
using JetBrains.Annotations;
34
using JsonApiDotNetCore.Resources;
@@ -11,8 +12,7 @@ public sealed class Company : Identifiable, ISoftDeletable
1112
[Attr]
1213
public string Name { get; set; }
1314

14-
[Attr]
15-
public bool IsSoftDeleted { get; set; }
15+
public DateTimeOffset? SoftDeletedAt { get; set; }
1616

1717
[HasMany]
1818
public ICollection<Department> Departments { get; set; }

test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Department.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using JetBrains.Annotations;
23
using JsonApiDotNetCore.Resources;
34
using JsonApiDotNetCore.Resources.Annotations;
@@ -10,8 +11,7 @@ public sealed class Department : Identifiable, ISoftDeletable
1011
[Attr]
1112
public string Name { get; set; }
1213

13-
[Attr]
14-
public bool IsSoftDeleted { get; set; }
14+
public DateTimeOffset? SoftDeletedAt { get; set; }
1515

1616
[HasOne]
1717
public Company Company { get; set; }
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
using System;
2+
using JetBrains.Annotations;
3+
14
namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion
25
{
6+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
37
public interface ISoftDeletable
48
{
5-
bool IsSoftDeleted { get; set; }
9+
DateTimeOffset? SoftDeletedAt { get; set; }
610
}
711
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
using JetBrains.Annotations;
7+
using JsonApiDotNetCore.Configuration;
8+
using JsonApiDotNetCore.Hooks;
9+
using JsonApiDotNetCore.Middleware;
10+
using JsonApiDotNetCore.Queries;
11+
using JsonApiDotNetCore.Repositories;
12+
using JsonApiDotNetCore.Resources;
13+
using JsonApiDotNetCore.Services;
14+
using Microsoft.AspNetCore.Authentication;
15+
using Microsoft.Extensions.Logging;
16+
17+
namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion
18+
{
19+
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
20+
public class SoftDeletionAwareResourceService<TResource, TId> : JsonApiResourceService<TResource, TId>
21+
where TResource : class, IIdentifiable<TId>
22+
{
23+
private readonly ISystemClock _systemClock;
24+
private readonly ITargetedFields _targetedFields;
25+
private readonly IResourceRepositoryAccessor _repositoryAccessor;
26+
private readonly IJsonApiRequest _request;
27+
28+
public SoftDeletionAwareResourceService(ISystemClock systemClock, ITargetedFields targetedFields, IResourceRepositoryAccessor repositoryAccessor,
29+
IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory,
30+
IJsonApiRequest request, IResourceChangeTracker<TResource> resourceChangeTracker, IResourceHookExecutorFacade hookExecutor)
31+
: base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, hookExecutor)
32+
{
33+
_systemClock = systemClock;
34+
_targetedFields = targetedFields;
35+
_repositoryAccessor = repositoryAccessor;
36+
_request = request;
37+
}
38+
39+
// To optimize performance, the default resource service does not always fetch all resources on write operations.
40+
// We do that here, to assure a 404 error is thrown for soft-deleted resources.
41+
42+
public override async Task<TResource> CreateAsync(TResource resource, CancellationToken cancellationToken)
43+
{
44+
if (_targetedFields.Relationships.Any(relationship => IsSoftDeletable(relationship.RightType)))
45+
{
46+
await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken);
47+
}
48+
49+
return await base.CreateAsync(resource, cancellationToken);
50+
}
51+
52+
public override async Task<TResource> UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken)
53+
{
54+
if (_targetedFields.Relationships.Any(relationship => IsSoftDeletable(relationship.RightType)))
55+
{
56+
await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken);
57+
}
58+
59+
return await base.UpdateAsync(id, resource, cancellationToken);
60+
}
61+
62+
public override async Task SetRelationshipAsync(TId primaryId, string relationshipName, object secondaryResourceIds,
63+
CancellationToken cancellationToken)
64+
{
65+
if (IsSoftDeletable(_request.Relationship.RightType))
66+
{
67+
await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken);
68+
}
69+
70+
await base.SetRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken);
71+
}
72+
73+
public override async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet<IIdentifiable> secondaryResourceIds,
74+
CancellationToken cancellationToken)
75+
{
76+
if (IsSoftDeletable(typeof(TResource)))
77+
{
78+
_ = await GetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute, cancellationToken);
79+
}
80+
81+
if (IsSoftDeletable(_request.Relationship.RightType))
82+
{
83+
await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken);
84+
}
85+
86+
await base.AddToToManyRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken);
87+
}
88+
89+
public override async Task DeleteAsync(TId id, CancellationToken cancellationToken)
90+
{
91+
if (IsSoftDeletable(typeof(TResource)))
92+
{
93+
await SoftDeleteAsync(id, cancellationToken);
94+
}
95+
else
96+
{
97+
await base.DeleteAsync(id, cancellationToken);
98+
}
99+
}
100+
101+
private async Task SoftDeleteAsync(TId id, CancellationToken cancellationToken)
102+
{
103+
TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id, cancellationToken);
104+
105+
((ISoftDeletable)resourceFromDatabase).SoftDeletedAt = _systemClock.UtcNow;
106+
107+
// A delete operation does not target any fields, so we can just pass resourceFromDatabase twice.
108+
await _repositoryAccessor.UpdateAsync(resourceFromDatabase, resourceFromDatabase, cancellationToken);
109+
}
110+
111+
private static bool IsSoftDeletable(Type resourceType)
112+
{
113+
return typeof(ISoftDeletable).IsAssignableFrom(resourceType);
114+
}
115+
}
116+
117+
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
118+
public class SoftDeletionAwareResourceService<TResource> : SoftDeletionAwareResourceService<TResource, int>, IResourceService<TResource>
119+
where TResource : class, IIdentifiable<int>
120+
{
121+
public SoftDeletionAwareResourceService(ISystemClock systemClock, ITargetedFields targetedFields, IResourceRepositoryAccessor repositoryAccessor,
122+
IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory,
123+
IJsonApiRequest request, IResourceChangeTracker<TResource> resourceChangeTracker, IResourceHookExecutorFacade hookExecutor)
124+
: base(systemClock, targetedFields, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request,
125+
resourceChangeTracker, hookExecutor)
126+
{
127+
}
128+
}
129+
}

test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using JetBrains.Annotations;
22
using Microsoft.EntityFrameworkCore;
33

4+
// @formatter:wrap_chained_method_calls chop_always
5+
46
namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion
57
{
68
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
@@ -13,5 +15,14 @@ public SoftDeletionDbContext(DbContextOptions<SoftDeletionDbContext> options)
1315
: base(options)
1416
{
1517
}
18+
19+
protected override void OnModelCreating(ModelBuilder builder)
20+
{
21+
builder.Entity<Company>()
22+
.HasQueryFilter(company => company.SoftDeletedAt == null);
23+
24+
builder.Entity<Department>()
25+
.HasQueryFilter(department => department.SoftDeletedAt == null);
26+
}
1627
}
1728
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System;
2+
using Bogus;
3+
using TestBuildingBlocks;
4+
5+
// @formatter:wrap_chained_method_calls chop_always
6+
// @formatter:keep_existing_linebreaks true
7+
8+
namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion
9+
{
10+
internal sealed class SoftDeletionFakers : FakerContainer
11+
{
12+
private readonly Lazy<Faker<Company>> _lazyCompanyFaker = new Lazy<Faker<Company>>(() =>
13+
new Faker<Company>()
14+
.UseSeed(GetFakerSeed())
15+
.RuleFor(company => company.Name, faker => faker.Company.CompanyName()));
16+
17+
private readonly Lazy<Faker<Department>> _lazyDepartmentFaker = new Lazy<Faker<Department>>(() =>
18+
new Faker<Department>()
19+
.UseSeed(GetFakerSeed())
20+
.RuleFor(department => department.Name, faker => faker.Commerce.Department()));
21+
22+
public Faker<Company> Company => _lazyCompanyFaker.Value;
23+
public Faker<Department> Department => _lazyDepartmentFaker.Value;
24+
}
25+
}

0 commit comments

Comments
 (0)