Skip to content

Commit ae0ce4f

Browse files
committed
Merge FR-009 — filter operators across all 5 ports + 20/20 cross-port api-contract
# Conflicts: # server/python/src/metaobjects/meta/core/field/field_constants.py
2 parents 82b3498 + 37289f4 commit ae0ce4f

54 files changed

Lines changed: 4161 additions & 134 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/CONFORMANCE.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ human-readable explanation somewhere, look it up in the
1919
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | 31 / 31 | 31 / 31 | inherits via Java | 31 / 31 | 31 / 31 |
2020
| [`fixtures/render-conformance/`](../fixtures/render-conformance/) | 4 | 4 / 4 | 4 / 4 | inherits via Java | 4 / 4 | 4 / 4 |
2121
| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 12 (9 query + 3 migration) | 12 / 12 | 12 / 12 | 12 / 12 (via Exposed) | 12 / 12 | 12 / 12 |
22-
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 10 | 10 / 10 (Fastify reference runner) | 10 / 10 (embedded HTTP + JDBC reference runner) | 10 / 10 (embedded HTTP + Exposed reference runner) | 10 / 10 (HttpListener + Npgsql reference runner) | 10 / 10 (FastAPI + pg8000 reference runner) |
22+
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 20 | 20 / 20 (Fastify reference runner) | 20 / 20 (embedded HTTP + JDBC reference runner) | 20 / 20 (embedded HTTP + Exposed reference runner) | 20 / 20 (HttpListener + Npgsql reference runner) | 20 / 20 (FastAPI + pg8000 reference runner) |
2323
| `fixtures/codegen-conformance/` (FR-007 — DROPPED in favor of `persistence-conformance` participation) | 0 | n/a | n/a | n/a | n/a | n/a |
2424

2525
Per-port runners + commands:
@@ -79,16 +79,20 @@ All 31 fixtures → [features/migrations-and-drift.md](features/migrations-and-d
7979
- `migrations/*` (3) → [features/migrations-and-drift.md](features/migrations-and-drift.md) (schema migration section)
8080
- `queries/*` (9) → [features/source-kinds.md](features/source-kinds.md) (query semantics against `source.rdb`)
8181

82-
### `fixtures/api-contract-conformance/` (10)
82+
### `fixtures/api-contract-conformance/` (20)
8383

84-
All 10 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port
84+
All 20 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port
8585
REST API URL grammar + JSON wire format). Verifies every backend's emitted CRUD
8686
routes answer identically over HTTP — list / get / create / patch+put / delete,
8787
plus pagination (`limit`/`offset`), sort (`sort=field:dir`), the `withCount=1`
8888
envelope, the `not_found` / `invalid_sort` error envelopes, and the 201 / 204
89-
status codes. Filter operators (`eq`/`ne`/...) deferred — backends defer these
90-
per their `KNOWN_GAPS.md`; conformance for filter operators arrives when the
91-
operators ship per port.
89+
status codes.
90+
91+
The corpus also covers the 9 cross-port filter operators (`eq`, `ne`, `gt`,
92+
`lt`, `in`, `like`, `isNull`, plus the implicit-AND combinator and 2 error
93+
shapes — `invalid_filter_field` / `invalid_filter_op`) under the URL grammar
94+
`?filter[<field>][<op>]=<value>` (FR-009). All 5 ports — TS, Java, Kotlin,
95+
C#, Python — satisfy these 10 scenarios today.
9296

