diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 450cf76d..228b3a6b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -40,6 +40,7 @@ solution; the rule applies to consumer solutions where these assets are installe - **Samples**: docker-compose infrastructure + dotnet run for Database projects + Aspire AppHost. - **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. ## Local Development Infrastructure diff --git a/.github/instructions/coreex-conventions.instructions.md b/.github/instructions/coreex-conventions.instructions.md index 83b81a1b..4c20fcc4 100644 --- a/.github/instructions/coreex-conventions.instructions.md +++ b/.github/instructions/coreex-conventions.instructions.md @@ -157,6 +157,7 @@ public async Task CreateAsync(Employee value, CancellationToken cancel - **Controllers / Minimal-API handlers** take a `CancellationToken` parameter (ASP.NET binds/injects it), pass it to the `WebApi` helper via `cancellationToken:`, and use the helper lambda's `ct` for the service call — see `coreex-api-controllers.instructions.md`. - **Interfaces** declare the parameter too, so implementations and callers can honour it (`Task GetAsync(string id, CancellationToken cancellationToken = default);`). - **Tests** are the exception — `Test.Scoped(...)` / the `WebApi` lambda supply the token; you don't manufacture one. +- **When calling any method with more than one optional parameter after the token's usual slot, pass `cancellationToken` by name, not position.** Several CoreEx extensions (e.g. `ToMappedItemsResultAsync`/`ToItemsResultAsync` — `(mapper, paging = null, autoCount = true, cancellationToken = default)`) have a `bool` parameter sitting between the common arguments and `cancellationToken`. A bare positional token there binds to the `bool` instead and fails to compile — write `cancellationToken: cancellationToken` explicitly whenever a call has 3+ optional parameters, rather than assuming it's always last positionally. ## XML Documentation Comments @@ -195,6 +196,27 @@ public class EmployeeService(IUnitOfWork unitOfWork, IEmployeeRepository reposit } ``` +## When Unsure of a CoreEx API Member + +CoreEx's singleton/static call-site patterns (`Default`, `To`/`From`, static `Map`) look similar across types but differ in real, specific ways. **Never invent a plausible-sounding member name under uncertainty** — `MapToEntity`, `MapToDto`, `OnMapToPrimary`/`OnMapToSecondary`, and similar guesses do not exist on any CoreEx mapper type, and guessing wastes a compile-fail round-trip at best and silently ships broken code at worst. + +**Resolution order when unsure of an exact member/signature:** + +1. Check the relevant `.github/instructions/coreex-*.instructions.md` file first — the call pattern for every commonly-used type is already documented with a working example (see the quick-reference table below for the most frequently guessed ones). +2. If still unsure, **just build** — a `CS1061`/`CS0117` ("no such member") is fast, authoritative, and reflects the *actually-restored* package version. Prefer this over speculative research. +3. Check the docs-sync cache (`.github/docs/coreex/*.md`, after `/coreex-docs-sync`) if present. +4. Only as a last resort, inspect CoreEx source directly — either the restored NuGet package (decompile, or `~/.nuget/packages/coreex*//`) or the GitHub repo. If using GitHub, **verify the tag/branch matches the installed package version** — `main` can be ahead of or behind what's actually referenced; do not assume it's current, and do not guess at file paths (e.g. the 3-generic-argument singleton mapper base lives in `BiDirectionMapperT3.cs`, not `BiDirectionMapper.cs`). +5. If still unresolved, ask the developer rather than fabricate a member name. + +**Quick reference — exact call patterns (do not substitute a guessed name):** + +| Type | Override | Call site | +|---|---|---| +| `BiDirectionMapper` | Two `OnMap` overloads, same name, distinguished by source type — **not** `OnMapToPrimary`/`OnMapToSecondary` | `{Name}Mapper.To.Map(source)` (left→right), `{Name}Mapper.From.Map(source)` (right→left) — **not** `MapToEntity`/`MapToDto`/`.Default.Map(...)` | +| `Mapper` (uni-directional) | One `OnMap(TSource)` | `{Name}Mapper.Map(source)` — static, directly on the mapper class, **not** `.Default.Map(...)` | +| `Validator` | Declarative rules / `OnValidateAsync` | `{Name}Validator.Default.ValidateAndThrowAsync(...)` / `.ValidateWithResultAsync(...)` — **never** bare `.ValidateAsync(...)` | +| `QueryArgsConfig` | Constructor-only `WithFilter`/`WithOrderBy` | `{Name}QueryArgsConfig.Default.Parse(query).ThrowOnError()` — never instantiate per-request | + ## Private Field Naming Private instance fields are always prefixed with `_`. No exceptions. @@ -218,7 +240,9 @@ private readonly ILogger _logger; - Do not use `DateTime.UtcNow` or `DateTimeOffset.UtcNow` — use `Runtime.UtcNow` (or `Runtime.UtcNow.UtcDateTime` for a `DateTime`). - Do not use `Guid.NewGuid()` — use `Runtime.NewGuid()`. - Do not omit or drop `CancellationToken` — every `async`/`Task`-returning method takes one (`CancellationToken cancellationToken = default`, last parameter) and passes it to every downstream awaitable call. +- Do not pass `cancellationToken` positionally to a method with 3+ optional parameters (e.g. `ToMappedItemsResultAsync`/`ToItemsResultAsync`) — pass it as `cancellationToken:` by name; a bare positional token can silently bind to an unrelated `bool` parameter (e.g. `autoCount`) and fail to compile. - Do not replace a private backing field with an auto-property simply because it could be one — backing fields are a valid developer choice. - Do not leave interface members or contract properties undocumented — each gets a ``. - Do not invert the doc convention — summaries go on **interfaces and contract properties**; the **implementing** class member gets `` (not a fresh summary). Summarising the concrete class while leaving the interface/contract undocumented is backwards. - Do not leave a private method undocumented — it still needs a `` (params/returns optional), unless it's a test helper in a `*.Test*` project. +- Do not invent a plausible-sounding CoreEx member name when unsure (`MapToEntity`, `OnMapToPrimary`/`OnMapToSecondary`, etc.) — check the relevant instructions file, then just build (a compile error is authoritative), before ever searching GitHub blind. diff --git a/.github/instructions/coreex-host-setup.instructions.md b/.github/instructions/coreex-host-setup.instructions.md index 2ebe23f0..67b28540 100644 --- a/.github/instructions/coreex-host-setup.instructions.md +++ b/.github/instructions/coreex-host-setup.instructions.md @@ -275,7 +275,7 @@ Key points: - `UseIdempotencyKey()` must come **after** `UseExecutionContext()`. - If the domain also publishes directly to Service Bus (e.g. for cross-domain adapters), add `AddAzureServiceBusPublisher(..., addAsDefaultIEventPublisher: false)` so the outbox publisher remains the default `IEventPublisher`. - `MapHealthChecks()`'s **basic** `live`/`startup`/`ready` endpoints are intentionally left anonymous — they're conventionally probed by container orchestrators without credentials. Its **detailed** endpoints (`/health/*/detailed`) are different: they emit the full `HealthReport`, which can include component names and exception details, and should be secured — pass `detailedGroupConfigure: g => g.RequireAuthorization()`, **commented out by default** (as shown above) since it 500s until an authentication scheme and authorization services are registered. Uncomment alongside `UseAuthentication()` once a scheme is configured. -- **`CoreEx.Data.GraphQL` is additive, not part of the base scaffold** — add it only when explicitly asked for a GraphQL query surface; use the `coreex-graphql` skill to wire it (`.github/skills/coreex-graphql/SKILL.md`) rather than improvising. Register roots with `builder.Services.AddCoreExGraphQLLite((o, sp) => o.AddQuery("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService().QueryAsync(qa, pa, ct).ConfigureAwait(false)).AddGet("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService().GetAsync(args.TryGetValue("id", out var id) && id is string { Length: > 0 } s ? s : throw new ArgumentException("'id' argument is required and must be a non-empty string.", nameof(args)), ct)))`, then `app.MapCoreExGraphQLLite("/query");` after `app.MapControllers();`. Resolve scoped dependencies (repositories, application services) per-invocation via `CoreEx.ExecutionContext.GetRequiredService()` — never capture an instance from the root `IServiceProvider` at registration time, since `IGraphQLEngine` is a singleton. It bridges GraphQL `where`/`orderBy` 1:1 onto the entity's existing `QueryArgsConfig` — no new resolver/filter logic. `MapCoreExGraphQLLite` executes through `WebApi.PostAsync(...)`, so standard exception-handling middleware applies as a safety net; add `.WithCoreExGraphQLTelemetry()` alongside the host's other OpenTelemetry tracing extensions to trace `ExecuteAsync` calls. To expose all reference data types as GraphQL roots in one call, use `o.AddReferenceDataQueries(sp, ReferenceDataQueryArgsConfig.Default)` — this bulk-registers every type known to the `ReferenceDataOrchestrator`, keyed as `ref_` where `` is the type's `AlternateNames` entry where registered, otherwise its own `Type.Name`; pass `excludeTypes` to opt specific types out. See [`CoreEx.Data.GraphQL` AGENTS.md](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Data.GraphQL/AGENTS.md). +- **`CoreEx.Data.GraphQL` is additive, not part of the base scaffold** — add it only when explicitly asked for a GraphQL query surface; use the `coreex-graphql` skill to wire it (`.github/skills/coreex-graphql/SKILL.md`) rather than improvising. Register roots with `builder.Services.AddCoreExGraphQLLite((o, sp) => o.AddQuery("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService().QueryAsync(qa, pa, ct).ConfigureAwait(false)).AddGet("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService().GetAsync(args.GetIdentifier(), ct)))` — `GetIdentifier` validates presence and type of the named argument (default `"id"`) — it casts to `TId`, it does not convert — and throws an `ArgumentException`, mapped by the engine to `ARGUMENT_ERROR`, if it's missing/empty/wrong-typed — then `app.MapCoreExGraphQLLite("/query");` after `app.MapControllers();`. Resolve scoped dependencies (repositories, application services) per-invocation via `CoreEx.ExecutionContext.GetRequiredService()` — never capture an instance from the root `IServiceProvider` at registration time, since `IGraphQLEngine` is a singleton. It bridges GraphQL `where`/`orderBy` 1:1 onto the entity's existing `QueryArgsConfig` — no new resolver/filter logic. `MapCoreExGraphQLLite` executes through `WebApi.PostAsync(...)`, so standard exception-handling middleware applies as a safety net; add `.WithCoreExGraphQLTelemetry()` alongside the host's other OpenTelemetry tracing extensions to trace `ExecuteAsync` calls. To expose all reference data types as GraphQL roots in one call, use `o.AddReferenceDataQueries(sp, ReferenceDataQueryArgsConfig.Default)` — this bulk-registers every type known to the `ReferenceDataOrchestrator`, keyed as `ref_` where `` is the type's `AlternateNames` entry where registered, otherwise its own `Type.Name`; pass `excludeTypes` to opt specific types out. See [`CoreEx.Data.GraphQL` AGENTS.md](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Data.GraphQL/AGENTS.md). --- diff --git a/.github/instructions/coreex-repositories.instructions.md b/.github/instructions/coreex-repositories.instructions.md index 29a05bab..084638c5 100644 --- a/.github/instructions/coreex-repositories.instructions.md +++ b/.github/instructions/coreex-repositories.instructions.md @@ -140,6 +140,8 @@ The model accessors (`EfDbModel` and `EfDbMappedModel<...>`) expose two varia Querying: `Query(...)` returns a filtered `IQueryable` (logical-delete and tenant filters already applied); `QueryTracked(...)` is the change-tracked variant. Materialize via the extensions `ToMappedItemsResultAsync()` (→ `ItemsResult` with paging/count), `ToMappedItemsAsync<...>()`, or `ToItemsResultAsync()`. +> **`cancellationToken` must be a named argument on `ToMappedItemsResultAsync`/`ToItemsResultAsync`.** Both signatures are `(mapper, paging = null, autoCount = true, cancellationToken = default)` — `autoCount` (`bool`) sits **before** `cancellationToken`. A bare positional `CancellationToken` in the third slot (e.g. `.ToMappedItemsResultAsync(mapper, paging, cancellationToken)`) binds to `autoCount` and fails to compile (`CS1503`). Always write `cancellationToken: cancellationToken` explicitly. + Per-model behaviour is configured on `EfDbOptions` / `EfDbModelOptions`: `WithModel(...)`, `WithLogicalDeleteFilter()`, `WithTenantFilter()`, `WithFilter(...)`, `WithGetKey(...)`, `WithArgs(...)`, `WithOnBeforeCreateOrUpdate(...)`, `WithUpdateModelMapper(...)`. ## Dynamic Query Configuration @@ -192,6 +194,8 @@ Choose the `AddField` overload based on the contract property type: | `AddNullField(field, ...)` | Null/not-null check only (no value comparison) | `Equal\|NotEqual` (null semantics) | `.WithModelPrefix(...)` | | `AddReferenceDataField(field, ...)` | Any `IReferenceData` type (resolved by code via orchestrator) | `EqualityOperators` (`eq`/`ne`/`in`) | `.MustBeActive(...)` | +> **Reference data fields — don't investigate, apply the rule.** `AddReferenceDataField(field, model, ...)`'s `field` argument is **always** the contract's generated navigation property name (`{Name}`, not `{Name}Code`) — a fixed Roslyn source-generator convention (`[ReferenceData] public partial string? {Name}Code { get; set; }` → generated `{Name}` nav property), documented in [`coreex-contracts.instructions.md#reference-data-properties`](/.github/instructions/coreex-contracts.instructions.md#reference-data-properties). This is never something to confirm by reading generated `.g.cs` files or exploring generator internals — it's deterministic. The `model` argument is the underlying persistence/EF column holding the code value; ask the developer only if the persistence model renames it from the `{Name}Code` default. + **Operator quick-reference** — use with `.WithOperators(...)` (combine flags with `|`): | Flag / Composite | Filter string operators enabled | Typical use | @@ -245,7 +249,7 @@ public async Task> QueryAsync(QueryArgs? quer Sku = x.Product.Sku, CategoryCode = x.CategoryCode, QtyOnHand = x.QtyOnHand - }, paging, cancellationToken) + }, paging, cancellationToken: cancellationToken) // named — `autoCount` (bool) sits before `cancellationToken` in the signature; a bare positional token there fails to compile (CS1503) .ConfigureAwait(false); } ``` @@ -404,6 +408,7 @@ Always call `.ConfigureAwait(false)` on every `await` inside repository and adap - Do not reference the Infrastructure project from the Application layer — Infrastructure implements Application interfaces, not the other way around. - Do not use AutoMapper or reflection-based mappers — use `BiDirectionMapper` with explicit `OnMap` overrides. +- Do not call the mapper via an invented member name (`MapToEntity`, `MapToDto`, `.Default.Map(...)`) — the real call sites are `{Name}Mapper.To.Map(source)` (left→right) and `{Name}Mapper.From.Map(source)` (right→left); see [`coreex-conventions.instructions.md#when-unsure-of-a-coreex-api-member`](/.github/instructions/coreex-conventions.instructions.md#when-unsure-of-a-coreex-api-member) if unsure. - Do not call `HttpClient` directly in adapter methods — use the typed HTTP client class in `Clients/`. - Do not conflate Application-level mapping (aggregate ↔ contract) with Infrastructure-level mapping (contract ↔ persistence model). - Do not write raw `DbContext` queries for standard CRUD — use the `EfDb` delegate methods. diff --git a/.github/skills/coreex-api/SKILL.md b/.github/skills/coreex-api/SKILL.md index 2b2a467b..84e963c1 100644 --- a/.github/skills/coreex-api/SKILL.md +++ b/.github/skills/coreex-api/SKILL.md @@ -45,6 +45,7 @@ Guides you through adding or modifying HTTP API endpoints in an `*.Api` host. Co 5. MVC controllers or Minimal API? **Key rules at a glance:** +- **If Q4 answer is "needs to be created":** stop before scaffolding the controller pair and invoke `coreex-app-service` (Path C — CQRS Read Service) to create `I{Name}ReadService`/`{Name}ReadService` first. Never shortcut by adding query/collection methods to the existing `I{Name}Service`/`{Name}Service`. - Inherit from `ControllerBase` — **never** `Controller` (that adds View support) - **CQRS split:** `{Name}Controller` (POST/PUT/PATCH/DELETE → `I{Name}Service`) + `{Name}ReadController` (GET/query → `I{Name}ReadService`). Both use the **same route** and **same `[OpenApiTag]`** so they appear as one OpenAPI group - All action methods return `Task` via the `WebApi` helper — never `ActionResult` directly diff --git a/.github/skills/coreex-api/references/workflow.md b/.github/skills/coreex-api/references/workflow.md index 6e531c53..bbcc6e19 100644 --- a/.github/skills/coreex-api/references/workflow.md +++ b/.github/skills/coreex-api/references/workflow.md @@ -22,6 +22,8 @@ Full workflow for adding or modifying HTTP API endpoints in a CoreEx `*.Api` hos | Read service (`I{Name}ReadService`) already exists? | Ask | Controllers cannot be written without a service to delegate to | | MVC controllers or Minimal API? | MVC | MVC for most projects; Minimal API for lighter hosts — see Step 7 | +**Gate — do not skip:** if the CQRS split calls for a `{Name}ReadController` (any GET/query/`$query` endpoint being added or moved) and `I{Name}ReadService`/`{Name}ReadService` does not already exist, **stop before Step 1** and invoke [`coreex-app-service`](../../coreex-app-service/SKILL.md) (Path C — CQRS Read Service) to create the read service pair first. Never add query/collection methods to the existing `I{Name}Service`/`{Name}Service` as a shortcut, and never write `{Name}ReadController` against a service that doesn't exist yet — only the by-id `GetAsync` may legitimately live on both. Resume the controller work once the read service exists. + --- ## Step 1 — Scaffold the Controller Pair @@ -339,6 +341,7 @@ All the same rules apply: `.Required()` on route params, no business logic in ha 1. `dotnet build` — no errors or warnings. 2. Confirm both `{Name}Controller` and `{Name}ReadController` share the same `[Route]` and `[OpenApiTag]`. +2a. Confirm `{Name}ReadController` delegates to a real, separately-defined `I{Name}ReadService`/`{Name}ReadService` (created via `coreex-app-service` Path C) — not a query method bolted onto the existing `I{Name}Service`/`{Name}Service`. 3. Every route parameter uses `.Required()` — search for `.ThrowIfNull()` on route params and replace. 4. POST endpoints have `[IdempotencyKey]` (or user has explicitly declined it). 5. PUT + PATCH are both present for full-entity update (unless partial-update is the explicit ask). diff --git a/.github/skills/coreex-app-service/SKILL.md b/.github/skills/coreex-app-service/SKILL.md index 83fb66d2..dfc3d5d2 100644 --- a/.github/skills/coreex-app-service/SKILL.md +++ b/.github/skills/coreex-app-service/SKILL.md @@ -58,6 +58,7 @@ Guides you through creating or modifying a CoreEx Application-layer service in ` - `Validator` (with injection): instantiate at call site — `new {Name}Validator(_dep).ValidateAndThrowAsync(...)` - All interface methods include `CancellationToken cancellationToken = default` as the last parameter; pass through to every async call and `TransactionAsync(async ct => ..., cancellationToken)` - CQRS: mutations + `GetAsync` → `{Name}Service`; queries + `GetAsync` → `{Name}ReadService` (both have `GetAsync`) +- **Before building Path C:** confirm `I{Name}Repository` already has `QueryAsync`/`QuerySchemaAsync` backed by a `{Name}QueryArgsConfig`. If not, stop and invoke `coreex-repository` first — never add filtering/ordering logic or a hand-rolled query in the service to work around a missing repository method - Always `.ConfigureAwait(false)` on every `await` - A Domain value object persisted via a JSON column (e.g. `Basket.ShippingAddress`) is mapped with a `BiDirectionMapper` in `Application/Mapping/` (not the uni-directional `Mapper` used for the root aggregate) — see [`coreex-application-services.instructions.md#json-backed-value-object-mapping`](/.github/instructions/coreex-application-services.instructions.md#json-backed-value-object-mapping) diff --git a/.github/skills/coreex-app-service/references/workflow.md b/.github/skills/coreex-app-service/references/workflow.md index 19dce01d..cab18f92 100644 --- a/.github/skills/coreex-app-service/references/workflow.md +++ b/.github/skills/coreex-app-service/references/workflow.md @@ -21,7 +21,7 @@ Full workflow for creating or modifying a CoreEx Application-layer service in `A | Cross-domain or external-service calls? | No | Yes → adapter interface + Path D | | Policy guard checks requiring I/O? | No | Yes → policy class + Path D | | Domain layer present? | No | Yes → `Application/Mapping/` mapper; affects Path B | -| Read queries or collection results needed? | No | Yes → Path C (CQRS read service) | +| Read queries or collection results needed? | No | Yes → Path C (CQRS read service) — **first** confirm `I{Name}Repository.QueryAsync`/`QuerySchemaAsync` already exist; if not, stop and invoke `coreex-repository` to add the `{Name}QueryArgsConfig` before building the read service | --- @@ -282,6 +282,8 @@ return await OrchestrateUpdateAsync(id, entity => entity.{Action}(pr.Value)) Split read operations from mutation operations. Both `{Name}Service` and `{Name}ReadService` expose `GetAsync` — this is **intentional**. Mutations + `GetAsync` belong to `{Name}Service`; queries and read-model shapes belong to `{Name}ReadService`. +**Stop-and-check before C1:** `{Name}ReadService.QueryAsync`/`QuerySchemaAsync` are thin delegations straight to the repository (see C2) — they carry no filtering/ordering logic of their own. That logic lives entirely in the repository's `{Name}QueryArgsConfig`. Confirm `I{Name}Repository` already exposes `QueryAsync(QueryArgs?, PagingArgs?, ...)` and `QuerySchemaAsync(...)` backed by a `{Name}QueryArgsConfig`. If it doesn't yet, invoke `coreex-repository` first to add it — do not stub, hand-roll, or work around a missing repository query method here. + ### C1 — Interface Create `Application/Interfaces/I{Name}ReadService.cs`: @@ -418,3 +420,4 @@ if (pr.IsFailure) - **Do not reference Infrastructure from Application** — reach persistence and transport through Application interfaces only. - **Do not add Query to `{Name}Service`** — query/collection shapes belong exclusively in `{Name}ReadService`. - **Do not split the repository to mirror CQRS** — both services share one `I{Name}Repository` per data source. +- **Do not build `{Name}ReadService.QueryAsync` against a repository that lacks it** — invoke `coreex-repository` to add the `{Name}QueryArgsConfig` first; the read service has no filtering/ordering logic of its own to fall back on. diff --git a/.github/skills/coreex-docs-sync/README.md b/.github/skills/coreex-docs-sync/README.md index 263e414e..c86ce890 100644 --- a/.github/skills/coreex-docs-sync/README.md +++ b/.github/skills/coreex-docs-sync/README.md @@ -29,6 +29,10 @@ since refreshing now means "re-install the matching release," not "re-fetch `mai Do not run this inside the CoreEx repository itself — the docs are already present locally at `samples/docs/` and `src/*/AGENTS.md`, and there is no NuGet-referenced version to pin to. +**After a refresh that adds/removes/renames a skill, restart your AI client** (not just start a new +session) — most clients load their skill catalog once at startup, so a newly added or renamed skill +folder under `.github/skills/coreex-*/` may not show up until the app itself is restarted. + ## How to invoke **Claude Code:** diff --git a/.github/skills/coreex-docs-sync/SKILL.md b/.github/skills/coreex-docs-sync/SKILL.md index cc441ef8..bc8fb7cb 100644 --- a/.github/skills/coreex-docs-sync/SKILL.md +++ b/.github/skills/coreex-docs-sync/SKILL.md @@ -37,7 +37,7 @@ Keeps `.github/instructions/`, `.github/skills/`, `.github/prompts/`, `.github/a 4. **Dry-runs first.** Runs `dotnet new coreex-ai --dry-run` (and `--app-folder ` if this is a monorepo, per the recorded app folder) and shows the "Create"/"Overwrite" file list it reports — `--dry-run` never writes anything, so this is always safe to run first, review it before proceeding. Do **not** rely on omitting `--force` alone: when none of the target files exist yet (e.g. a repo missing most of the bundle), `dotnet new coreex-ai` without `--force` and without `--dry-run` still writes every file for real — it only blocks and lists conflicts when files it would touch already exist and differ. 5. **Confirms, then applies** — re-runs with `--force`. Note: `--force` overwrites every file the template currently emits; it does **not** delete files the template no longer emits (see [Guardrails](#guardrails)). 6. **Flags possible orphans** — compares the skill folder names under `.github/skills/coreex-*/` against the current known skill catalog — both L1 per-capability skills and L2 end-to-end workflow skills — listed in [`coreex-ai-workflows.md`](/.github/coreex-ai-workflows.md); any extra folder is very likely left over from a prior version and should be reviewed for manual removal, not assumed safe. Scoped to the `coreex-` prefix deliberately: `CoreEx.Template` only ever installs `coreex-`-prefixed skill folders, and a consumer repo may have other, unrelated skills installed (its own or from other tooling) that this refresh neither writes nor owns — comparing *all* of `.github/skills/*/` would falsely flag those as CoreEx orphans. -7. **Reports** the refreshed `coreex-version`, the files changed (from the step-4 dry run), and any flagged orphans. +7. **Reports** the refreshed `coreex-version`, the files changed (from the step-4 dry run), any flagged orphans, and — if any skill folder was added, removed, or renamed under `.github/skills/coreex-*/` — a reminder that the running AI client (Copilot CLI, Copilot Chat, etc.) caches its skill catalog at session/app startup and will not see the new set until the client is restarted (a new session/tab alone is not always enough — some clients cache the catalog at the app-process level, not per-session). ## Guardrails @@ -46,6 +46,7 @@ Keeps `.github/instructions/`, `.github/skills/`, `.github/prompts/`, `.github/a - **Always dry-run before `--force`.** Use `dotnet new coreex-ai --dry-run` — it is the only invocation guaranteed never to write anything, regardless of whether the target files already exist. Omitting `--force` (without `--dry-run`) is not equivalent: it blocks and lists conflicts only when existing files would change, but it still creates any missing files for real with no confirmation — so it is not safe to treat as a preview on its own. - **Hand-edits to template-sourced files do not survive a refresh.** Every file under `.github/instructions/`, `.github/skills/`, `.github/prompts/`, `.github/agents/coreex-expert.agent.md`, and `.claude/commands/` is regenerated by this process — see the header on each file. - **Never run this inside the CoreEx framework repository itself.** +- **A skill set change requires an AI client restart to take effect.** Files on disk update immediately, but the client's skill catalog is typically loaded once at startup — a new chat/session in the same running app may still show the stale list. If the diff added, removed, or renamed a skill folder, tell the user to fully restart the AI client (not just open a new session) before relying on the updated catalog. ## Rationale — why version-pinned only, no live `main` fetch diff --git a/.github/skills/coreex-graphql/SKILL.md b/.github/skills/coreex-graphql/SKILL.md index 3b4bf682..42a85cbd 100644 --- a/.github/skills/coreex-graphql/SKILL.md +++ b/.github/skills/coreex-graphql/SKILL.md @@ -51,6 +51,9 @@ Guides you through adding `CoreEx.Data.GraphQL` (GraphQL-lite) to an `*.Api` hos - Add `.WithCoreExGraphQLTelemetry()` alongside the host's other OpenTelemetry tracing extensions - `EnableIntrospection` defaults to `false`; `MapCoreExGraphQLLite` is anonymous by default — pass `RequireAuthorization()` explicitly if the host's REST endpoints are secured - Record enablement in the host's `AGENTS.md` "This Host's Feature Configuration" section as a plain `**GraphQL:** Enabled (roots: ...)` line — no `dotnet new` template symbol; this is a per-host hand-authored fact added as a side effect of this skill +- Use `args.GetIdentifier(name = "id")` inside an `AddGet` resolver to validate the identifier argument's presence and type (it casts to `TId`, it does not convert) — it throws an `ArgumentException` (mapped to `ARGUMENT_ERROR`) if missing/empty/wrong-typed. Do not reach for `args.TryGetValue(...)` directly — `GraphQLLiteArgs` is not a dictionary; use `args.Arguments.TryGetValue(...)` only for non-identifier arguments. +- The `TItem`/`TId` generics in `AddQuery`/`AddGet` are just whatever the entity's existing read-service method already returns (e.g. `ProductLite`, or the plain contract if there's no separate "Lite" projection) — there is no required naming convention; "Lite" in the samples is that domain's own projection name, not a CoreEx requirement. +- `CoreEx.Data.GraphQL` is preview-versioned, but the code samples in this skill (`AddQuery`, `AddGet`, `AddReferenceDataQueries`, `GetIdentifier`, `MapCoreExGraphQLLite`, `AddCoreExGraphQLLite`, `WithCoreExGraphQLTelemetry`) are verified against the real source and kept current — copy them as-is and just build. Do **not** pre-emptively reflect on the installed `.dll` "to be safe" before even attempting a build; that's wasted work when the sample already matches. Only fall back to inspecting the installed package (per the resolution order in [`coreex-conventions.instructions.md#when-unsure-of-a-coreex-api-member`](/.github/instructions/coreex-conventions.instructions.md#when-unsure-of-a-coreex-api-member)) if an actual compile error appears after copying the sample. For full workflow and code examples see [`references/workflow.md`](references/workflow.md). diff --git a/.github/skills/coreex-graphql/references/workflow.md b/.github/skills/coreex-graphql/references/workflow.md index 271e05e2..100e1f6a 100644 --- a/.github/skills/coreex-graphql/references/workflow.md +++ b/.github/skills/coreex-graphql/references/workflow.md @@ -35,13 +35,8 @@ builder.Services.AddCoreExGraphQLLite((o, sp) => { o.AddQuery("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService().QueryAsync(qa, pa, ct).ConfigureAwait(false)) - .AddGet("product", (args, ct) => - { - if (!args.TryGetValue("id", out var id) || id is not string { Length: > 0 } idValue) - throw new ArgumentException("'id' argument is required and must be a non-empty string.", nameof(args)); - - return CoreEx.ExecutionContext.GetRequiredService().GetAsync(idValue, ct); - }); + // GetIdentifier validates the named argument (default "id") for presence and type (it casts to TId, it does not convert) and throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR, if missing/empty/wrong-typed. + .AddGet("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService().GetAsync(args.GetIdentifier(), ct)); }); // ... after app.MapControllers(): @@ -66,13 +61,8 @@ One `AddQuery`/`AddGet` pair per entity, bridging to its **existing** `Que ```csharp o.AddQuery<{Entity}Lite>("{entities}", {Entity}QueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService().QueryAsync(qa, pa, ct).ConfigureAwait(false)) - .AddGet<{Entity}>("{entity}", (args, ct) => - { - if (!args.TryGetValue("id", out var id) || id is not string { Length: > 0 } idValue) - throw new ArgumentException("'id' argument is required and must be a non-empty string.", nameof(args)); - - return CoreEx.ExecutionContext.GetRequiredService().GetAsync(idValue, ct); - }); + // GetIdentifier validates the named argument (default "id") for presence and type (it casts to TId, it does not convert) and throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR, if missing/empty/wrong-typed. + .AddGet<{Entity}>("{entity}", (args, ct) => CoreEx.ExecutionContext.GetRequiredService().GetAsync(args.GetIdentifier(), ct)); ``` **When triggered from `coreex-api`:** after that skill adds a REST GET/Query endpoint pair, and the host already has GraphQL enabled, offer to add the matching root here in the same session rather than requiring a separate invocation. diff --git a/.github/skills/coreex-repository/SKILL.md b/.github/skills/coreex-repository/SKILL.md index 9578a01d..25078d34 100644 --- a/.github/skills/coreex-repository/SKILL.md +++ b/.github/skills/coreex-repository/SKILL.md @@ -50,6 +50,8 @@ Guides you through creating or modifying a CoreEx Infrastructure-layer repositor - `*WithResultAsync` variants for `Result` ROP pipelines (per-project style choice) - `BiDirectionMapper`: override **both** `OnMap` overloads; map `Id` explicitly; **never** map `ETag` or `ChangeLog` — base mapper owns them - `QueryArgsConfig`: create a dedicated `{Name}QueryArgsConfig : QueryArgsConfig<{Name}QueryArgsConfig>` class per entity in `Infrastructure/Repositories/`; access via `.Default`; call `.Parse(query).ThrowOnError()` before use — never instantiate per-request +- `AddReferenceDataField(field, model, ...)`: `field` is always the contract's generated nav property (`{Name}`, never `{Name}Code`) — a fixed, documented source-generator convention, **not** something to verify by exploring generated code +- `ToMappedItemsResultAsync(mapper, paging, cancellationToken: cancellationToken)`: always pass `cancellationToken` **by name** — `autoCount` (`bool`, defaults `true`) sits before it in the signature, and a bare positional token there fails to compile - Always `.ConfigureAwait(false)` on every `await` For full workflow and code examples see [`references/workflow.md`](references/workflow.md). diff --git a/.github/skills/coreex-repository/references/workflow.md b/.github/skills/coreex-repository/references/workflow.md index 5ac79816..edae7aaf 100644 --- a/.github/skills/coreex-repository/references/workflow.md +++ b/.github/skills/coreex-repository/references/workflow.md @@ -185,6 +185,8 @@ Collect the following from the developer. **AI cannot infer field selection, ope | Model/LINQ name if different from the contract name | Needed when the persistence column name differs (e.g., `Category` → `CategoryCode`) | | Model prefix (e.g., `"Product"`) if using a join projection | Required when LINQ expressions must qualify via `Product.Sku`, `Product.Text`, etc. | +> **Reference data fields — don't investigate, apply the rule.** For a reference-data-backed field, `AddReferenceDataField(field, model, ...)`'s `field` argument is **always** the contract's generated navigation property name (`{Name}`, not `{Name}Code`) — a fixed Roslyn source-generator convention (`[ReferenceData] public partial string? {Name}Code { get; set; }` → generated `{Name}` nav property), documented in [`coreex-contracts.instructions.md#reference-data-properties`](/.github/instructions/coreex-contracts.instructions.md#reference-data-properties). This is never something to confirm by reading generated `.g.cs` files or exploring generator internals — it's deterministic. The `model` argument is the underlying persistence/EF column holding the code value; ask the developer only if the persistence model renames it from the `{Name}Code` default. + **Step 3 — For each order-by field (only if ordering = yes):** | Question | Why it matters | @@ -279,7 +281,7 @@ public async Task> QueryAsync(QueryArgs? query { Id = x.{Name}.Id, // ... project fields - }, paging, cancellationToken) + }, paging, cancellationToken: cancellationToken) // named — ToMappedItemsResultAsync's `autoCount` (bool) sits before `cancellationToken`; a bare positional token lands on autoCount and fails to compile (CS1503) .ConfigureAwait(false); } ``` diff --git a/Version.props b/Version.props index 6ab5dcbc..9efef45b 100644 --- a/Version.props +++ b/Version.props @@ -1,5 +1,5 @@ - 4.0.0-preview-2 + 4.0.0-preview-3 diff --git a/docs/capabilities.md b/docs/capabilities.md index 7f503852..4a9aab22 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -684,14 +684,8 @@ Supports: builder.Services.AddCoreExGraphQLLite((o, sp) => { o.AddQuery("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService().QueryAsync(qa, pa, ct).ConfigureAwait(false)) - .AddGet("product", (args, ct) => - { - // Validate explicitly rather than an indexer + null-forgiving lookup, so a missing/empty 'id' throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR. - if (!args.TryGetValue("id", out var id) || id is not string { Length: > 0 } idValue) - throw new ArgumentException("'id' argument is required and must be a non-empty string.", nameof(args)); - - return CoreEx.ExecutionContext.GetRequiredService().GetAsync(idValue, ct); - }); + // GetIdentifier validates the named argument (default "id") for presence and type (it casts to TId, it does not convert) and throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR, if missing/empty/wrong-typed. + .AddGet("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService().GetAsync(args.GetIdentifier(), ct)); }); app.MapCoreExGraphQLLite("/query"); diff --git a/docs/getting-started.md b/docs/getting-started.md index 13f6be78..ff3ce662 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -10,6 +10,35 @@ By the end you'll have a running, fully tested CoreEx microservice as a foundati --- +## Contents + +**Setup** +- [Prerequisites](#prerequisites) +- [Walk-through](#walk-through) + +**Core walkthrough** +- [1. Create and enter your solution folder](#1-create-and-enter-your-solution-folder) +- [2. Install the template pack](#2-install-the-template-pack) +- [3. Install AI workflow assets](#3-install-ai-workflow-assets) +- [4. Scaffold the solution](#4-scaffold-the-solution) +- [5. Start the infrastructure](#5-start-the-infrastructure) +- [6. Open your IDE and AI tooling](#6-open-your-ide-and-ai-tooling) +- [7–9. Bring your domain to life](#79-bring-your-domain-to-life) +- [7. Implement your first entity and API host](#7-implement-your-first-entity-and-api-host) +- [8. Add an Outbox Relay host](#8-add-an-outbox-relay-host) +- [9. Add a Subscribe host](#9-add-a-subscribe-host) +- [10. Build and test](#10-build-and-test) + +**Going further (optional)** +- [11. Add an Employee query endpoint](#11-add-an-employee-query-endpoint) +- [12. Add GraphQL query support](#12-add-graphql-query-support) + +**Reference** +- [What's next](#whats-next) +- [Alternative: AI-guided scaffold](#alternative-ai-guided-scaffold) + +--- + ## Prerequisites | Requirement | Notes | @@ -259,6 +288,58 @@ dotnet test tests/Avanade.Hr.People.Test.Unit # fast, no infrastructure requir dotnet test # all tests — requires containers + database ``` +At this point you have a complete, fully tested CoreEx microservice — CRUD API, Outbox Relay, and Subscribe host, all green. Everything from here is optional: two further extensions that build on the Employee entity you already have. + +> **Note:** The [walk-through video](#walk-through) above covers steps 1–10 only; it predates steps 11 and 12 below. + +--- + +## Going further (optional) + +## 11. Add an Employee query endpoint + +### AI-assisted (recommended) + +> Paste the following into GitHub Copilot (Agent mode) or Claude Code. It invokes the `coreex-api` skill to add a paged, filterable, sortable query endpoint for the existing Employee entity. + +``` +/coreex-api + +Create a query endpoint for the Employee. +Support paging. +Support the following filter properties: +- LastName - either EQ or StartsWith (case insensitive) +- Gender - standard +Support the following ordering properties (use all three as the default in sequence specified): +- LastName +- FirstName +- Id +``` + +This invokes the [`coreex-api`](https://github.com/Avanade/CoreEx/blob/main/.github/skills/coreex-api/SKILL.md) skill, which scaffolds the `{Entity}ReadService`/`{Entity}ReadController` CQRS read pair and the underlying `QueryArgsConfig` filter/order configuration on the repository — following the same interview-first pattern used in step 7. + +### Manual alternative + +Follow the query pattern in the [Pattern Catalog](https://github.com/Avanade/CoreEx/blob/main/samples/docs/patterns.md) by hand: add an `EmployeeQueryArgsConfig` under `Infrastructure/Repositories/`, a `QueryAsync` method on the repository and `IEmployeeReadService`, and a matching `QueryAsync` action on `EmployeeReadController`. See `ProductQueryArgsConfig`/`ProductRepository` in the [Contoso samples](https://github.com/Avanade/CoreEx/tree/main/samples) for a worked example. + +--- + +## 12. Add GraphQL query support + +### AI-assisted (recommended) + +> Paste the following into GitHub Copilot (Agent mode) or Claude Code. The `coreex-graphql` skill is self-contained — no additional prompt detail is required. + +``` +/coreex-graphql +``` + +This invokes the [`coreex-graphql`](https://github.com/Avanade/CoreEx/blob/main/.github/skills/coreex-graphql/SKILL.md) skill, which wires up `AddCoreExGraphQLLite`/`MapCoreExGraphQLLite` on the API host (first-time only) and registers a GraphQL root for the Employee query endpoint added in step 11 — bridging its existing `QueryArgsConfig`, with no new filter/sort logic written. + +### Manual alternative + +Follow [`CoreEx.Data.GraphQL`'s AGENTS.md](https://github.com/Avanade/CoreEx/blob/main/src/CoreEx.Data.GraphQL/AGENTS.md) to wire `AddCoreExGraphQLLite`/`MapCoreExGraphQLLite` by hand and register an `AddQuery` (or `AddQuery` if there's no separate "Lite" projection) root. + --- ## What's next diff --git a/samples/docs/hosts-layer.md b/samples/docs/hosts-layer.md index ed1d1795..925fb20c 100644 --- a/samples/docs/hosts-layer.md +++ b/samples/docs/hosts-layer.md @@ -73,14 +73,8 @@ paging, and field-selection behavior because they drive the exact same underlyin builder.Services.AddCoreExGraphQLLite((o, sp) => { o.AddQuery("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService().QueryAsync(qa, pa, ct).ConfigureAwait(false)) - .AddGet("product", (args, ct) => - { - // Validate explicitly rather than an indexer + null-forgiving lookup, so a missing/empty 'id' throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR. - if (!args.TryGetValue("id", out var id) || id is not string { Length: > 0 } idValue) - throw new ArgumentException("'id' argument is required and must be a non-empty string.", nameof(args)); - - return CoreEx.ExecutionContext.GetRequiredService().GetAsync(idValue, ct); - }); + // GetIdentifier validates the named argument (default "id") for presence and type (it casts to TId, it does not convert) and throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR, if missing/empty/wrong-typed. + .AddGet("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService().GetAsync(args.GetIdentifier(), ct)); }); // ... diff --git a/src/CoreEx.Data.GraphQL/AGENTS.md b/src/CoreEx.Data.GraphQL/AGENTS.md index 8f4c42bf..1428c77f 100644 --- a/src/CoreEx.Data.GraphQL/AGENTS.md +++ b/src/CoreEx.Data.GraphQL/AGENTS.md @@ -11,14 +11,8 @@ Register roots explicitly — no attribute-based auto-discovery. Each `AddQuery` builder.Services.AddCoreExGraphQLLite((o, sp) => { o.AddQuery("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService().QueryAsync(qa, pa, ct).ConfigureAwait(false)) - .AddGet("product", (args, ct) => - { - // Validate explicitly rather than an indexer + null-forgiving lookup, so a missing/empty 'id' throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR. - if (!args.TryGetValue("id", out var id) || id is not string { Length: > 0 } idValue) - throw new ArgumentException("'id' argument is required and must be a non-empty string.", nameof(args)); - - return CoreEx.ExecutionContext.GetRequiredService().GetAsync(idValue, ct); - }); + // GetIdentifier validates the named argument (default "id") for presence and type (it casts to TId, it does not convert) and throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR, if missing/empty/wrong-typed. + .AddGet("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService().GetAsync(args.GetIdentifier(), ct)); }); // ... diff --git a/src/CoreEx.Data.GraphQL/README.md b/src/CoreEx.Data.GraphQL/README.md index 5034f78f..9016cf19 100644 --- a/src/CoreEx.Data.GraphQL/README.md +++ b/src/CoreEx.Data.GraphQL/README.md @@ -87,15 +87,9 @@ The engine is deliberately **transport-agnostic**: it references only `CoreEx.Da builder.Services.AddCoreExGraphQLLite((o, sp) => { o.AddQuery("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService().QueryAsync(qa, pa, ct).ConfigureAwait(false)) - .AddGet("product", (args, ct) => - { - // Validate explicitly rather than an indexer + null-forgiving lookup, so a missing/empty 'id' throws an ArgumentException - which the engine maps to an - // ARGUMENT_ERROR GraphQL error - instead of an unhandled KeyNotFoundException/NullReferenceException surfacing as an opaque EXECUTION_ERROR. - if (!args.TryGetValue("id", out var id) || id is not string { Length: > 0 } idValue) - throw new ArgumentException("'id' argument is required and must be a non-empty string.", nameof(args)); - - return CoreEx.ExecutionContext.GetRequiredService().GetAsync(idValue, ct); - }); + // GetIdentifier validates the named argument (default "id") for presence and type (it casts to TId, it does not convert) and throws an ArgumentException - mapped by the engine to an ARGUMENT_ERROR GraphQL error - if + // it is missing, empty, or the wrong type, instead of an unhandled KeyNotFoundException/NullReferenceException surfacing as an opaque EXECUTION_ERROR. + .AddGet("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService().GetAsync(args.GetIdentifier(), ct)); }); // ... diff --git a/src/CoreEx.Data/AGENTS.md b/src/CoreEx.Data/AGENTS.md index e19058f8..84d47599 100644 --- a/src/CoreEx.Data/AGENTS.md +++ b/src/CoreEx.Data/AGENTS.md @@ -41,10 +41,12 @@ private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() .WithDefault($"{nameof(Order.CreatedOn)} desc")); // In the repository -public async Task> GetAllAsync(QueryArgs args, CancellationToken ct = default) - => await _efDb.Model() - .Query(new EfDbArgs(args, _queryConfig)) - .ToItemsResultAsync(MapToEntity, ct).ConfigureAwait(false); +public async Task> GetAllAsync(QueryArgs args, PagingArgs? paging, CancellationToken cancellationToken = default) + => await _efDb.Model().Query() + .Where(_queryConfig, args) + .OrderBy(_queryConfig, args) + .ToItemsResultAsync(paging, cancellationToken: cancellationToken) // named — autoCount (bool) sits before cancellationToken + .ConfigureAwait(false); ``` ## DataResult diff --git a/src/CoreEx.EntityFrameworkCore/AGENTS.md b/src/CoreEx.EntityFrameworkCore/AGENTS.md index b067c8ca..79665819 100644 --- a/src/CoreEx.EntityFrameworkCore/AGENTS.md +++ b/src/CoreEx.EntityFrameworkCore/AGENTS.md @@ -13,7 +13,7 @@ builder.Services ## EfDb — Entry Point -Inject `EfDb` (or your `IEfDb`) into repositories. Access typed CRUD via `Model()`. +Inject `EfDb` (or your `IEfDb`) into repositories. Access typed CRUD via `Model()` — this is for the case where the domain/contract type **is** the EF persistence model type (no separate mapper needed); `GetAsync`/`CreateAsync`/`UpdateAsync`/`DeleteAsync` take no mapper parameter: ```csharp [ScopedService] @@ -21,38 +21,53 @@ public class ProductRepository(EfDb efDb) : IProductRepository { private readonly EfDbModel _model = efDb.Model(); - public Task GetAsync(Guid id, CancellationToken ct = default) => - _model.GetAsync(new EfDbArgs(OperationType.Get), id, ProductMapper.Default.MapToEntity, ct); + public Task GetAsync(Guid id, CancellationToken ct = default) => _model.GetAsync(id, ct); - public Task CreateAsync(Product product, CancellationToken ct = default) => - _model.CreateAsync(new EfDbArgs(OperationType.Create), product, ProductMapper.Default, ct); + public Task> CreateAsync(ProductModel product, CancellationToken ct = default) => _model.CreateAsync(product, ct); } ``` ## Mapped Model (Separate EF Model Type) -Use `EfDbMappedModel` when the domain entity type differs from the EF persistence model type. +Use `EfDbMappedModel` when the domain/contract type (`TValue`) differs from the EF persistence model type (`TModel`). Construct it once — typically as a property on your `EfDb` subclass — via `Model().ToMappedModel(mapper)`. `TMapper` must be an `IBiDirectionMapper` (see [`CoreEx.Mapping`](../CoreEx/Mapping/README.md)); once mapped, `GetAsync`/`CreateAsync`/`UpdateAsync`/`DeleteAsync` still take **no mapper parameter** — the mapper is already bound via the generic type: ```csharp -private readonly EfDbMappedModel _model = - efDb.Model(); +public sealed class MyEfDb(MyDbContext dbContext) : EfDb(dbContext, _options) +{ + private static readonly EfDbOptions _options = new EfDbOptions().WithModel(m => m.WithLogicalDeleteFilter()); + + public EfDbMappedModel Products => Model().ToMappedModel(ProductMapper.Default); +} + +public class ProductRepository(MyEfDb ef) : IProductRepository +{ + public Task GetAsync(Guid id, CancellationToken ct = default) => ef.Products.GetAsync(id, ct); + + public Task> CreateAsync(Product product, CancellationToken ct = default) => ef.Products.CreateAsync(product, ct); +} ``` ## Dynamic Query with Paging -Use `Query(args)` for paged, filtered list endpoints. Combine with `EfDbExtensions.ToItemsResultAsync`. +Get the underlying `IQueryable` via `Model.Query()`, apply the parsed `QueryArgsConfig` filter/order (see [`CoreEx.Data`](../CoreEx.Data/AGENTS.md)), then materialize with `ToMappedItemsResultAsync`. + +> **Always pass `cancellationToken` as a named argument.** `ToMappedItemsResultAsync`'s signature is `(mapper, paging = null, autoCount = true, cancellationToken = default)` — `autoCount` (`bool`) sits *before* `cancellationToken`. A bare positional `CancellationToken` argument in the third slot lands on `autoCount` and fails to compile (`CS1503`). Use `cancellationToken: cancellationToken` explicitly, every time. ```csharp -private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create() - .WithFilter(f => f.AddField(nameof(ProductModel.Status))) - .WithOrderBy(o => o.AddField(nameof(ProductModel.Name)).WithDefault("Name")); - -public Task> GetAllAsync(QueryArgs? args, PagingArgs? paging, - CancellationToken ct = default) - => _model.Query(new EfDbArgs(args ?? new QueryArgs(), _queryConfig, paging)) - .ToMappedItemsResultAsync(ProductMapper.Default.MapToEntity, ct); +public async Task> QueryAsync(QueryArgs? query, PagingArgs? paging, CancellationToken cancellationToken = default) +{ + var parsed = ProductQueryArgsConfig.Default.Parse(query).ThrowOnError(); + + return await ef.Products.Model.Query() + .Where(parsed) + .OrderBy(parsed) + .ToMappedItemsResultAsync(m => ProductMapper.From.Map(m), paging, cancellationToken: cancellationToken) + .ConfigureAwait(false); +} ``` +> If the read side projects to a reduced "Lite" contract instead of the full entity, `ProductMapper.From.Map(m)` (an `IBiDirectionMapper`) still returns `Product`, not `ProductLite` — a mapper only knows its own two types. Project inline instead: `m => new ProductLite { Id = m.Id, Sku = m.Sku, ... }`. + ## ValueConverter Bridge Use `ValueConverterBridge` in `OnModelCreating` to reuse CoreEx `IConverter` instances as EF value converters.