Skip to content

Commit ddaad75

Browse files
Implemented basic outbox pattern. Small improvements to repository and entities.
1 parent d722ae9 commit ddaad75

21 files changed

+616
-81
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
namespace DotNetElements.Core.DataAnnotations;
2+
3+
public class EnumRequiredNotDefaultAttribute<TEnum> : ValidationAttribute
4+
where TEnum : struct, Enum
5+
{
6+
private readonly TEnum defaultValue;
7+
8+
public EnumRequiredNotDefaultAttribute(TEnum defaultValue)
9+
{
10+
this.defaultValue = defaultValue;
11+
}
12+
13+
protected override ValidationResult IsValid(object? value, ValidationContext validationContext)
14+
{
15+
if (value is null)
16+
return new ValidationResult("The field is required");
17+
18+
if (value is not TEnum enumValue || !Enum.IsDefined<TEnum>(enumValue))
19+
return new ValidationResult("Invalid value");
20+
21+
if (enumValue.Equals(defaultValue))
22+
return new ValidationResult($"The field is required (value can not be {defaultValue})");
23+
24+
return ValidationResult.Success!;
25+
}
26+
}

src/DotNetElements.Core/Core/EntityBase.cs

Lines changed: 61 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -3,124 +3,129 @@
33
namespace DotNetElements.Core;
44

55
public interface IEntity<TKey> : IHasKey<TKey>
6-
where TKey : notnull, IEquatable<TKey>;
6+
where TKey : notnull, IEquatable<TKey>;
77

88
public interface ICreationAuditedEntity<TKey> : IEntity<TKey>
9-
where TKey : notnull, IEquatable<TKey>
9+
where TKey : notnull, IEquatable<TKey>
1010
{
11-
Guid CreatorId { get; }
11+
Guid CreatorId { get; }
1212

13-
DateTimeOffset CreationTime { get; }
13+
DateTimeOffset CreationTime { get; }
1414

15-
void SetCreationAudited(Guid creatorId, DateTimeOffset creationTime);
15+
void SetCreationAudited(Guid creatorId, DateTimeOffset creationTime);
1616
}
1717

1818
public interface IAuditedEntity<TKey> : ICreationAuditedEntity<TKey>
19-
where TKey : notnull, IEquatable<TKey>
19+
where TKey : notnull, IEquatable<TKey>
2020
{
21-
Guid? LastModifierId { get; }
21+
Guid? LastModifierId { get; }
2222

23-
DateTimeOffset? LastModificationTime { get; }
23+
DateTimeOffset? LastModificationTime { get; }
2424

25-
bool HasChanged => LastModificationTime is null;
25+
bool HasChanged => LastModificationTime is null;
2626

27-
void SetModificationAudited(Guid lastModifierId, DateTimeOffset lastModificationTime);
27+
void SetModificationAudited(Guid lastModifierId, DateTimeOffset lastModificationTime);
2828
}
2929

3030
public interface ISoftDelete
3131
{
32-
bool IsDeleted { get; }
32+
bool IsDeleted { get; }
3333

34-
void Delete();
34+
void Delete();
3535
}
3636

3737
public interface IHasDeletionTime : ISoftDelete
3838
{
39-
DateTimeOffset? DeletionTime { get; }
39+
DateTimeOffset? DeletionTime { get; }
4040

41-
void Delete(DateTimeOffset deletionTime);
41+
void Delete(DateTimeOffset deletionTime);
4242
}
4343

4444
public interface IDeletionAuditedEntity : IHasDeletionTime
4545
{
46-
Guid? DeleterId { get; }
46+
Guid? DeleterId { get; }
4747

48-
void Delete(Guid deleterId, DateTimeOffset deletionTime);
48+
void Delete(Guid deleterId, DateTimeOffset deletionTime);
4949
}
5050

5151
public interface IUpdatable<TFrom>
5252
{
53-
void Update(TFrom from, IAttachRelatedEntity attachRelatedEntity);
53+
void Update(TFrom from);
54+
}
55+
56+
public interface IUpdatableEx<TFrom>
57+
{
58+
void Update(TFrom from, Guid currentUser, DateTimeOffset currentTime, IAttachRelatedEntity attachRelatedEntity);
5459
}
5560

5661
public interface IRelatedEntity<TSelf, TKey>
57-
where TSelf : IEntity<TKey>, IRelatedEntity<TSelf, TKey>
58-
where TKey : notnull, IEquatable<TKey>
62+
where TSelf : IEntity<TKey>, IRelatedEntity<TSelf, TKey>
63+
where TKey : notnull, IEquatable<TKey>
5964
{
60-
static abstract TSelf CreateRefById(TKey id);
65+
static abstract TSelf CreateRefById(TKey id);
6166
}
6267

