persistence records carry data: ClientData/OrderData + domain->persistence mappers
The fake DbContext now stores data-only DbBase subclasses (renamed from the identity-only Shadow wrappers) and refreshes rows in place on re-Save; the object graph is flattened to ids in storage. yak: data-carrying-persistence-records-orderdataclientdata-mappers-tb3a
This commit is contained in:
+6
-5
@@ -4,9 +4,10 @@ namespace After;
|
|||||||
/// The DB base class still exists in the "after" situation — it is part of the
|
/// The DB base class still exists in the "after" situation — it is part of the
|
||||||
/// *persistence* layer — but the direction of the dependency is flipped:
|
/// *persistence* layer — but the direction of the dependency is flipped:
|
||||||
/// domain objects (<see cref="Client"/>, <see cref="Order"/>) no longer derive
|
/// domain objects (<see cref="Client"/>, <see cref="Order"/>) no longer derive
|
||||||
/// from it. Only the <see cref="DbContext"/>'s own internal shadow entities do,
|
/// from it. Only the framework's own data-only persistence records do
|
||||||
/// so nothing outside the persistence layer can ever reach a
|
/// (<see cref="ClientData"/>, <see cref="OrderData"/>): storage wants data
|
||||||
/// <see cref="DbContext"/> through a domain object.
|
/// without behaviour, so the domain keeps the behaviour and the framework
|
||||||
|
/// keeps the data — the mappers bridge the two shapes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DbBase
|
public class DbBase
|
||||||
{
|
{
|
||||||
@@ -19,8 +20,8 @@ public class DbBase
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Back-reference to the owning <see cref="DbContext"/>. In the after
|
/// Back-reference to the owning <see cref="DbContext"/>. In the after
|
||||||
/// situation this lives only on the context's internal shadow entities —
|
/// situation this lives only on the persistence records — the plain
|
||||||
/// the plain domain objects that used to inherit it no longer do.
|
/// domain objects that used to inherit it no longer do.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DbContext? DbContext { get; internal set; }
|
public DbContext? DbContext { get; internal set; }
|
||||||
}
|
}
|
||||||
+81
-56
@@ -1,95 +1,120 @@
|
|||||||
namespace After;
|
namespace After;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Internal shadow wrappers — live *inside* the persistence layer so the
|
// The persistence side of the "after" situation. The (fake) framework stores
|
||||||
// domain never sees a DbContext back-reference. Subclassed per-entity-type
|
// its own data-only records — <see cref="ClientData"/> / <see cref="OrderData"/>,
|
||||||
// solely to make `Set<T>` strongly-typed.
|
// both <see cref="DbBase"/> subclasses — never the domain objects themselves.
|
||||||
|
// Domain state reaches storage only through explicit mapping; the context keeps
|
||||||
|
// a reference-identity map so a second <see cref="Save"/> of the same domain
|
||||||
|
// object refreshes its row instead of duplicating it.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>Lightweight shadow holder kept alive inside the context.</summary>
|
/// <summary>
|
||||||
public abstract class Shadow
|
/// Persisted shape of a <see cref="Client"/>: data only — the orders are
|
||||||
|
/// separate <see cref="OrderData"/> rows, so nothing more is needed here.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClientData : DbBase
|
||||||
{
|
{
|
||||||
/// <summary>ID assigned when first persisted.</summary>
|
/// <summary>Display name of the client.</summary>
|
||||||
public Guid Id { get; set; }
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <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>
|
/// <summary>
|
||||||
public sealed class ClientShadow : Shadow { /* no extra data */ }
|
/// Persisted shape of an <see cref="Order"/>: data only. The object graph is
|
||||||
|
/// flattened for storage — the back-reference to the client becomes a foreign
|
||||||
|
/// key, because storage has ids, not object references.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OrderData : DbBase
|
||||||
|
{
|
||||||
|
/// <summary>Free-text description of what this order is for.</summary>
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Shadow holder for a persisted <see cref="Order"/>.</summary>
|
/// <summary>
|
||||||
public sealed class OrderShadow : Shadow { /* no extra data */ }
|
/// Foreign key to the owning client: <see cref="Guid.Empty"/> when the
|
||||||
|
/// order has no client, or when its client had no id yet at save time —
|
||||||
|
/// save clients before their orders.
|
||||||
|
/// </summary>
|
||||||
|
public Guid ClientId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The fake <c>DbContext</c>, refactored to use per-type shadow collections
|
/// The fake <c>DbContext</c>: per-type tables of data records, mirroring EF
|
||||||
/// (mirroring EF Core's <c>Set<T></c>) plus an instance-level
|
/// Core's <c>Set<T></c>. <see cref="Save(Client)"/> / <see cref="Save(Order)"/>
|
||||||
/// <c>Find<T></c> query method.
|
/// map the domain object's *current* state into its record — inserting on the
|
||||||
|
/// first save, refreshing in place on later ones.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DbContext
|
public class DbContext
|
||||||
{
|
{
|
||||||
// ── per-type shadow tables ───────────────────────────────────────
|
// ── per-type data tables ─────────────────────────────────────────
|
||||||
private readonly List<ClientShadow> _clients = new();
|
private readonly List<ClientData> _clients = new();
|
||||||
private readonly List<OrderShadow> _orders = new();
|
private readonly List<OrderData> _orders = new();
|
||||||
|
|
||||||
// ── fast lookup: domain object reference → its shadow ────────────
|
// ── fast lookup: domain object reference → its persisted record ──
|
||||||
private readonly Dictionary<object, Shadow> _byRef = new();
|
private readonly Dictionary<object, DbBase> _byRef = new();
|
||||||
|
|
||||||
// ── exposed per-type tables (read-only view) ────────────────────
|
// ── exposed per-type tables (read-only view) ─────────────────────
|
||||||
public IReadOnlyList<ClientShadow> Clients => _clients.AsReadOnly();
|
public IReadOnlyList<ClientData> Clients => _clients.AsReadOnly();
|
||||||
public IReadOnlyList<OrderShadow> Orders => _orders.AsReadOnly();
|
public IReadOnlyList<OrderData> Orders => _orders.AsReadOnly();
|
||||||
|
|
||||||
/// <summary>Generic accessor: the strongly-typed "table" for <typeparamref name="T"/>.</summary>
|
/// <summary>Generic accessor: the strongly-typed "table" for <typeparamref name="T"/>.</summary>
|
||||||
public IReadOnlyList<T> Set<T>() where T : Shadow
|
public IReadOnlyList<T> Set<T>() where T : DbBase
|
||||||
=> typeof(T) switch
|
=> typeof(T) switch
|
||||||
{
|
{
|
||||||
var t when t == typeof(ClientShadow) => (IReadOnlyList<T>)_clients.AsReadOnly(),
|
var t when t == typeof(ClientData) => (IReadOnlyList<T>)_clients.AsReadOnly(),
|
||||||
var t when t == typeof(OrderShadow) => (IReadOnlyList<T>)_orders.AsReadOnly(),
|
var t when t == typeof(OrderData) => (IReadOnlyList<T>)_orders.AsReadOnly(),
|
||||||
_ => throw new NotSupportedException($"No per-type table for {typeof(T)}"),
|
_ => throw new NotSupportedException($"No per-type table for {typeof(T)}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── explicit persistence ────────────────────────────────────────
|
// ── explicit persistence: map + insert, or refresh ───────────────
|
||||||
|
|
||||||
/// <summary>Persist a <see cref="Client">:</summary>
|
/// <summary>
|
||||||
|
/// Persist a <see cref="Client"/>: the first save maps it to a fresh
|
||||||
|
/// <see cref="ClientData"/> row and writes the generated id back into the
|
||||||
|
/// domain object; a later save refreshes that row's data in place.
|
||||||
|
/// </summary>
|
||||||
public void Save(Client client)
|
public void Save(Client client)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(client);
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
if (!_byRef.TryAdd(client, new ClientShadow { DbContext = this }))
|
var fresh = Mappers.ToClientData(client);
|
||||||
return; // already saved → id unchanged
|
if (_byRef.TryGetValue(client, out var existing))
|
||||||
var shadow = (ClientShadow)_byRef[client];
|
{
|
||||||
|
((ClientData)existing).Name = fresh.Name; // refresh, keep row identity
|
||||||
|
return;
|
||||||
|
}
|
||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
shadow.Id = client.Id = id; // write-back identity
|
fresh.Id = client.Id = id; // write-back identity
|
||||||
|
fresh.DbContext = this;
|
||||||
|
_clients.Add(fresh);
|
||||||
|
_byRef[client] = fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Persist an <see cref="Order">.</summary>
|
/// <summary>
|
||||||
|
/// Persist an <see cref="Order"/> (same insert-or-refresh rule as
|
||||||
|
/// <see cref="Save(Client)"/>; save its client first so the foreign key
|
||||||
|
/// can be filled).
|
||||||
|
/// </summary>
|
||||||
public void Save(Order order)
|
public void Save(Order order)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(order);
|
ArgumentNullException.ThrowIfNull(order);
|
||||||
if (!_byRef.TryAdd(order, new OrderShadow { DbContext = this }))
|
var fresh = Mappers.ToOrderData(order);
|
||||||
return; // already saved → id unchanged
|
if (_byRef.TryGetValue(order, out var existing))
|
||||||
var shadow = (OrderShadow)_byRef[order];
|
{
|
||||||
|
var row = (OrderData)existing; // refresh, keep row identity
|
||||||
|
row.Description = fresh.Description;
|
||||||
|
row.ClientId = fresh.ClientId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
shadow.Id = order.Id = id; // write-back identity
|
fresh.Id = order.Id = id; // write-back identity
|
||||||
}
|
fresh.DbContext = this;
|
||||||
|
_orders.Add(fresh);
|
||||||
/// <summary>Persist every tracked entity (mirrors EF's <c>SaveChanges</c>).</summary>
|
_byRef[order] = fresh;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── query helpers ───────────────────────────────────────────────
|
// ── query helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>Search the per-type table for a shadow whose predicate matches.</summary>
|
/// <summary>Search the per-type table for a record whose predicate matches.</summary>
|
||||||
public T? Find<T>(Func<T, bool> predicate) where T : Shadow
|
public T? Find<T>(Func<T, bool> predicate) where T : DbBase
|
||||||
=> Set<T>().FirstOrDefault(predicate);
|
=> Set<T>().FirstOrDefault(predicate);
|
||||||
|
|
||||||
// ── tracking / status ───────────────────────────────────────────
|
// ── tracking / status ───────────────────────────────────────────
|
||||||
@@ -97,6 +122,6 @@ public class DbContext
|
|||||||
/// <summary>True if <paramref name="entity"/> has been saved through this context.</summary>
|
/// <summary>True if <paramref name="entity"/> has been saved through this context.</summary>
|
||||||
public bool IsTracked(object entity) => _byRef.ContainsKey(entity);
|
public bool IsTracked(object entity) => _byRef.ContainsKey(entity);
|
||||||
|
|
||||||
/// <summary>All shadow entities this context currently manages.</summary>
|
/// <summary>All data records this context currently manages.</summary>
|
||||||
public IReadOnlyList<Shadow> Tracked => _byRef.Values.ToList();
|
public IReadOnlyList<DbBase> Tracked => _byRef.Values.ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-4
@@ -1,13 +1,21 @@
|
|||||||
namespace After;
|
namespace After;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Hand-written mappers between the plain domain and the DTOs (no AutoMapper —
|
/// Hand-written mappers out of the plain domain (no AutoMapper — the mapping
|
||||||
/// the mapping code is part of the illustration). Each public method maps in
|
/// code is part of the illustration). Each public method maps in exactly one
|
||||||
/// exactly one direction and never mutates its input.
|
/// direction and never mutates its input. Two families, two destinations:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><see cref="ClientDto"/>/<see cref="OrderDto"/> — transport to the
|
||||||
|
/// WebApp;</item>
|
||||||
|
/// <item><see cref="ClientData"/>/<see cref="OrderData"/> — the data-only
|
||||||
|
/// records the persistence framework stores.</item>
|
||||||
|
/// </list>
|
||||||
///
|
///
|
||||||
/// The <c>Client.Orders</c> / <c>Order.Client</c> back-reference cycle is
|
/// The <c>Client.Orders</c> / <c>Order.Client</c> back-reference cycle is
|
||||||
/// resolved with a reference-identity cache: the same instance maps to the
|
/// resolved with a reference-identity cache: the same instance maps to the
|
||||||
/// same DTO (and back), so the graph terminates and stays consistent.
|
/// same DTO (and back), so the graph terminates and stays consistent. The
|
||||||
|
/// persistence mapping needs no cache: records reference each other by id,
|
||||||
|
/// not by object, so the graph is flattened and cannot cycle.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Mappers
|
public static class Mappers
|
||||||
{
|
{
|
||||||
@@ -99,4 +107,32 @@ public static class Mappers
|
|||||||
seen[dto] = order;
|
seen[dto] = order;
|
||||||
return order;
|
return order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Domain -> persistence (data-only records; graph flattened to ids)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Map a <see cref="Client"/> to its persistence record <see cref="ClientData"/>.</summary>
|
||||||
|
public static ClientData ToClientData(Client client)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
return new ClientData { Id = client.Id, Name = client.Name };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Map an <see cref="Order"/> to its persistence record <see cref="OrderData"/>:
|
||||||
|
/// the object graph is flattened — <see cref="Order.Client"/> becomes the
|
||||||
|
/// <see cref="OrderData.ClientId"/> foreign key (empty when the order has
|
||||||
|
/// no client, or when that client has not been saved yet).
|
||||||
|
/// </summary>
|
||||||
|
public static OrderData ToOrderData(Order order)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(order);
|
||||||
|
return new OrderData
|
||||||
|
{
|
||||||
|
Id = order.Id,
|
||||||
|
Description = order.Description,
|
||||||
|
ClientId = order.Client?.Id ?? Guid.Empty,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using After;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The after <see cref="DbContext"/> stores data-only persistence records
|
||||||
|
/// (<see cref="ClientData"/> / <see cref="OrderData"/>), never domain objects:
|
||||||
|
/// <see cref="DbContext"/>'s <c>Save</c> maps domain state into a row —
|
||||||
|
/// inserting on the first save, refreshing in place on later ones — and the
|
||||||
|
/// stored graph is flattened to ids.
|
||||||
|
/// </summary>
|
||||||
|
public class AfterPersistenceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Save_maps_domain_state_into_data_records()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var client = new Client { Name = "Acme" };
|
||||||
|
var order = new Order { Description = "one" };
|
||||||
|
client.Orders.Add(order);
|
||||||
|
|
||||||
|
db.Save(client);
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
var clientRow = Assert.Single(db.Clients);
|
||||||
|
Assert.Equal(client.Id, clientRow.Id);
|
||||||
|
Assert.Equal("Acme", clientRow.Name);
|
||||||
|
|
||||||
|
var orderRow = Assert.Single(db.Orders);
|
||||||
|
Assert.Equal(order.Id, orderRow.Id);
|
||||||
|
Assert.Equal("one", orderRow.Description);
|
||||||
|
Assert.Equal(client.Id, orderRow.ClientId); // the graph is flattened to a key
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void An_order_without_client_stores_an_empty_client_id()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var order = new Order { Description = "orphan" };
|
||||||
|
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
Assert.Equal(Guid.Empty, Assert.Single(db.Orders).ClientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Saving_again_refreshes_the_row_instead_of_duplicating_it()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var client = new Client { Name = "Acme" };
|
||||||
|
var order = new Order { Description = "one" };
|
||||||
|
client.Orders.Add(order);
|
||||||
|
db.Save(client);
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
order.Description = "one (edited)";
|
||||||
|
client.Name = "Acme GmbH";
|
||||||
|
db.Save(order);
|
||||||
|
db.Save(client);
|
||||||
|
|
||||||
|
// One row per saved object, refreshed with the *current* state:
|
||||||
|
var orderRow = Assert.Single(db.Orders);
|
||||||
|
Assert.Equal(order.Id, orderRow.Id);
|
||||||
|
Assert.Equal("one (edited)", orderRow.Description);
|
||||||
|
Assert.Equal(client.Id, orderRow.ClientId);
|
||||||
|
|
||||||
|
var clientRow = Assert.Single(db.Clients);
|
||||||
|
Assert.Equal("Acme GmbH", clientRow.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rows_are_reachable_through_Set_and_Find()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var client = new Client { Name = "Acme" };
|
||||||
|
var order = new Order { Description = "one" };
|
||||||
|
client.Orders.Add(order);
|
||||||
|
db.Save(client);
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
Assert.Single(db.Set<ClientData>());
|
||||||
|
Assert.Single(db.Set<OrderData>());
|
||||||
|
|
||||||
|
var found = db.Find<OrderData>(row => row.Description == "one");
|
||||||
|
Assert.Equal(order.Id, found!.Id);
|
||||||
|
Assert.Null(db.Find<OrderData>(row => row.Description == "no such order"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user