diff --git a/src/After/DbBase.cs b/src/After/DbBase.cs index a3ec7aa..15ed8f8 100644 --- a/src/After/DbBase.cs +++ b/src/After/DbBase.cs @@ -4,9 +4,10 @@ namespace After; /// 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: /// domain objects (, ) no longer derive -/// from it. Only the 's own internal shadow entities do, -/// so nothing outside the persistence layer can ever reach a -/// through a domain object. +/// from it. Only the framework's own data-only persistence records do +/// (, ): storage wants data +/// without behaviour, so the domain keeps the behaviour and the framework +/// keeps the data — the mappers bridge the two shapes. /// public class DbBase { @@ -19,8 +20,8 @@ public class DbBase /// /// Back-reference to the owning . In the after - /// situation this lives only on the context's internal shadow entities — - /// the plain domain objects that used to inherit it no longer do. + /// situation this lives only on the persistence records — the plain + /// domain objects that used to inherit it no longer do. /// public DbContext? DbContext { get; internal set; } } \ No newline at end of file diff --git a/src/After/DbContext.cs b/src/After/DbContext.cs index 2215fbf..4380ff1 100644 --- a/src/After/DbContext.cs +++ b/src/After/DbContext.cs @@ -1,95 +1,120 @@ 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` strongly-typed. +// The persistence side of the "after" situation. The (fake) framework stores +// its own data-only records — / , +// both subclasses — never the domain objects themselves. +// Domain state reaches storage only through explicit mapping; the context keeps +// a reference-identity map so a second of the same domain +// object refreshes its row instead of duplicating it. // --------------------------------------------------------------------------- -/// Lightweight shadow holder kept alive inside the context. -public abstract class Shadow +/// +/// Persisted shape of a : data only — the orders are +/// separate rows, so nothing more is needed here. +/// +public sealed class ClientData : DbBase { - /// ID assigned when first persisted. - public Guid Id { get; set; } - - /// Back-reference to the owning (internal only). - public DbContext? DbContext { get; internal set; } + /// Display name of the client. + public string Name { get; set; } = string.Empty; } -/// Shadow holder for a persisted . -public sealed class ClientShadow : Shadow { /* no extra data */ } +/// +/// Persisted shape of an : 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. +/// +public sealed class OrderData : DbBase +{ + /// Free-text description of what this order is for. + public string Description { get; set; } = string.Empty; -/// Shadow holder for a persisted . -public sealed class OrderShadow : Shadow { /* no extra data */ } + /// + /// Foreign key to the owning client: when the + /// order has no client, or when its client had no id yet at save time — + /// save clients before their orders. + /// + public Guid ClientId { get; set; } +} /// -/// The fake DbContext, refactored to use per-type shadow collections -/// (mirroring EF Core's Set<T>) plus an instance-level -/// Find<T> query method. +/// The fake DbContext: per-type tables of data records, mirroring EF +/// Core's Set<T>. / +/// map the domain object's *current* state into its record — inserting on the +/// first save, refreshing in place on later ones. /// public class DbContext { - // ── per-type shadow tables ─────────────────────────────────────── - private readonly List _clients = new(); - private readonly List _orders = new(); + // ── per-type data tables ───────────────────────────────────────── + private readonly List _clients = new(); + private readonly List _orders = new(); - // ── fast lookup: domain object reference → its shadow ──────────── - private readonly Dictionary _byRef = new(); + // ── fast lookup: domain object reference → its persisted record ── + private readonly Dictionary _byRef = new(); - // ── exposed per-type tables (read-only view) ──────────────────── - public IReadOnlyList Clients => _clients.AsReadOnly(); - public IReadOnlyList Orders => _orders.AsReadOnly(); + // ── exposed per-type tables (read-only view) ───────────────────── + public IReadOnlyList Clients => _clients.AsReadOnly(); + public IReadOnlyList Orders => _orders.AsReadOnly(); /// Generic accessor: the strongly-typed "table" for . - public IReadOnlyList Set() where T : Shadow + public IReadOnlyList Set() where T : DbBase => typeof(T) switch { - var t when t == typeof(ClientShadow) => (IReadOnlyList)_clients.AsReadOnly(), - var t when t == typeof(OrderShadow) => (IReadOnlyList)_orders.AsReadOnly(), - _ => throw new NotSupportedException($"No per-type table for {typeof(T)}"), + var t when t == typeof(ClientData) => (IReadOnlyList)_clients.AsReadOnly(), + var t when t == typeof(OrderData) => (IReadOnlyList)_orders.AsReadOnly(), + _ => throw new NotSupportedException($"No per-type table for {typeof(T)}"), }; - // ── explicit persistence ──────────────────────────────────────── + // ── explicit persistence: map + insert, or refresh ─────────────── - /// Persist a : + /// + /// Persist a : the first save maps it to a fresh + /// row and writes the generated id back into the + /// domain object; a later save refreshes that row's data in place. + /// public void Save(Client client) { ArgumentNullException.ThrowIfNull(client); - if (!_byRef.TryAdd(client, new ClientShadow { DbContext = this })) - return; // already saved → id unchanged - var shadow = (ClientShadow)_byRef[client]; + var fresh = Mappers.ToClientData(client); + if (_byRef.TryGetValue(client, out var existing)) + { + ((ClientData)existing).Name = fresh.Name; // refresh, keep row identity + return; + } 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; } - /// Persist an . + /// + /// Persist an (same insert-or-refresh rule as + /// ; save its client first so the foreign key + /// can be filled). + /// public void Save(Order order) { ArgumentNullException.ThrowIfNull(order); - if (!_byRef.TryAdd(order, new OrderShadow { DbContext = this })) - return; // already saved → id unchanged - var shadow = (OrderShadow)_byRef[order]; - var id = Guid.NewGuid(); - shadow.Id = order.Id = id; // write-back identity - } - - /// Persist every tracked entity (mirrors EF's SaveChanges). - public int SaveChanges() - { - var count = 0; - foreach (var shadow in _byRef.Values) + var fresh = Mappers.ToOrderData(order); + if (_byRef.TryGetValue(order, out var existing)) { - if (shadow.Id != Guid.Empty) continue; // already had an id - shadow.Id = Guid.NewGuid(); - count++; + var row = (OrderData)existing; // refresh, keep row identity + row.Description = fresh.Description; + row.ClientId = fresh.ClientId; + return; } - return count; + var id = Guid.NewGuid(); + fresh.Id = order.Id = id; // write-back identity + fresh.DbContext = this; + _orders.Add(fresh); + _byRef[order] = fresh; } // ── query helpers ─────────────────────────────────────────────── - /// Search the per-type table for a shadow whose predicate matches. - public T? Find(Func predicate) where T : Shadow + /// Search the per-type table for a record whose predicate matches. + public T? Find(Func predicate) where T : DbBase => Set().FirstOrDefault(predicate); // ── tracking / status ─────────────────────────────────────────── @@ -97,6 +122,6 @@ public class DbContext /// True if has been saved through this context. public bool IsTracked(object entity) => _byRef.ContainsKey(entity); - /// All shadow entities this context currently manages. - public IReadOnlyList Tracked => _byRef.Values.ToList(); + /// All data records this context currently manages. + public IReadOnlyList Tracked => _byRef.Values.ToList(); } diff --git a/src/After/Mappers.cs b/src/After/Mappers.cs index 3da98d8..d883b54 100644 --- a/src/After/Mappers.cs +++ b/src/After/Mappers.cs @@ -1,13 +1,21 @@ namespace After; /// -/// Hand-written mappers between the plain domain and the DTOs (no AutoMapper — -/// the mapping code is part of the illustration). Each public method maps in -/// exactly one direction and never mutates its input. +/// Hand-written mappers out of the plain domain (no AutoMapper — the mapping +/// code is part of the illustration). Each public method maps in exactly one +/// direction and never mutates its input. Two families, two destinations: +/// +/// / — transport to the +/// WebApp; +/// / — the data-only +/// records the persistence framework stores. +/// /// /// The Client.Orders / Order.Client back-reference cycle is /// 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. /// public static class Mappers { @@ -99,4 +107,32 @@ public static class Mappers seen[dto] = order; return order; } + + // ------------------------------------------------------------------ + // Domain -> persistence (data-only records; graph flattened to ids) + // ------------------------------------------------------------------ + + /// Map a to its persistence record . + public static ClientData ToClientData(Client client) + { + ArgumentNullException.ThrowIfNull(client); + return new ClientData { Id = client.Id, Name = client.Name }; + } + + /// + /// Map an to its persistence record : + /// the object graph is flattened — becomes the + /// foreign key (empty when the order has + /// no client, or when that client has not been saved yet). + /// + public static OrderData ToOrderData(Order order) + { + ArgumentNullException.ThrowIfNull(order); + return new OrderData + { + Id = order.Id, + Description = order.Description, + ClientId = order.Client?.Id ?? Guid.Empty, + }; + } } \ No newline at end of file diff --git a/tests/BeforeAfter.Tests/AfterPersistenceTests.cs b/tests/BeforeAfter.Tests/AfterPersistenceTests.cs new file mode 100644 index 0000000..00739c6 --- /dev/null +++ b/tests/BeforeAfter.Tests/AfterPersistenceTests.cs @@ -0,0 +1,88 @@ +using After; + +namespace BeforeAfter.Tests; + +/// +/// The after stores data-only persistence records +/// ( / ), never domain objects: +/// 's Save 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. +/// +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()); + Assert.Single(db.Set()); + + var found = db.Find(row => row.Description == "one"); + Assert.Equal(order.Id, found!.Id); + Assert.Null(db.Find(row => row.Description == "no such order")); + } +}