|
| 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 |
0 commit comments