-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathEFRepository.cs
More file actions
330 lines (270 loc) · 15.3 KB
/
Copy pathEFRepository.cs
File metadata and controls
330 lines (270 loc) · 15.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
// ReSharper disable LoopCanBeConvertedToQuery; foreach is simpler to read than the query
// ReSharper disable RedundantJumpStatement; multiple catch statements with continue
// intentional to simply ignore 'bad input'
using System.Linq.Expressions;
using System.Reflection;
using Application.Abstractions;
using Application.Abstractions.CRUDRequests;
using Application.Abstractions.HTTPQueryFiltering;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage;
namespace Infrastructure.Persistence;
/// <summary>
/// The EntityRepository is a generic repository which can be used for data management on Entities
///
/// Note that it CANNOT work with Aggregates, it directly links to the DbSet for Entity T and cannot,
/// and should not, pull from multiple DbSets. This cannot and should not be linked to several tables
/// to ensure SRP is maintained throughout the codebase.
/// </summary>
public class EFRepository<T> (AppDbContext context) : IRepository<T> where T : class
{
private readonly DbSet<T> _dbSet = context.Set<T>();
/*============================================================================================================*\
* EntityFramework Passthroughs *
\*============================================================================================================*/
/// <summary> Find a list of T entities based on an Expression. </summary>
public IQueryable<T> Where(Expression<Func<T, bool>> predicate) => _dbSet.Where(predicate);
/// <summary> Enable EF style queries on the underlying DbSet. </summary>
public IQueryable<T> Query() => _dbSet;
/// <summary> Expose the underlying SaveChangesAsync method. </summary>
public Task SaveChangesAsync() => context.SaveChangesAsync();
/*============================================================================================================*\
* Core Repository Methods *
\*============================================================================================================*/
/// <summary>
/// Retrieve a list of T entities.
/// Accepts a list of filters as defined in Util/HTTP/Filter.
/// Pagination defaults to 100 per page, but can be turned off by setting page <= 0. (pageSize then gets ignored)
/// </summary>
public async Task<IEnumerable<T>> List(ListRequest<T> request)
{
IQueryable<T> q = _dbSet;
// Handle includes
if (request.Include is { Count: > 0 })
{
var entityType = context.Model.FindEntityType(typeof(T));
var validIncludes = entityType!
.GetNavigations()
.Select(n => n.Name)
.Concat(entityType.GetSkipNavigations().Select(n => n.Name))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (string include in request.Include)
{
if (validIncludes.Contains(include))
q = q.Include(include);
}
}
// Handle filters
if (request.Filters is { Count: > 0 })
{
// Group the filters by their PropertyName so that we can apply OR queries to filters on the same property
IEnumerable<IGrouping<string, Clause>> filterGroup = request.Filters
.GroupBy(f => f.PropertyName);
// Go through each filter group and build an ƒλ to apply to the query
foreach (IGrouping<string, Clause> group in filterGroup)
{
try // ignore any nonsense along the way, we only want to pay attention to valid filters.
{
// build ƒλ parameter (x => ...) and the property to filter on (x.Foo)
ParameterExpression parameter = Expression.Parameter(typeof(T), "x");
MemberExpression property = Expression.PropertyOrField(parameter, group.Key);
Expression? expression = null; // Will later determine use of AND or OR
bool isRange = IsRangeGrouping(group); // Ranges will always use AND though
foreach (Clause filter in group) // find the correct comparison and values for each filter.
{
// Convert the filter value to the property's type and wrap it in a constant.
// This needs to be done here because `q.Where` cannot accept dynamic types.
ConstantExpression value = Expression.Constant(Convert.ChangeType(filter.Value, property.Type));
Expression comparison = filter.Operator switch // build comparison e.g. ƒλ: x.Foo == bar
{
Operator.Equal => Expression.Equal(property, value),
Operator.NotEqual => Expression.NotEqual(property, value),
Operator.LessThan => Expression.LessThan(property, value),
Operator.LessThanOrEquals => Expression.LessThanOrEqual(property, value),
Operator.GreaterThan => Expression.GreaterThan(property, value),
Operator.GreaterThanOrEquals => Expression.GreaterThanOrEqual(property, value),
Operator.Contains => Expression.Call( // assumes value is a string
property,
typeof(string).GetMethod("Contains", [typeof(string)])!,
value),
_ => throw new NotSupportedException($"Unsupported operator: {filter.Operator}")
};
// Set the expression which will be used. The first pass just sets it to the operator above.
if (expression == null) expression = comparison;
// Subsequent passes will check whether the expression is a range operator or not and set
// an AND or OR for the query based on that, so ranges use AND, equality uses OR.
else expression = isRange
? Expression.AndAlso(comparison, expression)
: Expression.OrElse(comparison, expression);
}
if (expression == null) continue; // The filter set is empty; skip.
// finally wrap the expression ƒλ: (x => x.Foo == bar) and apply it to the query
q = q.Where(Expression.Lambda<Func<T, bool>>(expression, parameter));
}
catch (ArgumentException) { continue; } // bad filter key
catch (NotSupportedException) { continue; } // bad operand type
catch (FormatException) { continue; } // bad value type
catch (InvalidCastException) { continue; } // bad value conversion
}
}
// Handle pagination
if (request.Page > 0) // Paginate the results, skip if pagination is off (page <= 0)
q = q.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize);
return await q.ToListAsync();
}
/// <summary>
/// Find an entity T based on its primary key.
/// Allows the inclusion of related models while querying through the includes expressions.
/// </summary>
public async Task<T?> Find(object id, params Expression<Func<T, object>>[] includes)
{
// Get the primary key for T & ensure there is only a single key
IKey pKey = GetPrimaryKey();
if (pKey.Properties.Count != 1)
throw new NotSupportedException($"Entity {typeof(T).Name} has a composite primary key. Use the composite overload.");
// New the base query
IQueryable<T> query = _dbSet;
// Include any requested resources
foreach (var include in includes)
query = query.Include(include);
// Build an expression and add the primary key to it (ƒλ: e => e.keyProperty == id)
ParameterExpression param = Expression.Parameter(typeof(T), "e");
BinaryExpression body = Expression.Equal(
Expression.Property(param, pKey.Properties[0].Name),
Expression.Constant(id)
);
// Execute the query using the expression
return await query.FirstOrDefaultAsync(Expression.Lambda<Func<T, bool>>(body, param));
}
/// <summary>
/// String-based overload. Converts string includes into type-safe expressions.
/// </summary>
public async Task<T?> Find(object id, params string[] includes)
{
Expression<Func<T, object>>[] expressions = includes
.Select(BuildIncludeExpression)
.Where(expr => expr != null)
.Cast<Expression<Func<T, object>>>()
.ToArray();
return await Find(id, expressions);
}
/// <summary>
/// Find an entity T based on a composite primary key.
/// Allows the inclusion of related models while querying through the includes expressions.
/// </summary>
public async Task<T?> Find(object[] composite, params string[] includes)
{
// Get the primary key for T
IKey pKey = GetPrimaryKey();
if (pKey.Properties.Count != composite.Length)
throw new ArgumentException($"Composite key for {typeof(T).Name} expects {pKey.Properties.Count} values, got {composite.Length}.");
// New a parameter expression representing an entity instance in a lambda: (ƒλ: e =>)
ParameterExpression param = Expression.Parameter(typeof(T), "e");
Expression? body = null;
for (int i = 0; i < pKey.Properties.Count; i++)
{
IProperty property = pKey.Properties[i];
ConstantExpression value = Expression.Constant(composite[i]);
BinaryExpression comparison = Expression.Equal(Expression.Property(param, property.Name), value);
body = body == null ? comparison : Expression.AndAlso(body, comparison);
}
// Wrap the comparison into a lambda: (ƒλ: e => e.keyProperty == id)
Expression<Func<T, bool>> lambda = Expression.Lambda<Func<T, bool>>(body!, param);
return await _dbSet.FirstOrDefaultAsync(lambda);
}
/// <summary>
/// Persists an entity T to storage; returns the saved entity on completion.
/// </summary>
public async Task<T> Create(T entity)
{
ArgumentNullException.ThrowIfNull(entity);
await context.AddAsync(entity);
await context.SaveChangesAsync();
return entity;
}
/// <summary>
/// Updates an entity T in storage; returns success status on completion.
/// This should only be used if the entity is not tracked.
/// </summary>
public async Task<T> Update(T entity)
{
ArgumentNullException.ThrowIfNull(entity);
// Attach only if not tracked — prevents unintended overwrites.
var entry = context.Entry(entity);
if (entry.State == EntityState.Detached)
context.Attach(entity);
entry.State = EntityState.Modified;
await context.SaveChangesAsync();
return entity;
}
/// <summary>
/// Remove an entity T from storage; returns success status on completion.
/// </summary>
public async Task<bool> Delete(T entity)
{
ArgumentNullException.ThrowIfNull(entity);
try
{
context.Remove(entity);
await context.SaveChangesAsync();
return true;
}
catch { return false; }
}
/*============================================================================================================*\
* Helper Methods *
\*============================================================================================================*/
/// <summary>
/// Helper function which retrieves the primary key from a model.
/// </summary>
private IKey GetPrimaryKey()
{
// Get EF Core metadata for the entity type T
IEntityType entityType = context.Model.FindEntityType(typeof(T))
?? throw new InvalidOperationException($"Entity type {typeof(T).Name} not found in DbContext.");
// Get the primary key definition from the metadata
IKey primaryKey = entityType.FindPrimaryKey()
?? throw new InvalidOperationException($"Entity {typeof(T).Name} does not have a primary key.");
return primaryKey;
}
/// <summary>
/// Used by the List function to add the navigations to the expression passed to EF.
/// </summary>
private static Expression<Func<T, object>>? BuildIncludeExpression(string property)
{
// This split allows nested string properties (e.g. Organisation.Contact.Address)
string[] properties = property.Split('.', StringSplitOptions.RemoveEmptyEntries);
if (properties.Length == 0) return null;
// Build the expression which will be used to query EntityFramework later.
ParameterExpression param = Expression.Parameter(typeof(T), "e");
Expression body = param; // Used in the loop to build the expression
Type type = typeof(T); // (See below about boxing properties)
// Loop through the parts and attach each to the includes query
foreach (string part in properties)
{
PropertyInfo? prop = typeof(T).GetProperty(part, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance);
if (prop is null) continue;
body = Expression.Property(body, prop); // Add the properties onto the expression
type = prop.PropertyType; // The last type determines the boxing
}
// It's not possible to box collection navigation properties when building expression based includes.
// EF Core requires a lambda to directly return the navigation property with no Convert on ref types
if (type.IsValueType) body = Expression.Convert(body, typeof(object));
return Expression.Lambda<Func<T, object>>(body, param);
}
/// <summary>
/// Used by the List function to determine how to handle filters with the same key.
/// </summary>
private static bool IsRangeGrouping(IEnumerable<Clause> filters)
{
var opSet = filters.Select(f => f.Operator).ToHashSet();
bool hasRangeOperators = opSet.Overlaps([
Operator.GreaterThan,
Operator.GreaterThanOrEquals,
Operator.LessThan,
Operator.LessThanOrEquals
]);
return hasRangeOperators && opSet.Count > 1;
}
}