diff --git a/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml index af582deb1..a66920200 100644 --- a/.github/workflows/dotnet-build.yml +++ b/.github/workflows/dotnet-build.yml @@ -28,6 +28,15 @@ on: paths: - "Source/DotNET/**" - "Specifications/**" + - "TestApps/**" + # What the build compiles against, not only what it compiles. A package + # version bump touches none of the paths above, so it used to reach main + # without ever having been built - which is how a Chronicle release that + # added members to an interface Arc implements got merged unbuilt. + - "Directory.Build.props" + - "Directory.Packages*.props" + - "global.json" + - "Arc.slnx" - ".github/workflows/dotnet-build.yml" jobs: diff --git a/.github/workflows/markdown-verification.yml b/.github/workflows/markdown-verification.yml index 1309386aa..95f11510e 100644 --- a/.github/workflows/markdown-verification.yml +++ b/.github/workflows/markdown-verification.yml @@ -33,12 +33,12 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Check links in Documentation - uses: JustinBeckwith/linkinator-action@v1 + - name: Setup Node + uses: actions/setup-node@v4 with: - paths: 'Documentation/**/*.md' - markdown: true - recurse: true - redirects: allow - statusCodes: '403:ok' - linksToSkip: '^(https?:\/\/)?(localhost|127\.0\.0\.1)(:\d+)?(\/|$)' + node-version: 23.x + + # The same script contributors run locally, so that what CI checks and + # what verify-markdown.sh checks cannot drift apart. + - name: Check links in Documentation + run: ./Documentation/verify-links.sh diff --git a/Documentation/backend/asp-net-core/configuration.md b/Documentation/backend/asp-net-core/configuration.md index 820c7d063..1bbc36f37 100644 --- a/Documentation/backend/asp-net-core/configuration.md +++ b/Documentation/backend/asp-net-core/configuration.md @@ -126,7 +126,7 @@ builder.AddCratisArc(options => options.CorrelationId.HttpHeader = "X-My-Correlation-ID"; // Configure tenancy - options.Tenancy.HttpHeader = "X-Tenant-ID"; + options.Tenancy.HttpHeader = "X-Custom-Tenant"; // Configure generated APIs options.GeneratedApis.RoutePrefix = "myapi"; diff --git a/Documentation/backend/chronicle/index.md b/Documentation/backend/chronicle/index.md index 28a04a267..3b9378978 100644 --- a/Documentation/backend/chronicle/index.md +++ b/Documentation/backend/chronicle/index.md @@ -45,7 +45,7 @@ Without this package, Arc and Chronicle are independent. With it: | Topic | Description | | ----- | ----------- | | [Aggregates](aggregates/index.md) | Working with aggregate roots and event sourcing. | -| [Add event sourcing to an Arc slice](add-event-sourcing.md) | Move one database-backed slice to Chronicle while keeping its query and React screen in place. | +| [Add event sourcing to an Arc slice](add-event-sourcing.mdx) | Move one database-backed slice to Chronicle while keeping its query and React screen in place. | | [Cratis Package](cratis-package.md) | The convenience package for Arc + Chronicle applications. | | [React to an event](react-to-an-event.md) | Run side effects or follow-up commands from Chronicle events with reactors. | | [Commands](commands/index.md) | Returning events from commands, event source id resolution, and concurrency scoping. | diff --git a/Documentation/backend/chronicle/react-to-an-event.md b/Documentation/backend/chronicle/react-to-an-event.md index 0485f485e..5ba1e88e7 100644 --- a/Documentation/backend/chronicle/react-to-an-event.md +++ b/Documentation/backend/chronicle/react-to-an-event.md @@ -42,5 +42,5 @@ A reactor may be called more than once for the same event — during replay or r ## See also - [Returning Commands as Side Effects](./reactors/toc.yml) — execute commands as side effects from a reactor. -- [Add event sourcing to an Arc slice](./add-event-sourcing.md) — where reactors enter the Arc model. +- [Add event sourcing to an Arc slice](./add-event-sourcing.mdx) — where reactors enter the Arc model. - [Return a result or an error](/arc/scenarios/return-a-result-or-error/) — what the command you execute can return. diff --git a/Documentation/backend/chronicle/read-models/injecting-into-commands.md b/Documentation/backend/chronicle/read-models/injecting-into-commands.md index a07ee9d17..2d3acc400 100644 --- a/Documentation/backend/chronicle/read-models/injecting-into-commands.md +++ b/Documentation/backend/chronicle/read-models/injecting-into-commands.md @@ -179,6 +179,77 @@ public record AddItemToCart([Key] Guid CartId, Guid ProductId, int Quantity) Read models never emit events. If the decision must hold under concurrency, drive it from the aggregate or from a Chronicle [constraint](/chronicle/constraints/) rather than from projected state — read models are eventually consistent. +## Read models from other providers + +Injection is not Chronicle-only. Any provider that owns a read model's storage can make its `[ReadModel]` types injectable into a command, resolved by the same key, so a validator, `Provide()`, or `Handle()` takes the read model exactly as it would a Chronicle-backed one. + +### Entity Framework Core + +A `[ReadModel]` entity carried by a `ReadOnlyDbContext` becomes injectable once the context is registered — there is nothing extra to wire up: + +```csharp +[ReadModel] +public class Customer +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; +} + +public class CustomerDbContext(DbContextOptions options) : ReadOnlyDbContext(options) +{ + public DbSet Customers => Set(); +} +``` + +```csharp +[Command] +public record RenameCustomer([Key] Guid CustomerId, string NewName) +{ + public CustomerRenamed Handle(Customer customer) => new(customer.Id, NewName); +} +``` + +`WithEntityFrameworkCore()` discovers the `ReadOnlyDbContext`, and the command's resolved key (here the `[Key]` on `CustomerId`) loads the entity by its primary key. The primary key may be a `Guid`, `int`, `long`, `string`, or a `ConceptAs` wrapping one of those. + +The nullable rules are identical: a nullable `Customer?` receives `null` when no row exists, and a non-nullable `Customer` fails the command with [`ReadModelDoesNotExistForCommand`](./failures.md#readmodeldoesnotexistforcommand). + +### MongoDB + +`WithMongoDB()` does the same for the read models MongoDB holds. There is nothing to declare — a `[ReadModel]` becomes injectable, resolved by the document `_id`: + +```csharp +[ReadModel] +public record Customer(Guid Id, string Name) +{ + public static IEnumerable AllCustomers(IMongoCollection collection) => + collection.Find(_ => true).ToList(); +} +``` + +```csharp +[Command] +public record RenameCustomer([Key] Guid CustomerId, string NewName) +{ + public CustomerRenamed Handle(Customer customer) => new(customer.Id, NewName); +} +``` + +The id member is whichever one MongoDB maps to `_id` — a member named `Id` by convention, or the one marked `[BsonId]`. Like the EF primary key it may be a `Guid`, `int`, `long`, `string`, or a `ConceptAs` wrapping one of those. A read model with no member mapped to `_id` cannot be resolved by key, and injecting it fails with `MissingIdMapping`. + +### Which provider resolves a read model + +More than one provider can be able to load the same read model, and the order an application registers them in should not decide the outcome. What decides it is whether an artifact in the application says the provider *owns* the read model: + +| Provider | Owns a read model when | Claims it as | +|---|---|---| +| Chronicle | a projection, model-bound projection, or reducer targets it | declared | +| Entity Framework Core | a `DbSet` on a `ReadOnlyDbContext` carries it | declared | +| MongoDB | — a collection is served for any read model | fallback | + +A declaring provider always wins, in either registration order. MongoDB claims only what nothing else resolves, and it also leaves your own registration of a read model type alone. This matters beyond tidiness: Chronicle is the provider that releases a read model's compliance-protected values, so a read model Chronicle projects has to be resolved by Chronicle. + +To contribute a provider of your own, implement `ICanResolveReadModelForCommand` — reporting the types it resolves, the `ReadModelForCommandOwnership` it claims them with, and how to load one by key — and register it with `services.AddReadModelsForCommand(...)`. + ## Testing Seed the state the command should see with the `Given` builder — either the events behind it or a pinned instance — and execute through the real pipeline. See [Testing with Chronicle](../../testing/chronicle.md#testing-commands-that-take-read-model-dependencies). diff --git a/Documentation/backend/core/getting-started.md b/Documentation/backend/core/getting-started.md index 91942d547..97ba10410 100644 --- a/Documentation/backend/core/getting-started.md +++ b/Documentation/backend/core/getting-started.md @@ -195,7 +195,7 @@ Arc.Core supports standard .NET configuration: "HttpHeader": "X-Correlation-ID" }, "Tenancy": { - "HttpHeader": "X-Tenant-ID" + "HttpHeader": "X-Custom-Tenant" } } }, diff --git a/Documentation/backend/getting-started/index.md b/Documentation/backend/getting-started/index.md index 5018935e4..634728206 100644 --- a/Documentation/backend/getting-started/index.md +++ b/Documentation/backend/getting-started/index.md @@ -7,7 +7,7 @@ Get an Arc backend up and building features. These first pages use a plain datab Once you have a project: -- **[Your first command and query](./your-first-command.md)** — build a backend slice end to end: a command with `Handle()`, the read model it writes, and the live query that serves it. +- **[Your first command and query](./your-first-command.mdx)** — build a backend slice end to end: a command with `Handle()`, the read model it writes, and the live query that serves it. - **[MongoDB integration](../mongodb/index.md)** — configure Arc over MongoDB collections and observable change streams. - **[Entity Framework integration](../entity-framework/getting-started.md)** — configure Arc over DbContexts and observed DbSets. diff --git a/Documentation/backend/index.md b/Documentation/backend/index.md index 23547eb3d..43a009dec 100644 --- a/Documentation/backend/index.md +++ b/Documentation/backend/index.md @@ -46,5 +46,5 @@ Arc meets the rest of your stack: | [Open API](./open-api/index.md) | OpenAPI/Swagger generation. | | [Code Analysis](./code-analysis/index.md) | Analyzers and fixers that catch mistakes at compile time. | -Building the UI on top? Head to the [frontend](../frontend/index.md), which consumes everything here +Building the UI on top? Head to the [frontend](../frontend/index.mdx), which consumes everything here through the generated proxies. diff --git a/Documentation/backend/tenancy/configuration.md b/Documentation/backend/tenancy/configuration.md index eec2c3ab7..ea251bdd9 100644 --- a/Documentation/backend/tenancy/configuration.md +++ b/Documentation/backend/tenancy/configuration.md @@ -23,7 +23,7 @@ builder.AddCratisArcCore(options => "Arc": { "Tenancy": { "ResolverType": "Header", - "HttpHeader": "X-Tenant-ID" + "HttpHeader": "X-Custom-Tenant" } } } diff --git a/Documentation/backend/tenancy/resolvers.md b/Documentation/backend/tenancy/resolvers.md index 3359d4b3d..3449e1942 100644 --- a/Documentation/backend/tenancy/resolvers.md +++ b/Documentation/backend/tenancy/resolvers.md @@ -11,7 +11,7 @@ Resolves the tenant ID from an HTTP header. ```csharp builder.AddCratisArcCore(options => { - options.UseHeaderTenancy("X-Tenant-ID"); + options.UseHeaderTenancy("X-Custom-Tenant"); }); ``` @@ -50,11 +50,11 @@ Resolves the tenant ID from the first segment of the request hostname. When the ```csharp builder.AddCratisArcCore(options => { - options.UseSubdomainTenancy("X-Tenant-ID"); + options.UseSubdomainTenancy("X-Custom-Tenant"); }); ``` -A request to `acme.myapp.com` resolves the tenant as `acme`. A request to `myapp.com` falls back to the `X-Tenant-ID` header. This pattern is useful for SaaS applications where each tenant is routed through its own subdomain. +A request to `acme.myapp.com` resolves the tenant as `acme`. A request to `myapp.com` falls back to the `X-Custom-Tenant` header. This pattern is useful for SaaS applications where each tenant is routed through its own subdomain. Default fallback header: `x-cratis-tenant-id` diff --git a/Documentation/frontend/react/index.md b/Documentation/frontend/react/index.md index 22a020935..f74431f4f 100644 --- a/Documentation/frontend/react/index.md +++ b/Documentation/frontend/react/index.md @@ -28,7 +28,7 @@ those two with validation, scopes, and identity. | [Identity](./identity.md) | Who the user is, and what they're allowed to see and do. | | [Dialogs](./dialogs.md) | Consistent dialog handling for command and data-entry flows. | | [Proxy Generation](../../backend/proxy-generation/index.md) | How the typed proxies you import here are generated from C#. | -| [Storybook](./storybook.md) | The Storybook for the components Arc exposes. | +| [Storybook](./storybook.mdx) | The Storybook for the components Arc exposes. | | [Story Components](./stories) | Building good-looking, consistent stories. | Prefer a structured, testable approach for complex screens? See [MVVM with React](../react.mvvm/index.md). diff --git a/Documentation/generating-a-screenplay.md b/Documentation/generating-a-screenplay.md index cf553c09b..9ea987b80 100644 --- a/Documentation/generating-a-screenplay.md +++ b/Documentation/generating-a-screenplay.md @@ -240,7 +240,7 @@ These are part of the language, but nothing in C# says them, so a generated docu ### Detail Screenplay cannot represent -These exist in your application and do not reach the document — most of them because the language has no counterpart. Almost all are reported as a diagnostic when encountered, so the document tells you it is silent about them. The two rows naming no code are the exception: nothing is reported for those. +These exist in your application and do not reach the document, because the language has no counterpart. Each is reported as a diagnostic when encountered, so the document tells you it is silent about them. | In Arc or Chronicle | Why it is not in the document | |---|---| @@ -251,8 +251,8 @@ These exist in your application and do not reach the document — most of them b | Inline `policy` code and requirements built in code | `RequireAssertion(…)` and a policy registered from an `AuthorizationPolicy` built elsewhere are code. `RequireAuthenticatedUser`, `RequireRole`, and `RequireClaim` given the values it accepts are recovered; the rest is reported as `SP0026` — including a `RequireClaim` naming only a claim type, which a policy condition has no way to state. | | The event source id from a `(TKey, TEvent)` handler | The event is recovered; the identifier saying *which* event source it goes to has no counterpart. `SP0013`. | | Emptying a scope with `[ClearWith]`; removing a child with `[RemovedWith]` on the property holding it | Nothing in the model a projection is built from carries a scope being emptied again, so `[ClearWith]` has nowhere to go (`SP0015`). A removal does have somewhere — but it is read from the type of the child, alongside the events filling that child in, so the same removal written beside the collection is reported as `SP0007` instead. | -| Read model tags | A read model has no declaration of its own — it appears as the type a query returns — so there is nowhere to hang a tag. Tags on *events* are recovered and written out. | -| Query paging and sorting, custom routes | These say how a model is served rather than what it is. The parameters the host fills in are left out, and a route template has no counterpart. | +| Read model tags | A read model has no declaration of its own — it appears as the type a query returns — so there is nowhere to hang a tag. Tags on *events* are recovered and written out. `SP0042`. | +| Query paging and sorting, custom routes | These say how a model is served rather than what it is. The parameters the host fills in are left out, and a route template — `[Path]`, `[Route]`, or a template on an HTTP verb — has no counterpart. `SP0041`. | If a generated `.play` is missing something you expected, the diagnostics are the first place to look — the omission is almost always reported. diff --git a/Documentation/tutorial/books-and-relationships.mdx b/Documentation/tutorial/books-and-relationships.mdx index e02392506..3c644fc02 100644 --- a/Documentation/tutorial/books-and-relationships.mdx +++ b/Documentation/tutorial/books-and-relationships.mdx @@ -144,4 +144,4 @@ The `authorId` is context the form needs to be *valid* from the start, so it goe - A **relationship as a foreign key + a filtered query**: `BooksForAuthor` reads exactly one author's catalog, live, with no join. - A React screen that composes the two: a list of authors, each rendering its own live catalog. -Two features, related, both live. We keep *saying* "it stays live" — next we'll make that concrete and watch a screen update itself the instant the data changes. [Let's make it live →](./real-time) +Two features, related, both live. We keep *saying* "it stays live" — next we'll make that concrete and watch a screen update itself the instant the data changes. [Let's make it live →](./real-time.mdx) diff --git a/Documentation/tutorial/first-slice.mdx b/Documentation/tutorial/first-slice.mdx index 031f818df..2862eb6c3 100644 --- a/Documentation/tutorial/first-slice.mdx +++ b/Documentation/tutorial/first-slice.mdx @@ -255,4 +255,4 @@ In one folder, read top to bottom: That's a complete vertical slice, backend to browser, over a plain database. The next feature will be another folder just like it. -There's one problem, though: right now a librarian can register an author with a blank name, or the same author twice, and nothing stops them. A real app has to say no. [Let's make it trustworthy →](./validation) +There's one problem, though: right now a librarian can register an author with a blank name, or the same author twice, and nothing stops them. A real app has to say no. [Let's make it trustworthy →](./validation.mdx) diff --git a/Documentation/tutorial/index.md b/Documentation/tutorial/index.md index 963882e42..c470c8a18 100644 --- a/Documentation/tutorial/index.md +++ b/Documentation/tutorial/index.md @@ -68,10 +68,10 @@ An Arc project running locally with a database. The [Get started](/arc/backend/g ## The tour -1. **[Your first full-stack slice](./first-slice)** — register an author from C# all the way to a live React screen, fully typed. -2. **[Make it trustworthy](./validation)** — reject bad input with a validator and a uniqueness rule, and show the reason in the form. -3. **[Relate your slices](./books-and-relationships)** — add books that belong to an author, and read them back. -4. **[Make it live](./real-time)** — observable queries that update the screen the moment the data changes. -5. **[Decide who can do what](./authorization)** — lock the catalog down with role-based authorization. +1. **[Your first full-stack slice](./first-slice.mdx)** — register an author from C# all the way to a live React screen, fully typed. +2. **[Make it trustworthy](./validation.mdx)** — reject bad input with a validator and a uniqueness rule, and show the reason in the form. +3. **[Relate your slices](./books-and-relationships.mdx)** — add books that belong to an author, and read them back. +4. **[Make it live](./real-time.mdx)** — observable queries that update the screen the moment the data changes. +5. **[Decide who can do what](./authorization.mdx)** — lock the catalog down with role-based authorization. -Each chapter ends where the next begins. By the end you'll have a real full-stack feature — and the model to build your own. Ready? [Let's build the first slice →](./first-slice) +Each chapter ends where the next begins. By the end you'll have a real full-stack feature — and the model to build your own. Ready? [Let's build the first slice →](./first-slice.mdx) diff --git a/Documentation/tutorial/real-time.mdx b/Documentation/tutorial/real-time.mdx index 480317f6b..2141cb9b5 100644 --- a/Documentation/tutorial/real-time.mdx +++ b/Documentation/tutorial/real-time.mdx @@ -79,4 +79,4 @@ That's the whole switch between "load once" and "stay live." You choose per quer - A clear picture of **why your screens update themselves** — observable queries are standing subscriptions, database to browser. - The one-line difference between a **one-shot** query and a **live** one — the return type, nothing else. -The catalog is live. There's just one thing left before it's a real back office: right now *anyone* can register authors and add books. Let's decide who's allowed. [Lock it down →](./authorization) +The catalog is live. There's just one thing left before it's a real back office: right now *anyone* can register authors and add books. Let's decide who's allowed. [Lock it down →](./authorization.mdx) diff --git a/Documentation/tutorial/validation.mdx b/Documentation/tutorial/validation.mdx index 4da45058e..8663fca71 100644 --- a/Documentation/tutorial/validation.mdx +++ b/Documentation/tutorial/validation.mdx @@ -101,4 +101,4 @@ One rule on the value type, one rule on the command, and both ends of the app ho - A `RegisterAuthorValidator` **business rule** that checks existing state in the database, with a unique index behind it for the hard guarantee. - A React form that surfaces both — client-side and server-side — with no extra frontend code. -Our author is trustworthy now. But an author with no books is a lonely thing. Next we'll give them a catalog — a second feature that *relates* to this one. [Let's add books →](./books-and-relationships) +Our author is trustworthy now. But an author with no books is a lonely thing. Next we'll give them a catalog — a second feature that *relates* to this one. [Let's add books →](./books-and-relationships.mdx) diff --git a/Documentation/verify-links.sh b/Documentation/verify-links.sh new file mode 100755 index 000000000..2399ca7df --- /dev/null +++ b/Documentation/verify-links.sh @@ -0,0 +1,100 @@ +#!/bin/bash + +# Link Verification Script +# Verifies the links in the documentation of this repository, and is used both +# by verify-markdown.sh and by the Markdown Verification workflow so the two +# cannot drift apart. +# +# What is verified here: +# - relative links between pages in this repository +# - external http(s) links +# +# What is not verified here: +# Links written as site-absolute paths (/arc/..., /chronicle/...) address the +# aggregated documentation site, where each product's Documentation folder is +# mounted under its own prefix. They cannot resolve against a single +# repository and are verified when that site is built. They are skipped by +# rule rather than by name, so a new prefix needs no change here. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Pinned so that a new major version cannot silently change what is scanned. +LINKINATOR_VERSION="8.0.2" + +# linkinator serves the scanned files from a local web server, so every internal +# link resolves to http://127.0.0.1:/. Skipping what falls outside +# /Documentation/ therefore skips exactly the site-absolute paths this +# repository cannot resolve, and nothing else. The pattern must never match the +# crawl root itself - a pattern that did is what turned this check into a no-op. +SITE_ABSOLUTE_LINKS='^https?://(localhost|127\.0\.0\.1):[0-9]+/(?!Documentation/)' + +cd "$ROOT_DIR" + +if ! command -v npx &> /dev/null; then + echo "Error: npx is not installed. Please install Node.js and npm." + exit 1 +fi + +echo "Checking links in Documentation..." +echo "This may take a few minutes to check all links..." +echo "" + +set +e +# One glob covering both extensions rather than one per extension: linkinator +# aborts on any glob that matches nothing, so a separate .mdx glob would fail a +# repository whose pages are all .md while every link in it is fine. +# +# NO_COLOR keeps the scan summary free of escape codes, so the link count below +# can be read out of it. +OUTPUT=$(NO_COLOR=1 npx --yes "linkinator@$LINKINATOR_VERSION" \ + "Documentation/**/*.{md,mdx}" \ + --markdown \ + --recurse \ + --directory-listing \ + --verbosity error \ + --status-code "403:ok" \ + --skip "$SITE_ABSOLUTE_LINKS" 2>&1) +LINKINATOR_EXIT_CODE=$? +set -e + +echo "$OUTPUT" + +# How many links were actually looked at. A check that scans nothing reports +# success while verifying nothing, which reads exactly like a passing check. +# +# Matched mid-word on purpose: linkinator writes "Successfully scanned N links" +# when every link resolved and "Scanned N links" when one did not, and the count +# has to be read out of either. +# +# The color codes are stripped first. linkinator wraps the count in them whenever +# it decides the output is a terminal, and FORCE_COLOR makes it decide that no +# matter what NO_COLOR says - which would leave the count unreadable and report a +# real link failure as "the globs matched nothing". +ESC=$(printf '\033') +SCANNED=$(echo "$OUTPUT" | sed -E "s/${ESC}\[[0-9;]*m//g" | grep -oE 'canned [0-9]+ link' | grep -oE '[0-9]+' | tail -1) + +echo "" + +if [ -z "$SCANNED" ]; then + echo "✗ Link verification could not determine how many links were scanned." + echo " linkinator reported no scan summary - the file globs most likely matched nothing." + exit 1 +fi + +if [ "$SCANNED" -eq 0 ]; then + echo "✗ Link verification scanned zero links." + echo " Nothing was checked, so this run proves nothing about the links in Documentation." + exit 1 +fi + +if [ $LINKINATOR_EXIT_CODE -ne 0 ]; then + echo "✗ Link verification failed - $SCANNED links scanned." + exit $LINKINATOR_EXIT_CODE +fi + +echo "✓ Link verification passed - $SCANNED links scanned." +echo " Site-absolute links (/arc/..., /chronicle/...) resolve only on the aggregated" +echo " documentation site and are verified when that site is built." diff --git a/Documentation/verify-markdown.sh b/Documentation/verify-markdown.sh index 9fae3f01d..8bc90ce92 100755 --- a/Documentation/verify-markdown.sh +++ b/Documentation/verify-markdown.sh @@ -13,10 +13,9 @@ echo "Markdown Verification" echo "==========================================" echo "" -# Check if running from repository root or Documentation folder -if [ "$(basename "$PWD")" = "Documentation" ]; then - cd .. -fi +# Both steps address paths from the repository root, whether this script is run +# from there or from the Documentation folder. +cd "$ROOT_DIR" echo "Working directory: $PWD" echo "" @@ -32,8 +31,13 @@ if ! command -v npx &> /dev/null; then exit 1 fi -npx markdownlint-cli2 "Documentation/**/*.md" -LINT_EXIT_CODE=$? +# Each step captures its own exit code and the run continues, so that a failing +# lint step does not leave the state of the links unreported. +if npx --yes markdownlint-cli2 "Documentation/**/*.md"; then + LINT_EXIT_CODE=0 +else + LINT_EXIT_CODE=$? +fi echo "" if [ $LINT_EXIT_CODE -eq 0 ]; then @@ -48,17 +52,11 @@ echo "==========================================" echo "Step 2: Running link verification..." echo "==========================================" echo "" -echo "This may take a few minutes to check all links..." -echo "" - -npx linkinator "Documentation/**/*.md" --markdown --recurse --verbosity error --status-code "403:ok" --skip "^(https?:\\/\\/)?(localhost|127\\.0\\.0\\.1)(:\\d+)?(\\/|$)" -LINK_EXIT_CODE=$? -echo "" -if [ $LINK_EXIT_CODE -eq 0 ]; then - echo "✓ Link verification passed!" +if "$SCRIPT_DIR/verify-links.sh"; then + LINK_EXIT_CODE=0 else - echo "✗ Link verification failed with exit code $LINK_EXIT_CODE" + LINK_EXIT_CODE=$? fi echo "" diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/a_read_model_resolver.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/a_read_model_resolver.cs new file mode 100644 index 000000000..e230c854b --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/a_read_model_resolver.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; + +namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions; + +/// +/// A test that resolves pinned instances for the read model types it owns. +/// +/// The read model types this resolver owns. +/// The pinned instances to resolve, keyed by read model type; a type with no entry resolves to null. +/// How strongly this resolver claims its read model types; declared unless stated otherwise. +public class a_read_model_resolver( + IEnumerable readModelTypes, + IReadOnlyDictionary? instances = null, + ReadModelForCommandOwnership ownership = ReadModelForCommandOwnership.Declared) : ICanResolveReadModelForCommand +{ + readonly IReadOnlyDictionary _instances = instances ?? new Dictionary(); + + public IEnumerable ReadModelTypes { get; } = [.. readModelTypes]; + + public ReadModelForCommandOwnership Ownership { get; } = ownership; + + public Task Resolve(Type readModelType, CommandContext commandContext) => + Task.FromResult(_instances.TryGetValue(readModelType, out var instance) ? instance : null); +} diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/read_models.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/read_models.cs new file mode 100644 index 000000000..f673f8c3c --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/read_models.cs @@ -0,0 +1,12 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions; + +#pragma warning disable SA1402, SA1649 // File may only contain a single type, File name should match first type name + +public record FirstReadModel(string Value); + +public record SecondReadModel(string Value); + +#pragma warning restore SA1402, SA1649 diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_for_command.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_for_command.cs new file mode 100644 index 000000000..83539b925 --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_for_command.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions; + +public class when_adding_read_models_for_command : Specification +{ + IServiceCollection _services; + RegisteredReadModelTypes _registeredReadModelTypes; + + void Establish() => _services = new ServiceCollection(); + + void Because() + { + _services.AddReadModelsForCommand(new a_read_model_resolver([typeof(FirstReadModel)])); + _registeredReadModelTypes = _services + .First(descriptor => descriptor.ServiceType == typeof(RegisteredReadModelTypes)) + .ImplementationInstance as RegisteredReadModelTypes; + } + + [Fact] void should_register_a_scoped_resolver_for_the_read_model() => + _services.ShouldContain(descriptor => descriptor.ServiceType == typeof(FirstReadModel) && descriptor.Lifetime == ServiceLifetime.Scoped); + + [Fact] void should_register_the_read_model_type_as_a_known_read_model() => _registeredReadModelTypes.Contains(typeof(FirstReadModel)).ShouldBeTrue(); +} diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_a_declaring_provider/and_a_fallback_provider_already_claimed_the_read_model.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_a_declaring_provider/and_a_fallback_provider_already_claimed_the_read_model.cs new file mode 100644 index 000000000..108daec2f --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_a_declaring_provider/and_a_fallback_provider_already_claimed_the_read_model.cs @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Execution; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions.when_adding_read_models_from_a_declaring_provider; + +public class and_a_fallback_provider_already_claimed_the_read_model : Specification +{ + ServiceProvider _rootProvider; + IServiceScope _scope; + FirstReadModel _fromDeclaringProvider; + object? _resolved; + + void Establish() + { + _fromDeclaringProvider = new FirstReadModel("declared"); + var commandContext = new CommandContext(CorrelationId.New(), typeof(object), new object(), [], new CommandContextValues()); + + _rootProvider = new ServiceCollection() + .AddScoped(_ => commandContext) + .AddReadModelsForCommand(new a_read_model_resolver( + [typeof(FirstReadModel)], + new Dictionary { [typeof(FirstReadModel)] = new FirstReadModel("fallback") }, + ReadModelForCommandOwnership.Fallback)) + .AddReadModelsForCommand(new a_read_model_resolver( + [typeof(FirstReadModel)], + new Dictionary { [typeof(FirstReadModel)] = _fromDeclaringProvider })) + .BuildServiceProvider(); + _scope = _rootProvider.CreateScope(); + } + + void Because() => _resolved = _scope.ServiceProvider.GetService(typeof(FirstReadModel)); + + [Fact] void should_take_the_read_model_over_from_the_fallback_provider() => _resolved.ShouldEqual(_fromDeclaringProvider); + + void Destroy() + { + _scope.Dispose(); + _rootProvider.Dispose(); + } +} diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_a_fallback_provider/and_a_declaring_provider_already_claimed_the_read_model.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_a_fallback_provider/and_a_declaring_provider_already_claimed_the_read_model.cs new file mode 100644 index 000000000..a4a79acc8 --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_a_fallback_provider/and_a_declaring_provider_already_claimed_the_read_model.cs @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Execution; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions.when_adding_read_models_from_a_fallback_provider; + +public class and_a_declaring_provider_already_claimed_the_read_model : Specification +{ + ServiceProvider _rootProvider; + IServiceScope _scope; + FirstReadModel _fromDeclaringProvider; + object? _resolved; + + void Establish() + { + _fromDeclaringProvider = new FirstReadModel("declared"); + var commandContext = new CommandContext(CorrelationId.New(), typeof(object), new object(), [], new CommandContextValues()); + + _rootProvider = new ServiceCollection() + .AddScoped(_ => commandContext) + .AddReadModelsForCommand(new a_read_model_resolver( + [typeof(FirstReadModel)], + new Dictionary { [typeof(FirstReadModel)] = _fromDeclaringProvider })) + .AddReadModelsForCommand(new a_read_model_resolver( + [typeof(FirstReadModel)], + new Dictionary { [typeof(FirstReadModel)] = new FirstReadModel("fallback") }, + ReadModelForCommandOwnership.Fallback)) + .BuildServiceProvider(); + _scope = _rootProvider.CreateScope(); + } + + void Because() => _resolved = _scope.ServiceProvider.GetService(typeof(FirstReadModel)); + + [Fact] void should_leave_the_read_model_with_the_declaring_provider() => _resolved.ShouldEqual(_fromDeclaringProvider); + + void Destroy() + { + _scope.Dispose(); + _rootProvider.Dispose(); + } +} diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_a_fallback_provider/and_no_other_provider_claimed_the_read_model.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_a_fallback_provider/and_no_other_provider_claimed_the_read_model.cs new file mode 100644 index 000000000..bd9fcdf7a --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_a_fallback_provider/and_no_other_provider_claimed_the_read_model.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions.when_adding_read_models_from_a_fallback_provider; + +public class and_no_other_provider_claimed_the_read_model : Specification +{ + IServiceCollection _services; + RegisteredReadModelTypes _registeredReadModelTypes; + + void Establish() => _services = new ServiceCollection(); + + void Because() + { + _services.AddReadModelsForCommand(new a_read_model_resolver([typeof(FirstReadModel)], ownership: ReadModelForCommandOwnership.Fallback)); + _registeredReadModelTypes = _services + .First(descriptor => descriptor.ServiceType == typeof(RegisteredReadModelTypes)) + .ImplementationInstance as RegisteredReadModelTypes; + } + + [Fact] void should_register_a_scoped_resolver_for_the_read_model() => + _services.ShouldContain(descriptor => descriptor.ServiceType == typeof(FirstReadModel) && descriptor.Lifetime == ServiceLifetime.Scoped); + + [Fact] void should_register_the_read_model_type_as_a_known_read_model() => _registeredReadModelTypes.Contains(typeof(FirstReadModel)).ShouldBeTrue(); +} diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_a_fallback_provider/and_the_application_registered_the_read_model_itself.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_a_fallback_provider/and_the_application_registered_the_read_model_itself.cs new file mode 100644 index 000000000..62557232d --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_a_fallback_provider/and_the_application_registered_the_read_model_itself.cs @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Execution; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions.when_adding_read_models_from_a_fallback_provider; + +public class and_the_application_registered_the_read_model_itself : Specification +{ + ServiceProvider _rootProvider; + IServiceScope _scope; + FirstReadModel _fromApplication; + RegisteredReadModelTypes _registeredReadModelTypes; + object? _resolved; + + void Establish() + { + _fromApplication = new FirstReadModel("the application's own"); + var commandContext = new CommandContext(CorrelationId.New(), typeof(object), new object(), [], new CommandContextValues()); + + var services = new ServiceCollection() + .AddScoped(_ => commandContext) + .AddSingleton(_fromApplication) + .AddReadModelsForCommand(new a_read_model_resolver( + [typeof(FirstReadModel)], + new Dictionary { [typeof(FirstReadModel)] = new FirstReadModel("fallback") }, + ReadModelForCommandOwnership.Fallback)); + + _registeredReadModelTypes = services + .First(descriptor => descriptor.ServiceType == typeof(RegisteredReadModelTypes)) + .ImplementationInstance as RegisteredReadModelTypes; + + _rootProvider = services.BuildServiceProvider(); + _scope = _rootProvider.CreateScope(); + } + + void Because() => _resolved = _scope.ServiceProvider.GetService(typeof(FirstReadModel)); + + [Fact] void should_leave_the_application_registration_in_place() => _resolved.ShouldEqual(_fromApplication); + [Fact] void should_not_treat_the_read_model_as_resolved_by_key() => _registeredReadModelTypes.Contains(typeof(FirstReadModel)).ShouldBeFalse(); + + void Destroy() + { + _scope.Dispose(); + _rootProvider.Dispose(); + } +} diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_two_providers.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_two_providers.cs new file mode 100644 index 000000000..31ed71c31 --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_adding_read_models_from_two_providers.cs @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions; + +public class when_adding_read_models_from_two_providers : Specification +{ + IServiceCollection _services; + RegisteredReadModelTypes _registeredReadModelTypes; + + void Establish() => _services = new ServiceCollection(); + + void Because() + { + _services.AddReadModelsForCommand(new a_read_model_resolver([typeof(FirstReadModel)])); + _services.AddReadModelsForCommand(new a_read_model_resolver([typeof(SecondReadModel)])); + _registeredReadModelTypes = _services + .First(descriptor => descriptor.ServiceType == typeof(RegisteredReadModelTypes)) + .ImplementationInstance as RegisteredReadModelTypes; + } + + [Fact] void should_register_a_scoped_resolver_for_the_first_provider_read_model() => + _services.ShouldContain(descriptor => descriptor.ServiceType == typeof(FirstReadModel) && descriptor.Lifetime == ServiceLifetime.Scoped); + + [Fact] void should_register_a_scoped_resolver_for_the_second_provider_read_model() => + _services.ShouldContain(descriptor => descriptor.ServiceType == typeof(SecondReadModel) && descriptor.Lifetime == ServiceLifetime.Scoped); + + [Fact] void should_keep_the_first_provider_read_model_in_the_known_set() => _registeredReadModelTypes.Contains(typeof(FirstReadModel)).ShouldBeTrue(); + [Fact] void should_add_the_second_provider_read_model_to_the_known_set() => _registeredReadModelTypes.Contains(typeof(SecondReadModel)).ShouldBeTrue(); +} diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_resolving_through_di/and_the_instance_is_absent.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_resolving_through_di/and_the_instance_is_absent.cs new file mode 100644 index 000000000..4a0a16d54 --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_resolving_through_di/and_the_instance_is_absent.cs @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Execution; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions.when_resolving_through_di; + +public class and_the_instance_is_absent : Specification +{ + ServiceProvider _rootProvider; + IServiceScope _scope; + object? _resolved; + + void Establish() + { + var commandContext = new CommandContext(CorrelationId.New(), typeof(object), new object(), [], new CommandContextValues()); + + _rootProvider = new ServiceCollection() + .AddScoped(_ => commandContext) + .AddReadModelsForCommand(new a_read_model_resolver([typeof(FirstReadModel)])) + .BuildServiceProvider(); + _scope = _rootProvider.CreateScope(); + } + + void Because() => _resolved = _scope.ServiceProvider.GetService(typeof(FirstReadModel)); + + [Fact] void should_resolve_to_null() => _resolved.ShouldBeNull(); + + void Destroy() + { + _scope.Dispose(); + _rootProvider.Dispose(); + } +} diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_resolving_through_di/and_the_instance_is_present.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_resolving_through_di/and_the_instance_is_present.cs new file mode 100644 index 000000000..f993e8234 --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelForCommandServiceCollectionExtensions/when_resolving_through_di/and_the_instance_is_present.cs @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Execution; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.Queries.for_ReadModelForCommandServiceCollectionExtensions.when_resolving_through_di; + +public class and_the_instance_is_present : Specification +{ + ServiceProvider _rootProvider; + IServiceScope _scope; + FirstReadModel _instance; + object? _resolved; + + void Establish() + { + _instance = new FirstReadModel("materialized"); + var instances = new Dictionary { [typeof(FirstReadModel)] = _instance }; + var commandContext = new CommandContext(CorrelationId.New(), typeof(object), new object(), [], new CommandContextValues()); + + _rootProvider = new ServiceCollection() + .AddScoped(_ => commandContext) + .AddReadModelsForCommand(new a_read_model_resolver([typeof(FirstReadModel)], instances)) + .BuildServiceProvider(); + _scope = _rootProvider.CreateScope(); + } + + void Because() => _resolved = _scope.ServiceProvider.GetService(typeof(FirstReadModel)); + + [Fact] void should_resolve_the_pinned_instance() => _resolved.ShouldEqual(_instance); + + void Destroy() + { + _scope.Dispose(); + _rootProvider.Dispose(); + } +} diff --git a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/given/a_classifier.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/given/a_classifier.cs similarity index 79% rename from Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/given/a_classifier.cs rename to Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/given/a_classifier.cs index 6f1cb43fb..9d06908d9 100644 --- a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/given/a_classifier.cs +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/given/a_classifier.cs @@ -2,13 +2,11 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Reflection; -using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands; -using Cratis.Chronicle.Events; using Cratis.Execution; using Microsoft.Extensions.DependencyInjection; -namespace Cratis.Arc.Chronicle.ReadModels.for_ReadModelUnresolvableDependencyClassifier.given; +namespace Cratis.Arc.Queries.for_ReadModelUnresolvableDependencyClassifier.given; public class a_classifier : Specification { @@ -23,13 +21,15 @@ void Establish() _parameter = typeof(Consumer).GetMethod(nameof(Consumer.Method))!.GetParameters()[0]; } - protected IServiceProvider ServiceProviderWith(bool registerReadModel, EventSourceId eventSourceId) + protected IServiceProvider ServiceProviderWith(bool registerReadModel, string? resolvedKey) { - var commandContextValues = new CommandContextValues + var commandContextValues = new CommandContextValues(); + if (resolvedKey is not null) { - { WellKnownCommandContextKeys.EventSourceId, eventSourceId } - }; - var commandContext = new CommandContext(CorrelationId.New(), typeof(TestCommand), new TestCommand(), [], commandContextValues, null); + commandContextValues[CommandContextKeys.ResolvedKey] = resolvedKey; + } + + var commandContext = new CommandContext(CorrelationId.New(), typeof(TestCommand), new TestCommand(), [], commandContextValues); var services = new ServiceCollection(); services.AddScoped(_ => commandContext); diff --git a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_a_registered_read_model_with_a_valid_id.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_a_registered_read_model_with_a_valid_key.cs similarity index 82% rename from Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_a_registered_read_model_with_a_valid_id.cs rename to Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_a_registered_read_model_with_a_valid_key.cs index c9564c865..1783f8c82 100644 --- a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_a_registered_read_model_with_a_valid_id.cs +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_a_registered_read_model_with_a_valid_key.cs @@ -2,15 +2,14 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Cratis.Arc.Validation; -using Cratis.Chronicle.Events; -namespace Cratis.Arc.Chronicle.ReadModels.for_ReadModelUnresolvableDependencyClassifier.when_classifying; +namespace Cratis.Arc.Queries.for_ReadModelUnresolvableDependencyClassifier.when_classifying; -public class and_the_dependency_is_a_registered_read_model_with_a_valid_id : given.a_classifier +public class and_the_dependency_is_a_registered_read_model_with_a_valid_key : given.a_classifier { IServiceProvider _serviceProvider; - void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: true, EventSourceId.New()); + void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: true, resolvedKey: "some-key"); void Because() => _result = _classifier.TryClassifyAsClientInput(_parameter, _serviceProvider, out _failure); diff --git a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_not_a_registered_read_model.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_not_a_registered_read_model.cs similarity index 76% rename from Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_not_a_registered_read_model.cs rename to Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_not_a_registered_read_model.cs index d0a264469..f07241a8e 100644 --- a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_not_a_registered_read_model.cs +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_dependency_is_not_a_registered_read_model.cs @@ -1,15 +1,13 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using Cratis.Chronicle.Events; - -namespace Cratis.Arc.Chronicle.ReadModels.for_ReadModelUnresolvableDependencyClassifier.when_classifying; +namespace Cratis.Arc.Queries.for_ReadModelUnresolvableDependencyClassifier.when_classifying; public class and_the_dependency_is_not_a_registered_read_model : given.a_classifier { IServiceProvider _serviceProvider; - void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: false, EventSourceId.New()); + void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: false, resolvedKey: "some-key"); void Because() => _result = _classifier.TryClassifyAsClientInput(_parameter, _serviceProvider, out _failure); diff --git a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_event_source_id_is_unspecified.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_resolved_key_is_absent.cs similarity index 67% rename from Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_event_source_id_is_unspecified.cs rename to Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_resolved_key_is_absent.cs index 32cdbf0f6..2ac0fd4b7 100644 --- a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_event_source_id_is_unspecified.cs +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_resolved_key_is_absent.cs @@ -1,15 +1,13 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using Cratis.Chronicle.Events; +namespace Cratis.Arc.Queries.for_ReadModelUnresolvableDependencyClassifier.when_classifying; -namespace Cratis.Arc.Chronicle.ReadModels.for_ReadModelUnresolvableDependencyClassifier.when_classifying; - -public class and_the_event_source_id_is_unspecified : given.a_classifier +public class and_the_resolved_key_is_absent : given.a_classifier { IServiceProvider _serviceProvider; - void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: true, EventSourceId.Unspecified); + void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: true, resolvedKey: null); void Because() => _result = _classifier.TryClassifyAsClientInput(_parameter, _serviceProvider, out _failure); diff --git a/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_resolved_key_is_empty.cs b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_resolved_key_is_empty.cs new file mode 100644 index 000000000..28e168b7e --- /dev/null +++ b/Source/DotNET/Arc.Core.Specs/Queries/for_ReadModelUnresolvableDependencyClassifier/when_classifying/and_the_resolved_key_is_empty.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Queries.for_ReadModelUnresolvableDependencyClassifier.when_classifying; + +public class and_the_resolved_key_is_empty : given.a_classifier +{ + IServiceProvider _serviceProvider; + + void Establish() => _serviceProvider = ServiceProviderWith(registerReadModel: true, resolvedKey: string.Empty); + + void Because() => _result = _classifier.TryClassifyAsClientInput(_parameter, _serviceProvider, out _failure); + + [Fact] void should_not_classify_as_client_input() => _result.ShouldBeFalse(); + [Fact] void should_not_produce_a_failure() => _failure.ShouldBeNull(); +} diff --git a/Source/DotNET/Arc.Core/Commands/CommandContextExtensions.cs b/Source/DotNET/Arc.Core/Commands/CommandContextExtensions.cs new file mode 100644 index 000000000..84de0058b --- /dev/null +++ b/Source/DotNET/Arc.Core/Commands/CommandContextExtensions.cs @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Commands; + +/// +/// Provides provider-neutral extension methods for the . +/// +public static class CommandContextExtensions +{ + /// + /// Gets the provider-neutral resolved key from the command context values, if present. + /// + /// The to get the resolved key from. + /// The resolved key, or null when the command carried no usable key. + public static string? GetResolvedKey(this CommandContext commandContext) => + commandContext.Values.TryGetValue(CommandContextKeys.ResolvedKey, out var value) && value is string resolvedKey + ? resolvedKey + : null; +} diff --git a/Source/DotNET/Arc.Core/Commands/CommandContextKeys.cs b/Source/DotNET/Arc.Core/Commands/CommandContextKeys.cs new file mode 100644 index 000000000..44c0cb724 --- /dev/null +++ b/Source/DotNET/Arc.Core/Commands/CommandContextKeys.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Commands; + +/// +/// Provider-neutral keys for values held on a . +/// +public static class CommandContextKeys +{ + /// + /// The key for the provider-neutral resolved key in the command context values. + /// + /// + /// The resolved key is the command's key expressed as a plain string, independent of any backing store. A provider + /// that owns the key resolution (for example the Chronicle integration, which resolves an event source id) writes it + /// so that a read model backing provider — which need not depend on the resolving provider — can load a read model + /// by the same key. + /// + public const string ResolvedKey = "resolvedKey"; +} diff --git a/Source/DotNET/Arc.Core/Queries/ICanResolveReadModelForCommand.cs b/Source/DotNET/Arc.Core/Queries/ICanResolveReadModelForCommand.cs new file mode 100644 index 000000000..e641a6f53 --- /dev/null +++ b/Source/DotNET/Arc.Core/Queries/ICanResolveReadModelForCommand.cs @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; + +namespace Cratis.Arc.Queries; + +/// +/// Defines a provider that can resolve read models by key for a command, so a read model backed by any store — Chronicle, +/// Entity Framework Core, or another provider — can be injected into command-scoped code. +/// +/// +/// A read model is injectable into command-scoped code (a CommandValidator<>, Provide(), or +/// Handle()) because it is resolvable by the command's resolved key. The command pipeline resolves each read model +/// dependency from DI by type, and a provider contributes a command-scoped, by-key resolver for the read model types it +/// owns through this abstraction. Providers coexist without claiming each other's read model types — each reports only +/// the types it can resolve through , and how strongly it claims them through +/// . +/// +public interface ICanResolveReadModelForCommand +{ + /// + /// Gets the read model types this provider can resolve by key for a command. + /// + IEnumerable ReadModelTypes { get; } + + /// + /// Gets how strongly this provider claims the read model types it reports. + /// + ReadModelForCommandOwnership Ownership { get; } + + /// + /// Resolves the read model of the given type for the current command context, keyed by the command's resolved key. + /// + /// The type of read model to resolve. + /// The to resolve from. + /// The resolved read model instance, or null when no instance exists for the command's key. + Task Resolve(Type readModelType, CommandContext commandContext); +} diff --git a/Source/DotNET/Chronicle/ReadModels/ReadModelDoesNotExistForCommand.cs b/Source/DotNET/Arc.Core/Queries/ReadModelDoesNotExistForCommand.cs similarity index 60% rename from Source/DotNET/Chronicle/ReadModels/ReadModelDoesNotExistForCommand.cs rename to Source/DotNET/Arc.Core/Queries/ReadModelDoesNotExistForCommand.cs index 91c091b8c..24e171fb6 100644 --- a/Source/DotNET/Chronicle/ReadModels/ReadModelDoesNotExistForCommand.cs +++ b/Source/DotNET/Arc.Core/Queries/ReadModelDoesNotExistForCommand.cs @@ -3,21 +3,21 @@ using Cratis.Arc.Validation; -namespace Cratis.Arc.Chronicle.ReadModels; +namespace Cratis.Arc.Queries; /// /// Exception that gets thrown when a command requires a non-nullable read model that does not exist for the command's -/// event source id. +/// resolved key. /// /// -/// The command carried a usable event source id, but no read model exists for it. A nullable read model dependency -/// tolerates this by receiving null; a non-nullable ("must exist") dependency cannot, and this would otherwise surface -/// as a server error (HTTP 500) leaking the read model type. It implements , so the -/// pipeline surfaces it as a validation failure (HTTP 400) with a message that does not reveal the read model type. +/// The command carried a usable key, but no read model exists for it. A nullable read model dependency tolerates this by +/// receiving null; a non-nullable ("must exist") dependency cannot, and this would otherwise surface as a server error +/// (HTTP 500) leaking the read model type. It implements , so the pipeline surfaces it as +/// a validation failure (HTTP 400) with a message that does not reveal the read model type. /// /// The type of read model that does not exist. public class ReadModelDoesNotExistForCommand(Type readModelType) - : Exception($"Read model of type '{readModelType.FullName}' does not exist for the command's event source id."), + : Exception($"Read model of type '{readModelType.FullName}' does not exist for the command's resolved key."), IValidationFailure { /// diff --git a/Source/DotNET/Arc.Core/Queries/ReadModelForCommandOwnership.cs b/Source/DotNET/Arc.Core/Queries/ReadModelForCommandOwnership.cs new file mode 100644 index 000000000..18ceb9f8c --- /dev/null +++ b/Source/DotNET/Arc.Core/Queries/ReadModelForCommandOwnership.cs @@ -0,0 +1,29 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.Queries; + +/// +/// Represents how strongly an claims the read model types it reports. +/// +/// +/// More than one provider can be able to resolve the same read model type, and the application does not control the +/// order its providers are registered in. The ownership a provider declares — rather than that order — decides which one +/// resolves the type. +/// +public enum ReadModelForCommandOwnership +{ + /// + /// An artifact in the application says the provider owns the read model: a Chronicle projection or reducer targeting + /// it, or a DbSet carrying it on a read model DbContext. A declaring provider claims the type even when + /// something else already resolves it. + /// + Declared = 0, + + /// + /// The provider can resolve any read model it reports — its store simply holds a document per read model — but + /// nothing in the application says it owns them. It claims only the read model types nothing else already resolves, + /// leaving both a declaring provider and the application's own registration untouched. + /// + Fallback = 1 +} diff --git a/Source/DotNET/Arc.Core/Queries/ReadModelForCommandServiceCollectionExtensions.cs b/Source/DotNET/Arc.Core/Queries/ReadModelForCommandServiceCollectionExtensions.cs new file mode 100644 index 000000000..306fab251 --- /dev/null +++ b/Source/DotNET/Arc.Core/Queries/ReadModelForCommandServiceCollectionExtensions.cs @@ -0,0 +1,76 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Arc.Queries; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for for registering command-scoped read model resolution. +/// +public static class ReadModelForCommandServiceCollectionExtensions +{ + /// + /// Registers a provider's read model types for command-scoped, by-key resolution. + /// + /// The to add to. + /// The that owns and resolves the read model types. + /// The service collection for continuation. + /// + /// For each read model type the resolver claims, a scoped factory is registered that delegates to the resolver, so the + /// command pipeline resolves the read model from DI by type like any other dependency. The set of registered read + /// model types is additive: multiple providers can contribute their own types and the classification of a missing + /// read model as invalid client input sees the union across all of them. + /// + /// Which types a provider claims follows from its rather than + /// from the order the application registers its providers in. A + /// provider claims every type it reports, taking over one another provider already resolves. A + /// provider claims only the types nothing else resolves yet, and a + /// declaring provider registered after it still takes those over — so a declaring provider wins either way round. + /// + /// + public static IServiceCollection AddReadModelsForCommand(this IServiceCollection services, ICanResolveReadModelForCommand resolver) + { + var claimed = new List(); + foreach (var readModelType in resolver.ReadModelTypes) + { + if (resolver.Ownership == ReadModelForCommandOwnership.Declared) + { + services.RemoveAll(readModelType); + } + else if (services.Any(descriptor => descriptor.ServiceType == readModelType)) + { + // A fallback provider fills gaps — it never takes over a read model type something else already resolves, + // be that a declaring provider registered before it or the application's own registration. + continue; + } + + services.AddScoped(readModelType, serviceProvider => + { + // Resolve the read model from the same scope the dependency is being resolved in, so the resolver can + // reach scoped collaborators (the Chronicle IReadModels, the EF Core DbContext, the MongoDB collection) + // through the context. + var commandContext = serviceProvider.GetRequiredService() with { ServiceProvider = serviceProvider }; + return resolver.Resolve(readModelType, commandContext).GetAwaiter().GetResult()!; + }); + + claimed.Add(readModelType); + } + + var existing = services + .FirstOrDefault(descriptor => descriptor.ServiceType == typeof(RegisteredReadModelTypes))? + .ImplementationInstance as RegisteredReadModelTypes; + + var union = (existing?.Types ?? []) + .Concat(claimed) + .Distinct() + .ToArray(); + + services.RemoveAll(); + services.AddSingleton(new RegisteredReadModelTypes(union)); + + return services; + } +} diff --git a/Source/DotNET/Chronicle/ReadModels/ReadModelUnresolvableDependencyClassifier.cs b/Source/DotNET/Arc.Core/Queries/ReadModelUnresolvableDependencyClassifier.cs similarity index 66% rename from Source/DotNET/Chronicle/ReadModels/ReadModelUnresolvableDependencyClassifier.cs rename to Source/DotNET/Arc.Core/Queries/ReadModelUnresolvableDependencyClassifier.cs index aa57f3822..cd947a0d3 100644 --- a/Source/DotNET/Chronicle/ReadModels/ReadModelUnresolvableDependencyClassifier.cs +++ b/Source/DotNET/Arc.Core/Queries/ReadModelUnresolvableDependencyClassifier.cs @@ -3,26 +3,24 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; -using Cratis.Arc.Chronicle.Commands; using Cratis.Arc.Commands; using Cratis.Arc.DependencyInjection; -using Cratis.Chronicle.Events; using Cratis.DependencyInjection; using Microsoft.Extensions.DependencyInjection; -namespace Cratis.Arc.Chronicle.ReadModels; +namespace Cratis.Arc.Queries; /// /// Represents an that recognizes a non-nullable read model dependency -/// which does not exist for the command's event source id as invalid client input rather than a server fault. +/// which does not exist for the command's resolved key as invalid client input rather than a server fault. /// /// -/// The classifier only runs when a non-nullable dependency resolved to null. For a read model that is invoked through -/// , a null result can only mean the entity does not -/// exist for a valid event source id: an unspecified id throws -/// before this point. It therefore classifies a registered read model dependency with a valid event source id as a +/// The classifier only runs when a non-nullable dependency resolved to null. For a read model resolved through an +/// , a null result can only mean the entity does not exist for a usable key: +/// a command carrying no usable key throws inside the resolver +/// before this point. It therefore classifies a registered read model dependency with a usable resolved key as a /// (HTTP 400). Any dependency that is not a registered read model, or where -/// no event source id is available, is left for the default server-error behavior so misconfigurations are not masked. +/// no usable key is available, is left for the default server-error behavior so misconfigurations are not masked. /// [Singleton] public class ReadModelUnresolvableDependencyClassifier : IUnresolvableDependencyClassifier @@ -39,7 +37,7 @@ public bool TryClassifyAsClientInput(ParameterInfo parameter, IServiceProvider s } var commandContext = serviceProvider.GetService(); - if (commandContext is null || commandContext.GetEventSourceId() == EventSourceId.Unspecified) + if (commandContext is null || string.IsNullOrEmpty(commandContext.GetResolvedKey())) { return false; } diff --git a/Source/DotNET/Chronicle/ReadModels/RegisteredReadModelTypes.cs b/Source/DotNET/Arc.Core/Queries/RegisteredReadModelTypes.cs similarity index 60% rename from Source/DotNET/Chronicle/ReadModels/RegisteredReadModelTypes.cs rename to Source/DotNET/Arc.Core/Queries/RegisteredReadModelTypes.cs index 6cb353756..21e5adc81 100644 --- a/Source/DotNET/Chronicle/ReadModels/RegisteredReadModelTypes.cs +++ b/Source/DotNET/Arc.Core/Queries/RegisteredReadModelTypes.cs @@ -1,17 +1,28 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -namespace Cratis.Arc.Chronicle.ReadModels; +namespace Cratis.Arc.Queries; /// /// Holds the set of read model types that were registered for command-scoped resolution, so consumers can tell a read /// model dependency apart from an unrelated one without re-resolving it. /// +/// +/// The set is additive across every provider that contributes a command-scoped resolver: a read model backed by +/// Chronicle and one backed by another provider (for example Entity Framework Core) are both registered here, so the +/// classification of a missing read model as invalid client input works uniformly regardless of where the read model is +/// materialized. +/// /// The read model types registered for command-scoped resolution. public class RegisteredReadModelTypes(IEnumerable types) { readonly HashSet _types = [.. types]; + /// + /// Gets the read model types registered for command-scoped resolution. + /// + public IEnumerable Types => _types; + /// /// Determines whether the given type is a registered read model. /// diff --git a/Source/DotNET/Chronicle/ReadModels/UnableToResolveReadModelFromCommandContext.cs b/Source/DotNET/Arc.Core/Queries/UnableToResolveReadModelFromCommandContext.cs similarity index 68% rename from Source/DotNET/Chronicle/ReadModels/UnableToResolveReadModelFromCommandContext.cs rename to Source/DotNET/Arc.Core/Queries/UnableToResolveReadModelFromCommandContext.cs index 283411ed1..443ed5961 100644 --- a/Source/DotNET/Chronicle/ReadModels/UnableToResolveReadModelFromCommandContext.cs +++ b/Source/DotNET/Arc.Core/Queries/UnableToResolveReadModelFromCommandContext.cs @@ -3,17 +3,17 @@ using Cratis.Arc.Validation; -namespace Cratis.Arc.Chronicle.ReadModels; +namespace Cratis.Arc.Queries; /// /// Exception that gets thrown when not being able to resolve a read model from command context. /// /// Type of read model that could not be resolved. /// -/// A read model is keyed by the command's event source id, so a command that carries no usable identifier can never -/// resolve one — that is invalid client input, not a server fault. This implements so -/// the command pipeline surfaces it as a validation failure (HTTP 400). The detailed message (naming the read model -/// type) is for server logs only; the client sees the generic validation message. +/// A read model is keyed by the command's resolved key, so a command that carries no usable identifier can never resolve +/// one — that is invalid client input, not a server fault. This implements so the +/// command pipeline surfaces it as a validation failure (HTTP 400). The detailed message (naming the read model type) is +/// for server logs only; the client sees the generic validation message. /// public class UnableToResolveReadModelFromCommandContext(Type readModelType) : Exception($"Unable to resolve read model of type '{readModelType.FullName}' from command context. Make sure the command has a property that is assignable to EventSourceId or marked with [Key] attribute"), diff --git a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelServiceCollectionExtensions/when_resolving_read_model/without_event_source_id.cs b/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelServiceCollectionExtensions/when_resolving_read_model/without_event_source_id.cs index 3ec50cbb4..5f67c92c9 100644 --- a/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelServiceCollectionExtensions/when_resolving_read_model/without_event_source_id.cs +++ b/Source/DotNET/Chronicle.Specs/ReadModels/for_ReadModelServiceCollectionExtensions/when_resolving_read_model/without_event_source_id.cs @@ -1,6 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Cratis.Arc.Queries; using Cratis.Arc.Validation; namespace Cratis.Arc.Chronicle.ReadModels.for_ReadModelServiceCollectionExtensions.when_resolving_read_model; diff --git a/Source/DotNET/Chronicle.Testing/EventStoreForScenario.cs b/Source/DotNET/Chronicle.Testing/EventStoreForScenario.cs index 33b6d9d58..c98050968 100644 --- a/Source/DotNET/Chronicle.Testing/EventStoreForScenario.cs +++ b/Source/DotNET/Chronicle.Testing/EventStoreForScenario.cs @@ -8,6 +8,8 @@ using Cratis.Chronicle.Events.Constraints; using Cratis.Chronicle.EventSequences; using Cratis.Chronicle.EventStoreSubscriptions; +using Cratis.Chronicle.ExternalServices; +using Cratis.Chronicle.Identities; using Cratis.Chronicle.Jobs; using Cratis.Chronicle.Observation; using Cratis.Chronicle.Projections; @@ -96,6 +98,14 @@ internal sealed class EventStoreForScenario(EventScenario eventScenario, IReadMo public IEventSeeding Seeding => throw new NotSupportedException("Seeding is not supported for command scenarios."); + /// + public IIdentityManager Identities => + throw new NotSupportedException("Identity management is not supported for command scenarios."); + + /// + public IExternalServices ExternalServices => + throw new NotSupportedException("External services are not supported for command scenarios."); + /// public Task DiscoverAll() => Task.CompletedTask; diff --git a/Source/DotNET/Chronicle/Commands/EventSourceValuesProvider.cs b/Source/DotNET/Chronicle/Commands/EventSourceValuesProvider.cs index ad2d086fc..a84f5b064 100644 --- a/Source/DotNET/Chronicle/Commands/EventSourceValuesProvider.cs +++ b/Source/DotNET/Chronicle/Commands/EventSourceValuesProvider.cs @@ -15,13 +15,37 @@ public class EventSourceValuesProvider(ILogger logger { /// public CommandContextValues Provide(object command) + { + var eventSourceId = ResolveEventSourceId(command); + + // Also expose the id as the provider-neutral resolved key so a read model backing provider that does not depend + // on Chronicle (for example Entity Framework Core) can load a read model by the same key. An unspecified id + // carries no usable key, so the neutral key is empty in that case. + return new CommandContextValues + { + { WellKnownCommandContextKeys.EventSourceId, eventSourceId }, + { Cratis.Arc.Commands.CommandContextKeys.ResolvedKey, NeutralKeyFrom(eventSourceId) } + }; + } + + /// + /// Converts an event source id into the provider-neutral resolved key string. + /// + /// The event source id to convert. + /// The id value as a string, or an empty string when the id is unspecified. + static string NeutralKeyFrom(EventSourceId eventSourceId) => + eventSourceId == EventSourceId.Unspecified ? string.Empty : eventSourceId.Value; + + /// + /// Resolves the event source id for a command, from a self-composing command or from a key property. + /// + /// The command to resolve the event source id for. + /// The resolved event source id, or when none could be composed. + EventSourceId ResolveEventSourceId(object command) { if (command is ICanProvideEventSourceId provider) { - return new CommandContextValues - { - { WellKnownCommandContextKeys.EventSourceId, ProvidedEventSourceIdOrUnspecified(provider) } - }; + return ProvidedEventSourceIdOrUnspecified(provider); } var eventSourceId = EventSourceId.New(); @@ -30,10 +54,7 @@ public CommandContextValues Provide(object command) eventSourceId = command.GetEventSourceId(); } - return new CommandContextValues - { - { WellKnownCommandContextKeys.EventSourceId, eventSourceId } - }; + return eventSourceId; } /// diff --git a/Source/DotNET/Chronicle/ReadModels/ChronicleReadModelForCommandResolver.cs b/Source/DotNET/Chronicle/ReadModels/ChronicleReadModelForCommandResolver.cs new file mode 100644 index 000000000..895b30756 --- /dev/null +++ b/Source/DotNET/Chronicle/ReadModels/ChronicleReadModelForCommandResolver.cs @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Arc.Queries; +using Cratis.Chronicle.ReadModels; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.Chronicle.ReadModels; + +/// +/// Represents an that resolves read models backed by Chronicle through +/// , keyed by the command's resolved key (the event source id). +/// +/// The read model types Chronicle can resolve by key. +public class ChronicleReadModelForCommandResolver(IEnumerable readModelTypes) : ICanResolveReadModelForCommand +{ + /// + public IEnumerable ReadModelTypes { get; } = readModelTypes; + + /// + /// + /// A Chronicle projection, model-bound projection, or reducer in the application targets each of these read models, + /// which is what makes Chronicle own them — and what makes Chronicle the provider that has to resolve them, since it + /// is the only one that releases their compliance-protected values. + /// + public ReadModelForCommandOwnership Ownership => ReadModelForCommandOwnership.Declared; + + /// + public Task Resolve(Type readModelType, CommandContext commandContext) + { + var readModels = commandContext.ServiceProvider!.GetRequiredService(); + return Task.FromResult(ReadModelServiceCollectionExtensions.ResolveReadModel(readModelType, commandContext, readModels)); + } +} diff --git a/Source/DotNET/Chronicle/ReadModels/ReadModelServiceCollectionExtensions.cs b/Source/DotNET/Chronicle/ReadModels/ReadModelServiceCollectionExtensions.cs index 4d3a79afa..60e1652b9 100644 --- a/Source/DotNET/Chronicle/ReadModels/ReadModelServiceCollectionExtensions.cs +++ b/Source/DotNET/Chronicle/ReadModels/ReadModelServiceCollectionExtensions.cs @@ -62,19 +62,11 @@ public static IServiceCollection AddReadModels(this IServiceCollection services, .Concat(ReadModelTargetsFrom(clientArtifactsProvider.Reducers, typeof(IReducerFor<>))) .Distinct() .ToArray(); - foreach (var readModelType in readModelTypes) - { - services.RemoveAll(readModelType); - services.AddScoped(readModelType, serviceProvider => ResolveReadModel( - readModelType, - serviceProvider.GetRequiredService(), - serviceProvider.GetRequiredService())!); - } - // Register the set of read model types so a command-scoped resolution failure for a non-nullable read model - // can be told apart from an unrelated missing dependency and surfaced as a validation failure (HTTP 400). - services.RemoveAll(); - services.AddSingleton(new RegisteredReadModelTypes(readModelTypes)); + // Contribute the Chronicle-backed read model types to the provider-neutral command-scope resolution. This + // registers a scoped, by-key resolver for each type and adds them to the additive set that lets a missing + // non-nullable read model be surfaced as a validation failure (HTTP 400), coexisting with any other provider. + services.AddReadModelsForCommand(new ChronicleReadModelForCommandResolver(readModelTypes)); return services; } diff --git a/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/read_models.cs b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/read_models.cs new file mode 100644 index 000000000..0d4b78007 --- /dev/null +++ b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/read_models.cs @@ -0,0 +1,43 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Queries.ModelBound; +using Microsoft.EntityFrameworkCore; + +namespace Cratis.Arc.EntityFrameworkCore.for_EntityFrameworkReadModelForCommandResolver; + +#pragma warning disable SA1402, SA1649 // File may only contain a single type, File name should match first type name + +[ReadModel] +public class CustomerReadModel +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; +} + +public class PlainEntity +{ + public Guid Id { get; set; } + public string Description { get; set; } = string.Empty; +} + +/// +/// A read-only DbContext carrying a read model entity and a plain entity, used to verify discovery. +/// +/// The options to be used by the DbContext. +public class CustomerReadModelDbContext(DbContextOptions options) : ReadOnlyDbContext(options) +{ + public DbSet Customers => Set(); + public DbSet Plain => Set(); +} + +/// +/// A writable DbContext over the same read model entity, used to seed data the resolver reads back. +/// +/// The options to be used by the DbContext. +public class SeedableCustomerDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Customers => Set(); +} + +#pragma warning restore SA1402, SA1649 diff --git a/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_discovering_read_model_types/and_a_context_has_read_model_and_plain_entities.cs b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_discovering_read_model_types/and_a_context_has_read_model_and_plain_entities.cs new file mode 100644 index 000000000..7b2891524 --- /dev/null +++ b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_discovering_read_model_types/and_a_context_has_read_model_and_plain_entities.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.EntityFrameworkCore.for_EntityFrameworkReadModelForCommandResolver.when_discovering_read_model_types; + +public class and_a_context_has_read_model_and_plain_entities : Specification +{ + IReadOnlyDictionary _result; + + void Because() => _result = EntityFrameworkReadModelForCommandResolver.DiscoverReadModelTypes([typeof(CustomerReadModelDbContext)]); + + [Fact] void should_map_the_read_model_entity_to_its_context() => _result[typeof(CustomerReadModel)].ShouldEqual(typeof(CustomerReadModelDbContext)); + [Fact] void should_not_map_the_plain_entity() => _result.ContainsKey(typeof(PlainEntity)).ShouldBeFalse(); +} diff --git a/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_injecting_through_di/and_the_read_model_exists.cs b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_injecting_through_di/and_the_read_model_exists.cs new file mode 100644 index 000000000..09aa26246 --- /dev/null +++ b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_injecting_through_di/and_the_read_model_exists.cs @@ -0,0 +1,63 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Execution; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.EntityFrameworkCore.for_EntityFrameworkReadModelForCommandResolver.when_injecting_through_di; + +/// +/// Exercises the exact seam the command pipeline uses to inject a read model: a scoped resolution by the read model type +/// through the service provider, keyed by the command's resolved key. +/// +public class and_the_read_model_exists : Specification +{ + SeedableCustomerDbContext _dbContext; + ServiceProvider _rootProvider; + IServiceScope _scope; + Guid _customerId; + object? _resolved; + + void Establish() + { + _customerId = Guid.NewGuid(); + + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:") + .Options; + + _dbContext = new SeedableCustomerDbContext(options); + _dbContext.Database.OpenConnection(); + _dbContext.Database.EnsureCreated(); + _dbContext.Customers.Add(new CustomerReadModel { Id = _customerId, Name = "Test" }); + _dbContext.SaveChanges(); + + var values = new CommandContextValues + { + [CommandContextKeys.ResolvedKey] = _customerId.ToString() + }; + var commandContext = new CommandContext(CorrelationId.New(), typeof(object), new object(), [], values); + + _rootProvider = new ServiceCollection() + .AddSingleton(_dbContext) + .AddScoped(_ => commandContext) + .AddReadModelsForCommand(new EntityFrameworkReadModelForCommandResolver( + new Dictionary { [typeof(CustomerReadModel)] = typeof(SeedableCustomerDbContext) })) + .BuildServiceProvider(); + _scope = _rootProvider.CreateScope(); + } + + void Because() => _resolved = _scope.ServiceProvider.GetService(typeof(CustomerReadModel)); + + [Fact] void should_inject_the_read_model() => _resolved.ShouldNotBeNull(); + [Fact] void should_inject_the_read_model_with_the_matching_key() => ((CustomerReadModel)_resolved!).Id.ShouldEqual(_customerId); + + void Destroy() + { + _dbContext.Database.CloseConnection(); + _scope.Dispose(); + _rootProvider.Dispose(); + } +} diff --git a/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_instance_is_absent.cs b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_instance_is_absent.cs new file mode 100644 index 000000000..2b4a3435f --- /dev/null +++ b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_instance_is_absent.cs @@ -0,0 +1,11 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.EntityFrameworkCore.for_EntityFrameworkReadModelForCommandResolver.when_resolving; + +public class and_the_instance_is_absent : given.a_seeded_resolver +{ + void Because() => ResolveCustomerWithKey(_customerId.ToString()); + + [Fact] void should_resolve_to_null() => _resolved.ShouldBeNull(); +} diff --git a/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_instance_is_present.cs b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_instance_is_present.cs new file mode 100644 index 000000000..658f1f9ad --- /dev/null +++ b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_instance_is_present.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.EntityFrameworkCore.for_EntityFrameworkReadModelForCommandResolver.when_resolving; + +public class and_the_instance_is_present : given.a_seeded_resolver +{ + void Establish() => SeedCustomer(); + + void Because() => ResolveCustomerWithKey(_customerId.ToString()); + + [Fact] void should_resolve_the_read_model() => _resolved.ShouldNotBeNull(); + [Fact] void should_resolve_the_read_model_with_the_matching_key() => ((CustomerReadModel)_resolved!).Id.ShouldEqual(_customerId); +} diff --git a/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_key_is_absent.cs b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_key_is_absent.cs new file mode 100644 index 000000000..0591f093a --- /dev/null +++ b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/and_the_key_is_absent.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Queries; +using Cratis.Arc.Validation; + +namespace Cratis.Arc.EntityFrameworkCore.for_EntityFrameworkReadModelForCommandResolver.when_resolving; + +public class and_the_key_is_absent : given.a_seeded_resolver +{ + void Establish() => SeedCustomer(); + + void Because() => CatchResolveCustomerWithKey(resolvedKey: null); + + [Fact] void should_throw_unable_to_resolve_read_model_from_command_context() => _exception.ShouldBeOfExactType(); + [Fact] void should_be_a_validation_failure() => (_exception is IValidationFailure).ShouldBeTrue(); +} diff --git a/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/given/a_seeded_resolver.cs b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/given/a_seeded_resolver.cs new file mode 100644 index 000000000..3de11e7c9 --- /dev/null +++ b/Source/DotNET/EntityFrameworkCore.Specs/for_EntityFrameworkReadModelForCommandResolver/when_resolving/given/a_seeded_resolver.cs @@ -0,0 +1,68 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Execution; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.EntityFrameworkCore.for_EntityFrameworkReadModelForCommandResolver.when_resolving.given; + +public class a_seeded_resolver : Specification +{ + protected SeedableCustomerDbContext _dbContext; + protected EntityFrameworkReadModelForCommandResolver _resolver; + protected IServiceProvider _serviceProvider; + protected Guid _customerId; + protected object? _resolved; + protected Exception _exception; + + void Establish() + { + _customerId = Guid.NewGuid(); + + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:") + .Options; + + _dbContext = new SeedableCustomerDbContext(options); + _dbContext.Database.OpenConnection(); + _dbContext.Database.EnsureCreated(); + + _serviceProvider = new ServiceCollection() + .AddSingleton(_dbContext) + .BuildServiceProvider(); + + _resolver = new EntityFrameworkReadModelForCommandResolver( + new Dictionary { [typeof(CustomerReadModel)] = typeof(SeedableCustomerDbContext) }); + } + + void Destroy() + { + _dbContext.Database.CloseConnection(); + _dbContext.Dispose(); + } + + protected void SeedCustomer() + { + _dbContext.Customers.Add(new CustomerReadModel { Id = _customerId, Name = "Test" }); + _dbContext.SaveChanges(); + } + + protected void ResolveCustomerWithKey(string? resolvedKey) => + _resolved = _resolver.Resolve(typeof(CustomerReadModel), CommandContextWithKey(resolvedKey)).GetAwaiter().GetResult(); + + protected void CatchResolveCustomerWithKey(string? resolvedKey) => + _exception = Catch.Exception(() => _resolver.Resolve(typeof(CustomerReadModel), CommandContextWithKey(resolvedKey)).GetAwaiter().GetResult()); + + CommandContext CommandContextWithKey(string? resolvedKey) + { + var values = new CommandContextValues(); + if (resolvedKey is not null) + { + values[CommandContextKeys.ResolvedKey] = resolvedKey; + } + + return new CommandContext(CorrelationId.New(), typeof(object), new object(), [], values) with { ServiceProvider = _serviceProvider }; + } +} diff --git a/Source/DotNET/EntityFrameworkCore/DbContextServiceCollectionExtensions.cs b/Source/DotNET/EntityFrameworkCore/DbContextServiceCollectionExtensions.cs index 7e286d681..923b44c6d 100644 --- a/Source/DotNET/EntityFrameworkCore/DbContextServiceCollectionExtensions.cs +++ b/Source/DotNET/EntityFrameworkCore/DbContextServiceCollectionExtensions.cs @@ -73,10 +73,13 @@ public static IServiceCollection AddReadModelDbContextsFromAssemblies(this IServ { var addDbContextMethod = typeof(ReadOnlyDbContextExtensions).GetMethod(nameof(ReadOnlyDbContextExtensions.AddReadOnlyDbContext), BindingFlags.Static | BindingFlags.Public)!; - foreach (var dbContext in DiscoverAndFilterDbContextTypes(assemblies)) + var dbContextTypes = DiscoverAndFilterDbContextTypes(assemblies).ToArray(); + foreach (var dbContext in dbContextTypes) { addDbContextMethod.MakeGenericMethod(dbContext).Invoke(null, [services, optionsAction]); } + + AddReadModelCommandResolution(services, dbContextTypes); return services; } @@ -94,13 +97,32 @@ public static IServiceCollection AddReadModelDbContextsWithConnectionStringFromA { var addDbContextMethod = typeof(ReadOnlyDbContextExtensions).GetMethod(nameof(ReadOnlyDbContextExtensions.AddReadOnlyDbContextWithConnectionString), BindingFlags.Static | BindingFlags.Public)!; - foreach (var dbContext in DiscoverAndFilterDbContextTypes(assemblies)) + var dbContextTypes = DiscoverAndFilterDbContextTypes(assemblies).ToArray(); + foreach (var dbContext in dbContextTypes) { addDbContextMethod.MakeGenericMethod(dbContext).Invoke(null, [services, connectionString, optionsAction]); } + + AddReadModelCommandResolution(services, dbContextTypes); return services; } + /// + /// Registers command-scoped, by-key resolution for the read model entities owned by the given DbContext types. + /// + /// The service collection to register with. + /// The read model DbContext types to discover read model entities from. + internal static void AddReadModelCommandResolution(IServiceCollection services, IEnumerable dbContextTypes) + { + var readModelToDbContext = EntityFrameworkReadModelForCommandResolver.DiscoverReadModelTypes(dbContextTypes); + if (readModelToDbContext.Count == 0) + { + return; + } + + services.AddReadModelsForCommand(new EntityFrameworkReadModelForCommandResolver(readModelToDbContext)); + } + /// /// Discovers and filters DbContext types from the specified assemblies, excluding those marked with IgnoreAutoRegistrationAttribute. /// diff --git a/Source/DotNET/EntityFrameworkCore/EntityFrameworkCoreBuilderExtensions.cs b/Source/DotNET/EntityFrameworkCore/EntityFrameworkCoreBuilderExtensions.cs index 07626c53a..de80b0369 100644 --- a/Source/DotNET/EntityFrameworkCore/EntityFrameworkCoreBuilderExtensions.cs +++ b/Source/DotNET/EntityFrameworkCore/EntityFrameworkCoreBuilderExtensions.cs @@ -97,6 +97,10 @@ public static IEntityFrameworkCoreBuilder DiscoverAndRegisterDbContexts(this IEn .Invoke(null, [builder.Services, connectionString, null]); } + // Contribute the EF Core read model entities to command-scoped, by-key resolution so they can be injected into + // commands the same way Chronicle-backed read models are. + DbContextServiceCollectionExtensions.AddReadModelCommandResolution(builder.Services, readOnlyTypes); + return builder; } } diff --git a/Source/DotNET/EntityFrameworkCore/EntityFrameworkReadModelForCommandResolver.cs b/Source/DotNET/EntityFrameworkCore/EntityFrameworkReadModelForCommandResolver.cs new file mode 100644 index 000000000..f562d3be6 --- /dev/null +++ b/Source/DotNET/EntityFrameworkCore/EntityFrameworkReadModelForCommandResolver.cs @@ -0,0 +1,85 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Arc.Queries; +using Cratis.Arc.Queries.ModelBound; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.Arc.EntityFrameworkCore; + +/// +/// Represents an that resolves read models backed by Entity Framework Core +/// through their owning , keyed by the command's resolved key. +/// +/// A map from each read model entity type to the DbContext type that owns it. +public class EntityFrameworkReadModelForCommandResolver(IReadOnlyDictionary readModelToDbContext) : ICanResolveReadModelForCommand +{ + /// + public IEnumerable ReadModelTypes { get; } = [.. readModelToDbContext.Keys]; + + /// + /// + /// A on a read model in the application carries each of + /// these read models, which is what makes Entity Framework Core own them. + /// + public ReadModelForCommandOwnership Ownership => ReadModelForCommandOwnership.Declared; + + /// + /// Discovers the read model entity types carried by the given DbContext types and maps each to its owning DbContext. + /// + /// The types to inspect. + /// A map from each read model entity type to the DbContext type that owns it. + /// + /// A read model entity is one carried by a whose entity type is marked with + /// [ReadModel]. When the same entity type appears in more than one DbContext the first one discovered owns it. + /// + public static IReadOnlyDictionary DiscoverReadModelTypes(IEnumerable dbContextTypes) + { + var map = new Dictionary(); + foreach (var dbContextType in dbContextTypes) + { + var entityTypes = dbContextType.GetProperties() + .Where(property => property.PropertyType.IsGenericType && property.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>)) + .Select(property => property.PropertyType.GetGenericArguments()[0]) + .Where(entityType => entityType.IsReadModel()); + + foreach (var entityType in entityTypes) + { + map.TryAdd(entityType, dbContextType); + } + } + + return map; + } + + /// + /// Thrown when the command carries no usable key to resolve the read model by; it surfaces as a validation failure (HTTP 400). + /// Thrown when the read model entity has no single-property primary key to resolve by. + public async Task Resolve(Type readModelType, CommandContext commandContext) + { + var resolvedKey = commandContext.GetResolvedKey(); + if (string.IsNullOrEmpty(resolvedKey)) + { + // A read model is keyed by the command's resolved key, so an absent key can never resolve one — for a + // nullable and a non-nullable dependency alike. That is invalid client input, so it surfaces as a + // validation failure (HTTP 400) rather than null or an unhandled server error. A valid-but-not-found read + // model still resolves to null below. + throw new UnableToResolveReadModelFromCommandContext(readModelType); + } + + var dbContext = (DbContext)commandContext.ServiceProvider!.GetRequiredService(readModelToDbContext[readModelType]); + var primaryKey = dbContext.Model.GetEntityTypes().FirstOrDefault(entityType => entityType.ClrType == readModelType)?.FindPrimaryKey(); + if (primaryKey is null || primaryKey.Properties.Count != 1) + { + throw new EntityDoesNotHavePrimaryKey(readModelType); + } + + var key = resolvedKey.ConvertTo(primaryKey.Properties[0].ClrType); + + // A never-materialized read model resolves to null; command-side code can inject a nullable read model and + // treat null as "does not exist", while a non-nullable dependency is surfaced as a validation failure (HTTP 400). + return await dbContext.FindAsync(readModelType, key); + } +} diff --git a/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/read_models.cs b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/read_models.cs new file mode 100644 index 000000000..24c1563bc --- /dev/null +++ b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/read_models.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Queries.ModelBound; +using Cratis.Concepts; + +namespace Cratis.Arc.MongoDB.for_MongoDBReadModelForCommandResolver; + +#pragma warning disable SA1402, SA1649 // File may only contain a single type, File name should match first type name + +public record CustomerId(Guid Value) : ConceptAs(Value) +{ + public static implicit operator Guid(CustomerId id) => id.Value; + public static implicit operator CustomerId(Guid value) => new(value); +} + +[ReadModel] +public record Customer(Guid Id, string Name); + +[ReadModel] +public record Account(CustomerId Id, decimal Balance); + +[ReadModel] +public record Preferences(string Theme); + +public record NotAReadModel(Guid Id); + +[ReadModel] +public abstract record AnAbstractReadModel(Guid Id); + +#pragma warning restore SA1402, SA1649 diff --git a/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_discovering_read_model_types.cs b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_discovering_read_model_types.cs new file mode 100644 index 000000000..817fe48ca --- /dev/null +++ b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_discovering_read_model_types.cs @@ -0,0 +1,16 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Arc.MongoDB.for_MongoDBReadModelForCommandResolver; + +public class when_discovering_read_model_types : Specification +{ + IEnumerable _result; + + void Because() => _result = MongoDBReadModelForCommandResolver.DiscoverReadModelTypes( + [typeof(Customer), typeof(Account), typeof(NotAReadModel), typeof(AnAbstractReadModel)]); + + [Fact] void should_include_the_read_models() => _result.ShouldContain(typeof(Customer), typeof(Account)); + [Fact] void should_not_include_a_type_that_is_not_a_read_model() => _result.ShouldNotContain(typeof(NotAReadModel)); + [Fact] void should_not_include_an_abstract_read_model() => _result.ShouldNotContain(typeof(AnAbstractReadModel)); +} diff --git a/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_instance_is_absent.cs b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_instance_is_absent.cs new file mode 100644 index 000000000..ccbff1575 --- /dev/null +++ b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_instance_is_absent.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using MongoDB.Driver; + +namespace Cratis.Arc.MongoDB.for_MongoDBReadModelForCommandResolver.when_resolving; + +public class and_the_instance_is_absent : given.a_resolver +{ + IMongoCollection _collection; + object? _result; + + void Establish() => _collection = CollectionHolding(); + + async Task Because() => _result = await _resolver.Resolve(typeof(Customer), CommandContextWith(Guid.NewGuid().ToString(), _collection)); + + [Fact] void should_resolve_to_null() => _result.ShouldBeNull(); +} diff --git a/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_instance_is_present.cs b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_instance_is_present.cs new file mode 100644 index 000000000..e76ffd699 --- /dev/null +++ b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_instance_is_present.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using MongoDB.Driver; + +namespace Cratis.Arc.MongoDB.for_MongoDBReadModelForCommandResolver.when_resolving; + +public class and_the_instance_is_present : given.a_resolver +{ + Customer _customer; + IMongoCollection _collection; + object? _result; + + void Establish() + { + _customer = new Customer(Guid.NewGuid(), "Alice"); + _collection = CollectionHolding(_customer); + } + + async Task Because() => _result = await _resolver.Resolve(typeof(Customer), CommandContextWith(_customer.Id.ToString(), _collection)); + + [Fact] void should_resolve_the_document() => _result.ShouldEqual(_customer); +} diff --git a/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_key_is_a_concept.cs b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_key_is_a_concept.cs new file mode 100644 index 000000000..693baebb5 --- /dev/null +++ b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_key_is_a_concept.cs @@ -0,0 +1,23 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using MongoDB.Driver; + +namespace Cratis.Arc.MongoDB.for_MongoDBReadModelForCommandResolver.when_resolving; + +public class and_the_key_is_a_concept : given.a_resolver +{ + Account _account; + IMongoCollection _collection; + object? _result; + + void Establish() + { + _account = new Account(new CustomerId(Guid.NewGuid()), 42m); + _collection = CollectionHolding(_account); + } + + async Task Because() => _result = await _resolver.Resolve(typeof(Account), CommandContextWith(_account.Id.Value.ToString(), _collection)); + + [Fact] void should_resolve_the_document() => _result.ShouldEqual(_account); +} diff --git a/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_key_is_absent.cs b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_key_is_absent.cs new file mode 100644 index 000000000..fc0929638 --- /dev/null +++ b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_key_is_absent.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Queries; + +namespace Cratis.Arc.MongoDB.for_MongoDBReadModelForCommandResolver.when_resolving; + +public class and_the_key_is_absent : given.a_resolver +{ + Exception _exception; + + async Task Because() => _exception = await Catch.Exception(() => _resolver.Resolve(typeof(Customer), CommandContextWith(null))); + + [Fact] void should_fail_because_the_command_carries_no_key() => _exception.ShouldBeOfExactType(); +} diff --git a/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_key_is_empty.cs b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_key_is_empty.cs new file mode 100644 index 000000000..8839a29e1 --- /dev/null +++ b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_key_is_empty.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Queries; + +namespace Cratis.Arc.MongoDB.for_MongoDBReadModelForCommandResolver.when_resolving; + +public class and_the_key_is_empty : given.a_resolver +{ + Exception _exception; + + async Task Because() => _exception = await Catch.Exception(() => _resolver.Resolve(typeof(Customer), CommandContextWith(string.Empty))); + + [Fact] void should_fail_because_the_command_carries_no_usable_key() => _exception.ShouldBeOfExactType(); +} diff --git a/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_read_model_has_no_document_id.cs b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_read_model_has_no_document_id.cs new file mode 100644 index 000000000..d712f7149 --- /dev/null +++ b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/and_the_read_model_has_no_document_id.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using MongoDB.Driver; + +namespace Cratis.Arc.MongoDB.for_MongoDBReadModelForCommandResolver.when_resolving; + +public class and_the_read_model_has_no_document_id : given.a_resolver +{ + Exception _exception; + + async Task Because() => _exception = await Catch.Exception(() => _resolver.Resolve(typeof(Preferences), CommandContextWith(Guid.NewGuid().ToString()))); + + [Fact] void should_fail_naming_the_missing_id_mapping() => _exception.ShouldBeOfExactType(); +} diff --git a/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/given/a_resolver.cs b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/given/a_resolver.cs new file mode 100644 index 000000000..9547645a4 --- /dev/null +++ b/Source/DotNET/MongoDB.Specs/for_MongoDBReadModelForCommandResolver/when_resolving/given/a_resolver.cs @@ -0,0 +1,63 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Commands; +using Cratis.Execution; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; + +namespace Cratis.Arc.MongoDB.for_MongoDBReadModelForCommandResolver.when_resolving.given; + +public class a_resolver : Specification +{ + protected MongoDBReadModelForCommandResolver _resolver; + + void Establish() + { + // Resolving looks up the class map for a read model, and mapping one reaches for process-wide state that only + // AddCratisMongoDB() establishes: the naming policy the member name convention reads, and the Cratis + // serializers, which cannot be registered once the driver has cached its own for a Guid. An application always + // has both in place long before a command resolves anything, so the specification puts them there the same way + // rather than assembling the pieces itself - assembling them is what left this passing only when some other + // specification had run AddCratisMongoDB() first. + new ServiceCollection().AddCratisMongoDB(); + + _resolver = new([typeof(Customer), typeof(Account), typeof(Preferences)]); + } + + protected static CommandContext CommandContextWith(string? resolvedKey, IMongoCollection? collection = null) + { + // The collection is available in the command scope the same way the MongoDB integration registers it. + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IMongoCollection)).Returns(collection); + + var values = new CommandContextValues(); + if (resolvedKey is not null) + { + values.Add(CommandContextKeys.ResolvedKey, resolvedKey); + } + + return new CommandContext( + CorrelationId.New(), + typeof(object), + new object(), + [], + values, + ServiceProvider: serviceProvider); + } + + protected static IMongoCollection CollectionHolding(params TReadModel[] documents) + { + // A find by id hands back the given documents. + var cursor = Substitute.For>(); + cursor.Current.Returns(documents); + cursor.MoveNextAsync(Arg.Any()).Returns(Task.FromResult(documents.Length > 0), Task.FromResult(false)); + + var collection = Substitute.For>(); + collection + .FindAsync(Arg.Any>(), Arg.Any>(), Arg.Any()) + .Returns(Task.FromResult(cursor)); + + return collection; + } +} diff --git a/Source/DotNET/MongoDB/MongoDBReadModelForCommandResolver.cs b/Source/DotNET/MongoDB/MongoDBReadModelForCommandResolver.cs new file mode 100644 index 000000000..e4ad1cfb0 --- /dev/null +++ b/Source/DotNET/MongoDB/MongoDBReadModelForCommandResolver.cs @@ -0,0 +1,79 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Reflection; +using Cratis.Arc.Commands; +using Cratis.Arc.Queries; +using Cratis.Arc.Queries.ModelBound; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Bson.Serialization; +using MongoDB.Driver; + +namespace Cratis.Arc.MongoDB; + +/// +/// Represents an that resolves read models held in MongoDB through the +/// collection for the read model, keyed by the command's resolved key. +/// +/// The read model types this resolver can resolve by key. +/// +/// MongoDB holds a document per read model instance, keyed by _id, so the command's resolved key is the document +/// id. Nothing in an application declares that a read model is stored in MongoDB, which is why this claims its types as a +/// : a provider that does own a read model — Chronicle for one it +/// projects, Entity Framework Core for one carried by a read model context — keeps it. +/// +public class MongoDBReadModelForCommandResolver(IEnumerable readModelTypes) : ICanResolveReadModelForCommand +{ + static readonly MethodInfo _findByIdAsyncMethod = typeof(MongoCollectionExtensions).GetMethod( + nameof(MongoCollectionExtensions.FindByIdAsync), + BindingFlags.Static | BindingFlags.Public)!; + + /// + public IEnumerable ReadModelTypes { get; } = [.. readModelTypes]; + + /// + public ReadModelForCommandOwnership Ownership => ReadModelForCommandOwnership.Fallback; + + /// + /// Discovers the read model types MongoDB can resolve by key from the types of the application. + /// + /// The types to inspect. + /// The read model types MongoDB can hold a document per. + /// + /// Every [ReadModel] is a candidate, because the MongoDB integration serves a collection for any read model + /// type without being told about it up front. Claiming them as a fallback is what keeps that breadth from taking a + /// read model away from the provider that owns it. + /// + public static IEnumerable DiscoverReadModelTypes(IEnumerable types) => + types.Where(type => type.IsClass && !type.IsAbstract && type.IsReadModel()); + + /// + /// Thrown when the command carries no usable key to resolve the read model by; it surfaces as a validation failure (HTTP 400). + /// Thrown when the read model has no member mapped to the document id to resolve by. + public async Task Resolve(Type readModelType, CommandContext commandContext) + { + var resolvedKey = commandContext.GetResolvedKey(); + if (string.IsNullOrEmpty(resolvedKey)) + { + // A read model is keyed by the command's resolved key, so an absent key can never resolve one — for a + // nullable and a non-nullable dependency alike. That is invalid client input, so it surfaces as a + // validation failure (HTTP 400) rather than null or an unhandled server error. A valid-but-not-found read + // model still resolves to null below. + throw new UnableToResolveReadModelFromCommandContext(readModelType); + } + + var idMemberMap = BsonClassMap.LookupClassMap(readModelType).IdMemberMap ?? throw new MissingIdMapping(readModelType); + var key = resolvedKey.ConvertTo(idMemberMap.MemberType); + var collection = commandContext.ServiceProvider!.GetRequiredService(typeof(IMongoCollection<>).MakeGenericType(readModelType)); + + // A never-materialized read model resolves to null; command-side code can inject a nullable read model and + // treat null as "does not exist", while a non-nullable dependency is surfaced as a validation failure (HTTP 400). + var find = (Task)_findByIdAsyncMethod + .MakeGenericMethod(readModelType, idMemberMap.MemberType) + .Invoke(null, [collection, key])!; + + await find; + + return find.GetType().GetProperty(nameof(Task.Result))!.GetValue(find); + } +} diff --git a/Source/DotNET/MongoDB/ServiceCollectionExtensions.cs b/Source/DotNET/MongoDB/ServiceCollectionExtensions.cs index ff3311c4b..fb3d4c3a1 100644 --- a/Source/DotNET/MongoDB/ServiceCollectionExtensions.cs +++ b/Source/DotNET/MongoDB/ServiceCollectionExtensions.cs @@ -101,6 +101,31 @@ static void AddServices( }); services.AddScoped(typeof(IMongoCollection<>), typeof(MongoCollectionAdapter<>)); + + AddReadModelCommandResolution(services); + } + + /// + /// Registers command-scoped, by-key resolution for the read models MongoDB holds a document per. + /// + /// The to register with. + /// + /// This is what lets a read model held in MongoDB be injected into a command's Handle(), Provide(), or + /// CommandValidator<> the same way a Chronicle- or Entity Framework Core-backed one is. MongoDB claims the + /// read models as a fallback, so a provider that owns one keeps it regardless of the order the two are registered in. + /// + static void AddReadModelCommandResolution(IServiceCollection services) + { + var readModelTypes = MongoDBReadModelForCommandResolver + .DiscoverReadModelTypes(Cratis.Types.Types.Instance.All) + .ToArray(); + + if (readModelTypes.Length == 0) + { + return; + } + + services.AddReadModelsForCommand(new MongoDBReadModelForCommandResolver(readModelTypes)); } static MongoDBBuilder CreateMongoDBBuilder(Action? configure) diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_controller_served_at_a_route_of_its_own.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_controller_served_at_a_route_of_its_own.cs new file mode 100644 index 000000000..e9c56d869 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_controller_served_at_a_route_of_its_own.cs @@ -0,0 +1,88 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// A controller says where it answers twice over - once for the controller and once for each verb that carries a +/// template. The document describes neither, and says so for each, so a reader can tell a route that was passed over +/// from an application that leaves the convention alone. +/// +/// +/// The web framework is declared alongside the application rather than referenced, the same way the other controller +/// specifications do it, because the recognizer matches on names rather than on the assembly they came from. +/// +public class a_controller_served_at_a_route_of_its_own : Specification +{ + const string WebFramework = """ + using System; + + namespace Microsoft.AspNetCore.Mvc; + + public abstract class ControllerBase; + + [AttributeUsage(AttributeTargets.Class)] + public sealed class RouteAttribute(string template) : Attribute + { + public string Template { get; } = template; + } + + [AttributeUsage(AttributeTargets.Method)] + public sealed class HttpPostAttribute(string template = "") : Attribute + { + public string Template { get; } = template; + } + + [AttributeUsage(AttributeTargets.Method)] + public sealed class HttpGetAttribute(string template = "") : Attribute + { + public string Template { get; } = template; + } + + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class FromBodyAttribute : Attribute; + """; + + const string Source = """ + using System.Collections.Generic; + using System.Threading.Tasks; + using Cratis.Chronicle.Events; + using Microsoft.AspNetCore.Mvc; + + namespace Library.Authors.Registration; + + [EventType] + public record AuthorRegistered(string Name); + + public record RegisterAuthor(string Name); + + public record Author(string Id, string Name); + + [Route("catalog/authors")] + public class AuthorsController : ControllerBase + { + [HttpPost("register-author")] + public Task Register([FromBody] RegisterAuthor command) => + Task.FromResult(new AuthorRegistered(command.Name)); + + [HttpGet] + public IEnumerable All() => []; + } + """; + + ApplicationModelAnalysis _analysis; + + void Establish() => _analysis = Analyzed.Source( + ("Framework.cs", WebFramework), + ("Library/Authors/Registration/Registration.cs", Source)); + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Framework.cs", WebFramework), ("Library/Authors/Registration/Registration.cs", Source)).ShouldBeEmpty(); + [Fact] void should_still_recover_the_command() => _analysis.Slice().Commands.Single().Name.ShouldEqual("RegisterAuthor"); + [Fact] void should_name_the_command_the_way_the_document_does() => _analysis.Diagnostics.Single(_ => _.Message.Contains("register-author'", StringComparison.Ordinal)).Message.ShouldContain("'RegisterAuthor'"); + [Fact] void should_still_recover_the_query() => _analysis.Slice().Queries.Single().Name.ShouldEqual("All"); + [Fact] void should_say_where_the_controller_answers() => _analysis.Diagnostics.Count(_ => _.Message.Contains("catalog/authors'", StringComparison.Ordinal)).ShouldEqual(1); + [Fact] void should_say_where_the_command_answers() => _analysis.Diagnostics.Count(_ => _.Message.Contains("register-author'", StringComparison.Ordinal)).ShouldEqual(1); + [Fact] void should_say_nothing_about_the_verb_that_leaves_the_convention_alone() => _analysis.Diagnostics.Count(_ => _.Code == ScreenplayDiagnosticCodes.ServingConcernWithoutCounterpart).ShouldEqual(2); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_controller_served_at_more_routes_than_it_declares_itself.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_controller_served_at_more_routes_than_it_declares_itself.cs new file mode 100644 index 000000000..139aedcf6 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_controller_served_at_more_routes_than_it_declares_itself.cs @@ -0,0 +1,75 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// A controller answers at every template that reaches it, not only at the first one written on it: the route and verb +/// attributes may each be applied more than once, and a template on an abstract base controller serves the controllers +/// deriving from it. Each of those is a route the application really serves, and stopping at the first would pass the +/// rest over in silence. +/// +/// +/// The web framework is declared alongside the application rather than referenced, the same way the other controller +/// specifications do it, because the recognizer matches on names rather than on the assembly they came from. It allows +/// the route and verb attributes to be applied more than once, as ASP.NET Core's own do. +/// +public class a_controller_served_at_more_routes_than_it_declares_itself : Specification +{ + const string WebFramework = """ + using System; + + namespace Microsoft.AspNetCore.Mvc; + + public abstract class ControllerBase; + + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public sealed class RouteAttribute(string template) : Attribute + { + public string Template { get; } = template; + } + + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public sealed class HttpGetAttribute(string template = "") : Attribute + { + public string Template { get; } = template; + } + """; + + const string Source = """ + using System.Collections.Generic; + using Microsoft.AspNetCore.Mvc; + + namespace Library.Authors.Listing; + + public record Author(string Id, string Name); + + [Route("catalog")] + public abstract class CatalogController : ControllerBase; + + [Route("catalog/authors")] + [Route("authors")] + public class AuthorsController : CatalogController + { + [HttpGet("all")] + [HttpGet("everything")] + public IEnumerable All() => []; + } + """; + + ApplicationModelAnalysis _analysis; + + void Establish() => _analysis = Analyzed.Source( + ("Framework.cs", WebFramework), + ("Library/Authors/Listing/Listing.cs", Source)); + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Framework.cs", WebFramework), ("Library/Authors/Listing/Listing.cs", Source)).ShouldBeEmpty(); + [Fact] void should_still_recover_the_query() => _analysis.Slice().Queries.Single().Name.ShouldEqual("All"); + [Fact] void should_say_where_the_controller_answers() => _analysis.Diagnostics.Count(_ => _.Message.Contains("catalog/authors'", StringComparison.Ordinal)).ShouldEqual(1); + [Fact] void should_also_say_the_second_route_on_the_controller() => _analysis.Diagnostics.Count(_ => _.Message.Contains("'authors'", StringComparison.Ordinal)).ShouldEqual(1); + [Fact] void should_also_say_the_route_it_inherits() => _analysis.Diagnostics.Count(_ => _.Message.Contains("'catalog'", StringComparison.Ordinal)).ShouldEqual(1); + [Fact] void should_say_both_routes_the_query_answers_at() => _analysis.Diagnostics.Count(_ => _.Message.Contains("'all'", StringComparison.Ordinal) || _.Message.Contains("'everything'", StringComparison.Ordinal)).ShouldEqual(2); + [Fact] void should_report_every_route_as_a_serving_concern() => _analysis.Diagnostics.All(_ => _.Code == ScreenplayDiagnosticCodes.ServingConcernWithoutCounterpart).ShouldBeTrue(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_query_served_at_a_route_of_its_own.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_query_served_at_a_route_of_its_own.cs new file mode 100644 index 000000000..b8ba94e8b --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_query_served_at_a_route_of_its_own.cs @@ -0,0 +1,45 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// An application can serve a single query somewhere other than where the convention would put it. Where it answers is +/// not something a Screenplay says, so the route is left out - but a route declared and never mentioned reads exactly +/// like an application that declared none. +/// +/// +/// A path on a query replaces the read model's rather than extending it, so the read model's own path here is a route +/// nothing answers at, and naming it would put a route in the report the application does not serve. +/// +public class a_query_served_at_a_route_of_its_own : Specification +{ + const string Source = """ + using System.Collections.Generic; + using Cratis.Arc.Queries.ModelBound; + + namespace Library.Authors.Listing; + + [ReadModel] + [Path("/catalog/authors")] + public record Author + { + public string Id { get; init; } = string.Empty; + + [Path("/catalog/authors/by-name")] + public static IEnumerable AuthorsByName(string name) => []; + } + """; + + ApplicationModelAnalysis _analysis; + + void Establish() => _analysis = Analyzed.Source(("Library/Authors/Listing/Listing.cs", Source)); + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Authors/Listing/Listing.cs", Source)).ShouldBeEmpty(); + [Fact] void should_still_recover_the_query() => _analysis.Slice().Queries.Single().Name.ShouldEqual("AuthorsByName"); + [Fact] void should_say_where_the_query_is_served() => _analysis.Diagnostics.Count(_ => _.Message.Contains("/catalog/authors/by-name'", StringComparison.Ordinal)).ShouldEqual(1); + [Fact] void should_not_name_the_read_model_path_its_only_query_replaced() => _analysis.Diagnostics.Count(_ => _.Message.Contains("/catalog/authors'", StringComparison.Ordinal)).ShouldEqual(0); + [Fact] void should_report_both_as_a_serving_concern() => _analysis.Diagnostics.All(_ => _.Code == ScreenplayDiagnosticCodes.ServingConcernWithoutCounterpart).ShouldBeTrue(); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_query_the_host_hands_more_than_arguments.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_query_the_host_hands_more_than_arguments.cs index b8a1f9e57..0775bf9e6 100644 --- a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_query_the_host_hands_more_than_arguments.cs +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_query_the_host_hands_more_than_arguments.cs @@ -48,5 +48,7 @@ void Establish() [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Authors/Listing/Listing.cs", Source)).ShouldBeEmpty(); [Fact] void should_key_the_query_by_what_the_caller_sends() => _query.By!.Name.ShouldEqual("name"); [Fact] void should_leave_out_everything_the_host_fills_in() => _query.Filters.ShouldBeEmpty(); - [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty(); + [Fact] void should_say_the_query_is_paged() => _analysis.Diagnostics.Count(_ => _.Code == ScreenplayDiagnosticCodes.ServingConcernWithoutCounterpart && _.Message.Contains("paging", StringComparison.Ordinal)).ShouldEqual(1); + [Fact] void should_say_the_query_is_sorted() => _analysis.Diagnostics.Count(_ => _.Code == ScreenplayDiagnosticCodes.ServingConcernWithoutCounterpart && _.Message.Contains("sorting", StringComparison.Ordinal)).ShouldEqual(1); + [Fact] void should_say_nothing_about_the_plumbing_the_host_fills_in_of_its_own() => _analysis.Diagnostics.Count.ShouldEqual(2); } diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_read_model_carrying_tags.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_read_model_carrying_tags.cs new file mode 100644 index 000000000..c3a67b9a1 --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_read_model_carrying_tags.cs @@ -0,0 +1,42 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// Chronicle tags read models as readily as it tags events, and only the event has somewhere in the document to carry +/// them - a read model is named there only as what a query answers with. Passing the tags over without a word would +/// leave a reader who sees tags throughout the events concluding that the read models carry none. +/// +public class a_read_model_carrying_tags : Specification +{ + const string Source = """ + using System.Collections.Generic; + using Cratis.Chronicle; + using Cratis.Arc.Queries.ModelBound; + + namespace Library.Authors.Listing; + + [ReadModel] + [Tag("audit")] + [Tags("authors")] + public record Author + { + public string Id { get; init; } = string.Empty; + + public static IEnumerable AllAuthors() => []; + } + """; + + ApplicationModelAnalysis _analysis; + + void Establish() => _analysis = Analyzed.Source(("Library/Authors/Listing/Listing.cs", Source)); + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Authors/Listing/Listing.cs", Source)).ShouldBeEmpty(); + [Fact] void should_still_recover_the_query() => _analysis.Slice().Queries.Single().Name.ShouldEqual("AllAuthors"); + [Fact] void should_say_the_tags_have_nowhere_to_go() => _analysis.Diagnostics.Count(_ => _.Code == ScreenplayDiagnosticCodes.ReadModelFeatureWithoutCounterpart).ShouldEqual(1); + [Fact] void should_name_every_tag_it_could_not_carry() => _analysis.Diagnostics.Single(_ => _.Code == ScreenplayDiagnosticCodes.ReadModelFeatureWithoutCounterpart).Message.ShouldContain("'audit', 'authors'"); + [Fact] void should_report_it_once_for_the_read_model_rather_than_once_per_tag() => _analysis.Diagnostics.Count.ShouldEqual(1); +} diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_read_model_exposing_queries.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_read_model_exposing_queries.cs index 3d6477495..a4a3a00ea 100644 --- a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_read_model_exposing_queries.cs +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_read_model_exposing_queries.cs @@ -47,5 +47,7 @@ public record Author(string Id, string Name) [Fact] void should_take_no_parameter_when_the_query_needs_none() => Query("AllAuthors").By.ShouldBeNull(); [Fact] void should_narrow_with_the_remaining_parameters() => Query("AuthorsByName").Filters.Select(_ => _.Name).ShouldContainOnly(["take"]); [Fact] void should_infer_a_state_view_slice() => _analysis.Slice().Kind.ShouldEqual(SliceKind.StateView); - [Fact] void should_report_nothing() => _analysis.Diagnostics.ShouldBeEmpty(); + [Fact] void should_say_the_host_pages_the_query_handing_back_a_queryable() => _analysis.Diagnostics.Single().Message.ShouldContain("'AuthorsByName'"); + [Fact] void should_report_that_as_a_serving_concern() => _analysis.Diagnostics.Single().Code.ShouldEqual(ScreenplayDiagnosticCodes.ServingConcernWithoutCounterpart); + [Fact] void should_say_nothing_about_the_queries_handed_back_whole() => _analysis.Diagnostics.Count(_ => _.Message.Contains("'AllAuthors'", StringComparison.Ordinal) || _.Message.Contains("'AuthorById'", StringComparison.Ordinal)).ShouldEqual(0); } diff --git a/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_read_model_served_at_a_route_of_its_own.cs b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_read_model_served_at_a_route_of_its_own.cs new file mode 100644 index 000000000..29a2f222c --- /dev/null +++ b/Source/DotNET/Screenplay.Specs/for_ApplicationModelAnalyzer/when_analyzing/a_read_model_served_at_a_route_of_its_own.cs @@ -0,0 +1,42 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Arc.Screenplay.Analysis; + +namespace Cratis.Arc.Screenplay.for_ApplicationModelAnalyzer.when_analyzing; + +/// +/// A read model can be served somewhere other than where the convention would put it, and every query that declares no +/// path of its own answers there. That is a route the application really serves, so the report names it. +/// +public class a_read_model_served_at_a_route_of_its_own : Specification +{ + const string Source = """ + using System.Collections.Generic; + using Cratis.Arc.Queries.ModelBound; + + namespace Library.Authors.Listing; + + [ReadModel] + [Path("/catalog/authors")] + public record Author + { + public string Id { get; init; } = string.Empty; + + public static IEnumerable AllAuthors() => []; + + [Path("/catalog/authors/by-name")] + public static IEnumerable AuthorsByName(string name) => []; + } + """; + + ApplicationModelAnalysis _analysis; + + void Establish() => _analysis = Analyzed.Source(("Library/Authors/Listing/Listing.cs", Source)); + + [Fact] void should_compile_the_source_it_analyzed() => Analyzed.ErrorsIn(("Library/Authors/Listing/Listing.cs", Source)).ShouldBeEmpty(); + [Fact] void should_still_recover_every_query() => _analysis.Slice().Queries.Select(_ => _.Name).ShouldContainOnly(["AllAuthors", "AuthorsByName"]); + [Fact] void should_say_where_the_read_model_is_served() => _analysis.Diagnostics.Count(_ => _.Message.Contains("/catalog/authors'", StringComparison.Ordinal)).ShouldEqual(1); + [Fact] void should_say_where_the_query_replacing_it_is_served() => _analysis.Diagnostics.Count(_ => _.Message.Contains("/catalog/authors/by-name'", StringComparison.Ordinal)).ShouldEqual(1); + [Fact] void should_report_both_as_a_serving_concern() => _analysis.Diagnostics.All(_ => _.Code == ScreenplayDiagnosticCodes.ServingConcernWithoutCounterpart).ShouldBeTrue(); +} diff --git a/Source/DotNET/Screenplay/Analysis/Controllers/ControllerRoutes.cs b/Source/DotNET/Screenplay/Analysis/Controllers/ControllerRoutes.cs index 6f15914d4..3647934f4 100644 --- a/Source/DotNET/Screenplay/Analysis/Controllers/ControllerRoutes.cs +++ b/Source/DotNET/Screenplay/Analysis/Controllers/ControllerRoutes.cs @@ -49,6 +49,33 @@ public static bool IsController(ITypeSymbol type) => /// True when the method carries the read verb. public static bool IsQuery(IMethodSymbol method) => Carries(method, "HttpGetAttribute"); + /// + /// Gets the routes a controller or one of its methods is served at. + /// + /// The controller or method to read. + /// The route templates, empty when the conventional route is used. + /// + /// A template appears either as the argument of the verb attribute or as a route attribute of its own, and both + /// say the same thing. None of them has a counterpart in a Screenplay, which says what an application is rather + /// than where it answers. + /// + /// Every one is read, not just the first: the route and the verb attributes all allow being applied more than once, + /// and two templates on the same controller are two routes the application really serves. The declarations a symbol + /// inherits count too - ASP.NET Core honors a route declared on a base controller or on an overridden action, and an + /// abstract base is never read on its own because it is not a controller. + /// + /// + public static IEnumerable RoutesOf(ISymbol symbol) => + [ + .. Declarations(symbol) + .SelectMany(_ => _.GetAttributes()) + .Where(_ => IsRouting(_.AttributeClass)) + .Select(_ => _.GetArgument(0) as string) + .Where(_ => !string.IsNullOrWhiteSpace(_)) + .Select(_ => _!) + .Distinct(StringComparer.Ordinal) + ]; + /// /// Gets the routable methods a controller declares. /// @@ -60,6 +87,31 @@ public static IEnumerable MethodsOf(INamedTypeSymbol type) => .Where(_ => _ is { MethodKind: MethodKind.Ordinary, IsStatic: false, DeclaredAccessibility: Accessibility.Public }) .OrderBy(_ => _.ToDisplayString(), StringComparer.Ordinal); + /// + /// Walks a symbol and everything it inherits its declarations from. + /// + /// The symbol to walk from. + /// The symbol first, then each one it inherits from, nearest first. + static IEnumerable Declarations(ISymbol symbol) + { + for (var current = symbol; current is not null; current = Inherited(current)) + { + yield return current; + } + } + + /// + /// Gets the declaration a symbol inherits its attributes from. + /// + /// The symbol to read. + /// The base type or the overridden method, or when there is none. + static ISymbol? Inherited(ISymbol symbol) => symbol switch + { + INamedTypeSymbol type => type.BaseType, + IMethodSymbol method => method.OverriddenMethod, + _ => null + }; + /// /// Determines whether a method carries an attribute of a given name from the MVC namespace. /// @@ -68,4 +120,13 @@ public static IEnumerable MethodsOf(INamedTypeSymbol type) => /// True when the attribute is applied. static bool Carries(IMethodSymbol method, string attributeName) => method.HasAttribute($"{MvcNamespace}.{attributeName}"); + + /// + /// Determines whether an attribute is one that says where something is served. + /// + /// The attribute to check. + /// True when the attribute carries a route template. + static bool IsRouting(INamedTypeSymbol? attribute) => + attribute?.ContainingNamespace?.ToDisplayString() == MvcNamespace && + (attribute.Name == "RouteAttribute" || attribute.Name == "HttpGetAttribute" || Array.Exists(_mutating, verb => verb == attribute.Name)); } diff --git a/Source/DotNET/Screenplay/Analysis/Events/EventReader.cs b/Source/DotNET/Screenplay/Analysis/Events/EventReader.cs index ba90f982a..48b8172c5 100644 --- a/Source/DotNET/Screenplay/Analysis/Events/EventReader.cs +++ b/Source/DotNET/Screenplay/Analysis/Events/EventReader.cs @@ -40,26 +40,9 @@ public EventModel Read(INamedTypeSymbol type, string location) { ReportWhatIsLost(type, location); - return new(type.Name, properties.Read(type), Tags(type)); + return new(type.Name, properties.Read(type), Tags.Of(type)); } - /// - /// Gets the tags an event is classified by. - /// - /// The type declaring the event. - /// The tags, ordered. - static IEnumerable Tags(ISymbol type) => - [ - .. type.GetAttributes() - .Where(_ => _.AttributeClass.Is(WellKnownTypeNames.TagAttribute) || _.AttributeClass.Is(WellKnownTypeNames.TagsAttribute)) - .SelectMany(_ => _.ConstructorArguments) - .SelectMany(_ => _.Kind == TypedConstantKind.Array ? _.Values.Select(value => value.Value) : [_.Value]) - .OfType() - .Where(_ => _.Length > 0) - .Distinct(StringComparer.Ordinal) - .Order(StringComparer.Ordinal) - ]; - /// /// Reports everything an event declares that the document cannot say. /// diff --git a/Source/DotNET/Screenplay/Analysis/Queries/QueryReader.cs b/Source/DotNET/Screenplay/Analysis/Queries/QueryReader.cs index 3ad7ec40d..d2f9cc5c4 100644 --- a/Source/DotNET/Screenplay/Analysis/Queries/QueryReader.cs +++ b/Source/DotNET/Screenplay/Analysis/Queries/QueryReader.cs @@ -70,6 +70,8 @@ public static IEnumerable MethodsOf(INamedTypeSymbol type) => return null; } + ReportHowItIsServed(method, location); + var parameters = method.Parameters.Where(IsInput).ToList(); var required = parameters.Find(_ => !_.HasExplicitDefaultValue); @@ -110,6 +112,49 @@ static bool Returns(IMethodSymbol method, INamedTypeSymbol readModel) return SymbolEqualityComparer.Default.Equals(QueryReturnTypes.Unwrap(method.ReturnType, ref collection), readModel); } + /// + /// Reports what a query says about how it is served, which the document does not describe. + /// + /// The method exposing the query. + /// Where the query lives. + /// + /// Paging and sorting are left out of the query's shape because no caller sends them as arguments, and that is + /// the right reading - but a query that pages is a real thing the application does, and a document that says + /// nothing at all reads exactly like a query that does not page. Which query pages is decided the way Arc decides + /// it, by the return type being a queryable; a paging or sorting parameter is read as well, for a signature that + /// says it means to be served that way even though Arc fills neither in. + /// + void ReportHowItIsServed(IMethodSymbol method, string location) + { + if (QueryReturnTypes.IsPagedByTheHost(method.ReturnType)) + { + diagnostics.Information( + ScreenplayDiagnosticCodes.ServingConcernWithoutCounterpart, + $"The query '{method.Name}' hands back a queryable, so the host pages and sorts it on the caller's behalf, which says how the result is asked for rather than what it is, and Screenplay has no counterpart for it", + location); + } + + foreach (var served in method.Parameters + .Where(_ => _.Type.Is(WellKnownTypeNames.Paging) || _.Type.Is(WellKnownTypeNames.Sorting)) + .Select(_ => _.Type.Name) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal)) + { + diagnostics.Information( + ScreenplayDiagnosticCodes.ServingConcernWithoutCounterpart, + $"The query '{method.Name}' is served with {served.ToLowerInvariant()}, which says how the result is asked for rather than what it is, and Screenplay has no counterpart for it", + location); + } + + if (method.GetAttribute(WellKnownTypeNames.PathAttribute)?.GetArgument(0) is string path && !string.IsNullOrWhiteSpace(path)) + { + diagnostics.Information( + ScreenplayDiagnosticCodes.ServingConcernWithoutCounterpart, + $"The query '{method.Name}' is served at '{path}' rather than the conventional route, which Screenplay has no counterpart for", + location); + } + } + /// /// Reads the type a query returns, stripping every wrapper that only says how it arrives. /// diff --git a/Source/DotNET/Screenplay/Analysis/Queries/QueryReturnTypes.cs b/Source/DotNET/Screenplay/Analysis/Queries/QueryReturnTypes.cs index 73da4c95d..a33394c2c 100644 --- a/Source/DotNET/Screenplay/Analysis/Queries/QueryReturnTypes.cs +++ b/Source/DotNET/Screenplay/Analysis/Queries/QueryReturnTypes.cs @@ -30,6 +30,39 @@ public static class QueryReturnTypes "System.Linq.IQueryable`1" ]; + static readonly string[] _queryables = + [ + "Cratis.Arc.Queries.IQueryable`1", + "System.Linq.IQueryable`1" + ]; + + /// + /// Determines whether the host pages and sorts a query's result on the caller's behalf. + /// + /// The return type to check. + /// True when the query hands back a queryable. + /// + /// Handing back a queryable rather than a materialized result is how a query tells Arc it may take the page and the + /// order off the request and apply them - the caller never passes either as an argument. That is the whole of what + /// makes a query paged, so it is what the return type is read for rather than any parameter. + /// + public static bool IsPagedByTheHost(ITypeSymbol type) + { + var current = type; + + while (current is INamedTypeSymbol named && named.TypeArguments.Length == 1) + { + if (Matches(named, _queryables)) + { + return true; + } + + current = named.TypeArguments[0]; + } + + return false; + } + /// /// Determines whether a type says only how a result was transported, and nothing about what it is. /// diff --git a/Source/DotNET/Screenplay/Analysis/Slices/SliceArtifactReader.cs b/Source/DotNET/Screenplay/Analysis/Slices/SliceArtifactReader.cs index 73b6dc1ec..27cf827a0 100644 --- a/Source/DotNET/Screenplay/Analysis/Slices/SliceArtifactReader.cs +++ b/Source/DotNET/Screenplay/Analysis/Slices/SliceArtifactReader.cs @@ -66,6 +66,7 @@ void ReadInto(INamedTypeSymbol type, string @namespace, SliceContents content) if (QueryReader.IsReadModel(type)) { + ReportWhatTheReadModelCannotSay(type, @namespace); AddQueries(content, type, QueryReader.MethodsOf(type), @namespace); Assign(content, readers.ModelBoundProjections.Read(type, @namespace), @namespace); } @@ -112,19 +113,71 @@ void ReadController(INamedTypeSymbol type, string @namespace, SliceContents cont return; } + ReportRoutes(ControllerRoutes.RoutesOf(type), $"The controller '{type.Name}'", @namespace); + foreach (var method in ControllerRoutes.MethodsOf(type)) { if (ControllerRoutes.IsCommand(method)) { - content.Commands.Add(readers.ControllerCommands.Read(type, method, @namespace)); + var command = readers.ControllerCommands.Read(type, method, @namespace); + content.Commands.Add(command); + ReportRoutes(ControllerRoutes.RoutesOf(method), $"The command '{command.Name}'", @namespace); } else if (ControllerRoutes.IsQuery(method)) { AddQueries(content, type, [method], @namespace); + ReportRoutes(ControllerRoutes.RoutesOf(method), $"The query '{method.Name}'", @namespace); } } } + /// + /// Reports everything a read model declares that the document has nowhere to hold. + /// + /// The read model. + /// Where it lives. + /// + /// A read model appears in the document only as the type a query answers with, so a tag on one has nowhere to go + /// - while a tag on an event is printed. Saying so is what keeps a reader from taking the difference for the + /// application's own. + /// + void ReportWhatTheReadModelCannotSay(INamedTypeSymbol type, string location) + { + var tags = Tags.Of(type); + if (tags.Length > 0) + { + diagnostics.Information( + ScreenplayDiagnosticCodes.ReadModelFeatureWithoutCounterpart, + $"The read model '{type.Name}' is tagged {string.Join(", ", tags.Select(tag => $"'{tag}'"))}, and a read model is named in the document only as what a query answers with, so it has nowhere to carry them", + location); + } + + // A path on a query replaces the read model's outright rather than extending it, so the read model's own path is + // only a route the application serves while some query still falls back to it. Reporting it regardless would + // name a route nothing answers at. + if (QueryReader.MethodsOf(type).Any(method => !method.HasAttribute(WellKnownTypeNames.PathAttribute))) + { + ReportRoutes([type.GetAttribute(WellKnownTypeNames.PathAttribute)?.GetArgument(0) as string], $"The read model '{type.Name}'", location); + } + } + + /// + /// Reports each route the application serves an artifact at. + /// + /// The routes that were declared, if any. + /// What declares them, as it reads at the start of the message. + /// Where it lives. + void ReportRoutes(IEnumerable routes, string what, string location) + { + foreach (var route in routes.Where(_ => !string.IsNullOrWhiteSpace(_))) + { + diagnostics.Information( + ScreenplayDiagnosticCodes.ServingConcernWithoutCounterpart, + $"{what} is served at '{route}' rather than the conventional route, which Screenplay has no counterpart for", + location); + } + } + /// /// Reads the queries a type exposes, leaving out any whose read model cannot be named. /// diff --git a/Source/DotNET/Screenplay/Analysis/Tags.cs b/Source/DotNET/Screenplay/Analysis/Tags.cs new file mode 100644 index 000000000..c1855da3e --- /dev/null +++ b/Source/DotNET/Screenplay/Analysis/Tags.cs @@ -0,0 +1,33 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; + +namespace Cratis.Arc.Screenplay.Analysis; + +/// +/// Reads the tags a declaration classifies itself by. +/// +/// +/// Chronicle tags observers, read models and event types alike, and only an event has somewhere in a Screenplay to +/// carry them. Reading them in one place is what lets the rest be reported rather than passed over. +/// +public static class Tags +{ + /// + /// Gets the tags a declaration carries. + /// + /// The declaration to read. + /// The tags, ordered, without duplicates. + public static string[] Of(ISymbol symbol) => + [ + .. symbol.GetAttributes() + .Where(_ => _.AttributeClass.Is(WellKnownTypeNames.TagAttribute) || _.AttributeClass.Is(WellKnownTypeNames.TagsAttribute)) + .SelectMany(_ => _.ConstructorArguments) + .SelectMany(_ => _.Kind == TypedConstantKind.Array ? _.Values.Select(value => value.Value) : [_.Value]) + .OfType() + .Where(_ => _.Length > 0) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + ]; +} diff --git a/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs b/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs index 4bd288e83..0512462a3 100644 --- a/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs +++ b/Source/DotNET/Screenplay/Analysis/WellKnownTypeNames.cs @@ -143,4 +143,7 @@ public static class WellKnownTypeNames /// The order a result is returned in, which the host fills in from the request. public const string Sorting = "Cratis.Arc.Queries.Sorting"; + + /// The attribute giving a read model or a query a route of its own rather than the conventional one. + public const string PathAttribute = "Cratis.Arc.Queries.ModelBound.PathAttribute"; } diff --git a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs index 69d49d3a0..80c92ef7a 100644 --- a/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs +++ b/Source/DotNET/Screenplay/ScreenplayDiagnosticCodes.cs @@ -375,4 +375,31 @@ public static class ScreenplayDiagnosticCodes /// /// public const string UnreadableSpecificationValue = "SP0040"; + + /// + /// The application says how an artifact is served, which a Screenplay does not describe. + /// + /// + /// A Screenplay says what an application is, never how a caller reaches it. Paging, sorting and a route template + /// are all real declarations about the running application, and all three concern the request rather than the + /// model - so there is nothing in the document they could be written into, and nothing to gain from inventing + /// somewhere. What is worth having is the reader knowing the question was asked at all: a document silent about + /// paging reads exactly like an application that does not page. The three share one code because they share one + /// reason, and a reader who does not want to hear about how the application is served suppresses all of it at + /// once. + /// + public const string ServingConcernWithoutCounterpart = "SP0041"; + + /// + /// A read model declares something the document has nowhere to hold. + /// + /// + /// A read model is never declared in a Screenplay in its own right - it appears only as the type a query answers + /// with - so anything it carries beyond its shape has nowhere to go. Tags are the case that matters, and they + /// matter because an event's tags are printed: a reader seeing tags throughout the events and none on + /// the read models would reasonably conclude that the read models carry none. This is separate from + /// because the reason is different - the declaration says nothing + /// about the request, it is a declaration the document does not make at all. + /// + public const string ReadModelFeatureWithoutCounterpart = "SP0042"; }