diff --git a/CHANGELOG.md b/CHANGELOG.md index f85b2e5a1..99a82eec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,8 +107,9 @@ it until 1.1 ships._ with a derived field over a `field.object`. In C# a report's enum dimension is sortable (an entity's enum field still is not). A decimal column (`avg`, a ratio, a `sum` of a decimal) has no cross-port JSON spelling: each port sends its own, and TypeScript sends a string. - Gated by a new api-contract sub-corpus, `fixtures/api-contract-conformance/report/` (12 - scenarios, generated lane, all five ports; the corpus goes from 61 scenarios to 73), which + Gated by a new api-contract sub-corpus, `fixtures/api-contract-conformance/report/` (13 + scenarios, generated lane, all five ports; the corpus goes from 61 scenarios to 78, the + `projection/` sub-corpus gaining four), which is also the first to assert a `field.date` literally. Anyone who declared a view-sourced report under the unreleased 1.1 vocabulary will see these files on the next `gen`. See [docs/features/reporting.md](docs/features/reporting.md#how-a-report-is-served). @@ -144,28 +145,32 @@ it until 1.1 ships._ ### Changed -Seven corrections that shipped with report serving and reach models that declare no report. +Eight corrections that shipped with report serving and reach models that declare no report. Each one changes generated code on the next `gen`, or generated docs on the next `meta docs` or Python api-docs build, as its entry says. A drift gate that covers that output reports it until you regenerate. -- **TypeScript and Python: a read-only projection with no declared identity and no field named - `id` loses its item surface.** It no longer gets `GET /{id}` or the three item-verb refusals, - its by-id query (`findById` in TypeScript, `find_by_id` on the Python repository - Protocol) or, in TypeScript, its detail hook and `detail` query keys. That surface could not - address a row: TypeScript built the query with no `WHERE` and answered the view's first row, - and Python bound an `id: int` to nothing. **Unchanged by this entry:** a projection with a - declared identity (a composite one still binds its first field; the next entry covers one - keyed on a field not named `id`), and a projection with an `id` field and no declared - identity. C#, Java and Kotlin are unchanged and remain stricter: they mount - `/{id}` only for a declared single-column identity, so a projection with an `id` field and no - declared identity has item routes in TypeScript and Python and none in the other three. The - TypeScript mounts (`mountReadOnlyCrudRoutes`, Fastify and Hono) take a new `itemRoutes: false` - option for this; it defaults to mounting them. **Upgrading:** an owned `routes` or - `routes-hono` generator ejected before this release keeps mounting the item routes for this - shape until you re-eject it or add `itemRoutes: false` to its read-only mount options, and - an owned `mount-read-only.ts` needs the re-sync described under the served-report entry - above. +- **TypeScript and Python: a read-only projection with no declared identity loses its item + surface, even when it has a field named `id`.** It no longer gets `GET /{id}` or the three + item-verb refusals, its by-id query (`findById` in TypeScript, `find_by_id` on the Python + repository Protocol) or, in TypeScript, its detail hook and `detail` query keys. This is a + **named behaviour change for an adopter whose view-only projection has an `id` field and no + `identity.primary`**: that shape kept working in these two ports (TypeScript answered the row + whose `id` matched), and now answers the framework's `404` for `/{id}`. To keep the item + route, declare the identity: `identity.primary` extending the base entity's, with a + pass-through field for its key. A field named `id` is a convention, not a key, and C#, Java + and Kotlin never mounted item routes for it, so all five ports now mount `/{id}` for exactly + the projections that declare an identity. Without any `id` field the same surface was already + unaddressable: TypeScript built the by-id query with no `WHERE` and answered the view's first + row, and Python bound an `id: int` to nothing. **Unchanged by this entry:** a projection with + a declared identity (a composite one still binds its first field in these two ports; the next + entry covers one keyed on a field not named `id`). The TypeScript mounts + (`mountReadOnlyCrudRoutes`, Fastify and Hono) take a new `itemRoutes: false` option for this; + it defaults to mounting them. **Upgrading:** an owned `routes` or `routes-hono` generator + ejected before this release keeps mounting the item routes for this shape until you re-eject + it or add `itemRoutes: false` to its read-only mount options, and an owned + `mount-read-only.ts` needs the re-sync described under the served-report entry above. Gated + in all five ports by `projection/keyless-no-item-route.yaml`. - **TypeScript: a read-only projection whose identity is on a field not named `id` now addresses that field.** The shape: a view-only `object.projection` whose `identity.primary` names a single field such as `code`, or a composite identity whose first @@ -197,6 +202,13 @@ until you regenerate. `GET /{id}` only when the projection has an item route (the rule above), and the filter allowlist. No write verb is listed, since the router answers those with `405`. Entities and write-through objects are unchanged. Regenerate the docs to pick the pages up. +- **Java and Kotlin API docs: a read-only projection's page lists the reads only.** The + api-docs builders listed `POST`, `PATCH`, `PUT` and `DELETE` on a read-only projection, each + described as a `405` refusal, beside `GET`. The other three ports never documented a write + verb on one, and a refusal is not an operation a caller can use, so the page now lists `GET + ` and, when the projection declares an identity, `GET /{id}`. The generated + controllers are unchanged. Regenerate the docs to pick it up. Gated in all five ports by + `projection/docs-routes.json`. - **Java: a filter allowlist with more than ten filterable fields now compiles.** `SpringFilterAllowlistGenerator` spelled `OPS_BY_FIELD` with `Map.of`, which has no overload past ten pairs, so an entity or projection with eleven or more `@filterable` fields generated diff --git a/agent-context/skills/metaobjects-codegen/references/python.md b/agent-context/skills/metaobjects-codegen/references/python.md index 520f5c33e..248ffe38b 100644 --- a/agent-context/skills/metaobjects-codegen/references/python.md +++ b/agent-context/skills/metaobjects-codegen/references/python.md @@ -118,10 +118,9 @@ Its REST surface is generated and READ-ONLY (F22): GET list + GET by id, the sam projection's OWN declared field set, and `POST` / `PATCH` / `PUT` / `DELETE` each answering `405 {"error": "method_not_allowed"}` — 405 and not 404 because the same path answers GET. A KEYLESS projection mounts no `/{id}` route at all, so it refuses only -the collection verb, and its repository Protocol has no `find_by_id`. In Python keyless -means no `identity.primary` AND no field named `id`: a projection with an `id` field and -no declared identity keeps its item routes (C#, Java and Kotlin are stricter and need a -declared single-column identity). +the collection verb, and its repository Protocol has no `find_by_id`. Keyless means no +declared `identity.primary`: a field that is merely named `id` is a convention, not a key, so +a projection with an `id` field and no declared identity is keyless too (in every port). The read-only router is a separate assembly from the writable one, sharing only the emitters they genuinely have in common; `router_generator` and `filter_allowlist_generator` ask one shared `emits_router()` predicate. diff --git a/agent-context/skills/metaobjects-codegen/references/typescript.md b/agent-context/skills/metaobjects-codegen/references/typescript.md index b29d1aad8..8048ef9b5 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript.md @@ -154,10 +154,9 @@ Its REST surface is generated and READ-ONLY (F22): GET list + GET by id, the sam projection's OWN declared field set, and `POST` / `PATCH` / `PUT` / `DELETE` each answering `405 {"error": "method_not_allowed"}` — 405 and not 404 because the same path answers GET. A KEYLESS projection mounts no `/:id` route at all, so it refuses only -the collection verb, and it gets no `find…ById` query and no detail hook. In TypeScript -keyless means no `identity.primary` AND no field named `id`: a projection with an `id` -field and no declared identity keeps its item routes (C#, Java and Kotlin are stricter and -need a declared single-column identity). +the collection verb, and it gets no `find…ById` query and no detail hook. Keyless means no +declared `identity.primary`: a field that is merely named `id` is a convention, not a key, so +a projection with an `id` field and no declared identity is keyless too (in every port). `routesFile()` mounts it through `mountReadOnlyCrudRoutes` from the drizzle-fastify adapter (your `codegen/runtime/` copy once ejected), which is where the refusals live. A `field.decimal` in a view's read schema is `z.string()`: the driver reads `numeric` as diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 5a34a3e14..1bcc00402 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -34,7 +34,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 48 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ | | [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ | | [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 39 (33 query + 6 migration) | all 39 | 33 query (migrations TS-only, ADR-0015) | 33 query (via Exposed) | 33 query | 33 query | -| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 73 (31 core + 10 tph + 9 m2m + 2 jsonb + 2 write-through + 7 projection + 12 report) | ✓ (Fastify reference + generated lane) | ✓ (embedded HTTP + JDBC) | ✓ (embedded HTTP + Exposed) | ✓ (HttpListener + Npgsql) | ✓ (FastAPI + pg8000) | +| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 78 (31 core + 10 tph + 9 m2m + 2 jsonb + 2 write-through + 11 projection + 13 report) | ✓ (Fastify reference + generated lane) | ✓ (embedded HTTP + JDBC) | ✓ (embedded HTTP + Exposed) | ✓ (HttpListener + Npgsql) | ✓ (FastAPI + pg8000) | | [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) | | [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ | | [`fixtures/object-model-conformance/`](../fixtures/object-model-conformance/) | 1 shared metadata fixture (per-port scenarios) | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -257,9 +257,9 @@ All 31 fixtures → [features/migrations-and-drift.md](features/migrations-and-d - `migrations/*` (6) → [features/migrations-and-drift.md](features/migrations-and-drift.md) (schema migration section) - `queries/*` (33) → [features/source-kinds.md](features/source-kinds.md) (query semantics against `source.rdb`) -### `fixtures/api-contract-conformance/` (73) +### `fixtures/api-contract-conformance/` (78) -All 73 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port +All 78 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port REST API URL grammar + JSON wire format). Verifies every backend's emitted CRUD routes answer identically over HTTP — list / get / create / patch+put / delete, plus pagination (`limit`/`offset`), sort (`sort=field:dir`), the `withCount=1` @@ -274,8 +274,10 @@ scenarios the corpus carries six sub-corpora — `tph/` (10, single-table inheritance), `m2m/` (9 — 3 plain, 5 gating TPH x M:N together, the combination each corpus alone could not reach, and 1 pinning the collection-URL spelling), `jsonb/` (2, typed value-object columns), -`write-through/` (2, table-write + view-read entities), `projection/` (7, a -read-only view answers reads and refuses writes with 405) and `report/` (12, +`write-through/` (2, table-write + view-read entities), `projection/` (11, a +read-only view answers reads and refuses writes with 405; a projection with no declared +identity has no item route; a projection keyed on a field not named `id` is addressed by +it; decimal and float fields filter) and `report/` (13, FR-044: a view-backed `object.report` is listed, filtered, sorted and paged on its derived fields, answers `POST` with 405 and mounts no `/{id}`). All 5 ports — TS, Java, Kotlin, C#, Python — run it in BOTH lanes: a hand-rolled reference server and @@ -405,7 +407,7 @@ own those two functions), and ## Orphaned fixtures (tested but not yet documented) The fixtures in the nine corpora mapped above (metamodel 364 + yaml 16 + verify 31 -+ render 15 + persistence 39 + api-contract 73 + source-resolution 25 + scope 10 + ++ render 15 + persistence 39 + api-contract 78 + source-resolution 25 + scope 10 + dependency 23) each map to a feature doc. None are orphaned today. The remaining corpora in the totals table gate tooling contracts (registry manifests, provider composition, agent context, docs emit) rather than user-facing metamodel behaviour, diff --git a/docs/features/api-contract.md b/docs/features/api-contract.md index 69b6e0d11..bb10d037a 100644 --- a/docs/features/api-contract.md +++ b/docs/features/api-contract.md @@ -425,21 +425,23 @@ The surface: on the collection, `PATCH` / `PUT` / `DELETE` on the item. 405 rather than 404 because the resource plainly exists: the same path answers `GET`. `message` is free prose and is not part of the contract. -- A **keyless** projection mounts no `/{id}` route at all, so it refuses only the - collection verb — refusing an item verb would advertise an address the port never - serves. What counts as keyless differs by port, and the corpus does not gate it: - - | Port | Item routes are mounted when | - |---|---| - | C#, Java, Kotlin | the projection declares or inherits a **single-column** `identity.primary` | - | TypeScript, Python | it declares or inherits an `identity.primary` (a composite one binds its first field), **or** it declares none and has a field named `id` | - - So a projection with an `id` field and no declared identity has item routes in - TypeScript and Python and none in the other three. Until FR-044, TypeScript and Python - mounted the item routes for **every** read-only projection, including one with no - identity and no `id` field, where they could not address a row (TypeScript answered the - view's first row). That one shape lost its `/{id}` routes, its by-id query and, in - TypeScript, its detail hook; every other projection is unchanged. +- A **keyless** projection, one that declares and inherits no `identity.primary`, mounts no + `/{id}` route of any verb in any port, so it refuses only the collection verb — + refusing an item verb would advertise an address the port never serves. A field that + happens to be named `id` is a convention, not a key: a projection with an `id` field and + no declared identity is keyless. `GET //1` on one answers the framework's own + `404`, never a row. A projection with a declared identity addresses a row by the field + that identity names, which need not be called `id` and which the view's column must + match: `GET //{key}` answers that row, or the `404 {"error": "not_found"}` + envelope. One difference remains, and the corpus does not gate it: C#, Java and Kotlin + mount item routes only for a **single-column** identity, while TypeScript and Python + bind a composite one to its first field. +- Filters apply to a projection's `field.decimal` and `field.float` fields as they do to + an entity's. A decimal's wire spelling is port-specific and is not asserted. +- **The api docs list the same routes.** A projection's api page documents `GET `, + and `GET /{id}` only when the item route exists. The `405` refusals are not + operations a caller can use, so no write verb is documented. Pinned in every port by + `projection/docs-routes.json`. Every port mounts those refusals **explicitly**. Left to the framework, ASP.NET and Spring each answer an unmatched method on a matched path with an empty-bodied 405 and @@ -472,7 +474,7 @@ entity's. No request parameter picks dimensions, measures or a grain. No port ge client hook, grid or form for a report yet. Gated by [`fixtures/api-contract-conformance/report/`](../../fixtures/api-contract-conformance/report/) -(12 scenarios), **generated lane only, on all five ports**, for the reason `projection/` +(13 scenarios), **generated lane only, on all five ports**, for the reason `projection/` gives. The columns, their types and the per-port generated files are in [reporting.md](reporting.md#how-a-report-is-served). diff --git a/docs/features/reporting.md b/docs/features/reporting.md index b4cf85313..6920b65a4 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -442,17 +442,17 @@ the Monday boundary, an hour bucket, a relative window). The derived columns are by every port. The SQL is produced by TypeScript only, so the other ports read the view the TypeScript migrate engine produced and never lower a report themselves. -The REST surface is gated by twelve scenarios under +The REST surface is gated by thirteen scenarios under [`fixtures/api-contract-conformance/report/`](../../fixtures/api-contract-conformance/report/), run in the **generated lane on all five ports**: list (a dimension with a segment-scoped sum that is null for one group), a time dimension at a grain (`YYYY-MM-DD`), a no-dimension totals report with the `withCount` envelope, a filter on a dimension and on a measure, a sort -on a measure, paging over groups, the three field-naming `400` envelopes, `405` on `POST`, -and `404` on every verb at `/{id}`. The corpus model carries one sourceless report, so a port -that serves every report it finds fails. No scenario asserts a decimal's spelling or a -timestamp literal. TypeScript and C# run the scenarios against the real views on Postgres; -Java, Kotlin and Python serve seeded rows behind their repository seam, and a TypeScript test -holds those rows equal to what the views return. +on a measure and an enum dimension, paging over groups, the three field-naming `400` envelopes, +`405` on `POST`, and `404` on every verb at `/{id}`. The corpus model carries one sourceless +report, so a port that serves every report it finds fails. No scenario asserts a decimal's +spelling or a timestamp literal. TypeScript and C# run the scenarios against the real views on +Postgres; Java, Kotlin and Python serve seeded rows behind their repository seam, and a +TypeScript test holds those rows equal to what the views return. ## The rules the loader enforces @@ -561,17 +561,22 @@ Serving reports added no vocabulary (`metamodelVersion` stays 1.1). The same cha generated output for models that declare no report. Each is in the [CHANGELOG](../../CHANGELOG.md) with the shape it affects: -- **TypeScript and Python: a read-only projection with no declared identity and no field named - `id`** no longer gets `/{id}` routes, a by-id query (`find…ById` / `find_by_id`) or, in - TypeScript, a detail hook. That surface could not address a row. A projection with a declared - identity, or with an `id` field and no declared identity, is unchanged. C#, Java and Kotlin - are unchanged and stricter: they mount `/{id}` only for a declared single-column identity. +- **TypeScript and Python: a read-only projection with no declared identity** (even one with a + field named `id`) no longer gets `/{id}` routes, a by-id query (`find…ById` / `find_by_id`) or, + in TypeScript, a detail hook. A field named `id` is a convention, not a key, so the surface + could not address a row by anything declared. A projection with a declared identity is + unchanged. C#, Java and Kotlin already behaved this way, so all five ports now mount `/{id}` + for exactly the projections that declare one. - **TypeScript: a `field.decimal` in a view read schema** (a projection's or a report's) is `z.string()`, not `z.number()`. The value read from the view was always a string. - **TypeScript API docs: a read-only object's page documents only what is generated.** A read-only projection's page no longer lists create, update or delete functions, write verbs or Insert/Update schemas, and a keyless one no longer lists `/:id` or the by-id function. `meta verify --docs` reports the page as stale until you regenerate. +- **Java and Kotlin API docs: a read-only projection's page lists the reads only.** It also + listed `POST`, `PATCH`, `PUT` and `DELETE`, each described as a `405` refusal. Those are + refusals, not operations, and no other port documented them, so the page now lists `GET ` + and `GET /{id}` when the projection has an item route. Regenerate the docs to pick it up. - **Python API docs: a read-only projection's page gains its read surface.** It listed the model alone; it now also lists the repository Protocol, `GET `, `GET /{id}` when the projection has an item route, and the filter allowlist, and never a write verb. diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index c86e1f278..5bc987503 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -113,8 +113,10 @@ The codegen emits: (`MapGet` list, `MapPost` answering 405, no `{id}` route) and `FilterAllowlist.g.cs`, with every derived field that has filter operators filterable and sortable (on a report an enum dimension sorts too; an entity's enum field - does not). No names artifact. See - [reporting](../features/reporting.md#how-a-report-is-served). + does not). No names artifact. An enum dimension reaches the wire as its string symbol only when + the host serializes enums as strings (`ConfigureHttpJsonOptions` with a + `JsonStringEnumConverter`): the routes return the row object and the host owns serialization. + See [reporting](../features/reporting.md#how-a-report-is-served). - `AppDbContext.g.cs` — `DbSet`, projection `.ToView()`, `@storage` owned types via `OwnsOne` (single) / `OwnsMany(...).ToJson(...)` (`@isArray` array-of-VO), enum-as-string via `HasConversion()`. diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/python.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/python.md index 520f5c33e..248ffe38b 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/python.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/python.md @@ -118,10 +118,9 @@ Its REST surface is generated and READ-ONLY (F22): GET list + GET by id, the sam projection's OWN declared field set, and `POST` / `PATCH` / `PUT` / `DELETE` each answering `405 {"error": "method_not_allowed"}` — 405 and not 404 because the same path answers GET. A KEYLESS projection mounts no `/{id}` route at all, so it refuses only -the collection verb, and its repository Protocol has no `find_by_id`. In Python keyless -means no `identity.primary` AND no field named `id`: a projection with an `id` field and -no declared identity keeps its item routes (C#, Java and Kotlin are stricter and need a -declared single-column identity). +the collection verb, and its repository Protocol has no `find_by_id`. Keyless means no +declared `identity.primary`: a field that is merely named `id` is a convention, not a key, so +a projection with an `id` field and no declared identity is keyless too (in every port). The read-only router is a separate assembly from the writable one, sharing only the emitters they genuinely have in common; `router_generator` and `filter_allowlist_generator` ask one shared `emits_router()` predicate. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md index b29d1aad8..8048ef9b5 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md @@ -154,10 +154,9 @@ Its REST surface is generated and READ-ONLY (F22): GET list + GET by id, the sam projection's OWN declared field set, and `POST` / `PATCH` / `PUT` / `DELETE` each answering `405 {"error": "method_not_allowed"}` — 405 and not 404 because the same path answers GET. A KEYLESS projection mounts no `/:id` route at all, so it refuses only -the collection verb, and it gets no `find…ById` query and no detail hook. In TypeScript -keyless means no `identity.primary` AND no field named `id`: a projection with an `id` -field and no declared identity keeps its item routes (C#, Java and Kotlin are stricter and -need a declared single-column identity). +the collection verb, and it gets no `find…ById` query and no detail hook. Keyless means no +declared `identity.primary`: a field that is merely named `id` is a convention, not a key, so +a projection with an `id` field and no declared identity is keyless too (in every port). `routesFile()` mounts it through `mountReadOnlyCrudRoutes` from the drizzle-fastify adapter (your `codegen/runtime/` copy once ejected), which is where the refusals live. A `field.decimal` in a view's read schema is `z.string()`: the driver reads `numeric` as diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md index b29d1aad8..8048ef9b5 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md @@ -154,10 +154,9 @@ Its REST surface is generated and READ-ONLY (F22): GET list + GET by id, the sam projection's OWN declared field set, and `POST` / `PATCH` / `PUT` / `DELETE` each answering `405 {"error": "method_not_allowed"}` — 405 and not 404 because the same path answers GET. A KEYLESS projection mounts no `/:id` route at all, so it refuses only -the collection verb, and it gets no `find…ById` query and no detail hook. In TypeScript -keyless means no `identity.primary` AND no field named `id`: a projection with an `id` -field and no declared identity keeps its item routes (C#, Java and Kotlin are stricter and -need a declared single-column identity). +the collection verb, and it gets no `find…ById` query and no detail hook. Keyless means no +declared `identity.primary`: a field that is merely named `id` is a convention, not a key, so +a projection with an `id` field and no declared identity is keyless too (in every port). `routesFile()` mounts it through `mountReadOnlyCrudRoutes` from the drizzle-fastify adapter (your `codegen/runtime/` copy once ejected), which is where the refusals live. A `field.decimal` in a view's read schema is `z.string()`: the driver reads `numeric` as diff --git a/fixtures/api-contract-conformance/README.md b/fixtures/api-contract-conformance/README.md index 7fd5eb22c..ed40981d3 100644 --- a/fixtures/api-contract-conformance/README.md +++ b/fixtures/api-contract-conformance/README.md @@ -53,7 +53,7 @@ fixtures/api-contract-conformance/ ``` Beside the 31 core scenarios above sit six **sub-corpora**, each a directory with its -own `meta.json`, `seed.json`, `scenarios/` and `README.md` (73 scenarios in all): +own `meta.json`, `seed.json`, `scenarios/` and `README.md` (78 scenarios in all): | Directory | Scenarios | Gates | Lanes | |---|---|---|---| @@ -61,8 +61,8 @@ own `meta.json`, `seed.json`, `scenarios/` and `README.md` (73 scenarios in all) | `m2m/` | 9 | M:N traversal, and TPH x M:N together | both | | `jsonb/` | 2 | typed value-object columns | both | | `write-through/` | 2 | an entity that writes a table and reads a view | generated only | -| `projection/` | 7 | a read-only `object.projection`: reads served, every write `405` | generated only | -| `report/` | 12 | a view-backed `object.report` (FR-044): list, filter, sort and paging on derived fields, `POST` is `405`, no `/{id}` route. Also carries `schema.postgres.sql`, the TypeScript-produced table and views the C# lane executes | generated only | +| `projection/` | 11 | a read-only `object.projection`: reads served, every write `405`; no declared identity means no `/{id}` route; a key not named `id` addresses the row; decimal and float filters. Its `docs-routes.json` pins the routes each port documents | generated only | +| `report/` | 13 | a view-backed `object.report` (FR-044): list, filter, sort and paging on derived fields, `POST` is `405`, no `/{id}` route. Also carries `schema.postgres.sql`, the TypeScript-produced table and views the C# lane executes | generated only | `meta.json` declares a single canonical `Author` entity in the `acme::blog` package: diff --git a/fixtures/api-contract-conformance/projection/README.md b/fixtures/api-contract-conformance/projection/README.md index 4dbc2c6da..bcbd3d5e1 100644 --- a/fixtures/api-contract-conformance/projection/README.md +++ b/fixtures/api-contract-conformance/projection/README.md @@ -26,6 +26,20 @@ the cross-port contract would drop a capability two ports already shipped. inherits (`identity.primary extends Invoice.pk`). - **`?filter[...]` and `?sort=`** apply, against allowlists generated from the **projection's own** declared field set — not the base entity's. +- **A projection with no declared primary identity has no `/{id}` route**, in any port, + even when it has a field named `id` (`InvoiceStub`). `GET /api/invoice_stubs/1` answers + the framework's own `404` and never a row, and `PATCH` / `PUT` / `DELETE` on it answer the + same `404`: with no item address there is nothing to refuse. The list and the collection + `POST` `405` are unchanged. A field named `id` is a convention, not a key. +- **A declared key need not be called `id`.** `InvoiceLedger` passes `Invoice`'s key through + on a field named `number`, and its view has no `id` column at all. `GET + /api/invoice_ledgers/2` answers that row, an unknown key answers the + `404 {"error": "not_found"}` envelope, and the item write verbs are refused with the `405` + envelope. The identity names its key explicitly (`@fields: number`) because that is the form + every port reads the key from. +- **Filters apply to `field.decimal` and `field.float`.** `InvoiceLedger` carries one of each; + the scenarios assert only how many rows match, because each port spells a decimal its own way. +- **The api docs list the same routes** (`docs-routes.json`, see below). - **A filter error names the field**, exactly as on a writable route (see `docs/features/api-contract.md` → "Error response"). - **Every write verb answers `405` with `{"error": "method_not_allowed"}`** — @@ -41,8 +55,9 @@ the cross-port contract would drop a capability two ports already shipped. ``` projection/ ├── README.md # this file -├── meta.json # writable Invoice + view-only InvoiceSummary projection -├── seed.json # 4 seed Invoice rows (the view derives; it is never seeded) +├── meta.json # writable Invoice + three view-only projections +├── seed.json # 4 seed Invoice rows (the views derive; they are never seeded) +├── docs-routes.json # the routes each projection's api docs page lists, in every port └── scenarios/ ├── list.yaml # GET list ├── get-by-id.yaml # GET by the inherited identity @@ -50,10 +65,20 @@ projection/ ├── filter-eq.yaml # FR-009 filter on a projection field ├── filter-invalid-field.yaml # 400 envelope, naming the field (F20 on the read-only mount) ├── sort-desc.yaml # ?sort on a projection field - └── write-verbs-405.yaml # POST/PATCH/PUT/DELETE → 405 + envelope + ├── write-verbs-405.yaml # POST/PATCH/PUT/DELETE → 405 + envelope + ├── keyless-no-item-route.yaml # no declared identity (even with an `id` field) → no /{id} route + ├── keyed-by-non-id-field.yaml # key on `number`, view with no `id` column → that row, 404 envelope + ├── filter-decimal.yaml # FR-009 filter on a field.decimal + └── filter-float.yaml # FR-009 filter on a field.float ``` -`seed.json` seeds the base `invoices` table. The view is created by each port's +The three projections: `InvoiceSummary` (key passed through from `Invoice` on `id`), +`InvoiceLedger` (key on `number`; also the decimal and float fields) and `InvoiceStub` (no +identity). `docs-routes.json` is read by a per-port docs test, not by the scenario runners: it +lists, for each projection, the `GET` routes its api docs page documents (no write verb, and no +`/{id}` for a keyless one), spelled without the api prefix and with `{id}`. + +`seed.json` seeds the base `invoices` table. The views are created by each port's harness after the table, because the ports do not agree on physical column spelling: TypeScript and Kotlin default to snake_case (`amount_cents`) while C#, Java and Python default to literal (`amountCents`). A view's column aliases have @@ -76,11 +101,22 @@ nothing about the emitted artifact, which is the thing that was missing. | Port | Generated lane | Note | |---|---|---| -| TypeScript | **wired, green (7/7)** | `test/api-contract-projection.test.ts` | -| Python | **wired, green (7/7)** | `tests/integration/test_api_contract_projection.py` | -| C# | **wired, green (7/7)** | `MetaObjects.IntegrationTests/Api/ApiContractProjectionConformanceTest.cs` | -| Java | **wired, green (7/7)** | `integration-tests/.../ProjectionGeneratedApiContractConformanceTest.java` | -| Kotlin | **wired, green (7/7)** | `integration-tests-kotlin/.../ProjectionGeneratedApiContractConformanceTest.kt` | +| TypeScript | **wired, green (11/11)** | `test/api-contract-projection.test.ts` | +| Python | **wired, green (11/11)** | `tests/integration/test_api_contract_projection.py` | +| C# | **wired, green (11/11)** | `MetaObjects.IntegrationTests/Api/ApiContractProjectionConformanceTest.cs` | +| Java | **wired, green (11/11)** | `integration-tests/.../ProjectionGeneratedApiContractConformanceTest.java` | +| Kotlin | **wired, green (11/11)** | `integration-tests-kotlin/.../ProjectionGeneratedApiContractConformanceTest.kt` | + +The docs half (`docs-routes.json`) runs in each port's unit-test project, over the same +`meta.json`: + +| Port | Docs test | +|---|---| +| TypeScript | `server/typescript/packages/codegen-ts/test/projection-docs-routes.test.ts` | +| C# | `server/csharp/MetaObjects.Codegen.Tests/ProjectionDocsRoutesTests.cs` | +| Java | `server/java/codegen-spring/src/test/java/com/metaobjects/generator/apidocs/ProjectionDocsRoutesTest.java` | +| Kotlin | `server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/ProjectionDocsRoutesKtTest.kt` | +| Python | `server/python/tests/codegen/test_projection_docs_routes.py` | All five ports are wired. The corpus was committed ahead of four of them deliberately: it is the contract they were changed to satisfy, and it had diff --git a/fixtures/api-contract-conformance/projection/docs-routes.json b/fixtures/api-contract-conformance/projection/docs-routes.json new file mode 100644 index 000000000..52cf27a20 --- /dev/null +++ b/fixtures/api-contract-conformance/projection/docs-routes.json @@ -0,0 +1,8 @@ +{ + "description": "The REST routes the api docs page of each read-only projection in meta.json lists. Spelled without a leading slash or api prefix, with {id} for the item parameter; every port normalizes its own spelling to this one. A keyed projection lists its two read routes, a keyless one its list route only, and no unit lists a write verb.", + "units": { + "InvoiceSummary": ["GET invoice_summaries", "GET invoice_summaries/{id}"], + "InvoiceLedger": ["GET invoice_ledgers", "GET invoice_ledgers/{id}"], + "InvoiceStub": ["GET invoice_stubs"] + } +} diff --git a/fixtures/api-contract-conformance/projection/meta.json b/fixtures/api-contract-conformance/projection/meta.json index 87dcfdcf1..d8f7361de 100644 --- a/fixtures/api-contract-conformance/projection/meta.json +++ b/fixtures/api-contract-conformance/projection/meta.json @@ -10,6 +10,8 @@ { "field.string": { "name": "reference", "@required": true, "@maxLength": 40 } }, { "field.string": { "name": "status", "@required": true, "@maxLength": 20 } }, { "field.long": { "name": "amountCents", "@required": true } }, + { "field.decimal": { "name": "discount", "@precision": 10, "@scale": 2 } }, + { "field.float": { "name": "weight" } }, { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } } ] }}, @@ -23,6 +25,25 @@ { "field.long": { "name": "amountCents", "extends": "Invoice.amountCents", "@filterable": true, "@sortable": true } }, { "identity.primary": { "name": "pk", "extends": "Invoice.pk" } } ] + }}, + { "object.projection": { + "name": "InvoiceLedger", + "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_invoice_ledger" } }, + { "field.long": { "name": "number", "extends": "Invoice.id", "@filterable": true, "@sortable": true } }, + { "field.string": { "name": "reference", "extends": "Invoice.reference", "@filterable": true, "@sortable": true } }, + { "field.decimal": { "name": "discount", "extends": "Invoice.discount", "@filterable": true, "@sortable": true } }, + { "field.float": { "name": "weight", "extends": "Invoice.weight", "@filterable": true, "@sortable": true } }, + { "identity.primary": { "name": "pk", "extends": "Invoice.pk", "@fields": "number" } } + ] + }}, + { "object.projection": { + "name": "InvoiceStub", + "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_invoice_stub" } }, + { "field.long": { "name": "id", "extends": "Invoice.id", "@filterable": true, "@sortable": true } }, + { "field.string": { "name": "reference", "extends": "Invoice.reference", "@filterable": true, "@sortable": true } } + ] }} ] } diff --git a/fixtures/api-contract-conformance/projection/scenarios/filter-decimal.yaml b/fixtures/api-contract-conformance/projection/scenarios/filter-decimal.yaml new file mode 100644 index 000000000..20458ca9e --- /dev/null +++ b/fixtures/api-contract-conformance/projection/scenarios/filter-decimal.yaml @@ -0,0 +1,20 @@ +name: projection-filter-decimal +description: > + The FR-009 filter grammar applies to a `field.decimal` of a projection, in every + port. The operand is parsed as a decimal and compared as one (Kotlin answered a + 500 on this before the decimal and float coercers were split). Only how many rows + match is asserted, because each port spells a decimal its own way on the wire. + Seed discounts: 7.25, 12.5, 3.0, 1.0. +requests: + - id: gt + method: GET + path: "/api/invoice_ledgers?filter[discount][gt]=10" + expect: { status: 200, body: { length: 1 } } + - id: lte + method: GET + path: "/api/invoice_ledgers?filter[discount][lte]=7.25" + expect: { status: 200, body: { length: 3 } } + - id: eq + method: GET + path: "/api/invoice_ledgers?filter[discount][eq]=12.5" + expect: { status: 200, body: { length: 1 } } diff --git a/fixtures/api-contract-conformance/projection/scenarios/filter-float.yaml b/fixtures/api-contract-conformance/projection/scenarios/filter-float.yaml new file mode 100644 index 000000000..f4d00afce --- /dev/null +++ b/fixtures/api-contract-conformance/projection/scenarios/filter-float.yaml @@ -0,0 +1,17 @@ +name: projection-filter-float +description: > + The FR-009 filter grammar applies to a `field.float` of a projection, in every + port. Seed weights (all exact in binary, so `eq` is safe): 0.5, 1.5, 2.5, 0.25. +requests: + - id: gte + method: GET + path: "/api/invoice_ledgers?filter[weight][gte]=1.5" + expect: { status: 200, body: { length: 2 } } + - id: lt + method: GET + path: "/api/invoice_ledgers?filter[weight][lt]=0.5" + expect: { status: 200, body: { length: 1 } } + - id: eq + method: GET + path: "/api/invoice_ledgers?filter[weight][eq]=0.25" + expect: { status: 200, body: { length: 1 } } diff --git a/fixtures/api-contract-conformance/projection/scenarios/keyed-by-non-id-field.yaml b/fixtures/api-contract-conformance/projection/scenarios/keyed-by-non-id-field.yaml new file mode 100644 index 000000000..5d1537c0a --- /dev/null +++ b/fixtures/api-contract-conformance/projection/scenarios/keyed-by-non-id-field.yaml @@ -0,0 +1,37 @@ +name: projection-keyed-by-non-id-field +description: > + InvoiceLedger passes the Invoice key through on a field named `number`, and its view + has NO `id` column. The item route addresses the declared key field, not a column + called `id`: a known key answers its row, an unknown one answers the 404 envelope (never + the view's first row, which is what a mount that looked for a missing `id` column + answered). The item write verbs are still refused with the 405 envelope. +requests: + - id: get-known + method: GET + path: /api/invoice_ledgers/2 + expect: + status: 200 + body: + row: { number: 2 } + - id: get-unknown + method: GET + path: /api/invoice_ledgers/999 + expect: + status: 404 + body: + error: "not_found" + - id: patch-item + method: PATCH + path: /api/invoice_ledgers/2 + body: { weight: 9 } + expect: + status: 405 + body: + error: "method_not_allowed" + - id: delete-item + method: DELETE + path: /api/invoice_ledgers/2 + expect: + status: 405 + body: + error: "method_not_allowed" diff --git a/fixtures/api-contract-conformance/projection/scenarios/keyless-no-item-route.yaml b/fixtures/api-contract-conformance/projection/scenarios/keyless-no-item-route.yaml new file mode 100644 index 000000000..54d35fd24 --- /dev/null +++ b/fixtures/api-contract-conformance/projection/scenarios/keyless-no-item-route.yaml @@ -0,0 +1,23 @@ +name: projection-keyless-no-item-route +description: > + A projection that declares no primary identity has no item address, so no /{id} + route of any verb is mounted — even when the view happens to carry a column named + `id`. InvoiceStub is that shape: no identity.primary, one field called id. The + status is the contract; the body is the framework's own 404 and is not asserted. + In particular a GET must never answer a row (it answered the view's first row in + TypeScript, and bound an `id: int` to nothing in Python, before this was gated). + The list still serves, and the collection POST still answers the 405 envelope. +requests: + - { id: list, method: GET, path: "/api/invoice_stubs?sort=id:asc", expect: { status: 200, body: { ids: [1, 2, 3, 4] } } } + - { id: get-item, method: GET, path: /api/invoice_stubs/1, expect: { status: 404 } } + - { id: patch-item, method: PATCH, path: /api/invoice_stubs/1, body: { reference: "X" }, expect: { status: 404 } } + - { id: put-item, method: PUT, path: /api/invoice_stubs/1, body: { id: 1, reference: "X" }, expect: { status: 404 } } + - { id: delete-item, method: DELETE, path: /api/invoice_stubs/1, expect: { status: 404 } } + - id: post-collection + method: POST + path: /api/invoice_stubs + body: { reference: "X" } + expect: + status: 405 + body: + error: "method_not_allowed" diff --git a/fixtures/api-contract-conformance/projection/seed.json b/fixtures/api-contract-conformance/projection/seed.json index 2179674ee..8517ce665 100644 --- a/fixtures/api-contract-conformance/projection/seed.json +++ b/fixtures/api-contract-conformance/projection/seed.json @@ -1,8 +1,8 @@ { "invoices": [ - { "id": 1, "reference": "INV-1001", "status": "PAID", "amountCents": 125000 }, - { "id": 2, "reference": "INV-1002", "status": "OPEN", "amountCents": 40000 }, - { "id": 3, "reference": "INV-1003", "status": "OPEN", "amountCents": 90500 }, - { "id": 4, "reference": "INV-1004", "status": "VOID", "amountCents": 0 } + { "id": 1, "reference": "INV-1001", "status": "PAID", "amountCents": 125000, "discount": 7.25, "weight": 0.5 }, + { "id": 2, "reference": "INV-1002", "status": "OPEN", "amountCents": 40000, "discount": 12.5, "weight": 1.5 }, + { "id": 3, "reference": "INV-1003", "status": "OPEN", "amountCents": 90500, "discount": 3.0, "weight": 2.5 }, + { "id": 4, "reference": "INV-1004", "status": "VOID", "amountCents": 0, "discount": 1.0, "weight": 0.25 } ] } diff --git a/fixtures/api-contract-conformance/report/README.md b/fixtures/api-contract-conformance/report/README.md index bfb55ce87..599094661 100644 --- a/fixtures/api-contract-conformance/report/README.md +++ b/fixtures/api-contract-conformance/report/README.md @@ -34,6 +34,12 @@ object uses (`InvoicesByMonth` is `/api/invoices_by_months`). `null` for a group with no row in the segment. - **No typed client hook and no UI tier is generated for a report** (no TanStack hook, grid, grid hook or form). The read route and its row type are the whole surface. +- **An enum dimension sorts and filters like any other.** `Invoice.status` is a `field.enum` + (`OPEN`, `PAID`, `VOID`, declared in alphabetical order so the stored text and the declared + order agree), so `InvoiceStatusTotals.status` is an enum dimension: `?sort=status:asc|desc` is + accepted in every port and orders by the stored value, and `?filter[status][eq]=OPEN` matches + it. It reaches the wire as its string symbol; in C# that is host configuration (a + `JsonStringEnumConverter`), as for any enum the routes return. - **A decimal's spelling is not asserted.** `paidShare` is a ratio, so it is a decimal, and each port spells a decimal its own way. The scenarios that touch it assert only how many rows match. @@ -74,6 +80,7 @@ report/ ├── filter-invalid-field.yaml # 400 envelope, naming the field ├── filter-invalid-op.yaml # 400 envelope, naming the field ├── sort-desc-on-measure.yaml # ?sort on a measure + ├── sort-enum-dimension.yaml # ?sort on an enum dimension, ascending and descending ├── sort-invalid.yaml # 400 envelope, naming the field ├── pagination.yaml # limit / offset / withCount count groups ├── write-verbs-405.yaml # POST on the collection -> 405 + envelope @@ -110,11 +117,11 @@ hand-rolled reference server would answer every scenario by construction. | Port | Generated lane | Note | |---|---|---| -| TypeScript | wired | `server/typescript/packages/integration-tests/test/api-contract-report.test.ts` (12 scenarios + a seed-vs-view check). Full stack: generated Fastify routes over the real views on Testcontainers Postgres | +| TypeScript | wired | `server/typescript/packages/integration-tests/test/api-contract-report.test.ts` (13 scenarios + a seed-vs-view check). Full stack: generated Fastify routes over the real views on Testcontainers Postgres | | C# | wired | `server/csharp/MetaObjects.IntegrationTests/Api/ApiContractReportConformanceTest.cs`. Full stack: generated routes and EF Core over `schema.postgres.sql` on Testcontainers Postgres | -| Java | wired | `server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java` (12 scenarios + a scenario-count check). Generated controllers behind an in-memory repository seeded from `reports` | -| Kotlin | wired | `server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt` (12 scenarios, the count check, and a check that the sourceless report generated nothing). Generated controllers over the generated Exposed table objects, seeded from `reports` | -| Python | wired | `server/python/tests/integration/test_api_contract_report.py` (12 scenarios, a check that exactly the served reports are generated, and `/api/invoice_days` is `404`). Generated routers behind in-memory repositories seeded from `reports` | +| Java | wired | `server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java` (13 scenarios + a scenario-count check). Generated controllers behind an in-memory repository seeded from `reports` | +| Kotlin | wired | `server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt` (13 scenarios, the count check, and a check that the sourceless report generated nothing). Generated controllers over the generated Exposed table objects, seeded from `reports` | +| Python | wired | `server/python/tests/integration/test_api_contract_report.py` (13 scenarios, a check that exactly the served reports are generated, and `/api/invoice_days` is `404`). Generated routers behind in-memory repositories seeded from `reports` | The scenarios use only assertion keys every runner already has (`equals`, `length`, `envelope`, `error` with `field`, and a status with no `body`). diff --git a/fixtures/api-contract-conformance/report/meta.json b/fixtures/api-contract-conformance/report/meta.json index 4cd743d60..66bc199b2 100644 --- a/fixtures/api-contract-conformance/report/meta.json +++ b/fixtures/api-contract-conformance/report/meta.json @@ -8,7 +8,7 @@ { "source.rdb": { "@table": "invoices" } }, { "field.long": { "name": "id" } }, { "field.string": { "name": "reference", "@required": true, "@maxLength": 40 } }, - { "field.string": { "name": "status", "@required": true, "@maxLength": 20 } }, + { "field.enum": { "name": "status", "@required": true, "@values": ["OPEN", "PAID", "VOID"] } }, { "field.long": { "name": "amountCents", "@required": true } }, { "field.date": { "name": "issuedOn", "@required": true } }, { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } }, diff --git a/fixtures/api-contract-conformance/report/scenarios/sort-enum-dimension.yaml b/fixtures/api-contract-conformance/report/scenarios/sort-enum-dimension.yaml new file mode 100644 index 000000000..73ef37808 --- /dev/null +++ b/fixtures/api-contract-conformance/report/scenarios/sort-enum-dimension.yaml @@ -0,0 +1,28 @@ +name: report-sort-enum-dimension +description: > + ?sort=status — `status` is a field.enum on Invoice, so the report's `status` dimension + is an enum. An enum dimension is sortable like any other derived field, in every port: + it is accepted (never `invalid_sort`) and orders by the stored value. The members are + declared in alphabetical order (OPEN, PAID, VOID), so the stored text and the declared + order agree and the assertion does not depend on which a port sorts by. +requests: + - id: asc + method: GET + path: /api/invoice_status_totals?sort=status:asc + expect: + status: 200 + body: + equals: + - { status: "OPEN", invoices: 2, totalCents: 130500, paidCents: null } + - { status: "PAID", invoices: 2, totalCents: 185000, paidCents: 185000 } + - { status: "VOID", invoices: 1, totalCents: 0, paidCents: null } + - id: desc + method: GET + path: /api/invoice_status_totals?sort=status:desc + expect: + status: 200 + body: + equals: + - { status: "VOID", invoices: 1, totalCents: 0, paidCents: null } + - { status: "PAID", invoices: 2, totalCents: 185000, paidCents: 185000 } + - { status: "OPEN", invoices: 2, totalCents: 130500, paidCents: null } diff --git a/fixtures/api-contract-conformance/report/schema.postgres.sql b/fixtures/api-contract-conformance/report/schema.postgres.sql index 49589fee1..c1f94e6e8 100644 --- a/fixtures/api-contract-conformance/report/schema.postgres.sql +++ b/fixtures/api-contract-conformance/report/schema.postgres.sql @@ -6,10 +6,11 @@ CREATE TABLE "invoices" ( "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, "reference" VARCHAR(40) NOT NULL, - "status" VARCHAR(20) NOT NULL, + "status" TEXT NOT NULL, "amountCents" BIGINT NOT NULL, "issuedOn" DATE NOT NULL, - CONSTRAINT "invoices_pkey" PRIMARY KEY ("id") + CONSTRAINT "invoices_pkey" PRIMARY KEY ("id"), + CONSTRAINT "invoices_status_chk" CHECK ("status" IN ('OPEN', 'PAID', 'VOID')) ); CREATE VIEW "v_invoice_status_totals" AS diff --git a/server/csharp/MetaObjects.Codegen.Tests/ProjectionDocsRoutesTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ProjectionDocsRoutesTests.cs new file mode 100644 index 000000000..938e2c5f5 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/ProjectionDocsRoutesTests.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using MetaObjects.Codegen.ApiDocs; +using MetaObjects.Loader; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +/// +/// The api-contract projection/ corpus, docs half. The REST routes a read-only +/// projection's api page lists are exactly the routes its generated surface answers with a +/// row. Every port runs this assertion over the same model and the same expected set +/// (fixtures/api-contract-conformance/projection/docs-routes.json): +/// +/// a projection with a declared identity lists GET <path> and GET <path>/{id}; +/// one with none lists GET <path> alone, even when it has a field named id; +/// no unit lists a write verb. +/// +/// The booted-server half of the same contract is the corpus scenarios themselves +/// (MetaObjects.IntegrationTests/Api/ApiContractProjectionConformanceTest). +/// +public sealed class ProjectionDocsRoutesTests +{ + private static string Corpus() + { + var dir = AppContext.BaseDirectory; + while (dir is not null && + !(Directory.Exists(Path.Combine(dir, "fixtures")) && Directory.Exists(Path.Combine(dir, "server")))) + dir = Directory.GetParent(dir)?.FullName; + if (dir is null) + throw new InvalidOperationException("could not locate the repo root from " + AppContext.BaseDirectory); + return Path.Combine(dir, "fixtures", "api-contract-conformance", "projection"); + } + + /// The spelling every port's expected set uses: no leading slash or api prefix, {id}. + private static string Normalize(string symbol) + { + var space = symbol.IndexOf(' '); + var path = Regex.Replace(symbol[(space + 1)..], "^/?(?:api/)?", ""); + return symbol[..space] + " " + path; + } + + [Fact] + public void Each_projection_documents_exactly_the_routes_it_mounts() + { + var corpus = Corpus(); + using var expectedDoc = JsonDocument.Parse(File.ReadAllText(Path.Combine(corpus, "docs-routes.json"))); + var result = new MetaDataLoader().Load([new FileSource(Path.Combine(corpus, "meta.json"))]); + Assert.Empty(result.Errors); + + var model = new CSharpApiModelBuilder(new GenConfig { OutDir = "/unused", Namespace = "Acme" }) + .Build(result.Root, "projection-docs"); + + foreach (var unitProp in expectedDoc.RootElement.GetProperty("units").EnumerateObject()) + { + var expected = unitProp.Value.EnumerateArray().Select(e => e.GetString()!).OrderBy(s => s).ToList(); + var unit = model.Units.Single(u => u.Node == unitProp.Name); + var documented = unit.Symbols + .Where(s => s.Kind == ApiSymbolKind.Rest) + .Select(s => Normalize(s.Name)) + .OrderBy(s => s) + .ToList(); + Assert.True(expected.SequenceEqual(documented), + $"{unitProp.Name}: expected [{string.Join(", ", expected)}] but the api page lists [{string.Join(", ", documented)}]"); + } + } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionFixture.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionFixture.cs index 39b97a375..7201c5862 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionFixture.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionFixture.cs @@ -24,6 +24,8 @@ internal static class ProjectionFixture "reference" VARCHAR(40) NOT NULL, "status" VARCHAR(20) NOT NULL, "amountCents" BIGINT NOT NULL, + "discount" NUMERIC(10,2), + "weight" REAL, CONSTRAINT "invoices_pkey" PRIMARY KEY ("id") ); CREATE VIEW "v_invoice_summary" AS @@ -32,9 +34,21 @@ CREATE VIEW "v_invoice_summary" AS i."status" AS "status", i."amountCents" AS "amountCents" FROM "invoices" i; + -- InvoiceLedger: its key is the field `number`; the view has NO id column. + CREATE VIEW "v_invoice_ledger" AS + SELECT i."id" AS "number", + i."reference" AS "reference", + i."discount" AS "discount", + i."weight" AS "weight" + FROM "invoices" i; + -- InvoiceStub: no declared identity; the view carries an id column all the same. + CREATE VIEW "v_invoice_stub" AS + SELECT i."id" AS "id", + i."reference" AS "reference" + FROM "invoices" i; """; - private static readonly string[] InvoiceCols = { "id", "reference", "status", "amountCents" }; + private static readonly string[] InvoiceCols = { "id", "reference", "status", "amountCents", "discount", "weight" }; /// Create the base table + the read-only view on a fresh container. public static async Task ProvisionSchemaAsync(string connString) @@ -71,6 +85,8 @@ public static async Task ApplySeedAsync(string connString, string seedPath) { var v = row[InvoiceCols[i]]; object val = v is null ? DBNull.Value + : InvoiceCols[i] == "discount" ? v.GetValue() + : InvoiceCols[i] == "weight" ? v.GetValue() : v.GetValueKind() == JsonValueKind.Number ? v.GetValue() : v.GetValue(); ins.Parameters.AddWithValue("@p" + i, val); diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs index 12b4d249f..3bbd5df20 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ProjectionGeneratedServerFactory.cs @@ -102,9 +102,10 @@ private static (Assembly Assembly, IReadOnlyList RoutedNames) CompileGen .Where(o => RoutesGenerator.AppliesTo(o, root)) .Select(o => CSharpNaming.Pascal(o.Name)) .ToList(); - if (routedNames.Count != 2) + if (routedNames.Count != 4) throw new InvalidOperationException( - "expected routes for Invoice AND InvoiceSummary, got: " + string.Join(", ", routedNames)); + "expected routes for Invoice, InvoiceSummary, InvoiceLedger AND InvoiceStub, got: " + + string.Join(", ", routedNames)); var ctx = new GenContext { diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs index 5fd434b56..57edb9f40 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ReportGeneratedServerFactory.cs @@ -68,6 +68,12 @@ public static async Task StartAsync(PostgresContai var builder = WebApplication.CreateBuilder(); builder.WebHost.UseUrls(baseUrl); builder.Logging.ClearProviders(); + // Host concern, not generator output: the generated routes return the row object and + // the host owns serialization, so an enum dimension (InvoiceStatusTotals.status) + // reaches the wire as its string symbol only because the host asks for it, exactly + // as the TPH lane does for its discriminator. + builder.Services.ConfigureHttpJsonOptions(o => + o.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter())); RegisterGeneratedDbContext(builder.Services, dbContextType, pg.ConnectionString); var app = builder.Build(); diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt index 890117192..b1f3b3961 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/apidocs/KotlinApiModelBuilder.kt @@ -314,18 +314,12 @@ class KotlinApiModelBuilder { // an operation of the API. if (obj is ReportReadModel) return // F22 — a read-only projection serves the reads and REFUSES every write verb with - // 405. Documenting it with the writable verb list would be the precise drift this - // builder exists to prevent; and a keyless projection has no /{id} route at all. + // 405. Those refusals are not operations a caller can use, so, like every other port, + // the page lists the reads only: GET list, and GET by id when the projection declares + // an identity. Documenting the writable verb list would be the precise drift this + // builder exists to prevent. if (RestSurfaceGate.isReadOnly(obj)) { - val hasItem = RestSurfaceGate.hasItemRoute(obj) - if (hasItem) rest("GET $base/{id}", "fetch one by id") - val refused = "405 {\"error\": \"method_not_allowed\"} — read-only projection" - rest("POST $base", refused) - if (hasItem) { - rest("PATCH $base/{id}", refused) - rest("PUT $base/{id}", refused) - rest("DELETE $base/{id}", refused) - } + if (RestSurfaceGate.hasItemRoute(obj)) rest("GET $base/{id}", "fetch one by id") return // a projection declares no M:N relationships to traverse } rest("GET $base/{id}", "fetch one by id") diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/KotlinApiDocsAccuracyKtTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/KotlinApiDocsAccuracyKtTest.kt index 80e7218dd..b8e6f816b 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/KotlinApiDocsAccuracyKtTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/KotlinApiDocsAccuracyKtTest.kt @@ -336,10 +336,9 @@ class KotlinApiDocsAccuracyKtTest { // ...and the documented REST surface must be the READ-ONLY one. val rest = sales.symbols.filter { it.kind == ApiSymbolKind.REST } assertTrue(rest.any { it.name == "GET /api/sales_reports" }, "list route documented: $rest") - assertTrue( - rest.filterNot { it.name.startsWith("GET ") }.all { it.usage.contains("method_not_allowed") }, - "every write verb documented as refused: $rest", - ) + // The controller answers every write verb with 405, which is a refusal and not an + // operation: no write verb is documented (the same rule as every other port). + assertTrue(rest.all { it.name.startsWith("GET ") }, "no write verb documented: $rest") } @Test diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/ProjectionDocsRoutesKtTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/ProjectionDocsRoutesKtTest.kt new file mode 100644 index 000000000..098e3fd84 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/codegen/kotlin/apidocs/ProjectionDocsRoutesKtTest.kt @@ -0,0 +1,63 @@ +package com.metaobjects.codegen.kotlin.apidocs + +import com.fasterxml.jackson.databind.ObjectMapper +import com.metaobjects.generator.kotlin.apidocs.ApiSymbolKind +import com.metaobjects.generator.kotlin.apidocs.KotlinApiModelBuilder +import com.metaobjects.metadata.ktx.loadString +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The api-contract `projection/` corpus, docs half. The REST routes a read-only projection's api + * page lists are exactly the routes its generated surface answers with a row. Every port runs this + * assertion over the same model and the same expected set + * (`fixtures/api-contract-conformance/projection/docs-routes.json`): + * + * - a projection with a declared identity lists `GET ` and `GET /{id}`; + * - one with none lists `GET ` alone, even when it has a field named `id`; + * - no unit lists a write verb. + * + * The booted-server half of the same contract is the corpus scenarios themselves + * (`ProjectionGeneratedApiContractConformanceTest` in `integration-tests-kotlin`). + */ +class ProjectionDocsRoutesKtTest { + + /** Walk up to the repo root (the dir holding both `fixtures/` and `server/`). */ + private fun corpus(): Path { + var p: Path? = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize() + while (p != null) { + if (Files.isDirectory(p.resolve("fixtures")) && Files.isDirectory(p.resolve("server"))) { + return p.resolve("fixtures/api-contract-conformance/projection") + } + p = p.parent + } + throw IllegalStateException("could not locate the repo root from user.dir") + } + + /** The spelling every port's expected set uses: no leading slash or api prefix, `{id}`. */ + private fun normalize(symbol: String): String { + val space = symbol.indexOf(' ') + return symbol.substring(0, space) + " " + symbol.substring(space + 1).replaceFirst(Regex("^/?(?:api/)?"), "") + } + + @Test + fun `each projection documents exactly the routes it mounts`() { + val corpus = corpus() + val expected = ObjectMapper().readTree(Files.readString(corpus.resolve("docs-routes.json"))).get("units") + val loader = loadString("projectionDocs", Files.readString(corpus.resolve("meta.json"))) + + val model = KotlinApiModelBuilder().build(loader, "projection-docs") + + for ((node, routes) in expected.fields().asSequence().map { it.key to it.value }) { + val want = routes.map { it.asText() }.sorted() + val unit = model.units.single { it.node == node } + val documented = unit.symbols + .filter { it.kind == ApiSymbolKind.REST } + .map { normalize(it.name) } + .sorted() + assertEquals(want, documented, "$node: the routes its api page lists") + } + } +} diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java index dda1ba199..23a735822 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/apidocs/JavaApiModelBuilder.java @@ -227,18 +227,14 @@ private void addRestSymbols(List symbols, MetaObject obj, if (GeneratorUtil.isReport(obj)) return; // F22 — a read-only projection's controller serves the reads and REFUSES every write - // verb with 405. Documenting it with the writable verb list would be the precise - // drift this builder exists to prevent: the emitted controller has no create path, - // and a keyless projection has no /{id} route to document at all. + // verb with 405. Those refusals are not operations a caller can use, so, like every + // other port, the page lists the reads only: GET list, and GET by id when the + // projection declares an identity. Documenting the writable verb list would be the + // precise drift this builder exists to prevent: the emitted controller has no create + // path, and a keyless projection has no /{id} route to document at all. if (RestSurfaceGate.isReadOnly(obj)) { - boolean hasItem = RestSurfaceGate.hasItemRoute(obj); - if (hasItem) addRest(symbols, controllerFqn, "GET " + base + "/{id}", "fetch one by id"); - String refused = "405 {\"error\": \"method_not_allowed\"} — read-only projection"; - addRest(symbols, controllerFqn, "POST " + base, refused); - if (hasItem) { - addRest(symbols, controllerFqn, "PATCH " + base + "/{id}", refused); - addRest(symbols, controllerFqn, "PUT " + base + "/{id}", refused); - addRest(symbols, controllerFqn, "DELETE " + base + "/{id}", refused); + if (RestSurfaceGate.hasItemRoute(obj)) { + addRest(symbols, controllerFqn, "GET " + base + "/{id}", "fetch one by id"); } return; // a projection declares no M:N relationships to traverse } diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/apidocs/JavaApiDocsAccuracyTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/apidocs/JavaApiDocsAccuracyTest.java index 04f615b0d..63dd0852f 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/apidocs/JavaApiDocsAccuracyTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/apidocs/JavaApiDocsAccuracyTest.java @@ -320,11 +320,10 @@ public void projectionIsDocumentedAsAReadOnlyRestSurface() { if (sym.kind() == ApiSymbolKind.REST) rest.add(sym.name()); } assertTrue("list route documented: " + rest, rest.contains("GET /api/author_summaries")); - assertTrue("every write verb documented as refused: " + rest, - summary.symbols().stream() - .filter(sym -> sym.kind() == ApiSymbolKind.REST) - .filter(sym -> !sym.name().startsWith("GET ")) - .allMatch(sym -> sym.usage().contains("method_not_allowed"))); + // The controller answers every write verb with 405, which is a refusal and not an + // operation: no write verb is documented (the same rule as every other port). + assertTrue("no write verb documented: " + rest, + rest.stream().allMatch(name -> name.startsWith("GET "))); } @Test diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/apidocs/ProjectionDocsRoutesTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/apidocs/ProjectionDocsRoutesTest.java new file mode 100644 index 000000000..2fe2310b0 --- /dev/null +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/apidocs/ProjectionDocsRoutesTest.java @@ -0,0 +1,87 @@ +package com.metaobjects.generator.apidocs; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.metaobjects.generator.spring.SpringTestFixtures; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.registry.SharedRegistryTestBase; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +/** + * The api-contract {@code projection/} corpus, docs half. The REST routes a read-only + * projection's api page lists are exactly the routes its generated surface answers with a + * row. Every port runs this assertion over the same model and the same expected set + * ({@code fixtures/api-contract-conformance/projection/docs-routes.json}): + * + *
    + *
  • a projection with a declared identity lists {@code GET } and {@code GET /{id}};
  • + *
  • one with none lists {@code GET } alone, even when it has a field named {@code id};
  • + *
  • no unit lists a write verb.
  • + *
