Compare commits
2
Commits
df829241c3
...
aab45728d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aab45728d0 | ||
|
|
d60ea160c4 |
@@ -1,17 +1,17 @@
|
|||||||
# AI Code Exploration Exercise
|
# AI Code Exploration Exercise
|
||||||
|
|
||||||
A small C# application in the Active Record style, for practicing using coding agents to explore unfamiliar codebases.
|
A small C# application for practicing code exploration with a coding agent: use it to produce diagrams that reveal the structure and behavior of an unfamiliar codebase.
|
||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
The `src/Before/` folder contains a small domain model (Client, Order) backed by a fake EF Core `DbContext`. The `src/Before.Console/` project is a demo console app that seeds data and queries it.
|
`src/Before/` contains a small domain model (Client, Order) with an in-memory `DbContext` and a `WebApp` consumer. `src/Before.Console/` is a demo console app that seeds data and queries it. `tests/Before.Tests/` covers the behavior.
|
||||||
|
|
||||||
**You do not need to read or understand the code before starting.** The point is to use a coding agent to produce diagrams that reveal the structure and behavior.
|
**You do not need to read or understand the code before starting.** The point is to use a coding agent to produce the diagrams.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- A coding agent with access to this repo (Claude Code, Cursor, Copilot, etc.)
|
- A coding agent with access to this repo (Claude Code, Cursor, Copilot, etc.)
|
||||||
- .NET SDK 10.0 (to build and run)
|
- .NET SDK 10.0
|
||||||
- `mmdc` CLI for Mermaid rendering, or IDE Mermaid preview
|
- `mmdc` CLI for Mermaid rendering, or IDE Mermaid preview
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
@@ -30,30 +30,29 @@ The `src/Before/` folder contains a small domain model (Client, Order) backed by
|
|||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
Use a coding agent to create diagrams of this codebase. Start broad, then iterate with follow-up prompts to refine.
|
Start broad, then iterate with follow-up prompts to refine.
|
||||||
|
|
||||||
### Task 1: Class diagram (sanity check)
|
### Task 1: Class diagram
|
||||||
|
|
||||||
Prompt your agent to create a **class diagram** of the classes in `src/Before/` using Mermaid. Iterate until you're satisfied it captures the structure accurately.
|
Create a **class diagram** of the classes in `src/Before/` using Mermaid. Iterate until it captures the structure accurately.
|
||||||
|
|
||||||
**What to check:** Are the inheritance relationships correct? Are the associations (Client → Orders, Order → Client) visible? Is the DbContext leak (domain objects reaching persistence) shown?
|
**What to check:** Are the inheritance relationships correct? Are the associations (Client → Orders, Order → Client) visible? Is the dependency between the domain classes and `DbContext` shown — and in which direction does it point?
|
||||||
|
|
||||||
### Task 2: Sequence diagram — happy path
|
### Task 2: Sequence diagram — happy path
|
||||||
|
|
||||||
Prompt your agent to create a **sequence diagram** for the flow when `Client.FindByName("Jane Doe")` is called.
|
Create a **sequence diagram** for the flow when `Client.FindByName("Jane Doe")` is called.
|
||||||
|
|
||||||
### Task 3: Sequence diagram — exception path
|
### Task 3: Sequence diagram — exception path
|
||||||
|
|
||||||
Prompt your agent to create a **sequence diagram** for `Client.FindByNameRequired("Nobody")` — when the client is not found. What interactions differ from the happy path? Where is `ObjectNotFoundException` thrown?
|
Create a **sequence diagram** for `Client.FindByNameRequired("Nobody")` — when the client is not found. What interactions differ from the happy path? Where is `ObjectNotFoundException` thrown?
|
||||||
|
|
||||||
### Task 4 (stretch): Full initialization flow
|
### Task 4 (stretch): Full initialization flow
|
||||||
|
|
||||||
From `Program.cs` (the Console entry point), trace how the database gets initialized and what interactions happen on `Client` and `Order` entities. Include the exception flow from Task 3. This will be a larger diagram — iterate and simplify as needed.
|
From `Program.cs` (the console entry point), trace how the `DbContext` is created and made available to entities, and what interactions happen on `Client` and `Order`. Include the exception flow from Task 3. This will be a larger diagram — iterate and simplify as needed.
|
||||||
|
|
||||||
## Tips
|
## Tips
|
||||||
|
|
||||||
- **Start broad, then refine:** "Draw me a class diagram of src/Before/" is enough to start.
|
- **Start broad, then refine:** "Draw me a class diagram of src/Before/" is enough to start.
|
||||||
- **Iterate on the prompt:** If the first diagram is missing something, ask the agent to add it. You don't need to write Mermaid syntax.
|
- **Iterate on the prompt:** If the first diagram is missing something, ask the agent to add it. You don't need to write Mermaid syntax.
|
||||||
- **Validate:** Use `mmdc` to check syntax, visually check the diagram against the code.
|
- **Validate:** Use `mmdc` to check syntax, visually check the diagram against the code.
|
||||||
- **The skill is directing the agent,** not writing diagrams by hand.
|
- **Direct the agent;** don't write diagrams by hand.
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
using Before;
|
using Before;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// Before.Console — seeds clients and orders, saves them through the
|
||||||
// Before.Console — a straight-line demo of the ActiveRecord-style pattern.
|
// DbContext, and queries them via the Client finders and DbBase.Context.
|
||||||
//
|
|
||||||
// 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();
|
var ctx = new DbContext();
|
||||||
DbBase.Context = ctx;
|
DbBase.Context = ctx;
|
||||||
@@ -23,7 +15,7 @@ jane.Orders.Add(new Order { Description = "Website redesign" });
|
|||||||
|
|
||||||
bob.Orders.Add(new Order { Description = "Monthly retainer" });
|
bob.Orders.Add(new Order { Description = "Monthly retainer" });
|
||||||
|
|
||||||
// Orders need to be attached too so they get IDs:
|
// Orders are attached separately so they get IDs:
|
||||||
ctx.Attach(jane.Orders[0]);
|
ctx.Attach(jane.Orders[0]);
|
||||||
ctx.Attach(jane.Orders[1]);
|
ctx.Attach(jane.Orders[1]);
|
||||||
ctx.Attach(bob.Orders[0]);
|
ctx.Attach(bob.Orders[0]);
|
||||||
@@ -43,7 +35,6 @@ foreach (var client in DbBase.Context!.Tracked.OfType<Client>())
|
|||||||
|
|
||||||
// ----- find by name -------------------------------------------------------
|
// ----- find by name -------------------------------------------------------
|
||||||
Console.WriteLine("\n=== Find by Name (null-returning) ===");
|
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");
|
var found = Client.FindByName("Jane Doe");
|
||||||
if (found is not null)
|
if (found is not null)
|
||||||
{
|
{
|
||||||
@@ -68,7 +59,7 @@ catch (ObjectNotFoundException ex)
|
|||||||
Console.WriteLine($"Not found: {ex.Message}");
|
Console.WriteLine($"Not found: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the contrast with a non-existent name:
|
// ----- a name that matches nothing ----------------------------------------
|
||||||
Console.WriteLine("\n=== Miss: null-returning vs throwing ===");
|
Console.WriteLine("\n=== Miss: null-returning vs throwing ===");
|
||||||
var missNull = Client.FindByName("Nobody Here");
|
var missNull = Client.FindByName("Nobody Here");
|
||||||
Console.WriteLine($"FindByName(\"Nobody Here\"): {(missNull == null ? "null" : missNull.Name)}");
|
Console.WriteLine($"FindByName(\"Nobody Here\"): {(missNull == null ? "null" : missNull.Name)}");
|
||||||
|
|||||||
+14
-46
@@ -2,21 +2,13 @@ using System.Collections.ObjectModel;
|
|||||||
|
|
||||||
namespace Before;
|
namespace Before;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>A client with a name and a collection of orders.</summary>
|
||||||
/// A client, backed by the database: it derives from <see cref="DbBase"/> and
|
|
||||||
/// holds its orders as a navigation collection. In the "after" situation this
|
|
||||||
/// becomes a plain POCO (no <see cref="DbBase"/>) with a <c>ClientDto</c>
|
|
||||||
/// carrying its data to the WebApp.
|
|
||||||
/// </summary>
|
|
||||||
public class Client : DbBase
|
public class Client : DbBase
|
||||||
{
|
{
|
||||||
/// <summary>Display name of the client.</summary>
|
/// <summary>Display name.</summary>
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>The orders belonging to this client.</summary>
|
||||||
/// Navigation collection of this client's orders. Adding to it performs
|
|
||||||
/// EF-style navigation fix-up (see <see cref="ClientOrders"/>).
|
|
||||||
/// </summary>
|
|
||||||
public ClientOrders Orders { get; }
|
public ClientOrders Orders { get; }
|
||||||
|
|
||||||
public Client()
|
public Client()
|
||||||
@@ -25,49 +17,26 @@ public class Client : DbBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Convenience lookup: finds the first <see cref="Client"/> whose
|
/// Returns the first client whose <see cref="Name"/> equals
|
||||||
/// <see cref="Name"/> equals <paramref name="name"/>. Returns
|
/// <paramref name="name"/>, or <c>null</c>. Uses <paramref name="db"/>
|
||||||
/// <c>null</c> when no match.
|
/// when given, otherwise <see cref="DbBase.Context"/>.
|
||||||
///
|
|
||||||
/// 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>
|
/// </summary>
|
||||||
public static Client? FindByName(string name, DbContext? db = null)
|
public static Client? FindByName(string name, DbContext? db = null)
|
||||||
=> DbBase.Find<Client>(c => c.Name == name, db);
|
=> DbBase.Find<Client>(c => c.Name == name, db);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Throwing variant of <see cref="FindByName(string,DbContext?)"/>. Returns
|
/// Returns the first client whose <see cref="Name"/> equals
|
||||||
/// the matching <see cref="Client"/> or throws <see cref="ObjectNotFoundException"/>
|
/// <paramref name="name"/>. Throws <see cref="ObjectNotFoundException"/>
|
||||||
/// with a message that includes the searched <paramref name="name"/>.
|
/// when there is no match, and <see cref="InvalidOperationException"/>
|
||||||
/// Throws <see cref="InvalidOperationException"/> instead when no context at all
|
/// when no context is available.
|
||||||
/// is configured (a configuration error, not a lookup miss).
|
|
||||||
///
|
|
||||||
/// Delegates to <see cref="DbBase.FindRequired{T}"/>.
|
|
||||||
/// </summary>
|
/// </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)
|
public static Client FindByNameRequired(string name, DbContext? db = null)
|
||||||
=> DbBase.FindRequired<Client>(c => c.Name == name, db, $"name == \"{name}\"");
|
=> DbBase.FindRequired<Client>(c => c.Name == name, db, $"name == \"{name}\"");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An <see cref="Order"/> collection that fakes EF's navigation fix-up: when an
|
/// Order collection that sets <see cref="Order.Client"/> to the owning
|
||||||
/// order is added, its <see cref="Order.Client"/> back-reference is set to the
|
/// client when an order is added.
|
||||||
/// owning client, exactly as EF would wire up the two ends of the relation.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ClientOrders : Collection<Order>
|
public sealed class ClientOrders : Collection<Order>
|
||||||
{
|
{
|
||||||
@@ -78,12 +47,11 @@ public sealed class ClientOrders : Collection<Order>
|
|||||||
_owner = owner;
|
_owner = owner;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both Add(...) and Insert(...) funnel through InsertItem, so overriding
|
// Add and Insert both route through InsertItem.
|
||||||
// it covers every way an order can be added to the collection.
|
|
||||||
protected override void InsertItem(int index, Order item)
|
protected override void InsertItem(int index, Order item)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(item);
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
item.Client = _owner; // navigation fix-up: order now knows its client
|
item.Client = _owner;
|
||||||
base.InsertItem(index, item);
|
base.InsertItem(index, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-75
@@ -1,74 +1,29 @@
|
|||||||
namespace Before;
|
namespace Before;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Base class for every DB-backed domain object. Fakes the active-record part
|
/// Base class for entities stored in a <see cref="DbContext"/>: carries an
|
||||||
/// of EF: each entity carries its own <see cref="Id"/> and, once it has been
|
/// <see cref="Id"/> and, once attached, a reference to the owning context.
|
||||||
/// created/saved through a context, a back-reference to that context.
|
|
||||||
///
|
|
||||||
/// <see cref="DbContext"/> is the dependency this base class leaks. The
|
|
||||||
/// "after" situation (Yak 02) removes this inheritance entirely — domain
|
|
||||||
/// objects become plain POCOs that know nothing about a DbContext.
|
|
||||||
/// </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>
|
/// <summary>
|
||||||
/// Shared (singleton) <see cref="DbContext" /> accessible from every
|
/// Shared context used by static finders when no context is passed
|
||||||
/// entity via its base type. Set once at application startup so that
|
/// explicitly. Set at application startup.
|
||||||
/// 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>
|
/// </summary>
|
||||||
public static DbContext? Context { get; set; }
|
public static DbContext? Context { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Entity key; <see cref="Guid.Empty"/> until saved.</summary>
|
||||||
/// Primary key. Fresh (unsaved) entities have <see cref="Guid.Empty"/>;
|
|
||||||
/// <see cref="DbContext.Save"/> assigns a real id, faking EF's identity
|
|
||||||
/// generation.
|
|
||||||
/// </summary>
|
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>The context this entity is attached to, or <c>null</c>.</summary>
|
||||||
/// Active-record back-reference: "I know which context created me". This
|
|
||||||
/// is the leak the exercise exposes — from a plain domain object you can
|
|
||||||
/// reach straight into the persistence layer. <c>internal set</c> because
|
|
||||||
/// only the owning <see cref="DbContext"/> may (re)assign it; consumers
|
|
||||||
/// (including the WebApp, and the tests) can only read it.
|
|
||||||
/// </summary>
|
|
||||||
public DbContext? DbContext { get; internal set; }
|
public DbContext? DbContext { get; internal set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generic ActiveRecord-style finder: walks the <paramref name="db"/
|
/// Returns the first tracked entity of type <typeparamref name="T"/> that
|
||||||
/// />'s tracked entities, looks for the first whose runtime type matches
|
/// satisfies <paramref name="predicate"/>, or <c>null</c> when there is no
|
||||||
/// <typeparamref name="T"/> and satisfies <paramref name="predicate"/>
|
/// match or no context. Uses <paramref name="db"/> when given, otherwise
|
||||||
///.
|
/// <see cref="Context"/>.
|
||||||
///
|
|
||||||
/// 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>
|
/// </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
|
public static T? Find<T>(Predicate<T> predicate, DbContext? db) where T : DbBase
|
||||||
{
|
{
|
||||||
db ??= Context;
|
db ??= Context;
|
||||||
@@ -84,27 +39,12 @@ public class DbBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Throwing variant of <see cref="Find{T}(System.Predicate{T},DbContext?)"/>. Finds the first entity
|
/// Returns the first tracked entity of type <typeparamref name="T"/> that
|
||||||
/// whose runtime type matches <typeparamref name="T"/> and satisfies
|
/// satisfies <paramref name="predicate"/>. Throws
|
||||||
/// <paramref name="predicate"/>. Throws <see cref="ObjectNotFoundException"/>
|
/// <see cref="ObjectNotFoundException"/> when there is no match, and
|
||||||
/// when no match is found, and <see cref="InvalidOperationException"/> when no
|
/// <see cref="InvalidOperationException"/> when no context is available.
|
||||||
/// <see cref="Context"/> singleton is configured (a configuration error, not a lookup miss).
|
/// <paramref name="predicateToString"/> is included in the miss message.
|
||||||
///
|
|
||||||
/// The exception message includes <paramref name="predicateToString"/> so
|
|
||||||
/// callers can debug which lookup failed.
|
|
||||||
/// </summary>
|
/// </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)
|
public static T FindRequired<T>(Predicate<T> predicate, DbContext? db, string predicateToString)
|
||||||
where T : DbBase
|
where T : DbBase
|
||||||
{
|
{
|
||||||
|
|||||||
+11
-55
@@ -3,40 +3,18 @@ using System.Collections.ObjectModel;
|
|||||||
namespace Before;
|
namespace Before;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A tiny hand-rolled fake of EF's <c>DbContext</c>: it keeps per-type
|
/// In-memory data store: one collection per entity type, id assignment on
|
||||||
/// change-tracker tables (mirroring EF's <see cref="DbSet{T}"/> pattern) and
|
/// save, and a configurable delay on every operation.
|
||||||
/// a <see cref="Save"/> that fakes <c>SaveChangesAsync</c>.
|
|
||||||
///
|
|
||||||
/// 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*
|
|
||||||
/// (domain object -> DbContext), not EF's behaviour.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DbContext
|
public class DbContext
|
||||||
{
|
{
|
||||||
// Per-type tables: each entity type has its own typed collection,
|
|
||||||
// mimicking EF Core's <see cref="DbSet{T}"/> model where the DbContext
|
|
||||||
// maintains a separate set per entity type.
|
|
||||||
private readonly Dictionary<Type, Collection<DbBase>> _tables = new();
|
private readonly Dictionary<Type, Collection<DbBase>> _tables = new();
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
/// <summary>Delay applied to every operation. Default: 200 ms.</summary>
|
||||||
// Simulated DB latency. Every "round trip" through this context —
|
|
||||||
// Attach, Save, Find, IsTracked, Tracked — sleeps for this long, the
|
|
||||||
// way a real database makes every call pay a network + query cost.
|
|
||||||
// Deliberately on by default: the console demo and the tests are
|
|
||||||
// supposed to feel sluggish, so the cost of persistence becomes
|
|
||||||
// visible. Set to TimeSpan.Zero for an instant fake.
|
|
||||||
// -----------------------------------------------------------------
|
|
||||||
/// <summary>
|
|
||||||
/// Simulated latency per DB operation. Default 200 ms.
|
|
||||||
/// </summary>
|
|
||||||
public static TimeSpan Latency { get; set; } = TimeSpan.FromMilliseconds(200);
|
public static TimeSpan Latency { get; set; } = TimeSpan.FromMilliseconds(200);
|
||||||
|
|
||||||
private static void SimulateLatency() => Thread.Sleep(Latency);
|
private static void SimulateLatency() => Thread.Sleep(Latency);
|
||||||
|
|
||||||
/// <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
|
private Collection<DbBase> Table<T>() where T : DbBase
|
||||||
{
|
{
|
||||||
var type = typeof(T);
|
var type = typeof(T);
|
||||||
@@ -48,10 +26,6 @@ public class DbContext
|
|||||||
return 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)
|
private Collection<DbBase> TableFor(Type type)
|
||||||
{
|
{
|
||||||
if (!_tables.TryGetValue(type, out var table))
|
if (!_tables.TryGetValue(type, out var table))
|
||||||
@@ -63,10 +37,8 @@ public class DbContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attach an entity to this context (EF's <c>Add</c>). Sets the
|
/// Adds the entity to the table for its runtime type and sets its
|
||||||
/// active-record back-reference so the entity knows its owner.
|
/// <see cref="DbBase.DbContext"/> reference.
|
||||||
/// 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)
|
||||||
{
|
{
|
||||||
@@ -79,10 +51,8 @@ public class DbContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fake <c>SaveChangesAsync</c>: walk all per-type tables and assign a
|
/// Assigns a fresh <see cref="Guid"/> to every attached entity whose id is
|
||||||
/// fresh <see cref="Guid"/> to any whose id is still <see cref="Guid.Empty"/>.
|
/// still <see cref="Guid.Empty"/>. Returns the number of entities saved.
|
||||||
/// Returns the number of entities that were (re)saved — i.e. newly
|
|
||||||
/// identified — mirroring EF's "rows written" return value.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int Save()
|
public int Save()
|
||||||
{
|
{
|
||||||
@@ -99,17 +69,9 @@ public class DbContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Find the first entity of type <typeparamref name="T"/> in this
|
/// Returns the first entity in the table for <typeparamref name="T"/> that
|
||||||
/// context's per-type table that satisfies <paramref name="predicate"/>.
|
/// satisfies <paramref name="predicate"/>, or <c>null</c>.
|
||||||
/// 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>
|
/// </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
|
public T? Find<T>(Predicate<T> predicate) where T : DbBase
|
||||||
{
|
{
|
||||||
SimulateLatency();
|
SimulateLatency();
|
||||||
@@ -120,10 +82,7 @@ public class DbContext
|
|||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Returns true if the entity is in any table.</summary>
|
||||||
/// True if <paramref name="entity"/> is in this context's tracker
|
|
||||||
/// (walks all per-type tables).
|
|
||||||
/// </summary>
|
|
||||||
public bool IsTracked(DbBase entity)
|
public bool IsTracked(DbBase entity)
|
||||||
{
|
{
|
||||||
SimulateLatency();
|
SimulateLatency();
|
||||||
@@ -133,10 +92,7 @@ public class DbContext
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>All tracked entities, across all tables.</summary>
|
||||||
/// Read-only view of all entities this context currently tracks
|
|
||||||
/// (flattened across all per-type tables).
|
|
||||||
/// </summary>
|
|
||||||
public IReadOnlyList<DbBase> Tracked
|
public IReadOnlyList<DbBase> Tracked
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
namespace Before;
|
namespace Before;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Thrown when a required lookup finds no matching entity.</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 class ObjectNotFoundException : Exception
|
||||||
{
|
{
|
||||||
public ObjectNotFoundException(string message) : base(message) { }
|
public ObjectNotFoundException(string message) : base(message) { }
|
||||||
|
|||||||
+3
-12
@@ -1,20 +1,11 @@
|
|||||||
namespace Before;
|
namespace Before;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>An order belonging to a <see cref="Client"/>.</summary>
|
||||||
/// An order, backed by the database: it derives from <see cref="DbBase"/> and
|
|
||||||
/// holds a back-reference to its <see cref="Client"/> (the other end of the
|
|
||||||
/// relation). In the "after" situation this becomes a plain POCO with an
|
|
||||||
/// <c>OrderDto</c> carrying its data to the WebApp.
|
|
||||||
/// </summary>
|
|
||||||
public class Order : DbBase
|
public class Order : DbBase
|
||||||
{
|
{
|
||||||
/// <summary>Free-text description of what this order is for.</summary>
|
/// <summary>Free-text description.</summary>
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>The owning client; set by <see cref="ClientOrders"/>.</summary>
|
||||||
/// The client that owns this order. Set automatically by
|
|
||||||
/// <see cref="ClientOrders"/> when the order is added to
|
|
||||||
/// <see cref="Client.Orders"/> (navigation fix-up).
|
|
||||||
/// </summary>
|
|
||||||
public Client? Client { get; set; }
|
public Client? Client { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-16
@@ -1,19 +1,13 @@
|
|||||||
namespace Before;
|
namespace Before;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The consuming application. In the "before" situation it talks to the
|
/// Consumes <see cref="Client"/> and <see cref="Order"/> domain objects
|
||||||
/// DB-backed domain model directly: its methods take <see cref="Client"/> and
|
/// directly.
|
||||||
/// <see cref="Order"/> (which inherit <see cref="DbBase"/>) as parameters.
|
|
||||||
///
|
|
||||||
/// One of its methods reaches the <see cref="DbContext"/> *through* a domain
|
|
||||||
/// object — the leak. In the "after" situation (Yak 02) the WebApp consumes
|
|
||||||
/// DTOs instead, and the domain objects no longer expose a DbContext.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class WebApp
|
public class WebApp
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Render a client together with all of its orders, consuming the
|
/// Returns the client's name, id, and orders formatted as text.
|
||||||
/// <see cref="Client"/>/ <see cref="Order"/> domain objects directly.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ShowClient(Client client)
|
public string ShowClient(Client client)
|
||||||
{
|
{
|
||||||
@@ -25,13 +19,7 @@ public class WebApp
|
|||||||
return string.Join(Environment.NewLine, lines);
|
return string.Join(Environment.NewLine, lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Returns true when the client is attached to a context.</summary>
|
||||||
/// The leak, made concrete: from a plain domain object the WebApp can reach
|
|
||||||
/// the <see cref="DbContext"/> (<see cref="DbBase.DbContext"/>) and thus
|
|
||||||
/// touch the persistence layer — here just to ask whether the client has
|
|
||||||
/// been saved. The "after" situation removes <c>client.DbContext</c>
|
|
||||||
/// entirely, so no DTO-consumer can do this.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsPersisted(Client client)
|
public bool IsPersisted(Client client)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(client);
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
|||||||
@@ -3,15 +3,11 @@ using Before;
|
|||||||
namespace BeforeAfter.Tests;
|
namespace BeforeAfter.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests for the BEFORE situation (Yak 01): DB-backed domain classes that
|
/// Tests for the Client, Order, DbBase, DbContext, and WebApp classes.
|
||||||
/// inherit <see cref="DbBase"/> and leak their <see cref="DbContext"/>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BeforeTests
|
public class BeforeTests
|
||||||
{
|
{
|
||||||
// ---------------------------------------------------------------------
|
// Client and Order derive from DbBase.
|
||||||
// (1) Inheritance: the domain classes ARE DB-backed (they derive from
|
|
||||||
// DbBase). This is the "before" shape Yak 02 will undo.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Client_and_Order_derive_from_DbBase()
|
public void Client_and_Order_derive_from_DbBase()
|
||||||
{
|
{
|
||||||
@@ -19,9 +15,7 @@ public class BeforeTests
|
|||||||
Assert.True(typeof(DbBase).IsAssignableFrom(typeof(Order)));
|
Assert.True(typeof(DbBase).IsAssignableFrom(typeof(Order)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// New entities have Id == Guid.Empty.
|
||||||
// (2) Fresh entities are unsaved: their Id is still Guid.Empty.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(typeof(Client))]
|
[InlineData(typeof(Client))]
|
||||||
[InlineData(typeof(Order))]
|
[InlineData(typeof(Order))]
|
||||||
@@ -31,10 +25,7 @@ public class BeforeTests
|
|||||||
Assert.Equal(Guid.Empty, entity.Id);
|
Assert.Equal(Guid.Empty, entity.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// Save assigns fresh, distinct Guids to unsaved entities.
|
||||||
// (3) Save() fakes EF's SaveChanges: it assigns fresh, distinct Guids to
|
|
||||||
// entities that do not have one yet.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Save_assigns_fresh_distinct_ids()
|
public void Save_assigns_fresh_distinct_ids()
|
||||||
{
|
{
|
||||||
@@ -51,10 +42,7 @@ public class BeforeTests
|
|||||||
Assert.NotEqual(acme.Id, globex.Id);
|
Assert.NotEqual(acme.Id, globex.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// A saved entity references its context and is tracked by it.
|
||||||
// (4) A saved entity is registered with its context, reachable via the
|
|
||||||
// active-record back-reference.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Saved_entity_is_registered_with_its_context()
|
public void Saved_entity_is_registered_with_its_context()
|
||||||
{
|
{
|
||||||
@@ -67,10 +55,7 @@ public class BeforeTests
|
|||||||
Assert.True(db.IsTracked(client));
|
Assert.True(db.IsTracked(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// Adding an order to client.Orders sets order.Client.
|
||||||
// (5) Navigation fix-up: adding an order to client.Orders wires the
|
|
||||||
// order.Client back-reference.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Adding_an_order_sets_the_client_back_reference()
|
public void Adding_an_order_sets_the_client_back_reference()
|
||||||
{
|
{
|
||||||
@@ -84,30 +69,21 @@ public class BeforeTests
|
|||||||
Assert.Single(client.Orders);
|
Assert.Single(client.Orders);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// A client exposes the context it was attached to.
|
||||||
// (6) THE LEAK: from a plain Client you can reach its DbContext.
|
|
||||||
// This is exactly what the "after" situation removes: a ClientDto has
|
|
||||||
// no DbContext to reach, so a consumer can never touch the persistence
|
|
||||||
// layer through it.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void A_client_can_reach_its_DbContext__the_leak()
|
public void A_client_can_reach_its_DbContext()
|
||||||
{
|
{
|
||||||
var db = new DbContext();
|
var db = new DbContext();
|
||||||
var client = new Client { Name = "Acme" };
|
var client = new Client { Name = "Acme" };
|
||||||
db.Attach(client);
|
db.Attach(client);
|
||||||
db.Save();
|
db.Save();
|
||||||
|
|
||||||
// Reaching the persistence layer *through* the domain object:
|
|
||||||
Assert.NotNull(client.DbContext);
|
Assert.NotNull(client.DbContext);
|
||||||
Assert.Same(db, client.DbContext);
|
Assert.Same(db, client.DbContext);
|
||||||
Assert.True(client.DbContext!.IsTracked(client));
|
Assert.True(client.DbContext!.IsTracked(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// WebApp works on domain classes directly.
|
||||||
// (7) The WebApp consumes the domain classes directly — including a method
|
|
||||||
// that rides the leak.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WebApp_works_on_the_domain_classes_directly()
|
public void WebApp_works_on_the_domain_classes_directly()
|
||||||
{
|
{
|
||||||
@@ -121,17 +97,14 @@ public class BeforeTests
|
|||||||
db.Attach(order);
|
db.Attach(order);
|
||||||
db.Save();
|
db.Save();
|
||||||
|
|
||||||
// ShowClient consumes Client and its Orders directly:
|
|
||||||
var view = app.ShowClient(client);
|
var view = app.ShowClient(client);
|
||||||
Assert.Contains("Acme", view);
|
Assert.Contains("Acme", view);
|
||||||
Assert.Contains("order one", view);
|
Assert.Contains("order one", view);
|
||||||
|
|
||||||
// IsPersisted rides the leak (client.DbContext) — and it is true here
|
|
||||||
// because the client was saved through the context:
|
|
||||||
Assert.True(app.IsPersisted(client));
|
Assert.True(app.IsPersisted(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
// A companion check: a bare client has no context until saved.
|
// A bare client has no context until saved.
|
||||||
[Fact]
|
[Fact]
|
||||||
public void A_bare_client_has_no_context_until_saved()
|
public void A_bare_client_has_no_context_until_saved()
|
||||||
{
|
{
|
||||||
@@ -142,15 +115,11 @@ public class BeforeTests
|
|||||||
Assert.False(app.IsPersisted(client));
|
Assert.False(app.IsPersisted(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// DbBase exposes a static, shared Context reference.
|
||||||
// (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]
|
[Fact]
|
||||||
public void DbBase_has_a_static_Context_singleton()
|
public void DbBase_has_a_static_Context_singleton()
|
||||||
{
|
{
|
||||||
Assert.Null(DbBase.Context); // fresh — not set yet
|
Assert.Null(DbBase.Context);
|
||||||
|
|
||||||
var db = new DbContext();
|
var db = new DbContext();
|
||||||
DbBase.Context = db;
|
DbBase.Context = db;
|
||||||
@@ -160,11 +129,7 @@ public class BeforeTests
|
|||||||
DbBase.Context = null; // cleanup
|
DbBase.Context = null; // cleanup
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// DbBase.Find returns the first tracked entity of type T matching the predicate.
|
||||||
// (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]
|
[Fact]
|
||||||
public void DbBase_Find_finds_matching_entity()
|
public void DbBase_Find_finds_matching_entity()
|
||||||
{
|
{
|
||||||
@@ -180,10 +145,7 @@ public class BeforeTests
|
|||||||
Assert.Same(globex, result);
|
Assert.Same(globex, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// DbBase.Find returns null when there is no match.
|
||||||
// (10) DbBase.Find returns null when no entity of type T satisfies
|
|
||||||
// the predicate.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DbBase_Find_returns_null_when_no_match()
|
public void DbBase_Find_returns_null_when_no_match()
|
||||||
{
|
{
|
||||||
@@ -195,10 +157,7 @@ public class BeforeTests
|
|||||||
Assert.Null(result);
|
Assert.Null(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// DbBase.Find only matches entities of type T.
|
||||||
// (11) DbBase.Find filters by runtime type — an Order attached to the
|
|
||||||
// context does NOT match a Client predicate.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DbBase_Find_ignores_non_matching_types()
|
public void DbBase_Find_ignores_non_matching_types()
|
||||||
{
|
{
|
||||||
@@ -210,9 +169,7 @@ public class BeforeTests
|
|||||||
Assert.Null(result);
|
Assert.Null(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// DbBase.Find returns the first match only.
|
||||||
// (12) DbBase.Find returns the FIRST matching entity only.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DbBase_Find_returns_first_match()
|
public void DbBase_Find_returns_first_match()
|
||||||
{
|
{
|
||||||
@@ -228,10 +185,7 @@ public class BeforeTests
|
|||||||
Assert.NotSame(second, result);
|
Assert.NotSame(second, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// DbBase.Find returns null when no context is available.
|
||||||
// (13) DbBase.Find handles a null context gracefully — with no explicit
|
|
||||||
// context AND no singleton configured, it returns default (null).
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DbBase_Find_with_null_context_returns_default()
|
public void DbBase_Find_with_null_context_returns_default()
|
||||||
{
|
{
|
||||||
@@ -239,8 +193,7 @@ public class BeforeTests
|
|||||||
Assert.Null(result);
|
Assert.Null(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// (13b) When no explicit context is passed, Find falls back to the
|
// DbBase.Find falls back to DbBase.Context when db is null.
|
||||||
// DbBase.Context singleton instead of returning null.
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DbBase_Find_falls_back_to_Context_singleton_when_db_is_null()
|
public void DbBase_Find_falls_back_to_Context_singleton_when_db_is_null()
|
||||||
{
|
{
|
||||||
@@ -262,9 +215,7 @@ public class BeforeTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// Client.FindByName delegates to DbBase.Find.
|
||||||
// (14) Client.FindByName delegates to DbBase.Find and works correctly.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Client_FindByName_finds_by_name()
|
public void Client_FindByName_finds_by_name()
|
||||||
{
|
{
|
||||||
@@ -278,9 +229,7 @@ public class BeforeTests
|
|||||||
Assert.Same(acme, result);
|
Assert.Same(acme, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// Client.FindByName returns null when there is no match.
|
||||||
// (15) Client.FindByName returns null when the name does not match.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Client_FindByName_returns_null_when_not_found()
|
public void Client_FindByName_returns_null_when_not_found()
|
||||||
{
|
{
|
||||||
@@ -292,7 +241,7 @@ public class BeforeTests
|
|||||||
Assert.Null(result);
|
Assert.Null(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// (15b) FindByName with no context argument uses the Context singleton.
|
// Client.FindByName uses DbBase.Context when db is omitted.
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Client_FindByName_uses_Context_singleton_when_db_omitted()
|
public void Client_FindByName_uses_Context_singleton_when_db_omitted()
|
||||||
{
|
{
|
||||||
@@ -314,12 +263,7 @@ public class BeforeTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// DbBase.Context can be passed as the context argument to Find.
|
||||||
// (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]
|
[Fact]
|
||||||
public void DbBase_Context_singleton_is_used_in_Find()
|
public void DbBase_Context_singleton_is_used_in_Find()
|
||||||
{
|
{
|
||||||
@@ -329,9 +273,6 @@ public class BeforeTests
|
|||||||
|
|
||||||
DbBase.Context = db;
|
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);
|
var queryResult = DbBase.Find<Client>(c => c.Name == "TargetCo", DbBase.Context);
|
||||||
|
|
||||||
Assert.NotNull(queryResult);
|
Assert.NotNull(queryResult);
|
||||||
@@ -340,9 +281,7 @@ public class BeforeTests
|
|||||||
DbBase.Context = null; // cleanup
|
DbBase.Context = null; // cleanup
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// DbBase.FindRequired returns the matching entity.
|
||||||
// (17) DbBase.FindRequired<T> finds the matching entity — same result as Find<T>.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DbBase_FindRequired_T_finds_matching_entity()
|
public void DbBase_FindRequired_T_finds_matching_entity()
|
||||||
{
|
{
|
||||||
@@ -357,10 +296,7 @@ public class BeforeTests
|
|||||||
Assert.Same(globex, result);
|
Assert.Same(globex, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// DbBase.FindRequired throws ObjectNotFoundException when there is no match.
|
||||||
// (18) DbBase.FindRequired<T> throws ObjectNotFoundException when no match
|
|
||||||
// is found (the domain-specific lookup miss, not a BCL exception).
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DbBase_FindRequired_T_throws_when_no_match()
|
public void DbBase_FindRequired_T_throws_when_no_match()
|
||||||
{
|
{
|
||||||
@@ -374,13 +310,8 @@ public class BeforeTests
|
|||||||
Assert.Contains("no matching Client found", ex.Message);
|
Assert.Contains("no matching Client found", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// DbBase.FindRequired throws InvalidOperationException when no context is
|
||||||
// (19) DbBase.FindRequired<T> throws with a useful message when no context is
|
// configured — InvalidOperationException, not ObjectNotFoundException.
|
||||||
// configured (both explicit null and singleton null).
|
|
||||||
//
|
|
||||||
// Deliberately InvalidOperationException, not ObjectNotFoundException:
|
|
||||||
// a missing context is a configuration error, not a lookup miss.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DbBase_FindRequired_T_throws_when_no_context_configured()
|
public void DbBase_FindRequired_T_throws_when_no_context_configured()
|
||||||
{
|
{
|
||||||
@@ -393,9 +324,7 @@ public class BeforeTests
|
|||||||
Assert.Contains("DbBase.Context", ex.Message);
|
Assert.Contains("DbBase.Context", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// Client.FindByNameRequired returns the matching client.
|
||||||
// (20) Client.FindByNameRequired finds the matching client by name.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Client_FindByNameRequired_finds_by_name()
|
public void Client_FindByNameRequired_finds_by_name()
|
||||||
{
|
{
|
||||||
@@ -408,10 +337,8 @@ public class BeforeTests
|
|||||||
Assert.Same(acme, result);
|
Assert.Same(acme, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// Client.FindByNameRequired throws ObjectNotFoundException when there is no
|
||||||
// (21) Client.FindByNameRequired throws ObjectNotFoundException when no
|
// match; the message includes the name.
|
||||||
// match — message includes name.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Client_FindByNameRequired_throws_with_name_when_not_found()
|
public void Client_FindByNameRequired_throws_with_name_when_not_found()
|
||||||
{
|
{
|
||||||
@@ -425,9 +352,7 @@ public class BeforeTests
|
|||||||
Assert.Contains("no matching Client found", ex.Message);
|
Assert.Contains("no matching Client found", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// Client.FindByNameRequired uses DbBase.Context when no context is passed.
|
||||||
// (22) Client.FindByNameRequired uses Context singleton when no context arg.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Client_FindByNameRequired_uses_Context_singleton()
|
public void Client_FindByNameRequired_uses_Context_singleton()
|
||||||
{
|
{
|
||||||
@@ -447,11 +372,8 @@ public class BeforeTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// Client.FindByNameRequired throws InvalidOperationException when no
|
||||||
// (23) Client.FindByNameRequired throws with a useful message when the
|
// context is configured.
|
||||||
// singleton is not configured (no arg, no singleton).
|
|
||||||
// Stays InvalidOperationException: configuration error, not a miss.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Client_FindByNameRequired_throws_with_context_hint_when_singleton_null()
|
public void Client_FindByNameRequired_throws_with_context_hint_when_singleton_null()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user