diff --git a/.claude/commands/coreex-scaffold.md b/.claude/commands/coreex-scaffold.md index a362f2fa..15da1144 100644 --- a/.claude/commands/coreex-scaffold.md +++ b/.claude/commands/coreex-scaffold.md @@ -1,5 +1,5 @@ --- -description: "CoreEx Solution Scaffolder — guides solution shaping after bootstrap (hosts, database, messaging, refdata/outbox/DDD/ROP options) and turns the answers into dotnet new template commands." +description: "CoreEx Solution Scaffolder — guides solution shaping after bootstrap (hosts, database, messaging, refdata/outbox/DDD/ROP options, and an optional Aspire AppHost for local multi-host orchestration/dashboard) and turns the answers into dotnet new template commands." allowed-tools: [Read, Glob, Grep, Edit, Write, Bash] --- diff --git a/.github/agents/coreex-expert.agent.md b/.github/agents/coreex-expert.agent.md index c823b43e..1795e177 100644 --- a/.github/agents/coreex-expert.agent.md +++ b/.github/agents/coreex-expert.agent.md @@ -111,6 +111,7 @@ Do not run `/coreex-docs-sync` silently — always offer and wait for confirmati - Separate explanation, plan, and implementation guidance clearly. - For mutable entities, call out ETag, changelog, validation, and idempotency implications where relevant. - For messaging, explicitly distinguish API-only, API plus outbox relay, API plus subscriber, and full orchestration shapes. +- The Api/Relay/Subscribe host split is a workload-isolation convention, not a technical requirement — for a small, low-traffic solution, consolidating hosted-service processing (outbox relay, subscriber receiving) into the Api host is a legitimate simplification. Mention it when a user's stated scale/traffic profile suggests the extra processes may not be earning their operational cost; see [Hosts Layer Guide § Choosing a Host Topology](https://github.com/Avanade/CoreEx/blob/main/samples/docs/hosts-layer.md#choosing-a-host-topology-split-vs-consolidate). - Never recommend editing `*.g.cs`, `*.g.sql`, or `*.g.pgsql` files — direct the user to the owning generator instead (Roslyn source generator for `*.g.cs`; `*.Database` project for `*.g.sql`/`*.g.pgsql`). ## Decision routing @@ -145,7 +146,7 @@ These skills are part of the CoreEx AI workflow set and live in `.github/skills/ **Broader routing:** -- Greenfield solution or host scaffolding → `/coreex-scaffold` (`coreex-solution-scaffolder` skill), which runs the matching [CoreEx.Template](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Template/README.md) `dotnet new coreex*` commands. +- Greenfield solution or host scaffolding, or adding a .NET Aspire AppHost for local multi-host orchestration/dashboard → `/coreex-scaffold` (`coreex-solution-scaffolder` skill), which runs the matching [CoreEx.Template](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Template/README.md) `dotnet new coreex*` commands. - Repo mapping or onboarding documentation → `/acquire-codebase-knowledge`. - Retrofit that no single skill covers → inspect the current code and recommend the smallest manual changes aligned to the samples and instructions. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4dd6d7dd..29e74647 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -38,6 +38,7 @@ solution; the rule applies to consumer solutions where these assets are installe - **Test**: `dotnet test CoreEx.sln` or target specific projects. - **Single test**: `dotnet test --filter "FullyQualifiedName~"` - **Samples**: docker-compose infrastructure + dotnet run for Database projects + Aspire AppHost. +- **`CoreEx.Template` changes**: `dotnet build`/`dotnet test` do **not** exercise `src/CoreEx.Template/content/**` — that's raw `dotnet new` template content, not compiled C#. Any change under `src/CoreEx.Template/content/` (a host's `Program.cs`/`GlobalUsing.cs`/`.csproj`, a `template.json` symbol, etc.) must be validated by actually scaffolding it: run [`tools/validate-template-pack.ps1`](../tools/validate-template-pack.ps1), which packs the template, installs it, scaffolds every parameter combination it knows about into temp directories, and `dotnet build`s the ones flagged `Build = $true`. It also runs in CI (`.github/workflows/CI.yml`). If you add a new template, host, or parameter combination, add a matching scenario to the script's `$testScenarios` array — parameter-conditional bugs (an unconditional `global using`/`ProjectReference` that should have been gated behind a symbol like `has-data-provider` or `implement-servicebus`) only surface when the generated code is actually compiled, which most existing host-template scenarios don't yet do since they scaffold in isolation without their `coreex` solution siblings. - **Linting**: No separate `dotnet format`. Build is the lint pass (nullable, LangVersion=preview, TreatWarningsAsErrors in `src\Directory.Build.props`). - **Formatting**: 4 spaces for `*.cs`, 2 spaces for `*.json|*.xml|*.yaml|*.props|*.csproj|*.sln|*.sql` per `.editorconfig`. - **Ad-hoc spike/reflection projects**: this repo uses Central Package Management (root `Directory.Packages.props`). A throwaway `dotnet new console` project scaffolded *inside* the repo tree (even outside `src\`/`tests\`) will silently inherit it and can fail to restore (`NU1008`) if it references a package/version not centrally pinned. Scaffold spike projects outside the repo tree, or set `false` in the spike project's own `.csproj` to opt out locally. @@ -189,7 +190,7 @@ see [coreex-ai-workflows.md](./coreex-ai-workflows.md). | Command | Type | When to use | |---------|------|-------------| -| `CoreEx.Template` | Template pack | Deterministic `dotnet new` scaffolding. Pin the version — `dotnet new install CoreEx.Template::` — then `dotnet new coreex` (solution), `coreex-api` / `coreex-relay` / `coreex-subscribe` (hosts), or `coreex-ai` (AI workflow assets). | +| `CoreEx.Template` | Template pack | Deterministic `dotnet new` scaffolding. Pin the version — `dotnet new install CoreEx.Template::` — then `dotnet new coreex` (solution), `coreex-api` / `coreex-relay` / `coreex-subscribe` (hosts), `coreex-domain` (optional DDD layer), `coreex-aspire` (optional local-orchestration AppHost), or `coreex-ai` (AI workflow assets). Validate any change to `src/CoreEx.Template/content/**` with [`tools/validate-template-pack.ps1`](../tools/validate-template-pack.ps1) — see "Build, Test, and Run" above. | | `CoreEx Expert` | Agent | Architecture guidance, pattern recommendations, and design review. Invoke via `/coreex-expert` (or `@coreex-expert`). | | `/coreex-scaffold` | Skill-backed prompt | Guided greenfield solution scaffolding (chooses the smallest safe shape, runs the `dotnet new coreex*` commands). | | `/coreex-docs-sync` | Skill | Refresh the whole AI asset bundle (instructions, skills, prompts, the `coreex-expert` agent, and the `.github/docs/coreex/` doc cache) to a new pinned CoreEx version after a version bump. | diff --git a/.github/instructions/coreex-domain.instructions.md b/.github/instructions/coreex-domain.instructions.md index 0432d5e5..5d4f3d41 100644 --- a/.github/instructions/coreex-domain.instructions.md +++ b/.github/instructions/coreex-domain.instructions.md @@ -16,6 +16,8 @@ tags: ["domain", "ddd", "aggregates", "entities", "value-objects", "result"] The Domain layer is **optional**. It is introduced only when a domain contains aggregates with meaningful business rules and invariants that must be enforced at the model level — not in orchestration code. For example, a checkout/basket domain with state-machine transitions and nested item rules benefits from this layer; a simple CRUD-oriented domain (like a product catalog) typically does not. +**Adoption is per-aggregate, not all-or-nothing.** Once a `*.Domain` project exists for a domain, that does not obligate every entity in the domain to become a full `Aggregate`/`Entity` with mutation guards and `PersistenceState` tracking. Only apply the extra ceremony to the entities that benefit from it — those with real invariants, state transitions, or integration events to raise. Other entities in the same domain can remain CRUD-oriented, orchestrated directly by an Application service against a repository. It is normal and expected to mix and match within a single domain. + > **Related skill:** to scaffold a new aggregate root, entity, or value object, invoke the [`coreex-aggregate`](/.github/skills/coreex-aggregate/SKILL.md) skill. > This file holds the invariants that must hold on **any** edit to a Domain-layer file; the skill drives the > step-by-step **creation** procedure. diff --git a/.github/instructions/coreex-host-setup.instructions.md b/.github/instructions/coreex-host-setup.instructions.md index 8dc6ded5..3145b4c6 100644 --- a/.github/instructions/coreex-host-setup.instructions.md +++ b/.github/instructions/coreex-host-setup.instructions.md @@ -16,6 +16,8 @@ tags: ["program-cs", "host-setup", "middleware", "dependency-registration", "cac The host is a **composition root only** — no business logic. There are three host types in a CoreEx solution depending on the capabilities required. Each follows the same opening skeleton, then diverges based on its responsibilities. +> **Split vs. consolidate:** Api/Relay/Subscribe as separate processes is a workload-isolation convention, not a technical requirement — see [Hosts Layer Guide § Choosing a Host Topology](/.github/docs/coreex/hosts-layer.md#choosing-a-host-topology-split-vs-consolidate) before assuming a small/low-traffic solution needs all three. + > **Related skill:** to scaffold a solution or an additional host (Api / Subscribe / Relay), invoke the [`coreex-solution-scaffolder`](/.github/skills/coreex-solution-scaffolder/SKILL.md) skill. > This file holds the invariants that must hold on **any** edit to a host `Program.cs`; the skill drives the > step-by-step **creation** procedure. (The per-host "Scaffolding an … host" blocks below stay here — they carry the diff --git a/.github/instructions/coreex-tooling.instructions.md b/.github/instructions/coreex-tooling.instructions.md index 7ceb0324..3cec3837 100644 --- a/.github/instructions/coreex-tooling.instructions.md +++ b/.github/instructions/coreex-tooling.instructions.md @@ -516,6 +516,13 @@ These `.g.sql` / `.g.pgsql` files are generated by DbEx — never edit them dire Seed data in `Data/ref-data.seed.yaml` is **cross-environment** — it is applied in every environment including production. It should therefore contain only shared **reference data** (lookup tables, code lists) that must exist everywhere. Do not seed master or transactional data here unless it is genuinely required in all environments; test-specific data belongs in the test project's own `data.yaml`, applied only during test setup. +> ⚠️ **`$`/`$^` merge is safe only for reference/lookup-shaped tables — not transactional or master data.** DbEx's generated `MERGE` upserts every non-key column it's given, unconditionally, on every run. That's fine for small `Code`/`Text`/`IsActive`/`SortOrder`-shaped reference tables with no other state to protect. It is **not** safe for transactional or master tables, which typically carry columns a blind upsert would silently corrupt: +> - `IChangeLog` audit columns (`CreatedBy`/`CreatedDate`/`UpdatedBy`/`UpdatedDate`) — a re-run merge overwrites the real audit trail with the seed's values. +> - Concurrency tokens (`RowVersion`/ETag) — merge doesn't participate in optimistic concurrency, so it can silently clobber a row a user has since updated. +> - `IsDeleted` soft-delete flags and FK-heavy business columns — merge has no notion of "this row was intentionally deleted" or referential business rules; it just overwrites. +> +> If a table has any of these shapes, seed it with a plain **unprefixed** INSERT (as `coreex-tests.instructions.md` already does for test seed data) instead of `$`/`$^`, and treat it as environment-specific (test/dev only), not something reused across environments via `ref-data.seed.yaml`. + **Structure** — there is exactly one valid shape, three levels deep: ``` @@ -593,6 +600,7 @@ products: - Do not declare the `IsDeleted` column under a table's `columns:` (and there is no `isDeleted` column flag) — it is recognised by convention from the live schema; keep table entries to `- name: Xxx` unless an override is genuinely needed. - Do not add a per-table `schema:` override for reference data (e.g. a `Ref` schema) — reference and transactional tables both live in the domain's root `schema:` unless a different schema actually exists. - Do not use the wrong casing in seed data — match the provider (SQL Server PascalCase `Code`/`Text`/`IsActive`/`SortOrder`; PostgreSQL snake_case `code`/`text`/`is_active`/`sort_order`), and do not hand-write `id`/`IsActive`/`SortOrder` rows — prefer the `Code: Text` shorthand. +- Do not use a `$`/`$^` merge prefix on a transactional or master table entry — merge blindly upserts every column it's given, which silently corrupts `IChangeLog` audit columns, concurrency tokens (`RowVersion`/ETag), `IsDeleted` soft-delete flags, and FK-heavy business columns on re-run. Merge prefixes are for small `Code`/`Text`/`IsActive`/`SortOrder`-shaped reference tables only; seed transactional/master data with a plain unprefixed INSERT instead. ## Further Reading diff --git a/.github/prompts/coreex-scaffold.prompt.md b/.github/prompts/coreex-scaffold.prompt.md index c7d38ef5..f7e3d18b 100644 --- a/.github/prompts/coreex-scaffold.prompt.md +++ b/.github/prompts/coreex-scaffold.prompt.md @@ -1,5 +1,5 @@ --- -description: Guide me through choosing and running the right CoreEx.Template dotnet new commands for a new solution +description: Guide me through choosing and running the right CoreEx.Template dotnet new commands for a new solution, including hosts, database/messaging choices, and an optional Aspire AppHost for local orchestration --- + + diff --git a/src/CoreEx.Template/README.md b/src/CoreEx.Template/README.md index 943f81e6..76d66a81 100644 --- a/src/CoreEx.Template/README.md +++ b/src/CoreEx.Template/README.md @@ -1,10 +1,10 @@ # CoreEx.Template -> Provides the `dotnet new` template pack for scaffolding CoreEx-based domain microservice solutions -- six composable templates, one `dotnet new install`. +> Provides the `dotnet new` template pack for scaffolding CoreEx-based domain microservice solutions -- seven composable templates, one `dotnet new install`. ## Overview -`CoreEx.Template` is a `PackageType=Template` NuGet package that installs six `dotnet new` templates as a single unit. Together they cover AI workflow assets, the full project topology for a CoreEx domain-based microservice (shared solution core plus independently deployable host processes), and an optional domain layer. +`CoreEx.Template` is a `PackageType=Template` NuGet package that installs seven `dotnet new` templates as a single unit. Together they cover AI workflow assets, the full project topology for a CoreEx domain-based microservice (shared solution core plus independently deployable host processes), an optional domain layer, and an optional Aspire AppHost for local orchestration. | Short name | Template | Emits | |---|---|---| @@ -14,6 +14,7 @@ | `coreex-api` | CoreEx API host | `src/[name].Api/` host project + `tests/[solution].Test.Api/` integration test project | | `coreex-relay` | CoreEx Outbox Relay host | `src/[name].Relay/` host project + `tests/[solution].Test.Relay/` integration test project | | `coreex-subscribe` | CoreEx Subscriber host | `src/[name].Subscribe/` host project + `tests/[solution].Test.Subscribe/` integration test project | +| `coreex-aspire` | CoreEx Aspire AppHost | `src/[name].Aspire/` AppHost project orchestrating this solution's own hosts — add-on, run after the hosts it references already exist | Parameters are consistent across templates -- the same `--data-provider`, `--messaging-provider`, and feature flags appear in every template that needs them, ensuring the generated code is coherent regardless of which templates you use. @@ -80,7 +81,7 @@ dotnet new coreex-ai /coreex-bootstrap ``` -Then answer the `/coreex-scaffold` questions. The workflow derives the required `coreex`, `coreex-domain` (optional), `coreex-api`, `coreex-relay`, and `coreex-subscribe` commands. +Then answer the `/coreex-scaffold` questions. The workflow derives the required `coreex`, `coreex-domain` (optional), `coreex-api`, `coreex-relay`, `coreex-subscribe`, and `coreex-aspire` (optional) commands. --- @@ -249,7 +250,7 @@ dotnet new coreex -n Avanade.Erp.Sales --data-provider None --messaging-provider Domain-driven design with ROP, Postgres, no outbox: ```sh dotnet new coreex -n Avanade.Erp.Sales --rop-enabled true --data-provider Postgres --outbox-enabled false -dotnet new coreex-domain -n Avanade.Erp.Sales +dotnet new coreex-domain -n Avanade.Erp.Sales.Domain dotnet sln Avanade.Erp.Sales.slnx add src/Avanade.Erp.Sales.Domain dotnet add src/Avanade.Erp.Sales.Application/Avanade.Erp.Sales.Application.csproj reference src/Avanade.Erp.Sales.Domain/Avanade.Erp.Sales.Domain.csproj ``` @@ -260,13 +261,13 @@ dotnet add src/Avanade.Erp.Sales.Application/Avanade.Erp.Sales.Application.cspro Scaffolds an optional `*.Domain` class-library project for solutions where domain complexity warrants domain-driven design: aggregate roots, child entities, value objects, and `PersistenceState`-driven mutation methods via `CoreEx.DomainDriven`. Most solutions do not need this -- add it only when the Application layer's CRUD-style orchestration is no longer enough. -Run this **after** `coreex`, from the solution root. Unlike `coreex-api`/`coreex-relay`/`coreex-subscribe`, this template does not add itself to the `.slnx` -- wire it in yourself with `dotnet sln add`. You must also add a project reference from `Application` to the new `Domain` project (`dotnet add ... reference ...`) -- `Application` is the only layer with a direct dependency on `Domain` (`Infrastructure` only reaches it transitively through `Application`), and nothing else wires that reference for you. +Run this **after** `coreex`, from the solution root. Like `coreex-api`/`coreex-relay`/`coreex-subscribe`, this template requires the solution base name **with its own suffix appended** (e.g. `Avanade.Erp.Sales.Domain`, not the bare `Avanade.Erp.Sales`) -- passing the bare base name emits into the wrong folder and breaks the `Contracts` project reference. Unlike those hosts, `coreex-domain` does not add itself to the `.slnx` -- wire it in yourself with `dotnet sln add`. You must also add a project reference from `Application` to the new `Domain` project (`dotnet add ... reference ...`) -- `Application` is the only layer with a direct dependency on `Domain` (`Infrastructure` only reaches it transitively through `Application`), and nothing else wires that reference for you. ### Parameters | Parameter | Type | Default | Description | |---|---|---|---| -| `-n` / `--name` | string | _(required)_ | Solution base name, matching the `coreex` invocation, e.g. `Avanade.Erp.Sales`. | +| `-n` / `--name` | string | _(required)_ | Solution base name **plus** the `.Domain` suffix, e.g. `Avanade.Erp.Sales.Domain`. | ### Output @@ -281,7 +282,7 @@ src/ ```sh dotnet new coreex -n Avanade.Erp.Sales --rop-enabled true --data-provider Postgres --outbox-enabled false -dotnet new coreex-domain -n Avanade.Erp.Sales +dotnet new coreex-domain -n Avanade.Erp.Sales.Domain dotnet sln Avanade.Erp.Sales.slnx add src/Avanade.Erp.Sales.Domain dotnet add src/Avanade.Erp.Sales.Application/Avanade.Erp.Sales.Application.csproj reference src/Avanade.Erp.Sales.Domain/Avanade.Erp.Sales.Domain.csproj ``` @@ -294,6 +295,11 @@ Scaffolds an ASP.NET Core Web API host project. Wires up CoreEx execution contex Run this from the **solution root** (the directory created by `coreex`). The template emits into both `src/` and `tests/` so it must be run at the root level. +> **Note:** If `--refdata-enabled true` and `*.CodeGen` has already been run once (e.g. this host is being added +> to an existing solution), re-run `dotnet run --project tools/[solution].CodeGen` afterward. CodeGen only emits +> the reference-data controller into an `*.Api` project directory that exists at generation time -- it silently +> skips it (a log warning, not an error) for a host added later. + ### Parameters | Parameter | Type | Default | Description | @@ -539,6 +545,64 @@ dotnet new coreex-subscribe -n Avanade.Erp.Sales.Subscribe --data-provider None --- +## Template 7 -- `coreex-aspire` (Aspire AppHost) + +Scaffolds a .NET Aspire AppHost project that orchestrates this solution's own `Api`/`Relay`/`Subscribe` hosts for local development, plus an `Extensions.cs` providing dashboard sugar (health-check deep links, and "Pause all services"/"Resume all services" buttons for hosts running CoreEx hosted services). + +Run this from the **solution root**, after the host projects it will reference already exist. Unlike `coreex-api`/`coreex-relay`/`coreex-subscribe`, host inclusion is not derived from a shared `coreex` parameter -- pass `--has-api`/`--has-relay`/`--has-subscribe` explicitly to match whichever hosts this solution actually has. + +> **Note:** If a new host is added to the solution *after* `coreex-aspire` has already been run, don't re-run it with `--force` -- that would overwrite any customisation already made to `AppHost.cs`/`Extensions.cs`. Add the missing `` and `builder.AddProject<...>(...)` line by hand instead. + +### Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `-n` / `--name` | string | _(required)_ | Full project name, e.g. `Avanade.Erp.Sales.Aspire`. | +| `--has-api` | bool | `true` | Include a project reference and `AddProject<...>` call for this solution's `Api` host. | +| `--has-relay` | bool | `false` | Include a project reference and `AddProject<...>` call for this solution's `Relay` host. | +| `--has-subscribe` | bool | `false` | Include a project reference and `AddProject<...>` call for this solution's `Subscribe` host. | + +### Output + +``` +src/ + [name].Aspire/ + [name].Aspire.csproj + AppHost.cs + Extensions.cs + appsettings.json + appsettings.Development.json + Properties/launchSettings.json + AGENTS.md +``` + +**`AppHost.cs`** (illustrative, all three hosts included): + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +// Sales domain. +builder.AddProject("sales-api").AddEndpoints("/health/ready/detailed"); +builder.AddProject("sales-relay").AddEndpoints("/health/ready/detailed").AddHostedServiceSupport(); +builder.AddProject("sales-subscribe").AddEndpoints("/health/ready/detailed").AddHostedServiceSupport(); + +builder.Build().Run(); +``` + +### Examples + +API only: +```sh +dotnet new coreex-aspire -n Avanade.Erp.Sales.Aspire --has-api true +``` + +Full event-driven (API + Relay + Subscribe): +```sh +dotnet new coreex-aspire -n Avanade.Erp.Sales.Aspire --has-api true --has-relay true --has-subscribe true +``` + +--- + ## Typical Workflow These templates are independent -- use only the ones your solution needs (an optional `coreex-domain` add-on can be scaffolded after Step 1 when DDD complexity warrants it; see the `coreex-domain` template above). The following shows a full event-driven microservice topology: @@ -576,6 +640,15 @@ dotnet sln Avanade.Erp.Sales.slnx add src/Avanade.Erp.Sales.Subscribe dotnet sln Avanade.Erp.Sales.slnx add tests/Avanade.Erp.Sales.Test.Subscribe ``` +### Step 4 -- Add an Aspire AppHost (optional) + +Once the hosts above exist, optionally scaffold an AppHost for local orchestration: + +```sh +dotnet new coreex-aspire -n Avanade.Erp.Sales.Aspire --has-api true --has-relay true --has-subscribe true +dotnet sln Avanade.Erp.Sales.slnx add src/Avanade.Erp.Sales.Aspire +``` + ### Resulting directory structure ``` @@ -589,6 +662,7 @@ Avanade.Erp.Sales/ Avanade.Erp.Sales.Api/ Avanade.Erp.Sales.Relay/ Avanade.Erp.Sales.Subscribe/ + Avanade.Erp.Sales.Aspire/ tools/ Avanade.Erp.Sales.Database/ Avanade.Erp.Sales.CodeGen/ @@ -613,6 +687,7 @@ dotnet new coreex -n Avanade.Erp.Sales dotnet new coreex-api -n Avanade.Erp.Sales.Api dotnet new coreex-relay -n Avanade.Erp.Sales.Relay dotnet new coreex-subscribe -n Avanade.Erp.Sales.Subscribe +dotnet new coreex-aspire -n Avanade.Erp.Sales.Aspire --has-api true --has-relay true --has-subscribe true ``` ### PostgreSQL variant @@ -649,18 +724,24 @@ dotnet new coreex-subscribe -n Avanade.Erp.Sales.Subscribe ## Package Design Notes -**Single install, five project-scaffolding templates.** All five ship inside the same NuGet package as `coreex-ai` (`PackageType=Template`). A single `dotnet new install CoreEx.Template` makes all short names available: `coreex`, `coreex-domain`, `coreex-api`, `coreex-relay`, and `coreex-subscribe`. +**Single install, six project-scaffolding templates.** All six ship inside the same NuGet package as `coreex-ai` (`PackageType=Template`). A single `dotnet new install CoreEx.Template` makes all short names available: `coreex`, `coreex-domain`, `coreex-api`, `coreex-relay`, `coreex-subscribe`, and `coreex-aspire`. -**Version stamping.** Each `template.json` carries `COREEX_VERSION` as a placeholder. During `dotnet pack`, an inline MSBuild `ReplaceTextInFile` task stamps the actual `$(Version)` into generated copies of the five `template.json` files (`CoreEx.Ai`, `CoreEx.Core`, `CoreEx.Api`, `CoreEx.Relay`, `CoreEx.Subscribe`) before they are packed. The source copies in `content/` retain the placeholder and remain editable. `CoreEx.Domain`'s `template.json` has no `COREEX_VERSION` token and is packed directly, unstamped. +**Version stamping.** Each `template.json` carries `COREEX_VERSION` as a placeholder. During `dotnet pack`, an inline MSBuild `ReplaceTextInFile` task stamps the actual `$(Version)` into generated copies of the five `template.json` files (`CoreEx.Ai`, `CoreEx.Core`, `CoreEx.Api`, `CoreEx.Relay`, `CoreEx.Subscribe`) before they are packed. The source copies in `content/` retain the placeholder and remain editable. `CoreEx.Domain` and `CoreEx.Aspire` have no `COREEX_VERSION` token (neither depends on a CoreEx NuGet package) and are packed directly, unstamped. -**Central Package Management.** All `PackageReference` entries in generated projects carry no `Version` attribute -- versions are resolved from the `Directory.Packages.props` emitted by the `coreex` solution template. The host templates therefore require that the `coreex` solution template has been applied first (since `Directory.Packages.props` lives at the solution root). +**Central Package Management.** All `PackageReference` entries in generated projects carry no `Version` attribute -- versions are resolved from the `Directory.Packages.props` emitted by the `coreex` solution template. The host templates therefore require that the `coreex` solution template has been applied first (since `Directory.Packages.props` lives at the solution root). `coreex-aspire` is the one exception: its single extra package (the `MessagePack` CVE override) uses a per-project `VersionOverride` instead, since it is designed to be run standalone without requiring a `Directory.Packages.props` update. **Conditional file exclusion.** The template engine's `sources.modifiers` with glob patterns handle all conditional file output -- no empty placeholder files are emitted. The `.slnx` solution file uses `specialCustomOperations` to enable `` XML-style conditionals (the template engine does not recognise `.slnx` as XML by default). -**`preferNameDirectory`.** All five project-scaffolding templates set `preferNameDirectory: false` so they generate into the current directory rather than creating an extra named subdirectory. The solution template generates its content directly into the current directory. Host templates (and the `coreex-domain` add-on) generate their content into `src/[ProjectName]/` (and `tests/[solution-name].Test.X/` for hosts) subdirectories, which are created by the template itself as part of the source layout -- not by the `preferNameDirectory` mechanism. +**`preferNameDirectory`.** All six project-scaffolding templates set `preferNameDirectory: false` so they generate into the current directory rather than creating an extra named subdirectory. The solution template generates its content directly into the current directory. Host templates (and the `coreex-domain`/`coreex-aspire` add-ons) generate their content into `src/[ProjectName]/` (and `tests/[solution-name].Test.X/` for hosts) subdirectories, which are created by the template itself as part of the source layout -- not by the `preferNameDirectory` mechanism. **`solution-name` file renaming.** The `solution-name` derived symbol (everything before the last dot-segment of the `-n` value) carries `fileRename: "solution-name"` in all host templates. This causes directory names like `solution-name.Test.Api` to be substituted at generation time, producing correctly-named test project folders (e.g. `Avanade.Erp.Sales.Test.Api`) that match the CoreEx sample naming convention. +## Validating Template Changes + +`dotnet build`/`dotnet test` on `CoreEx.sln` never compiles anything under `content/` -- it's raw `dotnet new` template source, not a C# project. A change to a host's `Program.cs`, `GlobalUsing.cs`, `.csproj`, or a `template.json` symbol is only proven correct by actually scaffolding it and building the result. Run [`../../tools/validate-template-pack.ps1`](../../tools/validate-template-pack.ps1) (also run in CI) -- it packs and installs the template, scaffolds every parameter combination in its `$testScenarios` array into temp directories, asserts expected file presence/content, and `dotnet build`s the scenarios flagged `Build = $true`. + +When adding a template, a host, or a new parameter, add a matching scenario. Most existing host-template scenarios (`coreex-api-*`, `coreex-relay-*`, `coreex-subscribe-*`) scaffold in isolation and set `Build = $false` because they have no `coreex`-generated siblings to compile against -- so a symbol-conditional bug (an unconditional `global using`/`ProjectReference` that should have been gated behind `has-data-provider`, `implement-servicebus`, etc.) will only be caught by a scenario that scaffolds `coreex` plus the host together in the same directory and builds the host's own `.csproj`. + ## Additional Resources - [CoreEx](https://github.com/Avanade/CoreEx) -- The framework these templates scaffold for. diff --git a/src/CoreEx.Template/content/CoreEx.Api/src/app-name.Api/GlobalUsing.cs b/src/CoreEx.Template/content/CoreEx.Api/src/app-name.Api/GlobalUsing.cs index e8b503e4..15dbfe59 100644 --- a/src/CoreEx.Template/content/CoreEx.Api/src/app-name.Api/GlobalUsing.cs +++ b/src/CoreEx.Template/content/CoreEx.Api/src/app-name.Api/GlobalUsing.cs @@ -1,7 +1,9 @@ global using CoreEx; global using CoreEx.AspNetCore.Mvc; global using CoreEx.Caching; +// #if has-data-provider global using CoreEx.Database; +// #endif // #if implement-sqlserver global using CoreEx.Database.SqlServer; // #elif implement-postgres diff --git a/src/CoreEx.Template/content/CoreEx.Api/src/app-name.Api/Program.cs b/src/CoreEx.Template/content/CoreEx.Api/src/app-name.Api/Program.cs index f1191cd3..0c731adc 100644 --- a/src/CoreEx.Template/content/CoreEx.Api/src/app-name.Api/Program.cs +++ b/src/CoreEx.Template/content/CoreEx.Api/src/app-name.Api/Program.cs @@ -4,7 +4,9 @@ using StackExchange.Redis; using ZiggyCreatures.Caching.Fusion; using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis; +// #if (has-data-provider || refdata-enabled) using solution-name.Infrastructure.Repositories; +// #endif namespace app-name.Api; diff --git a/src/CoreEx.Template/content/CoreEx.Aspire/.template.config/template.json b/src/CoreEx.Template/content/CoreEx.Aspire/.template.config/template.json new file mode 100644 index 00000000..02d700e7 --- /dev/null +++ b/src/CoreEx.Template/content/CoreEx.Aspire/.template.config/template.json @@ -0,0 +1,311 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "https://github.com/avanade/coreex", + "classifications": [ "CoreEx", "Microservice", "Aspire" ], + "identity": "CoreEx.Aspire", + "name": "CoreEx Aspire AppHost", + "description": "A .NET Aspire AppHost project that orchestrates this solution's own Api/Relay/Subscribe hosts for local development. Run after the host projects it references already exist — wire into the solution via dotnet sln add.", + "shortName": "coreex-aspire", + "tags": { + "language": "C#", + "type": "project" + }, + "sourceName": "app-name.Aspire", + "preferNameDirectory": false, + "symbols": { + "domain-name": { + "type": "derived", + "valueSource": "name", + "valueTransform": "ValueSecondToLastDotDelimited", + "fileRename": "domain-name", + "replaces": "domain-name" + }, + "solution-name": { + "type": "derived", + "valueSource": "name", + "valueTransform": "ValueWithoutLastDotDelimited", + "fileRename": "solution-name", + "replaces": "solution-name" + }, + "solution-name-underscore": { + "type": "derived", + "valueSource": "name", + "valueTransform": "SolutionNameUnderscore", + "replaces": "solution-name-underscore" + }, + "solution-parent-name": { + "type": "derived", + "valueSource": "name", + "valueTransform": "ValueWithoutLastTwoDotDelimited", + "replaces": "solution-parent-name" + }, + "domain-name-lower": { + "type": "derived", + "valueSource": "name", + "valueTransform": "ValueSecondToLastDotDelimitedLower", + "replaces": "domain-name-lower" + }, + "domain-parent-lower": { + "type": "derived", + "valueSource": "name", + "valueTransform": "DomainParentLower", + "replaces": "domain-parent-lower" + }, + "has-api": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "true", + "displayName": "Has API host", + "description": "Indicates this solution already has (or will have) an Api host to orchestrate." + }, + "has-relay": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "false", + "displayName": "Has Relay host", + "description": "Indicates this solution already has (or will have) a Relay host to orchestrate." + }, + "has-subscribe": { + "type": "parameter", + "datatype": "bool", + "defaultValue": "false", + "displayName": "Has Subscribe host", + "description": "Indicates this solution already has (or will have) a Subscribe host to orchestrate." + }, + "appHttpPort": { + "type": "parameter", + "datatype": "integer", + "description": "Port number to use for the AppHost's HTTP endpoint in launchSettings.json." + }, + "appHttpPortGenerated": { + "type": "generated", + "generator": "port", + "parameters": { "low": 15000, "high": 15299 } + }, + "appHttpPortReplacer": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "appHttpPort", + "fallbackVariableName": "appHttpPortGenerated" + }, + "replaces": "15174" + }, + "appHttpsPort": { + "type": "parameter", + "datatype": "integer", + "description": "Port number to use for the AppHost's HTTPS endpoint in launchSettings.json." + }, + "appHttpsPortGenerated": { + "type": "generated", + "generator": "port", + "parameters": { "low": 17000, "high": 17299 } + }, + "appHttpsPortReplacer": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "appHttpsPort", + "fallbackVariableName": "appHttpsPortGenerated" + }, + "replaces": "17226" + }, + "otlpHttpPort": { + "type": "parameter", + "datatype": "integer", + "description": "Port number to use for the Aspire dashboard's OTLP endpoint (HTTP profile) in launchSettings.json." + }, + "otlpHttpPortGenerated": { + "type": "generated", + "generator": "port", + "parameters": { "low": 19000, "high": 19299 } + }, + "otlpHttpPortReplacer": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "otlpHttpPort", + "fallbackVariableName": "otlpHttpPortGenerated" + }, + "replaces": "19089" + }, + "otlpHttpsPort": { + "type": "parameter", + "datatype": "integer", + "description": "Port number to use for the Aspire dashboard's OTLP endpoint (HTTPS profile) in launchSettings.json." + }, + "otlpHttpsPortGenerated": { + "type": "generated", + "generator": "port", + "parameters": { "low": 21000, "high": 21299 } + }, + "otlpHttpsPortReplacer": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "otlpHttpsPort", + "fallbackVariableName": "otlpHttpsPortGenerated" + }, + "replaces": "21089" + }, + "mcpHttpPort": { + "type": "parameter", + "datatype": "integer", + "description": "Port number to use for the Aspire dashboard's MCP endpoint (HTTP profile) in launchSettings.json." + }, + "mcpHttpPortGenerated": { + "type": "generated", + "generator": "port", + "parameters": { "low": 18000, "high": 18299 } + }, + "mcpHttpPortReplacer": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "mcpHttpPort", + "fallbackVariableName": "mcpHttpPortGenerated" + }, + "replaces": "18153" + }, + "mcpHttpsPort": { + "type": "parameter", + "datatype": "integer", + "description": "Port number to use for the Aspire dashboard's MCP endpoint (HTTPS profile) in launchSettings.json." + }, + "mcpHttpsPortGenerated": { + "type": "generated", + "generator": "port", + "parameters": { "low": 22000, "high": 22299 } + }, + "mcpHttpsPortReplacer": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "mcpHttpsPort", + "fallbackVariableName": "mcpHttpsPortGenerated" + }, + "replaces": "22089" + }, + "resourceServiceHttpPort": { + "type": "parameter", + "datatype": "integer", + "description": "Port number to use for the Aspire dashboard's resource-service endpoint (HTTP profile) in launchSettings.json." + }, + "resourceServiceHttpPortGenerated": { + "type": "generated", + "generator": "port", + "parameters": { "low": 23000, "high": 23299 } + }, + "resourceServiceHttpPortReplacer": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "resourceServiceHttpPort", + "fallbackVariableName": "resourceServiceHttpPortGenerated" + }, + "replaces": "23090" + }, + "resourceServiceHttpsPort": { + "type": "parameter", + "datatype": "integer", + "description": "Port number to use for the Aspire dashboard's resource-service endpoint (HTTPS profile) in launchSettings.json." + }, + "resourceServiceHttpsPortGenerated": { + "type": "generated", + "generator": "port", + "parameters": { "low": 20000, "high": 20299 } + }, + "resourceServiceHttpsPortReplacer": { + "type": "generated", + "generator": "coalesce", + "parameters": { + "sourceVariableName": "resourceServiceHttpsPort", + "fallbackVariableName": "resourceServiceHttpsPortGenerated" + }, + "replaces": "20089" + } + }, + "specialCustomOperations": { + "*.md": { + "operations": [ + { + "type": "conditional", + "configuration": { + "actionableIf": [ " +- **Api host** included. + + +- **Relay host** included. + + +- **Subscribe host** included. + + +## Adding a Host Later + +If a new `Api`, `Relay`, or `Subscribe` host is added to this solution *after* this AppHost was generated, do not +re-run `dotnet new coreex-aspire --force` -- it will overwrite any customisation already made here. Instead, add +the missing pieces by hand: + +1. A `` to the new host's `.csproj` in `app-name.Aspire.csproj`. +2. A `builder.AddProject("...")` call in `AppHost.cs`, following the pattern already used for the + other hosts. + +## `Extensions.cs` + +Provides fluent dashboard sugar used from `AppHost.cs`: + +- `AddEndpoints(...)` -- annotates dashboard-visible URLs (e.g. health-check deep links) on a resource. +- `AddCommand(...)` -- adds a dashboard button that invokes an HTTP verb against an endpoint. +- `AddHostedServiceSupport()` -- composes the above to add "Pause all services"/"Resume all services" dashboard + buttons for hosts that run `CoreEx` hosted services (Relay and Subscribe hosts expose + `/hosted-services/all/{status,pause,resume}` via `MapHostedServices()`; the Api host does not, so it is never + called there). + +## Running + +```sh +dotnet run --project src/solution-name.Aspire +``` + +Consult `.github/docs/coreex/local-dev.md` and `.github/docs/coreex/aspire.md` for the full local-development +workflow, including the Aspire CLI (`aspire run`, `aspire logs`, etc.). diff --git a/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/AppHost.cs b/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/AppHost.cs new file mode 100644 index 00000000..a3442f90 --- /dev/null +++ b/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/AppHost.cs @@ -0,0 +1,14 @@ +var builder = DistributedApplication.CreateBuilder(args); + +// domain-name domain. +// #if has-api +builder.AddProject("domain-name-lower-api").AddEndpoints("/health/ready/detailed"); +// #endif +// #if has-relay +builder.AddProject("domain-name-lower-relay").AddEndpoints("/health/ready/detailed").AddHostedServiceSupport(); +// #endif +// #if has-subscribe +builder.AddProject("domain-name-lower-subscribe").AddEndpoints("/health/ready/detailed").AddHostedServiceSupport(); +// #endif + +builder.Build().Run(); diff --git a/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/Extensions.cs b/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/Extensions.cs new file mode 100644 index 00000000..bbf1c345 --- /dev/null +++ b/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/Extensions.cs @@ -0,0 +1,49 @@ +/// +/// Provides extensions that add Aspire dashboard sugar (deep-linked endpoints +/// and command buttons) for the s this AppHost orchestrates. +/// +internal static class Extensions +{ + /// + /// Annotates the resource's http endpoint with one or more relative so they appear as clickable links in the Aspire dashboard. + /// + /// The of . + /// The relative URLs to add, e.g. "/health/ready/detailed". + /// The for fluent-style method-chaining. + public static IResourceBuilder AddEndpoints(this IResourceBuilder builder, params string[] urls) + { + var httpEndpoint = builder.GetEndpoint("http"); + foreach (var url in urls) + { + builder.WithAnnotation(new ResourceUrlAnnotation { Endpoint = httpEndpoint, Url = url }); + } + + return builder; + } + + /// + /// Adds a dashboard command button that invokes an HTTP against a relative on the resource. + /// + /// The of . + /// The to invoke. + /// The relative path to invoke, e.g. "/hosted-services/all/pause". + /// The button's display name shown in the dashboard. + /// The optional Fluent UI icon name for the button; see the icon catalog. + /// The for fluent-style method-chaining. + public static IResourceBuilder AddCommand(this IResourceBuilder builder, HttpMethod method, string path, string displayName, string? iconName) + => builder.WithHttpCommand( + path: path, + displayName: displayName, + commandOptions: new HttpCommandOptions() { Method = method, IconName = iconName }); + + /// + /// Adds dashboard support for the standard CoreEx hosted-service management endpoints (/hosted-services/all/{status,pause,resume} via MapHostedServices()) -- a status link plus "Pause all services"/"Resume all services" command buttons. + /// + /// The of . + /// The for fluent-style method-chaining. + /// The stock Api host template doesn't register AddHostedServiceManager()/MapHostedServices() -- Relay and Subscribe do. This split is a workload-isolation convention, not a technical requirement; for a small, low-traffic solution, consolidating hosted-service processing into the Api host is a reasonable simplification. Call this method wherever those endpoints are actually mapped. + public static IResourceBuilder AddHostedServiceSupport(this IResourceBuilder builder) + => builder.AddEndpoints("/hosted-services/all/status") + .AddCommand(HttpMethod.Post, "/hosted-services/all/pause", "Pause all services", "Pause") + .AddCommand(HttpMethod.Post, "/hosted-services/all/resume", "Resume all services", "PauseOff"); +} diff --git a/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/Properties/launchSettings.json b/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/Properties/launchSettings.json new file mode 100644 index 00000000..20604ead --- /dev/null +++ b/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/Properties/launchSettings.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17226;http://localhost:15174", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21089", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:22089", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:20089" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15174", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19089", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18153", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:23090" + } + } + } +} diff --git a/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/app-name.Aspire.csproj b/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/app-name.Aspire.csproj new file mode 100644 index 00000000..68b5f8a6 --- /dev/null +++ b/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/app-name.Aspire.csproj @@ -0,0 +1,25 @@ + + + Exe + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/appsettings.Development.json b/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/appsettings.Development.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/appsettings.Development.json @@ -0,0 +1 @@ +{} diff --git a/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/appsettings.json b/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/appsettings.json new file mode 100644 index 00000000..31c092aa --- /dev/null +++ b/src/CoreEx.Template/content/CoreEx.Aspire/src/app-name.Aspire/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/CoreEx.Template/content/CoreEx.Core/AGENTS.md b/src/CoreEx.Template/content/CoreEx.Core/AGENTS.md index d8a017a6..9f996add 100644 --- a/src/CoreEx.Template/content/CoreEx.Core/AGENTS.md +++ b/src/CoreEx.Template/content/CoreEx.Core/AGENTS.md @@ -54,7 +54,7 @@ app-name/ - **Reference data:** Disabled -- **Domain project:** Optional -- add `dotnet new coreex-domain -n app-name` when domain complexity warrants DDD +- **Domain project:** Optional -- add `dotnet new coreex-domain -n app-name.Domain` when domain complexity warrants DDD - **Railway-Oriented Programming:** Enabled -- service methods return `Result`/`Result` diff --git a/src/CoreEx.Template/content/CoreEx.Core/README.md b/src/CoreEx.Template/content/CoreEx.Core/README.md index 85d2928a..5d203d89 100644 --- a/src/CoreEx.Template/content/CoreEx.Core/README.md +++ b/src/CoreEx.Template/content/CoreEx.Core/README.md @@ -173,6 +173,16 @@ dotnet run --project tools/app-name.CodeGen Commit the generated `*.g.cs` files alongside the `ref-data.yaml` changes. **Never edit generated files by hand** — they are overwritten on the next run. +> **Run this before your first `dotnet run`, not just after editing `ref-data.yaml`.** The scaffold's +> `ReferenceDataService` requires `IReferenceDataRepository`, which only gets a DI registration once CodeGen +> generates it. `dotnet build` succeeds either way, but starting a host with `ASPNETCORE_ENVIRONMENT=Development` +> (the default for IDE launch profiles) before CodeGen has ever run throws a DI-validation exception at startup. +> +> This also applies any time a **new API host is added to an existing solution** after CodeGen already ran: +> CodeGen only emits the reference-data controller into an `*.Api` project that exists at generation time — it +> silently skips it (logging a warning, not an error) for any API project added later. Re-run CodeGen after +> adding a host, not just after editing `ref-data.yaml`. + --- diff --git a/src/CoreEx.Template/content/CoreEx.Domain/.template.config/template.json b/src/CoreEx.Template/content/CoreEx.Domain/.template.config/template.json index f2dd206a..9f2005fe 100644 --- a/src/CoreEx.Template/content/CoreEx.Domain/.template.config/template.json +++ b/src/CoreEx.Template/content/CoreEx.Domain/.template.config/template.json @@ -10,6 +10,44 @@ "language": "C#", "type": "project" }, - "sourceName": "app-name", - "preferNameDirectory": false + "sourceName": "app-name.Domain", + "preferNameDirectory": false, + "symbols": { + "domain-name": { + "type": "derived", + "valueSource": "name", + "valueTransform": "ValueSecondToLastDotDelimited", + "fileRename": "domain-name", + "replaces": "domain-name" + }, + "solution-name": { + "type": "derived", + "valueSource": "name", + "valueTransform": "ValueWithoutLastDotDelimited", + "fileRename": "solution-name", + "replaces": "solution-name" + }, + "solution-parent-name": { + "type": "derived", + "valueSource": "name", + "valueTransform": "ValueWithoutLastTwoDotDelimited", + "replaces": "solution-parent-name" + } + }, + "forms": { + "ValueSecondToLastDotDelimited": { + "identifier": "replace", + "pattern": "^(?:.*\\.)?([^.]+)\\.[^.]+$", + "replacement": "$1" + }, + "ValueWithoutLastDotDelimited": { + "identifier": "replace", + "pattern": "^(.*)\\.([^\\.]+)$", + "replacement": "$1" + }, + "ValueWithoutLastTwoDotDelimited": { + "identifier": "chain", + "steps": [ "ValueWithoutLastDotDelimited", "ValueWithoutLastDotDelimited" ] + } + } } diff --git a/src/CoreEx.Template/content/CoreEx.Domain/src/app-name.Domain/app-name.Domain.csproj b/src/CoreEx.Template/content/CoreEx.Domain/src/app-name.Domain/app-name.Domain.csproj index ce0dea66..ac2945b7 100644 --- a/src/CoreEx.Template/content/CoreEx.Domain/src/app-name.Domain/app-name.Domain.csproj +++ b/src/CoreEx.Template/content/CoreEx.Domain/src/app-name.Domain/app-name.Domain.csproj @@ -1,6 +1,6 @@ - + diff --git a/src/CoreEx.Template/content/CoreEx.Subscribe/src/app-name.Subscribe/GlobalUsing.cs b/src/CoreEx.Template/content/CoreEx.Subscribe/src/app-name.Subscribe/GlobalUsing.cs index 1296e9e6..e4673a79 100644 --- a/src/CoreEx.Template/content/CoreEx.Subscribe/src/app-name.Subscribe/GlobalUsing.cs +++ b/src/CoreEx.Template/content/CoreEx.Subscribe/src/app-name.Subscribe/GlobalUsing.cs @@ -1,8 +1,12 @@ global using CoreEx; global using CoreEx.AspNetCore.Mvc; +// #if implement-servicebus global using CoreEx.Azure.Messaging.ServiceBus; +// #endif global using CoreEx.Caching; +// #if has-data-provider global using CoreEx.Database; +// #endif // #if implement-sqlserver global using CoreEx.Database.SqlServer; // #elif implement-postgres diff --git a/src/CoreEx.Template/content/CoreEx.Subscribe/src/app-name.Subscribe/Program.cs b/src/CoreEx.Template/content/CoreEx.Subscribe/src/app-name.Subscribe/Program.cs index ccf9ffca..76b92529 100644 --- a/src/CoreEx.Template/content/CoreEx.Subscribe/src/app-name.Subscribe/Program.cs +++ b/src/CoreEx.Template/content/CoreEx.Subscribe/src/app-name.Subscribe/Program.cs @@ -1,4 +1,6 @@ +// #if (has-data-provider || refdata-enabled) using solution-name.Infrastructure.Repositories; +// #endif // #if implement-servicebus using CoreEx.Azure.Messaging.ServiceBus; // #endif diff --git a/src/CoreEx.Validation/Abstractions/SelfRuntimeMetadata.cs b/src/CoreEx.Validation/Abstractions/SelfRuntimeMetadata.cs index e4b6c62b..8f4d954c 100644 --- a/src/CoreEx.Validation/Abstractions/SelfRuntimeMetadata.cs +++ b/src/CoreEx.Validation/Abstractions/SelfRuntimeMetadata.cs @@ -37,7 +37,7 @@ internal readonly struct SelfRuntimeMetadata() : IPropertyRuntimeMetadata public string? Format => null; /// - public void Clean(object entity) { } + public void Clean(object entity, CleanArgs args) { } /// public string GetJsonName(JsonSerializerOptions? options = null) => string.Empty; @@ -56,4 +56,4 @@ public void Clean(object entity) { } /// public void SetValue(object entity, T value) => throw new NotSupportedException(); -} \ No newline at end of file +} diff --git a/src/CoreEx/Entities/ChangeLog.cs b/src/CoreEx/Entities/ChangeLog.cs index 72a705a5..566181ba 100644 --- a/src/CoreEx/Entities/ChangeLog.cs +++ b/src/CoreEx/Entities/ChangeLog.cs @@ -126,4 +126,4 @@ public virtual IEnumerable GetPropertyRuntimeMetadata( /// public bool IsDefault() => RuntimeMetadata.IsDefault(this); -} \ No newline at end of file +} diff --git a/src/CoreEx/Entities/CleanArgs.cs b/src/CoreEx/Entities/CleanArgs.cs new file mode 100644 index 00000000..712ef9dd --- /dev/null +++ b/src/CoreEx/Entities/CleanArgs.cs @@ -0,0 +1,24 @@ +namespace CoreEx.Entities; + +/// +/// Provides the arguments that control the behavior of (and related runtime metadata cleaning). +/// +public readonly struct CleanArgs +{ + /// + /// Gets the default instance (i.e. all options set to their default value of ). + /// + public static readonly CleanArgs Default = default; + + /// + /// Gets or sets a value indicating whether nested (child) values are also cleaned and defaulted (i.e. collapsed to ) where fully default and their is . + /// + /// Defaults to . + public bool CleanAndDefaultNested { get; init; } + + /// + /// Gets or sets a value indicating whether the root value itself is also cleaned and defaulted (i.e. collapsed to ) where fully default and its is . + /// + /// Defaults to . The root value is otherwise never defaulted, regardless of . + public bool CleanAndDefaultRoot { get; init; } +} diff --git a/src/CoreEx/Entities/Cleaner.cs b/src/CoreEx/Entities/Cleaner.cs index 8e4181fb..1f38527a 100644 --- a/src/CoreEx/Entities/Cleaner.cs +++ b/src/CoreEx/Entities/Cleaner.cs @@ -10,6 +10,7 @@ public static class Cleaner private static StringCase? _stringCase; private static DateTimeTransform? _dateTimeTransform; private static CleanOption? _cleanOption; + private static readonly ConcurrentDictionary _cleanOptions = new([new KeyValuePair(typeof(ChangeLog), CleanOption.CleanAndDefault)]); /// /// Resets the , , and to their respective default values. @@ -60,14 +61,35 @@ public static DateTimeTransform DefaultDateTimeTransform } /// - /// Gets or sets the default for all values unless explicitly overridden. Defaults to . + /// Gets or sets the default for all values unless explicitly overridden. Defaults to . /// public static CleanOption DefaultCleanOption { - get => _cleanOption ??= Internal.GetConfigurationValue("CoreEx:Entities:Cleaner:DefaultCleanOption", CleanOption.CleanAndDefault); + get => _cleanOption ??= Internal.GetConfigurationValue("CoreEx:Entities:Cleaner:DefaultCleanOption", CleanOption.Clean); set => _cleanOption = value == CleanOption.UseDefault ? throw new ArgumentException("The default cannot be set to UseDefault.", nameof(DefaultCleanOption)) : value; } + /// + /// Gets the dictionary that defines a 's default . + /// + /// The is automatically registered as . + public static ConcurrentDictionary CleanOptions => _cleanOptions; + + /// + /// Gets the for the specified + /// + /// The to get the for. + /// The for the specified . + /// Where not found then the is used. + public static CleanOption GetCleanOption(Type type) => _cleanOptions.TryGetValue(type.ThrowIfNull(), out var cleanOption) ? cleanOption : DefaultCleanOption; + + /// + /// Cleans a using the default and . + /// + /// The value to clean. + /// The cleaned value. + public static string? Clean(string? value) => Clean(value, DefaultStringTrim, DefaultStringTransform, DefaultStringCase); + /// /// Cleans a using the specified and . /// @@ -192,6 +214,7 @@ public static DateTime Clean(DateTime value, DateTimeTransform transform) /// /// The value . /// The value to clean. + /// The optional (defaults to ). /// The cleaned . - public static T? Clean(T value) => RuntimeMetadata.Clean(value); -} \ No newline at end of file + public static T? Clean(T value, CleanArgs args = default) => RuntimeMetadata.Clean(value, args); +} diff --git a/src/CoreEx/Entities/README.md b/src/CoreEx/Entities/README.md index 151d47c7..d5c862fa 100644 --- a/src/CoreEx/Entities/README.md +++ b/src/CoreEx/Entities/README.md @@ -27,6 +27,7 @@ The namespace also includes supporting utilities: `Cleaner` for normalizing stri | Type | Description | |------|-------------| | **[`ChangeLog`](./ChangeLog.cs)** | Record implementing `IReadOnlyChangeLogEx`; captures created/updated by and timestamp, populated from the ambient `ExecutionContext`. | +| **[`CleanArgs`](./CleanArgs.cs)** | Struct passed to `Cleaner.Clean{T}(T, CleanArgs)` (and `RuntimeMetadata.Clean{T}`) controlling whether nested (`CleanAndDefaultNested`) and/or the root value itself (`CleanAndDefaultRoot`) are collapsed to `default` when fully default and registered as `CleanOption.CleanAndDefault`. | | **[`Cleaner`](./Cleaner.cs)** | Static utility applying configurable string trimming, transformation, casing, and `DateTime` normalization to entity values. | | **[`CompositeKey`](./CompositeKey.cs)** | Immutable struct representing a multi-part entity key with boxing-free generic `Create` overloads for up to four arguments. | | **[`CompositeKeyComparer`](./CompositeKeyComparer.cs)** | `IEqualityComparer` for use in dictionaries and collections keyed by `CompositeKey`. | @@ -55,4 +56,4 @@ The namespace also includes supporting utilities: `Cleaner` for normalizing stri - **[`CoreEx.Metadata`](../Metadata/README.md)** - `RuntimeMetadata` and `IPropertyRuntimeMetadata` underpin `IContract` equality, copy, cleaning, and hash operations. - **[`CoreEx.Mapping`](../Mapping/README.md)** - `Mapper` utility maps standard entity properties (`IIdentifier`, `IETag`, `IChangeLog`, etc.) between source and destination types. - **[`CoreEx.Data`](../Data/README.md)** - Data-layer primitives such as `IPrimaryKey`, `IPartitionKey`, and `ILogicallyDeleted` extend the entity contract interfaces defined here. -- **[`CoreEx.Validation`](../../CoreEx.Validation/README.md)** - Validation rules produce `MessageItem` instances that are collected into a `ValidationException`. \ No newline at end of file +- **[`CoreEx.Validation`](../../CoreEx.Validation/README.md)** - Validation rules produce `MessageItem` instances that are collected into a `ValidationException`. diff --git a/src/CoreEx/Mapping/BiDirectionMapperT2.cs b/src/CoreEx/Mapping/BiDirectionMapperT2.cs index 77883329..4b642af9 100644 --- a/src/CoreEx/Mapping/BiDirectionMapperT2.cs +++ b/src/CoreEx/Mapping/BiDirectionMapperT2.cs @@ -63,4 +63,4 @@ public sealed class DestinationToSourceMapper(Func map) : /// protected override TSource OnMap(TDestination source) => _map(source); } -} \ No newline at end of file +} diff --git a/src/CoreEx/Mapping/IMapperT.cs b/src/CoreEx/Mapping/IMapperT.cs index db16406d..b8aa8a63 100644 --- a/src/CoreEx/Mapping/IMapperT.cs +++ b/src/CoreEx/Mapping/IMapperT.cs @@ -27,4 +27,4 @@ public interface IMapper : IMapper where TSource : class /// The destination value. [return: NotNullIfNotNull(nameof(source))] TDestination? Map(TSource? source); -} \ No newline at end of file +} diff --git a/src/CoreEx/Metadata/IPropertyRuntimeMetadata.cs b/src/CoreEx/Metadata/IPropertyRuntimeMetadata.cs index b585a152..52a2a24b 100644 --- a/src/CoreEx/Metadata/IPropertyRuntimeMetadata.cs +++ b/src/CoreEx/Metadata/IPropertyRuntimeMetadata.cs @@ -61,7 +61,8 @@ public interface IPropertyRuntimeMetadata /// Cleans the property value based on the . /// /// The entity value. - void Clean(object entity); + /// The . + void Clean(object entity, CleanArgs args); /// /// Gets the property value. @@ -100,4 +101,4 @@ public interface IPropertyRuntimeMetadata /// The JSON property name. /// Uses the where not ; otherwise, uses the property passed through the optional . string GetJsonName(JsonSerializerOptions? options = null); -} \ No newline at end of file +} diff --git a/src/CoreEx/Metadata/PropertyRuntimeMetadata.cs b/src/CoreEx/Metadata/PropertyRuntimeMetadata.cs index d7ffecba..09eb6c67 100644 --- a/src/CoreEx/Metadata/PropertyRuntimeMetadata.cs +++ b/src/CoreEx/Metadata/PropertyRuntimeMetadata.cs @@ -10,7 +10,7 @@ namespace CoreEx.Metadata; /// The action to set the value. /// The optional . /// The optional default value. -/// The used for cleaning. +/// The used for cleaning. /// The optional explicit JSON name. /// The optional format string. /// The underlying implementation does not store mutable state for an entity property; therefore, an instance can be cached and reused where applicable to improve performance, etc. @@ -18,6 +18,9 @@ public readonly struct PropertyRuntimeMetadata(string name, { private static readonly string? _nullObject = null; + // Where the property type does not itself carry runtime metadata (i.e. is not an IContract-like graph type), Clean and CleanAndDefault have the same outcome (see CleanOption remarks). + private static readonly bool _isRuntimeMetadataProperty = typeof(IRuntimeMetadataCore).IsAssignableFrom(typeof(TProperty)); + private readonly Func _getValue = getValue.ThrowIfNull(); private readonly Action? _setValue = setValue; private readonly Lazy _text = new(() => text?.Invoke() ?? new LText(name, name.ToSentenceCase())); @@ -72,30 +75,31 @@ public readonly struct PropertyRuntimeMetadata(string name, public bool IsDefault(TEntity entity) => RuntimeMetadata.IsDefault(GetValue(entity), DefaultValue); /// - void IPropertyRuntimeMetadata.Clean(object entity) + void IPropertyRuntimeMetadata.Clean(object entity, CleanArgs args) { if (entity is not null) - Clean((TEntity)entity); + Clean((TEntity)entity, args); } /// /// Cleans the property. /// /// The entity value. - public void Clean(TEntity entity) + /// The . + public void Clean(TEntity entity, CleanArgs args) { if (entity is null) return; - var clean = CleanOption == CleanOption.UseDefault ? Cleaner.DefaultCleanOption : CleanOption; + var clean = CleanOption == CleanOption.UseDefault ? Cleaner.GetCleanOption(typeof(TProperty)) : CleanOption; if (clean == CleanOption.None) return; - var val = Cleaner.Clean(GetValue(entity)); + var val = Cleaner.Clean(GetValue(entity), args); if (IsReadOnly) return; - if (clean == CleanOption.CleanAndDefault && RuntimeMetadata.AreEqual(val, DefaultValue)) + if ((clean == CleanOption.CleanAndDefault || !_isRuntimeMetadataProperty) && RuntimeMetadata.AreEqual(val, DefaultValue)) SetValue(entity, DefaultValue!); } @@ -133,4 +137,4 @@ public void SetValue(TEntity entity, TProperty value) public string GetJsonName(JsonSerializerOptions? options = null) => JsonName is not null ? JsonName : (options ?? JsonDefaults.SerializerOptions).PropertyNamingPolicy?.ConvertName(Name) ?? Name; -} \ No newline at end of file +} diff --git a/src/CoreEx/Metadata/RuntimeMetadata.Clean.cs b/src/CoreEx/Metadata/RuntimeMetadata.Clean.cs index e1dc82c6..1cd26210 100644 --- a/src/CoreEx/Metadata/RuntimeMetadata.Clean.cs +++ b/src/CoreEx/Metadata/RuntimeMetadata.Clean.cs @@ -10,10 +10,11 @@ public static partial class RuntimeMetadata /// /// The value . /// The value. + /// The optional (defaults to ). /// The cleaned . /// This will walk the fully object graph, including arrays, collections, and dictionaries cleaning all mutable properties. Note that where the entry for an array, collection, or dictionary is a value type /// this is unable to be cleaned/replaced. An empty array, collection, or dictionary will be set to . - public static T? Clean(T? value) + public static T? Clean(T? value, CleanArgs args = default) { if (value is string str) return Internal.Cast(Cleaner.Clean(str, Cleaner.DefaultStringTrim, Cleaner.DefaultStringTransform, Cleaner.DefaultStringCase)!); @@ -35,10 +36,10 @@ public static partial class RuntimeMetadata return value; // cycle detected — return as-is foreach (var p in rm.GetPropertyRuntimeMetadata().Where(x => !x.IsReadOnly)) - p.Clean(value); + p.Clean(value, args); set.Remove(value); // allow re-visit from a different path (DAG support) - return RuntimeMetadata.IsDefault(value) ? default : value; + return (isRoot ? args.CleanAndDefaultRoot : args.CleanAndDefaultNested) && Cleaner.GetCleanOption(value.GetType()) == CleanOption.CleanAndDefault && RuntimeMetadata.IsDefault(value) ? default : value; } // Zero-length collections are nulled out. @@ -49,7 +50,7 @@ public static partial class RuntimeMetadata if (value is IDictionary d) { foreach (DictionaryEntry de in d) - Clean(de.Value); + Clean(de.Value, args); return value; } @@ -68,7 +69,7 @@ public static partial class RuntimeMetadata return value; foreach (var item in e) - Clean(item); + Clean(item, args); return value; } @@ -82,10 +83,10 @@ public static partial class RuntimeMetadata return value; // cycle detected — return as-is foreach (var p in GetCachedProperties(type).Values.Where(x => !x.IsReadOnly)) - p.Clean(value); + p.Clean(value, args); set.Remove(value); - return RuntimeMetadata.IsDefault(value) ? default : value; + return (isRoot ? args.CleanAndDefaultRoot : args.CleanAndDefaultNested) && Cleaner.GetCleanOption(type) == CleanOption.CleanAndDefault && RuntimeMetadata.IsDefault(value) ? default : value; } finally { diff --git a/tests/CoreEx.AspNetCore.Test.Api/Services/PersonService.cs b/tests/CoreEx.AspNetCore.Test.Api/Services/PersonService.cs index b4c94056..8da0e879 100644 --- a/tests/CoreEx.AspNetCore.Test.Api/Services/PersonService.cs +++ b/tests/CoreEx.AspNetCore.Test.Api/Services/PersonService.cs @@ -67,7 +67,7 @@ public class PersonService public Task CreateAsync(Person person) { person.ETag = Guid.NewGuid().ToString(); - if (!_people.TryAdd(person.Id!, person)) + if (!_people.TryAdd(person.Id.Required("Identifier"), person)) throw new ConflictException(); return Task.FromResult(person); diff --git a/tests/CoreEx.AspNetCore.Test.Api/Services/PersonService2.cs b/tests/CoreEx.AspNetCore.Test.Api/Services/PersonService2.cs index b1e54a9b..f0d4e551 100644 --- a/tests/CoreEx.AspNetCore.Test.Api/Services/PersonService2.cs +++ b/tests/CoreEx.AspNetCore.Test.Api/Services/PersonService2.cs @@ -58,7 +58,7 @@ public class PersonService2 public Task> CreateAsync(Person person) { person.ETag = Guid.NewGuid().ToString(); - if (!_people.TryAdd(person.Id!, person)) + if (!_people.TryAdd(person.Id.Required("Identifier"), person)) return Task.FromResult(Result.ConflictError()); return Task.FromResult(Result.Ok(person)); diff --git a/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MutateTestsBase.cs b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MutateTestsBase.cs index 3b31cd04..45cee894 100644 --- a/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MutateTestsBase.cs +++ b/tests/CoreEx.AspNetCore.Test.Unit/PersonApi_MutateTestsBase.cs @@ -29,6 +29,16 @@ public void Create_NoValue() }); } + [Test] + public void Create_EmptyValue() + { + Test.Http() + .Run(HttpMethod.Post, $"{Route}", new Person()) + .AssertBadRequest() + .AssertContentTypeProblemJson() + .AssertErrors("Identifier is required."); + } + [Test] public void Create_Success() { diff --git a/tests/CoreEx.Test.Unit/Entities/CleanArgsTests.cs b/tests/CoreEx.Test.Unit/Entities/CleanArgsTests.cs new file mode 100644 index 00000000..5d073eaf --- /dev/null +++ b/tests/CoreEx.Test.Unit/Entities/CleanArgsTests.cs @@ -0,0 +1,29 @@ +using CoreEx.Entities; + +namespace CoreEx.Test.Unit.Entities; + +[TestFixture] +public class CleanArgsTests +{ + [Test] + public void Default_Has_All_Options_False() + { + var args = CleanArgs.Default; + args.CleanAndDefaultNested.Should().BeFalse(); + args.CleanAndDefaultRoot.Should().BeFalse(); + } + + [Test] + public void Default_ParameterlessValue_Matches_Static_Default() + { + default(CleanArgs).Should().BeEquivalentTo(CleanArgs.Default); + } + + [Test] + public void Init_Sets_Properties() + { + var args = new CleanArgs { CleanAndDefaultNested = true, CleanAndDefaultRoot = true }; + args.CleanAndDefaultNested.Should().BeTrue(); + args.CleanAndDefaultRoot.Should().BeTrue(); + } +} diff --git a/tests/CoreEx.Test.Unit/Entities/CleanerTests.cs b/tests/CoreEx.Test.Unit/Entities/CleanerTests.cs index 03e45254..dc7a7d7a 100644 --- a/tests/CoreEx.Test.Unit/Entities/CleanerTests.cs +++ b/tests/CoreEx.Test.Unit/Entities/CleanerTests.cs @@ -226,4 +226,45 @@ public void DefaultDateTimeTransform_SetToUseDefault_Throws() Action act = () => Cleaner.DefaultDateTimeTransform = DateTimeTransform.UseDefault; act.Should().Throw(); } + + [Test] + public void DefaultCleanOption_SetToUseDefault_Throws() + { + Action act = () => Cleaner.DefaultCleanOption = CleanOption.UseDefault; + act.Should().Throw(); + } + + [Test] + public void DefaultCleanOption_Defaults_To_Clean() + { + Cleaner.DefaultCleanOption.Should().Be(CleanOption.Clean); + } + + [Test] + public void GetCleanOption_ChangeLog_Registered_As_CleanAndDefault() + { + Cleaner.GetCleanOption(typeof(ChangeLog)).Should().Be(CleanOption.CleanAndDefault); + } + + [Test] + public void GetCleanOption_Unregistered_Type_Falls_Back_To_Default() + { + Cleaner.GetCleanOption(typeof(CleanerTests)).Should().Be(Cleaner.DefaultCleanOption); + } + + [Test] + public void CleanOptions_Add_And_Remove_Custom_Type() + { + Cleaner.CleanOptions.TryAdd(typeof(CleanerTests), CleanOption.CleanAndDefault).Should().BeTrue(); + try + { + Cleaner.GetCleanOption(typeof(CleanerTests)).Should().Be(CleanOption.CleanAndDefault); + } + finally + { + Cleaner.CleanOptions.TryRemove(typeof(CleanerTests), out _); + } + + Cleaner.GetCleanOption(typeof(CleanerTests)).Should().Be(Cleaner.DefaultCleanOption); + } } \ No newline at end of file diff --git a/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs b/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs index f88b0105..652324c3 100644 --- a/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs +++ b/tests/CoreEx.Test.Unit/Runtime/RuntimeMetadataTests.cs @@ -56,6 +56,11 @@ private partial class EntityD(string id) : IReadOnlyIdentifier [Clean(CleanOption.None)] public string? Description { get; set; } + // Explicitly Clean (not CleanAndDefault) on a non-IContract (plain string) property - proves Clean and CleanAndDefault + // have the same outcome for non-IContract types, per the CleanOption remarks, even without relying on type-default fallback. + [Clean(CleanOption.Clean)] + public string? Note { get; set; } + public ChangeLog? ChangeLog { get; set; } [Clean(CleanOption.Clean)] @@ -395,6 +400,7 @@ public void Clean_Including_Property_Graph() { Code = "abc ", Description = " xyz ", + Note = "", Date = DateTime.Now, ChangeLog = new ChangeLog() { CreatedBy = "" }, ChangeLog2 = new ChangeLog() { CreatedBy = "" }, @@ -407,6 +413,23 @@ public void Clean_Including_Property_Graph() d.Id.Should().Be("123"); d.Code.Should().Be("ABC"); d.Description.Should().Be(" xyz "); + d.Note.Should().BeNull(); // Explicit Clean (non-CleanAndDefault) on a non-IContract type still null-collapses. + d.Date.Value.Kind.Should().Be(DateTimeKind.Unspecified); + d.Date.Value.TimeOfDay.Should().Be(TimeSpan.Zero); + d.ChangeLog.Should().NotBeNull(); + d.ChangeLog.IsDefault().Should().BeTrue(); + d.ChangeLog2.Should().NotBeNull(); + d.ChangeLog2.IsDefault().Should().BeTrue(); + d.Tags.Should().BeNull(); // List + d.Strings.Should().NotBeNull().And.HaveCount(0); // Array + + // Now clean again with CleanAndDefaultNested = true, which will null-collapse the ChangeLog property; ChangeLog2 will remain as is individually defaulted. + Cleaner.Clean(d, new CleanArgs { CleanAndDefaultNested = true }); + + d.Id.Should().Be("123"); + d.Code.Should().Be("ABC"); + d.Description.Should().Be(" xyz "); + d.Note.Should().BeNull(); // Explicit Clean (non-CleanAndDefault) on a non-IContract type still null-collapses. d.Date.Value.Kind.Should().Be(DateTimeKind.Unspecified); d.Date.Value.TimeOfDay.Should().Be(TimeSpan.Zero); d.ChangeLog.Should().BeNull(); @@ -416,6 +439,51 @@ public void Clean_Including_Property_Graph() d.Strings.Should().NotBeNull().And.HaveCount(0); // Array } + [Test] + public void Clean_Root_IRuntimeMetadataCore_Never_Defaulted() + { + // ChangeLog is registered as CleanAndDefault and ends up fully default after cleaning, but the root instance passed + // to Clean() must never itself be replaced with null/default - only nested/child properties are eligible for that collapse. + var cl = new ChangeLog { CreatedBy = "" }; + var result = Cleaner.Clean(cl); + + result.Should().NotBeNull(); + ReferenceEquals(result, cl).Should().BeTrue(); + result!.IsDefault().Should().BeTrue(); + } + + [Test] + public void Clean_Root_IRuntimeMetadataCore_Defaulted_With_CleanAndDefaultRoot() + { + // With CleanAndDefaultRoot = true, the root instance itself becomes eligible for the CleanAndDefault collapse. + var cl = new ChangeLog { CreatedBy = "" }; + var result = Cleaner.Clean(cl, new CleanArgs { CleanAndDefaultRoot = true }); + + result.Should().BeNull(); + } + + [Test] + public void Clean_Root_PlainReflectedClass_Never_Defaulted_With_CleanAndDefaultRoot() + { + // EntityE's CleanOption is not CleanAndDefault (default is Clean), so CleanAndDefaultRoot has no effect and the root is returned as-is. + var e = new EntityE(0); + var result = Cleaner.Clean(e, new CleanArgs { CleanAndDefaultRoot = true }); + + result.Should().NotBeNull(); + ReferenceEquals(result, e).Should().BeTrue(); + } + + [Test] + public void Clean_Root_PlainReflectedClass_Never_Defaulted() + { + // EntityE is a plain reflected (non-IContract) class; even when fully default, the root instance must be returned as-is, never null'd. + var e = new EntityE(0); + var result = Cleaner.Clean(e); + + result.Should().NotBeNull(); + ReferenceEquals(result, e).Should().BeTrue(); + } + [Test] public void GetPropertyRuntimeMetadata_Type_With_Reflection() { @@ -588,4 +656,4 @@ public void Clean_DecimalProperty_DoesNotOverflow() act.Should().NotThrow(); b.Amount.Should().Be(1.23m); } -} \ No newline at end of file +} diff --git a/tools/validate-template-pack.ps1 b/tools/validate-template-pack.ps1 index 7c894252..06a927e8 100644 --- a/tools/validate-template-pack.ps1 +++ b/tools/validate-template-pack.ps1 @@ -294,6 +294,10 @@ $testScenarios = @( @{ Name = "coreex-domain" Template = "coreex-domain" + # Must pass the name WITH the .Domain suffix - like every other add-on template + # (coreex-api/relay/subscribe/aspire), sourceName is "app-name.Domain" so the suffix + # is part of the substitutable token, not a fixed literal folder segment. + ProjectName = "App.Domain" Parameters = @{} TestPath = "test-coreex-domain" Verify = @{ @@ -397,6 +401,112 @@ $testScenarios = @( ) } Build = $false # add-on template; no standalone solution + }, + # --------------------------------------------------------------------- + # Composite scenarios: scaffold `coreex` + one or more host templates into the + # SAME directory (unlike the isolated add-on scenarios above, which have no + # `coreex`-generated siblings to compile against and therefore can't set + # Build = $true). These are what actually catch a symbol-conditional bug — an + # unconditional `global using`/`ProjectReference` that should have been gated + # behind a symbol like `has-data-provider` or `implement-servicebus` only fails + # to compile once the host is built inside a real solution. + # --------------------------------------------------------------------- + @{ + Name = "coreex-api-none-data-provider-regression" + # Regression guard: --data-provider None + --refdata-enabled false used to leave + # `global using CoreEx.Database;` and `using solution-name.Infrastructure.Repositories;` + # unconditional in the Api host, even though neither the package nor the Repositories + # folder exist in this combination (CS0234 at build time). + Steps = @( + @{ Template = "coreex"; Name = "App"; Parameters = @{ "data-provider" = "None"; "messaging-provider" = "None"; "refdata-enabled" = "false"; "outbox-enabled" = "false"; "rop-enabled" = "false" } } + @{ Template = "coreex-api"; Name = "App.Api"; Parameters = @{ "data-provider" = "None"; "refdata-enabled" = "false"; "outbox-enabled" = "false" } } + ) + TestPath = "test-api-none-regression" + Build = $true + BuildTarget = "src/App.Api/App.Api.csproj" + }, + @{ + Name = "coreex-subscribe-none-data-provider-regression" + # Same regression guard as above, plus: --messaging-provider None used to leave + # `global using CoreEx.Azure.Messaging.ServiceBus;` unconditional even though the + # backing package is only referenced when implement-servicebus is true. + Steps = @( + @{ Template = "coreex"; Name = "App"; Parameters = @{ "data-provider" = "None"; "messaging-provider" = "None"; "refdata-enabled" = "false"; "outbox-enabled" = "false"; "rop-enabled" = "false" } } + @{ Template = "coreex-subscribe"; Name = "App.Subscribe"; Parameters = @{ "data-provider" = "None"; "messaging-provider" = "None"; "refdata-enabled" = "false" } } + ) + TestPath = "test-subscribe-none-regression" + Build = $true + BuildTarget = "src/App.Subscribe/App.Subscribe.csproj" + }, + @{ + Name = "coreex-aspire-full-stack" + Steps = @( + @{ Template = "coreex"; Name = "App"; Parameters = @{ "data-provider" = "Postgres"; "messaging-provider" = "ServiceBus"; "refdata-enabled" = "true"; "outbox-enabled" = "true"; "rop-enabled" = "false" } } + @{ Template = "coreex-api"; Name = "App.Api"; Parameters = @{ "data-provider" = "Postgres"; "refdata-enabled" = "true"; "outbox-enabled" = "true" } } + @{ Template = "coreex-relay"; Name = "App.Relay"; Parameters = @{ "data-provider" = "Postgres"; "messaging-provider" = "ServiceBus" } } + @{ Template = "coreex-subscribe"; Name = "App.Subscribe"; Parameters = @{ "data-provider" = "Postgres"; "messaging-provider" = "ServiceBus"; "refdata-enabled" = "true" } } + @{ Template = "coreex-aspire"; Name = "App.Aspire"; Parameters = @{ "has-api" = "true"; "has-relay" = "true"; "has-subscribe" = "true" } } + ) + TestPath = "test-aspire-full-stack" + Verify = @{ + FilesPresent = @( + "src/App.Aspire/App.Aspire.csproj" + "src/App.Aspire/AppHost.cs" + "src/App.Aspire/Extensions.cs" + ) + FileContains = @{ + "src/App.Aspire/AppHost.cs" = "Projects.App_Api" + } + } + Build = $true + BuildTarget = "src/App.Aspire/App.Aspire.csproj" # building the AppHost transitively builds every host it references + }, + @{ + Name = "coreex-aspire-api-only" + Steps = @( + @{ Template = "coreex"; Name = "App"; Parameters = @{ "data-provider" = "SqlServer"; "messaging-provider" = "None"; "refdata-enabled" = "false"; "outbox-enabled" = "false"; "rop-enabled" = "false" } } + @{ Template = "coreex-api"; Name = "App.Api"; Parameters = @{ "data-provider" = "SqlServer"; "refdata-enabled" = "false"; "outbox-enabled" = "false" } } + @{ Template = "coreex-aspire"; Name = "App.Aspire"; Parameters = @{ "has-api" = "true"; "has-relay" = "false"; "has-subscribe" = "false" } } + ) + TestPath = "test-aspire-api-only" + Verify = @{ + FilesPresent = @( + "src/App.Aspire/App.Aspire.csproj" + ) + FileContains = @{ + "src/App.Aspire/AppHost.cs" = "Projects.App_Api" + } + FileNotContains = @{ + # has-relay/has-subscribe are false — confirms the #if stripping actually drops + # the other hosts' AddProject calls and ProjectReferences, not just that has-api's survive. + "src/App.Aspire/AppHost.cs" = "Projects.App_Relay" + "src/App.Aspire/App.Aspire.csproj" = "App.Relay" + } + } + Build = $true + BuildTarget = "src/App.Aspire/App.Aspire.csproj" + }, + @{ + Name = "coreex-domain-regression" + # Regression guard: coreex-domain's ProjectReference to Contracts previously resolved only + # by accident, via substring overlap between the short sourceName "app-name" and the + # "app-name" prefix embedded in "app-name.Contracts" - a standalone scaffold (Build = $false + # above) can't catch this, since it never has a real Contracts sibling to compile against. + Steps = @( + @{ Template = "coreex"; Name = "App"; Parameters = @{ "data-provider" = "Postgres"; "messaging-provider" = "None"; "refdata-enabled" = "false"; "outbox-enabled" = "false"; "rop-enabled" = "false" } } + @{ Template = "coreex-domain"; Name = "App.Domain"; Parameters = @{} } + ) + TestPath = "test-domain-regression" + Verify = @{ + FilesPresent = @( + "src/App.Domain/App.Domain.csproj" + ) + FileContains = @{ + "src/App.Domain/App.Domain.csproj" = "App.Contracts\App.Contracts.csproj" + } + } + Build = $true + BuildTarget = "src/App.Domain/App.Domain.csproj" } ) @@ -450,6 +560,22 @@ function Invoke-Assertion { } } + if ($Verify.FileNotContains) { + foreach ($rel in $Verify.FileNotContains.Keys) { + $full = Join-Path $TestDir $rel + $needle = $Verify.FileNotContains[$rel] + if (-not (Test-Path $full)) { + Write-Fail "MISSING (negative content check): $rel" + $Failures.Value += "File missing for negative content check: $rel" + } elseif (-not (Get-Content $full -Raw).Contains($needle)) { + Write-Pass "Absent content '$needle': $rel" + } else { + Write-Fail "CONTENT SHOULD NOT BE PRESENT '$needle' in $rel" + $Failures.Value += "Expected '$needle' absent from $rel" + } + } + } + if ($Verify.GlobFileContains) { foreach ($pattern in $Verify.GlobFileContains.Keys) { $fullGlob = Join-Path $TestDir $pattern @@ -531,10 +657,15 @@ try { $failedScenarios = @() foreach ($scenario in $testScenarios) { + $isComposite = $null -ne $scenario.Steps Write-Output "" - Write-Output "▶ $($scenario.Name) ($($scenario.Template))" - if ($scenario.Parameters.Count -gt 0) { - Write-Output " Params: $(($scenario.Parameters | ConvertTo-Json -Compress))" + if ($isComposite) { + Write-Output "▶ $($scenario.Name) ($(($scenario.Steps | ForEach-Object { $_.Template }) -join ' + '))" + } else { + Write-Output "▶ $($scenario.Name) ($($scenario.Template))" + if ($scenario.Parameters.Count -gt 0) { + Write-Output " Params: $(($scenario.Parameters | ConvertTo-Json -Compress))" + } } $testDir = Join-Path $temporaryTestRoot $scenario.TestPath @@ -548,15 +679,30 @@ try { New-Item -ItemType Directory -Path $testDir -Force | Out-Null # Scaffold - $projectName = if ($scenario.ProjectName) { $scenario.ProjectName } else { "App" } - $args = @("new", $scenario.Template, "--output", $testDir, "--name", $projectName, "--no-update-check") - foreach ($kv in $scenario.Parameters.GetEnumerator()) { - $args += "--$($kv.Key)" - if ($kv.Value -ne "") { $args += $kv.Value } + if ($isComposite) { + # Multiple templates into the SAME directory — e.g. `coreex` plus one or more hosts — + # so the later steps have real siblings to compile against (see the composite scenarios above). + foreach ($step in $scenario.Steps) { + $args = @("new", $step.Template, "--output", $testDir, "--name", $step.Name, "--no-update-check") + foreach ($kv in $step.Parameters.GetEnumerator()) { + $args += "--$($kv.Key)" + if ($kv.Value -ne "") { $args += $kv.Value } + } + Write-Verbose "dotnet $($args -join ' ')" + & dotnet @args + if ($LASTEXITCODE -ne 0) { throw "dotnet new failed for step '$($step.Template)'" } + } + } else { + $projectName = if ($scenario.ProjectName) { $scenario.ProjectName } else { "App" } + $args = @("new", $scenario.Template, "--output", $testDir, "--name", $projectName, "--no-update-check") + foreach ($kv in $scenario.Parameters.GetEnumerator()) { + $args += "--$($kv.Key)" + if ($kv.Value -ne "") { $args += $kv.Value } + } + Write-Verbose "dotnet $($args -join ' ')" + & dotnet @args + if ($LASTEXITCODE -ne 0) { throw "dotnet new failed" } } - Write-Verbose "dotnet $($args -join ' ')" - & dotnet @args - if ($LASTEXITCODE -ne 0) { throw "dotnet new failed" } # Assertions if ($scenario.Verify) { @@ -582,10 +728,17 @@ try { Set-Content -Path (Join-Path $testDir "nuget.config") -Value $nugetConfigContent -Encoding utf8 Write-Output " Building generated output..." - $buildTarget = (Get-ChildItem $testDir -Filter "*.slnx" -Recurse | Select-Object -First 1) - if (-not $buildTarget) { $buildTarget = Get-ChildItem $testDir -Filter "*.sln" -Recurse | Select-Object -First 1 } - if (-not $buildTarget) { $buildTarget = Get-ChildItem $testDir -Filter "*.csproj" -Recurse | Select-Object -First 1 } - $buildPath = if ($buildTarget) { $buildTarget.FullName } else { $testDir } + if ($scenario.BuildTarget) { + # Explicit target — required for composite scenarios: later steps (hosts) are never + # `dotnet sln add`-ed to the first step's .slnx, so auto-detecting the .slnx would build + # only the `coreex` solution's own projects and silently skip the host being tested. + $buildPath = Join-Path $testDir $scenario.BuildTarget + } else { + $buildTarget = (Get-ChildItem $testDir -Filter "*.slnx" -Recurse | Select-Object -First 1) + if (-not $buildTarget) { $buildTarget = Get-ChildItem $testDir -Filter "*.sln" -Recurse | Select-Object -First 1 } + if (-not $buildTarget) { $buildTarget = Get-ChildItem $testDir -Filter "*.csproj" -Recurse | Select-Object -First 1 } + $buildPath = if ($buildTarget) { $buildTarget.FullName } else { $testDir } + } dotnet build $buildPath --nologo --verbosity minimal 2>&1 | Where-Object { $_ -match "error|warning|succeeded|failed" } if ($LASTEXITCODE -ne 0) { $scenarioFailures += "dotnet build failed"