6368
public abstract class Entity { }
6469

6570
public abstract class Entity<TKey> : Entity, IEntity<TKey>
66-
where TKey : notnull, IEquatable<TKey>
71+
where TKey : notnull, IEquatable<TKey>
6772
{
68-
public TKey Id { get; protected set; } = default!;
73+
public TKey Id { get; protected set; } = default!;
6974
}
7075

7176
public class CreationAuditedEntity<TKey> : Entity<TKey>, ICreationAuditedEntity<TKey>
72-
where TKey : notnull, IEquatable<TKey>
77+
where TKey : notnull, IEquatable<TKey>
7378
{
74-
public Guid CreatorId { get; private set; }
79+
public Guid CreatorId { get; private set; }
7580

76-
public DateTimeOffset CreationTime { get; private set; }
81+
public DateTimeOffset CreationTime { get; private set; }
7782

78-
public void SetCreationAudited(Guid creatorId, DateTimeOffset creationTime)
79-
{
80-
if (CreatorId != default)
81-
throw new InvalidOperationException("Can not set audit parameters of a already created entity");
83+
public void SetCreationAudited(Guid creatorId, DateTimeOffset creationTime)
84+
{
85+
if (CreatorId != default)
86+
throw new InvalidOperationException("Can not set audit parameters of a already created entity");
8287

83-
CreatorId = creatorId;
84-
CreationTime = creationTime;
85-
}
88+
CreatorId = creatorId;
89+
CreationTime = creationTime;
90+
}
8691
}
8792

8893
public class AuditedEntity<TKey> : CreationAuditedEntity<TKey>, IAuditedEntity<TKey>
89-
where TKey : notnull, IEquatable<TKey>
94+
where TKey : notnull, IEquatable<TKey>
9095
{
91-
public Guid? LastModifierId { get; private set; }
96+
public Guid? LastModifierId { get; private set; }
9297

93-
public DateTimeOffset? LastModificationTime { get; private set; }
98+
public DateTimeOffset? LastModificationTime { get; private set; }
9499

95-
public void SetModificationAudited(Guid lastModifierId, DateTimeOffset lastModificationTime)
96-
{
97-
LastModifierId = lastModifierId;
98-
LastModificationTime = lastModificationTime;
99-
}
100+
public void SetModificationAudited(Guid lastModifierId, DateTimeOffset lastModificationTime)
101+
{
102+
LastModifierId = lastModifierId;
103+
LastModificationTime = lastModificationTime;
104+
}
100105
}
101106

102107
public class PersistentEntity<TKey> : AuditedEntity<TKey>, IDeletionAuditedEntity
103-
where TKey : notnull, IEquatable<TKey>
108+
where TKey : notnull, IEquatable<TKey>
104109
{
105-
public bool IsDeleted { get; private set; }
110+
public bool IsDeleted { get; private set; }
106111

107-
public Guid? DeleterId { get; private set; }
112+
public Guid? DeleterId { get; private set; }
108113

109-
public DateTimeOffset? DeletionTime { get; private set; }
114+
public DateTimeOffset? DeletionTime { get; private set; }
110115

111-
public void Delete(Guid deleterId, DateTimeOffset deletionTime)
112-
{
113-
if (IsDeleted)
114-
throw new InvalidOperationException("Can not delete an already deleted entity");
116+
public void Delete(Guid deleterId, DateTimeOffset deletionTime)
117+
{
118+
if (IsDeleted)
119+
throw new InvalidOperationException("Can not delete an already deleted entity");
115120

116-
IsDeleted = true;
117-
DeleterId = deleterId;
118-
DeletionTime = deletionTime;
119-
}
121+
IsDeleted = true;
122+
DeleterId = deleterId;
123+
DeletionTime = deletionTime;
124+
}
120125

121-
[EditorBrowsable(EditorBrowsableState.Never)]
122-
public void Delete(DateTimeOffset deletionTime) => throw new NotImplementedException();
126+
[EditorBrowsable(EditorBrowsableState.Never)]
127+
public void Delete(DateTimeOffset deletionTime) => throw new NotImplementedException();
123128

124-
[EditorBrowsable(EditorBrowsableState.Never)]
125-
public void Delete() => throw new NotImplementedException();
129+
[EditorBrowsable(EditorBrowsableState.Never)]
130+
public void Delete() => throw new NotImplementedException();
126131
}

