Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Repositories/Repository.cs
T
2026-04-23 21:38:24 -04:00

351 lines
15 KiB
C#

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&lt;T&gt;()</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);
}
}