9397
## Orphaned fixtures (tested but not yet documented)
9498

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# FR-009 — Cross-port filter operators in generated REST routes
2+
3+
- **Date:** 2026-05-26
4+
- **Status:** Design — plan-of-record. Resolves the `KNOWN_GAPS.md` "filter operators" deferral in all 5 route-codegen modules.
5+
- **Target version:** 7.0.0
6+
- **Scope:** Wire the 9 filter operators (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `like`, `isNull`) into every port's generated routes + add API contract conformance scenarios that gate them across ports.
7+
8+
## 1. Background
9+
10+
The cross-port REST API contract ([`docs/features/api-contract.md`](../../docs/features/api-contract.md)) declares 9 filter operators with the URL grammar `?filter[<field>][<op>]=<value>` (or `?filter[<field>]=<value>` as sugar for `eq`).
11+
12+
**Current state:** TS already implements the full operator set in `runtime-ts/drizzle-fastify` (Project D). The 4 backend route generators that shipped under FR-008 (Java Spring, Kotlin Spring, C# Minimal API, Python FastAPI) all deferred filter operator handling to their respective `KNOWN_GAPS.md`. **None of the 4 newly-generated backends actually filter today** — they only sort + paginate.
13+
14+
This FR closes that gap port-by-port and adds conformance scenarios so the contract stays gated cross-port.
15+
16+
## 2. The contract (Tier 1 invariant)
17+
18+
Per [`docs/features/api-contract.md`](../../docs/features/api-contract.md):
19+
20+
| Operator | Field-subtype gating | URL form |
21+
|---|---|---|
22+
| `eq` | all subtypes | `?filter[name][eq]=Ada` OR `?filter[name]=Ada` |
23+
| `ne` | all subtypes | `?filter[name][ne]=Ada` |
24+
| `gt` | numbers, dates, currency | `?filter[priceCents][gt]=10000` |
25+
| `gte` | numbers, dates, currency | `?filter[createdAt][gte]=2026-01-01` |
26+
| `lt` | numbers, dates, currency | `?filter[priceCents][lt]=50000` |
27+
| `lte` | numbers, dates, currency | `?filter[priceCents][lte]=20000` |
28+
| `in` | all subtypes | `?filter[status][in]=DRAFT,PUBLISHED` (comma-separated) |
29+
| `like` | strings | `?filter[name][like]=%Ada%` (SQL `LIKE` semantics — `%` wildcard) |
30+
| `isNull` | nullable fields | `?filter[bio][isNull]=true` (or `false`) |
31+
32+
**Field allowlist** — only fields declared with `@filterable: true` in the metadata appear in the per-entity allowlist. Unknown field → HTTP 400 `{"error": "invalid_filter_field"}`. Disallowed operator-for-subtype → HTTP 400 `{"error": "invalid_filter_op"}`. Invalid value coercion → HTTP 400 `{"error": "invalid_filter_value"}`.
33+
34+
**Type coercion**:
35+
- `number` / `currency` → integer parsing; reject NaN / non-integer
36+
- `date` / `timestamp` → ISO 8601 string accepted directly
37+
- `string` → URL-decoded as-is
38+
- `boolean``"true"` / `"false"`
39+
- `isNull` value → `"true"` / `"false"`
40+
41+
**Combinator**: multiple filter params AND together. `?filter[name][like]=%Ada%&filter[priceCents][gt]=10000` = `name LIKE '%Ada%' AND priceCents > 10000`. No OR support in this FR (would require a richer URL grammar — separate FR).
42+
43+
## 3. Per-port implementation
44+
45+
Each port emits an `<Entity>FilterAllowlist` constant + a `parseFilter` helper that validates URL params against the allowlist + dispatches to the substrate's predicate type.
46+
47+
### 3.1 TypeScript
48+
49+
Already implemented in `runtime-ts/drizzle-fastify/parse-filter.ts`. **Scope here**: verify all 9 operators are covered + add conformance scenarios that gate them.
50+
51+
### 3.2 Java (Spring) — `codegen-spring`
52+
53+
Generator addition: `SpringFilterAllowlistGenerator` — emits `<Entity>FilterAllowlist.java` per entity:
54+
55+
```java
56+
package acme.blog;
57+
58+
import java.util.Map;
59+
import java.util.Set;
60+
61+
public final class AuthorFilterAllowlist {
62+
public static final Set<String> FIELDS = Set.of("name", "createdAt", "bio");
63+
public static final Map<String, Set<String>> OPS_BY_FIELD = Map.of(
64+
"name", Set.of("eq", "ne", "in", "like", "isNull"),
65+
"createdAt", Set.of("eq", "ne", "gt", "gte", "lt", "lte", "isNull"),
66+
"bio", Set.of("eq", "ne", "like", "isNull")
67+
);
68+
}
69+
```
70+
71+
Plus modify `SpringControllerGenerator.list()` to call a shared `FilterParser` runtime helper:
72+
73+
```java
74+
@GetMapping
75+
public ResponseEntity<?> list(/* ... existing params, plus: */
76+
@RequestParam Map<String, String> allParams
77+
) {
78+
// ...
79+
FilterParseResult filter = FilterParser.parse(allParams, AuthorFilterAllowlist.FIELDS, AuthorFilterAllowlist.OPS_BY_FIELD);
80+
if (filter.error() != null) return ResponseEntity.badRequest().body(Map.of("error", filter.error()));
81+
List<AuthorDto> rows = repository.list(actualLimit, actualOffset, sortClause, filter.predicates());
82+
// ...
83+
}
84+
```
85+
86+
The repository interface gains a `List<FilterPredicate>` parameter that the consumer translates to their persistence DSL.
87+
88+
NEW small runtime: `server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/runtime/FilterParser.java` — a substrate-agnostic parser that returns a `List<FilterPredicate(field, op, valueRaw)>` for the consumer to dispatch. Ships as part of codegen-spring (not a separate module — too small to warrant one).
89+
90+
### 3.3 Kotlin (Spring-Kotlin) — `codegen-kotlin`
91+
92+
Same pattern. New generator method on `KotlinSpringControllerGenerator` that emits `<Entity>FilterAllowlist.kt` + threads `filter: List<FilterPredicate>` through to the consumer's repository.
93+
94+
For Exposed substrate: include a small `exposedFilterDispatch(table, predicates)` helper in the consumer-facing module that converts `List<FilterPredicate>` to an Exposed `Op<Boolean>`. Generator emits the call.
95+
96+
### 3.4 C# (ASP.NET) — `MetaObjects.Codegen`
97+
98+
Add `<Entity>FilterAllowlist.cs` to `RoutesGenerator` output:
99+
100+
```csharp
101+
public static class AuthorFilterAllowlist
102+
{
103+
public static readonly HashSet<string> Fields = new() { "name", "createdAt", "bio" };
104+
public static readonly Dictionary<string, HashSet<string>> OpsByField = new()
105+
{
106+
["name"] = new() { "eq", "ne", "in", "like", "isNull" },
107+
["createdAt"] = new() { "eq", "ne", "gt", "gte", "lt", "lte", "isNull" },
108+
["bio"] = new() { "eq", "ne", "like", "isNull" }
109+
};
110+
}
111+
```
112+
113+
Modify `RoutesGenerator.GenerateOne()` to wire `FilterParser` in the list handler + emit an `EF.Property<>`-based dispatcher per entity that converts `FilterPredicate` to `Expression<Func<T,bool>>`. Closes C# `KNOWN_GAPS.md` G1 entirely.
114+
115+
### 3.5 Python (FastAPI) — `router_generator`
116+
117+
Add `<Entity>_filter_allowlist.py` per entity + `parse_filter` helper. Repository Protocol gains a `filter: list[FilterPredicate]` parameter.
118+
119+
For SQLAlchemy: ship a `_apply_filter(query, predicates, model)` helper that calls `getattr(model, predicate.field)` + dispatches the op via a small map. Consumer's repository imports it.
120+
121+
## 4. Cross-port API conformance — new scenarios
122+
123+
Add to `fixtures/api-contract-conformance/scenarios/` (NEW; current corpus has 10 scenarios — add 8 filter scenarios for a final 18):
124+
125+
- `filter-eq``?filter[name][eq]=Ada` → 1 row
126+
- `filter-ne``?filter[name][ne]=Ada` → 4 rows
127+
- `filter-gt``?filter[id][gt]=2` → ids > 2
128+
- `filter-lt``?filter[id][lt]=3` → ids < 3
129+
- `filter-in``?filter[name][in]=Ada,Alan` → 2 rows
130+
- `filter-like``?filter[name][like]=A%` → 2 rows (Ada, Alan)
131+
- `filter-isnull-true``?filter[bio][isNull]=true` → rows with null bio
132+
- `filter-and``?filter[name][like]=A%&filter[id][gt]=1` → AND combinator works
133+
134+
Plus 2 error scenarios:
135+
136+
- `filter-invalid-field``?filter[unknown][eq]=x` → 400 `{"error": "invalid_filter_field"}`
137+
- `filter-invalid-op``?filter[name][gt]=Ada` (string with numeric op) → 400 `{"error": "invalid_filter_op"}`
138+
139+
Total scenarios: 10 existing + 8 new + 2 error = **20 scenarios**.
140+
141+
Each port's runner picks them up automatically (parameterized over `scenarios/`).
142+
143+
## 5. Field allowlist authoring
144+
145+
Per CLAUDE.md Project D: mark a field with `@filterable: true` in metadata to include it in the allowlist. The metadata loader already validates `@filterable` (existing Java + Python + C# + TS support — verify).
146+
147+
For each port's generator: walk the entity's `field.*` children, include those where `@filterable: true` in the FILTERABLE set. Default behavior (no `@filterable` attr) = NOT filterable.
148+
149+
Operators-per-subtype mapping (CLAUDE.md Project D):
150+
- string subtypes: `eq`, `ne`, `in`, `like`, `isNull`
151+
- numeric / date / currency / timestamp: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `isNull`
152+
- boolean: `eq`, `isNull`
153+
154+
## 6. Implementation order
155+
156+
1. TS conformance scenarios — verify TS runtime supports all 9 (likely already does) + add 10 new scenarios
157+
2. Run TS test → 20/20 reference
158+
3. Java port — `SpringFilterAllowlistGenerator` + `FilterParser` runtime + 20/20 conformance
159+
4. Kotlin port — same shape, Spring-Kotlin
160+
5. C# port — closes KNOWN_GAPS G1 entirely
161+
6. Python port — `router_generator` extension + FastAPI 20/20
162+
163+
Each port = focused agent run (~1-2 hours each).
164+
165+
## 7. Out of scope (deferred)
166+
167+
- **OR combinator** (`?filter[name][eq]=Ada|filter[id][gt]=10`) — needs richer URL grammar; separate FR if real consumer demand
168+
- **Nested-field filters** (`?filter[author.name][eq]=Ada`) — joins are a separate concern; relationship navigation in REST
169+
- **Full-text search** — substrate-specific; out of cross-port contract
170+
- **Case-insensitive `like`** (`ilike`) — Postgres-specific; consumer post-processes if needed
171+
172+
## 8. Cross-references
173+
174+
- [`docs/features/api-contract.md`](../../docs/features/api-contract.md) — the URL grammar this FR implements
175+
- [`fixtures/api-contract-conformance/`](../../fixtures/api-contract-conformance/) — corpus to extend
176+
- TS reference: `server/typescript/packages/runtime-ts/src/drizzle-fastify/parse-filter.ts` + CLAUDE.md "Filter syntax + sort (Project D)"
177+
- KNOWN_GAPS to close:
178+
- `server/csharp/MetaObjects.Codegen/Generators/KNOWN_GAPS.md` (G1)
179+
- `server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/KNOWN_GAPS.md`
180+
- `server/python/src/metaobjects/codegen/KNOWN_GAPS.md`
181+
- `KotlinSpringControllerGenerator.kt` docstring deferral note

fixtures/api-contract-conformance/README.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,17 @@ fixtures/api-contract-conformance/
2828
├── create-201.yaml
2929
├── update-patch-and-put.yaml
3030
├── delete-204-and-404.yaml
31-
└── invalid-sort-400.yaml
31+
├── invalid-sort-400.yaml
32+
├── filter-eq.yaml # FR-009 filter operators
33+
├── filter-ne.yaml
34+
├── filter-gt.yaml
35+
├── filter-lt.yaml
36+
├── filter-in.yaml
37+
├── filter-like.yaml
38+
├── filter-isnull-true.yaml
39+
├── filter-and.yaml
40+
├── filter-invalid-field.yaml
41+
└── filter-invalid-op.yaml
3242
```
3343

3444
`meta.json` declares a single canonical `Author` entity in the `acme::blog`
@@ -85,6 +95,34 @@ Runners are responsible for normalizing `createdAt` (and any other
8595
non-deterministic fields) before comparison. The keys listed above are
8696
the only ones a runner must understand to be conformant.
8797

98+
## Filter operator coverage (FR-009)
99+
100+
The `filter-*` scenarios pin the 9 cross-port filter operators declared in
101+
[`docs/features/api-contract.md`](../../docs/features/api-contract.md) under
102+
the URL grammar `?filter[<field>][<op>]=<value>` (with bare `?filter[<field>]=<value>`
103+
sugar = `eq`). Coverage:
104+
105+
| Scenario | Operator | Path |
106+
|---|---|---|
107+
| `filter-eq` | `eq` | `?filter[name][eq]=Ada%20Lovelace` |
108+
| `filter-ne` | `ne` | `?filter[name][ne]=Ada%20Lovelace` |
109+
| `filter-gt` | `gt` (numeric) | `?filter[id][gt]=2` |
110+
| `filter-lt` | `lt` (numeric) | `?filter[id][lt]=3` |
111+
| `filter-in` | `in` (comma-sep) | `?filter[name][in]=Ada%20Lovelace,Alan%20Turing` |
112+
| `filter-like` | `like` (SQL `%` wildcard, URL-encoded `%25`) | `?filter[name][like]=A%25` |
113+
| `filter-isnull-true` | `isNull=true` | `?filter[bio][isNull]=true` |
114+
| `filter-and` | implicit-AND combinator across multiple `filter[...]` params | `?filter[name][like]=A%25&filter[id][gt]=1` |
115+
| `filter-invalid-field` | error: unknown field → 400 `{"error":"invalid_filter_field"}` | `?filter[unknown][eq]=x` |
116+
| `filter-invalid-op` | error: op-subtype mismatch → 400 `{"error":"invalid_filter_op"}` | `?filter[name][gt]=Ada` |
117+
118+
`gte` and `lte` are derivable from `gt`/`lt` + boundary value; the corpus pins
119+
the 9 listed above to keep the matrix focused. Per the cross-port operator-
120+
subtype matrix (`docs/features/api-contract.md`):
121+
122+
- **string** subtypes accept `eq`, `ne`, `in`, `like`, `isNull`
123+
- **numeric / date / timestamp** subtypes accept `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `isNull`
124+
- **boolean** subtypes accept `eq`, `isNull`
125+
88126
## How a port's runner works
89127

90128
Every per-port runner:
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: filter-and
2+
description: >
3+
GET /api/authors?filter[name][like]=A%25&filter[id][gt]=1 — multiple filter
4+
params AND together implicitly. Names starting with `A` AND id > 1 leaves
5+
only Alan Turing (id=2). Verifies the implicit-AND combinator across
6+
multiple `filter[...]` query parameters.
7+
requests:
8+
- id: r1
9+
method: GET
10+
path: /api/authors?filter[name][like]=A%25&filter[id][gt]=1
11+
expect:
12+
status: 200
13+
body:
14+
ids: [2]
15+
length: 1
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: filter-eq
2+
description: >
3+
GET /api/authors?filter[name][eq]=Ada%20Lovelace — exact-match filter on a
4+
string field returns the single matching row. Verifies the `eq` operator
5+
and that the cross-port URL grammar `?filter[<field>][<op>]=<value>` is
6+
honored.
7+
requests:
8+
- id: r1
9+
method: GET
10+
path: /api/authors?filter[name][eq]=Ada%20Lovelace
11+
expect:
12+
status: 200
13+
body:
14+
ids: [1]
15+
length: 1
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: filter-gt
2+
description: >
3+
GET /api/authors?filter[id][gt]=2 — `gt` on a numeric field returns rows
4+
whose id is strictly greater than 2 (ids 3, 4, 5).
5+
requests:
6+
- id: r1
7+
method: GET
8+
path: /api/authors?filter[id][gt]=2
9+
expect:
10+
status: 200
11+
body:
12+
ids: [3, 4, 5]
13+
length: 3
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
name: filter-in
2+
description: >
3+
GET /api/authors?filter[name][in]=Ada%20Lovelace,Alan%20Turing — `in` takes
4+
a comma-separated list and returns rows whose name matches any list
5+
member. Two seed rows match (ids 1 and 2).
6+
requests:
7+
- id: r1
8+
method: GET
9+
path: /api/authors?filter[name][in]=Ada%20Lovelace,Alan%20Turing
10+
expect:
11+
status: 200
12+
body:
13+
ids: [1, 2]
14+
length: 2
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: filter-invalid-field
2+
description: >
3+
GET /api/authors?filter[unknown][eq]=x — the field `unknown` is not on the
4+
entity's filter allowlist. The server rejects with HTTP 400 and the
5+
cross-port `invalid_filter_field` error envelope.
6+
requests:
7+
- id: r1
8+
method: GET
9+
path: /api/authors?filter[unknown][eq]=x
10+
expect:
11+
status: 400
12+
body:
13+
error: "invalid_filter_field"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
name: filter-invalid-op
2+
description: >
3+
GET /api/authors?filter[name][gt]=Ada — the `gt` operator is not valid for
4+
a string field (`gt` is gated to numeric/date/currency subtypes per the
5+
cross-port operator-subtype matrix). The server rejects with HTTP 400 and
6+
the cross-port `invalid_filter_op` error envelope.
7+
requests:
8+
- id: r1
9+
method: GET
10+
path: /api/authors?filter[name][gt]=Ada
11+
expect:
12+
status: 400
13+
body:
14+
error: "invalid_filter_op"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
name: filter-isnull-true
2+
description: >
3+
GET /api/authors?filter[bio][isNull]=true — `isNull=true` returns rows
4+
whose `bio` column is SQL NULL. Two seed rows have a null bio (Alan
5+
Turing id=2 and Edsger Dijkstra id=5).
6+
requests:
7+
- id: r1
8+
method: GET
9+
path: /api/authors?filter[bio][isNull]=true
10+
expect:
11+
status: 200
12+
body:
13+
ids: [2, 5]
14+
length: 2

0 commit comments

Comments
 (0)