src/DotNetElements.Core/Core/IReadOnlyRepository.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,9 @@ Task<CrudResult<AuditedModelDetails>> GetAuditedModelDetailsByIdAsync<TAuditedEn
6262
TKey id,
6363
CancellationToken cancellationToken = default)
6464
where TAuditedEntity : AuditedEntity<TKey>;
65+
66+
Task<CrudResult<PersistentModelDetails>> GetPersistentModelDetailsByIdAsync<TPersistentEntity>(
67+
TKey id,
68+
CancellationToken cancellationToken = default)
69+
where TPersistentEntity : PersistentEntity<TKey>;
6570
}

src/DotNetElements.Core/Core/ManagedReadOnlyRepository.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ public Task<CrudResult<AuditedModelDetails>> GetAuditedModelDetailsByIdAsync<TAu
105105
return repository.Inner.GetAuditedModelDetailsByIdAsync<TAuditedEntity>(id, cancellationToken);
106106
}
107107

108+
public Task<CrudResult<PersistentModelDetails>> GetPersistentModelDetailsByIdAsync<TPersistentEntity>(TKey id, CancellationToken cancellationToken = default)
109+
where TPersistentEntity : PersistentEntity<TKey>
110+
{
111+
using var repository = RepositoryFactory.Create();
112+
113+
return repository.Inner.GetPersistentModelDetailsByIdAsync<TPersistentEntity>(id, cancellationToken);
114+
}
115+
108116
public Task<CrudResult<TProjection>> GetFilteredWithProjectionAsync<TProjection>(
109117
Expression<Func<IQueryable<TEntity>, IQueryable<TProjection>>> selector,
110118
Expression<Func<TEntity, bool>> filter,

src/DotNetElements.Core/Core/ModelBase.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,14 @@ public class AuditedModelDetails : CreationAuditedModelDetails
8282

8383
public DateTimeOffset? LastModificationTime { get; init; }
8484
}
85+
86+
public class PersistentModelDetails : AuditedModelDetails
87+
{
88+
public bool IsDeleted { get; init; }
89+
90+
public Guid? DeleterId { get; init; }
91+
92+
public string? Deleter { get; init; }
93+
94+
public DateTimeOffset? DeletionTime { get; init; }
95+
}

src/DotNetElements.Core/Core/ReadOnlyRepository.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,4 +180,31 @@ public async Task<CrudResult<AuditedModelDetails>> GetAuditedModelDetailsByIdAsy
180180

181181
return CrudResult.OkIfNotNull(entity, CrudError.NotFound, id.ToString());
182182
}
183+
184+
public async Task<CrudResult<PersistentModelDetails>> GetPersistentModelDetailsByIdAsync<TPersistentEntity>(TKey id, CancellationToken cancellationToken = default)
185+
where TPersistentEntity : PersistentEntity<TKey>
186+
{
187+
DbSet<TPersistentEntity> localDbSet = DbContext.Set<TPersistentEntity>();
188+
189+
PersistentModelDetails? entity = await localDbSet
190+
.AsNoTracking()
191+
.Where(WithId<TPersistentEntity>(id))
192+
.Select(entity =>
193+
new PersistentModelDetails()
194+
{
195+
CreatorId = entity.CreatorId,
196+
Creator = "Felix", // todo get from user
197+
CreationTime = entity.CreationTime,
198+
LastModifierId = entity.LastModifierId,
199+
LastModifier = "Felix", // todo get from user
200+
LastModificationTime = entity.LastModificationTime,
201+
IsDeleted = entity.IsDeleted,
202+
DeleterId = entity.DeleterId,
203+
Deleter = "Felix", // todo get from user
204+
DeletionTime = entity.DeletionTime
205+
}
206+
).FirstOrDefaultAsync(cancellationToken);
207+
208+
return CrudResult.OkIfNotNull(entity, CrudError.NotFound, id.ToString());
209+
}
183210
}

src/DotNetElements.Core/Core/Repository.cs

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ public abstract class Repository<TDbContext, TEntity, TKey> : ReadOnlyRepository
1717
protected static readonly RelatedEntitiesAttribute? RelatedEntities = typeof(TEntity).GetCustomAttribute<RelatedEntitiesAttribute>();
1818
protected static readonly RelatedEntitiesCollectionsAttribute? RelatedEntitiesCollections = typeof(TEntity).GetCustomAttribute<RelatedEntitiesCollectionsAttribute>();
1919

