Compare commits
23
Commits
main
..
08a6fa757a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08a6fa757a | ||
|
|
ec9586c316 | ||
|
|
4b6e6f5a00 | ||
|
|
325925a592 | ||
|
|
68b9657d62 | ||
|
|
58f7a1184a | ||
|
|
06a12e55f8 | ||
|
|
8b46fccf08 | ||
|
|
c4bfc14f32 | ||
|
|
c21c6416cf | ||
|
|
92b9e3f784 | ||
|
|
cdadafe676 | ||
|
|
49a5633a7d | ||
|
|
5d02c1aabf | ||
|
|
7f0981e012 | ||
|
|
b012d0aaa0 | ||
|
|
406e8a97f6 | ||
|
|
bd4afcc935 | ||
|
|
c22a1e2ffa | ||
|
|
463245288a | ||
|
|
7e38f67e0b | ||
|
|
42b3c502a1 | ||
|
|
9d0079cbf0 |
@@ -3,10 +3,6 @@ artifacts/
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
.dotnet-cli/
|
.dotnet-cli/
|
||||||
.npm-cache/
|
|
||||||
node_modules/
|
|
||||||
*.user
|
*.user
|
||||||
# NuGet restore scratch dir (DOTNET_CLI_HOME fallout)
|
# NuGet restore scratch dir (DOTNET_CLI_HOME fallout)
|
||||||
.local/
|
.local/
|
||||||
Library/
|
|
||||||
.idea
|
|
||||||
|
|||||||
@@ -2,33 +2,20 @@
|
|||||||
|
|
||||||
Exercise repo: illustrating the transition from DB-backed domain classes
|
Exercise repo: illustrating the transition from DB-backed domain classes
|
||||||
(`DbBase` inheritance) to plain domain objects + DTOs + mappers.
|
(`DbBase` inheritance) to plain domain objects + DTOs + mappers.
|
||||||
|
Diagrams: `before-after-dto.mmd`, `after-dto.mmd` (source sketch:
|
||||||
**Working Set (for now): the split projects — `src/Domain`,
|
`before-after-dto-sketch.png`).
|
||||||
`src/Db`, `src/Web`** — focus changes there; leave other
|
|
||||||
projects untouched unless a yak explicitly says otherwise.
|
|
||||||
|
|
||||||
## Rendering diagrams (mermaid → svg)
|
## Rendering diagrams (mermaid → svg)
|
||||||
|
|
||||||
Mermaid diagrams are rendered to SVG with mermaid-cli (`mmdc`), pinned as a
|
Mermaid diagrams can be rendered to SVG with the `mmdc` CLI (mermaid-cli,
|
||||||
devDependency in `package.json`. Install it once per clone:
|
installed via npm): `mmdc -i <input>` writes `<input>.svg` next to the
|
||||||
|
source, i.e. `<name>.mmd` becomes `<name>.mmd.svg`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install # installs @mermaid-js/mermaid-cli into node_modules/
|
mmdc -i before-after-dto.mmd # -> before-after-dto.mmd.svg
|
||||||
|
mmdc -i after-dto.mmd # -> after-dto.mmd.svg
|
||||||
```
|
```
|
||||||
|
|
||||||
Then render with `npx mmdc`, which resolves the local install — the same
|
|
||||||
command on Linux, macOS and **Windows** (PowerShell/cmd):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npx mmdc -p puppeteer.config.json -i <diagram>.mmd # -> <diagram>.mmd.svg
|
|
||||||
```
|
|
||||||
|
|
||||||
`-p puppeteer.config.json` passes the headless-Chrome flags (`--no-sandbox`
|
|
||||||
et al.) — without it mmdc fails to launch the browser in agent/sandbox
|
|
||||||
shells. On desktop shells it is not needed: plain `npx mmdc -i <input>`
|
|
||||||
works. On first run puppeteer downloads its own Chrome. `node_modules/` is
|
|
||||||
gitignored.
|
|
||||||
|
|
||||||
## Running dotnet
|
## Running dotnet
|
||||||
|
|
||||||
dotnet SDK 10 is installed **via mise** (see `mise.toml`). It is NOT on PATH in
|
dotnet SDK 10 is installed **via mise** (see `mise.toml`). It is NOT on PATH in
|
||||||
@@ -39,6 +26,16 @@ agent/sandbox shells, and SDK 10 additionally fails without
|
|||||||
scripts/dotnet.sh build db-subclass-to-dto.sln
|
scripts/dotnet.sh build db-subclass-to-dto.sln
|
||||||
scripts/dotnet.sh test db-subclass-to-dto.sln
|
scripts/dotnet.sh test db-subclass-to-dto.sln
|
||||||
scripts/run-tests.sh # full test suite, from repo root
|
scripts/run-tests.sh # full test suite, from repo root
|
||||||
|
scripts/run-console.sh # run the Before.Console demo (bash; delegates
|
||||||
|
# to dotnet.sh); app args go after `--`
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows/PowerShell counterpart:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scripts\run-console.ps1 # run the Before.Console demo; app args go
|
||||||
|
# directly (the script inserts `--` for
|
||||||
|
# dotnet run); needs no DOTNET_CLI_HOME setup
|
||||||
```
|
```
|
||||||
|
|
||||||
The wrappers cd to the repo root (the test runner must run from the parent
|
The wrappers cd to the repo root (the test runner must run from the parent
|
||||||
@@ -73,9 +70,7 @@ yx done "<yak name>" # set state to done (only after acceptance
|
|||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- Target framework: net10.0 (SDK pinned by mise).
|
- Target framework: net10.0 (SDK pinned by mise).
|
||||||
- Tests: xUnit, mirroring the src split: `tests/Domain.Tests`,
|
- Tests: xUnit, one `tests/BeforeAfter.Tests/` project.
|
||||||
`tests/Db.Tests`, `tests/Web.Tests`, with the shared test-data builders in
|
|
||||||
`tests/TestData`.
|
|
||||||
- No EF Core / SQLite / AutoMapper — the DB layer is a small hand-rolled fake
|
- No EF Core / SQLite / AutoMapper — the DB layer is a small hand-rolled fake
|
||||||
(pure managed code, cross-platform).
|
(pure managed code, cross-platform).
|
||||||
- Commit per yak with a message referencing the yak.
|
- Commit per yak with a message referencing the yak.
|
||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) Living Software LTD (UK)
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
%% After: Client and Order become plain domain objects (no DbBase inheritance).
|
||||||
|
%% Dedicated *Dto classes carry data to/from the WebApp, and a mapper converts
|
||||||
|
%% each way, including the relations. EF dependency isolated to persistence layer.
|
||||||
|
classDiagram
|
||||||
|
class DbContext {
|
||||||
|
+SaveChangesAsync()
|
||||||
|
}
|
||||||
|
class DbBase {
|
||||||
|
+Guid id
|
||||||
|
}
|
||||||
|
class WebApp {
|
||||||
|
<<Web App>>
|
||||||
|
}
|
||||||
|
class Client {
|
||||||
|
+Guid id
|
||||||
|
+string name
|
||||||
|
}
|
||||||
|
class Order {
|
||||||
|
+Guid id
|
||||||
|
}
|
||||||
|
class ClientDto {
|
||||||
|
+Guid id
|
||||||
|
+string name
|
||||||
|
}
|
||||||
|
class OrderDto {
|
||||||
|
+Guid id
|
||||||
|
+ClientDto client
|
||||||
|
}
|
||||||
|
class Mappers {
|
||||||
|
+ClientDto ToClientDto(Client c)
|
||||||
|
+Client FromClientDto(ClientDto dto)
|
||||||
|
+OrderDto ToOrderDto(Order o)
|
||||||
|
+Order FromOrderDto(OrderDto dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
DbContext ..> DbBase : still used by persistence layer
|
||||||
|
WebApp --> ClientDto
|
||||||
|
WebApp --> OrderDto
|
||||||
|
WebApp --> Mappers
|
||||||
|
Mappers <--> Client : maps each way
|
||||||
|
Mappers <--> ClientDto : maps each way
|
||||||
|
Mappers <--> Order : maps each way
|
||||||
|
Mappers <--> OrderDto : maps each way
|
||||||
|
Client "1" --> "*" Order : has many (domain relation)
|
||||||
|
ClientDto "1" --> "*" OrderDto : has many (DTO relation)
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
%% Before: WebApp's domain classes inherit from DbBase (EF),
|
||||||
|
%% which depends on DbContext. Relations live inside the DB-backed classes.
|
||||||
|
classDiagram
|
||||||
|
class DbContext {
|
||||||
|
+SaveChangesAsync()
|
||||||
|
}
|
||||||
|
class DbBase {
|
||||||
|
+Guid id
|
||||||
|
}
|
||||||
|
class WebApp {
|
||||||
|
<<Web App>>
|
||||||
|
}
|
||||||
|
class Client {
|
||||||
|
+Guid id
|
||||||
|
+string name
|
||||||
|
}
|
||||||
|
class Order {
|
||||||
|
+Guid id
|
||||||
|
}
|
||||||
|
|
||||||
|
DbBase --> DbContext : uses (EF)
|
||||||
|
WebApp ..> DbBase : depends on
|
||||||
|
Client --|> DbBase : inherits
|
||||||
|
Order --|> DbBase : inherits
|
||||||
|
WebApp --> Client
|
||||||
|
WebApp --> Order
|
||||||
|
Client "1" --> "*" Order : has many
|
||||||
|
Order "*" --> "1" Client : open / close
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 26 KiB |
+72
-100
@@ -1,25 +1,23 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio Version 17
|
# Visual Studio Version 17
|
||||||
VisualStudioVersion = 17.0.31903.59
|
VisualStudioVersion = 17.0.31903.59
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "src\Domain\Domain.csproj", "{A103FFE5-4F20-4DE1-A086-38012E701F9E}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Before", "src\Before\Before.csproj", "{2FF256B4-B085-449E-95AD-B9E6202DA94A}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Db", "src\Db\Db.csproj", "{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "After", "src\After\After.csproj", "{E65C0410-A863-44B0-88A4-6C74EF929419}"
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web", "src\Web\Web.csproj", "{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}"
|
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestData", "tests\TestData\TestData.csproj", "{A9EA20AD-2B2D-456B-858A-1575C4B99E11}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeforeAfter.Tests", "tests\BeforeAfter.Tests\BeforeAfter.Tests.csproj", "{F5E0C182-CBC3-4440-AF8F-32887F9B589C}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain.Tests", "tests\Domain.Tests\Domain.Tests.csproj", "{824867CB-A83B-4968-A29D-D39FC17BCB85}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Db.Tests", "tests\Db.Tests\Db.Tests.csproj", "{7DA9971D-306A-4997-A585-D956F41C7A08}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExtractMethod", "tools\ExtractMethod\ExtractMethod.csproj", "{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web.Tests", "tests\Web.Tests\Web.Tests.csproj", "{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Before.Console", "src\Before.Console\Before.Console.csproj", "{AC39E693-E83D-4B50-A120-0DE259108AF2}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
@@ -31,101 +29,75 @@ Global
|
|||||||
Release|x86 = Release|x86
|
Release|x86 = Release|x86
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|x64.Build.0 = Debug|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Debug|x86.Build.0 = Debug|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|Any CPU.Build.0 = Release|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|x64.ActiveCfg = Release|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|x64.Build.0 = Release|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|x86.ActiveCfg = Release|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E}.Release|x86.Build.0 = Release|Any CPU
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|x64.Build.0 = Debug|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Debug|x86.Build.0 = Debug|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|Any CPU.Build.0 = Release|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|x64.ActiveCfg = Release|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|x64.Build.0 = Release|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|x86.ActiveCfg = Release|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA}.Release|x86.Build.0 = Release|Any CPU
|
{E65C0410-A863-44B0-88A4-6C74EF929419}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|x64.Build.0 = Debug|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Debug|x86.Build.0 = Debug|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|Any CPU.Build.0 = Release|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|x64.ActiveCfg = Release|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|x64.Build.0 = Release|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|x86.ActiveCfg = Release|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3}.Release|x86.Build.0 = Release|Any CPU
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|x64.Build.0 = Debug|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Debug|x86.Build.0 = Debug|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|Any CPU.Build.0 = Release|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|x64.ActiveCfg = Release|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|x64.Build.0 = Release|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|x86.ActiveCfg = Release|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11}.Release|x86.Build.0 = Release|Any CPU
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|x64.Build.0 = Debug|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Debug|x86.Build.0 = Debug|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|Any CPU.Build.0 = Release|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|x64.ActiveCfg = Release|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|x64.Build.0 = Release|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|x86.ActiveCfg = Release|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85}.Release|x86.Build.0 = Release|Any CPU
|
{AC39E693-E83D-4B50-A120-0DE259108AF2}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|x64.ActiveCfg = Debug|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|x64.Build.0 = Debug|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|x86.ActiveCfg = Debug|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Debug|x86.Build.0 = Debug|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|x64.ActiveCfg = Release|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|x64.Build.0 = Release|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|x86.ActiveCfg = Release|Any CPU
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED}.Release|x86.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{A103FFE5-4F20-4DE1-A086-38012E701F9E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{2FF256B4-B085-449E-95AD-B9E6202DA94A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{3C19CC9F-A2F5-4A7C-81C0-20CCEDD3E1EA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{E65C0410-A863-44B0-88A4-6C74EF929419} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{2336C0CF-86CD-4DEC-B2AF-24D0AFF0D6F3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{F5E0C182-CBC3-4440-AF8F-32887F9B589C} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
{A9EA20AD-2B2D-456B-858A-1575C4B99E11} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{3E8944E9-BB07-424B-B3A6-59FB072BAB3C} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
||||||
{824867CB-A83B-4968-A29D-D39FC17BCB85} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
{AC39E693-E83D-4B50-A120-0DE259108AF2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{7DA9971D-306A-4997-A585-D956F41C7A08} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
|
||||||
{17001C6E-C47D-4DB2-AC4F-93DE654CEAED} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
Generated
-2521
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"devDependencies": {
|
|
||||||
"@mermaid-js/mermaid-cli": "^11.17.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"executablePath": "/home/willem/.cache/puppeteer/chrome/linux-152.0.7977.75/chrome-linux64/chrome",
|
|
||||||
"headless": true,
|
|
||||||
"args": [
|
|
||||||
"--no-sandbox",
|
|
||||||
"--disable-setuid-sandbox",
|
|
||||||
"--disable-gpu",
|
|
||||||
"--disable-gpu-compositing",
|
|
||||||
"--disable-software-rasterizer",
|
|
||||||
"--disable-dev-shm-usage",
|
|
||||||
"--disable-features=VizDisplayCompositor",
|
|
||||||
"--use-gl=swiftshader",
|
|
||||||
"--disable-gpu-driver-bug-workaround"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Render a mermaid diagram to SVG using mmdc with sandbox/GPU workarounds
|
|
||||||
# for Ubuntu 24.04+ (AppArmor restrictions).
|
|
||||||
#
|
|
||||||
# Usage: scripts/mermaid.sh input.mmd [output.svg]
|
|
||||||
# scripts/mermaid.sh input.mmd - # output to stdout
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
||||||
CONFIG="$REPO_ROOT/puppeteer.config.json"
|
|
||||||
|
|
||||||
if [[ $# -lt 1 ]]; then
|
|
||||||
echo "Usage: $0 <input.mmd> [output.svg|-]" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
INPUT="$1"
|
|
||||||
shift
|
|
||||||
|
|
||||||
ARGS=("-i" "$INPUT" "-p" "$CONFIG")
|
|
||||||
|
|
||||||
if [[ $# -gt 0 ]]; then
|
|
||||||
OUTPUT="$1"
|
|
||||||
shift
|
|
||||||
ARGS+=("-o" "$OUTPUT")
|
|
||||||
fi
|
|
||||||
|
|
||||||
ARGS+=("$@")
|
|
||||||
|
|
||||||
exec mmdc "${ARGS[@]}"
|
|
||||||
@@ -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 -- "$@"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace After;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A plain domain object — the "after" shape of the exercise: no
|
||||||
|
/// <see cref="DbBase"/> inheritance, no knowledge of <see cref="DbContext"/>.
|
||||||
|
/// Its data travels to the WebApp as a <see cref="ClientDto"/>; persisting it
|
||||||
|
/// is an explicit act of the context, not an inherited capability.
|
||||||
|
/// </summary>
|
||||||
|
public class Client
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key as a plain value: fresh objects have
|
||||||
|
/// <see cref="Guid.Empty"/> until a <see cref="DbContext"/> explicitly
|
||||||
|
/// saves them (which copies a generated id in). A plain field — it is not
|
||||||
|
/// a persistence concept leaking into the domain.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Display name of the client.</summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Navigation collection of this client's orders, with EF-style
|
||||||
|
/// navigation fix-up (see <see cref="ClientOrders"/>).
|
||||||
|
/// </summary>
|
||||||
|
public ClientOrders Orders { get; }
|
||||||
|
|
||||||
|
public Client()
|
||||||
|
{
|
||||||
|
Orders = new ClientOrders(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An <see cref="Order"/> collection that fakes EF's navigation fix-up: when an
|
||||||
|
/// order is added, its <see cref="Order.Client"/> back-reference is set to the
|
||||||
|
/// owning client, exactly as EF would wire up the two ends of the relation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClientOrders : Collection<Order>
|
||||||
|
{
|
||||||
|
private readonly Client _owner;
|
||||||
|
|
||||||
|
public ClientOrders(Client owner)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both Add(...) and Insert(...) funnel through InsertItem, so overriding
|
||||||
|
// it covers every way an order can be added to the collection.
|
||||||
|
protected override void InsertItem(int index, Order item)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
item.Client = _owner; // navigation fix-up: order now knows its client
|
||||||
|
base.InsertItem(index, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace After;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO carrying a <see cref="Client"/> — including its relation — to the
|
||||||
|
/// WebApp. A record: value equality keeps round-trip comparisons trivial.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ClientDto
|
||||||
|
{
|
||||||
|
/// <summary>Primary key of the underlying client.</summary>
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Display name of the client.</summary>
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The client's orders, each carrying a back-reference to this DTO (the
|
||||||
|
/// relation, mapped both ways). Settable — not just <c>init</c> — so
|
||||||
|
/// <see cref="Mappers"/> can fill it in a second pass once the
|
||||||
|
/// client/order cycle has been resolved by the reference cache.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<OrderDto> Orders { get; set; } = Array.Empty<OrderDto>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace After;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class DbBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key. Fresh (unsaved) entities have <see cref="Guid.Empty"/>;
|
||||||
|
/// <see cref="DbContext"/> assigns a real id when it saves them, faking
|
||||||
|
/// EF's identity generation.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <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.
|
||||||
|
/// </summary>
|
||||||
|
public DbContext? DbContext { get; internal set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
namespace After;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal shadow wrappers — live *inside* the persistence layer so the
|
||||||
|
// domain never sees a DbContext back-reference. Subclassed per-entity-type
|
||||||
|
// solely to make `Set<T>` strongly-typed.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Lightweight shadow holder kept alive inside the context.</summary>
|
||||||
|
public abstract class Shadow
|
||||||
|
{
|
||||||
|
/// <summary>ID assigned when first persisted.</summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Back-reference to the owning <see cref="DbContext"/> (internal only).</summary>
|
||||||
|
public DbContext? DbContext { get; internal set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shadow holder for a persisted <see cref="Client"/>.</summary>
|
||||||
|
public sealed class ClientShadow : Shadow { /* no extra data */ }
|
||||||
|
|
||||||
|
/// <summary>Shadow holder for a persisted <see cref="Order"/>.</summary>
|
||||||
|
public sealed class OrderShadow : Shadow { /* no extra data */ }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The fake <c>DbContext</c>, refactored to use per-type shadow collections
|
||||||
|
/// (mirroring EF Core's <c>Set<T></c>) plus an instance-level
|
||||||
|
/// <c>Find<T></c> query method.
|
||||||
|
/// </summary>
|
||||||
|
public class DbContext
|
||||||
|
{
|
||||||
|
// ── per-type shadow tables ───────────────────────────────────────
|
||||||
|
private readonly List<ClientShadow> _clients = new();
|
||||||
|
private readonly List<OrderShadow> _orders = new();
|
||||||
|
|
||||||
|
// ── fast lookup: domain object reference → its shadow ────────────
|
||||||
|
private readonly Dictionary<object, Shadow> _byRef = new();
|
||||||
|
|
||||||
|
// ── exposed per-type tables (read-only view) ────────────────────
|
||||||
|
public IReadOnlyList<ClientShadow> Clients => _clients.AsReadOnly();
|
||||||
|
public IReadOnlyList<OrderShadow> Orders => _orders.AsReadOnly();
|
||||||
|
|
||||||
|
/// <summary>Generic accessor: the strongly-typed "table" for <typeparamref name="T"/>.</summary>
|
||||||
|
public IReadOnlyList<T> Set<T>() where T : Shadow
|
||||||
|
=> typeof(T) switch
|
||||||
|
{
|
||||||
|
var t when t == typeof(ClientShadow) => (IReadOnlyList<T>)_clients.AsReadOnly(),
|
||||||
|
var t when t == typeof(OrderShadow) => (IReadOnlyList<T>)_orders.AsReadOnly(),
|
||||||
|
_ => throw new NotSupportedException($"No per-type table for {typeof(T)}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── explicit persistence ────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Persist a <see cref="Client">:</summary>
|
||||||
|
public void Save(Client client)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
if (!_byRef.TryAdd(client, new ClientShadow { DbContext = this }))
|
||||||
|
return; // already saved → id unchanged
|
||||||
|
var shadow = (ClientShadow)_byRef[client];
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
shadow.Id = client.Id = id; // write-back identity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Persist an <see cref="Order">.</summary>
|
||||||
|
public void Save(Order order)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(order);
|
||||||
|
if (!_byRef.TryAdd(order, new OrderShadow { DbContext = this }))
|
||||||
|
return; // already saved → id unchanged
|
||||||
|
var shadow = (OrderShadow)_byRef[order];
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
shadow.Id = order.Id = id; // write-back identity
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Persist every tracked entity (mirrors EF's <c>SaveChanges</c>).</summary>
|
||||||
|
public int SaveChanges()
|
||||||
|
{
|
||||||
|
var count = 0;
|
||||||
|
foreach (var shadow in _byRef.Values)
|
||||||
|
{
|
||||||
|
if (shadow.Id != Guid.Empty) continue; // already had an id
|
||||||
|
shadow.Id = Guid.NewGuid();
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── query helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Search the per-type table for a shadow whose predicate matches.</summary>
|
||||||
|
public T? Find<T>(Func<T, bool> predicate) where T : Shadow
|
||||||
|
=> Set<T>().FirstOrDefault(predicate);
|
||||||
|
|
||||||
|
// ── tracking / status ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>True if <paramref name="entity"/> has been saved through this context.</summary>
|
||||||
|
public bool IsTracked(object entity) => _byRef.ContainsKey(entity);
|
||||||
|
|
||||||
|
/// <summary>All shadow entities this context currently manages.</summary>
|
||||||
|
public IReadOnlyList<Shadow> Tracked => _byRef.Values.ToList();
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
using Domain;
|
namespace After;
|
||||||
|
|
||||||
namespace Web;
|
/// <summary>
|
||||||
|
/// Hand-written mappers between the plain domain and the DTOs (no AutoMapper —
|
||||||
/// <summary>Hand-written mappers between domain objects and DTOs; each method maps one direction and never mutates its input. The client/order cycle is resolved with a reference-identity cache.</summary>
|
/// the mapping code is part of the illustration). Each public method maps in
|
||||||
public static class DtoMappers
|
/// exactly one direction and never mutates its input.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class Mappers
|
||||||
{
|
{
|
||||||
/// <summary>Map a <see cref="Client"/> (with its orders) to a <see cref="ClientDto"/>.</summary>
|
/// <summary>Map a <see cref="Client"/> (with its orders) to a <see cref="ClientDto"/>.</summary>
|
||||||
public static ClientDto ToClientDto(Client client)
|
public static ClientDto ToClientDto(Client client)
|
||||||
@@ -33,6 +39,9 @@ public static class DtoMappers
|
|||||||
return UnmapOrder(dto, new Dictionary<object, object>());
|
return UnmapOrder(dto, new Dictionary<object, object>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Domain -> DTO
|
||||||
|
// ------------------------------------------------------------------
|
||||||
private static ClientDto MapClient(Client client, Dictionary<object, object> seen)
|
private static ClientDto MapClient(Client client, Dictionary<object, object> seen)
|
||||||
{
|
{
|
||||||
if (seen.TryGetValue(client, out var cached))
|
if (seen.TryGetValue(client, out var cached))
|
||||||
@@ -55,13 +64,15 @@ public static class DtoMappers
|
|||||||
{
|
{
|
||||||
Id = order.Id,
|
Id = order.Id,
|
||||||
Description = order.Description,
|
Description = order.Description,
|
||||||
TotalInCents = order.TotalInCents, // plain int all the way through
|
|
||||||
ClientDto = order.Client is null ? null : MapClient(order.Client, seen),
|
ClientDto = order.Client is null ? null : MapClient(order.Client, seen),
|
||||||
};
|
};
|
||||||
seen[order] = dto;
|
seen[order] = dto;
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// DTO -> domain
|
||||||
|
// ------------------------------------------------------------------
|
||||||
private static Client UnmapClient(ClientDto dto, Dictionary<object, object> seen)
|
private static Client UnmapClient(ClientDto dto, Dictionary<object, object> seen)
|
||||||
{
|
{
|
||||||
if (seen.TryGetValue(dto, out var cached))
|
if (seen.TryGetValue(dto, out var cached))
|
||||||
@@ -83,7 +94,6 @@ public static class DtoMappers
|
|||||||
{
|
{
|
||||||
Id = dto.Id,
|
Id = dto.Id,
|
||||||
Description = dto.Description,
|
Description = dto.Description,
|
||||||
TotalInCents = dto.TotalInCents, // plain int all the way through
|
|
||||||
Client = dto.ClientDto is null ? null : UnmapClient(dto.ClientDto, seen),
|
Client = dto.ClientDto is null ? null : UnmapClient(dto.ClientDto, seen),
|
||||||
};
|
};
|
||||||
seen[dto] = order;
|
seen[dto] = order;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace After;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An order as a plain domain object: no <see cref="DbBase"/> inheritance, no
|
||||||
|
/// knowledge of <see cref="DbContext"/>. Its data travels to the WebApp as an
|
||||||
|
/// <see cref="OrderDto"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class Order
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Primary key as a plain value: <see cref="Guid.Empty"/> until a
|
||||||
|
/// <see cref="DbContext"/> explicitly saves this order (which copies a
|
||||||
|
/// generated id in).
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Free-text description of what this order is for.</summary>
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The client that owns this order. Set automatically by
|
||||||
|
/// <see cref="ClientOrders"/> when the order is added to
|
||||||
|
/// <see cref="Client.Orders"/> (navigation fix-up).
|
||||||
|
/// </summary>
|
||||||
|
public Client? Client { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace After;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO carrying an <see cref="Order"/> — including its back-reference to the
|
||||||
|
/// owning <see cref="ClientDto"/> — to the WebApp. A record: value equality
|
||||||
|
/// keeps round-trip comparisons trivial.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record OrderDto
|
||||||
|
{
|
||||||
|
/// <summary>Primary key of the underlying order.</summary>
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Free-text description of what this order is for.</summary>
|
||||||
|
public string Description { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Back-reference to the owning client, or <c>null</c> for a bare order
|
||||||
|
/// that has not been added to any client.
|
||||||
|
/// </summary>
|
||||||
|
public ClientDto? ClientDto { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace After;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The consuming application, after situation: it consumes only DTOs
|
||||||
|
/// (<see cref="ClientDto"/>/<see cref="OrderDto"/>). Its public API references
|
||||||
|
/// no domain type (Client/Order) and no persistence type
|
||||||
|
/// (DbContext/DbBase), so the WebApp can never reach the persistence layer
|
||||||
|
/// through them — the leak the before situation exposed is gone.
|
||||||
|
/// </summary>
|
||||||
|
public class WebApp
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Render a client together with all of its orders, consuming the
|
||||||
|
/// <see cref="ClientDto"/> (and its nested <see cref="OrderDto"/>)s only.
|
||||||
|
/// </summary>
|
||||||
|
public string ShowClient(ClientDto client)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
|
||||||
|
var lines = new List<string> { $"{client.Name} [{client.Id}]" };
|
||||||
|
foreach (var order in client.Orders)
|
||||||
|
lines.Add($" - order [{order.Id}]: {order.Description}");
|
||||||
|
return string.Join(Environment.NewLine, lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Domain\Domain.csproj" />
|
<ProjectReference Include="..\Before\Before.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using Before;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Before.Console — a straight-line demo of the ActiveRecord-style pattern.
|
||||||
|
//
|
||||||
|
// The console app sets DbBase.Context (the shared singleton) once at startup,
|
||||||
|
// then creates client/order entities, saves them through the context, and
|
||||||
|
// queries back using the ActiveRecord entry points:
|
||||||
|
// • Client.FindByName(name) — no context argument: rides the
|
||||||
|
// DbBase.Context singleton
|
||||||
|
// • listing via DbBase.Context!.Tracked
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var ctx = new DbContext();
|
||||||
|
DbBase.Context = ctx;
|
||||||
|
|
||||||
|
// ----- seed data ----------------------------------------------------------
|
||||||
|
var jane = new Client { Name = "Jane Doe" };
|
||||||
|
var bob = new Client { Name = "Bob Smith" };
|
||||||
|
|
||||||
|
jane.Orders.Add(new Order { Description = "Design consultation" });
|
||||||
|
jane.Orders.Add(new Order { Description = "Website redesign" });
|
||||||
|
|
||||||
|
bob.Orders.Add(new Order { Description = "Monthly retainer" });
|
||||||
|
|
||||||
|
// Orders need to be attached too so they get IDs:
|
||||||
|
ctx.Attach(jane.Orders[0]);
|
||||||
|
ctx.Attach(jane.Orders[1]);
|
||||||
|
ctx.Attach(bob.Orders[0]);
|
||||||
|
|
||||||
|
ctx.Attach(jane);
|
||||||
|
ctx.Attach(bob);
|
||||||
|
ctx.Save();
|
||||||
|
|
||||||
|
// ----- list all clients ---------------------------------------------------
|
||||||
|
Console.WriteLine("=== All Clients ===");
|
||||||
|
foreach (var client in DbBase.Context!.Tracked.OfType<Client>())
|
||||||
|
{
|
||||||
|
Console.WriteLine($"{client.Name} [{client.Id}]");
|
||||||
|
foreach (var order in client.Orders)
|
||||||
|
Console.WriteLine($" - order [{order.Id}]: {order.Description}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- find by name -------------------------------------------------------
|
||||||
|
Console.WriteLine("\n=== Find by Name (null-returning) ===");
|
||||||
|
// No context argument: FindByName falls back to the DbBase.Context singleton.
|
||||||
|
var found = Client.FindByName("Jane Doe");
|
||||||
|
if (found is not null)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Found: {found.Name} [{found.Id}]");
|
||||||
|
Console.WriteLine($" Orders: {found.Orders.Count}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("Not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- find by name (throwing variant) ------------------------------------
|
||||||
|
Console.WriteLine("\n=== Find by Name Required (throws instead of returning null) ===");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var required = Client.FindByNameRequired("Jane Doe");
|
||||||
|
Console.WriteLine($"Found: {required.Name} [{required.Id}]");
|
||||||
|
Console.WriteLine($" Orders: {required.Orders.Count}");
|
||||||
|
}
|
||||||
|
catch (ObjectNotFoundException ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Not found: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the contrast with a non-existent name:
|
||||||
|
Console.WriteLine("\n=== Miss: null-returning vs throwing ===");
|
||||||
|
var missNull = Client.FindByName("Nobody Here");
|
||||||
|
Console.WriteLine($"FindByName(\"Nobody Here\"): {(missNull == null ? "null" : missNull.Name)}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Client.FindByNameRequired("Nobody Here");
|
||||||
|
}
|
||||||
|
catch (ObjectNotFoundException ex)
|
||||||
|
{
|
||||||
|
var msg = ex.Message.ReplaceLineEndings(" ").Trim();
|
||||||
|
Console.WriteLine($"FindByNameRequired(\"Nobody Here\"): throws - {msg}");
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace Before;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A client, backed by the database: it derives from <see cref="DbBase"/> and
|
||||||
|
/// holds its orders as a navigation collection. In the "after" situation this
|
||||||
|
/// becomes a plain POCO (no <see cref="DbBase"/>) with a <c>ClientDto</c>
|
||||||
|
/// carrying its data to the WebApp.
|
||||||
|
/// </summary>
|
||||||
|
public class Client : DbBase
|
||||||
|
{
|
||||||
|
/// <summary>Display name of the client.</summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Navigation collection of this client's orders. Adding to it performs
|
||||||
|
/// EF-style navigation fix-up (see <see cref="ClientOrders"/>).
|
||||||
|
/// </summary>
|
||||||
|
public ClientOrders Orders { get; }
|
||||||
|
|
||||||
|
public Client()
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
/// An <see cref="Order"/> collection that fakes EF's navigation fix-up: when an
|
||||||
|
/// order is added, its <see cref="Order.Client"/> back-reference is set to the
|
||||||
|
/// owning client, exactly as EF would wire up the two ends of the relation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClientOrders : Collection<Order>
|
||||||
|
{
|
||||||
|
private readonly Client _owner;
|
||||||
|
|
||||||
|
public ClientOrders(Client owner)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both Add(...) and Insert(...) funnel through InsertItem, so overriding
|
||||||
|
// it covers every way an order can be added to the collection.
|
||||||
|
protected override void InsertItem(int index, Order item)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
item.Client = _owner; // navigation fix-up: order now knows its client
|
||||||
|
base.InsertItem(index, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
namespace Before;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for every DB-backed domain object. Fakes the active-record part
|
||||||
|
/// of EF: each entity carries its own <see cref="Id"/> and, once it has been
|
||||||
|
/// created/saved through a context, a back-reference to that context.
|
||||||
|
///
|
||||||
|
/// <see cref="DbContext"/> is the dependency this base class leaks. The
|
||||||
|
/// "after" situation (Yak 02) removes this inheritance entirely — domain
|
||||||
|
/// objects become plain POCOs that know nothing about a DbContext.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
/// generation.
|
||||||
|
/// </summary>
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Active-record back-reference: "I know which context created me". This
|
||||||
|
/// is the leak the exercise exposes — from a plain domain object you can
|
||||||
|
/// reach straight into the persistence layer. <c>internal set</c> because
|
||||||
|
/// only the owning <see cref="DbContext"/> may (re)assign it; consumers
|
||||||
|
/// (including the WebApp, and the tests) can only read it.
|
||||||
|
/// </summary>
|
||||||
|
public DbContext? DbContext { get; internal set; }
|
||||||
|
|
||||||
|
/// <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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace Before;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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*
|
||||||
|
/// (domain object -> DbContext), not EF's behaviour.
|
||||||
|
/// </summary>
|
||||||
|
public class DbContext
|
||||||
|
{
|
||||||
|
// Per-type tables: each entity type has its own typed collection,
|
||||||
|
// mimicking EF Core's <see cref="DbSet{T}"/> model where the DbContext
|
||||||
|
// maintains a separate set per entity type.
|
||||||
|
private readonly Dictionary<Type, Collection<DbBase>> _tables = new();
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
var table = TableFor(entity.GetType());
|
||||||
|
if (!table.Contains(entity))
|
||||||
|
table.Add(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public int Save()
|
||||||
|
{
|
||||||
|
var saved = 0;
|
||||||
|
foreach (var table in _tables.Values)
|
||||||
|
foreach (var entity in table)
|
||||||
|
if (entity.Id == Guid.Empty)
|
||||||
|
{
|
||||||
|
entity.Id = Guid.NewGuid();
|
||||||
|
saved++;
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
/// 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) { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Before;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An order, backed by the database: it derives from <see cref="DbBase"/> and
|
||||||
|
/// holds a back-reference to its <see cref="Client"/> (the other end of the
|
||||||
|
/// relation). In the "after" situation this becomes a plain POCO with an
|
||||||
|
/// <c>OrderDto</c> carrying its data to the WebApp.
|
||||||
|
/// </summary>
|
||||||
|
public class Order : DbBase
|
||||||
|
{
|
||||||
|
/// <summary>Free-text description of what this order is for.</summary>
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The client that owns this order. Set automatically by
|
||||||
|
/// <see cref="ClientOrders"/> when the order is added to
|
||||||
|
/// <see cref="Client.Orders"/> (navigation fix-up).
|
||||||
|
/// </summary>
|
||||||
|
public Client? Client { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
namespace Before;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The consuming application. In the "before" situation it talks to the
|
||||||
|
/// DB-backed domain model directly: its methods take <see cref="Client"/> and
|
||||||
|
/// <see cref="Order"/> (which inherit <see cref="DbBase"/>) as parameters.
|
||||||
|
///
|
||||||
|
/// One of its methods reaches the <see cref="DbContext"/> *through* a domain
|
||||||
|
/// object — the leak. In the "after" situation (Yak 02) the WebApp consumes
|
||||||
|
/// DTOs instead, and the domain objects no longer expose a DbContext.
|
||||||
|
/// </summary>
|
||||||
|
public class WebApp
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Render a client together with all of its orders, consuming the
|
||||||
|
/// <see cref="Client"/>/ <see cref="Order"/> domain objects directly.
|
||||||
|
/// </summary>
|
||||||
|
public string ShowClient(Client client)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
|
||||||
|
var lines = new List<string> { $"{client.Name} [{client.Id}]" };
|
||||||
|
foreach (var order in client.Orders)
|
||||||
|
lines.Add($" - order [{order.Id}]: {order.Description}");
|
||||||
|
return string.Join(Environment.NewLine, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The leak, made concrete: from a plain domain object the WebApp can reach
|
||||||
|
/// the <see cref="DbContext"/> (<see cref="DbBase.DbContext"/>) and thus
|
||||||
|
/// touch the persistence layer — here just to ask whether the client has
|
||||||
|
/// been saved. The "after" situation removes <c>client.DbContext</c>
|
||||||
|
/// entirely, so no DTO-consumer can do this.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsPersisted(Client client)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(client);
|
||||||
|
return client.DbContext is not null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace Db;
|
|
||||||
|
|
||||||
/// <summary>Persisted shape of a <see cref="Client"/>: data only.</summary>
|
|
||||||
public sealed class ClientData : DbBase
|
|
||||||
{
|
|
||||||
/// <summary>Display name of the client.</summary>
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace Db;
|
|
||||||
|
|
||||||
/// <summary>Hand-written mappers between domain objects and persistence records; each method maps one direction and never mutates its input.</summary>
|
|
||||||
public static class DataMappers
|
|
||||||
{
|
|
||||||
/// <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 client back-reference becomes a foreign key.</summary>
|
|
||||||
public static OrderData ToOrderData(Order order)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(order);
|
|
||||||
return new OrderData
|
|
||||||
{
|
|
||||||
Id = order.Id,
|
|
||||||
Description = order.Description,
|
|
||||||
TotalInCents = order.TotalInCents, // plain int all the way through
|
|
||||||
ClientId = order.Client?.Id ?? Guid.Empty,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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"/>; the context reassembles the graph.</summary>
|
|
||||||
public static Order FromOrderData(OrderData row)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(row);
|
|
||||||
return new Order
|
|
||||||
{
|
|
||||||
Id = row.Id,
|
|
||||||
Description = row.Description,
|
|
||||||
TotalInCents = row.TotalInCents, // plain int all the way through
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace Db;
|
|
||||||
|
|
||||||
/// <summary>Persistence framework base class: only the data records derive from it, never the domain objects.</summary>
|
|
||||||
public class DbBase
|
|
||||||
{
|
|
||||||
/// <summary>Primary key; <see cref="Guid.Empty"/> until the context assigns one on save.</summary>
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Back-reference to the owning <see cref="DbContext"/>; lives on persistence records only.</summary>
|
|
||||||
public DbContext? DbContext { get; internal set; }
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace Db;
|
|
||||||
|
|
||||||
/// <summary>The fake DbContext: per-type tables of data-only records (never domain objects); Save inserts-or-refreshes rows, Load reassembles domain objects.</summary>
|
|
||||||
public class DbContext
|
|
||||||
{
|
|
||||||
private readonly List<ClientData> _clients = new();
|
|
||||||
private readonly List<OrderData> _orders = new();
|
|
||||||
|
|
||||||
// domain object reference → its persisted record
|
|
||||||
private readonly Dictionary<object, DbBase> _byRef = new();
|
|
||||||
|
|
||||||
public IReadOnlyList<ClientData> Clients => _clients.AsReadOnly();
|
|
||||||
public IReadOnlyList<OrderData> Orders => _orders.AsReadOnly();
|
|
||||||
|
|
||||||
/// <summary>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)}"),
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>Persist a <see cref="Client"/>: insert on first save (assigning its id), refresh in place on later ones.</summary>
|
|
||||||
public void Save(Client client)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(client);
|
|
||||||
var fresh = DataMappers.ToClientData(client);
|
|
||||||
if (_byRef.TryGetValue(client, out var existing))
|
|
||||||
{
|
|
||||||
var row = (ClientData)existing; // refresh, keep row identity
|
|
||||||
row.Name = fresh.Name;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var id = Guid.NewGuid();
|
|
||||||
fresh.Id = client.Id = id; // write-back identity
|
|
||||||
fresh.DbContext = this;
|
|
||||||
_clients.Add(fresh);
|
|
||||||
_byRef[client] = fresh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Persist an <see cref="Order"/> (same insert-or-refresh rule; save its client first so the foreign key can be filled).</summary>
|
|
||||||
public void Save(Order order)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(order);
|
|
||||||
var fresh = DataMappers.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>Load the <see cref="Client"/> stored under <paramref name="id"/> with all of its orders, or null.</summary>
|
|
||||||
public Client? LoadClient(Guid id)
|
|
||||||
{
|
|
||||||
var row = Find<ClientData>(r => r.Id == id);
|
|
||||||
if (row is null) return null;
|
|
||||||
var client = DataMappers.FromClientData(row);
|
|
||||||
foreach (var orderRow in _orders.Where(o => o.ClientId == id))
|
|
||||||
client.Orders.Add(DataMappers.FromOrderData(orderRow)); // fix-up wires Order.Client
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Load the <see cref="Order"/> stored under <paramref name="id"/> with its client, or null; use <see cref="LoadClient"/> for a client with all its orders.</summary>
|
|
||||||
public Order? LoadOrder(Guid id)
|
|
||||||
{
|
|
||||||
var row = Find<OrderData>(r => r.Id == id);
|
|
||||||
if (row is null) return null;
|
|
||||||
var order = DataMappers.FromOrderData(row);
|
|
||||||
if (row.ClientId != Guid.Empty)
|
|
||||||
{
|
|
||||||
var clientRow = Find<ClientData>(c => c.Id == row.ClientId);
|
|
||||||
if (clientRow is not null)
|
|
||||||
{
|
|
||||||
var client = DataMappers.FromClientData(clientRow);
|
|
||||||
client.Orders.Add(order); // fix-up wires Order.Client
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return order;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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);
|
|
||||||
|
|
||||||
/// <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();
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace Db;
|
|
||||||
|
|
||||||
/// <summary>Persisted shape of an <see cref="Order"/>: data only, object graph flattened to ids.</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; empty when the order has no client.</summary>
|
|
||||||
public Guid ClientId { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
|
|
||||||
namespace Domain;
|
|
||||||
|
|
||||||
/// <summary>A plain domain object: no persistence base class, no persistence context knowledge.</summary>
|
|
||||||
public class Client
|
|
||||||
{
|
|
||||||
/// <summary>Identity: <see cref="Guid.Empty"/> until the persistence layer assigns one on save.</summary>
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Display name of the client.</summary>
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>The client's orders; adding one wires its <see cref="Order.Client"/> back-reference.</summary>
|
|
||||||
public ClientOrders Orders { get; }
|
|
||||||
|
|
||||||
public Client()
|
|
||||||
{
|
|
||||||
Orders = new ClientOrders(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>An <see cref="Order"/> collection that fakes EF's navigation fix-up.</summary>
|
|
||||||
public sealed class ClientOrders : Collection<Order>
|
|
||||||
{
|
|
||||||
private readonly Client _owner;
|
|
||||||
|
|
||||||
public ClientOrders(Client owner)
|
|
||||||
{
|
|
||||||
_owner = owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add(...) and Insert(...) both funnel through InsertItem.
|
|
||||||
protected override void InsertItem(int index, Order item)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(item);
|
|
||||||
item.Client = _owner; // navigation fix-up
|
|
||||||
base.InsertItem(index, item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
namespace Domain;
|
|
||||||
|
|
||||||
/// <summary>A plain domain order: no persistence base class, no persistence context knowledge.</summary>
|
|
||||||
public class Order
|
|
||||||
{
|
|
||||||
/// <summary>Identity: <see cref="Guid.Empty"/> until the persistence layer assigns one on save.</summary>
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Free-text description of what this order is for.</summary>
|
|
||||||
public string Description { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>Order total in cents (non-negative by convention); the DTO and storage row carry the same plain int.</summary>
|
|
||||||
public int TotalInCents { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The owning client; wired by navigation fix-up when the order is added to <see cref="Client.Orders"/>.</summary>
|
|
||||||
public Client? Client { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace Web;
|
|
||||||
|
|
||||||
/// <summary>DTO carrying a <see cref="Client"/> — with its orders — to the WebApp; record: value equality keeps round-trip comparisons trivial.</summary>
|
|
||||||
public sealed record ClientDto
|
|
||||||
{
|
|
||||||
/// <summary>Primary key of the underlying client.</summary>
|
|
||||||
public Guid Id { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Display name of the client.</summary>
|
|
||||||
public string Name { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>The client's orders, each carrying a back-reference to this DTO; settable so the mappers can fill it in a second pass.</summary>
|
|
||||||
public IReadOnlyList<OrderDto> Orders { get; set; } = Array.Empty<OrderDto>();
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace Web;
|
|
||||||
|
|
||||||
/// <summary>DTO carrying an <see cref="Order"/> — including its client back-reference — to the WebApp; record: value equality.</summary>
|
|
||||||
public sealed record OrderDto
|
|
||||||
{
|
|
||||||
/// <summary>Primary key of the underlying order.</summary>
|
|
||||||
public Guid Id { get; init; }
|
|
||||||
|
|
||||||
/// <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 null for a bare order.</summary>
|
|
||||||
public ClientDto? ClientDto { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Deliberately NO reference to Db: the Web layer consumes DTOs
|
|
||||||
only, so it cannot reach the persistence layer — the leak the
|
|
||||||
exercise illustrates is now enforced by the compiler. -->
|
|
||||||
<ProjectReference Include="..\Domain\Domain.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
namespace Web;
|
|
||||||
|
|
||||||
/// <summary>The consuming application: it sees only DTOs, never domain or persistence types.</summary>
|
|
||||||
public class WebApp
|
|
||||||
{
|
|
||||||
/// <summary>Render a client together with all of its orders, consuming DTOs only.</summary>
|
|
||||||
public string ShowClient(ClientDto client)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(client);
|
|
||||||
|
|
||||||
var lines = new List<string> { $"{client.Name} [{client.Id}]" };
|
|
||||||
foreach (var order in client.Orders)
|
|
||||||
lines.Add($" - order [{order.Id}]: {order.Description}");
|
|
||||||
return string.Join(Environment.NewLine, lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
using After;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class AfterTests
|
||||||
|
{
|
||||||
|
private const BindingFlags AllMembers =
|
||||||
|
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (1) The leak is gone: Client/Order do NOT derive from DbBase and expose
|
||||||
|
// no DbContext-typed property or field (reflection).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Theory]
|
||||||
|
[InlineData(typeof(Client))]
|
||||||
|
[InlineData(typeof(Order))]
|
||||||
|
public void Domain_objects_are_plain__no_DbBase_no_DbContext_member(Type type)
|
||||||
|
{
|
||||||
|
// Not DbBase, and not a derived type either (walk the base chain):
|
||||||
|
for (var t = type; t is not null && t != typeof(object); t = t.BaseType)
|
||||||
|
Assert.NotSame(typeof(DbBase), t);
|
||||||
|
|
||||||
|
// No DbContext-typed member, public or private:
|
||||||
|
foreach (var p in type.GetProperties(AllMembers))
|
||||||
|
Assert.True(p.PropertyType != typeof(DbContext),
|
||||||
|
$"{type.Name}.{p.Name} leaks a DbContext-typed property");
|
||||||
|
foreach (var f in type.GetFields(AllMembers))
|
||||||
|
Assert.True(f.FieldType != typeof(DbContext),
|
||||||
|
$"{type.Name}.{f.Name} leaks a DbContext-typed field");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (2) Mappers map all scalar fields, in both directions.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void ToClientDto_maps_all_scalar_fields()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var client = new Client { Id = id, Name = "Acme" };
|
||||||
|
|
||||||
|
var dto = Mappers.ToClientDto(client);
|
||||||
|
|
||||||
|
Assert.Equal(id, dto.Id);
|
||||||
|
Assert.Equal("Acme", dto.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToOrderDto_maps_all_scalar_fields()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var order = new Order { Id = id, Description = "one" };
|
||||||
|
|
||||||
|
var dto = Mappers.ToOrderDto(order);
|
||||||
|
|
||||||
|
Assert.Equal(id, dto.Id);
|
||||||
|
Assert.Equal("one", dto.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromClientDto_maps_all_scalar_fields()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var dto = new ClientDto { Id = id, Name = "Acme" };
|
||||||
|
|
||||||
|
var client = Mappers.FromClientDto(dto);
|
||||||
|
|
||||||
|
Assert.Equal(id, client.Id);
|
||||||
|
Assert.Equal("Acme", client.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromOrderDto_maps_all_scalar_fields()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var dto = new OrderDto { Id = id, Description = "one" };
|
||||||
|
|
||||||
|
var order = Mappers.FromOrderDto(dto);
|
||||||
|
|
||||||
|
Assert.Equal(id, order.Id);
|
||||||
|
Assert.Equal("one", order.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (3) Mappers map the relations: Client.Orders -> ClientDto.Orders, and the
|
||||||
|
// Order.Client / OrderDto.ClientDto back-references stay consistent.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[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 dto = Mappers.ToClientDto(client);
|
||||||
|
var orderDto = Assert.Single(dto.Orders);
|
||||||
|
|
||||||
|
// The order is mapped with the client's data:
|
||||||
|
Assert.Equal(order.Id, orderDto.Id);
|
||||||
|
Assert.Equal(order.Description, orderDto.Description);
|
||||||
|
|
||||||
|
// The back-reference cycle is resolved to the SAME dto reference:
|
||||||
|
Assert.Same(dto, orderDto.ClientDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromClientDto_wires_the_order_client_back_reference()
|
||||||
|
{
|
||||||
|
var orderDto = new OrderDto { Description = "one" };
|
||||||
|
var dto = new ClientDto { Name = "Acme", Orders = new[] { orderDto } };
|
||||||
|
|
||||||
|
var client = Mappers.FromClientDto(dto);
|
||||||
|
var order = Assert.Single(client.Orders);
|
||||||
|
|
||||||
|
Assert.Equal(orderDto.Description, order.Description);
|
||||||
|
// Navigation fix-up: the reconstructed order points at the client:
|
||||||
|
Assert.Same(client, order.Client);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (4) Round-trips preserve all state (compared by values, not reference).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[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 roundTrip = Mappers.FromClientDto(Mappers.ToClientDto(client));
|
||||||
|
|
||||||
|
Assert.Equal(client.Id, roundTrip.Id);
|
||||||
|
Assert.Equal(client.Name, roundTrip.Name);
|
||||||
|
var roundTripOrder = Assert.Single(roundTrip.Orders);
|
||||||
|
Assert.Equal(order.Id, roundTripOrder.Id);
|
||||||
|
Assert.Equal(order.Description, roundTripOrder.Description);
|
||||||
|
// The back-reference is still consistent on the reconstructed graph:
|
||||||
|
Assert.Same(roundTrip, roundTripOrder.Client);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 dto = Mappers.ToClientDto(client);
|
||||||
|
var dtoOrder = dto.Orders[0];
|
||||||
|
|
||||||
|
var roundTrip = Mappers.ToClientDto(Mappers.FromClientDto(dto));
|
||||||
|
|
||||||
|
Assert.Equal(dto.Id, roundTrip.Id);
|
||||||
|
Assert.Equal(dto.Name, roundTrip.Name);
|
||||||
|
var roundTripOrder = Assert.Single(roundTrip.Orders);
|
||||||
|
Assert.Equal(dtoOrder.Id, roundTripOrder.Id);
|
||||||
|
Assert.Equal(dtoOrder.Description, roundTripOrder.Description);
|
||||||
|
Assert.Same(roundTrip, roundTripOrder.ClientDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (5) Mappers do not mutate their inputs (snapshot fields, map, compare).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[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 ordersCount = client.Orders.Count;
|
||||||
|
|
||||||
|
Mappers.ToClientDto(client);
|
||||||
|
Mappers.ToOrderDto(order);
|
||||||
|
|
||||||
|
Assert.Equal("Acme", client.Name);
|
||||||
|
Assert.Equal(ordersCount, client.Orders.Count);
|
||||||
|
Assert.Same(client, order.Client); // back-reference untouched
|
||||||
|
Assert.Equal("one", order.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromClientDto_does_not_mutate_the_dto()
|
||||||
|
{
|
||||||
|
var orderDto = new OrderDto { Description = "one" };
|
||||||
|
var dto = new ClientDto { Id = Guid.NewGuid(), Name = "Acme", Orders = new[] { orderDto } };
|
||||||
|
|
||||||
|
Mappers.FromClientDto(dto);
|
||||||
|
Mappers.FromOrderDto(orderDto);
|
||||||
|
|
||||||
|
Assert.Equal("Acme", dto.Name);
|
||||||
|
Assert.Same(orderDto, Assert.Single(dto.Orders)); // collection untouched
|
||||||
|
Assert.Null(orderDto.ClientDto); // back-reference untouched
|
||||||
|
Assert.Equal("one", orderDto.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (6) The after WebApp consumes only DTOs: its public API references no
|
||||||
|
// domain (Client/Order) or persistence (DbContext/DbBase) type.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
private static readonly Type[] BannedTypes =
|
||||||
|
[typeof(Client), typeof(Order), typeof(DbContext), typeof(DbBase)];
|
||||||
|
|
||||||
|
private static IEnumerable<Type> TypeAndArguments(Type t)
|
||||||
|
{
|
||||||
|
yield return t;
|
||||||
|
if (t.IsGenericType)
|
||||||
|
foreach (var arg in t.GetGenericArguments())
|
||||||
|
foreach (var nested in TypeAndArguments(arg))
|
||||||
|
yield return nested;
|
||||||
|
if (t.IsArray && t.GetElementType() is { } elem)
|
||||||
|
foreach (var nested in TypeAndArguments(elem))
|
||||||
|
yield return nested;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void After_WebApp_public_api_references_no_domain_or_db_types()
|
||||||
|
{
|
||||||
|
var app = typeof(WebApp);
|
||||||
|
var banned = BannedTypes.ToList();
|
||||||
|
|
||||||
|
foreach (var m in app.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
|
||||||
|
{
|
||||||
|
var signature = m.GetParameters()
|
||||||
|
.Select(p => p.ParameterType)
|
||||||
|
.Append(m.ReturnType)
|
||||||
|
.ToList();
|
||||||
|
foreach (var t in signature.SelectMany(TypeAndArguments))
|
||||||
|
Assert.False(banned.Contains(t),
|
||||||
|
$"WebApp.{m.Name}({string.Join(", ", signature)}) references banned type {t}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var p in app.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
|
||||||
|
foreach (var t in TypeAndArguments(p.PropertyType))
|
||||||
|
Assert.False(banned.Contains(t),
|
||||||
|
$"WebApp.{p.Name} references banned type {t}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
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 view = app.ShowClient(Mappers.ToClientDto(client));
|
||||||
|
|
||||||
|
Assert.Contains("Acme", view);
|
||||||
|
Assert.Contains("order one", view);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (7) Persistence still works through the fake DbContext in the after
|
||||||
|
// world: explicit Save, distinct ids, tracking — and even a saved
|
||||||
|
// domain object keeps no back-reference to the context.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
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);
|
||||||
|
|
||||||
|
Assert.Equal(Guid.Empty, client.Id);
|
||||||
|
db.Save(client);
|
||||||
|
db.Save(order);
|
||||||
|
|
||||||
|
Assert.NotEqual(Guid.Empty, client.Id);
|
||||||
|
Assert.NotEqual(Guid.Empty, order.Id);
|
||||||
|
Assert.NotEqual(client.Id, order.Id);
|
||||||
|
Assert.True(db.IsTracked(client));
|
||||||
|
Assert.True(db.IsTracked(order));
|
||||||
|
Assert.Equal(2, db.Tracked.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Saving_twice_does_not_reassign_the_id()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var client = new Client { Name = "Acme" };
|
||||||
|
db.Save(client);
|
||||||
|
var id = client.Id;
|
||||||
|
|
||||||
|
db.Save(client);
|
||||||
|
|
||||||
|
Assert.Equal(id, client.Id);
|
||||||
|
Assert.Single(db.Tracked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_saved_domain_object_keeps_no_reference_to_the_context()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var client = new Client { Name = "Acme" };
|
||||||
|
db.Save(client);
|
||||||
|
|
||||||
|
// Saved (the id was assigned by the context) ...
|
||||||
|
Assert.NotEqual(Guid.Empty, client.Id);
|
||||||
|
Assert.True(db.IsTracked(client));
|
||||||
|
|
||||||
|
// ... but there is no way back to the context through the domain
|
||||||
|
// object: no DbContext-typed member exists on it (test 1), so the
|
||||||
|
// only "receipt" is the plain Id value.
|
||||||
|
var dto = Mappers.ToClientDto(client);
|
||||||
|
Assert.Equal(client.Id, dto.Id); // the id flows on into the DTO
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\Before\Before.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\After\After.csproj" />
|
||||||
|
<ProjectReference Include="..\..\tools\ExtractMethod\ExtractMethod.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- The ExtractMethod fixtures are DATA for the Roslyn tool, not source:
|
||||||
|
they are excluded from this project's compilation (the scratch
|
||||||
|
compilation gives the tool the exact checked-in text) and copied to
|
||||||
|
the output dir so tests can find them by path. -->
|
||||||
|
<Compile Remove="ExtractMethod\Fixtures\**\*.cs" />
|
||||||
|
<!-- Rebase the copy target to Fixtures/ in the output dir: the tool's
|
||||||
|
apphost (the Executable 'ExtractMethod') is copied here too, and a
|
||||||
|
folder named ExtractMethod/ would collide with that file. -->
|
||||||
|
<Content Include="ExtractMethod\Fixtures\**\*.cs" Link="Fixtures\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
using Before;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the BEFORE situation (Yak 01): DB-backed domain classes that
|
||||||
|
/// inherit <see cref="DbBase"/> and leak their <see cref="DbContext"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class BeforeTests
|
||||||
|
{
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (1) Inheritance: the domain classes ARE DB-backed (they derive from
|
||||||
|
// DbBase). This is the "before" shape Yak 02 will undo.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Client_and_Order_derive_from_DbBase()
|
||||||
|
{
|
||||||
|
Assert.True(typeof(DbBase).IsAssignableFrom(typeof(Client)));
|
||||||
|
Assert.True(typeof(DbBase).IsAssignableFrom(typeof(Order)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (2) Fresh entities are unsaved: their Id is still Guid.Empty.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Theory]
|
||||||
|
[InlineData(typeof(Client))]
|
||||||
|
[InlineData(typeof(Order))]
|
||||||
|
public void New_entities_have_an_empty_id(Type type)
|
||||||
|
{
|
||||||
|
var entity = (DbBase)Activator.CreateInstance(type)!;
|
||||||
|
Assert.Equal(Guid.Empty, entity.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (3) Save() fakes EF's SaveChanges: it assigns fresh, distinct Guids to
|
||||||
|
// entities that do not have one yet.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Save_assigns_fresh_distinct_ids()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var acme = new Client { Name = "Acme" };
|
||||||
|
var globex = new Client { Name = "Globex" };
|
||||||
|
db.Attach(acme);
|
||||||
|
db.Attach(globex);
|
||||||
|
|
||||||
|
Assert.Equal(2, db.Save());
|
||||||
|
|
||||||
|
Assert.NotEqual(Guid.Empty, acme.Id);
|
||||||
|
Assert.NotEqual(Guid.Empty, globex.Id);
|
||||||
|
Assert.NotEqual(acme.Id, globex.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (4) A saved entity is registered with its context, reachable via the
|
||||||
|
// active-record back-reference.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Saved_entity_is_registered_with_its_context()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var client = new Client { Name = "Acme" };
|
||||||
|
db.Attach(client);
|
||||||
|
db.Save();
|
||||||
|
|
||||||
|
Assert.Same(db, client.DbContext);
|
||||||
|
Assert.True(db.IsTracked(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (5) Navigation fix-up: adding an order to client.Orders wires the
|
||||||
|
// order.Client back-reference.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Adding_an_order_sets_the_client_back_reference()
|
||||||
|
{
|
||||||
|
var client = new Client { Name = "Acme" };
|
||||||
|
var order = new Order { Description = "first order" };
|
||||||
|
|
||||||
|
client.Orders.Add(order);
|
||||||
|
|
||||||
|
Assert.Same(client, order.Client);
|
||||||
|
Assert.Same(order, client.Orders[0]);
|
||||||
|
Assert.Single(client.Orders);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (6) THE LEAK: from a plain Client you can reach its DbContext.
|
||||||
|
// This is exactly what the "after" situation removes: a ClientDto has
|
||||||
|
// no DbContext to reach, so a consumer can never touch the persistence
|
||||||
|
// layer through it.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void A_client_can_reach_its_DbContext__the_leak()
|
||||||
|
{
|
||||||
|
var db = new DbContext();
|
||||||
|
var client = new Client { Name = "Acme" };
|
||||||
|
db.Attach(client);
|
||||||
|
db.Save();
|
||||||
|
|
||||||
|
// Reaching the persistence layer *through* the domain object:
|
||||||
|
Assert.NotNull(client.DbContext);
|
||||||
|
Assert.Same(db, client.DbContext);
|
||||||
|
Assert.True(client.DbContext!.IsTracked(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (7) The WebApp consumes the domain classes directly — including a method
|
||||||
|
// that rides the leak.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void WebApp_works_on_the_domain_classes_directly()
|
||||||
|
{
|
||||||
|
var app = new WebApp();
|
||||||
|
var db = new DbContext();
|
||||||
|
|
||||||
|
var client = new Client { Name = "Acme" };
|
||||||
|
var order = new Order { Description = "order one" };
|
||||||
|
client.Orders.Add(order);
|
||||||
|
db.Attach(client);
|
||||||
|
db.Attach(order);
|
||||||
|
db.Save();
|
||||||
|
|
||||||
|
// ShowClient consumes Client and its Orders directly:
|
||||||
|
var view = app.ShowClient(client);
|
||||||
|
Assert.Contains("Acme", view);
|
||||||
|
Assert.Contains("order one", view);
|
||||||
|
|
||||||
|
// IsPersisted rides the leak (client.DbContext) — and it is true here
|
||||||
|
// because the client was saved through the context:
|
||||||
|
Assert.True(app.IsPersisted(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A companion check: a bare client has no context until saved.
|
||||||
|
[Fact]
|
||||||
|
public void A_bare_client_has_no_context_until_saved()
|
||||||
|
{
|
||||||
|
var app = new WebApp();
|
||||||
|
var client = new Client { Name = "Nobody" };
|
||||||
|
|
||||||
|
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,186 @@
|
|||||||
|
using ExtractMethod.Tooling;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests.ExtractMethod;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for yak em 02: the data-flow classifier (buckets: params / returns /
|
||||||
|
/// locals) over the checked-in Demo.cs fixture. Line anchors are the exact
|
||||||
|
/// fixture lines pinned by em 01 (see Demo.cs header comment):
|
||||||
|
/// ScoreReads body 56..58, Summarize body 68..76 (for-loop 69..72),
|
||||||
|
/// Heaviest body 87..93.
|
||||||
|
/// </summary>
|
||||||
|
public class DataFlowClassificationTests
|
||||||
|
{
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// in-param bucket (ScoreReads, full body 56..58).
|
||||||
|
// ReadInside = {_seed→field, seed, score, bonus}; the classifier must
|
||||||
|
// report the reads that are locals/params — seed/score/bonus — as in-
|
||||||
|
// params, and must NOT leak `this` (the implicit this-read behind the
|
||||||
|
// _seed field access; filtered via IParameterSymbol.IsThis) into params,
|
||||||
|
// nor the _seed field itself (decision #6 keeps fields for em 03).
|
||||||
|
// WrittenInside = {seed, score}; no tail, trailing return is an expression
|
||||||
|
// (score + bonus) which v1 cannot name -> no returns, both written vars
|
||||||
|
// are scratch locals.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void ScoreReads_full_body_reads_report_three_in_params_and_no_returns()
|
||||||
|
{
|
||||||
|
var suggestion = ResolveAndClassify(56, 58);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "bonus", "score", "seed" }, suggestion.Params.Select(p => p.Name));
|
||||||
|
// None is reassigned inside (declaration initializers are not write-backs) -> no ref.
|
||||||
|
Assert.All(suggestion.Params, p => Assert.False(p.ByRef));
|
||||||
|
Assert.Empty(suggestion.Returns);
|
||||||
|
// v1 reports the read locals ALSO as scratch locals (decision #6 over-
|
||||||
|
// reports local reads); em 03 dedupes them into the signature.
|
||||||
|
Assert.Equal(new[] { "score", "seed" }, suggestion.Locals);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// multi-statement range case (Summarize 68..72: LocalDeclarationStatement
|
||||||
|
// + ForStatement): the resolution, the selection data flow AND the tail
|
||||||
|
// data flow all go through the two-argument overload with Succeeded=true
|
||||||
|
// (the synth-block trick from parent decision #3 is the DEAD path — see
|
||||||
|
// Test for it below). Buckets: total written inside + read at line 74 in
|
||||||
|
// the tail -> return; for-var i never read after -> local.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Summarize_multi_statement_range_flows_via_two_argument_overload_and_buckets_total_return_i_local()
|
||||||
|
{
|
||||||
|
var (tree, compilation) = DemoFixture.Load();
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
var report = SelectionResolver.Resolve(tree, 68, 72);
|
||||||
|
Assert.True(report.Succeeded, report.Error);
|
||||||
|
|
||||||
|
var selectionFlow = DataFlowClassifier.AnalyzeSelectionFlow(model, report);
|
||||||
|
Assert.True(selectionFlow.Succeeded, "two-argument AnalyzeDataFlow must succeed on a multi-statement selection");
|
||||||
|
|
||||||
|
var tailFlow = DataFlowClassifier.AnalyzeTailFlow(model, report);
|
||||||
|
Assert.NotNull(tailFlow);
|
||||||
|
Assert.True(tailFlow.Succeeded, "two-argument AnalyzeDataFlow must succeed on the (multi-statement) tail region");
|
||||||
|
|
||||||
|
var suggestion = DataFlowClassifier.Classify(model, report);
|
||||||
|
Assert.Equal(new[] { "total" }, suggestion.Returns.Select(r => r.Name));
|
||||||
|
Assert.Equal(new[] { "int" }, suggestion.Returns.Select(r => r.Type));
|
||||||
|
Assert.Equal(new[] { "i" }, suggestion.Locals);
|
||||||
|
|
||||||
|
// limit is read in the for-header (68..72) — a plain by-value in-param.
|
||||||
|
var limit = Assert.Single(suggestion.Params.Where(p => p.Name == "limit"));
|
||||||
|
Assert.False(limit.ByRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// return + ref-param buckets (Summarize, full body 68..76).
|
||||||
|
// Returns: the tail is empty, so the tail-flow rule yields nothing; the
|
||||||
|
// selection ends with `return message;` -> message is the trailing-return
|
||||||
|
// candidate (parent spec). Locals: everything else written inside:
|
||||||
|
// {i, scaled, total}.
|
||||||
|
// Params (all ReadInside locals/params): the differential ref check —
|
||||||
|
// total is REASSIGNED inside (total += i at line 71) -> ByRef; scaled and
|
||||||
|
// limit are never reassigned (declaration write only) -> plain in-params.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Summarize_full_body_returns_trailing_message_and_flags_reassigned_total_as_ref()
|
||||||
|
{
|
||||||
|
var suggestion = ResolveAndClassify(68, 76);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "message" }, suggestion.Returns.Select(r => r.Name));
|
||||||
|
Assert.Equal(new[] { "i", "scaled", "total" }, suggestion.Locals);
|
||||||
|
|
||||||
|
var total = Assert.Single(suggestion.Params.Where(p => p.Name == "total"));
|
||||||
|
Assert.True(total.ByRef, "total is reassigned inside (total += i) -> the extraction owes a write-back");
|
||||||
|
|
||||||
|
var scaled = Assert.Single(suggestion.Params.Where(p => p.Name == "scaled"));
|
||||||
|
Assert.False(scaled.ByRef, "scaled is only declared inside, never reassigned");
|
||||||
|
|
||||||
|
var limit = Assert.Single(suggestion.Params.Where(p => p.Name == "limit"));
|
||||||
|
Assert.False(limit.ByRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// return + extract-first isolation (Heaviest, full body 87..93).
|
||||||
|
// Returns: `return best;` is the last statement -> best (trailing-return
|
||||||
|
// rule again). Written inside but never read after: i -> local.
|
||||||
|
// Params: reads are best/count/i/widgets; best and i are reassigned
|
||||||
|
// inside -> ref. Nothing extract-first (widgets[i] indexer, Bigger
|
||||||
|
// invocation — em 03 territory) may leak into any bucket: the params list
|
||||||
|
// is exactly the four variable reads.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Heaviest_full_body_returns_best_locals_i_and_leaks_no_extract_first_names()
|
||||||
|
{
|
||||||
|
var suggestion = ResolveAndClassify(87, 93);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "best" }, suggestion.Returns.Select(r => r.Name));
|
||||||
|
Assert.Equal(new[] { "i" }, suggestion.Locals);
|
||||||
|
Assert.Equal(new[] { "best", "count", "i", "widgets" }, suggestion.Params.Select(p => p.Name));
|
||||||
|
Assert.Equal(new[] { true, false, true, false }, suggestion.Params.Select(p => p.ByRef));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// single-statement fallback (ScoreReads, line 58 alone: `return ...;`).
|
||||||
|
// The one-argument AnalyzeDataFlow overload is the live path for a single
|
||||||
|
// statement. The trailing return is `score + bonus` — an expression, not
|
||||||
|
// a simple name — so v1 reports no return candidate (composite return
|
||||||
|
// expressions are extract-first shape for em 03) and no locals.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Single_statement_selection_uses_one_argument_overload_and_composite_trailing_return_is_not_nameable()
|
||||||
|
{
|
||||||
|
var (tree, compilation) = DemoFixture.Load();
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
var report = SelectionResolver.Resolve(tree, 58, 58);
|
||||||
|
Assert.True(report.Succeeded, report.Error);
|
||||||
|
|
||||||
|
Assert.True(DataFlowClassifier.AnalyzeSelectionFlow(model, report).Succeeded);
|
||||||
|
|
||||||
|
var suggestion = DataFlowClassifier.Classify(model, report);
|
||||||
|
Assert.Equal(new[] { "bonus", "score" }, suggestion.Params.Select(p => p.Name));
|
||||||
|
Assert.All(suggestion.Params, p => Assert.False(p.ByRef));
|
||||||
|
Assert.Empty(suggestion.Returns);
|
||||||
|
Assert.Empty(suggestion.Locals);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// DEAD PATH (parent decision #3, pinned so the choice is self-explanatory
|
||||||
|
// to future readers): a synthetic BlockSyntax re-parents tree nodes that
|
||||||
|
// are not in the tree, and AnalyzeDataFlow refuses with
|
||||||
|
// ArgumentException("statements not within tree"). That is why the live
|
||||||
|
// path uses the two-argument overload (both endpoints are real tree
|
||||||
|
// nodes) — which also keeps symbol identity across selection and tail
|
||||||
|
// analyses, the prerequisite for the written-inside ∩ read-after
|
||||||
|
// intersection of the return bucket.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Synthetic_block_data_flow_throws_not_within_tree_so_the_two_argument_overload_stays_live()
|
||||||
|
{
|
||||||
|
var (tree, compilation) = DemoFixture.Load();
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
|
||||||
|
var summarize = tree.GetRoot().DescendantNodes()
|
||||||
|
.OfType<MethodDeclarationSyntax>()
|
||||||
|
.Single(m => m.Identifier.ValueText == "Summarize");
|
||||||
|
var statements = summarize.Body!.Statements;
|
||||||
|
|
||||||
|
// Same node instances, new (synthetic) parent — this is the shape
|
||||||
|
// parent decision #3 proposed for multi-statement ranges and tails.
|
||||||
|
var syntheticBlock = SyntaxFactory.Block(statements[2], statements[3], statements[4]);
|
||||||
|
|
||||||
|
var ex = Assert.Throws<ArgumentException>(() => model.AnalyzeDataFlow(syntheticBlock));
|
||||||
|
Assert.Contains("not within tree", ex.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExtractionSuggestion ResolveAndClassify(int startLine, int endLine)
|
||||||
|
{
|
||||||
|
var (tree, compilation) = DemoFixture.Load();
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
|
||||||
|
var report = SelectionResolver.Resolve(tree, startLine, endLine);
|
||||||
|
Assert.True(report.Succeeded, report.Error);
|
||||||
|
|
||||||
|
return DataFlowClassifier.Classify(model, report);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ExtractMethod.Tooling;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests.ExtractMethod;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared entry point for the ExtractMethod tests: the checked-in Demo.cs
|
||||||
|
/// fixture is DATA (excluded from this project's compilation, copied to the
|
||||||
|
/// output dir) and must ALWAYS be loaded through the tool's own
|
||||||
|
/// <see cref="CompilationLoader"/> so tests exercise the exact parse +
|
||||||
|
/// scratch-compilation path the CLI uses.
|
||||||
|
/// </summary>
|
||||||
|
internal static class DemoFixture
|
||||||
|
{
|
||||||
|
/// <summary>Where the fixture lands after the csproj copies it (link Fixtures/).</summary>
|
||||||
|
public static string Path =>
|
||||||
|
System.IO.Path.Combine(AppContext.BaseDirectory, "Fixtures", "Demo.cs");
|
||||||
|
|
||||||
|
/// <summary>Parse + scratch-compile the fixture once per caller.</summary>
|
||||||
|
public static (SyntaxTree Tree, CSharpCompilation Compilation) Load()
|
||||||
|
{
|
||||||
|
if (!File.Exists(Path))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"fixture missing at {Path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var tree = CompilationLoader.ParseFile(Path);
|
||||||
|
var compilation = CompilationLoader.CreateCompilation(tree, "DemoFixture");
|
||||||
|
return (tree, compilation);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using ExtractMethod.Tooling;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests.ExtractMethod;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for yak em 01: the scaffold (console tool) and the checked-in demo
|
||||||
|
/// fixture. The fixture is DATA (excluded from compilation, copied to the
|
||||||
|
/// output dir), and it is parsed with the tool's own <see cref="CompilationLoader"/>
|
||||||
|
/// so these tests exercise the exact loading path the CLI uses.
|
||||||
|
/// </summary>
|
||||||
|
public class DemoFixtureTests
|
||||||
|
{
|
||||||
|
private static (SyntaxTree Tree, CSharpCompilation Compilation) LoadFixture() => DemoFixture.Load();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (a) The fixture is pristine under the scratch compilation: if this
|
||||||
|
// fails, the fixture (or the TRUSTED_PLATFORM_ASSEMBLIES reference
|
||||||
|
// loading) is broken and every later yak would analyze garbage.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Demo_compiles_with_no_diagnostics()
|
||||||
|
{
|
||||||
|
var (_, compilation) = LoadFixture();
|
||||||
|
|
||||||
|
Assert.Empty(compilation.GetDiagnostics());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (b) A known line range snaps to the expected whole statements.
|
||||||
|
// Lines 68..72 of Demo.cs are `int total = 0;` followed by the whole
|
||||||
|
// for-loop (for keyword .. closing brace) inside Summarize — exactly
|
||||||
|
// the "multi-statement range incl. a for-loop" shape of the spec.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Known_range_resolves_to_expected_statements()
|
||||||
|
{
|
||||||
|
var (tree, _) = LoadFixture();
|
||||||
|
|
||||||
|
var report = SelectionResolver.Resolve(tree, 68, 72);
|
||||||
|
|
||||||
|
Assert.True(report.Succeeded, report.Error);
|
||||||
|
Assert.Equal(2, report.Count);
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { SyntaxKind.LocalDeclarationStatement, SyntaxKind.ForStatement },
|
||||||
|
report.Kinds);
|
||||||
|
Assert.Equal("Summarize", report.Method?.Identifier.ValueText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// (c) The semantic model's data-flow analysis works on the fixture's
|
||||||
|
// for-loop node — the foundation every classification in yak 02
|
||||||
|
// builds on. (AnalyzeDataFlow on the loop itself walks the bound loop
|
||||||
|
// body; Succeeded == false would mean the scratch compilation or the
|
||||||
|
// selected node cannot be bound.)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Fixture_for_loop_supports_data_flow_analysis()
|
||||||
|
{
|
||||||
|
var (tree, compilation) = LoadFixture();
|
||||||
|
|
||||||
|
var forStatement = tree.GetRoot()
|
||||||
|
.DescendantNodes()
|
||||||
|
.OfType<ForStatementSyntax>()
|
||||||
|
.Single(n => EnclosingMethodName(n) == "Summarize");
|
||||||
|
|
||||||
|
var dataFlow = compilation.GetSemanticModel(tree).AnalyzeDataFlow(forStatement);
|
||||||
|
|
||||||
|
// AnalyzeDataFlow is nullable-annotated in Roslyn 5.x: assert non-null
|
||||||
|
// so the dereference below is provably safe (and test intent explicit).
|
||||||
|
Assert.NotNull(dataFlow);
|
||||||
|
Assert.True(dataFlow.Succeeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Acceptance also demands "clean error otherwise": selecting only the
|
||||||
|
// for-loop header line (line 69) splits the statement and must fail with
|
||||||
|
// a readable reason instead of a crash or a silent wrong count.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Range_ending_mid_statement_fails_cleanly()
|
||||||
|
{
|
||||||
|
var (tree, _) = LoadFixture();
|
||||||
|
|
||||||
|
var report = SelectionResolver.Resolve(tree, 69, 69);
|
||||||
|
|
||||||
|
Assert.False(report.Succeeded);
|
||||||
|
Assert.NotNull(report.Error);
|
||||||
|
Assert.Contains("no whole", report.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? EnclosingMethodName(SyntaxNode node) =>
|
||||||
|
node.AncestorsAndSelf().OfType<MethodDeclarationSyntax>().FirstOrDefault()?.Identifier.ValueText;
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using ExtractMethod.Tooling;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests.ExtractMethod;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for yak em 03: the extract-first scan over the checked-in Demo.cs
|
||||||
|
/// fixture. Line anchors are the exact fixture lines pinned by em 01/02:
|
||||||
|
/// ScoreReads body 56..58, Summarize body 68..76, Heaviest body 87..93,
|
||||||
|
/// RepeatReads body 105..108, Casts body 116 (the em 03 additions at the
|
||||||
|
/// end of the fixture; nothing before them may shift — see Demo.cs header).
|
||||||
|
///
|
||||||
|
/// Extract-first = read expressions that are NOT simple local/parameter
|
||||||
|
/// references (parent bucket spec): member/field/property access, element
|
||||||
|
/// access, invocations, casts — deduped by symbol + text, occurrence-
|
||||||
|
/// counted, invocations flagged ("hoisting changes eval count"), own-class
|
||||||
|
/// field/property access marked optional.
|
||||||
|
/// </summary>
|
||||||
|
public class ExtractFirstTests
|
||||||
|
{
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// optional marking (own-class FIELD): ScoreReads reads `_seed` once;
|
||||||
|
// `seed`, `score`, `bonus` are simple local/param names and must NOT
|
||||||
|
// appear (decision #6 puts them in the params bucket instead).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void ScoreReads_reports_own_class_field_once_and_optional()
|
||||||
|
{
|
||||||
|
var report = Compose(56, 58);
|
||||||
|
|
||||||
|
var entry = Assert.Single(report.ExtractFirst);
|
||||||
|
Assert.Equal("_seed", entry.Text);
|
||||||
|
Assert.Equal("Demo._seed", entry.SymbolDisplay);
|
||||||
|
Assert.Equal(1, entry.Occurrences);
|
||||||
|
Assert.False(entry.IsInvocation);
|
||||||
|
Assert.True(entry.Optional, "own-class field reads are optional to hoist");
|
||||||
|
Assert.Equal("field", entry.OwnMemberKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// optional marking (own-class PROPERTY): Summarize reads `Scale` once
|
||||||
|
// (line 74). The binary expressions around it (`total * Scale`,
|
||||||
|
// `"sum=" + scaled`) are composites of locals/params and literals —
|
||||||
|
// exactly the shapes the parent spec does NOT list, so they stay out.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Summarize_reports_own_class_property_once_and_optional()
|
||||||
|
{
|
||||||
|
var report = Compose(68, 76);
|
||||||
|
|
||||||
|
var entry = Assert.Single(report.ExtractFirst);
|
||||||
|
Assert.Equal("Scale", entry.Text);
|
||||||
|
Assert.Equal("Demo.Scale", entry.SymbolDisplay);
|
||||||
|
Assert.Equal(1, entry.Occurrences);
|
||||||
|
Assert.False(entry.IsInvocation);
|
||||||
|
Assert.True(entry.Optional, "own-class property reads are optional to hoist");
|
||||||
|
Assert.Equal("property", entry.OwnMemberKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// invocation flag + element access (Heaviest, full body 87..93):
|
||||||
|
// `widgets[0]` and `widgets[i]` are two DIFFERENT indexer reads (same
|
||||||
|
// indexer symbol, different text -> separate entries); the call
|
||||||
|
// `best.Bigger(widgets[i])` is flagged because hoisting it changes how
|
||||||
|
// often it is evaluated. The callee `best.Bigger` must NOT be reported
|
||||||
|
// separately (the invocation covers it), and `best`/`i`/`widgets`/
|
||||||
|
// `count` are simple local/param names — invisible to this bucket.
|
||||||
|
// None of Heaviest's candidates is own-class (Widget is a nested record,
|
||||||
|
// the indexer belongs to List<T>) -> nothing optional here.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Heaviest_flags_the_invocation_and_lists_both_indexer_reads()
|
||||||
|
{
|
||||||
|
var report = Compose(87, 93);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { "widgets[0]", "best.Bigger(widgets[i])", "widgets[i]" },
|
||||||
|
report.ExtractFirst.Select(e => e.Text));
|
||||||
|
Assert.All(report.ExtractFirst, e => Assert.Equal(1, e.Occurrences));
|
||||||
|
Assert.All(report.ExtractFirst, e => Assert.False(e.Optional)); // none of Heaviest's reads is own-class
|
||||||
|
|
||||||
|
var invocation = Assert.Single(report.ExtractFirst, e => e.IsInvocation);
|
||||||
|
Assert.Equal("best.Bigger(widgets[i])", invocation.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// dedupe + occurrence count (RepeatReads, body 105..108): `_seed` is
|
||||||
|
// read TWICE (lines 105, 106) and must dedupe to ONE entry with
|
||||||
|
// Occurrences == 2. The field WRITE `_seed = a;` (line 107) is a
|
||||||
|
// non-variable assignment LHS — the reads-only scan skips it, so it
|
||||||
|
// neither adds an occurrence nor changes the count (decision 5), and
|
||||||
|
// the report must carry the decision-5 limitation note.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void RepeatReads_dedupes_the_field_read_to_count_two_and_skips_the_field_write()
|
||||||
|
{
|
||||||
|
var report = Compose(105, 108);
|
||||||
|
|
||||||
|
var entry = Assert.Single(report.ExtractFirst);
|
||||||
|
Assert.Equal("_seed", entry.Text);
|
||||||
|
Assert.Equal(2, entry.Occurrences);
|
||||||
|
Assert.True(entry.Optional);
|
||||||
|
Assert.Equal("field", entry.OwnMemberKind);
|
||||||
|
|
||||||
|
// Two notes apply to this selection: the decision-5 write limitation
|
||||||
|
// AND the composite trailing return (`return a + b;`).
|
||||||
|
Assert.Equal(2, report.Notes.Count);
|
||||||
|
Assert.Contains(report.Notes, n => n.Contains("assignment left-hand sides", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(report.Notes, n => n.Contains("composite return expression (a + b)", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// cast (Casts, body 116): `(double)total` is an extract-first candidate
|
||||||
|
// (parent spec lists casts). A conversion is not a symbol, so the entry
|
||||||
|
// keys on its text alone ("unbound" display) and carries no flags.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Casts_reports_the_cast_expression_and_the_field_read()
|
||||||
|
{
|
||||||
|
var report = Compose(116, 116);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { "(double)total", "_seed" },
|
||||||
|
report.ExtractFirst.Select(e => e.Text));
|
||||||
|
|
||||||
|
var cast = report.ExtractFirst[0];
|
||||||
|
Assert.Equal(1, cast.Occurrences);
|
||||||
|
Assert.False(cast.IsInvocation);
|
||||||
|
Assert.False(cast.Optional);
|
||||||
|
Assert.Equal("<unbound>", cast.SymbolDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExtractionReport Compose(int startLine, int endLine)
|
||||||
|
{
|
||||||
|
var (tree, compilation) = DemoFixture.Load();
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
|
||||||
|
var resolved = SelectionResolver.Resolve(tree, startLine, endLine);
|
||||||
|
Assert.True(resolved.Succeeded, resolved.Error);
|
||||||
|
|
||||||
|
return ExtractionReporter.Compose(model, resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Demo fixture for the extract-method micro-tool (tools/ExtractMethod).
|
||||||
|
//
|
||||||
|
// This file is DATA, not project source: the test project excludes it from
|
||||||
|
// compilation (see BeforeAfter.Tests.csproj) and runs Roslyn over its raw
|
||||||
|
// text, so the tool sees exactly what is checked in here. It uses explicit
|
||||||
|
// usings because the scratch compilation has no implicit usings.
|
||||||
|
//
|
||||||
|
// Bucket map (the classification yak 02 works from):
|
||||||
|
// bucket where example
|
||||||
|
// local, read-only (param) ScoreReads seed, score
|
||||||
|
// written + read-later (return) Summarize, Heaviest total, message, best
|
||||||
|
// scratch local (local) Summarize for-loop i
|
||||||
|
// enclosing-method param (param) ScoreReads, Summarize bonus, limit
|
||||||
|
// field access (extract-first) ScoreReads _seed (optional)
|
||||||
|
// property access (extract-first) Summarize Scale (optional)
|
||||||
|
// indexer access (extract-first) Heaviest widgets[i]
|
||||||
|
// method invocation (extract-first, flagged) Heaviest best.Bigger(...)
|
||||||
|
// multi-statement range incl. for Summarize total + for-loop
|
||||||
|
//
|
||||||
|
// Tests pin EXACT line numbers of the statements they select. Keep the
|
||||||
|
// formatting stable; when a line must change, update DemoFixtureTests too.
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests.ExtractMethod.Fixtures;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Small first-class citizen of the extract-method tool: every method below
|
||||||
|
/// shows one or more of the classification "buckets" the tool must report.
|
||||||
|
/// </summary>
|
||||||
|
public class Demo
|
||||||
|
{
|
||||||
|
// Own-class state: reading it from a selection is "extract-first optional"
|
||||||
|
// (a same-class extracted method can still see these fields). Initialized
|
||||||
|
// here so the fixture compiles with zero diagnostics (CS0649 otherwise).
|
||||||
|
private int _seed = 5;
|
||||||
|
|
||||||
|
// Own-class property: like a field, accessible from a same-class method,
|
||||||
|
// so hoisting it is optional in the report.
|
||||||
|
public int Scale { get; set; } = 1;
|
||||||
|
|
||||||
|
/// <summary>Small local type so the scratch compilation binds.</summary>
|
||||||
|
public sealed record Widget(int Weight, int Count, string Label)
|
||||||
|
{
|
||||||
|
/// <summary>Instance method used to exercise the invocation bucket.</summary>
|
||||||
|
public Widget Bigger(Widget other) =>
|
||||||
|
Weight >= other.Weight ? this : other;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bucket: enclosing-method parameter read (bonus => param), local
|
||||||
|
/// read-only (seed, score => param), own-class field access
|
||||||
|
/// (_seed => extract-first, optional). Ends with a return expression.
|
||||||
|
/// </summary>
|
||||||
|
public int ScoreReads(int bonus)
|
||||||
|
{
|
||||||
|
int seed = _seed;
|
||||||
|
int score = seed * 2;
|
||||||
|
return score + bonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bucket: written + read-later locals (total, message => return),
|
||||||
|
/// scratch local (i => local), own-class property access (Scale =>
|
||||||
|
/// extract-first optional), multi-statement range incl. a for-loop.
|
||||||
|
/// </summary>
|
||||||
|
public string Summarize(int limit)
|
||||||
|
{
|
||||||
|
int total = 0;
|
||||||
|
for (int i = 0; i < limit; i++)
|
||||||
|
{
|
||||||
|
total += i;
|
||||||
|
}
|
||||||
|
|
||||||
|
int scaled = total * Scale;
|
||||||
|
string message = "sum=" + scaled;
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bucket: indexer access (widgets[i] on List<T> => extract-first),
|
||||||
|
/// method invocation (Bigger => extract-first, flagged because hoisting
|
||||||
|
/// changes how often it is evaluated), local written + read-later
|
||||||
|
/// (best => return).
|
||||||
|
/// </summary>
|
||||||
|
public Widget Heaviest(List<Widget> widgets, int count)
|
||||||
|
{
|
||||||
|
Widget best = widgets[0];
|
||||||
|
for (int i = 1; i < count; i++)
|
||||||
|
{
|
||||||
|
best = best.Bigger(widgets[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bucket (em 03): the field read <c>_seed</c> appears TWICE — the
|
||||||
|
/// extract-first scan must dedupe it to one entry with an occurrence
|
||||||
|
/// count of 2. The field WRITE below (<c>_seed = a;</c>) is a
|
||||||
|
/// non-variable assignment left-hand side: the reads-only scan skips it
|
||||||
|
/// (decision 5) and the report notes the limitation.
|
||||||
|
/// </summary>
|
||||||
|
public int RepeatReads(int n)
|
||||||
|
{
|
||||||
|
int a = _seed + n;
|
||||||
|
int b = _seed * n;
|
||||||
|
_seed = a;
|
||||||
|
return a + b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bucket (em 03): a cast is an extract-first candidate too
|
||||||
|
/// (parent spec lists casts); its bound symbol is null — conversions are
|
||||||
|
/// not symbols — so the entry keys on its text alone.</summary>
|
||||||
|
public double Casts(int total)
|
||||||
|
{
|
||||||
|
return (double)total / _seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bucket (em 04 codegen): a local DECLARED BEFORE the selection
|
||||||
|
/// that the selection writes and the tail reads — the promoted signature
|
||||||
|
/// keeps it as a <c>ref</c> parameter (the write-back already flows the
|
||||||
|
/// value out, so the result is not re-returned; void) and the call site
|
||||||
|
/// is a plain call, no assignment: <c>Extract(ref acc, n);</c>.</summary>
|
||||||
|
public int Accumulate(int n)
|
||||||
|
{
|
||||||
|
int acc = 0;
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
acc += i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Bucket (em 04 codegen): TWO locals declared inside, both
|
||||||
|
/// written inside and read after — the promoted signature returns a
|
||||||
|
/// tuple <c>(int, int)</c> and the call site deconstructs it:
|
||||||
|
/// <c>var (x, y) = Extract(n);</c>.</summary>
|
||||||
|
public (int, int) Pairwise(int n)
|
||||||
|
{
|
||||||
|
int x = 0;
|
||||||
|
int y = 1;
|
||||||
|
for (int i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
x += i;
|
||||||
|
y += i * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (x + y, x - y);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
using ExtractMethod.Tooling;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests.ExtractMethod;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for yak em 04 (codegen): generate the refactoring from the em 03
|
||||||
|
/// suggestion — new method (promoted signature, parameter names from the
|
||||||
|
/// source symbols) + rewritten call site + transformed tree — and the
|
||||||
|
/// round-trip check (the transformed tree compiles with zero diagnostics).
|
||||||
|
///
|
||||||
|
/// Fixture line anchors: the em 01/02/03 anchors (ScoreReads 56..58,
|
||||||
|
/// Summarize 68..76 / partial 68..72, Heaviest 87..93) plus the em 04
|
||||||
|
/// additions Accumulate for-loop 127..130 and Pairwise 141..147.
|
||||||
|
/// </summary>
|
||||||
|
public class RefactoringTests
|
||||||
|
{
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Summarize partial (68..72): the declared-inside `total` promotes to the
|
||||||
|
// plain return of the new method; at the call site it is re-declared from
|
||||||
|
// the result. The new body gains the appended `return total;`.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Summarize_partial_selection_generates_returning_method_and_declaration_call()
|
||||||
|
{
|
||||||
|
var (method, calls, tree) = Generate(68, 72);
|
||||||
|
|
||||||
|
Assert.Equal("Extract", method.Identifier.ValueText);
|
||||||
|
Assert.Equal("int", method.ReturnType.ToString());
|
||||||
|
Assert.Equal(new[] { "int limit" }, method.ParameterList.Parameters.Select(p => p.ToString()));
|
||||||
|
|
||||||
|
var body = (BlockSyntax)method.Body!;
|
||||||
|
Assert.Equal(3, body.Statements.Count); // total declaration, for-loop, appended return
|
||||||
|
Assert.Equal("return total;", body.Statements[2].ToString());
|
||||||
|
|
||||||
|
// `total` was declared inside the selection: re-declared at the call
|
||||||
|
// site from the result.
|
||||||
|
Assert.Single(calls);
|
||||||
|
Assert.Equal("int total = Extract(limit);", calls[0].ToString());
|
||||||
|
|
||||||
|
VerifyZeroDiagnostics(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Summarize full body (68..76): the selection ends the (non-void)
|
||||||
|
// enclosing method and the trailing `return message;` is nameable — the
|
||||||
|
// call site becomes `return Extract(limit);` and NO extra return is
|
||||||
|
// appended to the new body (the existing one is kept).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Summarize_full_body_selection_becomes_return_call()
|
||||||
|
{
|
||||||
|
var (method, calls, tree) = Generate(68, 76);
|
||||||
|
|
||||||
|
Assert.Equal("Extract", method.Identifier.ValueText);
|
||||||
|
Assert.Equal("string", method.ReturnType.ToString());
|
||||||
|
Assert.Equal(new[] { "int limit" }, method.ParameterList.Parameters.Select(p => p.ToString()));
|
||||||
|
|
||||||
|
var body = (BlockSyntax)method.Body!;
|
||||||
|
Assert.Equal(5, body.Statements.Count); // total, for, scaled, message, return — unchanged
|
||||||
|
Assert.Equal("return message;", body.Statements[^1].ToString());
|
||||||
|
|
||||||
|
Assert.Single(calls);
|
||||||
|
Assert.Equal("return Extract(limit);", calls[0].ToString());
|
||||||
|
|
||||||
|
VerifyZeroDiagnostics(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Heaviest full body (87..93): the trailing `return best;` names the
|
||||||
|
// single return slot, which matches the enclosing method's return type —
|
||||||
|
// again a `return Extract(...)` call site, with the widgets/count
|
||||||
|
// parameters as plain in-params.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Heaviest_full_body_selection_becomes_return_call()
|
||||||
|
{
|
||||||
|
var (method, calls, tree) = Generate(87, 93);
|
||||||
|
|
||||||
|
Assert.Equal("Widget", method.ReturnType.ToString());
|
||||||
|
Assert.Equal(new[] { "int count", "List<Widget> widgets" },
|
||||||
|
method.ParameterList.Parameters.Select(p => p.ToString()));
|
||||||
|
|
||||||
|
var body = (BlockSyntax)method.Body!;
|
||||||
|
Assert.Equal(3, body.Statements.Count);
|
||||||
|
Assert.Equal("return best;", body.Statements[^1].ToString());
|
||||||
|
|
||||||
|
Assert.Single(calls);
|
||||||
|
Assert.Equal("return Extract(count, widgets);", calls[0].ToString());
|
||||||
|
|
||||||
|
VerifyZeroDiagnostics(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// ScoreReads (56..58): the enclosing method returns int but the selection
|
||||||
|
// ends with the COMPOSITE `return score + bonus;` — not nameable in v1,
|
||||||
|
// so the promoted signature is void and codegen must REFUSE (the report
|
||||||
|
// note already says: extract a local first, then re-run).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void ScoreReads_refuses_when_the_composite_trailing_return_is_not_nameable()
|
||||||
|
{
|
||||||
|
var (tree, compilation) = DemoFixture.Load();
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
|
||||||
|
var resolved = SelectionResolver.Resolve(tree, 56, 58);
|
||||||
|
Assert.True(resolved.Succeeded, resolved.Error);
|
||||||
|
|
||||||
|
var suggestion = DataFlowClassifier.Classify(model, resolved);
|
||||||
|
var signature = SignatureBuilder.Build(model, resolved, suggestion);
|
||||||
|
Assert.Equal("void", signature.ReturnType);
|
||||||
|
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(
|
||||||
|
() => RefactoringGenerator.Generate(model, resolved, signature));
|
||||||
|
Assert.Contains("extract it into a local first", ex.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Accumulate for-loop (127..130): `acc` is declared BEFORE the selection,
|
||||||
|
// written inside and read after — the promoted signature keeps it as a
|
||||||
|
// ref parameter (the write-back flows the value out; void return, no
|
||||||
|
// return slot) and the call site is a plain call with `ref acc`.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Accumulate_forloop_selection_calls_with_ref_write_back()
|
||||||
|
{
|
||||||
|
var (method, calls, tree) = Generate(127, 130);
|
||||||
|
|
||||||
|
Assert.Equal("void", method.ReturnType.ToString());
|
||||||
|
Assert.Equal(new[] { "ref int acc", "int n" },
|
||||||
|
method.ParameterList.Parameters.Select(p => p.ToString()));
|
||||||
|
|
||||||
|
var body = (BlockSyntax)method.Body!;
|
||||||
|
Assert.Single(body.Statements); // the for-loop; no return appended (void)
|
||||||
|
|
||||||
|
Assert.Single(calls);
|
||||||
|
Assert.Equal("Extract(ref acc, n);", calls[0].ToString());
|
||||||
|
|
||||||
|
VerifyZeroDiagnostics(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Pairwise (141..147): two declared-inside locals both written inside and
|
||||||
|
// read after — the promoted signature returns the tuple `(int, int)` and
|
||||||
|
// the call site deconstructs it; the new body gains `return (x, y);`.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Pairwise_selection_generates_tuple_return_and_deconstruction_call()
|
||||||
|
{
|
||||||
|
var (method, calls, tree) = Generate(141, 147);
|
||||||
|
|
||||||
|
Assert.Equal("(int, int)", method.ReturnType.ToString());
|
||||||
|
Assert.Equal(new[] { "int n" }, method.ParameterList.Parameters.Select(p => p.ToString()));
|
||||||
|
|
||||||
|
var body = (BlockSyntax)method.Body!;
|
||||||
|
Assert.Equal(4, body.Statements.Count); // x, y, for-loop, appended tuple return
|
||||||
|
Assert.Equal("return (x, y);", body.Statements[^1].ToString());
|
||||||
|
|
||||||
|
Assert.Single(calls);
|
||||||
|
Assert.Equal("var (x, y) = Extract(n);", calls[0].ToString());
|
||||||
|
|
||||||
|
VerifyZeroDiagnostics(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// The unified diff, pinned exactly for a minimal edit (hunk header,
|
||||||
|
// context, deletion before insertion).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void UnifiedDiff_pins_the_hunk_format()
|
||||||
|
{
|
||||||
|
var diff = UnifiedDiff.Diff(
|
||||||
|
"a\nb\nc\n", "a\nX\nc\nd\n", "Demo.cs", "Demo.cs (generated)");
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"--- Demo.cs\n" +
|
||||||
|
"+++ Demo.cs (generated)\n" +
|
||||||
|
"@@ -1,3 +1,4 @@\n" +
|
||||||
|
" a\n" +
|
||||||
|
"-b\n" +
|
||||||
|
"+X\n" +
|
||||||
|
" c\n" +
|
||||||
|
"+d\n",
|
||||||
|
diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Identical texts produce a header with no hunks.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void UnifiedDiff_of_identical_texts_is_header_only()
|
||||||
|
{
|
||||||
|
Assert.Equal("--- a\n+++ b\n", UnifiedDiff.Diff("same\n", "same\n", "a", "b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static (MethodDeclarationSyntax Method, List<StatementSyntax> Calls, SyntaxTree Tree)
|
||||||
|
Generate(int startLine, int endLine)
|
||||||
|
{
|
||||||
|
var (tree, compilation) = DemoFixture.Load();
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
|
||||||
|
var resolved = SelectionResolver.Resolve(tree, startLine, endLine);
|
||||||
|
Assert.True(resolved.Succeeded, resolved.Error);
|
||||||
|
|
||||||
|
var suggestion = DataFlowClassifier.Classify(model, resolved);
|
||||||
|
var signature = SignatureBuilder.Build(model, resolved, suggestion);
|
||||||
|
|
||||||
|
var refactoring = RefactoringGenerator.Generate(model, resolved, signature);
|
||||||
|
return (refactoring.NewMethod, refactoring.CallStatements.ToList(), refactoring.TransformedTree);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The yak's round-trip check: the transformed tree must compile with
|
||||||
|
/// ZERO diagnostics under the same scratch compilation the input file
|
||||||
|
/// compiles under.
|
||||||
|
/// </summary>
|
||||||
|
private static void VerifyZeroDiagnostics(SyntaxTree tree)
|
||||||
|
{
|
||||||
|
var compilation = CompilationLoader.CreateCompilation(tree, "DemoFixture");
|
||||||
|
var diagnostics = compilation.GetDiagnostics().ToList();
|
||||||
|
Assert.True(diagnostics.Count == 0, string.Join("\n", diagnostics));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using ExtractMethod.Tooling;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace BeforeAfter.Tests.ExtractMethod;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for yak em 03: the signature promotion (SignatureBuilder) and the
|
||||||
|
/// formatted report (ReportFormatter) over the checked-in Demo.cs fixture.
|
||||||
|
/// Line anchors are the exact fixture lines pinned by em 01/02: ScoreReads
|
||||||
|
/// body 56..58, Summarize 68..76 (partial selection 68..72), Heaviest 87..93.
|
||||||
|
/// </summary>
|
||||||
|
public class ReportTests
|
||||||
|
{
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Signature promotion, the case em 02's comment promises: in Summarize
|
||||||
|
// lines 68..72, `total` sits in params[ref] AND returns (declared inside,
|
||||||
|
// reassigned inside, read after), and `i` sits in params AND locals (the
|
||||||
|
// for-header declares it inside). Promotion: both drop out of the params,
|
||||||
|
// total becomes the plain return value -> `int Extract(int limit)`.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Signature_for_Summarize_selection_promotes_declared_inside_variables()
|
||||||
|
{
|
||||||
|
var (report, formatted) = Compose(68, 72);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "limit" }, report.Signature.Params.Select(p => p.Name));
|
||||||
|
Assert.Equal("int", report.Signature.ReturnType);
|
||||||
|
Assert.Contains("suggested signature: int Extract(int limit)", formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Heaviest full body: `best` (declared inside, ref in the raw params,
|
||||||
|
// trailing return candidate) promotes to the return; the for-var `i`
|
||||||
|
// drops from the params; widgets/count stay as plain in-params.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Signature_for_Heaviest_returns_the_trailing_candidate_and_drops_declared_inside_params()
|
||||||
|
{
|
||||||
|
var (_, formatted) = Compose(87, 93);
|
||||||
|
|
||||||
|
// Parameter order follows the classifier's ordinal-by-name order —
|
||||||
|
// the same order the params bucket above prints (em 02 determinism).
|
||||||
|
Assert.Contains("suggested signature: Widget Extract(int count, List<Widget> widgets)", formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// No nameable return -> void (ScoreReads ends with `return score + bonus;`,
|
||||||
|
// a composite expression v1 cannot name — the report notes this); locals
|
||||||
|
// declared inside (seed, score) drop from the params; only the enclosing
|
||||||
|
// method's own parameter survives.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Signature_for_ScoreReads_is_void_and_carries_only_the_enclosing_parameter()
|
||||||
|
{
|
||||||
|
var (report, formatted) = Compose(56, 58);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "bonus" }, report.Signature.Params.Select(p => p.Name));
|
||||||
|
Assert.Equal("void", report.Signature.ReturnType);
|
||||||
|
Assert.Contains("suggested signature: void Extract(int bonus)", formatted);
|
||||||
|
|
||||||
|
// The void-ness has an explanation in the report: the composite
|
||||||
|
// trailing return is not nameable in v1.
|
||||||
|
Assert.Contains(report.Notes, n => n.Contains("composite return expression (score + bonus)", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// The report the CLI prints, pinned EXACTLY for one fixture selection
|
||||||
|
// (acceptance: "CLI report on the fixture matches the tests"). Heaviest
|
||||||
|
// full body is the richest case: raw buckets (params include the
|
||||||
|
// declared-inside best/i — the promotion happens only in the signature),
|
||||||
|
// extract-first with the invocation flag, no notes, signature last.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
[Fact]
|
||||||
|
public void Formatted_report_for_Heaviest_matches_the_CLI_output_exactly()
|
||||||
|
{
|
||||||
|
var (_, formatted) = Compose(87, 93);
|
||||||
|
|
||||||
|
var expected =
|
||||||
|
"3 statement(s) selected, lines 87..93 in Heaviest(): LocalDeclarationStatement, ForStatement, ReturnStatement\n" +
|
||||||
|
"params: best (Widget) [ref]\n" +
|
||||||
|
"params: count (int) [in]\n" +
|
||||||
|
"params: i (int) [ref]\n" +
|
||||||
|
"params: widgets (List<Widget>) [in]\n" +
|
||||||
|
"returns: best (Widget)\n" +
|
||||||
|
"locals: i\n" +
|
||||||
|
"extract-first:\n" +
|
||||||
|
" - widgets[0] — List<Widget>.this[int] ×1\n" +
|
||||||
|
" - best.Bigger(widgets[i]) — Widget.Bigger(Widget) ×1 [hoisting changes eval count]\n" +
|
||||||
|
" - widgets[i] — List<Widget>.this[int] ×1\n" +
|
||||||
|
"suggested signature: Widget Extract(int count, List<Widget> widgets)\n";
|
||||||
|
|
||||||
|
Assert.Equal(expected, formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (ExtractionReport Report, string Formatted) Compose(int startLine, int endLine)
|
||||||
|
{
|
||||||
|
var (tree, compilation) = DemoFixture.Load();
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
|
||||||
|
var resolved = SelectionResolver.Resolve(tree, startLine, endLine);
|
||||||
|
Assert.True(resolved.Succeeded, resolved.Error);
|
||||||
|
|
||||||
|
var report = ExtractionReporter.Compose(model, resolved);
|
||||||
|
return (report, ReportFormatter.Format(report));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
using static TestData.ClientBuilder;
|
|
||||||
using static TestData.OrderBuilder;
|
|
||||||
|
|
||||||
namespace Db.Tests;
|
|
||||||
|
|
||||||
/// <summary>The fake <see cref="DbContext"/> stores data-only records, never domain objects; Save maps state into rows (insert or refresh), the graph 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.TotalInCents = 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.
|
|
||||||
|
|
||||||
[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()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tracking: explicit Save, distinct ids; a saved object keeps no reference to the context.
|
|
||||||
[Fact]
|
|
||||||
public void DbContext_saves_plain_domain_objects_and_tracks_them()
|
|
||||||
{
|
|
||||||
var db = new DbContext();
|
|
||||||
var order = ValidOrder().WithDescription("one").Build();
|
|
||||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
|
||||||
|
|
||||||
Assert.Equal(Guid.Empty, client.Id);
|
|
||||||
db.Save(client);
|
|
||||||
db.Save(order);
|
|
||||||
|
|
||||||
Assert.NotEqual(Guid.Empty, client.Id);
|
|
||||||
Assert.NotEqual(Guid.Empty, order.Id);
|
|
||||||
Assert.NotEqual(client.Id, order.Id);
|
|
||||||
Assert.True(db.IsTracked(client));
|
|
||||||
Assert.True(db.IsTracked(order));
|
|
||||||
Assert.Equal(2, db.Tracked.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Saving_twice_does_not_reassign_the_id()
|
|
||||||
{
|
|
||||||
var db = new DbContext();
|
|
||||||
var client = ValidClient().WithName("Acme").Build();
|
|
||||||
db.Save(client);
|
|
||||||
var id = client.Id;
|
|
||||||
|
|
||||||
db.Save(client);
|
|
||||||
|
|
||||||
Assert.Equal(id, client.Id);
|
|
||||||
Assert.Single(db.Tracked);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void A_saved_domain_object_keeps_no_reference_to_the_context()
|
|
||||||
{
|
|
||||||
var db = new DbContext();
|
|
||||||
var client = ValidClient().WithName("Acme").Build();
|
|
||||||
db.Save(client);
|
|
||||||
|
|
||||||
// Saved (the id was assigned by the context) ...
|
|
||||||
Assert.NotEqual(Guid.Empty, client.Id);
|
|
||||||
Assert.True(db.IsTracked(client));
|
|
||||||
|
|
||||||
// No way back to the context through the domain object (see DomainStaysPlainTests): the only "receipt" is the plain Id value.
|
|
||||||
Assert.Equal(db.Tracked.Single(t => t.Id == client.Id).Id, client.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Using Include="Xunit" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Domain is referenced explicitly because these tests use domain
|
|
||||||
types directly (it also flows in transitively through Db). -->
|
|
||||||
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
|
|
||||||
<ProjectReference Include="..\..\src\Db\Db.csproj" />
|
|
||||||
<ProjectReference Include="..\TestData\TestData.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
using System.Reflection;
|
|
||||||
|
|
||||||
namespace Db.Tests;
|
|
||||||
|
|
||||||
/// <summary>The domain stayed clean: Client/Order do NOT derive from <see cref="DbBase"/> and expose no <see cref="DbContext"/>-typed member; asserted from the persistence side, where both are visible.</summary>
|
|
||||||
public class DomainStaysPlainTests
|
|
||||||
{
|
|
||||||
private const BindingFlags AllMembers =
|
|
||||||
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
|
||||||
|
|
||||||
// The leak is gone: no DbBase in the base chain, no DbContext-typed member (reflection).
|
|
||||||
[Theory]
|
|
||||||
[InlineData(typeof(Client))]
|
|
||||||
[InlineData(typeof(Order))]
|
|
||||||
public void Domain_objects_are_plain__no_DbBase_no_DbContext_member(Type type)
|
|
||||||
{
|
|
||||||
// Not DbBase, and not a derived type either (walk the base chain):
|
|
||||||
for (var t = type; t is not null && t != typeof(object); t = t.BaseType)
|
|
||||||
Assert.NotSame(typeof(DbBase), t);
|
|
||||||
|
|
||||||
// No DbContext-typed member, public or private:
|
|
||||||
foreach (var p in type.GetProperties(AllMembers))
|
|
||||||
Assert.True(p.PropertyType != typeof(DbContext),
|
|
||||||
$"{type.Name}.{p.Name} leaks a DbContext-typed property");
|
|
||||||
foreach (var f in type.GetFields(AllMembers))
|
|
||||||
Assert.True(f.FieldType != typeof(DbContext),
|
|
||||||
$"{type.Name}.{f.Name} leaks a DbContext-typed field");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Using Include="Xunit" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
|
|
||||||
<ProjectReference Include="..\TestData\TestData.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace TestData;
|
|
||||||
|
|
||||||
/// <summary>Test data builder for a <see cref="Client"/> (TestDataBuilder pattern); <see cref="ValidClient"/> starts fresh — 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;
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
|
|
||||||
namespace TestData;
|
|
||||||
|
|
||||||
/// <summary>Test data builder for an <see cref="Order"/> (TestDataBuilder pattern); the only place tests touch an order's construction, so tests survive domain evolution.</summary>
|
|
||||||
public sealed class OrderBuilder
|
|
||||||
{
|
|
||||||
private readonly Order _order = new();
|
|
||||||
|
|
||||||
/// <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).</summary>
|
|
||||||
public OrderBuilder WithTotal(int totalInCents)
|
|
||||||
{
|
|
||||||
_order.TotalInCents = totalInCents;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Order Build() => _order;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Using Include="Xunit" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
using static TestData.ClientBuilder;
|
|
||||||
using static TestData.OrderBuilder;
|
|
||||||
|
|
||||||
namespace Web.Tests;
|
|
||||||
|
|
||||||
/// <summary>The hand-written <see cref="DtoMappers"/> map both ways, never mutate their input, and round-trip losslessly; domain objects come from the test-data builders, DTO initializers stay literal.</summary>
|
|
||||||
public class DtoMapperTests
|
|
||||||
{
|
|
||||||
// (1) Mappers map all scalar fields, in both directions.
|
|
||||||
[Fact]
|
|
||||||
public void ToClientDto_maps_all_scalar_fields()
|
|
||||||
{
|
|
||||||
var id = Guid.NewGuid();
|
|
||||||
var client = ValidClient().WithId(id).WithName("Acme").Build();
|
|
||||||
|
|
||||||
var dto = DtoMappers.ToClientDto(client);
|
|
||||||
|
|
||||||
Assert.Equal(id, dto.Id);
|
|
||||||
Assert.Equal("Acme", dto.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ToOrderDto_maps_all_scalar_fields()
|
|
||||||
{
|
|
||||||
var id = Guid.NewGuid();
|
|
||||||
var order = ValidOrder().WithId(id).WithDescription("one").WithTotal(150_00).Build();
|
|
||||||
|
|
||||||
var dto = DtoMappers.ToOrderDto(order);
|
|
||||||
|
|
||||||
Assert.Equal(id, dto.Id);
|
|
||||||
Assert.Equal("one", dto.Description);
|
|
||||||
Assert.Equal(150_00, dto.TotalInCents);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FromClientDto_maps_all_scalar_fields()
|
|
||||||
{
|
|
||||||
var id = Guid.NewGuid();
|
|
||||||
var dto = new ClientDto { Id = id, Name = "Acme" };
|
|
||||||
|
|
||||||
var client = DtoMappers.FromClientDto(dto);
|
|
||||||
|
|
||||||
Assert.Equal(id, client.Id);
|
|
||||||
Assert.Equal("Acme", client.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FromOrderDto_maps_all_scalar_fields()
|
|
||||||
{
|
|
||||||
var id = Guid.NewGuid();
|
|
||||||
var dto = new OrderDto { Id = id, Description = "one", TotalInCents = 150_00 };
|
|
||||||
|
|
||||||
var order = DtoMappers.FromOrderDto(dto);
|
|
||||||
|
|
||||||
Assert.Equal(id, order.Id);
|
|
||||||
Assert.Equal("one", order.Description);
|
|
||||||
Assert.Equal(150_00, order.TotalInCents);
|
|
||||||
}
|
|
||||||
|
|
||||||
// (2) Mappers map the relations, and the back-references stay consistent.
|
|
||||||
[Fact]
|
|
||||||
public void ToClientDto_maps_orders_and_keeps_the_back_reference_consistent()
|
|
||||||
{
|
|
||||||
var order = ValidOrder().WithDescription("one").Build();
|
|
||||||
var client = ValidClient().WithName("Acme").WithOrder(order).Build();
|
|
||||||
|
|
||||||
var dto = DtoMappers.ToClientDto(client);
|
|
||||||
var orderDto = Assert.Single(dto.Orders);
|
|
||||||
|
|
||||||
// The order is mapped with the client's data:
|
|
||||||
Assert.Equal(order.Id, orderDto.Id);
|
|
||||||
Assert.Equal(order.Description, orderDto.Description);
|
|
||||||
|
|
||||||
// The back-reference cycle is resolved to the SAME dto reference:
|
|
||||||
Assert.Same(dto, orderDto.ClientDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FromClientDto_wires_the_order_client_back_reference()
|
|
||||||
{
|
|
||||||
var orderDto = new OrderDto { Description = "one" };
|
|
||||||
var dto = new ClientDto { Name = "Acme", Orders = new[] { orderDto } };
|
|
||||||
|
|
||||||
var client = DtoMappers.FromClientDto(dto);
|
|
||||||
var order = Assert.Single(client.Orders);
|
|
||||||
|
|
||||||
Assert.Equal(orderDto.Description, order.Description);
|
|
||||||
// Navigation fix-up: the reconstructed order points at the client:
|
|
||||||
Assert.Same(client, order.Client);
|
|
||||||
}
|
|
||||||
|
|
||||||
// (3) Round-trips preserve all state (compared by values, not reference).
|
|
||||||
[Fact]
|
|
||||||
public void Domain_round_trip_preserves_all_state()
|
|
||||||
{
|
|
||||||
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 = DtoMappers.FromClientDto(DtoMappers.ToClientDto(client));
|
|
||||||
|
|
||||||
Assert.Equal(client.Id, roundTrip.Id);
|
|
||||||
Assert.Equal(client.Name, roundTrip.Name);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Dto_round_trip_preserves_all_state()
|
|
||||||
{
|
|
||||||
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 = DtoMappers.ToClientDto(client);
|
|
||||||
var dtoOrder = dto.Orders[0];
|
|
||||||
|
|
||||||
var roundTrip = DtoMappers.ToClientDto(DtoMappers.FromClientDto(dto));
|
|
||||||
|
|
||||||
Assert.Equal(dto.Id, roundTrip.Id);
|
|
||||||
Assert.Equal(dto.Name, roundTrip.Name);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// (4) Mappers do not mutate their inputs (snapshot fields, map, compare).
|
|
||||||
[Fact]
|
|
||||||
public void ToClientDto_does_not_mutate_the_domain()
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
|
|
||||||
DtoMappers.ToClientDto(client);
|
|
||||||
DtoMappers.ToOrderDto(order);
|
|
||||||
|
|
||||||
Assert.Equal("Acme", client.Name);
|
|
||||||
Assert.Equal(ordersCount, client.Orders.Count);
|
|
||||||
Assert.Same(client, order.Client); // back-reference untouched
|
|
||||||
Assert.Equal("one", order.Description);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void FromClientDto_does_not_mutate_the_dto()
|
|
||||||
{
|
|
||||||
var orderDto = new OrderDto { Description = "one" };
|
|
||||||
var dto = new ClientDto { Id = Guid.NewGuid(), Name = "Acme", Orders = new[] { orderDto } };
|
|
||||||
|
|
||||||
DtoMappers.FromClientDto(dto);
|
|
||||||
DtoMappers.FromOrderDto(orderDto);
|
|
||||||
|
|
||||||
Assert.Equal("Acme", dto.Name);
|
|
||||||
Assert.Same(orderDto, Assert.Single(dto.Orders)); // collection untouched
|
|
||||||
Assert.Null(orderDto.ClientDto); // back-reference untouched
|
|
||||||
Assert.Equal("one", orderDto.Description);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Using Include="Xunit" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Domain is referenced explicitly because these tests use domain
|
|
||||||
types directly (it also flows in transitively through Web).
|
|
||||||
Deliberately NO reference to Db — mirroring the Web layer. -->
|
|
||||||
<ProjectReference Include="..\..\src\Domain\Domain.csproj" />
|
|
||||||
<ProjectReference Include="..\..\src\Web\Web.csproj" />
|
|
||||||
<ProjectReference Include="..\TestData\TestData.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
using Domain;
|
|
||||||
using System.Reflection;
|
|
||||||
using static TestData.ClientBuilder;
|
|
||||||
using static TestData.OrderBuilder;
|
|
||||||
|
|
||||||
namespace Web.Tests;
|
|
||||||
|
|
||||||
/// <summary>The WebApp consumes only DTOs; persistence types are excluded at compile time (no Web → Db project reference).</summary>
|
|
||||||
public class WebAppTests
|
|
||||||
{
|
|
||||||
// The WebApp's public API references no domain type (reflection).
|
|
||||||
private static readonly Type[] BannedTypes = [typeof(Client), typeof(Order)];
|
|
||||||
|
|
||||||
private static IEnumerable<Type> TypeAndArguments(Type t)
|
|
||||||
{
|
|
||||||
yield return t;
|
|
||||||
if (t.IsGenericType)
|
|
||||||
foreach (var arg in t.GetGenericArguments())
|
|
||||||
foreach (var nested in TypeAndArguments(arg))
|
|
||||||
yield return nested;
|
|
||||||
if (t.IsArray && t.GetElementType() is { } elem)
|
|
||||||
foreach (var nested in TypeAndArguments(elem))
|
|
||||||
yield return nested;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void WebApp_public_api_references_no_domain_types()
|
|
||||||
{
|
|
||||||
var app = typeof(WebApp);
|
|
||||||
var banned = BannedTypes.ToList();
|
|
||||||
|
|
||||||
foreach (var m in app.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
|
|
||||||
{
|
|
||||||
var signature = m.GetParameters()
|
|
||||||
.Select(p => p.ParameterType)
|
|
||||||
.Append(m.ReturnType)
|
|
||||||
.ToList();
|
|
||||||
foreach (var t in signature.SelectMany(TypeAndArguments))
|
|
||||||
Assert.False(banned.Contains(t),
|
|
||||||
$"WebApp.{m.Name}({string.Join(", ", signature)}) references banned type {t}");
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var p in app.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
|
|
||||||
foreach (var t in TypeAndArguments(p.PropertyType))
|
|
||||||
Assert.False(banned.Contains(t),
|
|
||||||
$"WebApp.{p.Name} references banned type {t}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void WebApp_consumes_dtos()
|
|
||||||
{
|
|
||||||
var app = new WebApp();
|
|
||||||
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(DtoMappers.ToClientDto(client));
|
|
||||||
|
|
||||||
Assert.Contains("Acme", view);
|
|
||||||
Assert.Contains("order one", view);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>ExtractMethod</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!--
|
||||||
|
Roslyn IS the tool: we parse C# source, build a scratch compilation and
|
||||||
|
ask the semantic model for data flow. Pinned to the latest stable on
|
||||||
|
nuget.org (5.9.0, Roslyn's versioning now tracks the VS release train).
|
||||||
|
-->
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using ExtractMethod.Tooling;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
|
||||||
|
// CLIs are boring on purpose: argument parsing and printing live here, all
|
||||||
|
// Roslyn logic lives in Tooling/ so the tests can drive it directly.
|
||||||
|
|
||||||
|
string usage = "usage: ExtractMethod <file.cs> <startLine> <endLine> (1-based, inclusive)";
|
||||||
|
|
||||||
|
if (args.Length != 3)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(usage);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
string file = Path.GetFullPath(args[0]);
|
||||||
|
if (!File.Exists(file))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"error: file not found: {file}");
|
||||||
|
Console.Error.WriteLine(usage);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!int.TryParse(args[1], out int startLine) || !int.TryParse(args[2], out int endLine))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"error: line numbers must be integers");
|
||||||
|
Console.Error.WriteLine(usage);
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. parse the file
|
||||||
|
var tree = CompilationLoader.ParseFile(file);
|
||||||
|
|
||||||
|
// 2. build the scratch compilation (refs from TRUSTED_PLATFORM_ASSEMBLIES)
|
||||||
|
var compilation = CompilationLoader.CreateCompilation(tree, Path.GetFileNameWithoutExtension(file));
|
||||||
|
if (compilation.GetDiagnostics().Any(d => d.Severity == DiagnosticSeverity.Error))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine("warning: the file does not compile cleanly under a plain Roslyn compilation; reporting syntax-level resolution only");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. snap the range to whole statements, report cleanly otherwise
|
||||||
|
var resolved = SelectionResolver.Resolve(tree, startLine, endLine);
|
||||||
|
if (!resolved.Succeeded)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"error: {resolved.Error}");
|
||||||
|
return SelectionResolver.ExitError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. compose the full suggestion (em 02 buckets + em 03 extract-first and
|
||||||
|
// signature), generate the refactoring (em 04), verify it and print the
|
||||||
|
// diff. Any failure is a clean error: message on stderr, same exit code
|
||||||
|
// as the resolver, never a stack trace.
|
||||||
|
var model = compilation.GetSemanticModel(tree);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var extraction = ExtractionReporter.Compose(model, resolved);
|
||||||
|
Console.Write(ReportFormatter.Format(extraction));
|
||||||
|
|
||||||
|
// 5. generate the refactoring from the suggestion (em 04 codegen): new
|
||||||
|
// method + rewritten call site + transformed tree.
|
||||||
|
var refactoring = RefactoringGenerator.Generate(model, resolved, extraction.Signature);
|
||||||
|
|
||||||
|
// 6. round-trip check: the generated code must compile with zero
|
||||||
|
// diagnostics (same scratch-compilation path as the input file).
|
||||||
|
var transformedCompilation = CompilationLoader.CreateCompilation(
|
||||||
|
refactoring.TransformedTree, Path.GetFileNameWithoutExtension(file));
|
||||||
|
var diagnostics = transformedCompilation.GetDiagnostics().ToList();
|
||||||
|
if (diagnostics.Count > 0)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
$"error: round-trip check failed — the generated code has {diagnostics.Count} diagnostic(s):");
|
||||||
|
foreach (var diagnostic in diagnostics)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($" {diagnostic}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.Error.WriteLine("---- generated code ----");
|
||||||
|
Console.Error.Write(refactoring.TransformedText);
|
||||||
|
return SelectionResolver.ExitError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. v1 decision (em 04): print a unified diff to stdout — PREVIEW, never
|
||||||
|
// an in-place file rewrite. Applying the diff (or a future --write
|
||||||
|
// flag with backup semantics) is a deliberate next step, because
|
||||||
|
// mutating the caller's file is a different safety policy than
|
||||||
|
// generating code.
|
||||||
|
Console.Write(UnifiedDiff.Diff(
|
||||||
|
tree.ToString(),
|
||||||
|
refactoring.TransformedText,
|
||||||
|
Path.GetFileName(file),
|
||||||
|
Path.GetFileName(file) + " (generated)"));
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is InvalidOperationException or ArgumentException)
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"error: {e.Message}");
|
||||||
|
return SelectionResolver.ExitError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns a plain .cs file into a Roslyn <see cref="CSharpCompilation"/> that a
|
||||||
|
/// semantic model can be queried against.
|
||||||
|
///
|
||||||
|
/// Shared by the CLI (Program.cs) and the tests so both exercise the same
|
||||||
|
/// loading path — the tests should not re-implement this, or they would be
|
||||||
|
/// testing their own assumptions instead of the tool.
|
||||||
|
/// </summary>
|
||||||
|
public static class CompilationLoader
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parse a file into a <see cref="SyntaxTree"/> using the latest language
|
||||||
|
/// version. Nothing here is project-specific: no .sln, no .csproj, no
|
||||||
|
/// generated files — the tool is a micro-tool that runs on a single file.
|
||||||
|
/// </summary>
|
||||||
|
public static SyntaxTree ParseFile(string path)
|
||||||
|
{
|
||||||
|
var text = File.ReadAllText(Path.GetFullPath(path));
|
||||||
|
return CSharpSyntaxTree.ParseText(
|
||||||
|
text,
|
||||||
|
path: path,
|
||||||
|
options: CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a scratch compilation over <paramref name="tree"/>.
|
||||||
|
///
|
||||||
|
/// WHY <c>TRUSTED_PLATFORM_ASSEMBLIES</c>: it is the runtime-resolved list
|
||||||
|
/// of the actual BCL assemblies this process (net10.0) runs on, e.g.
|
||||||
|
/// System.Private.CoreLib, System.Runtime, System.Console. Referencing
|
||||||
|
/// them means an arbitrary fixture can bind List<T>, string, and the
|
||||||
|
/// rest of the BCL without any NuGet/EF/SQLite haul. (A single
|
||||||
|
/// typeof(object) reference is NOT enough on modern .NET — that only pulls
|
||||||
|
/// in the core library, and Console/GC/enums etc. fail to bind.)
|
||||||
|
/// </summary>
|
||||||
|
public static CSharpCompilation CreateCompilation(SyntaxTree tree, string assemblyName)
|
||||||
|
{
|
||||||
|
var refs = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
|
||||||
|
.Split(Path.PathSeparator)
|
||||||
|
.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p));
|
||||||
|
|
||||||
|
return CSharpCompilation.Create(
|
||||||
|
assemblyName,
|
||||||
|
new[] { tree },
|
||||||
|
refs,
|
||||||
|
new CSharpCompilationOptions(
|
||||||
|
OutputKind.DynamicallyLinkedLibrary,
|
||||||
|
// Match the repo's net10.0 projects so the fixture's nullable
|
||||||
|
// annotations compile without warnings.
|
||||||
|
nullableContextOptions: NullableContextOptions.Enable));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Yak em 02: data-flow classification. Turns a resolved selection into the
|
||||||
|
// three buckets the extract-method report is built from (params / returns /
|
||||||
|
// locals), using Roslyn's DataFlowAnalysis. Names and types are plain strings
|
||||||
|
// for v1; the records are the stable contract em 03 (extract-first + report)
|
||||||
|
// and em 04 (codegen) build on.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>One suggested parameter of the extracted method (v1: plain strings).</summary>
|
||||||
|
/// <param name="Name">Variable name as it appears in the source.</param>
|
||||||
|
/// <param name="Type">Type as a display string (e.g. <c>int</c>, <c>string</c>).</param>
|
||||||
|
/// <param name="ByRef">True when the extraction must take the variable by <c>ref</c>
|
||||||
|
/// (write-back required); see <see cref="DataFlowClassifier.IsReassignedInside"/> for
|
||||||
|
/// the precise v1 rule.</param>
|
||||||
|
public sealed record ParamSuggestion(string Name, string Type, bool ByRef);
|
||||||
|
|
||||||
|
/// <summary>One return candidate of the extracted method (v1: plain strings).</summary>
|
||||||
|
/// <param name="Name">Variable name as it appears in the source.</param>
|
||||||
|
/// <param name="Type">Type as a display string — the signature line of em 03
|
||||||
|
/// prints it as the extracted method's return type.</param>
|
||||||
|
public sealed record ReturnSuggestion(string Name, string Type);
|
||||||
|
|
||||||
|
/// <summary>The classified buckets of one selection (v1: symbol names as plain strings).</summary>
|
||||||
|
/// <param name="Params">Variables the selection READS and the new method therefore
|
||||||
|
/// receives: read locals + the enclosing method's parameters (parent decision #6).
|
||||||
|
/// A variable may also appear in <see cref="Locals"/> and/or <see cref="Returns"/> —
|
||||||
|
/// v1 reports per-bucket; em 03's signature builder dedupes into a coherent
|
||||||
|
/// signature (a variable DECLARED inside the selection cannot be a parameter).</param>
|
||||||
|
/// <param name="Returns">Variables written inside AND read after the selection
|
||||||
|
/// (tail data flow), plus the value of a trailing <c>return X;</c> when X is a
|
||||||
|
/// simple name — they must flow out of the extraction. Typed since em 03: the
|
||||||
|
/// signature line needs the return type, not just the name.</param>
|
||||||
|
/// <param name="Locals">Variables written inside and never read after: scratch
|
||||||
|
/// locals of the new method.</param>
|
||||||
|
public sealed record ExtractionSuggestion(
|
||||||
|
IReadOnlyList<ParamSuggestion> Params,
|
||||||
|
IReadOnlyList<ReturnSuggestion> Returns,
|
||||||
|
IReadOnlyList<string> Locals);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Buckets a <see cref="SelectionReport"/> via Roslyn data-flow analysis.
|
||||||
|
///
|
||||||
|
/// Two regions are analyzed, in the SAME tree and compilation (so symbols keep
|
||||||
|
/// their identity and <see cref="SymbolEqualityComparer"/> matches them across
|
||||||
|
/// regions):
|
||||||
|
/// 1. the selection itself (parent spec: ReadInside / WrittenInside /
|
||||||
|
/// WrittenOutside),
|
||||||
|
/// 2. the TAIL: the statements AFTER the selection in the same enclosing
|
||||||
|
/// block — its ReadInside is the "read after" set (parent decision #4;
|
||||||
|
/// branch-insensitive over-approximation, see <see cref="Classify"/>).
|
||||||
|
///
|
||||||
|
/// WHY the two-argument overload and not a synthetic BlockSyntax (parent
|
||||||
|
/// decision #3 was "spike it"): the em 01 spike proved a synthetic block is
|
||||||
|
/// NOT part of the tree, so <c>AnalyzeDataFlow(block)</c> throws
|
||||||
|
/// <c>ArgumentException: statements not within tree</c>. Making it work would
|
||||||
|
/// mean re-parsing the method body, which RE-BINDS every symbol — the tail's
|
||||||
|
/// symbols would no longer equal the selection's, and the
|
||||||
|
/// written-inside ∩ read-after intersection (the return bucket) would break.
|
||||||
|
/// The two-argument overload analyzes a contiguous run in one statement list,
|
||||||
|
/// and both endpoints ARE tree nodes — no re-parse, identity preserved. It is
|
||||||
|
/// the LIVE path; the synthetic block is the DEAD path (pinned in tests).
|
||||||
|
/// </summary>
|
||||||
|
public static class DataFlowClassifier
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Data flow of the selected statements themselves.
|
||||||
|
/// Single statement → the one-argument overload; many contiguous
|
||||||
|
/// statements → <see cref="SemanticModel.AnalyzeDataFlow(SyntaxNode, SyntaxNode)"/>.
|
||||||
|
/// The resolver guarantees the selection is a contiguous run of whole
|
||||||
|
/// statements in one statement list, which is exactly that overload's
|
||||||
|
/// contract (both endpoints in the tree, same parent list).
|
||||||
|
/// </summary>
|
||||||
|
public static DataFlowAnalysis AnalyzeSelectionFlow(SemanticModel model, SelectionReport selection)
|
||||||
|
=> AnalyzeRegion(model, selection.Statements);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data flow of the statements AFTER the selection in the same enclosing
|
||||||
|
/// block (parent decision #4's "tail flow"). Returns null when the
|
||||||
|
/// selection already reaches the end of the block (nothing to read after).
|
||||||
|
/// The tail is again a contiguous run in one statement list, so the same
|
||||||
|
/// two-argument overload applies — no synthetic block needed, and symbol
|
||||||
|
/// identity with the selection analysis is preserved (see class comment).
|
||||||
|
/// </summary>
|
||||||
|
public static DataFlowAnalysis? AnalyzeTailFlow(SemanticModel model, SelectionReport selection)
|
||||||
|
{
|
||||||
|
var body = selection.Method?.Body;
|
||||||
|
if (body is null)
|
||||||
|
{
|
||||||
|
return null; // resolution succeeded but had no body: nothing to do (defensive)
|
||||||
|
}
|
||||||
|
|
||||||
|
var all = body.Statements;
|
||||||
|
int lastIndex = all.IndexOf(selection.Statements[^1]); // reference identity: same tree
|
||||||
|
int tailCount = all.Count - lastIndex - 1;
|
||||||
|
if (tailCount == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnalyzeRegion(model, all.Skip(lastIndex + 1).Take(tailCount).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The three report buckets for a resolved selection.
|
||||||
|
///
|
||||||
|
/// RULES (parent spec):
|
||||||
|
/// - params: every variable READ inside that is a local or the enclosing
|
||||||
|
/// method's parameter (decision #6 keeps fields/properties/statics out —
|
||||||
|
/// they are the extract-first bucket of em 03). The implicit `this`
|
||||||
|
/// parameter is filtered out: it is the instance, not a passable value.
|
||||||
|
/// - returns: written inside AND read after the selection (tail flow),
|
||||||
|
/// plus the value of a trailing `return X;` when X is a simple name.
|
||||||
|
/// - locals: written inside and never read after → scratch locals.
|
||||||
|
///
|
||||||
|
/// OVER-APPROXIMATION (decision #4, kept for v1, comment is the contract):
|
||||||
|
/// "read after" is ReadInside of the whole tail, branch-insensitively. We
|
||||||
|
/// do NOT track whether the write from inside the selection actually
|
||||||
|
/// reaches each tail read (e.g. the variable could be overwritten in the
|
||||||
|
/// tail before its next read). Consequences: some variables are reported
|
||||||
|
/// as returns that a precise analysis would classify as locals. Accepted.
|
||||||
|
///
|
||||||
|
/// Overlap between buckets is a v1 artifact of the parent spec, not a bug:
|
||||||
|
/// a variable that is read, declared and reassigned inside (e.g. `total`)
|
||||||
|
/// legitimately lands in params (by-ref: a write-back is owed) AND in
|
||||||
|
/// locals/returns. em 03 promotes such variables into the signature.
|
||||||
|
/// </summary>
|
||||||
|
public static ExtractionSuggestion Classify(SemanticModel model, SelectionReport selection)
|
||||||
|
{
|
||||||
|
var selectionFlow = AnalyzeSelectionFlow(model, selection);
|
||||||
|
if (!selectionFlow.Succeeded)
|
||||||
|
{
|
||||||
|
// Bindable code that Roslyn cannot analyze means a tool bug or an
|
||||||
|
// unhandled file shape; be loud instead of silently producing an
|
||||||
|
// empty classification.
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"data-flow analysis of the selection failed to bind (Succeeded == false)");
|
||||||
|
}
|
||||||
|
|
||||||
|
var readInside = selectionFlow.ReadInside;
|
||||||
|
var writtenInside = selectionFlow.WrittenInside;
|
||||||
|
// WrittenOutside is not needed for the v1 buckets (a variable written
|
||||||
|
// outside the selection and read inside is already a by-value in-param;
|
||||||
|
// one written inside and outside is caught by IsReassignedInside).
|
||||||
|
// It is still computed/exposed via AnalyzeSelectionFlow — the sets are
|
||||||
|
// the curriculum — but unused here by design.
|
||||||
|
|
||||||
|
// ReadInside is ImmutableArray (not a set): fine for Contains lookups.
|
||||||
|
var readAfter = AnalyzeTailFlow(model, selection)?.ReadInside ?? ImmutableArray<ISymbol>.Empty;
|
||||||
|
|
||||||
|
// ---- returns: written inside ∧ read after, plus trailing return X ----
|
||||||
|
// Typed since em 03 (the signature line needs the return type). The
|
||||||
|
// symbols come straight from the flow sets, so types are exact; the
|
||||||
|
// trailing-return candidate resolves its own identifier below.
|
||||||
|
var returnSymbols = writtenInside
|
||||||
|
.Where(v => readAfter.Contains(v))
|
||||||
|
.ToDictionary(v => v.Name, VariableTypeString, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var trailingReturn = TrailingReturnSuggestion(model, selection);
|
||||||
|
if (trailingReturn is { } candidate)
|
||||||
|
{
|
||||||
|
returnSymbols.TryAdd(candidate.Name, candidate.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
var returnSuggestions = returnSymbols
|
||||||
|
.Select(kv => new ReturnSuggestion(kv.Key, kv.Value))
|
||||||
|
.OrderBy(r => r.Name, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// ---- locals: written inside, never read after -> scratch locals ----
|
||||||
|
var localNames = writtenInside
|
||||||
|
.Where(v => !returnSymbols.ContainsKey(v.Name))
|
||||||
|
.Select(v => v.Name)
|
||||||
|
.OrderBy(n => n, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// ---- params: reads that are locals / enclosing-method parameters ----
|
||||||
|
var paramSuggestions = readInside
|
||||||
|
.Where(v => v is ILocalSymbol or IParameterSymbol)
|
||||||
|
.Where(v => v is not IParameterSymbol { IsThis: true }) // `this` is the instance, not a value
|
||||||
|
.OrderBy(v => v.Name, StringComparer.Ordinal)
|
||||||
|
.Select(v => new ParamSuggestion(
|
||||||
|
v.Name,
|
||||||
|
VariableTypeString(v),
|
||||||
|
IsReassignedInside(model, v, selection.Statements)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new ExtractionSuggestion(paramSuggestions, returnSuggestions, localNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Display string of a variable's type. ISymbol has no Type member; only
|
||||||
|
/// locals and parameters carry one. Used for the params bucket AND (since
|
||||||
|
/// em 03) the returns bucket — the signature line prints it.
|
||||||
|
/// </summary>
|
||||||
|
private static string VariableTypeString(ISymbol variable) => variable switch
|
||||||
|
{
|
||||||
|
ILocalSymbol local => local.Type?.ToDisplayString(Displays.TypeFormat) ?? "unknown",
|
||||||
|
IParameterSymbol parameter => parameter.Type?.ToDisplayString(Displays.TypeFormat) ?? "unknown",
|
||||||
|
_ => "unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes a contiguous run of statements (one or many) in one statement
|
||||||
|
/// list. One statement → the one-argument overload; several → the two-
|
||||||
|
/// argument overload. Both endpoints are real tree nodes; a synthetic
|
||||||
|
/// block would throw "statements not within tree" (see class comment).
|
||||||
|
/// </summary>
|
||||||
|
private static DataFlowAnalysis AnalyzeRegion(SemanticModel model, IReadOnlyList<StatementSyntax> statements)
|
||||||
|
{
|
||||||
|
// Both overloads are nullable-annotated in Roslyn 5.x; null here means
|
||||||
|
// the region could not be bound, which the caller treats like
|
||||||
|
// Succeeded == false (loud failure, never a silent empty result).
|
||||||
|
DataFlowAnalysis? flow = statements.Count == 1
|
||||||
|
? model.AnalyzeDataFlow(statements[0])
|
||||||
|
: model.AnalyzeDataFlow(statements[0], statements[^1]);
|
||||||
|
return flow ?? throw new InvalidOperationException(
|
||||||
|
$"data-flow analysis returned null for a {statements.Count}-statement region (unable to bind)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ByRef flag (parent spec: "mark ref if also written inside").
|
||||||
|
///
|
||||||
|
/// REFINEMENT, with the WHY: a variable's own declaration write (its
|
||||||
|
/// initializer) must NOT force a ref — the extraction declares the
|
||||||
|
/// variable itself and the caller never owed a write-back. Only a
|
||||||
|
/// REASSIGNMENT inside the selection (assignment, compound assignment,
|
||||||
|
/// ++/--, ref/out argument) represents a value written back to a variable
|
||||||
|
/// the caller already owns, which is what demands by-ref. Without this
|
||||||
|
/// refinement, every local declared-and-read inside (ScoreReads' seed and
|
||||||
|
/// score, per the note-6 fixture map) would be wrongly flagged ref.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsReassignedInside(SemanticModel model, ISymbol variable, IReadOnlyList<StatementSyntax> statements)
|
||||||
|
{
|
||||||
|
foreach (var statement in statements)
|
||||||
|
{
|
||||||
|
foreach (var node in statement.DescendantNodesAndSelf())
|
||||||
|
{
|
||||||
|
switch (node)
|
||||||
|
{
|
||||||
|
// `x = ...`, `x += ...`, `x ??= ...`, `x ?? y` is read, etc.:
|
||||||
|
// the LHS of any AssignmentExpression is a write position.
|
||||||
|
case AssignmentExpressionSyntax a when RefersTo(a.Left, model, variable):
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// `x++`, `++x`, `x--`, `--x` (read-modify-write). The
|
||||||
|
// unary-expression Kind distinguishes inc/dec from other
|
||||||
|
// unary operators (e.g. `-x`, `!x` are reads only).
|
||||||
|
case PostfixUnaryExpressionSyntax p
|
||||||
|
when p.Kind() is SyntaxKind.PostIncrementExpression or SyntaxKind.PostDecrementExpression
|
||||||
|
&& RefersTo(p.Operand, model, variable):
|
||||||
|
case PrefixUnaryExpressionSyntax q
|
||||||
|
when q.Kind() is SyntaxKind.PreIncrementExpression or SyntaxKind.PreDecrementExpression
|
||||||
|
&& RefersTo(q.Operand, model, variable):
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// `Foo(ref x)`, `Foo(out x)`: ref/out arguments write the
|
||||||
|
// variable (out even more so).
|
||||||
|
case ArgumentSyntax arg
|
||||||
|
when !arg.RefOrOutKeyword.IsKind(SyntaxKind.None)
|
||||||
|
&& RefersTo(arg.Expression, model, variable):
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when <paramref name="expr"/> is a simple reference to <paramref name="variable"/>.</summary>
|
||||||
|
private static bool RefersTo(ExpressionSyntax expr, SemanticModel model, ISymbol variable)
|
||||||
|
=> expr is IdentifierNameSyntax
|
||||||
|
&& SymbolEqualityComparer.Default.Equals(model.GetSymbolInfo(expr).Symbol, variable);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parent spec: "if the selection ends with `return X;`, X is the candidate."
|
||||||
|
/// Returns the simple-name candidate with its type, or null when the
|
||||||
|
/// returned expression is not a simple name — an expression like
|
||||||
|
/// `score + bonus` is not nameable as a v1 string (composite return
|
||||||
|
/// expressions are extract-first shape for em 03: the report suggests
|
||||||
|
/// extracting it into a local first). Empty return ('return;') has no
|
||||||
|
/// candidate.
|
||||||
|
/// </summary>
|
||||||
|
private static ReturnSuggestion? TrailingReturnSuggestion(SemanticModel model, SelectionReport selection)
|
||||||
|
{
|
||||||
|
var last = selection.Statements[^1];
|
||||||
|
if (last is not ReturnStatementSyntax { Expression: IdentifierNameSyntax name })
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = model.GetSymbolInfo(name).Symbol switch
|
||||||
|
{
|
||||||
|
ILocalSymbol local => local.Type?.ToDisplayString(Displays.TypeFormat) ?? "unknown",
|
||||||
|
IParameterSymbol parameter => parameter.Type?.ToDisplayString(Displays.TypeFormat) ?? "unknown",
|
||||||
|
_ => "unknown",
|
||||||
|
};
|
||||||
|
return new ReturnSuggestion(name.Identifier.ValueText, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The trailing return statement when its expression is NOT a simple
|
||||||
|
/// name (and not absent) — the composite-return shape the em 03 report
|
||||||
|
/// flags as "extract a local first" (see <see cref="TrailingReturnSuggestion"/>).
|
||||||
|
/// </summary>
|
||||||
|
public static ReturnStatementSyntax? CompositeTrailingReturn(SelectionReport selection)
|
||||||
|
=> selection.Statements[^1] is ReturnStatementSyntax { Expression: not null and not IdentifierNameSyntax } ret
|
||||||
|
? ret
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Collections.Immutable;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compact symbol display for the suggestion report. The defaults are wrong
|
||||||
|
/// for a report a human reads next to their own source:
|
||||||
|
/// - <c>ToDisplayString()</c> fully qualifies every namespace
|
||||||
|
/// (<c>System.Collections.Generic.List<Widget></c>),
|
||||||
|
/// - <c>ToMinimalDisplayString()</c> keeps containing types AND prefixes
|
||||||
|
/// members with their own type (<c>int Demo._seed</c>).
|
||||||
|
/// The report wants the shortest form that stays readable in context:
|
||||||
|
/// types as bare names (<c>Widget</c>, <c>List<Widget></c>), members as
|
||||||
|
/// <c>ContainingType.Member</c> with parameter types.
|
||||||
|
/// </summary>
|
||||||
|
internal static class Displays
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Type display for suggestion lines: shortest unambiguous-enough form
|
||||||
|
/// (no namespaces, no containing types, keyword spellings for special
|
||||||
|
/// types, nullable annotations kept). Built explicitly because
|
||||||
|
/// MinimallyQualifiedFormat keeps containing types for nested types
|
||||||
|
/// (<c>Demo.Widget</c>) and has no With-style override here.
|
||||||
|
/// </summary>
|
||||||
|
internal static readonly SymbolDisplayFormat TypeFormat = new(
|
||||||
|
globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted,
|
||||||
|
typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameOnly,
|
||||||
|
genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters
|
||||||
|
| SymbolDisplayGenericsOptions.IncludeVariance,
|
||||||
|
miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes
|
||||||
|
| SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers
|
||||||
|
| SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
|
||||||
|
|
||||||
|
/// <summary>Compact member display for extract-first entries:
|
||||||
|
/// <c>Demo._seed</c>, <c>Demo.Scale</c>, <c>Widget.Bigger(Widget)</c>,
|
||||||
|
/// <c>List<Widget>.this[int]</c>.</summary>
|
||||||
|
internal static string Member(ISymbol symbol) => symbol switch
|
||||||
|
{
|
||||||
|
IMethodSymbol method =>
|
||||||
|
$"{Type(method.ContainingType)}.{method.Name}({Parameters(method.Parameters)})",
|
||||||
|
IPropertySymbol { IsIndexer: true } indexer =>
|
||||||
|
$"{Type(indexer.ContainingType)}.this[{Parameters(indexer.Parameters)}]",
|
||||||
|
IFieldSymbol or IPropertySymbol or IEventSymbol =>
|
||||||
|
$"{Type(symbol.ContainingType)}.{symbol.Name}",
|
||||||
|
_ => symbol.ToDisplayString(TypeFormat),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string Parameters(ImmutableArray<IParameterSymbol> parameters)
|
||||||
|
=> string.Join(", ", parameters.Select(p => Type(p.Type)));
|
||||||
|
|
||||||
|
private static string Type(ITypeSymbol? type) => type?.ToDisplayString(TypeFormat) ?? "?";
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One deduplicated "extract-first" candidate (parent bucket spec): a read
|
||||||
|
/// expression that is NOT a simple local/parameter reference, so the classic
|
||||||
|
/// refactoring sequence is to Extract-Variable it FIRST and re-run the tool —
|
||||||
|
/// the extraction then receives a clean variable instead of a nested
|
||||||
|
/// expression. <see cref="Text"/> plus <see cref="SymbolDisplay"/> identify
|
||||||
|
/// the candidate; <see cref="Occurrences"/> is how often the same candidate
|
||||||
|
/// (same symbol + same source text) is read inside the selection.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Text">Source text of the expression (trivia-free).</param>
|
||||||
|
/// <param name="SymbolDisplay">The bound symbol, minimally qualified for the
|
||||||
|
/// report, or <c><unbound></c> when the expression resolves to no
|
||||||
|
/// symbol (e.g. a cast — conversions are not symbols).</param>
|
||||||
|
/// <param name="Occurrences">How often this exact candidate is read.</param>
|
||||||
|
/// <param name="IsInvocation">True for method invocations: hoisting one into
|
||||||
|
/// a variable changes HOW OFTEN it is evaluated (pure calls are safe to
|
||||||
|
/// hoist, effectful ones are a behavior change — v1 cannot tell them apart,
|
||||||
|
/// so every invocation is flagged).</param>
|
||||||
|
/// <param name="OwnMemberKind"><c>field</c> or <c>property</c> when the
|
||||||
|
/// accessed member belongs to the class that owns the selection — hoisting
|
||||||
|
/// such an access is OPTIONAL because a same-class extracted method can still
|
||||||
|
/// see it (it is still listed: extraction often precedes moving the method to
|
||||||
|
/// another class, where the field would need to travel as a parameter).</param>
|
||||||
|
public sealed record ExtractFirstEntry(
|
||||||
|
string Text,
|
||||||
|
string SymbolDisplay,
|
||||||
|
int Occurrences,
|
||||||
|
bool IsInvocation,
|
||||||
|
string? OwnMemberKind)
|
||||||
|
{
|
||||||
|
/// <summary>Own-class field/property reads are optional to hoist (see <see cref="OwnMemberKind"/>).</summary>
|
||||||
|
public bool Optional => OwnMemberKind is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The extract-first scan (yak em 03, parent bucket spec): find the READ
|
||||||
|
/// expressions of the selection that are not simple local/parameter
|
||||||
|
/// references — member/property/field access, element access, invocations,
|
||||||
|
/// casts — dedupe them by semantic key (bound symbol + source text), count
|
||||||
|
/// occurrences, flag invocations, and mark own-class field/property access
|
||||||
|
/// as optional.
|
||||||
|
///
|
||||||
|
/// WHY a hand-rolled recursive walk instead of one DescendantNodes query:
|
||||||
|
/// "which expression positions are READS" is exactly what data-flow analysis
|
||||||
|
/// answers for VARIABLES (em 02's ReadInside), but for nested expressions
|
||||||
|
/// the read/write distinction must come from the SYNTAX shape — and the scan
|
||||||
|
/// needs three shape rules a flat query cannot express:
|
||||||
|
/// 1. assignment LEFT sides are writes, skipped entirely (decision #5),
|
||||||
|
/// 2. the direct callee of an invocation (and the container of an element
|
||||||
|
/// access) is covered by that invocation/access — reporting `best.Bigger`
|
||||||
|
/// AND `best.Bigger(...)` would double-count one call,
|
||||||
|
/// 3. a member access's NAME is part of the access node, not an
|
||||||
|
/// independent expression — visiting it as an IdentifierName would
|
||||||
|
/// report the same access twice (e.g. `a.B.C` as both the member
|
||||||
|
/// access and a bare `C`).
|
||||||
|
///
|
||||||
|
/// KNOWN v1 APPROXIMATIONS (documented, accepted):
|
||||||
|
/// - Reads-only: non-variable assignment left-hand sides (`arr[i] = 5`) are
|
||||||
|
/// skipped whole — including reads NESTED in them (`arr` in
|
||||||
|
/// `arr[i].X = 5`). Surfaced as a report note (see
|
||||||
|
/// <see cref="HasNonVariableAssignmentLeftSide"/>).
|
||||||
|
/// - Compound-assignment LHS also reads its old value (`obj.Count += 1`);
|
||||||
|
/// v1 skips it with the rest of the LHS.
|
||||||
|
/// - Chained accesses report each link (`a.B.C` lists `a.B` and `a.B.C`)
|
||||||
|
/// except the direct callee of a call, which the call covers.
|
||||||
|
/// - Null-conditional chains are approximated: `a?.B` reports the member
|
||||||
|
/// binding (`.B`) with its symbol; the receiver is not re-reported.
|
||||||
|
/// - The scan classifies expression NODES; a statement's descendant TYPE
|
||||||
|
/// references (`Widget best = ...`) are reached by the generic descent but
|
||||||
|
/// filtered out because they resolve to types, not values.
|
||||||
|
/// </summary>
|
||||||
|
public static class ExtractFirstScanner
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Scans the selection and returns the deduplicated extract-first
|
||||||
|
/// candidates in first-occurrence (source) order.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<ExtractFirstEntry> Scan(SemanticModel model, SelectionReport selection)
|
||||||
|
{
|
||||||
|
// The resolver guarantees Method is non-null on a successful report;
|
||||||
|
// its containing type is what "own-class" is measured against.
|
||||||
|
var enclosingType = (model.GetDeclaredSymbol(selection.Method!) as IMethodSymbol)?.ContainingType;
|
||||||
|
|
||||||
|
var entries = new List<ExtractFirstEntry>();
|
||||||
|
var indexByKey = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Records one candidate occurrence. The semantic key is the bound
|
||||||
|
// symbol PLUS the source text: the same member read through different
|
||||||
|
// expressions (`widgets[0]` vs `widgets[i]`) stays separate, while an
|
||||||
|
// identical expression read twice (`_seed` twice in RepeatReads)
|
||||||
|
// dedupes into one entry with Occurrences > 1.
|
||||||
|
void Report(ExpressionSyntax expr, bool invocation)
|
||||||
|
{
|
||||||
|
var info = model.GetSymbolInfo(expr);
|
||||||
|
// Prefer the resolved symbol; on an ambiguous binding the first
|
||||||
|
// candidate still identifies the member better than nothing.
|
||||||
|
var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();
|
||||||
|
|
||||||
|
var text = expr.ToString(); // trivia-free expression text
|
||||||
|
var key = $"{symbol?.ToDisplayString() ?? "<unbound>"}|{text}";
|
||||||
|
var display = symbol is null ? "<unbound>" : Displays.Member(symbol);
|
||||||
|
var ownKind = OwnMemberKind(symbol, enclosingType);
|
||||||
|
|
||||||
|
if (indexByKey.TryGetValue(key, out var index))
|
||||||
|
{
|
||||||
|
entries[index] = entries[index] with { Occurrences = entries[index].Occurrences + 1 };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
indexByKey[key] = entries.Count;
|
||||||
|
entries.Add(new ExtractFirstEntry(text, display, 1, invocation, ownKind));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Own-class data member → hoisting is optional (parent bucket spec).
|
||||||
|
// Exact containing-type match: base-class members would need a base
|
||||||
|
// reference in the extracted method — not v1's "optional" case.
|
||||||
|
static string? OwnMemberKind(ISymbol? symbol, INamedTypeSymbol? enclosingType)
|
||||||
|
{
|
||||||
|
if (enclosingType is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return symbol switch
|
||||||
|
{
|
||||||
|
IFieldSymbol field when SymbolEqualityComparer.Default.Equals(field.ContainingType, enclosingType)
|
||||||
|
=> "field",
|
||||||
|
IPropertySymbol property when SymbolEqualityComparer.Default.Equals(property.ContainingType, enclosingType)
|
||||||
|
=> "property",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classifies a bare simple name. Simple names ARE reported when they
|
||||||
|
// bind to anything that is not a variable or a type/namespace — that
|
||||||
|
// is how the field read `_seed` and the property read `Scale` (both
|
||||||
|
// written as bare identifiers) enter this bucket (decision #6 keeps
|
||||||
|
// them out of the params bucket).
|
||||||
|
void ClassifyIdentifier(IdentifierNameSyntax id)
|
||||||
|
{
|
||||||
|
if (id.IsVar)
|
||||||
|
{
|
||||||
|
return; // the contextual `var` keyword is not a value read
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbol = model.GetSymbolInfo(id).Symbol;
|
||||||
|
switch (symbol)
|
||||||
|
{
|
||||||
|
case null:
|
||||||
|
return; // unresolvable: nothing sensible to report
|
||||||
|
case ILocalSymbol or IParameterSymbol:
|
||||||
|
return; // the params bucket (decision #6), not extract-first
|
||||||
|
case INamespaceSymbol or ITypeSymbol:
|
||||||
|
return; // a type/namespace mention (e.g. `Widget`), not a value read
|
||||||
|
case IDiscardSymbol:
|
||||||
|
return; // discards are write sinks, never reads
|
||||||
|
default:
|
||||||
|
Report(id, invocation: false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>nameof is spelled like a call but reads no member state.</summary>
|
||||||
|
static bool IsNameOf(InvocationExpressionSyntax inv)
|
||||||
|
=> inv.Expression is IdentifierNameSyntax { Identifier.ValueText: "nameof" };
|
||||||
|
|
||||||
|
void Visit(SyntaxNode node, bool suppress)
|
||||||
|
{
|
||||||
|
switch (node)
|
||||||
|
{
|
||||||
|
// Decision #5: v1 is a READS-only scan. The whole LHS subtree
|
||||||
|
// is skipped — writes to non-variables are invisible (the
|
||||||
|
// report notes this when it applies; see
|
||||||
|
// HasNonVariableAssignmentLeftSide). Nested reads inside an
|
||||||
|
// LHS (the `arr` in `arr[i].X = 5`) are the documented loss.
|
||||||
|
case AssignmentExpressionSyntax assignment:
|
||||||
|
Visit(assignment.Right, suppress: false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case InvocationExpressionSyntax inv when IsNameOf(inv):
|
||||||
|
foreach (var arg in inv.ArgumentList.Arguments)
|
||||||
|
{
|
||||||
|
Visit(arg.Expression, suppress: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case InvocationExpressionSyntax inv:
|
||||||
|
// The invocation is the candidate; its direct callee is
|
||||||
|
// covered by it (suppress = true below).
|
||||||
|
if (!suppress)
|
||||||
|
{
|
||||||
|
Report(inv, invocation: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Visit(inv.Expression, suppress: true);
|
||||||
|
foreach (var arg in inv.ArgumentList.Arguments)
|
||||||
|
{
|
||||||
|
Visit(arg.Expression, suppress: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ElementAccessExpressionSyntax elementAccess:
|
||||||
|
if (!suppress)
|
||||||
|
{
|
||||||
|
Report(elementAccess, invocation: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The indexed CONTAINER is covered by the access itself
|
||||||
|
// (same rule as the invocation callee above).
|
||||||
|
Visit(elementAccess.Expression, suppress: true);
|
||||||
|
foreach (var arg in elementAccess.ArgumentList.Arguments)
|
||||||
|
{
|
||||||
|
Visit(arg.Expression, suppress: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MemberAccessExpressionSyntax memberAccess:
|
||||||
|
if (!suppress)
|
||||||
|
{
|
||||||
|
Report(memberAccess, invocation: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The member NAME is part of this node; only the receiver
|
||||||
|
// is an independent read. Suppression never propagates
|
||||||
|
// here: it marks exactly ONE node (the direct callee), so
|
||||||
|
// `a.B.C(x)` still reports its `a.B` link.
|
||||||
|
Visit(memberAccess.Expression, suppress: false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MemberBindingExpressionSyntax binding:
|
||||||
|
// `a?.B`: the binding is the member access shape of a
|
||||||
|
// null-conditional chain. No children to visit (the name
|
||||||
|
// is part of this node).
|
||||||
|
if (!suppress)
|
||||||
|
{
|
||||||
|
Report(binding, invocation: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CastExpressionSyntax cast:
|
||||||
|
if (!suppress)
|
||||||
|
{
|
||||||
|
Report(cast, invocation: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Visit(cast.Expression, suppress: false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case IdentifierNameSyntax id:
|
||||||
|
if (!suppress)
|
||||||
|
{
|
||||||
|
ClassifyIdentifier(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ParenthesizedExpressionSyntax parenthesized:
|
||||||
|
// Parentheses must not change what "covered by the call"
|
||||||
|
// means: `(best.Bigger)(...)` keeps the callee covered.
|
||||||
|
Visit(parenthesized.Expression, suppress);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Generic descent (statement scaffolding, binaries,
|
||||||
|
// unary/conditional operators, interpolation holes, ...):
|
||||||
|
// none of these shapes is itself an extract-first
|
||||||
|
// candidate, but their operand expressions are.
|
||||||
|
foreach (var child in node.ChildNodes())
|
||||||
|
{
|
||||||
|
Visit(child, suppress: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var statement in selection.Statements)
|
||||||
|
{
|
||||||
|
Visit(statement, suppress: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True when the selection assigns to something that is not a local or a
|
||||||
|
/// parameter (a field, property, indexer, ...) — the decision-5 case the
|
||||||
|
/// report must flag, because the reads-only scan cannot see that write.
|
||||||
|
/// </summary>
|
||||||
|
public static bool HasNonVariableAssignmentLeftSide(SemanticModel model, SelectionReport selection)
|
||||||
|
{
|
||||||
|
foreach (var statement in selection.Statements)
|
||||||
|
{
|
||||||
|
foreach (var node in statement.DescendantNodesAndSelf().OfType<AssignmentExpressionSyntax>())
|
||||||
|
{
|
||||||
|
var symbol = model.GetSymbolInfo(node.Left).Symbol;
|
||||||
|
if (symbol is not ILocalSymbol and not IParameterSymbol)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>Everything the extract-method report is built from: the resolved
|
||||||
|
/// selection, the raw classification buckets (em 02), the extract-first
|
||||||
|
/// candidates (em 03), the promoted signature line (em 03) and the v1
|
||||||
|
/// limitation notes. One record so the CLI and the tests compose EXACTLY the
|
||||||
|
/// same thing — the report cannot drift between them.</summary>
|
||||||
|
public sealed record ExtractionReport(
|
||||||
|
SelectionReport Selection,
|
||||||
|
ExtractionSuggestion Suggestion,
|
||||||
|
IReadOnlyList<ExtractFirstEntry> ExtractFirst,
|
||||||
|
SignatureSuggestion Signature,
|
||||||
|
IReadOnlyList<string> Notes);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes the full extraction suggestion for a resolved selection: the em 02
|
||||||
|
/// data-flow buckets, the em 03 extract-first scan, the promoted signature and
|
||||||
|
/// the limitation notes. Throws the same clean exceptions as
|
||||||
|
/// <see cref="DataFlowClassifier.Classify"/> when analysis cannot bind.
|
||||||
|
/// </summary>
|
||||||
|
public static class ExtractionReporter
|
||||||
|
{
|
||||||
|
public static ExtractionReport Compose(SemanticModel model, SelectionReport selection)
|
||||||
|
{
|
||||||
|
var suggestion = DataFlowClassifier.Classify(model, selection);
|
||||||
|
var extractFirst = ExtractFirstScanner.Scan(model, selection);
|
||||||
|
var signature = SignatureBuilder.Build(model, selection, suggestion);
|
||||||
|
return new ExtractionReport(selection, suggestion, extractFirst, signature, BuildNotes(model, selection));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The v1 limitation notes (parent decision #5 and the em 02 contract):
|
||||||
|
/// each note is printed only when its limitation actually applies to the
|
||||||
|
/// selection, so a clean selection gets a clean report.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<string> BuildNotes(SemanticModel model, SelectionReport selection)
|
||||||
|
{
|
||||||
|
var notes = new List<string>();
|
||||||
|
|
||||||
|
if (ExtractFirstScanner.HasNonVariableAssignmentLeftSide(model, selection))
|
||||||
|
{
|
||||||
|
notes.Add(
|
||||||
|
"reads-only scan (decision 5): assignment left-hand sides are skipped, " +
|
||||||
|
"so writes to fields/indexers/properties are invisible to this report");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DataFlowClassifier.CompositeTrailingReturn(selection) is { } trailing)
|
||||||
|
{
|
||||||
|
notes.Add(
|
||||||
|
$"the selection ends with a composite return expression ({trailing.Expression!.ToFullString().Trim()}) — " +
|
||||||
|
"extract it into a local first to make the return value nameable");
|
||||||
|
}
|
||||||
|
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The generated extract-method refactoring (yak em 04 codegen): the NEW
|
||||||
|
/// method (the em 03 promoted signature; parameter names come straight from
|
||||||
|
/// the source symbols), the CALL STATEMENTS that replace the selection in the
|
||||||
|
/// enclosing method, and the fully transformed syntax tree.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="NewMethod">The extracted method, ready to be read. Built
|
||||||
|
/// with SyntaxFactory and NORMALIZED (whitespace) before insertion, so its
|
||||||
|
/// text is the readable shape that lands in the file — not the trivia-less
|
||||||
|
/// builder output.</param>
|
||||||
|
/// <param name="CallStatements">The statement(s) that replace the selected
|
||||||
|
/// run at the call site (one statement, or a capture + destructure pair for
|
||||||
|
/// a mixed tuple return), normalized like <see cref="NewMethod"/>.</param>
|
||||||
|
/// <param name="TransformedTree">The whole input tree with the selection
|
||||||
|
/// moved out and the new method added next to the enclosing one — the
|
||||||
|
/// round-trip check compiles exactly this tree.</param>
|
||||||
|
public sealed record Refactoring(
|
||||||
|
MethodDeclarationSyntax NewMethod,
|
||||||
|
IReadOnlyList<StatementSyntax> CallStatements,
|
||||||
|
SyntaxTree TransformedTree)
|
||||||
|
{
|
||||||
|
public string TransformedText => TransformedTree.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emits the refactoring behind the em 03 suggestion.
|
||||||
|
///
|
||||||
|
/// WHY no identifier rewriting at all: the suggested parameter names ARE the
|
||||||
|
/// source variable names (em 02/03 promotion), so the moved statements bind
|
||||||
|
/// unchanged — every local/parameter read inside the selection is a parameter
|
||||||
|
/// of the new method, every variable declared inside moves with the body, and
|
||||||
|
/// the remaining reads are type members visible from the same class
|
||||||
|
/// (the new method is inserted into the SAME containing type). This is the
|
||||||
|
/// property the resolver (one method body, whole statements) and the
|
||||||
|
/// classifier (params bucket = exactly the local/parameter reads) jointly
|
||||||
|
/// guarantee; a generic SyntaxRewriter renaming pass would be dead weight in
|
||||||
|
/// v1. What IS generated structurally:
|
||||||
|
/// - the new method declaration (signature line, static-ness mirrored from
|
||||||
|
/// the enclosing method, body = selected statements + appended
|
||||||
|
/// <c>return ...</c> for return slots the selection did not already end
|
||||||
|
/// with a return of),
|
||||||
|
/// - the call statement(s) replacing the selection,
|
||||||
|
/// - the insertion of the new method right after the enclosing one.
|
||||||
|
///
|
||||||
|
/// CALL-SITE RULES (per promoted return slots, v1):
|
||||||
|
/// - void → a plain call expression statement. A ref parameter already flows
|
||||||
|
/// the value back (e.g. Accumulate: <c>Extract(ref acc, n);</c>).
|
||||||
|
/// - one slot DECLARED inside the selection → redeclared at the call site
|
||||||
|
/// from the result: <c>int total = Extract(limit);</c>.
|
||||||
|
/// - one slot declared OUTSIDE → plain assignment: <c>acc = Extract(...);</c>.
|
||||||
|
/// - several slots ALL declared inside → tuple deconstruction:
|
||||||
|
/// <c>var (x, y) = Extract(n);</c>.
|
||||||
|
/// - several slots, any declared outside → capture + ItemN assignments
|
||||||
|
/// (the result is evaluated ONCE, then assigned member-wise).
|
||||||
|
/// - the enclosing method's return type is non-void and the selection ends
|
||||||
|
/// the method → the call site must be <c>return Extract(...);</c> and the
|
||||||
|
/// suggested return type must equal the enclosing one (otherwise: refuse).
|
||||||
|
///
|
||||||
|
/// REFUSALS (loud, never a silent wrong refactoring):
|
||||||
|
/// - the selection contains a return that does NOT end the enclosing method
|
||||||
|
/// (v1: select to the end of the method);
|
||||||
|
/// - the suggested return type does not match the enclosing method's return
|
||||||
|
/// type — including the em 03 "composite trailing return" case, where the
|
||||||
|
/// signature is void although the enclosing method returns a value (the
|
||||||
|
/// report's note already says: extract a local first, then re-run).
|
||||||
|
///
|
||||||
|
/// BUILDER STYLE: single-slot declarations and assignments are hand-built
|
||||||
|
/// with SyntaxFactory; the deconstruction call <c>var (x, y) = ...;</c> and
|
||||||
|
/// the tuple return <c>return (x, y);</c> are PARSED from a text skeleton and
|
||||||
|
/// the call slot swapped in via ReplaceNode — the parser knows the
|
||||||
|
/// designation/tuple-element shapes a v1 builder would get wrong. (Parse
|
||||||
|
/// diagnostics would surface as red nodes and fail the round-trip check
|
||||||
|
/// loudly, not silently.)
|
||||||
|
///
|
||||||
|
/// KNOWN v1 APPROXIMATIONS: type display strings are re-parsed for the
|
||||||
|
/// generated declarations (a tuple return with a comma-bearing generic type
|
||||||
|
/// element is not supported); the capture name <c>result</c> could in theory
|
||||||
|
/// collide with an enclosing local; the name check "suggested return type ==
|
||||||
|
/// enclosing return type" is a string comparison, not symbol equality.
|
||||||
|
/// </summary>
|
||||||
|
public static class RefactoringGenerator
|
||||||
|
{
|
||||||
|
public static Refactoring Generate(SemanticModel model, SelectionReport selection, SignatureSuggestion signature)
|
||||||
|
{
|
||||||
|
var method = selection.Method!;
|
||||||
|
var body = method.Body!;
|
||||||
|
|
||||||
|
var declaredInside = SignatureBuilder.DeclaredInsideNames(model, selection);
|
||||||
|
var slots = signature.ReturnSlots.ToList();
|
||||||
|
var slotNames = slots.Select(s => s.Name).ToList();
|
||||||
|
|
||||||
|
var containsReturn = selection.Statements.OfType<ReturnStatementSyntax>().Any();
|
||||||
|
var selectionEndsBody = ReferenceEquals(selection.Statements[^1], body.Statements[^1]);
|
||||||
|
var enclosingNonVoid = method.ReturnType is not PredefinedTypeSyntax voidType
|
||||||
|
|| !voidType.Keyword.IsKind(SyntaxKind.VoidKeyword);
|
||||||
|
var enclosingReturnType = method.ReturnType.ToString();
|
||||||
|
|
||||||
|
// ---- soundness refusals (see class comment) ----
|
||||||
|
if (containsReturn && enclosingNonVoid)
|
||||||
|
{
|
||||||
|
if (!selectionEndsBody)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"the selection contains a return that does not end the enclosing method — select to the end of the method (v1 codegen)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signature.ReturnType != enclosingReturnType)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
signature.ReturnType == "void"
|
||||||
|
? "the selection ends with the method's return, but its value is not nameable in v1 — " +
|
||||||
|
"extract it into a local first (see the report note), then re-run"
|
||||||
|
: $"the suggested return type {signature.ReturnType} does not match the enclosing method's " +
|
||||||
|
$"return type {enclosingReturnType} — refusing to generate");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the call expression (parameter names = source names, ref-ness kept) ----
|
||||||
|
var arguments = signature.Params
|
||||||
|
.Select(p =>
|
||||||
|
{
|
||||||
|
var argument = SyntaxFactory.Argument(SyntaxFactory.IdentifierName(p.Name));
|
||||||
|
return p.ByRef
|
||||||
|
? argument.WithRefOrOutKeyword(SyntaxFactory.Token(SyntaxKind.RefKeyword))
|
||||||
|
: argument;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
var invocation = SyntaxFactory.InvocationExpression(
|
||||||
|
SyntaxFactory.IdentifierName(signature.MethodName))
|
||||||
|
.WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments)))
|
||||||
|
// Normalized once, here: the PARSED deconstruction skeleton (see
|
||||||
|
// BuildCallStatements) must not be re-normalized (the normalizer
|
||||||
|
// drops the space after the contextual keyword `var` before `(`),
|
||||||
|
// so it receives the invocation already properly spaced.
|
||||||
|
.NormalizeWhitespace();
|
||||||
|
|
||||||
|
// Normalizing only the GENERATED nodes keeps the original trivia —
|
||||||
|
// and therefore the doc comments — untouched (a whole-root
|
||||||
|
// NormalizeWhitespace corrupts doc-comment trivia in a way Roslyn's
|
||||||
|
// XML-doc writer rejects with CS1569, and it would also reformat the
|
||||||
|
// entire file, making the printed diff useless). The call statements
|
||||||
|
// and the new method get their indentation/end-of-line grafted from
|
||||||
|
// the selection they replace, so the edit slots into the original
|
||||||
|
// formatting.
|
||||||
|
var callStatements = BuildCallStatements(slots, slotNames, invocation, declaredInside,
|
||||||
|
callMustReturn: containsReturn && enclosingNonVoid).ToList();
|
||||||
|
var indent = SyntaxFactory.TriviaList(
|
||||||
|
selection.Statements[0].GetLeadingTrivia().Where(t => t.IsKind(SyntaxKind.WhitespaceTrivia)));
|
||||||
|
for (var index = 0; index < callStatements.Count; index++)
|
||||||
|
{
|
||||||
|
var call = callStatements[index].WithLeadingTrivia(indent);
|
||||||
|
if (index == callStatements.Count - 1)
|
||||||
|
{
|
||||||
|
// The replaced statement's trailing end-of-line kept the
|
||||||
|
// following token (the next statement or the closing brace)
|
||||||
|
// on its own line — carry it over.
|
||||||
|
call = call.WithTrailingTrivia(selection.Statements[^1].GetTrailingTrivia());
|
||||||
|
}
|
||||||
|
|
||||||
|
callStatements[index] = call;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the new method INSIDE a throwaway class wrapper: the
|
||||||
|
// normalizer computes indentation from nesting depth, and a bare
|
||||||
|
// method node has none — its body would come out flush-left. At class
|
||||||
|
// depth the signature lands one level in and the body two. The grafted
|
||||||
|
// leading/trailing trivia below replaces whatever the wrapper put
|
||||||
|
// around the member.
|
||||||
|
var newMethod = (MethodDeclarationSyntax)SyntaxFactory.ClassDeclaration("__normalizer__")
|
||||||
|
.WithMembers(SyntaxFactory.SingletonList<MemberDeclarationSyntax>(
|
||||||
|
BuildNewMethod(method, selection, signature, slots, slotNames)))
|
||||||
|
.NormalizeWhitespace()
|
||||||
|
.Members[0];
|
||||||
|
var methodIndent = method.GetLeadingTrivia()
|
||||||
|
.LastOrDefault(t => t.IsKind(SyntaxKind.WhitespaceTrivia));
|
||||||
|
newMethod = newMethod
|
||||||
|
.WithLeadingTrivia(SyntaxFactory.TriviaList(SyntaxFactory.EndOfLine("\n"), methodIndent))
|
||||||
|
.WithTrailingTrivia(SyntaxFactory.EndOfLine("\n"));
|
||||||
|
|
||||||
|
// ---- apply to the tree: swap the selection for the call, add the method ----
|
||||||
|
var allStatements = body.Statements;
|
||||||
|
var firstIndex = allStatements.IndexOf(selection.Statements[0]); // reference identity: same tree
|
||||||
|
var newStatements = new List<StatementSyntax>();
|
||||||
|
newStatements.AddRange(allStatements.Take(firstIndex));
|
||||||
|
newStatements.AddRange(callStatements);
|
||||||
|
newStatements.AddRange(allStatements.Skip(firstIndex + selection.Statements.Count));
|
||||||
|
var rewrittenMethod = method.WithBody(body.WithStatements(SyntaxFactory.List(newStatements)));
|
||||||
|
|
||||||
|
// The new method lands right after the enclosing one, in the SAME type
|
||||||
|
// (that is what keeps the extract-first own-class members in scope).
|
||||||
|
var containingType = method.AncestorsAndSelf().OfType<TypeDeclarationSyntax>().First();
|
||||||
|
var memberIndex = containingType.Members.IndexOf(method);
|
||||||
|
var newType = containingType.WithMembers(
|
||||||
|
containingType.Members.Replace(method, rewrittenMethod).Insert(memberIndex + 1, newMethod));
|
||||||
|
|
||||||
|
var tree = model.SyntaxTree;
|
||||||
|
var visited = new SingleNodeReplacer(containingType, newType).Visit(tree.GetRoot());
|
||||||
|
if (visited is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("the type replacement rewrote the root away (internal error)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Refactoring(newMethod, callStatements, tree.WithRootAndOptions(visited, tree.Options));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one place a SyntaxRewriter enters this tool (yak em 04's stated
|
||||||
|
/// curriculum): swapping a single node by REFERENCE identity. The typed
|
||||||
|
/// With*-slices (WithBody, WithMembers) cover the local changes; only the
|
||||||
|
/// "put the rewritten type back into the root" step is a whole-tree walk.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class SingleNodeReplacer : CSharpSyntaxRewriter
|
||||||
|
{
|
||||||
|
private readonly SyntaxNode _oldNode;
|
||||||
|
private readonly SyntaxNode _newNode;
|
||||||
|
|
||||||
|
public SingleNodeReplacer(SyntaxNode oldNode, SyntaxNode newNode)
|
||||||
|
{
|
||||||
|
_oldNode = oldNode;
|
||||||
|
_newNode = newNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override SyntaxNode? Visit(SyntaxNode? node)
|
||||||
|
=> ReferenceEquals(node, _oldNode) ? _newNode : base.Visit(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The call statement(s) replacing the selection (see class comment).</summary>
|
||||||
|
private static IReadOnlyList<StatementSyntax> BuildCallStatements(
|
||||||
|
IReadOnlyList<ReturnSuggestion> slots,
|
||||||
|
IReadOnlyList<string> slotNames,
|
||||||
|
InvocationExpressionSyntax invocation,
|
||||||
|
ISet<string> declaredInside,
|
||||||
|
bool callMustReturn)
|
||||||
|
{
|
||||||
|
if (callMustReturn)
|
||||||
|
{
|
||||||
|
// The selection ends the enclosing (non-void) method: the call is
|
||||||
|
// the return value, the slots live in the new method.
|
||||||
|
return [Norm(SyntaxFactory.ReturnStatement(invocation))];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slots.Count == 0)
|
||||||
|
{
|
||||||
|
return [Norm(SyntaxFactory.ExpressionStatement(invocation))];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slots.Count == 1)
|
||||||
|
{
|
||||||
|
var slot = slots[0];
|
||||||
|
if (declaredInside.Contains(slot.Name))
|
||||||
|
{
|
||||||
|
// Declared inside the selection: redeclare it at the call site
|
||||||
|
// from the result (the declaration statement moved with the body).
|
||||||
|
var declarator = SyntaxFactory.VariableDeclarator(slot.Name)
|
||||||
|
.WithInitializer(SyntaxFactory.EqualsValueClause(invocation));
|
||||||
|
var declaration = SyntaxFactory.VariableDeclaration(
|
||||||
|
ParseType(slot.Type), SyntaxFactory.SingletonSeparatedList(declarator));
|
||||||
|
return [Norm(SyntaxFactory.LocalDeclarationStatement(declaration))];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declared outside: the caller already owns it (a parameter, or a
|
||||||
|
// local declared before the selection) — plain assignment.
|
||||||
|
return [Norm(SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression(
|
||||||
|
SyntaxKind.SimpleAssignmentExpression,
|
||||||
|
SyntaxFactory.IdentifierName(slot.Name),
|
||||||
|
invocation)))];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Several return slots.
|
||||||
|
if (slots.All(s => declaredInside.Contains(s.Name)))
|
||||||
|
{
|
||||||
|
// All declared inside: tuple deconstruction at the call site.
|
||||||
|
// Parsed from a skeleton (see class comment — builder style), the
|
||||||
|
// call swapped in at the placeholder identifier. NOT normalized:
|
||||||
|
// the skeleton text is already well-spaced, and NormalizeWhitespace
|
||||||
|
// would drop the space after the contextual keyword `var` (it is
|
||||||
|
// an identifier token to the normalizer, so `var (` becomes
|
||||||
|
// `var(`).
|
||||||
|
return [ParseStatementWithCall($"var ({string.Join(", ", slotNames)}) = __call__;", invocation)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixed: evaluate the call ONCE into a capture, then assign each slot
|
||||||
|
// from the matching ItemN. (Capture name collision with an enclosing
|
||||||
|
// local is a documented v1 limitation.)
|
||||||
|
var statements = new List<StatementSyntax>
|
||||||
|
{
|
||||||
|
Norm(SyntaxFactory.LocalDeclarationStatement(SyntaxFactory.VariableDeclaration(
|
||||||
|
SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.VarKeyword)),
|
||||||
|
SyntaxFactory.SingletonSeparatedList(SyntaxFactory.VariableDeclarator("result")
|
||||||
|
.WithInitializer(SyntaxFactory.EqualsValueClause(invocation)))))),
|
||||||
|
};
|
||||||
|
for (var index = 0; index < slots.Count; index++)
|
||||||
|
{
|
||||||
|
statements.Add(Norm(SyntaxFactory.ExpressionStatement(SyntaxFactory.AssignmentExpression(
|
||||||
|
SyntaxKind.SimpleAssignmentExpression,
|
||||||
|
SyntaxFactory.IdentifierName(slots[index].Name),
|
||||||
|
SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression,
|
||||||
|
SyntaxFactory.IdentifierName("result"),
|
||||||
|
SyntaxFactory.IdentifierName($"Item{index + 1}"))))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return statements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes a builder-made statement: SyntaxFactory tokens carry no
|
||||||
|
/// trivia, so without this <c>return</c> and <c>Extract</c> would render
|
||||||
|
/// glued together. Only used for BUILDER statements — the parsed skeleton
|
||||||
|
/// statements are already well-spaced (see the deconstruction comment).
|
||||||
|
/// </summary>
|
||||||
|
private static StatementSyntax Norm(StatementSyntax statement) => statement.NormalizeWhitespace();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses <paramref name="statementText"/> (containing the placeholder
|
||||||
|
/// identifier <c>__call__</c>) and swaps the placeholder for the built
|
||||||
|
/// <paramref name="invocation"/> — the parser supplies the syntax shapes
|
||||||
|
/// (tuple designations, tuple elements) the v1 builder avoids.
|
||||||
|
/// </summary>
|
||||||
|
private static StatementSyntax ParseStatementWithCall(string statementText, InvocationExpressionSyntax invocation)
|
||||||
|
{
|
||||||
|
var parsed = SyntaxFactory.ParseStatement(statementText);
|
||||||
|
var placeholder = parsed.DescendantNodes()
|
||||||
|
.OfType<IdentifierNameSyntax>()
|
||||||
|
.First(n => n.Identifier.ValueText == "__call__");
|
||||||
|
return parsed.ReplaceNode(placeholder, invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The extracted method: the promoted signature, static-ness mirrored from
|
||||||
|
/// the enclosing method, body = the selected statements plus an appended
|
||||||
|
/// <c>return</c> for the return slots (skipped when the selection already
|
||||||
|
/// ends in a return — a second one would be unreachable). The appended
|
||||||
|
/// tuple return is parsed from its text skeleton (see class comment).
|
||||||
|
/// </summary>
|
||||||
|
private static MethodDeclarationSyntax BuildNewMethod(
|
||||||
|
MethodDeclarationSyntax enclosingMethod,
|
||||||
|
SelectionReport selection,
|
||||||
|
SignatureSuggestion signature,
|
||||||
|
IReadOnlyList<ReturnSuggestion> slots,
|
||||||
|
IReadOnlyList<string> slotNames)
|
||||||
|
{
|
||||||
|
// A static enclosing method can only read statics and parameters, so
|
||||||
|
// the extracted body needs no instance — mirror the static modifier.
|
||||||
|
var modifiers = enclosingMethod.Modifiers.Any(m => m.IsKind(SyntaxKind.StaticKeyword))
|
||||||
|
? new[] { SyntaxFactory.Token(SyntaxKind.StaticKeyword) }
|
||||||
|
: Array.Empty<SyntaxToken>();
|
||||||
|
|
||||||
|
var parameters = signature.Params
|
||||||
|
.Select(p =>
|
||||||
|
{
|
||||||
|
var parameter = SyntaxFactory.Parameter(SyntaxFactory.Identifier(p.Name))
|
||||||
|
.WithType(ParseType(p.Type));
|
||||||
|
return p.ByRef
|
||||||
|
? parameter.WithModifiers(SyntaxFactory.TokenList(
|
||||||
|
SyntaxFactory.Token(SyntaxKind.RefKeyword)))
|
||||||
|
: parameter;
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var bodyStatements = selection.Statements.ToList();
|
||||||
|
if (slots.Count > 0 && selection.Statements[^1] is not ReturnStatementSyntax)
|
||||||
|
{
|
||||||
|
// The slot order here is the same name-ordered sequence the tuple
|
||||||
|
// ReturnType was built from, so the emitted value matches the
|
||||||
|
// declared element types.
|
||||||
|
bodyStatements.Add(slots.Count == 1
|
||||||
|
? SyntaxFactory.ReturnStatement(SyntaxFactory.IdentifierName(slotNames[0]))
|
||||||
|
: SyntaxFactory.ParseStatement($"return ({string.Join(", ", slotNames)});"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return SyntaxFactory.MethodDeclaration(ParseType(signature.ReturnType), SyntaxFactory.Identifier(signature.MethodName))
|
||||||
|
.WithModifiers(SyntaxFactory.TokenList(modifiers))
|
||||||
|
.WithParameterList(SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(parameters)))
|
||||||
|
.WithBody(SyntaxFactory.Block(SyntaxFactory.List(bodyStatements)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-parses a type display string (em 02/03 are plain strings) in type
|
||||||
|
/// context — <c>ParseTypeName</c> handles plain names, generics and tuple
|
||||||
|
/// display strings <c>(T1, T2)</c> alike.
|
||||||
|
///
|
||||||
|
/// EXCEPT <c>void</c>: parsing "void" in TYPE context yields a node the
|
||||||
|
/// compiler rejects as a method return type with CS1547 (keyword 'void'
|
||||||
|
/// cannot be used in this context) — the parser that reads real method
|
||||||
|
/// declarations produces <c>PredefinedType(Token(VoidKeyword))</c>, so
|
||||||
|
/// that is what a void signature must be built from.
|
||||||
|
/// Single choke point for the string-to-syntax hop; its correctness is
|
||||||
|
/// enforced by the round-trip check and the tests, not by construction.
|
||||||
|
/// </summary>
|
||||||
|
private static TypeSyntax ParseType(string text) => text == "void"
|
||||||
|
? SyntaxFactory.PredefinedType(SyntaxFactory.Token(SyntaxKind.VoidKeyword))
|
||||||
|
: SyntaxFactory.ParseTypeName(text);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders an <see cref="ExtractionReport"/> as the human-readable extract-
|
||||||
|
/// method suggestion report (parent spec): params (with ref), return
|
||||||
|
/// candidates, locals, extract-first candidates (deduped, with occurrence
|
||||||
|
/// counts and flags), the limitation notes, and the suggested signature line
|
||||||
|
/// LAST. Pure string shaping — no Roslyn — so tests can pin the exact output
|
||||||
|
/// the CLI prints.
|
||||||
|
/// </summary>
|
||||||
|
public static class ReportFormatter
|
||||||
|
{
|
||||||
|
public static string Format(ExtractionReport report)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var selection = report.Selection;
|
||||||
|
var suggestion = report.Suggestion;
|
||||||
|
|
||||||
|
// Header (same wording the CLI printed since em 01).
|
||||||
|
sb.AppendLine(
|
||||||
|
$"{selection.Count} statement(s) selected, lines {selection.StartLine}..{selection.EndLine} " +
|
||||||
|
$"in {selection.Method?.Identifier.ValueText}(): " +
|
||||||
|
string.Join(", ", selection.Kinds));
|
||||||
|
|
||||||
|
AppendBucket(sb, "params", suggestion.Params.Select(p =>
|
||||||
|
$"{p.Name} ({p.Type}){(p.ByRef ? " [ref]" : " [in]")}"));
|
||||||
|
AppendBucket(sb, "returns", suggestion.Returns.Select(r => $"{r.Name} ({r.Type})"));
|
||||||
|
AppendBucket(sb, "locals", suggestion.Locals);
|
||||||
|
|
||||||
|
if (report.ExtractFirst.Count == 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine("extract-first: (none)");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.AppendLine("extract-first:");
|
||||||
|
foreach (var entry in report.ExtractFirst)
|
||||||
|
{
|
||||||
|
// Flags accumulate; occurrence count is always shown because
|
||||||
|
// "count occurrences" is the point of the dedupe.
|
||||||
|
var flags = new List<string>();
|
||||||
|
if (entry.IsInvocation)
|
||||||
|
{
|
||||||
|
flags.Add("hoisting changes eval count");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.Optional)
|
||||||
|
{
|
||||||
|
flags.Add($"optional: own-class {entry.OwnMemberKind}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var flagText = flags.Count > 0 ? $" [{string.Join("; ", flags)}]" : string.Empty;
|
||||||
|
sb.AppendLine($" - {entry.Text} — {entry.SymbolDisplay} ×{entry.Occurrences}{flagText}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var note in report.Notes)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"note: {note}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var signature = report.Signature;
|
||||||
|
var parameterList = string.Join(", ", signature.Params.Select(p => $"{(p.ByRef ? "ref " : "")}{p.Type} {p.Name}"));
|
||||||
|
sb.AppendLine($"suggested signature: {signature.ReturnType} {signature.MethodName}({parameterList})");
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendBucket(StringBuilder sb, string name, IEnumerable<string> items)
|
||||||
|
{
|
||||||
|
var enumerated = items.ToList();
|
||||||
|
sb.AppendLine(enumerated.Count == 0 ? $"{name}: (none)" : $"{name}: {enumerated[0]}");
|
||||||
|
for (var i = 1; i < enumerated.Count; i++)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"{name}: {enumerated[i]}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
using Microsoft.CodeAnalysis.Text;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>A line range resolved to the whole statements it covers.</summary>
|
||||||
|
public sealed record SelectionReport
|
||||||
|
{
|
||||||
|
public required bool Succeeded { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Human-readable reason when <see cref="Succeeded"/> is false.</summary>
|
||||||
|
public string? Error { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The selected whole statements, in source order, all siblings in one
|
||||||
|
/// statement list (v1: the enclosing method's body block).
|
||||||
|
/// </summary>
|
||||||
|
public required IReadOnlyList<StatementSyntax> Statements { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The method that owns the selected statements.</summary>
|
||||||
|
public required MethodDeclarationSyntax? Method { get; init; }
|
||||||
|
|
||||||
|
public int StartLine { get; init; }
|
||||||
|
public int EndLine { get; init; }
|
||||||
|
|
||||||
|
public int Count => Statements.Count;
|
||||||
|
|
||||||
|
/// <summary>Statement kinds (e.g. LocalDeclarationStatement, ForStatement).</summary>
|
||||||
|
public IEnumerable<SyntaxKind> Kinds => Statements.Select(s => s.Kind());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Snap a 1-based, inclusive line range (IDE-selection style) to a contiguous
|
||||||
|
/// set of WHOLE statements — never split a statement (parent spec decision #1).
|
||||||
|
///
|
||||||
|
/// v1 rule: the selection is matched against the top-level statement list of
|
||||||
|
/// the enclosing method's body block. A range nested inside a block whose
|
||||||
|
/// siblings live deeper (e.g. the body of a for-loop) is a clean error: such
|
||||||
|
/// selections would not survive extraction anyway, so the tool tells the user
|
||||||
|
/// to widen the selection to the whole enclosing statement (the loop).
|
||||||
|
/// </summary>
|
||||||
|
public static class SelectionResolver
|
||||||
|
{
|
||||||
|
/// <summary>Line range of a missing selection (error path).</summary>
|
||||||
|
private static readonly SelectionReport Failure = new()
|
||||||
|
{
|
||||||
|
Succeeded = false,
|
||||||
|
Statements = Array.Empty<StatementSyntax>(),
|
||||||
|
Method = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Common ExitCode for a clean resolution error (see also 2 = usage).</summary>
|
||||||
|
public const int ExitError = 1;
|
||||||
|
|
||||||
|
public static SelectionReport Resolve(SyntaxTree tree, int startLine, int endLine)
|
||||||
|
{
|
||||||
|
var text = tree.GetText();
|
||||||
|
|
||||||
|
// ---- validate and normalize the requested line range ----
|
||||||
|
if (startLine < 1 || startLine > text.Lines.Count)
|
||||||
|
{
|
||||||
|
return Fail($"startLine {startLine} is out of range (file has {text.Lines.Count} lines)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endLine < startLine || endLine > text.Lines.Count)
|
||||||
|
{
|
||||||
|
return Fail($"endLine {endLine} is out of range (file has {text.Lines.Count} lines)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snap the endpoints past whitespace-only lines: an IDE "select lines"
|
||||||
|
// range often trails a blank line; blank lines are not statements, so
|
||||||
|
// skipping them is snapping, not splitting.
|
||||||
|
while (endLine > startLine && IsBlank(text.Lines[endLine - 1])) endLine--;
|
||||||
|
while (startLine < endLine && IsBlank(text.Lines[startLine - 1])) startLine++;
|
||||||
|
|
||||||
|
// Positions of the first / last non-whitespace character of the
|
||||||
|
// bounding lines. Trailing content (e.g. a `// comment` after the last
|
||||||
|
// statement) intentionally KEEPS the end position past the statement,
|
||||||
|
// which the boundary check below reports as "range ends mid-statement".
|
||||||
|
int selStart = FirstNonWhitespacePosition(text.Lines[startLine - 1]);
|
||||||
|
int selEnd = LastNonWhitespacePosition(text.Lines[endLine - 1]);
|
||||||
|
|
||||||
|
// ---- find the enclosing method ----
|
||||||
|
// Anchor on the first token of the selection: at statement start the
|
||||||
|
// token belongs to the statement we want (FindNode at exact boundaries
|
||||||
|
// is ambiguous, FindToken is not).
|
||||||
|
var firstToken = tree.GetRoot().FindToken(selStart);
|
||||||
|
var enclosingStatement = firstToken.Parent?.AncestorsAndSelf()
|
||||||
|
.OfType<StatementSyntax>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
var method = enclosingStatement?.AncestorsAndSelf()
|
||||||
|
.OfType<MethodDeclarationSyntax>()
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (method is null)
|
||||||
|
{
|
||||||
|
return Fail($"{startLine} does not sit inside a method (local functions and expression bodies are not supported in v1)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole selection must live inside ONE method body.
|
||||||
|
if (method.Body is null || selEnd > method.Body.FullSpan.End)
|
||||||
|
{
|
||||||
|
return Fail($"range {startLine}..{endLine} crosses a method boundary (selection must stay inside one method body)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- v1: only the method body's own statement list is selectable ----
|
||||||
|
if (enclosingStatement is not null && enclosingStatement.Parent != method.Body)
|
||||||
|
{
|
||||||
|
return Fail(
|
||||||
|
$"range {startLine}..{endLine} is nested inside \"{enclosingStatement.Kind()}\" — " +
|
||||||
|
"select the whole enclosing statement (e.g. the entire for-loop) instead");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- collect the whole statements covered by the range ----
|
||||||
|
var body = method.Body;
|
||||||
|
var selected = body.Statements
|
||||||
|
.Where(s => selStart <= s.SpanStart && s.Span.End <= selEnd)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (selected.Count == 0)
|
||||||
|
{
|
||||||
|
return Fail($"range {startLine}..{endLine} covers no whole top-level statement (mid-statement or empty range)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boundary checks: the selection must START at a statement's first
|
||||||
|
// token and END right after a statement's last token. Siblings never
|
||||||
|
// overlap, so equal boundaries also imply the selected run is
|
||||||
|
// contiguous (any statement between first and last lies inside the
|
||||||
|
// range and is therefore in `selected`).
|
||||||
|
if (selected[0].SpanStart != selStart)
|
||||||
|
{
|
||||||
|
return Fail($"range {startLine}..{endLine} starts inside a statement — snap to whole statements");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected[^1].Span.End != selEnd)
|
||||||
|
{
|
||||||
|
return Fail($"range {startLine}..{endLine} ends inside a statement (or picks up trailing text on the last line) — snap to whole statements");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SelectionReport
|
||||||
|
{
|
||||||
|
Succeeded = true,
|
||||||
|
Statements = selected,
|
||||||
|
Method = method,
|
||||||
|
StartLine = startLine,
|
||||||
|
EndLine = endLine,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SelectionReport Fail(string reason) => Failure with { Error = reason };
|
||||||
|
|
||||||
|
private static bool IsBlank(TextLine line) => string.IsNullOrWhiteSpace(line.ToString());
|
||||||
|
|
||||||
|
private static int FirstNonWhitespacePosition(TextLine line)
|
||||||
|
{
|
||||||
|
var s = line.ToString();
|
||||||
|
return line.Start + (s.Length - s.TrimStart().Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int LastNonWhitespacePosition(TextLine line)
|
||||||
|
{
|
||||||
|
var s = line.ToString();
|
||||||
|
return line.End - (s.Length - s.TrimEnd().Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>The suggested signature line of the extraction (parent spec:
|
||||||
|
/// "ends with a suggested signature: <c>int Extract(int i, Order order)</c>").</summary>
|
||||||
|
/// <param name="ReturnType">Return type, <c>void</c> when nothing must flow
|
||||||
|
/// out, or a tuple type when several declared-inside variables must.</param>
|
||||||
|
/// <param name="MethodName">v1 always suggests <c>Extract</c>.</param>
|
||||||
|
/// <param name="Params">The coherent parameter list (ref-ness preserved from
|
||||||
|
/// the classification).</param>
|
||||||
|
/// <param name="ReturnSlots">The individual return slot candidates (name +
|
||||||
|
/// type), name-ordered — the same order the tuple <see cref="ReturnType"/>
|
||||||
|
/// was built from, and the order em 04's codegen emits them in the appended
|
||||||
|
/// <c>return (...)</c> statement and the call-side declaration. Empty for a
|
||||||
|
/// void extraction.</param>
|
||||||
|
public sealed record SignatureSuggestion(
|
||||||
|
string ReturnType,
|
||||||
|
string MethodName,
|
||||||
|
IReadOnlyList<ParamSuggestion> Params,
|
||||||
|
IReadOnlyList<ReturnSuggestion> ReturnSlots);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns the raw em 02 buckets into ONE coherent signature line. The buckets
|
||||||
|
/// over-report on purpose (parent spec: a variable can sit in several
|
||||||
|
/// buckets); building a signature is where the overlaps are resolved — the
|
||||||
|
/// "promotion" em 02's comment promises.
|
||||||
|
///
|
||||||
|
/// RULES (v1):
|
||||||
|
/// - A variable DECLARED inside the selection cannot be a parameter of the
|
||||||
|
/// extracted method (it does not exist before the selection): drop it from
|
||||||
|
/// the params. If it is also a return candidate, it becomes the RETURN —
|
||||||
|
/// e.g. `total` (declared, reassigned, read after) in Summarize lands in
|
||||||
|
/// params[ref] ∧ returns raw, and promotes to the plain return value.
|
||||||
|
/// - A return candidate declared OUTSIDE the selection that the extraction
|
||||||
|
/// already receives by-ref (read inside ∧ written inside) keeps its ref
|
||||||
|
/// parameter: the write-back already flows the value out, and also
|
||||||
|
/// returning it would hand the caller the same value twice.
|
||||||
|
/// - A return candidate declared OUTSIDE that is NOT a param (written
|
||||||
|
/// inside, never read inside) becomes a return slot too: the caller
|
||||||
|
/// reassigns it from the extracted method's result.
|
||||||
|
/// - Trailing composite returns (`return a + b;`) are not nameable in v1 —
|
||||||
|
/// no return slot; the report's note suggests extracting a local first.
|
||||||
|
///
|
||||||
|
/// Determinism: parameters keep the classifier's ordinal-by-name order and
|
||||||
|
/// return slots are ordered by name, so the line is stable for tests.
|
||||||
|
/// </summary>
|
||||||
|
public static class SignatureBuilder
|
||||||
|
{
|
||||||
|
public static SignatureSuggestion Build(SemanticModel model, SelectionReport selection, ExtractionSuggestion suggestion)
|
||||||
|
{
|
||||||
|
var declaredInside = DeclaredInsideNames(model, selection);
|
||||||
|
var paramNames = suggestion.Params.Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var parameters = suggestion.Params
|
||||||
|
.Where(p => !declaredInside.Contains(p.Name))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var returnSlots = suggestion.Returns
|
||||||
|
.Where(r => declaredInside.Contains(r.Name) || !paramNames.Contains(r.Name))
|
||||||
|
.OrderBy(r => r.Name, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var returnType = returnSlots.Count switch
|
||||||
|
{
|
||||||
|
0 => "void",
|
||||||
|
1 => returnSlots[0].Type,
|
||||||
|
// Branch-insensitive over-approximation can over-report returns
|
||||||
|
// (decision #4); the tuple keeps every candidate flowing rather
|
||||||
|
// than guessing which is real.
|
||||||
|
_ => $"({string.Join(", ", returnSlots.Select(r => r.Type))})",
|
||||||
|
};
|
||||||
|
|
||||||
|
return new SignatureSuggestion(returnType, "Extract", parameters, returnSlots);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Names of the locals DECLARED inside the selection. v1 covers the
|
||||||
|
/// declaration shapes the fixture uses — local declarations, for-loop
|
||||||
|
/// headers (`for (int i = ...)`) and foreach headers; other declaring
|
||||||
|
/// shapes (pattern declarations, using/fixed statements, local functions)
|
||||||
|
/// are not selected by the v1 resolver and would need their own cases.
|
||||||
|
///
|
||||||
|
/// Names, not symbols, are matched against the bucket entries — sound
|
||||||
|
/// here because C# forbids a local from shadowing another local or a
|
||||||
|
/// parameter of the same method, so names are unique per method.
|
||||||
|
/// </summary>
|
||||||
|
public static HashSet<string> DeclaredInsideNames(SemanticModel model, SelectionReport selection)
|
||||||
|
{
|
||||||
|
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var statement in selection.Statements)
|
||||||
|
{
|
||||||
|
foreach (var node in statement.DescendantNodesAndSelf())
|
||||||
|
{
|
||||||
|
switch (node)
|
||||||
|
{
|
||||||
|
case LocalDeclarationStatementSyntax localDeclaration:
|
||||||
|
foreach (var variable in localDeclaration.Declaration.Variables)
|
||||||
|
{
|
||||||
|
if (model.GetDeclaredSymbol(variable) is ILocalSymbol)
|
||||||
|
{
|
||||||
|
names.Add(variable.Identifier.ValueText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ForStatementSyntax forStatement when forStatement.Declaration is not null:
|
||||||
|
foreach (var variable in forStatement.Declaration.Variables)
|
||||||
|
{
|
||||||
|
if (model.GetDeclaredSymbol(variable) is ILocalSymbol)
|
||||||
|
{
|
||||||
|
names.Add(variable.Identifier.ValueText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ForEachStatementSyntax forEach:
|
||||||
|
if (model.GetDeclaredSymbol(forEach) is ILocalSymbol)
|
||||||
|
{
|
||||||
|
names.Add(forEach.Identifier.ValueText);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ExtractMethod.Tooling;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Line-based unified diff (v1, hand-rolled, no dependencies): an LCS over
|
||||||
|
/// lines is backtracked into equal/delete/insert runs, and the runs are
|
||||||
|
/// printed as unified hunks with 3 lines of context (hunks closer than
|
||||||
|
/// 2×context lines apart merge). The shape is the familiar
|
||||||
|
/// <c>---/+++/@@</c> one — enough for a human to review the generated
|
||||||
|
/// refactoring; no patch-file semantics are promised.
|
||||||
|
/// </summary>
|
||||||
|
public static class UnifiedDiff
|
||||||
|
{
|
||||||
|
private const int Context = 3;
|
||||||
|
|
||||||
|
public static string Diff(string original, string modified, string originalLabel, string modifiedLabel)
|
||||||
|
{
|
||||||
|
var a = SplitLines(original);
|
||||||
|
var b = SplitLines(modified);
|
||||||
|
|
||||||
|
var sb = new StringBuilder($"--- {originalLabel}\n+++ {modifiedLabel}\n");
|
||||||
|
foreach (var hunk in BuildHunks(a, b))
|
||||||
|
{
|
||||||
|
sb.Append(hunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Split on newlines, dropping a trailing carriage return (CRLF tolerance).
|
||||||
|
/// A trailing newline does NOT start a new line: "a\n" is ONE line, so the
|
||||||
|
/// phantom empty element a naive Split('\n') leaves at the end is dropped
|
||||||
|
/// (it would inflate every hunk header by one). The empty string is zero
|
||||||
|
/// lines.
|
||||||
|
/// </summary>
|
||||||
|
private static string[] SplitLines(string text)
|
||||||
|
{
|
||||||
|
if (text.Length == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = text.Split('\n').Select(line => line.TrimEnd('\r')).ToArray();
|
||||||
|
return lines[^1].Length == 0 ? lines[..^1] : lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Op { Equal, Del, Ins }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// LCS + backtracking + hunk assembly. <c>lcs[i, j]</c> is the length of
|
||||||
|
/// the longest common suffix of <c>a[i..]</c> and <c>b[j..]</c>.
|
||||||
|
/// </summary>
|
||||||
|
private static IReadOnlyList<string> BuildHunks(string[] a, string[] b)
|
||||||
|
{
|
||||||
|
var n = a.Length;
|
||||||
|
var m = b.Length;
|
||||||
|
|
||||||
|
var lcs = new int[n + 1, m + 1];
|
||||||
|
for (var row = n - 1; row >= 0; row--)
|
||||||
|
{
|
||||||
|
for (var col = m - 1; col >= 0; col--)
|
||||||
|
{
|
||||||
|
lcs[row, col] = a[row] == b[col]
|
||||||
|
? lcs[row + 1, col + 1] + 1
|
||||||
|
: Math.Max(lcs[row + 1, col], lcs[row, col + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backtrack: equal lines advance both; on a mismatch prefer a deletion
|
||||||
|
// (ties go to delete, which keeps the printed block old-then-new).
|
||||||
|
var ops = new List<(Op Tag, int A, int B)>();
|
||||||
|
var i = 0;
|
||||||
|
var j = 0;
|
||||||
|
while (i < n && j < m)
|
||||||
|
{
|
||||||
|
if (a[i] == b[j])
|
||||||
|
{
|
||||||
|
ops.Add((Op.Equal, i, j));
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
else if (lcs[i + 1, j] >= lcs[i, j + 1])
|
||||||
|
{
|
||||||
|
ops.Add((Op.Del, i, j));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ops.Add((Op.Ins, i, j));
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < n)
|
||||||
|
{
|
||||||
|
ops.Add((Op.Del, i, j));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (j < m)
|
||||||
|
{
|
||||||
|
ops.Add((Op.Ins, i, j));
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A "region" is a maximal run of non-equal ops: [AStart, AEnd) ×
|
||||||
|
// [BStart, BEnd) in the two line arrays.
|
||||||
|
var regions = new List<(int AStart, int AEnd, int BStart, int BEnd)>();
|
||||||
|
var index = 0;
|
||||||
|
while (index < ops.Count)
|
||||||
|
{
|
||||||
|
if (ops[index].Tag == Op.Equal)
|
||||||
|
{
|
||||||
|
index++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = ops[index];
|
||||||
|
var regionAStart = first.Tag == Op.Del ? first.A : (index > 0 ? ops[index - 1].A + 1 : 0);
|
||||||
|
var regionBStart = first.B;
|
||||||
|
while (index < ops.Count && ops[index].Tag != Op.Equal)
|
||||||
|
{
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var last = ops[index - 1];
|
||||||
|
regions.Add((regionAStart,
|
||||||
|
last.Tag == Op.Del ? last.A + 1 : last.A,
|
||||||
|
regionBStart,
|
||||||
|
last.Tag == Op.Ins ? last.B + 1 : last.B));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend every region by Context lines (clamped), then merge regions
|
||||||
|
// whose extended ranges overlap.
|
||||||
|
var hunks = new List<(int AStart, int AEnd, int BStart, int BEnd)>();
|
||||||
|
foreach (var (aStart, aEnd, bStart, bEnd) in regions)
|
||||||
|
{
|
||||||
|
var (haStart, haEnd) = (Math.Max(0, aStart - Context), Math.Min(n, aEnd + Context));
|
||||||
|
var (hbStart, hbEnd) = (Math.Max(0, bStart - Context), Math.Min(m, bEnd + Context));
|
||||||
|
if (hunks.Count > 0)
|
||||||
|
{
|
||||||
|
var prev = hunks[^1];
|
||||||
|
if (haStart <= prev.AEnd && hbStart <= prev.BEnd)
|
||||||
|
{
|
||||||
|
hunks[^1] = (prev.AStart, Math.Max(prev.AEnd, haEnd), prev.BStart, Math.Max(prev.BEnd, hbEnd));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hunks.Add((haStart, haEnd, hbStart, hbEnd));
|
||||||
|
}
|
||||||
|
|
||||||
|
return hunks.Select(hunk => RenderHunk(ops, a, b, hunk.AStart, hunk.AEnd, hunk.BStart, hunk.BEnd)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RenderHunk(
|
||||||
|
IReadOnlyList<(Op Tag, int A, int B)> ops,
|
||||||
|
string[] a,
|
||||||
|
string[] b,
|
||||||
|
int aStart,
|
||||||
|
int aEnd,
|
||||||
|
int bStart,
|
||||||
|
int bEnd)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"@@ -{aStart + 1},{aEnd - aStart} +{bStart + 1},{bEnd - bStart} @@");
|
||||||
|
foreach (var (tag, aIndex, bIndex) in ops)
|
||||||
|
{
|
||||||
|
switch (tag)
|
||||||
|
{
|
||||||
|
case Op.Equal when aStart <= aIndex && aIndex < aEnd:
|
||||||
|
sb.Append(' ').AppendLine(a[aIndex]);
|
||||||
|
break;
|
||||||
|
case Op.Del when aStart <= aIndex && aIndex < aEnd:
|
||||||
|
sb.Append('-').AppendLine(a[aIndex]);
|
||||||
|
break;
|
||||||
|
case Op.Ins when bStart <= bIndex && bIndex < bEnd:
|
||||||
|
sb.Append('+').AppendLine(b[bIndex]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user