Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46b91fc3ce | ||
|
|
89a1158c93 | ||
|
|
08b52806de | ||
|
|
53586d33a4 | ||
|
|
37ebb21182 | ||
|
|
d39426d328 | ||
|
|
803580786b | ||
|
|
49f4ca395f | ||
|
|
9895ef87e4 | ||
|
|
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 test db-subclass-to-dto.sln
|
||||
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
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
# Exercise: the discount that wouldn't map
|
||||
|
||||
You are in the **"after" situation** of this code base: the domain objects
|
||||
(`Client`, `Order`) are plain — no `DbBase` inheritance, no knowledge of the
|
||||
`DbContext`. Two hand-written mapping families lead out of the domain:
|
||||
|
||||
- `OrderDto` / `ClientDto` — transport shapes consumed by the WebApp;
|
||||
- `OrderData` / `ClientData` — the data-only records the persistence framework
|
||||
stores (one row per object, object graph flattened to ids).
|
||||
|
||||
The *before* situation was Active Record: the domain object and the stored
|
||||
shape were one and the same class, so there was nothing to map. Now mapping is
|
||||
explicit — and that is where this exercise lives.
|
||||
|
||||
## What you get
|
||||
|
||||
- `Order.TotalInCents` — the order total, an integer in cents, persisted and
|
||||
mapped one-on-one into `OrderDto` and `OrderData`.
|
||||
- `DiscountCalculator.CalculateDiscount(order, discountBarrierCents, discountPercentage)` —
|
||||
a pure domain service. It returns a **`DiscountedOrder`** (a separate domain
|
||||
type, correlated to the order by `OrderId`) carrying the order's total, the
|
||||
percentage *actually applied* (0 when the total does not strictly exceed the
|
||||
barrier), and the discounted total. Rounding is half-up, in integer math.
|
||||
- Unit tests for all of the above already pass.
|
||||
- `ValidOrder()` / `ValidClient()` test data builders in the test project
|
||||
(`tests/BeforeAfter.Tests/TestData/`) — construct domain objects in tests
|
||||
through them, so tests survive domain evolution. Import their static
|
||||
factories with `using static BeforeAfter.Tests.TestData.OrderBuilder;`
|
||||
(and `...ClientBuilder;`).
|
||||
|
||||
## The challenge
|
||||
|
||||
The `DiscountedOrder` is **not** a one-on-one copy of the order, and it must
|
||||
not become one:
|
||||
|
||||
- `Order` needs to learn the *discounted total* — but neither `OrderDto` nor
|
||||
`OrderData` may gain it. The discount is derived data: it lives in the
|
||||
domain only, and the storage schema stays untouched.
|
||||
- The `DiscountedOrder.DiscountPercentage` is **not** copied onto the order —
|
||||
it can be derived from the two totals (and `TotalInCents` never changes).
|
||||
|
||||
So the flow is: order → `DiscountedOrder` (calculate) → order (correlate +
|
||||
copy, dropping what is derivable) → persistence (which stores *less* than the
|
||||
domain now knows).
|
||||
|
||||
Work through the three steps below — each is small enough to hand to a coding
|
||||
agent as a single instruction. Run the tests after each step:
|
||||
|
||||
```sh
|
||||
scripts/run-tests.sh
|
||||
```
|
||||
|
||||
## Step 1 — teach the order its discounted total
|
||||
|
||||
Add a single property `DiscountedTotalInCents` (`int`, default 0) to the
|
||||
**domain** `Order` in `src/After/Order.cs`. Do not touch `OrderDto`,
|
||||
`OrderData`, or the mappers: the discounted total must exist in the domain
|
||||
only. Acceptance: the solution builds and the full test suite still passes.
|
||||
|
||||
## Step 2 — correlate a discounted order onto its order
|
||||
|
||||
Create `tests/BeforeAfter.Tests/DiscountedOrderMappingTests.cs` from the stub
|
||||
below and make the **first** test pass. The point is the correlation: the
|
||||
`DiscountedOrder` knows the id of the order it was calculated from — use it
|
||||
instead of assuming there is only one order around.
|
||||
|
||||
```csharp
|
||||
using After;
|
||||
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||
|
||||
namespace BeforeAfter.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// EXERCISE — the mapping from a DiscountedOrder onto an Order is NOT
|
||||
/// one-on-one. Make these tests pass, following EXERCISE.md step by step.
|
||||
/// </summary>
|
||||
public class DiscountedOrderMappingTests
|
||||
{
|
||||
[Fact] // EXERCISE STEP 2
|
||||
public void A_discounted_order_can_be_correlated_onto_its_order()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
db.Save(client);
|
||||
db.Save(order);
|
||||
|
||||
// 10 % for orders above €100:
|
||||
var discounted = DiscountCalculator.CalculateDiscount(
|
||||
order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
// TODO (step 2): correlate the discounted order to the saved order and
|
||||
// copy the discounted total onto the order.
|
||||
// Hints: DiscountedOrder.OrderId identifies the order; the discount
|
||||
// percentage is deliberately NOT copied — it is derivable.
|
||||
|
||||
// ... your code here ...
|
||||
|
||||
Assert.Equal(135_00, order.DiscountedTotalInCents);
|
||||
}
|
||||
|
||||
[Fact] // EXERCISE STEP 3
|
||||
public void Saving_a_discounted_order_keeps_the_stored_total_untouched()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
db.Save(client);
|
||||
db.Save(order);
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(
|
||||
order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
// TODO (step 3): correlate + copy as in step 2, then save the updated
|
||||
// order through the context. Afterwards find the order's persisted
|
||||
// OrderData row (hint: db.Find<OrderData>(...)) and assert that the
|
||||
// stored total is STILL the original 150_00: the database knows
|
||||
// nothing about discounts — derived data lives in the domain only.
|
||||
|
||||
// ... your code here ...
|
||||
|
||||
OrderData row = null!; // TODO (step 3): find the OrderData row for this order
|
||||
Assert.Equal(150_00, row.TotalInCents);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Acceptance: the first test is green, the second still red.
|
||||
|
||||
## Step 3 — save, and check what actually persisted
|
||||
|
||||
Make the **second** test pass: re-save the updated order through the context
|
||||
(the context refreshes the stored row in place), then assert on the persisted
|
||||
`OrderData` row. Note what you are *not* asserting: there is no discounted
|
||||
column to check — the row simply keeps the original total.
|
||||
|
||||
Acceptance: the whole suite is green — including the pre-existing tests, which
|
||||
guard exactly the invariants you must not break (the DTO and the persistence
|
||||
records still map the same fields as before).
|
||||
|
||||
## Facilitator notes
|
||||
|
||||
- Expected end state per step: (1) one new domain-only property; (2) a mapping
|
||||
fragment *outside* the `Mappers` class — correlation by id plus a field
|
||||
copy, dropping the derivable percentage; (3) `db.Save(order)` after which
|
||||
the `OrderData` row still shows `TotalInCents == 150_00`.
|
||||
- Discussion points: why two mapping families out of the domain (transport vs
|
||||
persistence) and why neither carries the discount; what an Active Record
|
||||
"before" world would have done here (the discounted value would have been
|
||||
a column, or nothing at all); what happens to `DiscountedTotalInCents` when
|
||||
the order travels to the WebApp and back (the DTO round-trip silently drops
|
||||
it — derived data must be recomputed, not transported).
|
||||
- The unit tests in `DiscountCalculatorTests.cs` pin the calculation contract
|
||||
(strict barrier, half-up rounding, validation); participants should not need
|
||||
to touch them.
|
||||
@@ -2,10 +2,53 @@
|
||||
|
||||
This is a workspace for creating three exercises.
|
||||
|
||||
One exercise is about exploring a repository using prompts for a coding agent to create Mermaid and PlantUML diagrams. We're focusing on class diagrams and sequence diagrams to get an idea of what is going on. We use the starting point of the second exercise for this.
|
||||
## Explore a repository with LLM diagrams
|
||||
|
||||
The second exercise is factoring out domain objects, domain entities, from a structure that is previously subclass-based. So say you use a structure like active records or Entity Framework where you have an active record class and your domain class subclasses from the active record class which gives you maybe some nice things like find by an attribute. And then you can quite quickly — so these are straight-offs — you can quite quickly structure an application while it's also persisted. So you can say, "I want to have a customer, I want to have an order. And oh yes, I'm doing this for a webshop." So you have objects, relations, attributes, and you can all easily save them in your database. And this is fine when your application is small and you're just starting out. It allows you to very quickly scaffold an application. But it can become painful when your data doesn't have the right shape for it. So performance is slow. So at some point as your application grows, you probably want to be more deliberate in how you start.
|
||||
Explore a repository using prompts for a coding agent to create Mermaid and PlantUML diagrams. We're focusing on class diagrams and sequence diagrams to get an idea of what is going on. We use the starting point of the second exercise for this.
|
||||
|
||||
The second exercise is factoring out domain classes from a database inheritance structure. So we start with a couple of domain classes and some relations. Then we pre-deliver some classes that we already created and we demo it. As we have some domain classes, we create a mapping for writing and reading a domain class. And we make sure that running the existing tests against the fake database that we have for the exercise is really slow. The sequence diagrams that we make in the first exercise will come in handy. Because in order to do this, you do start wondering how does this web application find a client? How can we see clients? How can we create an order? And what happens once the flow through the application as we do this? You sort of need to understand this to choose the kind of mapping that you use. So we are going to reuse the analysis we did in the first exercise in the second exercise.
|
||||
## Factor out domain objects from ActiveRecord-like structures
|
||||
|
||||
The third part is a spike in creating an extract method refactoring using Roslyn, the C# static analysis tooling that comes as part of C# more or less. It's an additional NuGet download but yeah it is quite good. So this is a simple command line that allows you to specify the name of a file, the starting line and the end line and it will give you an analysis of dependencies inside the method that you need to decide which things could lend itself to extracting to a method, what the parameters could be and what the return value could be. This is work in progress so this is not something to share with participants but it is part of a flow where you first you iterate and you prompt, you create codes then maybe you extract things into a skill or you prompt for a diagram of some kind. You go, ah I often seem to make sequence diagrams. Okay, can we make something deterministic to create these sequence diagrams so it's faster and more reliable? Or in my case I don't like, well I like the extract method stuff in JetBrains IDEs but I need to do some steps before to find the parameters, extract variables, extract the methods with these variables as parameters then inline the variables again and I was wondering if I could do that more smoothly in the flow with a coding agent. That also means that going further I could do things like forbid extracted methods from having Async in all parts, but see if we can extract parts without side effects first.
|
||||
Factor out domain objects, domain entities, from a structure that is previously subclass-based.
|
||||
|
||||
Say you use a structure like Active Record or Entity Framework where you have an active record class and your domain class subclasses from the active record class.
|
||||
|
||||
This super class gives you some easy features like finding an object by attribute. You can quite quickly structure an application while it is also persisted.
|
||||
|
||||
For instance you can say, "I want to have a customer, I want to have an order. And oh yes, I'm doing this for a webshop." So you have objects, relations, attributes, and you can all easily save them in your database.
|
||||
|
||||
This is fine when your application is small and you're just starting out. It allows you to very quickly scaffold an application. But it can become painful when your data doesn't have the right shape for it. So performance is slow. At some point as your application grows, you probably want to be more deliberate in how you deal with data.
|
||||
|
||||
### How do we factor out domain classes?
|
||||
|
||||
To get away from the inheritance structure with an 'abstract' class tied to the database, we need two or three database-bound concrete classes and some relations.
|
||||
|
||||
We pre-deliver some classes that we already migrated to the new structure and we demo it. We create a mapping for writing and reading a domain class. And we make sure that running the existing tests against the fake database that we have for the exercise is **really slow.**
|
||||
|
||||
### Sequence diagrams come in handy
|
||||
|
||||
The sequence diagrams that we made in the first exercise will come in handy. Because in order to do this, you do start wondering how does this web application find a client? How can we see clients? How can we create an order? And what happens to the flow through the application as we do this?
|
||||
|
||||
You need to understand the application flow to choose the kind of mapping that you use. Therefore we are going to reuse the analysis we did in the first exercise. Instructions for participants will be pre-structured, so they can focus on doing the exercise instead of devising exacp prompts.
|
||||
|
||||
## Spike: Static analysis and Extract Method refactoring with Roslyn
|
||||
|
||||
The third part is a spike in creating an extract method refactoring using Roslyn, the C# static analysis tooling that comes as part of C# more or less.
|
||||
|
||||
It's an additional NuGet download but it is quite powerful.
|
||||
|
||||
This is a simple command line app that allows you to specify the name of a file, the starting line and the end line and it will give you an analysis of dependencies inside the method that you need to decide which things could lend itself to extracting to a method, what the parameters could be and what the return value could be.
|
||||
|
||||
This is work in progress so this is not something to share with participants yet, unless it is in the form of a demo after participants have prompted diagrams.
|
||||
|
||||
But it is part of a flow where:
|
||||
|
||||
1. you iterate and you prompt,
|
||||
2. create visualisations
|
||||
3. extract things into a skill
|
||||
4. extract moldable tools to make the skill faster and more reliable
|
||||
|
||||
Skills and tools together create a flywheel where you can do things that happen again and again more reliably and faster. Speed through quality.
|
||||
|
||||
## aside on why skills and tools in the line of moldable development
|
||||
|
||||
You go, ah I often seem to make sequence diagrams. Okay, can we make something deterministic to create these sequence diagrams so it's faster and more reliable? Or in my case I don't like, well I like the extract method stuff in JetBrains IDEs but I need to do some steps before to find the parameters, extract variables, extract the methods with these variables as parameters then inline the variables again and I was wondering if I could do that more smoothly in the flow with a coding agent. That also means that going further I could do things like forbid extracted methods from having Async in all parts, but see if we can extract parts without side effects first.
|
||||
|
||||
+16
-1
@@ -1,4 +1,4 @@
|
||||
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
@@ -17,6 +17,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExtractMethod", "tools\ExtractMethod\ExtractMethod.csproj", "{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Before.Console", "src\Before.Console\Before.Console.csproj", "{AC39E693-E83D-4B50-A120-0DE259108AF2}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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|x86.ActiveCfg = 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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -84,5 +98,6 @@ Global
|
||||
{E65C0410-A863-44B0-88A4-6C74EF929419} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{F5E0C182-CBC3-4440-AF8F-32887F9B589C} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
||||
{AC39E693-E83D-4B50-A120-0DE259108AF2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
EndGlobalSection
|
||||
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 -- "$@"
|
||||
+6
-5
@@ -4,9 +4,10 @@ namespace After;
|
||||
/// The DB base class still exists in the "after" situation — it is part of the
|
||||
/// *persistence* layer — but the direction of the dependency is flipped:
|
||||
/// domain objects (<see cref="Client"/>, <see cref="Order"/>) no longer derive
|
||||
/// from it. Only the <see cref="DbContext"/>'s own internal shadow entities do,
|
||||
/// so nothing outside the persistence layer can ever reach a
|
||||
/// <see cref="DbContext"/> through a domain object.
|
||||
/// from it. Only the framework's own data-only persistence records do
|
||||
/// (<see cref="ClientData"/>, <see cref="OrderData"/>): storage wants data
|
||||
/// without behaviour, so the domain keeps the behaviour and the framework
|
||||
/// keeps the data — the mappers bridge the two shapes.
|
||||
/// </summary>
|
||||
public class DbBase
|
||||
{
|
||||
@@ -19,8 +20,8 @@ public class DbBase
|
||||
|
||||
/// <summary>
|
||||
/// Back-reference to the owning <see cref="DbContext"/>. In the after
|
||||
/// situation this lives only on the context's internal shadow entities —
|
||||
/// the plain domain objects that used to inherit it no longer do.
|
||||
/// situation this lives only on the persistence records — the plain
|
||||
/// domain objects that used to inherit it no longer do.
|
||||
/// </summary>
|
||||
public DbContext? DbContext { get; internal set; }
|
||||
}
|
||||
+149
-29
@@ -1,51 +1,171 @@
|
||||
namespace After;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The persistence side of the "after" situation. The (fake) framework stores
|
||||
// its own data-only records — <see cref="ClientData"/> / <see cref="OrderData"/>,
|
||||
// both <see cref="DbBase"/> subclasses — never the domain objects themselves.
|
||||
// Domain state reaches storage only through explicit mapping; the context keeps
|
||||
// a reference-identity map so a second <see cref="Save"/> of the same domain
|
||||
// object refreshes its row instead of duplicating it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// The fake <c>DbContext</c> of the after situation. The persistence layer
|
||||
/// still exists — but the direction of the dependency is flipped: domain
|
||||
/// objects are plain POCOs that know nothing about a context, and persisting
|
||||
/// 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.
|
||||
/// Persisted shape of a <see cref="Client"/>: data only — the orders are
|
||||
/// separate <see cref="OrderData"/> rows, so nothing more is needed here.
|
||||
/// </summary>
|
||||
public sealed class ClientData : DbBase
|
||||
{
|
||||
/// <summary>Display name of the client.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persisted shape of an <see cref="Order"/>: data only. The object graph is
|
||||
/// flattened for storage — the back-reference to the client becomes a foreign
|
||||
/// key, because storage has ids, not object references.
|
||||
/// </summary>
|
||||
public sealed class OrderData : DbBase
|
||||
{
|
||||
/// <summary>Free-text description of what this order is for.</summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Order total in cents of the stored order.</summary>
|
||||
public int TotalInCents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Foreign key to the owning client: <see cref="Guid.Empty"/> when the
|
||||
/// order has no client, or when its client had no id yet at save time —
|
||||
/// save clients before their orders.
|
||||
/// </summary>
|
||||
public Guid ClientId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The fake <c>DbContext</c>: per-type tables of data records, mirroring EF
|
||||
/// Core's <c>Set<T></c>. <see cref="Save(Client)"/> / <see cref="Save(Order)"/>
|
||||
/// map the domain object's *current* state into its record — inserting on the
|
||||
/// first save, refreshing in place on later ones.
|
||||
/// </summary>
|
||||
public class DbContext
|
||||
{
|
||||
// Change tracker, keyed by the domain object's reference identity.
|
||||
private readonly Dictionary<object, DbBase> _tracked = new();
|
||||
// ── per-type data tables ─────────────────────────────────────────
|
||||
private readonly List<ClientData> _clients = new();
|
||||
private readonly List<OrderData> _orders = new();
|
||||
|
||||
// ── fast lookup: domain object reference → its persisted record ──
|
||||
private readonly Dictionary<object, DbBase> _byRef = new();
|
||||
|
||||
// ── exposed per-type tables (read-only view) ─────────────────────
|
||||
public IReadOnlyList<ClientData> Clients => _clients.AsReadOnly();
|
||||
public IReadOnlyList<OrderData> Orders => _orders.AsReadOnly();
|
||||
|
||||
/// <summary>Generic accessor: the strongly-typed "table" for <typeparamref name="T"/>.</summary>
|
||||
public IReadOnlyList<T> Set<T>() where T : DbBase
|
||||
=> typeof(T) switch
|
||||
{
|
||||
var t when t == typeof(ClientData) => (IReadOnlyList<T>)_clients.AsReadOnly(),
|
||||
var t when t == typeof(OrderData) => (IReadOnlyList<T>)_orders.AsReadOnly(),
|
||||
_ => throw new NotSupportedException($"No per-type table for {typeof(T)}"),
|
||||
};
|
||||
|
||||
// ── explicit persistence: map + insert, or refresh ───────────────
|
||||
|
||||
/// <summary>
|
||||
/// Explicitly persist a <see cref="Client"/>: register it with a shadow
|
||||
/// 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.
|
||||
/// Persist a <see cref="Client"/>: the first save maps it to a fresh
|
||||
/// <see cref="ClientData"/> row and writes the generated id back into the
|
||||
/// domain object; a later save refreshes that row's data in place.
|
||||
/// </summary>
|
||||
public void Save(Client client)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
if (!_tracked.TryAdd(client, new DbBase { DbContext = this }))
|
||||
return; // already saved
|
||||
client.Id = _tracked[client].Id = Guid.NewGuid();
|
||||
var fresh = Mappers.ToClientData(client);
|
||||
if (_byRef.TryGetValue(client, out var existing))
|
||||
{
|
||||
((ClientData)existing).Name = fresh.Name; // refresh, keep row identity
|
||||
return;
|
||||
}
|
||||
var id = Guid.NewGuid();
|
||||
fresh.Id = client.Id = id; // write-back identity
|
||||
fresh.DbContext = this;
|
||||
_clients.Add(fresh);
|
||||
_byRef[client] = fresh;
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Persist an <see cref="Order"/> (same insert-or-refresh rule as
|
||||
/// <see cref="Save(Client)"/>; save its client first so the foreign key
|
||||
/// can be filled).
|
||||
/// </summary>
|
||||
public void Save(Order order)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(order);
|
||||
if (!_tracked.TryAdd(order, new DbBase { DbContext = this }))
|
||||
return; // already saved
|
||||
order.Id = _tracked[order].Id = Guid.NewGuid();
|
||||
var fresh = Mappers.ToOrderData(order);
|
||||
if (_byRef.TryGetValue(order, out var existing))
|
||||
{
|
||||
var row = (OrderData)existing; // refresh, keep row identity
|
||||
row.Description = fresh.Description;
|
||||
row.TotalInCents = fresh.TotalInCents;
|
||||
row.ClientId = fresh.ClientId;
|
||||
return;
|
||||
}
|
||||
var id = Guid.NewGuid();
|
||||
fresh.Id = order.Id = id; // write-back identity
|
||||
fresh.DbContext = this;
|
||||
_orders.Add(fresh);
|
||||
_byRef[order] = fresh;
|
||||
}
|
||||
|
||||
/// <summary>True if <paramref name="entity"/> has been saved through this context.</summary>
|
||||
public bool IsTracked(object entity) => _tracked.ContainsKey(entity);
|
||||
// ── loading: reassemble domain objects from the stored rows ──────
|
||||
|
||||
/// <summary>Read-only view of the shadow entities this context currently tracks.</summary>
|
||||
public IReadOnlyList<DbBase> Tracked => _tracked.Values.ToList();
|
||||
}
|
||||
/// <summary>
|
||||
/// Materialize the <see cref="Client"/> stored under <paramref name="id"/>,
|
||||
/// including all of its orders (navigation fix-up wires
|
||||
/// <see cref="Order.Client"/> back). Null when nothing is stored under it.
|
||||
/// </summary>
|
||||
public Client? LoadClient(Guid id)
|
||||
{
|
||||
var row = Find<ClientData>(r => r.Id == id);
|
||||
if (row is null) return null;
|
||||
var client = Mappers.FromClientData(row);
|
||||
foreach (var orderRow in _orders.Where(o => o.ClientId == id))
|
||||
client.Orders.Add(Mappers.FromOrderData(orderRow)); // fix-up wires Order.Client
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materialize the <see cref="Order"/> stored under <paramref name="id"/>,
|
||||
/// including its client (holding just this one order), or null when
|
||||
/// nothing is stored under it. Use <see cref="LoadClient"/> to load a
|
||||
/// client with all of its orders.
|
||||
/// </summary>
|
||||
public Order? LoadOrder(Guid id)
|
||||
{
|
||||
var row = Find<OrderData>(r => r.Id == id);
|
||||
if (row is null) return null;
|
||||
var order = Mappers.FromOrderData(row);
|
||||
if (row.ClientId != Guid.Empty)
|
||||
{
|
||||
var clientRow = Find<ClientData>(c => c.Id == row.ClientId);
|
||||
if (clientRow is not null)
|
||||
{
|
||||
var client = Mappers.FromClientData(clientRow);
|
||||
client.Orders.Add(order); // fix-up wires Order.Client
|
||||
}
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
// ── query helpers ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>Search the per-type table for a record whose predicate matches.</summary>
|
||||
public T? Find<T>(Func<T, bool> predicate) where T : DbBase
|
||||
=> 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 data records this context currently manages.</summary>
|
||||
public IReadOnlyList<DbBase> Tracked => _byRef.Values.ToList();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace After;
|
||||
|
||||
/// <summary>
|
||||
/// Domain service computing a discount for an <see cref="Order"/>. Pure
|
||||
/// calculation: it reads the order and returns a <see cref="DiscountedOrder"/>;
|
||||
/// it never mutates the order. Copying the result onto the order — and what
|
||||
/// that does or does not persist — is the exercise (see EXERCISE.md).
|
||||
/// </summary>
|
||||
public static class DiscountCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculate the discount for <paramref name="order"/>. The discount
|
||||
/// applies only when the order's total is <em>strictly greater</em> than
|
||||
/// the barrier — an order at exactly the barrier gets none.
|
||||
/// </summary>
|
||||
/// <param name="order">The order to discount; its <see cref="Order.TotalInCents"/> is the input.</param>
|
||||
/// <param name="discountBarrierCents">Minimum total (in cents) required to qualify; the total must exceed it.</param>
|
||||
/// <param name="discountPercentage">The discount as an integer percent (10 means 10 %), 0–100.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="DiscountedOrder"/> correlated to the order by id.
|
||||
/// <see cref="DiscountedOrder.DiscountPercentage"/> is 0 when the barrier
|
||||
/// was not passed, and <see cref="DiscountedOrder.DiscountedTotalInCents"/>
|
||||
/// equals the total in that case.
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="order"/> is null.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// The total or barrier is negative, or the percentage is outside 0–100.
|
||||
/// </exception>
|
||||
/// <remarks>
|
||||
/// The discount amount is rounded <b>half-up</b> with pure integer math —
|
||||
/// <c>(total × percentage + 50) / 100</c> — so 10 % of 155 cents is a
|
||||
/// 16-cent discount (15.5 rounds up), and no floating point is involved.
|
||||
/// </remarks>
|
||||
public static DiscountedOrder CalculateDiscount(Order order, int discountBarrierCents, int discountPercentage)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(order);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(order.TotalInCents);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(discountBarrierCents);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(discountPercentage, 0);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThan(discountPercentage, 100);
|
||||
|
||||
if (order.TotalInCents <= discountBarrierCents)
|
||||
return new DiscountedOrder
|
||||
{
|
||||
OrderId = order.Id,
|
||||
TotalInCents = order.TotalInCents,
|
||||
DiscountPercentage = 0,
|
||||
DiscountedTotalInCents = order.TotalInCents,
|
||||
};
|
||||
|
||||
// Half-up rounding in integers: add half of the divisor before the
|
||||
// integer division. 64-bit intermediate: cents × percent can overflow
|
||||
// 32 bits for large orders.
|
||||
var discount = (int)(((long)order.TotalInCents * discountPercentage + 50) / 100);
|
||||
|
||||
return new DiscountedOrder
|
||||
{
|
||||
OrderId = order.Id,
|
||||
TotalInCents = order.TotalInCents,
|
||||
DiscountPercentage = discountPercentage,
|
||||
DiscountedTotalInCents = order.TotalInCents - discount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace After;
|
||||
|
||||
/// <summary>
|
||||
/// The result of discounting an <see cref="Order"/>: a separate domain type,
|
||||
/// not a modified order. The discount is derived data — computed on demand,
|
||||
/// correlated to its order by <see cref="OrderId"/>. What a caller does with
|
||||
/// it (and what of it ever reaches <see cref="OrderDto"/> or
|
||||
/// <see cref="OrderData"/>) is deliberately left to the caller.
|
||||
/// </summary>
|
||||
public sealed record DiscountedOrder
|
||||
{
|
||||
/// <summary>Primary key of the discounted <see cref="Order"/>.</summary>
|
||||
public Guid OrderId { get; init; }
|
||||
|
||||
/// <summary>The order's total in cents, as read from it (the untouched input).</summary>
|
||||
public int TotalInCents { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The percentage actually applied, as an integer percent. 0 when the
|
||||
/// order did not pass the discount barrier.
|
||||
/// </summary>
|
||||
public int DiscountPercentage { get; init; }
|
||||
|
||||
/// <summary>The total after the discount, in cents.</summary>
|
||||
public int DiscountedTotalInCents { get; init; }
|
||||
}
|
||||
+74
-4
@@ -1,13 +1,21 @@
|
||||
namespace After;
|
||||
|
||||
/// <summary>
|
||||
/// Hand-written mappers between the plain domain and the DTOs (no AutoMapper —
|
||||
/// the mapping code is part of the illustration). Each public method maps in
|
||||
/// exactly one direction and never mutates its input.
|
||||
/// Hand-written mappers out of the plain domain (no AutoMapper — the mapping
|
||||
/// code is part of the illustration). Each public method maps in exactly one
|
||||
/// direction and never mutates its input. Two families, two destinations:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ClientDto"/>/<see cref="OrderDto"/> — transport to the
|
||||
/// WebApp;</item>
|
||||
/// <item><see cref="ClientData"/>/<see cref="OrderData"/> — the data-only
|
||||
/// records the persistence framework stores.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The <c>Client.Orders</c> / <c>Order.Client</c> back-reference cycle is
|
||||
/// resolved with a reference-identity cache: the same instance maps to the
|
||||
/// same DTO (and back), so the graph terminates and stays consistent.
|
||||
/// same DTO (and back), so the graph terminates and stays consistent. The
|
||||
/// persistence mapping needs no cache: records reference each other by id,
|
||||
/// not by object, so the graph is flattened and cannot cycle.
|
||||
/// </summary>
|
||||
public static class Mappers
|
||||
{
|
||||
@@ -64,6 +72,7 @@ public static class Mappers
|
||||
{
|
||||
Id = order.Id,
|
||||
Description = order.Description,
|
||||
TotalInCents = order.TotalInCents,
|
||||
ClientDto = order.Client is null ? null : MapClient(order.Client, seen),
|
||||
};
|
||||
seen[order] = dto;
|
||||
@@ -96,7 +105,68 @@ public static class Mappers
|
||||
Description = dto.Description,
|
||||
Client = dto.ClientDto is null ? null : UnmapClient(dto.ClientDto, seen),
|
||||
};
|
||||
// Derived data cannot be written, only re-derived: the flat DTO total
|
||||
// becomes exactly one line.
|
||||
order.Lines.Add(new OrderLine(dto.Description, dto.TotalInCents));
|
||||
seen[dto] = order;
|
||||
return order;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Domain -> persistence (data-only records; graph flattened to ids)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Map a <see cref="Client"/> to its persistence record <see cref="ClientData"/>.</summary>
|
||||
public static ClientData ToClientData(Client client)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
return new ClientData { Id = client.Id, Name = client.Name };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map an <see cref="Order"/> to its persistence record <see cref="OrderData"/>:
|
||||
/// the object graph is flattened — <see cref="Order.Client"/> becomes the
|
||||
/// <see cref="OrderData.ClientId"/> foreign key (empty when the order has
|
||||
/// no client, or when that client has not been saved yet).
|
||||
/// </summary>
|
||||
public static OrderData ToOrderData(Order order)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(order);
|
||||
return new OrderData
|
||||
{
|
||||
Id = order.Id,
|
||||
Description = order.Description,
|
||||
TotalInCents = order.TotalInCents,
|
||||
ClientId = order.Client?.Id ?? Guid.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Persistence -> domain (read-back). A plain field copy for now; when
|
||||
// the domain grows OrderLines, FromOrderData becomes the one-line
|
||||
// reconstruction (row description + total become a single line) —
|
||||
// see EXERCISE2.md.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Map a <see cref="ClientData"/> record back to a <see cref="Client"/>.</summary>
|
||||
public static Client FromClientData(ClientData row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
return new Client { Id = row.Id, Name = row.Name };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map an <see cref="OrderData"/> record back to an <see cref="Order"/>.
|
||||
/// Reassembling the graph (wiring <see cref="Order.Client"/>) is the
|
||||
/// context's job, not the mapper's.
|
||||
/// </summary>
|
||||
public static Order FromOrderData(OrderData row)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
var order = new Order { Id = row.Id, Description = row.Description };
|
||||
// Read-back: the flat row becomes exactly one synthetic line, so the
|
||||
// computed total — and everything calculated from it — keeps working.
|
||||
order.Lines.Add(new OrderLine(row.Description, row.TotalInCents));
|
||||
return order;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,28 @@ public class Order
|
||||
/// <summary>Free-text description of what this order is for.</summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Order total in cents (integer — no floating-point money), computed
|
||||
/// from the order lines. Since the lines arrived this is derived data:
|
||||
/// it cannot be written, only re-derived — which is exactly why the
|
||||
/// inbound mappings (DTO, storage row) reconstruct a one-line order.
|
||||
/// Persisted in <see cref="OrderData"/> and mapped one-on-one into
|
||||
/// <see cref="OrderDto.TotalInCents"/>.
|
||||
/// </summary>
|
||||
public int TotalInCents => Lines.Sum(line => line.AmountInCents);
|
||||
|
||||
/// <summary>The lines this order consists of. The total is their sum.</summary>
|
||||
public List<OrderLine> Lines { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The discounted total in cents, copied onto the order by the caller of
|
||||
/// the discount flow (see <see cref="DiscountCalculator"/>). Derived
|
||||
/// data: it lives in the domain only — neither <see cref="OrderDto"/>
|
||||
/// nor <see cref="OrderData"/> carries it — and the applied percentage
|
||||
/// is not stored either, being derivable from the two totals.
|
||||
/// </summary>
|
||||
public int DiscountedTotalInCents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The client that owns this order. Set automatically by
|
||||
/// <see cref="ClientOrders"/> when the order is added to
|
||||
|
||||
@@ -13,6 +13,9 @@ public sealed record OrderDto
|
||||
/// <summary>Free-text description of what this order is for.</summary>
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Order total in cents of the underlying order.</summary>
|
||||
public int TotalInCents { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Back-reference to the owning client, or <c>null</c> for a bare order
|
||||
/// that has not been added to any client.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace After;
|
||||
|
||||
/// <summary>
|
||||
/// One line of an <see cref="Order"/>: its own description and amount. A
|
||||
/// plain, mutable domain type in the same style as <see cref="Order"/> and
|
||||
/// <see cref="Client"/> (only the DTOs are records).
|
||||
/// </summary>
|
||||
public class OrderLine(string description, int amountInCents)
|
||||
{
|
||||
/// <summary>Free-text description of what this line is for.</summary>
|
||||
public string Description { get; set; } = description;
|
||||
|
||||
/// <summary>Line amount in cents (integer — no floating-point money).</summary>
|
||||
public int AmountInCents { get; set; } = amountInCents;
|
||||
}
|
||||
@@ -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", TotalInCents = 120_00 });
|
||||
jane.Orders.Add(new Order { Description = "Website redesign", TotalInCents = 750_00 });
|
||||
|
||||
bob.Orders.Add(new Order { Description = "Monthly retainer", TotalInCents = 85_00 });
|
||||
|
||||
// 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} ({order.TotalInCents} cents)");
|
||||
}
|
||||
|
||||
// ----- 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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
|
||||
@@ -11,6 +11,21 @@ namespace Before;
|
||||
/// </summary>
|
||||
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>
|
||||
/// Primary key. Fresh (unsaved) entities have <see cref="Guid.Empty"/>;
|
||||
/// <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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// A tiny hand-rolled fake of EF's <c>DbContext</c>: it keeps a change-tracker
|
||||
/// (a registration collection of the <see cref="DbBase"/> entities it knows
|
||||
/// about) and a <see cref="Save"/> that fakes <c>SaveChangesAsync</c>.
|
||||
/// A tiny hand-rolled fake of EF's <c>DbContext</c>: it keeps per-type
|
||||
/// change-tracker tables (mirroring EF's <see cref="DbSet{T}"/> pattern) and
|
||||
/// 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*
|
||||
@@ -11,25 +13,57 @@ namespace Before;
|
||||
/// </summary>
|
||||
public class DbContext
|
||||
{
|
||||
// The change tracker: the set of entities this context is responsible for.
|
||||
// (EF calls this its change tracker; a list stands in for the
|
||||
// id -> entity registration dictionary.)
|
||||
private readonly List<DbBase> _tracked = new();
|
||||
// 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();
|
||||
|
||||
/// <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>
|
||||
/// Attach an entity to this context (EF's <c>Add</c>). Sets the
|
||||
/// 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>
|
||||
public void Attach(DbBase entity)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entity);
|
||||
entity.DbContext = this;
|
||||
if (!_tracked.Contains(entity))
|
||||
_tracked.Add(entity);
|
||||
var table = TableFor(entity.GetType());
|
||||
if (!table.Contains(entity))
|
||||
table.Add(entity);
|
||||
}
|
||||
|
||||
/// <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"/>.
|
||||
/// Returns the number of entities that were (re)saved — i.e. newly
|
||||
/// identified — mirroring EF's "rows written" return value.
|
||||
@@ -37,20 +71,61 @@ public class DbContext
|
||||
public int Save()
|
||||
{
|
||||
var saved = 0;
|
||||
foreach (var entity in _tracked)
|
||||
{
|
||||
if (entity.Id == Guid.Empty)
|
||||
{
|
||||
entity.Id = Guid.NewGuid();
|
||||
saved++;
|
||||
}
|
||||
}
|
||||
foreach (var table in _tables.Values)
|
||||
foreach (var entity in table)
|
||||
if (entity.Id == Guid.Empty)
|
||||
{
|
||||
entity.Id = Guid.NewGuid();
|
||||
saved++;
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/// <summary>True if <paramref name="entity"/> is in this context's tracker.</summary>
|
||||
public bool IsTracked(DbBase entity) => _tracked.Contains(entity);
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public IReadOnlyList<DbBase> Tracked => _tracked;
|
||||
/// <summary>
|
||||
/// 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) { }
|
||||
}
|
||||
@@ -11,6 +11,12 @@ public class Order : DbBase
|
||||
/// <summary>Free-text description of what this order is for.</summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Order total in cents (integer — no floating-point money). Part of the
|
||||
/// persisted state, like every other scalar.
|
||||
/// </summary>
|
||||
public int TotalInCents { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The client that owns this order. Set automatically by
|
||||
/// <see cref="ClientOrders"/> when the order is added to
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using After;
|
||||
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||
|
||||
namespace BeforeAfter.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The after <see cref="DbContext"/> stores data-only persistence records
|
||||
/// (<see cref="ClientData"/> / <see cref="OrderData"/>), never domain objects:
|
||||
/// <see cref="DbContext"/>'s <c>Save</c> maps domain state into a row —
|
||||
/// inserting on the first save, refreshing in place on later ones — and the
|
||||
/// stored graph is flattened to ids.
|
||||
/// </summary>
|
||||
public class AfterPersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Save_maps_domain_state_into_data_records()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
|
||||
db.Save(client);
|
||||
db.Save(order);
|
||||
|
||||
var clientRow = Assert.Single(db.Clients);
|
||||
Assert.Equal(client.Id, clientRow.Id);
|
||||
Assert.Equal("Acme", clientRow.Name);
|
||||
|
||||
var orderRow = Assert.Single(db.Orders);
|
||||
Assert.Equal(order.Id, orderRow.Id);
|
||||
Assert.Equal("one", orderRow.Description);
|
||||
Assert.Equal(150_00, orderRow.TotalInCents);
|
||||
Assert.Equal(client.Id, orderRow.ClientId); // the graph is flattened to a key
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_order_without_client_stores_an_empty_client_id()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("orphan").Build();
|
||||
|
||||
db.Save(order);
|
||||
|
||||
Assert.Equal(Guid.Empty, Assert.Single(db.Orders).ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Saving_again_refreshes_the_row_instead_of_duplicating_it()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
db.Save(client);
|
||||
db.Save(order);
|
||||
|
||||
order.Description = "one (edited)";
|
||||
order.Lines[0].AmountInCents = 99_00;
|
||||
client.Name = "Acme GmbH";
|
||||
db.Save(order);
|
||||
db.Save(client);
|
||||
|
||||
// One row per saved object, refreshed with the *current* state:
|
||||
var orderRow = Assert.Single(db.Orders);
|
||||
Assert.Equal(order.Id, orderRow.Id);
|
||||
Assert.Equal("one (edited)", orderRow.Description);
|
||||
Assert.Equal(99_00, orderRow.TotalInCents);
|
||||
Assert.Equal(client.Id, orderRow.ClientId);
|
||||
|
||||
var clientRow = Assert.Single(db.Clients);
|
||||
Assert.Equal("Acme GmbH", clientRow.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rows_are_reachable_through_Set_and_Find()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
db.Save(client);
|
||||
db.Save(order);
|
||||
|
||||
Assert.Single(db.Set<ClientData>());
|
||||
Assert.Single(db.Set<OrderData>());
|
||||
|
||||
var found = db.Find<OrderData>(row => row.Description == "one");
|
||||
Assert.Equal(order.Id, found!.Id);
|
||||
Assert.Null(db.Find<OrderData>(row => row.Description == "no such order"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Read-back: LoadOrder / LoadClient reassemble domain objects from the
|
||||
// stored rows (identity round-trip for now — OrderLines come later).
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Loading_an_order_restores_its_state_and_its_client()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
db.Save(client);
|
||||
db.Save(order);
|
||||
|
||||
var loaded = db.LoadOrder(order.Id);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(order.Id, loaded!.Id);
|
||||
Assert.Equal("one", loaded.Description);
|
||||
Assert.Equal(150_00, loaded.TotalInCents);
|
||||
|
||||
// The graph is reassembled: the loaded order knows its client again,
|
||||
// and the client's collection holds the loaded order.
|
||||
Assert.NotNull(loaded.Client);
|
||||
Assert.Equal("Acme", loaded.Client!.Name);
|
||||
Assert.Same(loaded, Assert.Single(loaded.Client.Orders));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loading_a_client_restores_it_with_all_of_its_orders()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var first = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||
var second = ValidOrder().WithDescription("two").WithTotal(250_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(first).WithOrder(second).Build();
|
||||
db.Save(client);
|
||||
db.Save(first);
|
||||
db.Save(second);
|
||||
|
||||
var loaded = db.LoadClient(client.Id);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Acme", loaded!.Name);
|
||||
Assert.Equal(2, loaded.Orders.Count);
|
||||
Assert.All(loaded.Orders, o => Assert.Same(loaded, o.Client));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Loading_an_unknown_id_returns_null()
|
||||
{
|
||||
var db = new DbContext();
|
||||
|
||||
Assert.Null(db.LoadOrder(Guid.NewGuid()));
|
||||
Assert.Null(db.LoadClient(Guid.NewGuid()));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using After;
|
||||
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||
using System.Reflection;
|
||||
|
||||
namespace BeforeAfter.Tests;
|
||||
@@ -7,6 +9,11 @@ namespace BeforeAfter.Tests;
|
||||
/// Tests for the AFTER situation (Yak 02): plain domain objects (no
|
||||
/// <see cref="DbBase"/>), DTOs (records) carrying the data including the
|
||||
/// relations, hand-written mappers, and a WebApp that consumes only DTOs.
|
||||
///
|
||||
/// Domain objects in these tests are constructed through the TestDataBuilders
|
||||
/// (<see cref="OrderBuilder.ValidOrder"/> / <see cref="ClientBuilder.ValidClient"/>)
|
||||
/// so the tests survive domain evolution; DTO initializers stay literal —
|
||||
/// DTOs are the transport shapes under test.
|
||||
/// </summary>
|
||||
public class AfterTests
|
||||
{
|
||||
@@ -42,7 +49,7 @@ public class AfterTests
|
||||
public void ToClientDto_maps_all_scalar_fields()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var client = new Client { Id = id, Name = "Acme" };
|
||||
var client = ValidClient().WithId(id).WithName("Acme").Build();
|
||||
|
||||
var dto = Mappers.ToClientDto(client);
|
||||
|
||||
@@ -54,12 +61,13 @@ public class AfterTests
|
||||
public void ToOrderDto_maps_all_scalar_fields()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var order = new Order { Id = id, Description = "one" };
|
||||
var order = ValidOrder().WithId(id).WithDescription("one").WithTotal(150_00).Build();
|
||||
|
||||
var dto = Mappers.ToOrderDto(order);
|
||||
|
||||
Assert.Equal(id, dto.Id);
|
||||
Assert.Equal("one", dto.Description);
|
||||
Assert.Equal(150_00, dto.TotalInCents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -78,12 +86,13 @@ public class AfterTests
|
||||
public void FromOrderDto_maps_all_scalar_fields()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var dto = new OrderDto { Id = id, Description = "one" };
|
||||
var dto = new OrderDto { Id = id, Description = "one", TotalInCents = 150_00 };
|
||||
|
||||
var order = Mappers.FromOrderDto(dto);
|
||||
|
||||
Assert.Equal(id, order.Id);
|
||||
Assert.Equal("one", order.Description);
|
||||
Assert.Equal(150_00, order.TotalInCents);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -93,9 +102,8 @@ public class AfterTests
|
||||
[Fact]
|
||||
public void ToClientDto_maps_orders_and_keeps_the_back_reference_consistent()
|
||||
{
|
||||
var client = new Client { Name = "Acme" };
|
||||
var order = new Order { Description = "one" };
|
||||
client.Orders.Add(order);
|
||||
var order = ValidOrder().WithDescription("one").Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
|
||||
var dto = Mappers.ToClientDto(client);
|
||||
var orderDto = Assert.Single(dto.Orders);
|
||||
@@ -128,9 +136,8 @@ public class AfterTests
|
||||
[Fact]
|
||||
public void Domain_round_trip_preserves_all_state()
|
||||
{
|
||||
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
|
||||
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
|
||||
client.Orders.Add(order);
|
||||
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||
|
||||
var roundTrip = Mappers.FromClientDto(Mappers.ToClientDto(client));
|
||||
|
||||
@@ -139,6 +146,7 @@ public class AfterTests
|
||||
var roundTripOrder = Assert.Single(roundTrip.Orders);
|
||||
Assert.Equal(order.Id, roundTripOrder.Id);
|
||||
Assert.Equal(order.Description, roundTripOrder.Description);
|
||||
Assert.Equal(order.TotalInCents, roundTripOrder.TotalInCents);
|
||||
// The back-reference is still consistent on the reconstructed graph:
|
||||
Assert.Same(roundTrip, roundTripOrder.Client);
|
||||
}
|
||||
@@ -146,9 +154,8 @@ public class AfterTests
|
||||
[Fact]
|
||||
public void Dto_round_trip_preserves_all_state()
|
||||
{
|
||||
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
|
||||
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
|
||||
client.Orders.Add(order);
|
||||
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||
var dto = Mappers.ToClientDto(client);
|
||||
var dtoOrder = dto.Orders[0];
|
||||
|
||||
@@ -159,6 +166,7 @@ public class AfterTests
|
||||
var roundTripOrder = Assert.Single(roundTrip.Orders);
|
||||
Assert.Equal(dtoOrder.Id, roundTripOrder.Id);
|
||||
Assert.Equal(dtoOrder.Description, roundTripOrder.Description);
|
||||
Assert.Equal(dtoOrder.TotalInCents, roundTripOrder.TotalInCents);
|
||||
Assert.Same(roundTrip, roundTripOrder.ClientDto);
|
||||
}
|
||||
|
||||
@@ -168,9 +176,8 @@ public class AfterTests
|
||||
[Fact]
|
||||
public void ToClientDto_does_not_mutate_the_domain()
|
||||
{
|
||||
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
|
||||
var order = new Order { Id = Guid.NewGuid(), Description = "one" };
|
||||
client.Orders.Add(order);
|
||||
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("one").Build();
|
||||
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||
var ordersCount = client.Orders.Count;
|
||||
|
||||
Mappers.ToClientDto(client);
|
||||
@@ -243,9 +250,8 @@ public class AfterTests
|
||||
public void After_WebApp_consumes_dtos()
|
||||
{
|
||||
var app = new WebApp();
|
||||
var client = new Client { Id = Guid.NewGuid(), Name = "Acme" };
|
||||
var order = new Order { Id = Guid.NewGuid(), Description = "order one" };
|
||||
client.Orders.Add(order);
|
||||
var order = ValidOrder().WithId(Guid.NewGuid()).WithDescription("order one").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithId(Guid.NewGuid()).WithName("Acme").WithOrder(order).Build();
|
||||
|
||||
var view = app.ShowClient(Mappers.ToClientDto(client));
|
||||
|
||||
@@ -262,9 +268,8 @@ public class AfterTests
|
||||
public void DbContext_saves_plain_domain_objects_and_tracks_them()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var client = new Client { Name = "Acme" };
|
||||
var order = new Order { Description = "one" };
|
||||
client.Orders.Add(order);
|
||||
var order = ValidOrder().WithDescription("one").Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
|
||||
Assert.Equal(Guid.Empty, client.Id);
|
||||
db.Save(client);
|
||||
@@ -282,7 +287,7 @@ public class AfterTests
|
||||
public void Saving_twice_does_not_reassign_the_id()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var client = new Client { Name = "Acme" };
|
||||
var client = ValidClient().WithName("Acme").Build();
|
||||
db.Save(client);
|
||||
var id = client.Id;
|
||||
|
||||
@@ -296,7 +301,7 @@ public class AfterTests
|
||||
public void A_saved_domain_object_keeps_no_reference_to_the_context()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var client = new Client { Name = "Acme" };
|
||||
var client = ValidClient().WithName("Acme").Build();
|
||||
db.Save(client);
|
||||
|
||||
// Saved (the id was assigned by the context) ...
|
||||
@@ -309,4 +314,4 @@ public class AfterTests
|
||||
var dto = Mappers.ToClientDto(client);
|
||||
Assert.Equal(client.Id, dto.Id); // the id flows on into the DTO
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,8 +131,7 @@ public class BeforeTests
|
||||
Assert.True(app.IsPersisted(client));
|
||||
}
|
||||
|
||||
// A companion check: a client that was never attached/saved exposes no
|
||||
// context yet, so the leak is dormant until the entity meets a context.
|
||||
// A companion check: a bare client has no context until saved.
|
||||
[Fact]
|
||||
public void A_bare_client_has_no_context_until_saved()
|
||||
{
|
||||
@@ -142,4 +141,326 @@ public class BeforeTests
|
||||
Assert.Null(client.DbContext);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using After;
|
||||
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||
|
||||
namespace BeforeAfter.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="DiscountCalculator.CalculateDiscount"/>:
|
||||
/// barrier semantics (strictly greater), integer-percent units, half-up
|
||||
/// rounding of the discount amount, input validation, and the guarantee that
|
||||
/// the calculation never mutates the order.
|
||||
/// </summary>
|
||||
public class DiscountCalculatorTests
|
||||
{
|
||||
// ---------------------------------------------------------------------
|
||||
// The barrier: only a total strictly greater than it is discounted.
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void A_total_below_the_barrier_gets_no_discount()
|
||||
{
|
||||
var order = ValidOrder().WithTotal(50_00).Build();
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
Assert.Equal(0, discounted.DiscountPercentage);
|
||||
Assert.Equal(50_00, discounted.DiscountedTotalInCents);
|
||||
Assert.Equal(50_00, discounted.TotalInCents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_total_exactly_at_the_barrier_gets_no_discount()
|
||||
{
|
||||
var order = ValidOrder().WithTotal(100_00).Build();
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
Assert.Equal(0, discounted.DiscountPercentage);
|
||||
Assert.Equal(100_00, discounted.DiscountedTotalInCents);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Above the barrier: percentage applied, total reduced.
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void A_total_above_the_barrier_gets_the_percentage_discount()
|
||||
{
|
||||
var order = ValidOrder().WithTotal(150_00).Build();
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
Assert.Equal(10, discounted.DiscountPercentage);
|
||||
Assert.Equal(135_00, discounted.DiscountedTotalInCents); // 10 % of 150 = 15 off
|
||||
Assert.Equal(150_00, discounted.TotalInCents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_zero_percentage_discounts_nothing_even_above_the_barrier()
|
||||
{
|
||||
var order = ValidOrder().WithTotal(150_00).Build();
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 0);
|
||||
|
||||
Assert.Equal(0, discounted.DiscountPercentage);
|
||||
Assert.Equal(150_00, discounted.DiscountedTotalInCents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_hundred_percent_discount_leaves_nothing()
|
||||
{
|
||||
var order = ValidOrder().WithTotal(150_00).Build();
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 100);
|
||||
|
||||
Assert.Equal(100, discounted.DiscountPercentage);
|
||||
Assert.Equal(0, discounted.DiscountedTotalInCents);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Half-up rounding of the discount amount (integer math, no float).
|
||||
// ---------------------------------------------------------------------
|
||||
[Theory]
|
||||
[InlineData(155, 16, 139)] // 15.5 rounds UP to 16
|
||||
[InlineData(145, 15, 130)] // 14.5 rounds UP to 15
|
||||
[InlineData(154, 15, 139)] // 15.4 rounds down to 15
|
||||
[InlineData(151, 15, 136)] // 15.1 rounds down to 15
|
||||
public void The_discount_amount_is_rounded_half_up(int totalInCents, int expectedDiscountCents, int expectedDiscountedTotal)
|
||||
{
|
||||
var order = ValidOrder().WithTotal(totalInCents).Build();
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100, discountPercentage: 10);
|
||||
|
||||
Assert.Equal(totalInCents - expectedDiscountCents, discounted.DiscountedTotalInCents);
|
||||
Assert.Equal(expectedDiscountedTotal, discounted.DiscountedTotalInCents);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Correlation and purity.
|
||||
// ---------------------------------------------------------------------
|
||||
[Fact]
|
||||
public void The_result_is_correlated_to_the_order_by_id()
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var order = ValidOrder().WithId(id).WithTotal(150_00).Build();
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
Assert.Equal(id, discounted.OrderId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculating_does_not_mutate_the_order()
|
||||
{
|
||||
var order = ValidOrder().WithDescription("one").WithTotal(150_00).Build();
|
||||
|
||||
DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
Assert.Equal("one", order.Description);
|
||||
Assert.Equal(150_00, order.TotalInCents);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Input validation: invalid arguments throw (loudly, not silently).
|
||||
// ---------------------------------------------------------------------
|
||||
[Theory]
|
||||
[InlineData(-1, 100_00, 10)] // negative total
|
||||
[InlineData(150_00, -1, 10)] // negative barrier
|
||||
[InlineData(150_00, 100_00, -1)] // percentage below 0
|
||||
[InlineData(150_00, 100_00, 101)]// percentage above 100
|
||||
public void Invalid_arguments_throw(int totalInCents, int discountBarrierCents, int discountPercentage)
|
||||
{
|
||||
var order = ValidOrder().WithTotal(totalInCents).Build();
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => DiscountCalculator.CalculateDiscount(order, discountBarrierCents, discountPercentage));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_null_order_throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(
|
||||
() => DiscountCalculator.CalculateDiscount(null!, discountBarrierCents: 100_00, discountPercentage: 10));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using After;
|
||||
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||
|
||||
namespace BeforeAfter.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// EXERCISE — the mapping from a <see cref="DiscountedOrder"/> onto an
|
||||
/// <see cref="Order"/> is NOT one-on-one. (This is the solution participants
|
||||
/// arrive at from the stub in EXERCISE.md.)
|
||||
/// </summary>
|
||||
public class DiscountedOrderMappingTests
|
||||
{
|
||||
[Fact] // EXERCISE STEP 2
|
||||
public void A_discounted_order_can_be_correlated_onto_its_order()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
db.Save(client);
|
||||
db.Save(order);
|
||||
|
||||
// 10 % for orders above €100:
|
||||
var discounted = DiscountCalculator.CalculateDiscount(
|
||||
order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
// Correlate: the discounted result belongs to the order carrying this id.
|
||||
Assert.Equal(order.Id, discounted.OrderId);
|
||||
|
||||
// Copy the discounted total onto the order. The discount percentage is
|
||||
// deliberately NOT copied — it is derivable from the two totals.
|
||||
order.DiscountedTotalInCents = discounted.DiscountedTotalInCents;
|
||||
|
||||
Assert.Equal(135_00, order.DiscountedTotalInCents);
|
||||
}
|
||||
|
||||
[Fact] // EXERCISE STEP 3
|
||||
public void Saving_a_discounted_order_keeps_the_stored_total_untouched()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
db.Save(client);
|
||||
db.Save(order);
|
||||
|
||||
var discounted = DiscountCalculator.CalculateDiscount(
|
||||
order, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
Assert.Equal(order.Id, discounted.OrderId);
|
||||
order.DiscountedTotalInCents = discounted.DiscountedTotalInCents;
|
||||
|
||||
// Save the updated order: the context refreshes the stored row in place.
|
||||
db.Save(order);
|
||||
|
||||
// The persisted record keeps the ORIGINAL total: the database knows
|
||||
// nothing about discounts — derived data lives in the domain only.
|
||||
var row = db.Find<OrderData>(candidate => candidate.Id == discounted.OrderId)!;
|
||||
Assert.Equal(150_00, row.TotalInCents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using After;
|
||||
using static BeforeAfter.Tests.TestData.ClientBuilder;
|
||||
using static BeforeAfter.Tests.TestData.OrderBuilder;
|
||||
|
||||
namespace BeforeAfter.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// EXERCISE 2 — the domain gained OrderLines; persistence still stores one
|
||||
/// flat row per order. (This is the solution participants arrive at from the
|
||||
/// stub in EXERCISE2.md.)
|
||||
/// </summary>
|
||||
public class OrderLinesEvolutionTests
|
||||
{
|
||||
[Fact] // EXERCISE STEP 3a
|
||||
public void A_reloaded_order_can_be_discounted()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("big order").WithTotal(150_00).Build();
|
||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
||||
db.Save(client);
|
||||
db.Save(order);
|
||||
|
||||
var loaded = db.LoadOrder(order.Id)!;
|
||||
|
||||
// The discount works off the computed total of the RELOADED order:
|
||||
var discounted = DiscountCalculator.CalculateDiscount(
|
||||
loaded, discountBarrierCents: 100_00, discountPercentage: 10);
|
||||
|
||||
Assert.Equal(135_00, discounted.DiscountedTotalInCents);
|
||||
Assert.Equal(10, discounted.DiscountPercentage);
|
||||
}
|
||||
|
||||
[Fact] // EXERCISE STEP 3b — the accepted loss
|
||||
public void A_multi_line_order_flattens_to_a_single_line_when_stored()
|
||||
{
|
||||
var db = new DbContext();
|
||||
var order = ValidOrder().WithDescription("mixed order").Build();
|
||||
order.Lines.Add(new OrderLine("first item", 100_00));
|
||||
order.Lines.Add(new OrderLine("second item", 50_00));
|
||||
|
||||
db.Save(order);
|
||||
|
||||
var loaded = db.LoadOrder(order.Id)!;
|
||||
|
||||
// Storage flattened the order: one line, the flat total, and the
|
||||
// order-level description. Documented, not hidden.
|
||||
var line = Assert.Single(loaded.Lines);
|
||||
Assert.Equal(150_00, line.AmountInCents);
|
||||
Assert.Equal("mixed order", line.Description);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using After;
|
||||
|
||||
namespace BeforeAfter.Tests.TestData;
|
||||
|
||||
/// <summary>
|
||||
/// Test data builder for a <see cref="Client"/> with its orders
|
||||
/// (TestDataBuilder pattern). <see cref="ValidClient"/> starts from a
|
||||
/// <em>fresh</em> client, exactly what <c>new Client()</c> gives.
|
||||
/// </summary>
|
||||
public sealed class ClientBuilder
|
||||
{
|
||||
private readonly Client _client = new();
|
||||
|
||||
/// <summary>A valid, fresh client — the neutral starting point for tests.</summary>
|
||||
public static ClientBuilder ValidClient() => new();
|
||||
|
||||
public ClientBuilder WithId(Guid id)
|
||||
{
|
||||
_client.Id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
public ClientBuilder WithName(string name)
|
||||
{
|
||||
_client.Name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Adds an order to the client (navigation fix-up wires the back-reference).</summary>
|
||||
public ClientBuilder WithOrder(Order order)
|
||||
{
|
||||
_client.Orders.Add(order);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Builds the given builder and adds its order — chaining sugar for <c>.WithOrder(builder.Build())</c>.</summary>
|
||||
public ClientBuilder WithOrder(OrderBuilder order) => WithOrder(order.Build());
|
||||
|
||||
public Client Build() => _client;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using After;
|
||||
|
||||
namespace BeforeAfter.Tests.TestData;
|
||||
|
||||
/// <summary>
|
||||
/// Test data builder for an <see cref="Order"/> (TestDataBuilder pattern).
|
||||
/// <see cref="ValidOrder"/> starts from a <em>fresh</em> order —
|
||||
/// <see cref="Guid.Empty"/> id, empty description, zero total, exactly what
|
||||
/// <c>new Order()</c> gives — and every <c>With...</c> returns the builder for
|
||||
/// chaining; <see cref="Build"/> hands back the order.
|
||||
///
|
||||
/// This class is the only place tests touch an order's *construction*: when
|
||||
/// the domain evolves (e.g. OrderLines making the total computed), only these
|
||||
/// internals change and the tests keep reading the same.
|
||||
/// </summary>
|
||||
public sealed class OrderBuilder
|
||||
{
|
||||
private readonly Order _order = new();
|
||||
private int? _totalInCents;
|
||||
|
||||
/// <summary>A valid, fresh order — the neutral starting point for tests.</summary>
|
||||
public static OrderBuilder ValidOrder() => new();
|
||||
|
||||
public OrderBuilder WithId(Guid id)
|
||||
{
|
||||
_order.Id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
public OrderBuilder WithDescription(string description)
|
||||
{
|
||||
_order.Description = description;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the total in cents (<c>150_00</c> = €150.00). Since OrderLines
|
||||
/// arrived, the total is computed — the requested total is realised in
|
||||
/// <see cref="Build"/> as a single line carrying the order's description,
|
||||
/// so <c>WithDescription</c>/<c>WithTotal</c> work in any order. This API
|
||||
/// stayed identical across that domain evolution; only these internals
|
||||
/// changed.
|
||||
/// </summary>
|
||||
public OrderBuilder WithTotal(int totalInCents)
|
||||
{
|
||||
_totalInCents = totalInCents;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Order Build()
|
||||
{
|
||||
if (_totalInCents is int total)
|
||||
_order.Lines.Add(new OrderLine(_order.Description, total));
|
||||
return _order;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user