20+
protected readonly Dictionary<string, Action<TEntity, object?, object?>> propertyChangedActions = [];
21+
2022
public Repository(TDbContext dbContext, ICurrentUserProvider currentUserProvider, TimeProvider timeProvider) : base(dbContext)
2123
{
2224
CurrentUserProvider = currentUserProvider;
@@ -80,7 +82,7 @@ public virtual async Task<CrudResult<TSelf>> CreateOrUpdateAsync<TSelf>(TKey id,
8082
if (existingEntity is null)
8183
return CrudResult.NotFound(id);
8284

83-
entity.Update(entity, this);
85+
entity.Update(entity);
8486

8587
// Check if entity has changed and set audit properties if needed
8688
if (DbContext.ChangeTracker.HasChanges())
@@ -109,14 +111,26 @@ public virtual async Task<CrudResult<TEntity>> UpdateAsync<TFrom>(TKey id, TFrom
109111
if (existingEntity is null)
110112
return CrudResult.NotFound(id);
111113

112-
if (existingEntity is not IUpdatable<TFrom> updatableEntity)
113-
throw new InvalidOperationException($"UpdateAsync<TFrom> is only supported for entities implementing IUpdatable<{typeof(TFrom)}>.");
114-
115-
updatableEntity.Update(from, this);
114+
if (existingEntity is IUpdatable<TFrom> updatableEntity)
115+
updatableEntity.Update(from);
116+
else if (existingEntity is IUpdatableEx<TFrom> updatableEntityEx)
117+
updatableEntityEx.Update(from, CurrentUserProvider.GetCurrentUserId(), TimeProvider.GetUtcNow(), this);
118+
else
119+
throw new InvalidOperationException($"UpdateAsync<TFrom> is only supported for entities implementing IUpdatable<{typeof(TFrom)}> or IUpdatableEx<{typeof(TFrom)}>.");
116120

117121
// Check if entity has changed and set audit properties if needed
118122
if (DbContext.ChangeTracker.HasChanges())
119123
{
124+
// todo move to method
125+
if (propertyChangedActions.Count > 0)
126+
{
127+
foreach (var entryProp in DbContext.Entry(existingEntity).Properties.Where(prop => prop.IsModified))
128+
{
129+
if (propertyChangedActions.TryGetValue(entryProp.Metadata.Name, out Action<TEntity, object?, object?>? action))
130+
action(existingEntity, entryProp.OriginalValue, entryProp.CurrentValue);
131+
}
132+
}
133+
120134
SetModificationAudited(existingEntity);
121135

122136
UpdateEntityVersion(existingEntity, from);
@@ -171,7 +185,7 @@ public virtual async Task<CrudResult> DeleteAsync<TEntityToDelete>(TEntityToDele
171185

172186
public async Task<CrudResult> DeleteByIdAsync(TKey id)
173187
{
174-
TEntity? entityToDelete = await Entities.FirstOrDefaultAsync(WithId(id));
188+
TEntity? entityToDelete = await Entities.FirstOrDefaultAsync(WithId(id));
175189

176190
if (entityToDelete is null)
177191
return CrudResult.NotFound(id);
@@ -183,7 +197,6 @@ public async Task<CrudResult> DeleteByIdAsync(TKey id)
183197
return CrudResult.Ok();
184198
}
185199

186-
187200
public virtual async Task ClearTable()
188201
{
189202
await Entities.ExecuteDeleteAsync();
@@ -302,6 +315,7 @@ protected IQueryable<TUpdatedEntity> LoadRelatedEntitiesOnUpdate<TUpdatedEntity>
302315
foreach (string relatedProperty in RelatedEntitiesOnUpdate.ReferenceProperties)
303316
query = query.Include(relatedProperty);
304317
}
318+
305319
return query;
306320
}
307321

@@ -310,6 +324,17 @@ protected Task LoadRelatedEntities(TEntity entity)
310324
return LoadRelatedEntities(DbContext.Entry(entity));
311325
}
312326

327+
// todo check if we want to compile the expression
328+
protected void RegisterPropertyChanged<TValue>(Expression<Func<TEntity, TValue>> propertyExpression, Action<TEntity, TValue?, TValue?> onPropertyChanged)
329+
{
330+
MemberExpression? member = propertyExpression.Body as MemberExpression ?? (propertyExpression.Body as UnaryExpression)?.Operand as MemberExpression;
331+
332+
if (member is null || member.Member.DeclaringType != typeof(TEntity))
333+
throw new ArgumentNullException($"Expression {nameof(propertyExpression)} must point to a member of {typeof(TEntity)}.");
334+
335+
propertyChangedActions.Add(member.Member.Name, (entity, oldValue, newValue) => onPropertyChanged(entity, (TValue?)oldValue, (TValue?)newValue));
336+
}
337+
313338
// todo, review loading related entities. Maybe add parameter returnRelatedEntities?
314339
// (Was needed, so referenced entities got included when returning entity)
315340
// Check if we can batch the loadings

src/DotNetElements.Core/DotNetElements.Core.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
2121
</PackageReference>
2222
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.2" />
23+
<PackageReference Include="NCronJob" Version="4.3.3" />
2324
</ItemGroup>
2425

2526
</Project>

0 commit comments

Comments
 (0)