Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 73 additions & 24 deletions .github/instructions/coreex-repositories.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ tags: ["repositories", "infrastructure", "data-access", "efcore", "mapping", "ad
|---|---|
| `CoreEx` | `[ScopedService<T>]`, `.ThrowIfNull()`, `ItemsResult<T>`, `Result<T>`, `.GoAsync()`, `.ThenAs()`, `.ThenAsAsync()` |
| `CoreEx.Events` | `EventData` |
| `CoreEx.Data` | `IUnitOfWork`, `DataResult<T>`, `QueryArgsConfig`, `QueryFilterOperator`, `.Where(parsed)`, `.OrderBy(parsed)` |
| `CoreEx.Data` | `IUnitOfWork`, `DataResult<T>`, `QueryArgsConfig<TSelf>`, `QueryFilterOperator`, `.Where(parsed)`, `.OrderBy(parsed)` |
| `CoreEx.EntityFrameworkCore` | `EfDb<TContext>`, `EfDbModel<T>`, `EfDbMappedModel<TContract,TModel,TMapper>`, `EfDbOptions`, `.GetAsync()`, `.CreateAsync()`, `.UpdateAsync()`, `.DeleteAsync()`, `.GetWithResultAsync()`, `.CreateWithResultAsync()`, `.UpdateWithResultAsync()`, `.Query()`, `.ToMappedItemsResultAsync()` |
| `CoreEx.Database.SqlServer` | SQL Server outbox publisher, ADO.NET helpers |
| `CoreEx.Database.Postgres` | PostgreSQL outbox publisher, ADO.NET helpers |
Expand Down Expand Up @@ -144,35 +144,83 @@ Per-model behaviour is configured on `EfDbOptions` / `EfDbModelOptions`: `WithMo

## Dynamic Query Configuration

> **GlobalUsing requirement:** `QueryArgsConfig` and the related query types live in the `CoreEx.Data.Querying` namespace. When introducing query configuration, ensure `global using CoreEx.Data.Querying;` is present in the Infrastructure project's `GlobalUsing.cs` — add it if missing. This prevents avoidable compilation errors (per the Global Usings convention, imports go in `GlobalUsing.cs`, never in individual files).
> **GlobalUsing requirement:** `QueryArgsConfig<TSelf>` and the related query types live in the `CoreEx.Data.Querying` namespace. When introducing query configuration, ensure `global using CoreEx.Data.Querying;` is present in the Infrastructure project's `GlobalUsing.cs` — add it if missing.

Define a `static readonly QueryArgsConfig _queryConfig` once at class level for OData-style filtering and ordering:
> **Interview first.** The fields to filter and order, the operators permitted per field, and the mapping from contract names to model names are **business and design decisions** — the developer must specify them. Never generate a `QueryArgsConfig` without first collecting the complete field list, types, and operators from the developer.

Each entity's query configuration lives in its own dedicated class in `Infrastructure/Repositories/`, named `{Name}QueryArgsConfig`. Extend `QueryArgsConfig<{Name}QueryArgsConfig>` (CRTP) — the base provides a thread-safe lazy `Default` singleton and all `WithFilter` / `WithOrderBy` configuration is performed in the constructor:

```csharp
private static readonly QueryArgsConfig _queryConfig = QueryArgsConfig.Create()
.WithFilter(filter => filter
.WithDefaultModelPrefix("Product")
.AddField<string>(nameof(ProductBase.Sku), c => c
.WithOperators(QueryFilterOperator.EqualityOperators | QueryFilterOperator.StartsWith)
.AsUpperCase())
.AddField<string>(nameof(ProductBase.Text), c => c
.WithOperators(QueryFilterOperator.StringFunctions)
.AsUpperCase())
.AddReferenceDataField<Category>(nameof(ProductBase.Category), "CategoryCode",
c => c.WithModelPrefix(null)))
.WithOrderBy(orderby => orderby
.WithDefaultModelPrefix("Product")
.AddField(nameof(ProductBase.Sku), c => c.WithDefault().WithAlwaysInclude())
.AddField(nameof(ProductBase.Text))
.AddField(nameof(ProductBase.Brand)));
// Infrastructure/Repositories/ProductQueryArgsConfig.cs
internal class ProductQueryArgsConfig : QueryArgsConfig<ProductQueryArgsConfig>
{
public ProductQueryArgsConfig()
{
WithFilter(filter => filter
.WithDefaultModelPrefix("Product")
.AddField<string>(nameof(Contracts.ProductBase.Sku), c => c
.WithOperators(QueryFilterOperator.EqualityOperators | QueryFilterOperator.StartsWith)
.AsUpperCase())
.AddField<string>(nameof(Contracts.ProductBase.Text), c => c
.WithOperators(QueryFilterOperator.StringFunctions)
.AsUpperCase())
.AddReferenceDataField<Contracts.Category>(nameof(Contracts.ProductBase.Category), "CategoryCode",
c => c.WithNoModelPrefix()));

WithOrderBy(orderby => orderby
.WithDefaultModelPrefix("Product")
.AddField(nameof(Contracts.ProductBase.Sku), c => c.WithDefault().WithAlwaysInclude())
.AddField(nameof(Contracts.ProductBase.Text)));
}
}
```

In the query method, compose the full base query first (including any required joins), then apply `Where(parsed)` and `OrderBy(parsed)`:
### Field type reference

Choose the `AddField` overload based on the contract property type:

| Method | Use when the property type is | Default operators | Key options |
|---|---|---|---|
| `AddField<string>(field, ...)` | `string` | Comparison + string functions | `.WithOperators(...)`, `.AsUpperCase()`, `.AsLowerCase()` |
| `AddField<T>(field, ...)` | `int`, `decimal`, `DateTime`, `DateOnly`, `Guid`, `bool`, any `IParsable<T>` | Numeric/date: `ComparisonOperators`; bool: `Equal\|NotEqual` | `.WithOperators(...)`, `.WithConverter(...)`, `.WithValue(...)` |
Comment thread
chullybun marked this conversation as resolved.
| `AddField<TEnum>(field, ...)` | Any `Enum` type | `Equal\|NotEqual\|In` | `.WithOperators(...)`, `.WithConverter(...)` |
| `AddNullField(field, ...)` | Null/not-null check only (no value comparison) | `Equal\|NotEqual` (null semantics) | `.WithModelPrefix(...)` |
| `AddReferenceDataField<TRef>(field, ...)` | Any `IReferenceData` type (resolved by code via orchestrator) | `EqualityOperators` (`eq`/`ne`/`in`) | `.MustBeActive(...)` |

**Operator quick-reference** — use with `.WithOperators(...)` (combine flags with `|`):

| Flag / Composite | Filter string operators enabled | Typical use |
|---|---|---|
| `Equal` | `eq` | Exact match |
| `NotEqual` | `ne` | Exclusion |
| `GreaterThan` / `GreaterThanOrEqual` | `gt` / `ge` | Numeric or date lower bound |
| `LessThan` / `LessThanOrEqual` | `lt` / `le` | Numeric or date upper bound |
| `In` | `in ('a', 'b')` | Multi-value match |
| `StartsWith` / `EndsWith` / `Contains` | `startswith(f,'v')` / `endswith(f,'v')` / `contains(f,'v')` | String prefix/suffix/substring |
| **`EqualityOperators`** *(composite)* | `eq`, `ne`, `in` | Code / ID fields — exact match only |
| **`ComparisonOperators`** *(composite)* | `eq`, `ne`, `lt`, `le`, `gt`, `ge` | Numeric, date, or sortable fields |
| **`StringFunctions`** *(composite)* | `startswith`, `endswith`, `contains` | Free-text / description fields |

The **operators per field** are a design decision — restrict them deliberately. For example, `EqualityOperators` for a SKU (exact match only), `EqualityOperators | StartsWith` when prefix search is needed, `StringFunctions` for a description field.

**Case sensitivity (string fields):** `.AsUpperCase()` wraps both the stored column value and the filter input in `ToUpperInvariant()`, making all string comparisons case-insensitive. `.AsLowerCase()` does the same with `ToLowerInvariant()`. Apply to any string field where case should be ignored (SKUs, codes, free-text search). Omit for fields where case is meaningful.

### Contract-vs-model naming

The first `field` argument is the **public contract property name**. Supply it as `nameof(Contracts.SomeType.Property)` or as a plain string literal — the developer must specify it. The optional second `model` argument is the **LINQ property name** when it differs (e.g., a ref-data navigation `Category` maps to the persistence column `CategoryCode`).

`WithDefaultModelPrefix("Product")` wraps all LINQ expressions with `Product.xxx` — needed when the query projects into an anonymous type like `new { Product = p, ... }`. Override per-field with `.WithModelPrefix(...)` or `.WithNoModelPrefix()` when a joined column lives at the projection root rather than under the prefixed object.
Comment thread
chullybun marked this conversation as resolved.
Outdated

Order-by fields: `.WithDefault()` includes the field in the sort when the consumer sends no `$orderby`; `.WithAlwaysInclude()` appends it to every result regardless (useful for a stable tie-breaker).

### Using the config in a repository method

Access via the lazy `Default` singleton — never instantiate per-request:

```csharp
public async Task<ItemsResult<Contracts.ProductLite>> QueryAsync(QueryArgs? query, PagingArgs? paging, CancellationToken cancellationToken = default)
{
var parsed = _queryConfig.Parse(query).ThrowOnError();
var parsed = ProductQueryArgsConfig.Default.Parse(query).ThrowOnError();

// Compose the base query with required joins before applying parsed filters.
var q =
Expand All @@ -192,14 +240,15 @@ public async Task<ItemsResult<Contracts.ProductLite>> QueryAsync(QueryArgs? quer
Sku = x.Product.Sku,
CategoryCode = x.CategoryCode,
QtyOnHand = x.QtyOnHand
}, paging, cancellationToken);
}, paging, cancellationToken)
.ConfigureAwait(false);
}
```

Expose the query schema for the `$query` endpoint via `ToJsonSchema()`:

```csharp
public Task<JsonElement> QuerySchemaAsync(CancellationToken cancellationToken = default) => Task.FromResult(_queryConfig.ToJsonSchema());
public Task<JsonElement> QuerySchemaAsync(CancellationToken cancellationToken = default) => Task.FromResult(ProductQueryArgsConfig.Default.ToJsonSchema());
```

## Result&lt;T&gt; Pipeline in Repositories
Expand Down Expand Up @@ -336,7 +385,7 @@ Always call `.ConfigureAwait(false)` on every `await` inside repository and adap
- 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.
- Do not edit `*.g.cs` persistence or DbContext files directly — regenerate via the `*.Database` tooling project.
- Do not add a mapper without ensuring the `<Domain>.Infrastructure.Mapping` namespace is in the Infrastructure `GlobalUsing.cs`; likewise ensure `CoreEx.Data.Querying` is present when adding `QueryArgsConfig` query configuration.
- Do not add a mapper without ensuring the `<Domain>.Infrastructure.Mapping` namespace is in the Infrastructure `GlobalUsing.cs`; likewise ensure `CoreEx.Data.Querying` is present when adding a `QueryArgsConfig<TSelf>` query configuration class.
- Do not mix EfDb flows — use the `...WithResultAsync` methods inside `Result<T>` pipelines and the plain `...Async` methods for exception flow; do not wrap a throwing `...Async` call to fake a `Result`.

## Further Reading
Expand Down
6 changes: 3 additions & 3 deletions .github/skills/coreex-repository/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: coreex-repository
description: "Create or modify a CoreEx Infrastructure-layer repository. USE FOR: new repository class, adding CRUD operations, adding a custom query (QueryArgsConfig), bidirectional mapper (BiDirectionMapper), EfDb model accessor, Result<T> pipeline variants. DO NOT USE FOR: Application-layer service logic, domain invariants, typed HTTP clients/adapters (those follow adapter conventions in the infrastructure instructions, not this skill)."
argument-hint: "Optional: entity name, database type (PostgreSQL/SQL Server), operations needed (get/create/update/delete/query), new or existing repository"
argument-hint: "Optional: entity name, database type (PostgreSQL/SQL Server), operations needed (get/create/update/delete/query), new or existing repository; for query: filtering (default: yes), ordering (default: yes), paging (default: yes), count support (default: no), then per filter field: name + property type + allowed operators + case-insensitive? + model mapping if different"
Comment thread
chullybun marked this conversation as resolved.
tags: ["repository", "infrastructure", "efcore", "mapping", "coreex", "data-access", "result"]
---

Expand Down Expand Up @@ -40,7 +40,7 @@ Guides you through creating or modifying a CoreEx Infrastructure-layer repositor
2. New repository or adding to an existing one?
3. Operations needed: Get / Create / Update / Delete / Query?
4. Does the project use `Result<T>` / ROP pipelines? (→ `*WithResultAsync` — per-project style choice, not tied to DDD)
5. Does the query need dynamic filtering/ordering? (→ `QueryArgsConfig`)
5. Does the query need dynamic filtering/ordering? (→ `QueryArgsConfig<TSelf>`) — if yes, collect the **complete field list** from the developer before writing any code: for each filter field the name, property type, allowed operators, and model/LINQ name if it differs from the contract name; for each order-by field the name and whether it should be in the default sort or always appended. **AI cannot infer these from the entity shape.**

**Key rules at a glance:**
- `[ScopedService<IInterface>]` on every repository class — auto-registers in DI
Expand All @@ -49,7 +49,7 @@ Guides you through creating or modifying a CoreEx Infrastructure-layer repositor
- `DataResult<T>` return for Create/Update; `DataResult` for Delete — includes mutation flag for event decisions
- `*WithResultAsync` variants for `Result<T>` ROP pipelines (per-project style choice)
- `BiDirectionMapper`: override **both** `OnMap` overloads; map `Id` explicitly; **never** map `ETag` or `ChangeLog` — base mapper owns them
- `QueryArgsConfig`: define once as `private static readonly`; call `.Parse(query).ThrowOnError()` before use
- `QueryArgsConfig<TSelf>`: 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
- Always `.ConfigureAwait(false)` on every `await`

For full workflow and code examples see [`references/workflow.md`](references/workflow.md).
Expand Down
Loading
Loading