Compare commits
12
Commits
5270eabcf3
...
01ba13f94d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01ba13f94d | ||
|
|
08a6fa757a | ||
|
|
ec9586c316 | ||
|
|
4b6e6f5a00 | ||
|
|
325925a592 | ||
|
|
68b9657d62 | ||
|
|
58f7a1184a | ||
|
|
06a12e55f8 | ||
|
|
8b46fccf08 | ||
|
|
c4bfc14f32 | ||
|
|
c21c6416cf | ||
|
|
92b9e3f784 |
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Executable
+10
@@ -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
@@ -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<T></c>) plus an instance-level
|
||||||
/// objects are plain POCOs that know nothing about a context, and persisting
|
/// <c>Find<T></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();
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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<T></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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) { }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user