Compare commits

...
12 Commits
Author SHA1 Message Date
mostalive 01ba13f94d Merge branch 'main' of ssh://gitea.apps.sustainabledelivery.com:3022/learning-hours/db-subclass-to-dto
# Conflicts:
#	after-dto.mmd.svg
#	before-after-dto.mmd.svg
2026-09-14 22:48:48 +01:00
mostalive 08a6fa757a svgs 2026-09-14 22:45:03 +01:00
mostalive ec9586c316 Throw ObjectNotFoundException from FindRequired; add run-console wrappers
- DbBase.FindRequired / Client.FindByNameRequired now throw the
  domain-specific ObjectNotFoundException (NHibernate lineage; Rails
  equivalent: ActiveRecord::RecordNotFound) on a lookup miss; the
  no-context case stays InvalidOperationException (configuration error)
- Console demo catches the new type; tests (18)/(21) assert it,
  (19)/(23) keep InvalidOperationException with rationale comments
- scripts/run-console.sh delegates to dotnet.sh; run-console.ps1 is
  the Windows/PowerShell counterpart (mise-first, repo-root cd)

Yak: objectnotfoundexception-run-console-wrappers-3p8b
2026-09-14 22:39:21 +01:00
mostalive 4b6e6f5a00 Add FindByNameRequired throwing variant (yak: Add FindByName! throwing variant) 2026-09-14 21:23:57 +01:00
mostalive 325925a592 Find falls back to DbBase.Context singleton when no context passed
DbBase.Find<T> now uses db ?? Context, and Client.FindByName's context
parameter becomes optional — call sites read like Client.FindByName("Jane
Doe") without context threading. A null result now overwhelmingly means
"no match"; the residual "no context configured at all" ambiguity is
documented in remarks as part of the ActiveRecord-leak cost this exercise
illustrates.
2026-09-14 21:17:03 +01:00
mostalive 68b9657d62 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
2026-09-14 20:56:27 +01:00
mostalive 58f7a1184a Add tests for ActiveRecord functionality (DbBase.Find<T>, Client.FindByName, DbBase.Context singleton) 2026-09-14 20:53:04 +01:00
mostalive 06a12e55f8 Add Before.Console — console app demonstrating ActiveRecord pattern
Scaffold Before.Console project referencing the Before library.
Implements a straight-line demo that creates clients/orders, saves
them via DbBase.Context (singleton), lists all clients with orders,
and finds a client by name using Client.FindByName().
2026-09-14 20:50:25 +01:00
mostalive 8b46fccf08 Add static Context (Singleton) to DbBase
ActiveRecord pattern: expose a shared DbContext on DbBase so any
entity subclass can reach it without passing context through parameters.
The instance-level DbContext back-reference still works; the static one
provides a class-level global fallback.
2026-09-14 20:48:21 +01:00
mostalive c4bfc14f32 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
2026-09-14 20:47:04 +01:00
mostalive c21c6416cf Add DbBase.Find<T> static generic finder (yak: Console app for Before (ActiveRecord-style) > ○ Add DbBase.Find<T> static generic) 2026-09-14 20:45:12 +01:00
mostalive 92b9e3f784 Console app for Before (ActiveRecord-style) > Add Client.FindByName convenience wrapper 2026-09-14 20:43:47 +01:00
12 changed files with 831 additions and 58 deletions
+10
View File
@@ -26,6 +26,16 @@ agent/sandbox shells, and SDK 10 additionally fails without
scripts/dotnet.sh build db-subclass-to-dto.sln scripts/dotnet.sh build db-subclass-to-dto.sln
scripts/dotnet.sh test db-subclass-to-dto.sln scripts/dotnet.sh test db-subclass-to-dto.sln
scripts/run-tests.sh # full test suite, from repo root scripts/run-tests.sh # full test suite, from repo root
scripts/run-console.sh # run the Before.Console demo (bash; delegates
# to dotnet.sh); app args go after `--`
```
Windows/PowerShell counterpart:
```powershell
scripts\run-console.ps1 # run the Before.Console demo; app args go
# directly (the script inserts `--` for
# dotnet run); needs no DOTNET_CLI_HOME setup
``` ```
The wrappers cd to the repo root (the test runner must run from the parent The wrappers cd to the repo root (the test runner must run from the parent
+16 -1
View File
@@ -1,4 +1,4 @@
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59 VisualStudioVersion = 17.0.31903.59
@@ -17,6 +17,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExtractMethod", "tools\ExtractMethod\ExtractMethod.csproj", "{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExtractMethod", "tools\ExtractMethod\ExtractMethod.csproj", "{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Before.Console", "src\Before.Console\Before.Console.csproj", "{AC39E693-E83D-4B50-A120-0DE259108AF2}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -75,6 +77,18 @@ Global
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x64.Build.0 = Release|Any CPU {3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x64.Build.0 = Release|Any CPU
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x86.ActiveCfg = Release|Any CPU {3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x86.ActiveCfg = Release|Any CPU
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x86.Build.0 = Release|Any CPU {3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x86.Build.0 = Release|Any CPU
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|x64.ActiveCfg = Debug|Any CPU
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|x64.Build.0 = Debug|Any CPU
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|x86.ActiveCfg = Debug|Any CPU
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|x86.Build.0 = Debug|Any CPU
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|Any CPU.Build.0 = Release|Any CPU
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|x64.ActiveCfg = Release|Any CPU
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|x64.Build.0 = Release|Any CPU
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|x86.ActiveCfg = Release|Any CPU
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -84,5 +98,6 @@ Global
{E65C0410-A863-44B0-88A4-6C74EF929419} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {E65C0410-A863-44B0-88A4-6C74EF929419} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{F5E0C182-CBC3-4440-AF8F-32887F9B589C} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {F5E0C182-CBC3-4440-AF8F-32887F9B589C} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} {3E8944E9-BB07-424B-B3A6-59FB072BAB3C} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
{AC39E693-E83D-4B50-A120-0DE259108AF2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
+39
View File
@@ -0,0 +1,39 @@
# Run the Before.Console demo app (src/Before.Console/Program.cs) on Windows.
#
# Windows counterpart of scripts/run-console.sh:
# - runs from the repo root, like the other scripts
# - prefers mise (see mise.toml) and falls back to a dotnet on PATH
#
# Troubleshooting: if dotnet fails with "The user's home directory could not
# be determined" (some sandboxed shells), set DOTNET_CLI_HOME first:
# PS> $env:DOTNET_CLI_HOME = "$PWD\.dotnet-cli"
#
# Usage (from anywhere):
# PS> .\scripts\run-console.ps1 # no args
# PS> .\scripts\run-console.ps1 --verbose # args go to the program
# The script inserts the `--` separator for dotnet run itself.
#
# If script execution is blocked by policy:
# PS> powershell -ExecutionPolicy Bypass -File .\scripts\run-console.ps1
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $PSScriptRoot
Push-Location $repoRoot
try {
$dotnetArgs = @('run', '--project', 'src/Before.Console')
if ($args.Count -gt 0) {
$dotnetArgs += '--'
$dotnetArgs += $args
}
if (Get-Command mise -ErrorAction SilentlyContinue) {
mise exec -- dotnet @dotnetArgs
}
else {
dotnet @dotnetArgs
}
exit $LASTEXITCODE
}
finally {
Pop-Location
}
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Run the Before.Console demo app (src/Before.Console/Program.cs).
# Delegates to scripts/dotnet.sh — see there for why the wrapper exists
# (mise-installed dotnet, DOTNET_CLI_HOME, run from repo root).
#
# Usage: scripts/run-console.sh [app args]
# scripts/run-console.sh # no args
# scripts/run-console.sh -- --verbose # args are passed to the program
set -euo pipefail
exec "$(cd "$(dirname "$0")" && pwd)/dotnet.sh" run --project src/Before.Console -- "$@"
+84 -33
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();
}
+14
View File
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Before\Before.csproj" />
</ItemGroup>
</Project>
+83
View File
@@ -0,0 +1,83 @@
using Before;
// ---------------------------------------------------------------------------
// Before.Console — a straight-line demo of the ActiveRecord-style pattern.
//
// The console app sets DbBase.Context (the shared singleton) once at startup,
// then creates client/order entities, saves them through the context, and
// queries back using the ActiveRecord entry points:
// • Client.FindByName(name) — no context argument: rides the
// DbBase.Context singleton
// • listing via DbBase.Context!.Tracked
// ---------------------------------------------------------------------------
var ctx = new DbContext();
DbBase.Context = ctx;
// ----- seed data ----------------------------------------------------------
var jane = new Client { Name = "Jane Doe" };
var bob = new Client { Name = "Bob Smith" };
jane.Orders.Add(new Order { Description = "Design consultation" });
jane.Orders.Add(new Order { Description = "Website redesign" });
bob.Orders.Add(new Order { Description = "Monthly retainer" });
// Orders need to be attached too so they get IDs:
ctx.Attach(jane.Orders[0]);
ctx.Attach(jane.Orders[1]);
ctx.Attach(bob.Orders[0]);
ctx.Attach(jane);
ctx.Attach(bob);
ctx.Save();
// ----- list all clients ---------------------------------------------------
Console.WriteLine("=== All Clients ===");
foreach (var client in DbBase.Context!.Tracked.OfType<Client>())
{
Console.WriteLine($"{client.Name} [{client.Id}]");
foreach (var order in client.Orders)
Console.WriteLine($" - order [{order.Id}]: {order.Description}");
}
// ----- find by name -------------------------------------------------------
Console.WriteLine("\n=== Find by Name (null-returning) ===");
// No context argument: FindByName falls back to the DbBase.Context singleton.
var found = Client.FindByName("Jane Doe");
if (found is not null)
{
Console.WriteLine($"Found: {found.Name} [{found.Id}]");
Console.WriteLine($" Orders: {found.Orders.Count}");
}
else
{
Console.WriteLine("Not found.");
}
// ----- find by name (throwing variant) ------------------------------------
Console.WriteLine("\n=== Find by Name Required (throws instead of returning null) ===");
try
{
var required = Client.FindByNameRequired("Jane Doe");
Console.WriteLine($"Found: {required.Name} [{required.Id}]");
Console.WriteLine($" Orders: {required.Orders.Count}");
}
catch (ObjectNotFoundException ex)
{
Console.WriteLine($"Not found: {ex.Message}");
}
// Show the contrast with a non-existent name:
Console.WriteLine("\n=== Miss: null-returning vs throwing ===");
var missNull = Client.FindByName("Nobody Here");
Console.WriteLine($"FindByName(\"Nobody Here\"): {(missNull == null ? "null" : missNull.Name)}");
try
{
Client.FindByNameRequired("Nobody Here");
}
catch (ObjectNotFoundException ex)
{
var msg = ex.Message.ReplaceLineEndings(" ").Trim();
Console.WriteLine($"FindByNameRequired(\"Nobody Here\"): throws - {msg}");
}
+39
View File
@@ -23,6 +23,45 @@ public class Client : DbBase
{ {
Orders = new ClientOrders(this); Orders = new ClientOrders(this);
} }
/// <summary>
/// Convenience lookup: finds the first <see cref="Client"/> whose
/// <see cref="Name"/> equals <paramref name="name"/>. Returns
/// <c>null</c> when no match.
///
/// This mirrors how an ActiveRecord-style ORM might surface a static
/// finder on the domain class itself — it rides the <c>DbContext</c>
/// leak from the entity's back-reference. Delegates to
/// <see cref="DbBase.Find{T}"/> for the common traversal logic.
///
/// The context is optional: when omitted, the <see cref="DbBase.Context"/>
/// singleton is used, so call sites read like
/// <c>Client.FindByName("Jane Doe")</c> — no context threading required.
/// </summary>
public static Client? FindByName(string name, DbContext? db = null)
=> DbBase.Find<Client>(c => c.Name == name, db);
/// <summary>
/// Throwing variant of <see cref="FindByName(string,DbContext?)"/>. Returns
/// the matching <see cref="Client"/> or throws <see cref="ObjectNotFoundException"/>
/// with a message that includes the searched <paramref name="name"/>.
/// Throws <see cref="InvalidOperationException"/> instead when no context at all
/// is configured (a configuration error, not a lookup miss).
///
/// Delegates to <see cref="DbBase.FindRequired{T}"/>.
/// </summary>
/// <param name="name">The client name to search for.</param>
/// <param name="db">The context whose tracked entities to search. When
/// <c>null</c>, falls back to the <see cref="DbBase.Context"/> singleton.</param>
/// <returns>The first client whose <see cref="Client.Name"/> equals <paramref name="name"/>.</returns>
/// <exception cref="ObjectNotFoundException">
/// Thrown when no matching client is found.
/// </exception>
/// <exception cref="InvalidOperationException">
/// Thrown when no context is configured (neither passed explicitly nor set as the singleton).
/// </exception>
public static Client FindByNameRequired(string name, DbContext? db = null)
=> DbBase.FindRequired<Client>(c => c.Name == name, db, $"name == \"{name}\"");
} }
/// <summary> /// <summary>
+99
View File
@@ -11,6 +11,21 @@ namespace Before;
/// </summary> /// </summary>
public class DbBase public class DbBase
{ {
// -----------------------------------------------------------------
// Static singleton: an ActiveRecord-style shared context that any
// entity can reach without passing it through method parameters.
// -----------------------------------------------------------------
/// <summary>
/// Shared (singleton) <see cref="DbContext" /> accessible from every
/// entity via its base type. Set once at application startup so that
/// entity methods can call <c>DbBase.Context!</c> instead of carrying
/// a context reference.
///
/// This is yet another leak: domain objects depend on the persistence
/// layer at the *type* level, not just the instance level.
/// </summary>
public static DbContext? Context { get; set; }
/// <summary> /// <summary>
/// Primary key. Fresh (unsaved) entities have <see cref="Guid.Empty"/>; /// Primary key. Fresh (unsaved) entities have <see cref="Guid.Empty"/>;
/// <see cref="DbContext.Save"/> assigns a real id, faking EF's identity /// <see cref="DbContext.Save"/> assigns a real id, faking EF's identity
@@ -26,4 +41,88 @@ public class DbBase
/// (including the WebApp, and the tests) can only read it. /// (including the WebApp, and the tests) can only read it.
/// </summary> /// </summary>
public DbContext? DbContext { get; internal set; } public DbContext? DbContext { get; internal set; }
/// <summary>
/// Generic ActiveRecord-style finder: walks the <paramref name="db"/
/// />'s tracked entities, looks for the first whose runtime type matches
/// <typeparamref name="T"/> and satisfies <paramref name="predicate"/>
///.
///
/// This static generic rides the <c>DbContext</c> leak just like
/// <see cref="Client.FindByName"/>, but works for any <see cref="DbBase"
/// /> subtype without each entity needing its own hand-written finder.
/// </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>
/// <param name="db">The context whose tracked entities to search.
/// When <c>null</c>, falls back to the <see cref="Context"/> singleton —
/// so a caller only passes a context explicitly when it must differ from
/// the ambient one.
/// </param>
/// <returns>The first matching entity, or <c>null</c> when no match.
/// </returns>
/// <remarks>
/// Note the (deliberate, ActiveRecord-style) ambiguity this still leaves:
/// a <c>null</c> result means "no match — or no context configured at
/// all". The caller cannot distinguish the two; that is part of the cost
/// of the static-singleton leak this exercise illustrates.
/// </remarks>
public static T? Find<T>(Predicate<T> predicate, DbContext? db) where T : DbBase
{
db ??= Context;
if (db is null)
return default;
foreach (var e in db.Tracked)
if (e is T candidate && predicate(candidate))
return candidate;
return default;
}
/// <summary>
/// Throwing variant of <see cref="Find{T}(System.Predicate{T},DbContext?)"/>. Finds the first entity
/// whose runtime type matches <typeparamref name="T"/> and satisfies
/// <paramref name="predicate"/>. Throws <see cref="ObjectNotFoundException"/>
/// when no match is found, and <see cref="InvalidOperationException"/> when no
/// <see cref="Context"/> singleton is configured (a configuration error, not a lookup miss).
///
/// The exception message includes <paramref name="predicateToString"/> so
/// callers can debug which lookup failed.
/// </summary>
/// <param name="predicate">Filter applied to candidates of type <typeparamref name="T"/>.</param>
/// <param name="db">The context whose tracked entities to search. When <c>null</c>, falls back
/// to the <see cref="Context"/> singleton.</param>
/// <param name="predicateToString">A human-readable description of the predicate, used in the
/// exception message when the search fails.</param>
/// <returns>The first matching entity.</returns>
/// <exception cref="ObjectNotFoundException">
/// Thrown when no entity matches <paramref name="predicate"/>.
/// </exception>
/// <exception cref="InvalidOperationException">
/// Thrown when no context is available (neither passed explicitly nor set as the singleton).
/// </exception>
public static T FindRequired<T>(Predicate<T> predicate, DbContext? db, string predicateToString)
where T : DbBase
{
db ??= Context;
if (db is null)
throw new InvalidOperationException(
$"Cannot perform FindRequired<{typeof(T).Name}>: " +
$"no DbContext configured (neither passed explicitly nor set as " +
$"DbBase.Context singleton). Set DbBase.Context before calling " +
$"FindRequired<{typeof(T).Name}>.");
foreach (var e in db.Tracked)
if (e is T candidate && predicate(candidate))
return candidate;
throw new ObjectNotFoundException(
$"FindRequired<{typeof(T).Name}>({predicateToString}) — " +
$"no matching {typeof(T).Name} found in context.");
}
} }
+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();
}
}
} }
+17
View File
@@ -0,0 +1,17 @@
namespace Before;
/// <summary>
/// Thrown when a "required" ActiveRecord-style lookup —
/// <see cref="DbBase.FindRequired{T}"/> / <see cref="Client.FindByNameRequired"/> —
/// finds no matching entity in the context.
///
/// Named after NHibernate's <c>ObjectNotFoundException</c> (Rails' ActiveRecord
/// raises <c>ActiveRecord::RecordNotFound</c> for the same situation), so a
/// lookup miss is distinguishable from unrelated
/// <see cref="InvalidOperationException"/>s such as EF's <c>Single()</c>
/// "Sequence contains no elements".
/// </summary>
public class ObjectNotFoundException : Exception
{
public ObjectNotFoundException(string message) : base(message) { }
}
+323 -2
View File
@@ -131,8 +131,7 @@ public class BeforeTests
Assert.True(app.IsPersisted(client)); Assert.True(app.IsPersisted(client));
} }
// A companion check: a client that was never attached/saved exposes no // A companion check: a bare client has no context until saved.
// context yet, so the leak is dormant until the entity meets a context.
[Fact] [Fact]
public void A_bare_client_has_no_context_until_saved() public void A_bare_client_has_no_context_until_saved()
{ {
@@ -142,4 +141,326 @@ public class BeforeTests
Assert.Null(client.DbContext); Assert.Null(client.DbContext);
Assert.False(app.IsPersisted(client)); Assert.False(app.IsPersisted(client));
} }
// -----------------------------------------------------------------
// (8) Static Context (Singleton): DbBase exposes a class-level
// shared DbContext. Setting it makes the context accessible
// from any entity via its base type.
// -----------------------------------------------------------------
[Fact]
public void DbBase_has_a_static_Context_singleton()
{
Assert.Null(DbBase.Context); // fresh — not set yet
var db = new DbContext();
DbBase.Context = db;
Assert.Same(db, DbBase.Context);
DbBase.Context = null; // cleanup
}
// ---------------------------------------------------------------------
// (9) ActiveRecord finder: DbBase.Find<T> walks the context's tracked
// entities, looks for the first whose runtime type matches T,
// and returns it when <paramref name="predicate"/> matches.
// ---------------------------------------------------------------------
[Fact]
public void DbBase_Find_finds_matching_entity()
{
var db = new DbContext();
var acme = new Client { Name = "Acme" };
var globex = new Client { Name = "Globex" };
db.Attach(acme);
db.Attach(globex);
var result = DbBase.Find<Client>(c => c.Name == "Globex", db);
Assert.NotNull(result);
Assert.Same(globex, result);
}
// ---------------------------------------------------------------------
// (10) DbBase.Find returns null when no entity of type T satisfies
// the predicate.
// ---------------------------------------------------------------------
[Fact]
public void DbBase_Find_returns_null_when_no_match()
{
var db = new DbContext();
db.Attach(new Client { Name = "Acme" });
var result = DbBase.Find<Client>(c => c.Name == "Nobody", db);
Assert.Null(result);
}
// ---------------------------------------------------------------------
// (11) DbBase.Find filters by runtime type — an Order attached to the
// context does NOT match a Client predicate.
// ---------------------------------------------------------------------
[Fact]
public void DbBase_Find_ignores_non_matching_types()
{
var db = new DbContext();
db.Attach(new Order { Description = "test" });
var result = DbBase.Find<Client>(_ => true, db);
Assert.Null(result);
}
// ---------------------------------------------------------------------
// (12) DbBase.Find returns the FIRST matching entity only.
// ---------------------------------------------------------------------
[Fact]
public void DbBase_Find_returns_first_match()
{
var db = new DbContext();
var first = new Client { Name = "SameName" };
var second = new Client { Name = "SameName" };
db.Attach(first);
db.Attach(second);
var result = DbBase.Find<Client>(c => c.Name == "SameName", db);
Assert.Same(first, result); // first one inserted wins
Assert.NotSame(second, result);
}
// ---------------------------------------------------------------------
// (13) DbBase.Find handles a null context gracefully — with no explicit
// context AND no singleton configured, it returns default (null).
// ---------------------------------------------------------------------
[Fact]
public void DbBase_Find_with_null_context_returns_default()
{
var result = DbBase.Find<Client>(_ => true, null);
Assert.Null(result);
}
// (13b) When no explicit context is passed, Find falls back to the
// DbBase.Context singleton instead of returning null.
[Fact]
public void DbBase_Find_falls_back_to_Context_singleton_when_db_is_null()
{
var db = new DbContext();
var acme = new Client { Name = "SingletonCo" };
db.Attach(acme);
DbBase.Context = db;
try
{
var result = DbBase.Find<Client>(c => c.Name == "SingletonCo", null);
Assert.NotNull(result);
Assert.Same(acme, result);
}
finally
{
DbBase.Context = null; // cleanup
}
}
// ---------------------------------------------------------------------
// (14) Client.FindByName delegates to DbBase.Find and works correctly.
// ---------------------------------------------------------------------
[Fact]
public void Client_FindByName_finds_by_name()
{
var db = new DbContext();
var acme = new Client { Name = "Acme Corp" };
db.Attach(acme);
var result = Client.FindByName("Acme Corp", db);
Assert.NotNull(result);
Assert.Same(acme, result);
}
// ---------------------------------------------------------------------
// (15) Client.FindByName returns null when the name does not match.
// ---------------------------------------------------------------------
[Fact]
public void Client_FindByName_returns_null_when_not_found()
{
var db = new DbContext();
db.Attach(new Client { Name = "Acme Corp" });
var result = Client.FindByName("Nobody", db);
Assert.Null(result);
}
// (15b) FindByName with no context argument uses the Context singleton.
[Fact]
public void Client_FindByName_uses_Context_singleton_when_db_omitted()
{
var db = new DbContext();
var acme = new Client { Name = "Acme Corp" };
db.Attach(acme);
DbBase.Context = db;
try
{
var result = Client.FindByName("Acme Corp");
Assert.NotNull(result);
Assert.Same(acme, result);
}
finally
{
DbBase.Context = null; // cleanup
}
}
// ---------------------------------------------------------------------
// (16) The class-level DbBase.Context singleton can be used as the
// implicit context source for Find operations — the classic
// ActiveRecord pattern where any entity method reaches the shared
// context without parameter passing.
// ---------------------------------------------------------------------
[Fact]
public void DbBase_Context_singleton_is_used_in_Find()
{
var db = new DbContext();
var targeted = new Client { Name = "TargetCo" };
db.Attach(targeted);
DbBase.Context = db;
// Query using the singleton instead of passing the context:
// (In practice, callers often do this to avoid threading db through
// every call — the whole point of the ActiveRecord leak.)
var queryResult = DbBase.Find<Client>(c => c.Name == "TargetCo", DbBase.Context);
Assert.NotNull(queryResult);
Assert.Same(targeted, queryResult);
DbBase.Context = null; // cleanup
}
// ---------------------------------------------------------------------
// (17) DbBase.FindRequired<T> finds the matching entity — same result as Find<T>.
// ---------------------------------------------------------------------
[Fact]
public void DbBase_FindRequired_T_finds_matching_entity()
{
var db = new DbContext();
var acme = new Client { Name = "Acme" };
var globex = new Client { Name = "Globex" };
db.Attach(acme);
db.Attach(globex);
var result = DbBase.FindRequired<Client>(c => c.Name == "Globex", db, "name == Globex");
Assert.Same(globex, result);
}
// ---------------------------------------------------------------------
// (18) DbBase.FindRequired<T> throws ObjectNotFoundException when no match
// is found (the domain-specific lookup miss, not a BCL exception).
// ---------------------------------------------------------------------
[Fact]
public void DbBase_FindRequired_T_throws_when_no_match()
{
var db = new DbContext();
db.Attach(new Client { Name = "Acme" });
var ex = Assert.Throws<ObjectNotFoundException>(() =>
DbBase.FindRequired<Client>(c => c.Name == "Nobody", db, "name == Nobody"));
Assert.Contains("Nobody", ex.Message);
Assert.Contains("no matching Client found", ex.Message);
}
// ---------------------------------------------------------------------
// (19) DbBase.FindRequired<T> throws with a useful message when no context is
// configured (both explicit null and singleton null).
//
// Deliberately InvalidOperationException, not ObjectNotFoundException:
// a missing context is a configuration error, not a lookup miss.
// ---------------------------------------------------------------------
[Fact]
public void DbBase_FindRequired_T_throws_when_no_context_configured()
{
DbBase.Context = null;
var ex = Assert.Throws<InvalidOperationException>(() =>
DbBase.FindRequired<Client>(_ => true, null, "true"));
Assert.Contains("no DbContext configured", ex.Message);
Assert.Contains("DbBase.Context", ex.Message);
}
// ---------------------------------------------------------------------
// (20) Client.FindByNameRequired finds the matching client by name.
// ---------------------------------------------------------------------
[Fact]
public void Client_FindByNameRequired_finds_by_name()
{
var db = new DbContext();
var acme = new Client { Name = "Acme Corp" };
db.Attach(acme);
var result = Client.FindByNameRequired("Acme Corp", db);
Assert.Same(acme, result);
}
// ---------------------------------------------------------------------
// (21) Client.FindByNameRequired throws ObjectNotFoundException when no
// match — message includes name.
// ---------------------------------------------------------------------
[Fact]
public void Client_FindByNameRequired_throws_with_name_when_not_found()
{
var db = new DbContext();
db.Attach(new Client { Name = "Acme Corp" });
var ex = Assert.Throws<ObjectNotFoundException>(() =>
Client.FindByNameRequired("Nobody", db));
Assert.Contains("Nobody", ex.Message);
Assert.Contains("no matching Client found", ex.Message);
}
// ---------------------------------------------------------------------
// (22) Client.FindByNameRequired uses Context singleton when no context arg.
// ---------------------------------------------------------------------
[Fact]
public void Client_FindByNameRequired_uses_Context_singleton()
{
var db = new DbContext();
var acme = new Client { Name = "SingletonCo" };
db.Attach(acme);
DbBase.Context = db;
try
{
var result = Client.FindByNameRequired("SingletonCo");
Assert.Same(acme, result);
}
finally
{
DbBase.Context = null;
}
}
// ---------------------------------------------------------------------
// (23) Client.FindByNameRequired throws with a useful message when the
// singleton is not configured (no arg, no singleton).
// Stays InvalidOperationException: configuration error, not a miss.
// ---------------------------------------------------------------------
[Fact]
public void Client_FindByNameRequired_throws_with_context_hint_when_singleton_null()
{
DbBase.Context = null;
var ex = Assert.Throws<InvalidOperationException>(() =>
Client.FindByNameRequired("SomeBody"));
Assert.Contains("no DbContext configured", ex.Message);
Assert.Contains("DbBase.Context", ex.Message);
}
} }