+ * + *

The booted-server half of the same contract is the corpus scenarios themselves + * ({@code ProjectionGeneratedApiContractConformanceTest} in {@code integration-tests}).

+ */ +public class ProjectionDocsRoutesTest extends SharedRegistryTestBase { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + /** Walk up to the repo root (the dir holding both {@code fixtures/} and {@code server/}). */ + private static Path corpus() { + Path dir = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize(); + for (Path p = dir; p != null; p = p.getParent()) { + if (Files.isDirectory(p.resolve("fixtures")) && Files.isDirectory(p.resolve("server"))) { + return p.resolve("fixtures/api-contract-conformance/projection"); + } + } + throw new IllegalStateException("could not locate the repo root from " + dir); + } + + /** The spelling every port's expected set uses: no leading slash or api prefix, {@code {id}}. */ + private static String normalize(String symbol) { + int space = symbol.indexOf(' '); + return symbol.substring(0, space) + " " + symbol.substring(space + 1).replaceFirst("^/?(?:api/)?", ""); + } + + @Test + public void eachProjectionDocumentsExactlyTheRoutesItMounts() throws Exception { + Path corpus = corpus(); + JsonNode expected = MAPPER.readTree(Files.readString(corpus.resolve("docs-routes.json"))).get("units"); + Path workspace = tempFolder.newFolder("projection-docs").toPath(); + MetaDataLoader loader = SpringTestFixtures.loadFixture( + workspace, "projection-docs", Files.readString(corpus.resolve("meta.json"))); + + JavaApiModel model = new JavaApiModelBuilder().build(loader, "projection-docs"); + + Iterator> units = expected.fields(); + while (units.hasNext()) { + Map.Entry e = units.next(); + List want = new ArrayList<>(); + e.getValue().forEach(n -> want.add(n.asText())); + want.sort(null); + + ApiUnit unit = model.units().stream().filter(u -> u.node().equals(e.getKey())).findFirst() + .orElseThrow(() -> new AssertionError("no api unit " + e.getKey())); + List documented = new ArrayList<>(); + for (ApiSymbol s : unit.symbols()) { + if (s.kind() == ApiSymbolKind.REST) documented.add(normalize(s.name())); + } + documented.sort(null); + assertEquals(e.getKey() + ": the routes its api page lists", want, documented); + } + } +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/projection/generated/GeneratedProjectionControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/projection/generated/GeneratedProjectionControllerHarness.kt index 462b88f1a..741e899fa 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/projection/generated/GeneratedProjectionControllerHarness.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/projection/generated/GeneratedProjectionControllerHarness.kt @@ -26,9 +26,9 @@ import kotlin.io.path.isRegularFile import kotlin.io.path.readText /** - * F22 — host the GENERATED Kotlin Spring `InvoiceSummaryController` for the view-only - * projection corpus over real HTTP (an embedded Tomcat) and drive the `projection/` scenarios - * against it. Mirrors [com.metaobjects.integration.kotlin.api.writethrough.generated.GeneratedWriteThroughControllerHarness]. + * F22 — host the GENERATED Kotlin Spring controllers (`InvoiceSummaryController`, + * `InvoiceLedgerController`, `InvoiceStubController`) for the view-only projection corpus over + * real HTTP (one embedded Tomcat) and drive the `projection/` scenarios against them. Mirrors [com.metaobjects.integration.kotlin.api.writethrough.generated.GeneratedWriteThroughControllerHarness]. * * Mechanism: * 1. Load `fixtures/api-contract-conformance/projection/meta.json`. @@ -39,14 +39,14 @@ import kotlin.io.path.readText * that proves the emitted read-only controller COMPILES: the codegen-compile gate * excludes the framework-bound route tier in every port by design. * 4. Per scenario: fresh in-memory H2 (PostgreSQL mode), `SchemaUtils.create(InvoiceTable)`, - * then HAND-EXEC `CREATE VIEW v_invoice_summary` (Exposed cannot create a view — the - * generated `InvoiceSummaryTable` is a SELECT-only binding), then seed `invoices`. - * 5. Serve the controller from an embedded Tomcat over a real socket ([TomcatHost]). + * then HAND-EXEC `CREATE VIEW` for each projection (Exposed cannot create a view — each + * generated `Table` is a SELECT-only binding), then seed `invoices`. + * 5. Serve the controllers from an embedded Tomcat over a real socket ([TomcatHost]). * - * The view is `SELECT *` over `invoices` on purpose. The projection declares exactly the base - * entity's four fields, so both generated Exposed objects derive the same physical column - * names under the same naming strategy — and `SELECT *` therefore cannot disagree with either - * of them, whereas a hand-spelled column list silently could. + * Each view's column list and aliases are spelled from the generated `Table`'s own + * columns, so a view cannot disagree with the Exposed binding that reads it. `InvoiceLedger` is + * keyed on `number`, which the view aliases from `id`: it has NO `id` column. `InvoiceStub` + * declares no identity and carries an `id` column all the same. */ @OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class) class GeneratedProjectionControllerHarness( @@ -60,8 +60,10 @@ class GeneratedProjectionControllerHarness( .registerModule(JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - private val controllerClass: Class<*> + private val controllerClasses: List> private val invoiceTable: Table + /** Generated `Table` object per projection name. */ + private val viewTables: Map private val dbSeq = AtomicInteger(0) private var host: TomcatHost? = null @@ -83,13 +85,15 @@ class GeneratedProjectionControllerHarness( g.execute(loader) } - // The projection's controller must have been emitted at all — otherwise this fails + // Every projection's controller must have been emitted at all — otherwise this fails // downstream as a ClassNotFoundException with no hint that the emit gate, and not // the harness, was the cause. - val emittedController = srcDir.resolve("acme/sales/InvoiceSummaryController.kt") - check(Files.exists(emittedController)) { - "no controller was generated for the InvoiceSummary projection at $emittedController " + - "— the F22 emit gate did not admit it" + for (name in PROJECTIONS) { + val emittedController = srcDir.resolve("acme/sales/${name}Controller.kt") + check(Files.exists(emittedController)) { + "no controller was generated for the $name projection at $emittedController " + + "— the F22 emit gate did not admit it" + } } val sources = Files.walk(srcDir).use { stream -> @@ -108,9 +112,12 @@ class GeneratedProjectionControllerHarness( "generated Kotlin failed to compile:\n${result.messages}" } - this.controllerClass = result.classLoader.loadClass(CONTROLLER_FQCN) + this.controllerClasses = PROJECTIONS.map { result.classLoader.loadClass("$ENTITY_PKG.${it}Controller") } this.invoiceTable = result.classLoader.loadClass(INVOICE_TABLE_FQCN) .getDeclaredField("INSTANCE").get(null) as Table + this.viewTables = PROJECTIONS.associateWith { + result.classLoader.loadClass("$ENTITY_PKG.${it}Table").getDeclaredField("INSTANCE").get(null) as Table + } } /** Rebuild a fresh in-memory H2 + view + seed + controller + Tomcat. */ @@ -119,7 +126,15 @@ class GeneratedProjectionControllerHarness( val db = Database.connect("jdbc:h2:mem:$dbName;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", driver = "org.h2.Driver") transaction(db) { SchemaUtils.create(invoiceTable) - exec("CREATE VIEW v_invoice_summary AS SELECT * FROM ${identity(invoiceTable)}") + for ((name, view) in VIEWS) { + val table = viewTables.getValue(name) + // view column (the generated Exposed column's physical name) <- `invoices` column + val select = view.columns.joinToString(", ") { (viewCol, baseCol) -> + val target = table.columns.first { it.name == snakeCase(viewCol) } + "${identity(column(baseCol))} AS ${identity(target)}" + } + exec("CREATE VIEW ${view.name} AS SELECT $select FROM ${identity(invoiceTable)}") + } val cols = SEED_FIELDS.map { field -> column(field) } val colList = cols.joinToString(", ") { identity(it) } for (row in invoices) { @@ -128,9 +143,9 @@ class GeneratedProjectionControllerHarness( } } - val controller = controllerClass.getDeclaredConstructor().newInstance() + val controllers = controllerClasses.map { it.getDeclaredConstructor().newInstance() } host?.close() - host = TomcatHost.start(mapper, controller) + host = TomcatHost.start(mapper, *controllers.toTypedArray()) } /** @@ -161,11 +176,26 @@ class GeneratedProjectionControllerHarness( private companion object { const val ENTITY_PKG = "acme.sales" - const val CONTROLLER_FQCN = "$ENTITY_PKG.InvoiceSummaryController" const val INVOICE_TABLE_FQCN = "$ENTITY_PKG.InvoiceTable" + /** The corpus's view-only projections, each mounted and served. */ + val PROJECTIONS = listOf("InvoiceSummary", "InvoiceLedger", "InvoiceStub") + /** The seed row keys, in `invoices` column order. */ - val SEED_FIELDS = listOf("id", "reference", "status", "amountCents") + val SEED_FIELDS = listOf("id", "reference", "status", "amountCents", "discount", "weight") + + class ViewSpec(val name: String, val columns: List>) + + /** Each projection's view: (projection field -> `invoices` field) pairs. */ + val VIEWS = mapOf( + "InvoiceSummary" to ViewSpec("v_invoice_summary", listOf( + "id" to "id", "reference" to "reference", "status" to "status", "amountCents" to "amountCents")), + // Keyed on `number`; the view has NO `id` column. + "InvoiceLedger" to ViewSpec("v_invoice_ledger", listOf( + "number" to "id", "reference" to "reference", "discount" to "discount", "weight" to "weight")), + // No declared identity; the view carries an `id` column all the same. + "InvoiceStub" to ViewSpec("v_invoice_stub", listOf("id" to "id", "reference" to "reference")), + ) fun snakeCase(s: String): String = buildString { for (c in s) { diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt index 14d0ae8a7..94a65e7f9 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/report/ReportGeneratedApiContractConformanceTest.kt @@ -46,9 +46,9 @@ internal class ReportGeneratedApiContractConformanceTest { } @Test - fun `the corpus has its twelve scenarios`() { + fun `the corpus has its thirteen scenarios`() { // A lane that silently ran fewer would still be green. - assertEquals(12, scenarios().count()) + assertEquals(13, scenarios().count()) } @Test diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java index 4c0b16357..3f3519ee7 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/ReportGeneratedApiContractConformanceTest.java @@ -25,7 +25,7 @@ * FR-044 Plan 3 — the Java GENERATED-controller lane for the view-backed-report * api-contract sub-corpus. Boots the three GENERATED Spring report controllers on one * embedded Tomcat, each behind an in-memory repository seam seeded with what its view - * returns, and drives all twelve scenarios. + * returns, and drives all thirteen scenarios. * *

Generated lane ONLY, on purpose and on every port (see the sub-corpus README). What * is under test is whether the port's GENERATOR emits a read route for a served report, @@ -61,9 +61,9 @@ static Stream scenarios() { } @Test - void theCorpusCarriesItsTwelveScenarios() { + void theCorpusCarriesItsThirteenScenarios() { // A scenarios directory that resolved to nothing would leave this lane green and empty. - assertEquals(12, SCENARIOS.size()); + assertEquals(13, SCENARIOS.size()); } @ParameterizedTest(name = "{0}") diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedProjectionControllerHarness.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedProjectionControllerHarness.java index 15d238e37..3360a1618 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedProjectionControllerHarness.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedProjectionControllerHarness.java @@ -26,6 +26,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -33,42 +34,65 @@ /** - * F22 — host the GENERATED Java Spring {@code @RestController} for the view-only - * {@code InvoiceSummary} projection over real HTTP (an embedded Tomcat, {@link TomcatHost}) and drive - * the {@code projection/} api-contract scenarios against it. Sibling of - * {@link GeneratedJsonbControllerHarness}. + * F22 — host the GENERATED Java Spring {@code @RestController} of every view-only + * projection in the {@code projection/} corpus ({@code InvoiceSummary}, {@code InvoiceLedger}, + * {@code InvoiceStub}) over real HTTP (one embedded Tomcat, {@link TomcatHost}) and drive the + * api-contract scenarios against them. Sibling of {@link GeneratedJsonbControllerHarness}. * - *

The artifact under test is the GENERATED {@code InvoiceSummaryController} — - * read routes plus a 405 refusal on every write verb — together with the read-only - * {@code InvoiceSummaryRepository} interface it delegates to. The only hand-written - * piece is {@link InMemoryInvoiceSummaryRepositorySource}, the consumer seam.

+ *

The artifacts under test are the GENERATED {@code Controller}s — read + * routes plus a 405 refusal on every write verb — together with the read-only + * {@code Repository} interfaces they delegate to. The only hand-written piece + * is {@link InMemoryProjectionRepositorySource}, the consumer seam.

* - *

Both the projection AND the writable {@code Invoice} entity are generated and - * compiled, because the interesting failure is a gate that admits one shape and breaks - * the other. Only the projection's controller is MOUNTED — mounting Invoice's too would - * need a second in-memory seam for a surface no scenario exercises.

+ *

The projections AND the writable {@code Invoice} entity are generated and compiled, + * because the interesting failure is a gate that admits one shape and breaks the other. + * Only the projections' controllers are MOUNTED — mounting Invoice's too would need a + * second in-memory seam for a surface no scenario exercises.

*/ public final class GeneratedProjectionControllerHarness implements AutoCloseable { private static final String ENTITY_PKG = "acme.sales"; - private static final String CONTROLLER_FQCN = ENTITY_PKG + ".InvoiceSummaryController"; - private static final String DTO_FQCN = ENTITY_PKG + ".InvoiceSummaryDto"; - private static final String REPO_FQCN = ENTITY_PKG + ".InvoiceSummaryRepository"; + + /** + * One mounted projection: the columns its view returns (view column to {@code invoices} + * column; the view derives from the seeded base table, so the harness models it by + * selecting and renaming), the DTO component its {@code findById} matches, or + * {@code null} for a projection that declares no primary identity, and that component's + * Java type. + */ + private record Spec(Map columns, String keyComponent, String keyType) {} + + private static final Map SPECS = new LinkedHashMap<>(); + static { + SPECS.put("InvoiceSummary", new Spec(columns( + "id", "id", "reference", "reference", "status", "status", "amountCents", "amountCents"), + "id", "Long")); + // Keyed on `number`; the view has NO `id` column. + SPECS.put("InvoiceLedger", new Spec(columns( + "number", "id", "reference", "reference", "discount", "discount", "weight", "weight"), + "number", "Long")); + // No declared identity; the view carries an `id` column all the same. + SPECS.put("InvoiceStub", new Spec(columns("id", "id", "reference", "reference"), null, null)); + } + + private static Map columns(String... pairs) { + Map out = new LinkedHashMap<>(); + for (int i = 0; i < pairs.length; i += 2) out.put(pairs[i], pairs[i + 1]); + return out; + } + + /** A mounted projection's generated row type and the constructors a scenario rebuilds it with. */ + private record Mount(Class dtoClass, Constructor repoCtor, Constructor controllerCtor, + List> viewRows) {} private final ObjectMapper mapper = new ObjectMapper(); private final URLClassLoader classLoader; - private final Class dtoClass; - private final Constructor controllerCtor; // (InvoiceSummaryRepository) — no ObjectMapper, - // no Validator: nothing here binds a body. - private final Constructor repoCtor; // (List seed) - private final List> seedRows; + private final Map mounts = new LinkedHashMap<>(); private TomcatHost host; public GeneratedProjectionControllerHarness(Path corpusRoot, Path genDir, List> seedRows) throws Exception { - this.seedRows = seedRows; - Path srcDir = genDir.resolve("src"); Path classesDir = genDir.resolve("classes"); Files.createDirectories(srcDir); @@ -81,42 +105,64 @@ public GeneratedProjectionControllerHarness(Path corpusRoot, Path genDir, runGenerator(new SpringRepositoryGenerator(), loader, srcDir); runGenerator(new SpringFilterAllowlistGenerator(), loader, srcDir); - // The projection's controller must have been emitted at all — a silently-skipped - // generator would otherwise surface downstream as a ClassNotFoundException with no - // hint that codegen, not the harness, was the cause. - Path emittedController = srcDir.resolve(ENTITY_PKG.replace('.', '/')) - .resolve("InvoiceSummaryController.java"); - if (!Files.exists(emittedController)) { - throw new IllegalStateException( - "no controller was generated for the InvoiceSummary projection at " - + emittedController + " — the F22 emit gate did not admit it"); + Path pkgDir = srcDir.resolve(ENTITY_PKG.replace('.', '/')); + for (Map.Entry e : SPECS.entrySet()) { + String name = e.getKey(); + // Every projection's controller must have been emitted at all — a silently-skipped + // generator would otherwise surface downstream as a ClassNotFoundException with no + // hint that codegen, not the harness, was the cause. + Path emittedController = pkgDir.resolve(name + "Controller.java"); + if (!Files.exists(emittedController)) { + throw new IllegalStateException( + "no controller was generated for the " + name + " projection at " + + emittedController + " — the F22 emit gate did not admit it"); + } + // The generated repository is the contract: a keyless projection must not have + // grown a findById, and a keyed one must have kept it. + boolean hasFindById = Files.readString(pkgDir.resolve(name + "Repository.java")).contains("findById("); + if (hasFindById != (e.getValue().keyComponent() != null)) { + throw new IllegalStateException( + name + "Repository " + (hasFindById ? "has" : "has no") + " findById, but the corpus " + + (e.getValue().keyComponent() != null ? "declares" : "declares no") + " identity"); + } + Files.writeString(pkgDir.resolve(InMemoryProjectionRepositorySource.simpleName(name) + ".java"), + InMemoryProjectionRepositorySource.source( + name, e.getValue().keyComponent(), e.getValue().keyType())); } - Path repoImpl = srcDir.resolve(ENTITY_PKG.replace('.', '/')) - .resolve("InMemoryInvoiceSummaryRepository.java"); - Files.writeString(repoImpl, InMemoryInvoiceSummaryRepositorySource.SOURCE); - compile(srcDir, classesDir); this.classLoader = new URLClassLoader( new URL[]{ classesDir.toUri().toURL() }, getClass().getClassLoader()); - this.dtoClass = classLoader.loadClass(DTO_FQCN); - Class repoInterface = classLoader.loadClass(REPO_FQCN); - Class controllerClass = classLoader.loadClass(CONTROLLER_FQCN); - this.controllerCtor = controllerClass.getDeclaredConstructor(repoInterface); - Class repoImplClass = classLoader.loadClass(InMemoryInvoiceSummaryRepositorySource.FQCN); - this.repoCtor = repoImplClass.getDeclaredConstructor(List.class); + for (Map.Entry e : SPECS.entrySet()) { + String name = e.getKey(); + Class dtoClass = classLoader.loadClass(ENTITY_PKG + "." + name + "Dto"); + Class repoInterface = classLoader.loadClass(ENTITY_PKG + "." + name + "Repository"); + // (Repository) — no ObjectMapper, no Validator: nothing here binds a body. + Constructor controllerCtor = classLoader.loadClass(ENTITY_PKG + "." + name + "Controller") + .getDeclaredConstructor(repoInterface); + Constructor repoCtor = classLoader.loadClass(InMemoryProjectionRepositorySource.fqcn(name)) + .getDeclaredConstructor(List.class); + List> viewRows = new ArrayList<>(); + for (Map row : seedRows) { + Map view = new LinkedHashMap<>(); + e.getValue().columns().forEach((col, src) -> view.put(col, row.get(src))); + viewRows.add(view); + } + mounts.put(name, new Mount(dtoClass, repoCtor, controllerCtor, viewRows)); + } } - /** Re-seed for a scenario: fresh repo + controller + Tomcat from the corpus seed. */ + /** Re-seed for a scenario: fresh repositories and controllers, all on one Tomcat. */ public void reset() throws Exception { - List dtos = new ArrayList<>(); - for (Map row : seedRows) dtos.add(mapper.convertValue(row, dtoClass)); - Object repo = repoCtor.newInstance(dtos); - Object controller = controllerCtor.newInstance(repo); - + List controllers = new ArrayList<>(); + for (Mount m : mounts.values()) { + List dtos = new ArrayList<>(); + for (Map row : m.viewRows()) dtos.add(mapper.convertValue(row, m.dtoClass())); + controllers.add(m.controllerCtor().newInstance(m.repoCtor().newInstance(dtos))); + } if (host != null) host.close(); - this.host = TomcatHost.start(mapper, controller); + this.host = TomcatHost.start(mapper, controllers.toArray()); } public Response exchange(String method, String path, Object jsonBody) throws Exception { diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryInvoiceSummaryRepositorySource.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryInvoiceSummaryRepositorySource.java deleted file mode 100644 index 7666c5ab1..000000000 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryInvoiceSummaryRepositorySource.java +++ /dev/null @@ -1,163 +0,0 @@ -package com.metaobjects.integration.api.generated; - -/** - * The Java SOURCE for the in-memory {@code acme.sales.InvoiceSummaryRepository} impl, - * emitted alongside the GENERATED read-only controller/DTO/interface so it compiles - * against them, then loaded + instantiated reflectively by - * {@link GeneratedProjectionControllerHarness}. - * - *

The consumer seam MetaObjects intentionally leaves unimplemented — and for a - * projection the seam is READ-ONLY, so this class implements exactly three methods. - * If the generator ever emitted a write method on a projection's repository interface, - * this class would stop compiling, which is the cheapest possible gate on that.

- * - *

Test scaffolding, not a conformance subject: its job is to faithfully apply the - * controller-supplied {@code List}, {@code SortClause} and - * {@code limit/offset} to the seeded list, so the GENERATED controller's - * qs→predicate→repository translation is exercised end-to-end. It must NOT - * re-implement envelopes or status codes — those are the generated controller's job, - * and they are what the corpus asserts.

- * - *

Kept as a string constant (not a real source file) so it lives entirely inside the - * test module: it references the generated {@code acme.sales.*} types, which only exist - * after codegen runs.

- */ -final class InMemoryInvoiceSummaryRepositorySource { - - private InMemoryInvoiceSummaryRepositorySource() {} - - /** Fully-qualified name of the emitted impl (package + simple name). */ - static final String FQCN = "acme.sales.InMemoryInvoiceSummaryRepository"; - - static final String SOURCE = """ - package acme.sales; - - import com.metaobjects.generator.spring.runtime.FilterPredicate; - - import java.util.ArrayList; - import java.util.Comparator; - import java.util.List; - import java.util.Optional; - - /** - * Hand-written in-memory {@link InvoiceSummaryRepository} (the read-only consumer - * seam). Stands in for the SQL view v_invoice_summary, which derives from the - * seeded invoices rows one-for-one. NOT a conformance subject — test scaffolding. - */ - public final class InMemoryInvoiceSummaryRepository implements InvoiceSummaryRepository { - - private final List rows = new ArrayList<>(); - - public InMemoryInvoiceSummaryRepository(List seed) { - rows.addAll(seed); - } - - @Override - public List list(int limit, int offset, SortClause sort, List filters) { - List out = new ArrayList<>(); - for (InvoiceSummaryDto r : rows) if (matchesAll(r, filters)) out.add(r); - out.sort(comparatorFor(sort)); - int from = Math.min(offset, out.size()); - int to = Math.min(from + limit, out.size()); - return new ArrayList<>(out.subList(from, to)); - } - - @Override - public long count(List filters) { - long n = 0; - for (InvoiceSummaryDto r : rows) if (matchesAll(r, filters)) n++; - return n; - } - - @Override - public Optional findById(Long id) { - for (InvoiceSummaryDto r : rows) if (id.equals(r.id())) return Optional.of(r); - return Optional.empty(); - } - - // --- predicate application --------------------------------------------------- - - private static boolean matchesAll(InvoiceSummaryDto r, List filters) { - if (filters == null) return true; - for (FilterPredicate p : filters) if (!matches(r, p)) return false; // implicit AND - return true; - } - - @SuppressWarnings("unchecked") - private static boolean matches(InvoiceSummaryDto r, FilterPredicate p) { - Object col = column(r, p.field()); - switch (p.op()) { - case "isNull": { - boolean wantNull = Boolean.TRUE.equals(p.value()); - return wantNull == (col == null); - } - case "in": { - List items = (List) p.value(); - for (String item : items) if (col != null && compare(col, p.field(), item) == 0) return true; - return false; - } - case "like": { - if (col == null) return false; - return sqlLike(String.valueOf(col), (String) p.value()); - } - default: { - if (col == null) return false; - int cmp = compare(col, p.field(), (String) p.value()); - return switch (p.op()) { - case "eq" -> cmp == 0; - case "ne" -> cmp != 0; - case "gt" -> cmp > 0; - case "gte" -> cmp >= 0; - case "lt" -> cmp < 0; - case "lte" -> cmp <= 0; - default -> throw new IllegalStateException("unknown op: " + p.op()); - }; - } - } - } - - private static Object column(InvoiceSummaryDto r, String field) { - return switch (field) { - case "id" -> r.id(); - case "reference" -> r.reference(); - case "status" -> r.status(); - case "amountCents" -> r.amountCents(); - default -> throw new IllegalStateException("unknown column: " + field); - }; - } - - /** Compare a column value against a raw string operand, coercing by field type. */ - private static int compare(Object col, String field, String raw) { - return switch (field) { - case "id", "amountCents" -> Long.compare((Long) col, Long.parseLong(raw)); - default -> String.valueOf(col).compareTo(raw); - }; - } - - /** SQL LIKE with `%` (any run) and `_` (any single char), anchored full-string match. */ - private static boolean sqlLike(String value, String pattern) { - StringBuilder re = new StringBuilder("^"); - for (int i = 0; i < pattern.length(); i++) { - char c = pattern.charAt(i); - if (c == '%') re.append(".*"); - else if (c == '_') re.append('.'); - else re.append(java.util.regex.Pattern.quote(String.valueOf(c))); - } - re.append("$"); - return value.matches(re.toString()); - } - - private static Comparator comparatorFor(SortClause sort) { - String field = sort != null ? sort.field() : "id"; // default sort by id asc - boolean desc = sort != null && "desc".equalsIgnoreCase(sort.direction()); - Comparator c = switch (field) { - case "reference" -> Comparator.comparing(InvoiceSummaryDto::reference, Comparator.nullsLast(Comparator.naturalOrder())); - case "status" -> Comparator.comparing(InvoiceSummaryDto::status, Comparator.nullsLast(Comparator.naturalOrder())); - case "amountCents" -> Comparator.comparing(InvoiceSummaryDto::amountCents, Comparator.nullsLast(Comparator.naturalOrder())); - default -> Comparator.comparing(InvoiceSummaryDto::id, Comparator.nullsLast(Comparator.naturalOrder())); - }; - return desc ? c.reversed() : c; - } - } - """; -} diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryProjectionRepositorySource.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryProjectionRepositorySource.java new file mode 100644 index 000000000..a366a03d0 --- /dev/null +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryProjectionRepositorySource.java @@ -0,0 +1,209 @@ +package com.metaobjects.integration.api.generated; + +/** + * The Java SOURCE for an in-memory {@code acme.sales.Repository} impl: ONE generic + * template, instantiated once per mounted projection by substituting its name. Emitted + * alongside the GENERATED controller / DTO / interface so it compiles against them, then + * loaded and instantiated reflectively by {@link GeneratedProjectionControllerHarness}. + * + *

The consumer seam MetaObjects leaves unimplemented. For a projection that declares a + * primary identity it is {@code list}, {@code count} and {@code findById}; for a keyless + * one it is {@code list} and {@code count} only, and if the generator ever emitted + * {@code findById} or a write method on that repository interface, this class would stop + * compiling, which is the cheapest possible gate on it.

+ * + *

Test scaffolding, not a conformance subject: its job is to apply the + * controller-supplied {@code List}, {@code SortClause} and + * {@code limit/offset} to the seeded rows faithfully, so the GENERATED controller's + * qs→predicate→repository translation is exercised end to end. Envelopes and + * status codes are the generated controller's job.

+ * + *

Generic over the row: a column is read through the DTO record's own component + * accessor and an operand is coerced by that component's declared type, so nothing here + * names a projection's fields. A decimal compares as {@link java.math.BigDecimal} (exact + * and scale-blind), never through a double.

+ */ +final class InMemoryProjectionRepositorySource { + + private InMemoryProjectionRepositorySource() {} + + private static final String PKG = "acme.sales"; + private static final String NAME = "__NAME__"; + private static final String FIND_BY_ID = "__FIND_BY_ID__"; + + /** Simple name of the emitted impl for {@code projection}. */ + static String simpleName(String projection) { + return "InMemory" + projection + "Repository"; + } + + /** Fully-qualified name of the emitted impl for {@code projection}. */ + static String fqcn(String projection) { + return PKG + "." + simpleName(projection); + } + + /** + * The impl source for {@code projection}. {@code keyComponent} is the DTO record + * component its {@code findById} matches (of Java type {@code keyType}), or {@code null} + * for a keyless projection, whose repository has no {@code findById} to implement. + */ + static String source(String projection, String keyComponent, String keyType) { + String findById = keyComponent == null ? "" : """ + + @Override + public Optional<__NAME__Dto> findById(__KEY_TYPE__ id) { + for (__NAME__Dto r : rows) if (id.equals(r.__KEY__())) return Optional.of(r); + return Optional.empty(); + } +""".replace("__KEY__", keyComponent).replace("__KEY_TYPE__", keyType); + return TEMPLATE.replace(FIND_BY_ID, findById).replace(NAME, projection); + } + + private static final String TEMPLATE = """ + package acme.sales; + + import com.metaobjects.generator.spring.runtime.FilterPredicate; + + import java.lang.reflect.RecordComponent; + import java.math.BigDecimal; + import java.time.LocalDate; + import java.util.ArrayList; + import java.util.Comparator; + import java.util.List; +import java.util.Optional; + + /** + * Hand-written in-memory {@link __NAME__Repository} (the read-only consumer seam). + * Stands in for the projection's SQL view. NOT a conformance subject: test scaffolding. + */ + public final class InMemory__NAME__Repository implements __NAME__Repository { + + private final List<__NAME__Dto> rows = new ArrayList<>(); + + public InMemory__NAME__Repository(List<__NAME__Dto> seed) { + rows.addAll(seed); + } + + @Override + public List<__NAME__Dto> list(int limit, int offset, SortClause sort, List filters) { + List<__NAME__Dto> out = new ArrayList<>(); + for (__NAME__Dto r : rows) if (matchesAll(r, filters)) out.add(r); + // No sort: the view's own order, which is the seed's (ascending by id). + if (sort != null) out.sort(comparatorFor(sort)); + int from = Math.min(offset, out.size()); + int to = Math.min(from + limit, out.size()); + return new ArrayList<>(out.subList(from, to)); + } + + @Override + public long count(List filters) { + long n = 0; + for (__NAME__Dto r : rows) if (matchesAll(r, filters)) n++; + return n; + } + +__FIND_BY_ID__ + // --- the row, read generically ------------------------------------------------ + + private static RecordComponent component(String field) { + for (RecordComponent c : __NAME__Dto.class.getRecordComponents()) { + if (c.getName().equals(field)) return c; + } + throw new IllegalStateException("unknown column: " + field); + } + + private static Object column(__NAME__Dto r, String field) { + try { + return component(field).getAccessor().invoke(r); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("cannot read column " + field, e); + } + } + + /** A raw query operand as the column's own type, so the comparison is typed. */ + private static Comparable operand(String field, String raw) { + Class type = component(field).getType(); + Object value; + if (type == Long.class) value = Long.valueOf(raw); + else if (type == Integer.class) value = Integer.valueOf(raw); + else if (type == Double.class) value = Double.valueOf(raw); + else if (type == Float.class) value = Float.valueOf(raw); + else if (type == BigDecimal.class) value = new BigDecimal(raw); + else if (type == LocalDate.class) value = LocalDate.parse(raw); + else if (type == Boolean.class) value = Boolean.valueOf(raw); + else if (type == String.class) value = raw; + else throw new IllegalStateException("no operand coercion for " + type.getName() + " (" + field + ")"); + return comparable(value); + } + + @SuppressWarnings("unchecked") + private static Comparable comparable(Object value) { + return (Comparable) value; + } + + // --- predicate application --------------------------------------------------- + + private static boolean matchesAll(__NAME__Dto r, List filters) { + if (filters == null) return true; + for (FilterPredicate p : filters) if (!matches(r, p)) return false; // implicit AND + return true; + } + + @SuppressWarnings("unchecked") + private static boolean matches(__NAME__Dto r, FilterPredicate p) { + Object col = column(r, p.field()); + switch (p.op()) { + case "isNull": { + boolean wantNull = Boolean.TRUE.equals(p.value()); + return wantNull == (col == null); + } + case "in": { + if (col == null) return false; + for (String item : (List) p.value()) { + if (comparable(col).compareTo(operand(p.field(), item)) == 0) return true; + } + return false; + } + case "like": { + if (col == null) return false; + return sqlLike(String.valueOf(col), (String) p.value()); + } + default: { + if (col == null) return false; + // compareTo, never equals: BigDecimal.equals is scale-sensitive. + int cmp = comparable(col).compareTo(operand(p.field(), (String) p.value())); + return switch (p.op()) { + case "eq" -> cmp == 0; + case "ne" -> cmp != 0; + case "gt" -> cmp > 0; + case "gte" -> cmp >= 0; + case "lt" -> cmp < 0; + case "lte" -> cmp <= 0; + default -> throw new IllegalStateException("unknown op: " + p.op()); + }; + } + } + } + + /** SQL LIKE with `%` (any run) and `_` (any single char), anchored full-string match. */ + private static boolean sqlLike(String value, String pattern) { + StringBuilder re = new StringBuilder("^"); + for (int i = 0; i < pattern.length(); i++) { + char c = pattern.charAt(i); + if (c == '%') re.append(".*"); + else if (c == '_') re.append('.'); + else re.append(java.util.regex.Pattern.quote(String.valueOf(c))); + } + re.append("$"); + return value.matches(re.toString()); + } + + private static Comparator<__NAME__Dto> comparatorFor(SortClause sort) { + String field = sort.field(); + component(field); // an unknown sort column is a harness bug, not an empty sort + Comparator<__NAME__Dto> c = Comparator.comparing( + r -> comparable(column(r, field)), Comparator.nullsLast(Comparator.naturalOrder())); + return "desc".equalsIgnoreCase(sort.direction()) ? c.reversed() : c; + } + } + """; +} diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryReportRepositorySource.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryReportRepositorySource.java index da53e7c7c..f78d3fdc1 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryReportRepositorySource.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryReportRepositorySource.java @@ -114,10 +114,17 @@ private static Comparable operand(String field, String raw) { else if (type == LocalDate.class) value = LocalDate.parse(raw); else if (type == Boolean.class) value = Boolean.valueOf(raw); else if (type == String.class) value = raw; + else if (type.isEnum()) value = enumConstant(type, raw); else throw new IllegalStateException("no operand coercion for " + type.getName() + " (" + field + ")"); return comparable(value); } + /** A derived enum column's operand: the declared member of that name. */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Object enumConstant(Class type, String raw) { + return Enum.valueOf((Class) type, raw); + } + @SuppressWarnings("unchecked") private static Comparable comparable(Object value) { return (Comparable) value; diff --git a/server/python/src/metaobjects/codegen/instance_artifacts.py b/server/python/src/metaobjects/codegen/instance_artifacts.py index ee878d17a..9347e0af0 100644 --- a/server/python/src/metaobjects/codegen/instance_artifacts.py +++ b/server/python/src/metaobjects/codegen/instance_artifacts.py @@ -65,25 +65,20 @@ def is_served_report(obj: MetaObject) -> bool: def has_item_route(entity: MetaObject) -> bool: """Whether a read-only object gets a ``/{id}`` route, a ``find_by_id`` on its - repository seam and the three item-verb refusals (FR-044 open question 4, as ruled). + repository seam and the three item-verb refusals. - Answer 4 removes only what could never serve a row, so the rule is: + The object has one exactly when it DECLARES a primary identity (a single field, or a + composite one, which binds its FIRST field). Otherwise there is no declared key to + address a row by, so no item route is generated: * a report (the declared node or its read model) NEVER has one, even if a derived field is named ``id``: it has no identity, and a row of it is not addressable; - * otherwise it has one when it declares a primary identity (a single field, or a - composite one, which binds its FIRST field exactly as before this change), or when - it declares NO primary identity and has an effective field named ``id`` (the - default key ``pk_field_name`` falls back to); - * otherwise (no identity and no ``id`` field) the route could only bind an ``int`` it - cannot honour, so it is not generated. - - Every projection whose router had a usable item route before FR-044 renders - byte-identically. ADR-0039: ``children()`` / ``fields()`` resolve, so an inherited - identity or ``id`` field counts. + * a projection with no declared primary identity has none either, even when it has + a field named ``id``. "A field called ``id``" is a convention, not a key. This is + the rule of C#, Java and Kotlin, and of the cross-port ``projection/`` corpus. + + ADR-0039: ``primary_identity()`` resolves, so an inherited identity counts. """ if entity.sub_type == OBJECT_SUBTYPE_REPORT: return False - if entity.primary_identity() is not None: - return True - return any(f.name == "id" for f in entity.fields()) + return entity.primary_identity() is not None diff --git a/server/python/tests/codegen/test_projection_docs_routes.py b/server/python/tests/codegen/test_projection_docs_routes.py new file mode 100644 index 000000000..8deaaf624 --- /dev/null +++ b/server/python/tests/codegen/test_projection_docs_routes.py @@ -0,0 +1,56 @@ +"""The api-contract ``projection/`` corpus, docs half. + +The REST routes a read-only projection's api page lists are exactly the routes its generated +surface answers with a row. Every port runs this assertion over the same model and the same +expected set (``fixtures/api-contract-conformance/projection/docs-routes.json``): + +* a projection with a declared identity lists ``GET `` and ``GET /{id}``; +* one with none lists ``GET `` alone, even when it has a field named ``id``; +* no unit lists a write verb. + +The booted-server half of the same contract is the corpus scenarios themselves +(``tests/integration/test_api_contract_projection.py``). +""" +from __future__ import annotations + +import json +import re +import shutil +from pathlib import Path + +from metaobjects import MetaDataLoader +from metaobjects.apidocs import ApiSymbolKind, PythonApiModelBuilder + + +def _corpus() -> Path: + here = Path(__file__).resolve() + for parent in here.parents: + candidate = parent / "fixtures" / "api-contract-conformance" / "projection" + if candidate.is_dir(): + return candidate + raise RuntimeError("could not locate fixtures/api-contract-conformance/projection") + + +def _normalize(symbol: str) -> str: + """The spelling every port's expected set uses: no leading ``/`` or ``/api`` prefix, + ``{id}`` for the item parameter (this port names it after the object).""" + verb, path = symbol.split(" ", 1) + path = re.sub(r"^/?(?:api/)?", "", path) + path = re.sub(r"\{[a-z_]+_id\}", "{id}", path) + return f"{verb} {path}" + + +def test_each_projection_documents_exactly_the_routes_it_mounts(tmp_path: Path) -> None: + corpus = _corpus() + expected = json.loads((corpus / "docs-routes.json").read_text())["units"] + # The loader reads a directory: give it meta.json alone, not the sibling seed and scenarios. + shutil.copy(corpus / "meta.json", tmp_path / "meta.json") + result = MetaDataLoader.from_directory(str(tmp_path)) + assert not result.errors, result.errors + model = PythonApiModelBuilder().build(result.root, "projection-docs") + for node, routes in expected.items(): + unit = next(u for u in model.units if u.node == node) + documented = sorted( + _normalize(s.name) for s in unit.symbols if s.kind == ApiSymbolKind.REST + ) + assert documented == sorted(routes), node diff --git a/server/python/tests/codegen/test_report_router.py b/server/python/tests/codegen/test_report_router.py index ea758c4da..6b9fe05a0 100644 --- a/server/python/tests/codegen/test_report_router.py +++ b/server/python/tests/codegen/test_report_router.py @@ -124,10 +124,17 @@ def test_a_projection_with_a_single_field_identity_has_the_item_surface() -> Non assert _has_item_surface(render_router(p)) -def test_a_projection_with_an_id_field_and_no_identity_has_the_item_surface() -> None: +def test_a_projection_with_an_id_field_and_no_identity_is_keyless() -> None: + # A field named `id` is a convention, not a declared key: nothing addresses a row, so no + # item route of any verb and no `find_by_id` on the seam (the JVM and C# rule). p = _projection(identity=None, id_field=True) - assert has_item_route(p) - assert _has_item_surface(render_router(p)) + assert not has_item_route(p) + src = render_router(p) + assert src is not None + assert not _has_item_surface(src) + decorators = re.findall(r'@router\.(\w+)\(("[^"]*")', src) + assert decorators == [("get", '""'), ("post", '""')], decorators + assert "find_by_id" not in src def test_a_composite_identity_keeps_its_item_route_bound_to_the_first_field() -> None: @@ -136,7 +143,7 @@ def test_a_composite_identity_keeps_its_item_route_bound_to_the_first_field() -> assert _has_item_surface(render_router(p)) -def test_a_projection_with_no_identity_and_no_id_field_is_keyless() -> None: +def test_a_projection_with_no_identity_and_no_id_field_is_also_keyless() -> None: p = _projection(identity=None, id_field=False) assert not has_item_route(p) src = render_router(p) diff --git a/server/python/tests/codegen/test_router_generator.py b/server/python/tests/codegen/test_router_generator.py index 517a94843..b50c93d99 100644 --- a/server/python/tests/codegen/test_router_generator.py +++ b/server/python/tests/codegen/test_router_generator.py @@ -17,6 +17,11 @@ from metaobjects.codegen.runtime.filter_parser import parse_filter from metaobjects.meta.core.field.meta_field import MetaField from metaobjects.meta.core.field import field_constants as fc +from metaobjects.meta.core.identity.identity_constants import ( + IDENTITY_ATTR_FIELDS, + IDENTITY_SUBTYPE_PRIMARY, +) +from metaobjects.meta.core.identity.meta_identity import MetaIdentity from metaobjects.meta.core.object.meta_object import MetaObject from metaobjects.meta.persistence.source.meta_source import MetaSource from metaobjects.meta.persistence.source.source_constants import ( @@ -24,7 +29,7 @@ SOURCE_KIND_VIEW, SOURCE_SUBTYPE_RDB, ) -from metaobjects.shared.base_types import TYPE_FIELD, TYPE_OBJECT, TYPE_SOURCE +from metaobjects.shared.base_types import TYPE_FIELD, TYPE_IDENTITY, TYPE_OBJECT, TYPE_SOURCE def _entity( @@ -47,6 +52,15 @@ def _entity( return o +def _with_primary_identity(obj: MetaObject, *fields: str) -> MetaObject: + """Declare ``identity.primary @fields: ``: a read-only object has an item route + only when it declares a key (a field merely named ``id`` is a convention, not a key).""" + ident = MetaIdentity(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY, "pk") + ident.set_attr(IDENTITY_ATTR_FIELDS, list(fields)) + obj.add_child(ident) + return obj + + def _f(name: str, sub: str, *, required: bool = False) -> MetaField: f = MetaField(TYPE_FIELD, sub, name) if required: @@ -125,12 +139,12 @@ def test_view_kind_gets_a_read_only_router() -> None: ever asked which was right. Ruled all-five-serve-projections, so the assertion is inverted deliberately — the old one was the defect, written down. """ - view = _entity( + view = _with_primary_identity(_entity( "AuthorView", [_f("id", fc.FIELD_SUBTYPE_INT, required=True)], source_kind=SOURCE_KIND_VIEW, package="acme::blog", - ) + ), "id") out = render_router(view) assert out is not None # Reads are mounted. @@ -160,13 +174,13 @@ def test_projection_subtype_gets_a_read_only_router() -> None: ERR_ENTITY_PRIMARY_SOURCE_READONLY at load (B4b), so `object.projection` is the shape a real model uses — and the generator must key off the SOURCE, not the subtype (instance artifacts derive from a declared source).""" - proj = _entity( + proj = _with_primary_identity(_entity( "InvoiceSummary", [_f("id", fc.FIELD_SUBTYPE_INT, required=True)], source_kind=SOURCE_KIND_VIEW, package="acme::sales", subtype="projection", - ) + ), "id") out = render_router(proj) assert out is not None assert 'router = APIRouter(prefix="/api/invoice_summaries"' in out diff --git a/server/python/tests/integration/generated_projection_app.py b/server/python/tests/integration/generated_projection_app.py index a2743b08a..e46c07a59 100644 --- a/server/python/tests/integration/generated_projection_app.py +++ b/server/python/tests/integration/generated_projection_app.py @@ -1,24 +1,26 @@ -"""F22 — boot the GENERATED read-only projection router over HTTP. +"""F22 — boot the GENERATED read-only projection routers over HTTP. Peer of ``generated_router_app.py``, for the view-only projection corpus (``fixtures/api-contract-conformance/projection/``). Runs the REAL generators -(``render_router`` + ``render_filter_allowlist``) for the corpus -``InvoiceSummary`` projection, writes the emitted modules to a temp package, -imports the generated router UNMODIFIED, and mounts it. +(``render_router`` + ``render_filter_allowlist``) for each corpus projection +(``InvoiceSummary``, ``InvoiceLedger``, ``InvoiceStub``), writes the emitted modules +to a temp package, imports the generated routers UNMODIFIED, and mounts them. The generated router is the artifact under test, and for this corpus that is the whole point: Python used to emit NO router at all for a view-only object (``router_generator`` returned ``None``), while TypeScript and C# served one. -The in-memory repo behind the generated seam is read-only, because the -generated ``InvoiceSummaryRepository`` Protocol is — it offers ``list`` / -``count`` / ``find_by_id`` and nothing else. If a write verb ever reached the -repo, there would be no method to call; the 405s are answered by the router -before the seam. +The in-memory repo behind each generated seam is read-only, because the +generated ``Repository`` Protocol is — it offers ``list`` / ``count`` +and, only for a projection that declares a primary identity, ``find_by_id``. If a +write verb ever reached the repo, there would be no method to call; the 405s are +answered by the router before the seam. """ from __future__ import annotations import importlib.util +import inspect +import re import shutil import sys import tempfile @@ -35,11 +37,27 @@ from metaobjects.meta.core.object.meta_object import MetaObject from metaobjects.shared.base_types import TYPE_OBJECT -PROJECTION_NAME = "InvoiceSummary" - - -def _find_projection(meta_json: Path) -> MetaObject: - """Load the corpus metadata and return the ``InvoiceSummary`` projection.""" +# Each corpus projection: the columns its view returns (view column -> `invoices` column), +# and the view column its generated seam addresses a row by (None: no declared identity, +# so no ``find_by_id`` on the Protocol). The view derives from the seeded base table, so +# the harness models it by selecting and renaming those columns. +PROJECTIONS: dict[str, tuple[dict[str, str], str | None]] = { + "InvoiceSummary": ( + {"id": "id", "reference": "reference", "status": "status", "amountCents": "amountCents"}, + "id", + ), + # Keyed on `number`; the view has NO `id` column. + "InvoiceLedger": ( + {"number": "id", "reference": "reference", "discount": "discount", "weight": "weight"}, + "number", + ), + # No declared identity; the view carries an `id` column all the same. + "InvoiceStub": ({"id": "id", "reference": "reference"}, None), +} + + +def _load_projections(meta_json: Path) -> dict[str, MetaObject]: + """Load the corpus metadata and return its projections by name.""" # Copy meta.json into its own dir so the loader does not try to parse the # sibling seed.json / scenario yaml as metadata. tmp = Path(tempfile.mkdtemp(prefix="apic-proj-meta-")) @@ -52,44 +70,54 @@ def _find_projection(meta_json: Path) -> MetaObject: c for c in result.root.children() if c.type == TYPE_OBJECT and isinstance(c, MetaObject) ] - for obj in objects: - if obj.name == PROJECTION_NAME or obj.name.endswith(f"::{PROJECTION_NAME}"): - return obj - raise RuntimeError(f"{PROJECTION_NAME} not found among {[o.name for o in objects]}") + found: dict[str, MetaObject] = {} + for name in PROJECTIONS: + for obj in objects: + if obj.name == name or obj.name.endswith(f"::{name}"): + found[name] = obj + if name not in found: + raise RuntimeError(f"{name} not found among {[o.name for o in objects]}") + return found + + +def _snake(name: str) -> str: + return re.sub(r"(? tuple[FastAPI, "InMemoryProjectionRepository"]: - """Generate the projection router, import it, mount it, and wire the seam. +) -> tuple[FastAPI, dict[str, "InMemoryProjectionRepository"]]: + """Generate every projection router, import them, mount them, and wire the seams. - Returns ``(app, repo)``; the test resets/seeds ``repo`` per scenario. + Returns ``(app, repos)`` with one repo per projection name; the test resets and + seeds them per scenario. """ - projection = _find_projection(corpus_root / "meta.json") - - router_src = render_router(projection) - allowlist_src = render_filter_allowlist(projection) - if router_src is None: - raise RuntimeError( - f"router_generator returned None for the {PROJECTION_NAME} projection — " - "a view-only object must get a read-only router (F22)" - ) - if allowlist_src is None: - raise RuntimeError(f"filter_allowlist_generator returned None for {PROJECTION_NAME}") + projections = _load_projections(corpus_root / "meta.json") - # A uniquely-named temp package so the router's relative import - # `from .invoice_summary_filter_allowlist import ...` resolves. + # A uniquely-named temp package so each router's relative import + # `from ._filter_allowlist import ...` resolves. pkg_name = f"genproj_{uuid.uuid4().hex[:8]}" tmp = Path(tempfile.mkdtemp(prefix="apic-proj-gen-")) pkg_dir = tmp / pkg_name pkg_dir.mkdir() (pkg_dir / "__init__.py").write_text("") - (pkg_dir / "invoice_summary_filter_allowlist.py").write_text(allowlist_src) - (pkg_dir / "invoice_summary_router.py").write_text(router_src) # NOTE: no entity-model module is emitted here, and that is the contract — # the read-only router imports no Create / Patch validation model, because a # projection has no create or patch. If this harness ever needs one, the # generator has grown a write surface it should not have. + for name, projection in projections.items(): + snake = _snake(name) + router_src = render_router(projection) + allowlist_src = render_filter_allowlist(projection) + if router_src is None: + raise RuntimeError( + f"router_generator returned None for the {name} projection — " + "a view-only object must get a read-only router (F22)" + ) + if allowlist_src is None: + raise RuntimeError(f"filter_allowlist_generator returned None for {name}") + (pkg_dir / f"{snake}_filter_allowlist.py").write_text(allowlist_src) + (pkg_dir / f"{snake}_router.py").write_text(router_src) sys.path.insert(0, str(tmp)) pkg_spec = importlib.util.spec_from_file_location( @@ -98,36 +126,53 @@ def build_generated_projection_app( pkg_mod = importlib.util.module_from_spec(pkg_spec) sys.modules[pkg_name] = pkg_mod pkg_spec.loader.exec_module(pkg_mod) - spec = importlib.util.spec_from_file_location( - f"{pkg_name}.invoice_summary_router", pkg_dir / "invoice_summary_router.py" - ) - router_mod = importlib.util.module_from_spec(spec) - sys.modules[f"{pkg_name}.invoice_summary_router"] = router_mod - spec.loader.exec_module(router_mod) - repo = InMemoryProjectionRepository() app = FastAPI() - app.include_router(router_mod.router) - app.dependency_overrides[router_mod.get_repository] = lambda: repo - return app, repo + repos: dict[str, InMemoryProjectionRepository] = {} + for name, (columns, key) in PROJECTIONS.items(): + snake = _snake(name) + spec = importlib.util.spec_from_file_location( + f"{pkg_name}.{snake}_router", pkg_dir / f"{snake}_router.py" + ) + router_mod = importlib.util.module_from_spec(spec) + sys.modules[f"{pkg_name}.{snake}_router"] = router_mod + spec.loader.exec_module(router_mod) + + repo = InMemoryProjectionRepository(columns, key) + # The generated Protocol is the contract: a keyless projection must not have + # grown a `find_by_id`, and a keyed one must have kept it. + has_find = "find_by_id" in inspect.getsource(router_mod.__dict__[f"{name}Repository"]) + if has_find != (key is not None): + raise RuntimeError( + f"{name}: the generated repository Protocol " + f"{'has' if has_find else 'has no'} find_by_id, but the corpus " + f"{'declares' if key is not None else 'declares no'} identity" + ) + app.include_router(router_mod.router) + app.dependency_overrides[router_mod.get_repository] = lambda r=repo: r + repos[name] = repo + return app, repos class InMemoryProjectionRepository: - """In-memory impl of the GENERATED read-only ``InvoiceSummaryRepository``. + """In-memory impl of a GENERATED read-only ``Repository``. - Seeded with the corpus's base ``invoices`` rows — the view is a straight - projection of that table, so the harness models it as the same rows. Only - ``list`` / ``count`` / ``find_by_id`` exist, matching the generated Protocol. + Seeded with the corpus's base ``invoices`` rows, projected onto the view's columns + (the view is a straight projection of that table). Only ``list`` / ``count`` exist, + plus ``find_by_id`` for a projection that declares an identity, matching the + generated Protocol. """ - def __init__(self) -> None: + def __init__(self, columns: dict[str, str], key: str | None) -> None: + self._columns = columns + self._key = key self._rows: list[dict[str, Any]] = [] def reset(self) -> None: self._rows = [] def seed(self, rows: list[dict[str, Any]]) -> None: - self._rows = [dict(r) for r in rows] + self._rows = [{col: r[src] for col, src in self._columns.items()} for r in rows] def _coerce(self, field: str, raw: str) -> Any: for r in self._rows: @@ -180,15 +225,14 @@ def list(self, limit: int, offset: int, sort: Any, filters: list[FilterPredicate key=lambda r: (r.get(sort.field) is None, r.get(sort.field)), reverse=(sort.direction == "desc"), ) - else: - rows = sorted(rows, key=lambda r: r["id"]) + # No sort: the view's own order, which is the seed's (ascending by id). return [dict(r) for r in rows[offset : offset + limit]] def count(self, filters: list[FilterPredicate]) -> int: return len(self._filtered(filters)) - def find_by_id(self, id: int) -> Any | None: + def find_by_id(self, id: Any) -> Any | None: for r in self._rows: - if r["id"] == id: + if r[self._key] == id: return dict(r) return None diff --git a/server/python/tests/integration/test_api_contract_projection.py b/server/python/tests/integration/test_api_contract_projection.py index 215239472..6385f9e72 100644 --- a/server/python/tests/integration/test_api_contract_projection.py +++ b/server/python/tests/integration/test_api_contract_projection.py @@ -67,7 +67,7 @@ def _load_seed_rows() -> list[dict[str, Any]]: _SEED_ROWS = _load_seed_rows() _SCENARIOS = _load_scenarios() -_APP, _REPO = build_generated_projection_app(_CORPUS) +_APP, _REPOS = build_generated_projection_app(_CORPUS) _CLIENT = TestClient(_APP) @@ -78,10 +78,11 @@ def _load_seed_rows() -> list[dict[str, Any]]: ) def test_projection_scenario(scenario_name: str, scenario: dict[str, Any]) -> None: """Run one projection api-contract scenario against the GENERATED router.""" - _REPO.reset() setup = scenario.get("setup") or {} - if not setup.get("truncate"): - _REPO.seed(_SEED_ROWS) + for repo in _REPOS.values(): + repo.reset() + if not setup.get("truncate"): + repo.seed(_SEED_ROWS) for req in scenario.get("requests", []): _run_request(scenario_name, req) diff --git a/server/typescript/packages/codegen-ts-tanstack/test/report-no-ui-tier.test.ts b/server/typescript/packages/codegen-ts-tanstack/test/report-no-ui-tier.test.ts index 628c007d9..6265a9ae0 100644 --- a/server/typescript/packages/codegen-ts-tanstack/test/report-no-ui-tier.test.ts +++ b/server/typescript/packages/codegen-ts-tanstack/test/report-no-ui-tier.test.ts @@ -101,7 +101,7 @@ describe("a keyless projection gets a list hook and no detail hook", () => { { "identity.primary": { name: "id", extends: "Tag.id" } }, ] : []), - // An `id` column and no declared identity: addressed by convention. + // An `id` column and no declared identity: a convention, not a key. ...(shape === "id-by-convention" ? [{ "field.long": { name: "id" } }] : []), { "field.string": { name: "label", extends: "Tag.label" } }, ], @@ -134,17 +134,26 @@ describe("a keyless projection gets a list hook and no detail hook", () => { expect(out).not.toContain("/${id}"); }); - for (const shape of ["identity", "id-by-convention"] as const) { - test(`${shape}: the detail hook and its keys are still there`, async () => { - const { obj, out } = await projection(shape); - expect(obj.primaryIdentity() === undefined).toBe(shape === "id-by-convention"); - expect(hasItemRoute(obj)).toBe(true); - expect(out).toContain("export function useTagLabel("); - expect(out).toContain("export function useTagLabels("); - expect(out).toContain("details:"); - expect(out).toContain("detail:"); - }); - } + test("identity: the detail hook and its keys are still there", async () => { + const { obj, out } = await projection("identity"); + expect(obj.primaryIdentity()).toBeDefined(); + expect(hasItemRoute(obj)).toBe(true); + expect(out).toContain("export function useTagLabel("); + expect(out).toContain("export function useTagLabels("); + expect(out).toContain("details:"); + expect(out).toContain("detail:"); + }); + + test("id-by-convention: an `id` field is not a declared key, so no detail hook either", async () => { + const { obj, out } = await projection("id-by-convention"); + expect(obj.primaryIdentity()).toBeUndefined(); + expect(obj.findField("id")).toBeDefined(); + expect(hasItemRoute(obj)).toBe(false); + expect(out).toContain("export function useTagLabels("); + expect(out).not.toContain("export function useTagLabel("); + expect(out).not.toContain("details:"); + expect(out).not.toContain("detail:"); + }); }); // The grid generators gate on `servesClientTier` AND on a `layout.dataGrid`. Through diff --git a/server/typescript/packages/codegen-ts/src/api-surface.ts b/server/typescript/packages/codegen-ts/src/api-surface.ts index 4d6000fd1..91ff5978e 100644 --- a/server/typescript/packages/codegen-ts/src/api-surface.ts +++ b/server/typescript/packages/codegen-ts/src/api-surface.ts @@ -27,7 +27,7 @@ import type { MetaObject } from "@metaobjectsdev/metadata"; import { isAbstract } from "./instance-artifacts.js"; import { isProjection } from "./projection/projection-detector.js"; import { hasAnyRdbSource, hasWritableRdbSource, isReport, servedReport } from "./source-detect.js"; -import { DEFAULT_ID_FIELD, getPkFields } from "./templates/queries.js"; +import { getPkFields } from "./templates/queries.js"; import { resourcePath, restPath } from "./templates/entity-ui-descriptor.js"; import { declaresTphDiscriminator, @@ -58,23 +58,16 @@ export function servesReadApi(entity: MetaObject): boolean { /** * True when the read-only surface of the object has `/:id` routes, a by-id query and a - * detail hook: the column a row would be addressed by actually exists on it. + * detail hook: the object declares a primary identity, and the column it names is a field + * of the object. * * - A report (the declared node or its read model) never has one, even when a derived * field happens to be named `id`: a report has no identity, and its rows are groups. - * - Otherwise the answer is whether the by-id column resolves to a field of the object. - * That column is `getPkInfo`'s: the first field of the primary identity, or `id` by - * convention when the object declares no primary identity. So a projection with an - * identity has item routes, a projection with no identity and a field named `id` has - * them too (it always did, and they work), and a projection with neither has none. - * - * A composite identity answers true, as before this predicate existed: its by-id query - * reads the first component. Nothing here changes what that shape generates. - * - * This is NOT the JVM's `RestSurfaceGate.hasItemRoute`, which requires a DECLARED - * single-column primary identity. TypeScript has always also served the `id`-by-convention - * projection, and this keeps doing so. The one thing removed is a surface that could never - * serve a row: no identity and no `id` column. + * - A projection with no declared primary identity has none either, even when it has a + * field named `id`. There is no declared key to address a row by, and "a field called + * `id`" is a convention, not a key. This is the JVM and C# rule + * (`RestSurfaceGate.hasItemRoute`), and the rule of the cross-port `projection/` corpus. + * - A composite identity answers true: its by-id query reads the first component. * * Only the read-only templates ask. The writable surface emits item routes unconditionally. */ @@ -95,10 +88,10 @@ export function hasItemRoute(entity: MetaObject): boolean { export function itemRouteField(entity: MetaObject): string | undefined { if (isReport(entity)) return undefined; // ADR-0039: resolving. `getPkFields` reads `primaryIdentity()` and `findField` reads - // `fields()`, both of which walk the super chain; a projection's identity and its `id` + // `fields()`, both of which walk the super chain; a projection's identity and its key // field are typically inherited from its base entity. - const idField = getPkFields(entity)[0] ?? DEFAULT_ID_FIELD; - return entity.findField(idField) !== undefined ? idField : undefined; + const idField = getPkFields(entity)[0]; + return idField !== undefined && entity.findField(idField) !== undefined ? idField : undefined; } /** diff --git a/server/typescript/packages/codegen-ts/test/golden/api-docs-accuracy.test.ts b/server/typescript/packages/codegen-ts/test/golden/api-docs-accuracy.test.ts index 41eba0252..d4a320e9e 100644 --- a/server/typescript/packages/codegen-ts/test/golden/api-docs-accuracy.test.ts +++ b/server/typescript/packages/codegen-ts/test/golden/api-docs-accuracy.test.ts @@ -1125,13 +1125,14 @@ const READ_WRITE_FIXTURE = JSON.stringify({ { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, { "identity.reference": { name: "fkCustomer", "@fields": "customerId", "@references": "Customer" } }, ] } }, - // Read-only, keyed: an `id` column by convention addresses a row. + // Read-only, keyed: a declared identity addresses a row. { "object.projection": { name: "CustomerCard", children: [ { "source.rdb": { "@kind": "view", "@table": "v_customer_card", "@unmanaged": true } }, - { "field.long": { name: "id" } }, + { "field.long": { name: "id", extends: "Customer.id" } }, { "field.string": { name: "name" } }, + { "identity.primary": { name: "id", extends: "Customer.id" } }, ] } }, - // Read-only, keyless: no identity and no `id` column. + // Read-only, keyless: no declared identity (this one has no `id` column either). { "object.projection": { name: "RegionTotal", children: [ { "source.rdb": { "@kind": "view", "@table": "v_region_total", "@unmanaged": true } }, { "field.string": { name: "region" } }, diff --git a/server/typescript/packages/codegen-ts/test/projection-docs-routes.test.ts b/server/typescript/packages/codegen-ts/test/projection-docs-routes.test.ts new file mode 100644 index 000000000..f87599edf --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/projection-docs-routes.test.ts @@ -0,0 +1,45 @@ +// The api-contract `projection/` corpus, docs half: the REST routes a read-only +// projection's api page lists are exactly the routes its generated surface answers with +// a row. Every port runs the same assertion over the same model and the same expected +// set (fixtures/api-contract-conformance/projection/docs-routes.json): +// +// - a projection with a declared identity lists `GET ` and `GET /{id}`; +// - one with none lists `GET ` alone, even when it has a field named `id`; +// - no unit lists a write verb, because none is documented as a usable operation. +// +// The booted-server half of the same contract is the corpus scenarios themselves +// (`test/api-contract-projection.test.ts` in the integration-tests package). + +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { InMemoryStringSource, MetaDataLoader } from "@metaobjectsdev/metadata"; +import { buildApiModel } from "../src/generators/api-model.js"; + +// test -> codegen-ts -> packages -> typescript -> server -> repo root +const CORPUS = join(resolve(import.meta.dir, "..", "..", "..", "..", ".."), "fixtures", "api-contract-conformance", "projection"); + +/** The spelling every port's expected set uses: no leading `/` or `/api` prefix, `{id}`. */ +function normalize(symbol: string): string { + return symbol.replace(/^(\w+) \/?(?:api\/)?/, "$1 ").replace(/:id\b/g, "{id}"); +} + +describe("api docs: the routes a read-only projection documents (projection/ corpus)", () => { + const expected = JSON.parse(readFileSync(join(CORPUS, "docs-routes.json"), "utf8")) as { + units: Record; + }; + + test("each projection documents exactly the routes it mounts", async () => { + const res = await new MetaDataLoader().load([ + new InMemoryStringSource(readFileSync(join(CORPUS, "meta.json"), "utf8"), { id: "meta.json", format: "json" }), + ]); + expect(res.errors).toEqual([]); + const model = buildApiModel(res.root, { loadedRoot: res.root }); + for (const [node, routes] of Object.entries(expected.units)) { + const unit = model.units.find((u) => u.node === node); + expect(unit).toBeDefined(); + const documented = unit!.symbols.filter((s) => s.kind === "rest").map((s) => normalize(s.name)); + expect({ node, documented: [...documented].sort() }).toEqual({ node, documented: [...routes].sort() }); + } + }); +}); diff --git a/server/typescript/packages/codegen-ts/test/projection/queries-file.test.ts b/server/typescript/packages/codegen-ts/test/projection/queries-file.test.ts index 1af251adf..3c718fe7b 100644 --- a/server/typescript/packages/codegen-ts/test/projection/queries-file.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/queries-file.test.ts @@ -57,7 +57,8 @@ async function loadProjectionFixture() { name: "ProgramSummary", children: [ { "source.rdb": { "@kind": "view", "@table": "v_program_summary" } }, - { "field.int": { name: "id" } }, + { "field.int": { name: "id", extends: "Program.id" } }, + { "identity.primary": { name: "id", extends: "Program.id" } }, { "field.int": { name: "weekCount", @@ -216,14 +217,40 @@ describe("renderQueriesFile — a served report and a keyless projection (FR-044 expect(out).not.toContain("projection"); }); - test("a projection with an `id` column and no declared identity keeps its by-id query", async () => { - // The id-by-convention shape: `getPkInfo` falls back to `id`, the column exists. - // It is not keyless, so the generated by-id query text does not move. - const { projection, ctx } = await loadProjectionFixture(); + test("a projection with an `id` column and no declared identity gets no by-id query", async () => { + // A field named `id` is a convention, not a declared key, so it is not addressable + // (the JVM and C# rule, and the cross-port `projection/` corpus). + const root = await loadMetadata([ + { + "object.entity": { + name: "Program", + children: [ + { "source.rdb": { "@table": "programs" } }, + { "field.int": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + ], + }, + }, + { + "object.projection": { + name: "ProgramRow", + children: [ + { "source.rdb": { "@kind": "view", "@table": "v_program_row" } }, + { "field.int": { name: "id" } }, + ], + }, + }, + ]); + const projection = root.objects().find((o) => o.name === "ProgramRow"); + if (!projection) throw new Error("ProgramRow not found"); expect(projection.primaryIdentity()).toBeUndefined(); + const ctx = makeRenderContext({ + dialect: "sqlite", loadedRoot: root, outDir: "/x", dbImport: "~/db", + pkMap: buildPkMap(root), relationMap: buildRelationMap(root), + }); const out = renderQueriesFile(projection, ctx); - expect(out).toContain("export async function findProgramSummaryById(db: Db, id: number)"); - expect(out).toContain("eq(programSummaryView.id, id)"); + expect(out).toContain("export async function listProgramRows("); + expect(out).not.toContain("ById"); }); test("a report with a derived field named `id` still gets no by-id query", async () => { diff --git a/server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts b/server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts index 19be3b945..637e91cb5 100644 --- a/server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts +++ b/server/typescript/packages/codegen-ts/test/projection/routes-file.test.ts @@ -426,9 +426,9 @@ describe("renderRoutesFile — a served report (FR-044 Plan 3)", () => { } }); - test("a projection with an `id` column and no declared identity is unchanged", async () => { - // The id-by-convention shape: the mount addresses `id` by default and the column is - // there, so the item routes work and stay. + test("a projection with an `id` column and no declared identity mounts no item routes", async () => { + // A field named `id` is a convention, not a declared key: nothing addresses a row, so + // no `/:id` route of any verb is mounted (the JVM and C# rule). const root = await loadMetadata([ { "object.entity": { @@ -452,14 +452,14 @@ describe("renderRoutesFile — a served report (FR-044 Plan 3)", () => { ]); const projection = declared(root, "ProgramRow"); expect(projection.primaryIdentity()).toBeUndefined(); - expect(hasItemRoute(projection)).toBe(true); + expect(hasItemRoute(projection)).toBe(false); const ctx = makeRenderContext({ dialect: "sqlite", loadedRoot: root, outDir: "/x", dbImport: "~/db", pkMap: buildPkMap(root), relationMap: buildRelationMap(root), }); for (const out of [renderRoutesFile(projection, ctx), renderRoutesFileHono(projection, ctx)]) { - expect(out).toContain("Exposes GET list + GET :id only. POST/PATCH/DELETE return 405."); - expect(out).not.toContain("itemRoutes"); + expect(out).toContain("itemRoutes: false,"); + expect(out).not.toContain("GET :id"); } }); diff --git a/server/typescript/packages/codegen-ts/test/reporting-docs.test.ts b/server/typescript/packages/codegen-ts/test/reporting-docs.test.ts index 5219b8042..09910b3bc 100644 --- a/server/typescript/packages/codegen-ts/test/reporting-docs.test.ts +++ b/server/typescript/packages/codegen-ts/test/reporting-docs.test.ts @@ -367,9 +367,10 @@ describe("FR-044 answer 4: a keyless projection documents no item surface", () = expect(symbols).not.toContain("findRegionTotalsById"); }); - test("an id column by convention keeps both (unchanged)", async () => { + test("an id column by convention is not a declared key: no item surface either", async () => { const symbols = names(await loadJson(model(true))); - expect(symbols).toContain("GET /api/region_totals/:id"); - expect(symbols).toContain("findRegionTotalsById"); + expect(symbols).toContain("GET /api/region_totals"); + expect(symbols.filter((n) => n.includes(":id"))).toEqual([]); + expect(symbols).not.toContain("findRegionTotalsById"); }); }); diff --git a/server/typescript/packages/integration-tests/src/api-contract-projection-generated-server.ts b/server/typescript/packages/integration-tests/src/api-contract-projection-generated-server.ts index eeac73f36..7d88014f6 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-projection-generated-server.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-projection-generated-server.ts @@ -29,7 +29,14 @@ import { executeSql } from "./postgres-sql.ts"; import { loadMetadataFile } from "./load-metadata.ts"; export interface ProjectionSeed { - invoices: Array<{ id: number; reference: string; status: string; amountCents: number }>; + invoices: Array<{ + id: number; + reference: string; + status: string; + amountCents: number; + discount: number; + weight: number; + }>; } export interface GeneratedProjectionServerHandle { @@ -84,20 +91,36 @@ export const db = drizzle(pool); "id" bigserial PRIMARY KEY, "reference" varchar(40) NOT NULL, "status" varchar(20) NOT NULL, - "amount_cents" bigint NOT NULL + "amount_cents" bigint NOT NULL, + "discount" numeric(10,2), + "weight" real ); CREATE OR REPLACE VIEW "v_invoice_summary" AS SELECT "id", "reference", "status", "amount_cents" FROM "invoices"; + -- InvoiceLedger: its key is the field \`number\`, and the view has NO id column. + CREATE OR REPLACE VIEW "v_invoice_ledger" AS + SELECT "id" AS "number", "reference", "discount", "weight" FROM "invoices"; + -- InvoiceStub: no declared identity; the view carries an id column all the same. + CREATE OR REPLACE VIEW "v_invoice_stub" AS + SELECT "id", "reference" FROM "invoices"; `); // 4. Import the EMITTED InvoiceSummary route file unmodified and mount it. const routes = (await import( pathToFileURL(join(tmp, "InvoiceSummary.routes.ts")).href )) as { invoiceSummaryRoutes: (f: FastifyInstance) => Promise }; + const ledgerRoutes = (await import( + pathToFileURL(join(tmp, "InvoiceLedger.routes.ts")).href + )) as { invoiceLedgerRoutes: (f: FastifyInstance) => Promise }; + const stubRoutes = (await import( + pathToFileURL(join(tmp, "InvoiceStub.routes.ts")).href + )) as { invoiceStubRoutes: (f: FastifyInstance) => Promise }; const dbMod = (await import(pathToFileURL(join(tmp, "db.ts")).href)) as { pool: pg.Pool }; const fastify = Fastify(); await fastify.register(routes.invoiceSummaryRoutes); + await fastify.register(ledgerRoutes.invoiceLedgerRoutes); + await fastify.register(stubRoutes.invoiceStubRoutes); await fastify.ready(); const baseUrl = await fastify.listen({ host: "127.0.0.1", port: 0 }); @@ -121,8 +144,8 @@ export async function seedProjection(connectionUri: string, seed: ProjectionSeed for (const i of seed.invoices) { await executeSql( connectionUri, - `INSERT INTO "invoices" ("id","reference","status","amount_cents") - VALUES (${i.id}, ${str(i.reference)}, ${str(i.status)}, ${i.amountCents})`, + `INSERT INTO "invoices" ("id","reference","status","amount_cents","discount","weight") + VALUES (${i.id}, ${str(i.reference)}, ${str(i.status)}, ${i.amountCents}, ${i.discount}, ${i.weight})`, ); } } diff --git a/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts b/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts index 838f95833..5fb65a2d4 100644 --- a/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts +++ b/server/typescript/packages/integration-tests/test/api-contract-report-corpus.test.ts @@ -45,9 +45,9 @@ describe("api-contract report corpus", () => { expect(Object.keys(seed.reports)).toEqual(["InvoiceStatusTotals", "InvoicesByMonth", "InvoiceTotals"]); }); - test("every scenario parses, and there are twelve", () => { + test("every scenario parses, and there are thirteen", () => { const scenarios = loadScenarios(API_CONTRACT_REPORT_SCENARIOS_DIR); - expect(scenarios.length).toBe(12); + expect(scenarios.length).toBe(13); for (const s of scenarios) expect(s.requests.length).toBeGreaterThan(0); });