Initial commit
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
using System.Linq.Expressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Generic repository that provides standard CRUD, search, and pagination operations for any
|
||||
/// entity that inherits from <see cref="BaseEntity"/>.
|
||||
/// <para>
|
||||
/// The repository operates on top of <see cref="ApplicationDbContext"/>, which already applies
|
||||
/// two global query filters to every query: soft-delete (<c>IsDeleted == false</c>) and
|
||||
/// multi-tenancy (<c>CompanyId == CurrentCompanyId</c>). Pass <c>ignoreQueryFilters: true</c>
|
||||
/// to any read method to bypass <em>both</em> filters simultaneously — use this sparingly and
|
||||
/// only in SuperAdmin contexts or when generating unique sequential numbers (to avoid gaps).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All write methods (Add, Update, Delete, SoftDelete) stage changes in the EF Core change
|
||||
/// tracker only. Call <see cref="IUnitOfWork.SaveChangesAsync"/> (or <c>CompleteAsync</c>)
|
||||
/// after one or more write operations to flush the batch to the database in a single round-trip.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <typeparam name="T">An entity type that inherits <see cref="BaseEntity"/>.</typeparam>
|
||||
public class Repository<T> : IRepository<T> where T : BaseEntity
|
||||
{
|
||||
/// <summary>The shared DbContext instance; all repositories within the same <see cref="UnitOfWork"/> share this context so they participate in the same change-tracker and transaction.</summary>
|
||||
protected readonly ApplicationDbContext _context;
|
||||
/// <summary>The EF Core DbSet for <typeparamref name="T"/>; obtained once in the constructor to avoid repeated <c>Set<T>()</c> dictionary lookups.</summary>
|
||||
protected readonly DbSet<T> _dbSet;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the repository by resolving the <see cref="DbSet{T}"/> from the shared context.
|
||||
/// </summary>
|
||||
/// <param name="context">The scoped DbContext provided by <see cref="UnitOfWork"/>.</param>
|
||||
public Repository(ApplicationDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
_dbSet = context.Set<T>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a single entity by its primary key, with optional eager loading.
|
||||
/// Returns <c>null</c> if no matching row is found (after applying query filters).
|
||||
/// </summary>
|
||||
/// <param name="id">The primary key value to look up.</param>
|
||||
/// <param name="ignoreQueryFilters">
|
||||
/// When <c>true</c>, bypasses both the soft-delete and multi-tenancy global query filters.
|
||||
/// Use for SuperAdmin lookups of deleted or cross-tenant records.
|
||||
/// </param>
|
||||
/// <param name="includes">Optional navigation-property selectors for eager loading (avoids N+1 queries).</param>
|
||||
public virtual async Task<T?> GetByIdAsync(int id, bool ignoreQueryFilters = false, params Expression<Func<T, object>>[] includes)
|
||||
{
|
||||
IQueryable<T> query = _dbSet;
|
||||
|
||||
if (ignoreQueryFilters)
|
||||
{
|
||||
query = query.IgnoreQueryFilters();
|
||||
}
|
||||
|
||||
foreach (var include in includes)
|
||||
{
|
||||
query = query.Include(include);
|
||||
}
|
||||
|
||||
return await query.FirstOrDefaultAsync(e => e.Id == id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all entities of type <typeparamref name="T"/>, with optional eager loading.
|
||||
/// Global query filters (soft-delete + tenant isolation) are applied unless
|
||||
/// <paramref name="ignoreQueryFilters"/> is <c>true</c>.
|
||||
/// <para>
|
||||
/// Avoid calling this without a filter on large tables — prefer <see cref="FindAsync"/> or
|
||||
/// <see cref="GetPagedAsync"/> to limit the result set.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="ignoreQueryFilters">When <c>true</c>, bypasses soft-delete and multi-tenancy filters.</param>
|
||||
/// <param name="includes">Optional navigation-property selectors for eager loading.</param>
|
||||
public virtual async Task<IEnumerable<T>> GetAllAsync(bool ignoreQueryFilters = false, params Expression<Func<T, object>>[] includes)
|
||||
{
|
||||
IQueryable<T> query = _dbSet;
|
||||
|
||||
if (ignoreQueryFilters)
|
||||
{
|
||||
query = query.IgnoreQueryFilters();
|
||||
}
|
||||
|
||||
foreach (var include in includes)
|
||||
{
|
||||
query = query.Include(include);
|
||||
}
|
||||
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all entities that satisfy <paramref name="predicate"/>, with optional eager loading.
|
||||
/// The predicate is composed with global query filters (unless bypassed), so the final SQL
|
||||
/// WHERE clause is the conjunction of both conditions — no tenant data can leak through a
|
||||
/// permissive predicate.
|
||||
/// </summary>
|
||||
/// <param name="predicate">A LINQ expression used to filter results.</param>
|
||||
/// <param name="ignoreQueryFilters">When <c>true</c>, bypasses soft-delete and multi-tenancy filters.</param>
|
||||
/// <param name="includes">Optional navigation-property selectors for eager loading.</param>
|
||||
public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate, bool ignoreQueryFilters = false, params Expression<Func<T, object>>[] includes)
|
||||
{
|
||||
IQueryable<T> query = _dbSet;
|
||||
|
||||
if (ignoreQueryFilters)
|
||||
query = query.IgnoreQueryFilters();
|
||||
|
||||
foreach (var include in includes)
|
||||
query = query.Include(include);
|
||||
|
||||
return await query.Where(predicate).ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first entity that satisfies <paramref name="predicate"/>, or <c>null</c> if none match.
|
||||
/// Useful when exactly one result is expected but membership in the result set is uncertain
|
||||
/// (e.g., looking up a record by a unique business key such as email or job number).
|
||||
/// </summary>
|
||||
/// <param name="predicate">A LINQ expression used to filter results.</param>
|
||||
/// <param name="ignoreQueryFilters">When <c>true</c>, bypasses soft-delete and multi-tenancy filters.</param>
|
||||
/// <param name="includes">Optional navigation-property selectors for eager loading.</param>
|
||||
public virtual async Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate, bool ignoreQueryFilters = false, params Expression<Func<T, object>>[] includes)
|
||||
{
|
||||
IQueryable<T> query = _dbSet;
|
||||
|
||||
if (ignoreQueryFilters)
|
||||
query = query.IgnoreQueryFilters();
|
||||
|
||||
foreach (var include in includes)
|
||||
query = query.Include(include);
|
||||
|
||||
return await query.FirstOrDefaultAsync(predicate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if at least one entity satisfies <paramref name="predicate"/>.
|
||||
/// More efficient than <see cref="CountAsync"/> when only existence is needed, because
|
||||
/// EF Core translates this to <c>SELECT TOP 1</c> rather than a full aggregation.
|
||||
/// </summary>
|
||||
/// <param name="predicate">A LINQ expression used to test membership.</param>
|
||||
/// <param name="ignoreQueryFilters">When <c>true</c>, bypasses soft-delete and multi-tenancy filters.</param>
|
||||
public virtual async Task<bool> AnyAsync(Expression<Func<T, bool>> predicate, bool ignoreQueryFilters = false)
|
||||
{
|
||||
IQueryable<T> query = _dbSet;
|
||||
|
||||
if (ignoreQueryFilters)
|
||||
{
|
||||
query = query.IgnoreQueryFilters();
|
||||
}
|
||||
|
||||
return await query.AnyAsync(predicate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts entities, optionally filtered by <paramref name="predicate"/>.
|
||||
/// Used by dashboard stat cards and pagination metadata.
|
||||
/// When <paramref name="predicate"/> is <c>null</c>, counts all visible rows for the current tenant.
|
||||
/// </summary>
|
||||
/// <param name="predicate">An optional LINQ expression to narrow the count. Pass <c>null</c> for a total count.</param>
|
||||
/// <param name="ignoreQueryFilters">When <c>true</c>, bypasses soft-delete and multi-tenancy filters to count across all tenants or include deleted rows.</param>
|
||||
public virtual async Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null, bool ignoreQueryFilters = false)
|
||||
{
|
||||
IQueryable<T> query = _dbSet;
|
||||
|
||||
if (ignoreQueryFilters)
|
||||
{
|
||||
query = query.IgnoreQueryFilters();
|
||||
}
|
||||
|
||||
return predicate == null
|
||||
? await query.CountAsync()
|
||||
: await query.CountAsync(predicate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stages a new entity for insertion.
|
||||
/// The entity is not yet written to the database — call <see cref="IUnitOfWork.SaveChangesAsync"/>
|
||||
/// to commit. <see cref="ApplicationDbContext.SaveChangesAsync"/> will auto-stamp
|
||||
/// <c>CreatedAt</c>, <c>CreatedBy</c>, and <c>CompanyId</c> (if 0) before the INSERT.
|
||||
/// </summary>
|
||||
/// <param name="entity">The new entity to insert.</param>
|
||||
/// <returns>The same entity reference (useful for chaining or to read the auto-generated ID after save).</returns>
|
||||
public virtual async Task<T> AddAsync(T entity)
|
||||
{
|
||||
await _dbSet.AddAsync(entity);
|
||||
return entity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stages a collection of new entities for bulk insertion.
|
||||
/// More efficient than calling <see cref="AddAsync"/> in a loop because EF Core batches the
|
||||
/// INSERTs into a single round-trip.
|
||||
/// </summary>
|
||||
/// <param name="entities">The entities to insert.</param>
|
||||
/// <returns>The same collection reference passed in.</returns>
|
||||
public virtual async Task<IEnumerable<T>> AddRangeAsync(IEnumerable<T> entities)
|
||||
{
|
||||
await _dbSet.AddRangeAsync(entities);
|
||||
return entities;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks an entity as modified so that all its properties will be included in the next UPDATE.
|
||||
/// <para>
|
||||
/// Returns a completed <see cref="Task"/> synchronously because EF Core's
|
||||
/// <c>DbSet.Update</c> is synchronous — the async signature is preserved for interface
|
||||
/// consistency and to allow overrides that perform async validation.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="entity">The entity to update. Must be tracked or will be attached in the Modified state.</param>
|
||||
public virtual Task UpdateAsync(T entity)
|
||||
{
|
||||
_dbSet.Update(entity);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a collection of entities as modified for bulk update.
|
||||
/// EF Core batches the UPDATEs into a single round-trip on save.
|
||||
/// </summary>
|
||||
/// <param name="entities">The entities to update.</param>
|
||||
public virtual Task UpdateRangeAsync(IEnumerable<T> entities)
|
||||
{
|
||||
_dbSet.UpdateRange(entities);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Physically removes an entity from the database.
|
||||
/// <para>
|
||||
/// Use sparingly — most deletions should use <see cref="SoftDeleteAsync(T)"/> instead so that
|
||||
/// audit history is preserved and the entity remains visible to SuperAdmin queries.
|
||||
/// Physical delete is appropriate for draft records, temporary data, or entities whose
|
||||
/// referential integrity constraints prohibit soft delete.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="entity">The entity to physically remove.</param>
|
||||
public virtual async Task DeleteAsync(T entity)
|
||||
{
|
||||
_dbSet.Remove(entity);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the entity by <paramref name="id"/> and then physically removes it.
|
||||
/// If no entity is found (already deleted or not in this tenant), the operation is a no-op.
|
||||
/// </summary>
|
||||
/// <param name="id">The primary key of the entity to delete.</param>
|
||||
public virtual async Task DeleteAsync(int id)
|
||||
{
|
||||
var entity = await GetByIdAsync(id);
|
||||
if (entity != null)
|
||||
{
|
||||
await DeleteAsync(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Physically removes a collection of entities in a single SQL DELETE statement.
|
||||
/// EF Core translates <c>RemoveRange</c> into a batched DELETE for efficiency.
|
||||
/// </summary>
|
||||
/// <param name="entities">The entities to physically remove.</param>
|
||||
public virtual async Task DeleteRangeAsync(IEnumerable<T> entities)
|
||||
{
|
||||
_dbSet.RemoveRange(entities);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-deletes an entity by setting <c>IsDeleted = true</c> and <c>DeletedAt = UtcNow</c>.
|
||||
/// The row remains in the database and is visible to SuperAdmin queries with
|
||||
/// <c>ignoreQueryFilters: true</c>, preserving full audit history.
|
||||
/// The entity is then staged as Modified — call <see cref="IUnitOfWork.SaveChangesAsync"/> to commit.
|
||||
/// </summary>
|
||||
/// <param name="entity">The entity to soft-delete.</param>
|
||||
public virtual async Task SoftDeleteAsync(T entity)
|
||||
{
|
||||
entity.IsDeleted = true;
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
await UpdateAsync(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the entity by <paramref name="id"/> and soft-deletes it.
|
||||
/// If no entity is found (already deleted or not in this tenant), the operation is a no-op.
|
||||
/// </summary>
|
||||
/// <param name="id">The primary key of the entity to soft-delete.</param>
|
||||
public virtual async Task SoftDeleteAsync(int id)
|
||||
{
|
||||
var entity = await GetByIdAsync(id);
|
||||
if (entity != null)
|
||||
{
|
||||
await SoftDeleteAsync(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a page of results with the total count for pagination metadata.
|
||||
/// <para>
|
||||
/// <b>Design note</b>: the total count is computed <em>before</em> ordering and slicing so
|
||||
/// that callers always receive the accurate count for the current filter, regardless of page
|
||||
/// size. The ordering is applied after counting to minimise SQL complexity.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="pageNumber">1-based page index.</param>
|
||||
/// <param name="pageSize">Number of items per page.</param>
|
||||
/// <param name="filter">Optional predicate to narrow the result set; applied before count and paging.</param>
|
||||
/// <param name="orderBy">Optional ordering function; applied after counting but before slicing.</param>
|
||||
/// <param name="includes">Optional navigation-property selectors for eager loading.</param>
|
||||
/// <returns>A tuple of (Items, TotalCount) where TotalCount is the total matching rows before paging.</returns>
|
||||
public virtual async Task<(IEnumerable<T> Items, int TotalCount)> GetPagedAsync(
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
Expression<Func<T, bool>>? filter = null,
|
||||
Func<IQueryable<T>, IOrderedQueryable<T>>? orderBy = null,
|
||||
params Expression<Func<T, object>>[] includes)
|
||||
{
|
||||
IQueryable<T> query = _dbSet;
|
||||
|
||||
if (filter != null)
|
||||
{
|
||||
query = query.Where(filter);
|
||||
}
|
||||
|
||||
foreach (var include in includes)
|
||||
{
|
||||
query = query.Include(include);
|
||||
}
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
if (orderBy != null)
|
||||
{
|
||||
query = orderBy(query);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return (items, totalCount);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user