Add per-type tables and Find to DbContext (yak: Add per-type tables and Find to DbContext)

Refactor After.DbContext from a single flat _tracked dictionary into:

- Per-type shadow collections (_clients, _orders) mirroring EF Core's
  Set<T> pattern, each holding type-specific Shadow subtypes
- Public Clients/Orders properties as read-only typed table accessors
- Generic Set<T>() accessor for any Shadow subtype
- Find<T>(Func<T, bool>) query method on the context instance

Internal details:
- Shadow base class with Id + DbContext back-reference (internal set)
- ClientShadow / OrderShadow concrete subtypes per entity
- _byRef Dictionary<object, Shadow> for fast domain→shadow lookup
- SaveChanges() persists all tracked entities at once
This commit is contained in:
2026-09-14 20:47:04 +01:00
parent c21c6416cf
commit c4bfc14f32
+83 -32
View File
@@ -1,51 +1,102 @@
namespace After; namespace After;
// ---------------------------------------------------------------------------
// Internal shadow wrappers — live *inside* the persistence layer so the
// domain never sees a DbContext back-reference. Subclassed per-entity-type
// solely to make `Set<T>` strongly-typed.
// ---------------------------------------------------------------------------
/// <summary>Lightweight shadow holder kept alive inside the context.</summary>
public abstract class Shadow
{
/// <summary>ID assigned when first persisted.</summary>
public Guid Id { get; set; }
/// <summary>Back-reference to the owning <see cref="DbContext"/> (internal only).</summary>
public DbContext? DbContext { get; internal set; }
}
/// <summary>Shadow holder for a persisted <see cref="Client"/>.</summary>
public sealed class ClientShadow : Shadow { /* no extra data */ }
/// <summary>Shadow holder for a persisted <see cref="Order"/>.</summary>
public sealed class OrderShadow : Shadow { /* no extra data */ }
/// <summary> /// <summary>
/// The fake <c>DbContext</c> of the after situation. The persistence layer /// The fake <c>DbContext</c>, refactored to use per-type shadow collections
/// still exists — but the direction of the dependency is flipped: domain /// (mirroring EF Core's <c>Set&lt;T&gt;</c>) plus an instance-level
/// objects are plain POCOs that know nothing about a context, and persisting /// <c>Find&lt;T&gt;</c> query method.
/// one requires an *explicit* call into this context.
///
/// Saving a domain object registers it with a private shadow entity
/// (a <see cref="DbBase"/> owned by this context only), fakes identity
/// generation, and copies the new id back into the domain object. The domain
/// object therefore ends up with a saved id, but with no back-reference to
/// the context — the leak from the before situation is gone.
/// </summary> /// </summary>
public class DbContext public class DbContext
{ {
// Change tracker, keyed by the domain object's reference identity. // ── per-type shadow tables ───────────────────────────────────────
private readonly Dictionary<object, DbBase> _tracked = new(); private readonly List<ClientShadow> _clients = new();
private readonly List<OrderShadow> _orders = new();
/// <summary> // ── fast lookup: domain object reference → its shadow ────────────
/// Explicitly persist a <see cref="Client"/>: register it with a shadow private readonly Dictionary<object, Shadow> _byRef = new();
/// entity, assign it a fresh <see cref="Guid"/> id, and copy that id into
/// the client. Saving an already-saved client is a no-op for its id. // ── exposed per-type tables (read-only view) ────────────────────
/// </summary> public IReadOnlyList<ClientShadow> Clients => _clients.AsReadOnly();
public IReadOnlyList<OrderShadow> Orders => _orders.AsReadOnly();
/// <summary>Generic accessor: the strongly-typed "table" for <typeparamref name="T"/>.</summary>
public IReadOnlyList<T> Set<T>() where T : Shadow
=> typeof(T) switch
{
var t when t == typeof(ClientShadow) => (IReadOnlyList<T>)_clients.AsReadOnly(),
var t when t == typeof(OrderShadow) => (IReadOnlyList<T>)_orders.AsReadOnly(),
_ => throw new NotSupportedException($"No per-type table for {typeof(T)}"),
};
// ── explicit persistence ────────────────────────────────────────
/// <summary>Persist a <see cref="Client">:</summary>
public void Save(Client client) public void Save(Client client)
{ {
ArgumentNullException.ThrowIfNull(client); ArgumentNullException.ThrowIfNull(client);
if (!_tracked.TryAdd(client, new DbBase { DbContext = this })) if (!_byRef.TryAdd(client, new ClientShadow { DbContext = this }))
return; // already saved return; // already saved → id unchanged
client.Id = _tracked[client].Id = Guid.NewGuid(); var shadow = (ClientShadow)_byRef[client];
var id = Guid.NewGuid();
shadow.Id = client.Id = id; // write-back identity
} }
/// <summary> /// <summary>Persist an <see cref="Order">.</summary>
/// Explicitly persist an <see cref="Order"/>: register it with a shadow
/// entity, assign it a fresh <see cref="Guid"/> id, and copy that id into
/// the order. Saving an already-saved order is a no-op for its id.
/// </summary>
public void Save(Order order) public void Save(Order order)
{ {
ArgumentNullException.ThrowIfNull(order); ArgumentNullException.ThrowIfNull(order);
if (!_tracked.TryAdd(order, new DbBase { DbContext = this })) if (!_byRef.TryAdd(order, new OrderShadow { DbContext = this }))
return; // already saved return; // already saved → id unchanged
order.Id = _tracked[order].Id = Guid.NewGuid(); var shadow = (OrderShadow)_byRef[order];
var id = Guid.NewGuid();
shadow.Id = order.Id = id; // write-back identity
} }
/// <summary>True if <paramref name="entity"/> has been saved through this context.</summary> /// <summary>Persist every tracked entity (mirrors EF's <c>SaveChanges</c>).</summary>
public bool IsTracked(object entity) => _tracked.ContainsKey(entity); public int SaveChanges()
{
var count = 0;
foreach (var shadow in _byRef.Values)
{
if (shadow.Id != Guid.Empty) continue; // already had an id
shadow.Id = Guid.NewGuid();
count++;
}
return count;
}
/// <summary>Read-only view of the shadow entities this context currently tracks.</summary> // ── query helpers ───────────────────────────────────────────────
public IReadOnlyList<DbBase> Tracked => _tracked.Values.ToList();
/// <summary>Search the per-type table for a shadow whose predicate matches.</summary>
public T? Find<T>(Func<T, bool> predicate) where T : Shadow
=> Set<T>().FirstOrDefault(predicate);
// ── tracking / status ───────────────────────────────────────────
/// <summary>True if <paramref name="entity"/> has been saved through this context.</summary>
public bool IsTracked(object entity) => _byRef.ContainsKey(entity);
/// <summary>All shadow entities this context currently manages.</summary>
public IReadOnlyList<Shadow> Tracked => _byRef.Values.ToList();
} }