Add per-type tables and Find to DbContext (yak: add-per-type-tables-and-find-to-dbcontext-engc)

Refactor DbContext to use per-type internal storage (Dictionary<Type, Collection>)
instead of a single flat List<DbBase>, mirroring EF Core's DbSet<T> pattern.

Changes:
- Replace _tracked List<DbBase> with _tables Dictionary<Type, Collection<DbBase>>
- Add Table<T>() generic helper for compile-time known types (Find<T>)
- Add TableFor(Type) non-generic helper for Attach where type is only known at runtime
- Add instance-level Find<T>(Predicate<T>) method on DbContext that searches only
  the per-type table for T (mirroring DbSet<T>.Find behavior)
- Keep Tracked { get } as a flattened view across all per-type tables (API compatible)
- IsTracked now walks all per-type tables
This commit is contained in:
2026-09-14 20:56:27 +01:00
parent 58f7a1184a
commit 68b9657d62
+97 -22
View File
@@ -1,9 +1,11 @@
using System.Collections.ObjectModel;
namespace Before; namespace Before;
/// <summary> /// <summary>
/// A tiny hand-rolled fake of EF's <c>DbContext</c>: it keeps a change-tracker /// A tiny hand-rolled fake of EF's <c>DbContext</c>: it keeps per-type
/// (a registration collection of the <see cref="DbBase"/> entities it knows /// change-tracker tables (mirroring EF's <see cref="DbSet{T}"/> pattern) and
/// about) and a <see cref="Save"/> that fakes <c>SaveChangesAsync</c>. /// a <see cref="Save"/> that fakes <c>SaveChangesAsync</c>.
/// ///
/// This is deliberately NOT real EF Core — no providers, no SQLite, no NuGet /// This is deliberately NOT real EF Core — no providers, no SQLite, no NuGet
/// beyond xUnit. The point of the exercise is the *shape of the dependencies* /// beyond xUnit. The point of the exercise is the *shape of the dependencies*
@@ -11,25 +13,57 @@ namespace Before;
/// </summary> /// </summary>
public class DbContext public class DbContext
{ {
// The change tracker: the set of entities this context is responsible for. // Per-type tables: each entity type has its own typed collection,
// (EF calls this its change tracker; a list stands in for the // mimicking EF Core's <see cref="DbSet{T}"/> model where the DbContext
// id -> entity registration dictionary.) // maintains a separate set per entity type.
private readonly List<DbBase> _tracked = new(); private readonly Dictionary<Type, Collection<DbBase>> _tables = new();
/// <summary>
/// Get or create the per-type table for <typeparamref name="T"/>.
/// This mirrors EF's <see cref="DbSet{T}"/> / <c>Set&lt;T&gt;</c> accessor.
/// </summary>
private Collection<DbBase> Table<T>() where T : DbBase
{
var type = typeof(T);
if (!_tables.TryGetValue(type, out var table))
{
table = new Collection<DbBase>();
_tables[type] = table;
}
return table;
}
/// <summary>
/// Get or create the per-type table for the given runtime <paramref name="type"/>.
/// Called from <see cref="Attach"/> where we only know the type at runtime.
/// </summary>
private Collection<DbBase> TableFor(Type type)
{
if (!_tables.TryGetValue(type, out var table))
{
table = new Collection<DbBase>();
_tables[type] = table;
}
return table;
}
/// <summary> /// <summary>
/// Attach an entity to this context (EF's <c>Add</c>). Sets the /// Attach an entity to this context (EF's <c>Add</c>). Sets the
/// active-record back-reference so the entity knows its owner. /// active-record back-reference so the entity knows its owner.
/// The entity is placed into the per-type table that matches its
/// runtime type — just as EF writes rows to the correct table.
/// </summary> /// </summary>
public void Attach(DbBase entity) public void Attach(DbBase entity)
{ {
ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(entity);
entity.DbContext = this; entity.DbContext = this;
if (!_tracked.Contains(entity)) var table = TableFor(entity.GetType());
_tracked.Add(entity); if (!table.Contains(entity))
table.Add(entity);
} }
/// <summary> /// <summary>
/// Fake <c>SaveChangesAsync</c>: walk the tracked entities and assign a /// Fake <c>SaveChangesAsync</c>: walk all per-type tables and assign a
/// fresh <see cref="Guid"/> to any whose id is still <see cref="Guid.Empty"/>. /// fresh <see cref="Guid"/> to any whose id is still <see cref="Guid.Empty"/>.
/// Returns the number of entities that were (re)saved — i.e. newly /// Returns the number of entities that were (re)saved — i.e. newly
/// identified — mirroring EF's "rows written" return value. /// identified — mirroring EF's "rows written" return value.
@@ -37,20 +71,61 @@ public class DbContext
public int Save() public int Save()
{ {
var saved = 0; var saved = 0;
foreach (var entity in _tracked) foreach (var table in _tables.Values)
{ foreach (var entity in table)
if (entity.Id == Guid.Empty) if (entity.Id == Guid.Empty)
{ {
entity.Id = Guid.NewGuid(); entity.Id = Guid.NewGuid();
saved++; saved++;
} }
}
return saved; return saved;
} }
/// <summary>True if <paramref name="entity"/> is in this context's tracker.</summary> /// <summary>
public bool IsTracked(DbBase entity) => _tracked.Contains(entity); /// Find the first entity of type <typeparamref name="T"/> in this
/// context's per-type table that satisfies <paramref name="predicate"/>.
/// Only checks entities whose runtime type exactly matches <typeparamref name="T"/>
/// — just as EF's <c>DbSet{T}.Find</c> operates on a single table.
///
/// This instance-level finder complements the static
/// <see cref="DbBase.Find{T}(Predicate{T},DbContext?)"/>.
/// </summary>
/// <typeparam name="T">Entity type to find (must derive from <see cref="DbBase"/>).</typeparam>
/// <param name="predicate">Filter applied to candidates of type <typeparamref name="T"/>.</param>
/// <returns>The first matching entity, or <c>null</c> when no match.</returns>
public T? Find<T>(Predicate<T> predicate) where T : DbBase
{
var table = Table<T>();
foreach (var e in table)
if (e is T candidate && predicate(candidate))
return candidate;
return default;
}
/// <summary>Read-only view of the entities this context currently tracks.</summary> /// <summary>
public IReadOnlyList<DbBase> Tracked => _tracked; /// True if <paramref name="entity"/> is in this context's tracker
/// (walks all per-type tables).
/// </summary>
public bool IsTracked(DbBase entity)
{
foreach (var table in _tables.Values)
if (table.Contains(entity))
return true;
return false;
}
/// <summary>
/// Read-only view of all entities this context currently tracks
/// (flattened across all per-type tables).
/// </summary>
public IReadOnlyList<DbBase> Tracked
{
get
{
var all = new List<DbBase>();
foreach (var table in _tables.Values)
all.AddRange(table);
return all.AsReadOnly();
}
}
} }