Compare commits

..
13 Commits
Author SHA1 Message Date
mostalive 9895ef87e4 edit readme for structure 2026-09-15 11:11:37 +01:00
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
13 changed files with 878 additions and 62 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
+47 -4
View File
@@ -2,10 +2,53 @@
This is a workspace for creating three exercises. This is a workspace for creating three exercises.
One exercise is about exploring a repository using prompts for a coding agent to create Mermaid and PlantUML diagrams. We're focusing on class diagrams and sequence diagrams to get an idea of what is going on. We use the starting point of the second exercise for this. ## Explore a repository with LLM diagrams
The second exercise is factoring out domain objects, domain entities, from a structure that is previously subclass-based. So say you use a structure like active records or Entity Framework where you have an active record class and your domain class subclasses from the active record class which gives you maybe some nice things like find by an attribute. And then you can quite quickly — so these are straight-offs — you can quite quickly structure an application while it's also persisted. So you can say, "I want to have a customer, I want to have an order. And oh yes, I'm doing this for a webshop." So you have objects, relations, attributes, and you can all easily save them in your database. And this is fine when your application is small and you're just starting out. It allows you to very quickly scaffold an application. But it can become painful when your data doesn't have the right shape for it. So performance is slow. So at some point as your application grows, you probably want to be more deliberate in how you start. Explore a repository using prompts for a coding agent to create Mermaid and PlantUML diagrams. We're focusing on class diagrams and sequence diagrams to get an idea of what is going on. We use the starting point of the second exercise for this.
The second exercise is factoring out domain classes from a database inheritance structure. So we start with a couple of domain classes and some relations. Then we pre-deliver some classes that we already created and we demo it. As we have some domain classes, we create a mapping for writing and reading a domain class. And we make sure that running the existing tests against the fake database that we have for the exercise is really slow. The sequence diagrams that we make in the first exercise will come in handy. Because in order to do this, you do start wondering how does this web application find a client? How can we see clients? How can we create an order? And what happens once the flow through the application as we do this? You sort of need to understand this to choose the kind of mapping that you use. So we are going to reuse the analysis we did in the first exercise in the second exercise. ## Factor out domain objects from ActiveRecord-like structures
The third part is a spike in creating an extract method refactoring using Roslyn, the C# static analysis tooling that comes as part of C# more or less. It's an additional NuGet download but yeah it is quite good. So this is a simple command line that allows you to specify the name of a file, the starting line and the end line and it will give you an analysis of dependencies inside the method that you need to decide which things could lend itself to extracting to a method, what the parameters could be and what the return value could be. This is work in progress so this is not something to share with participants but it is part of a flow where you first you iterate and you prompt, you create codes then maybe you extract things into a skill or you prompt for a diagram of some kind. You go, ah I often seem to make sequence diagrams. Okay, can we make something deterministic to create these sequence diagrams so it's faster and more reliable? Or in my case I don't like, well I like the extract method stuff in JetBrains IDEs but I need to do some steps before to find the parameters, extract variables, extract the methods with these variables as parameters then inline the variables again and I was wondering if I could do that more smoothly in the flow with a coding agent. That also means that going further I could do things like forbid extracted methods from having Async in all parts, but see if we can extract parts without side effects first. Factor out domain objects, domain entities, from a structure that is previously subclass-based.
Say you use a structure like Active Record or Entity Framework where you have an active record class and your domain class subclasses from the active record class.
This super class gives you some easy features like finding an object by attribute. You can quite quickly structure an application while it is also persisted.
For instance you can say, "I want to have a customer, I want to have an order. And oh yes, I'm doing this for a webshop." So you have objects, relations, attributes, and you can all easily save them in your database.
This is fine when your application is small and you're just starting out. It allows you to very quickly scaffold an application. But it can become painful when your data doesn't have the right shape for it. So performance is slow. At some point as your application grows, you probably want to be more deliberate in how you deal with data.
### How do we factor out domain classes?
To get away from the inheritance structure with an 'abstract' class tied to the database, we need two or three database-bound concrete classes and some relations.
We pre-deliver some classes that we already migrated to the new structure and we demo it. We create a mapping for writing and reading a domain class. And we make sure that running the existing tests against the fake database that we have for the exercise is **really slow.**
### Sequence diagrams come in handy
The sequence diagrams that we made in the first exercise will come in handy. Because in order to do this, you do start wondering how does this web application find a client? How can we see clients? How can we create an order? And what happens to the flow through the application as we do this?
You need to understand the application flow to choose the kind of mapping that you use. Therefore we are going to reuse the analysis we did in the first exercise. Instructions for participants will be pre-structured, so they can focus on doing the exercise instead of devising exacp prompts.
## Spike: Static analysis and Extract Method refactoring with Roslyn
The third part is a spike in creating an extract method refactoring using Roslyn, the C# static analysis tooling that comes as part of C# more or less.
It's an additional NuGet download but it is quite powerful.
This is a simple command line app that allows you to specify the name of a file, the starting line and the end line and it will give you an analysis of dependencies inside the method that you need to decide which things could lend itself to extracting to a method, what the parameters could be and what the return value could be.
This is work in progress so this is not something to share with participants yet, unless it is in the form of a demo after participants have prompted diagrams.
But it is part of a flow where:
1. you iterate and you prompt,
2. create visualisations
3. extract things into a skill
4. extract moldable tools to make the skill faster and more reliable
Skills and tools together create a flywheel where you can do things that happen again and again more reliably and faster. Speed through quality.
## aside on why skills and tools in the line of moldable development
You go, ah I often seem to make sequence diagrams. Okay, can we make something deterministic to create these sequence diagrams so it's faster and more reliable? Or in my case I don't like, well I like the extract method stuff in JetBrains IDEs but I need to do some steps before to find the parameters, extract variables, extract the methods with these variables as parameters then inline the variables again and I was wondering if I could do that more smoothly in the flow with a coding agent. That also means that going further I could do things like forbid extracted methods from having Async in all parts, but see if we can extract parts without side effects first.
+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 -- "$@"
+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();
} }
+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.");
}
} }
+92 -17
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);
}
} }