fastify-auto-sqlapi 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/AGENTS_BACKEND.md +32 -5
  2. package/BREAKING_CHANGES.md +80 -5
  3. package/CHANGELOG.md +45 -0
  4. package/README.md +49 -7
  5. package/dist/bin/generate-schema.js +89 -77
  6. package/dist/bin/generate-tables.js +72 -62
  7. package/dist/bin/utils.d.ts.map +1 -1
  8. package/dist/bin/utils.js +47 -33
  9. package/dist/lib/agent/manifest.d.ts +3 -2
  10. package/dist/lib/agent/manifest.d.ts.map +1 -1
  11. package/dist/lib/agent/manifest.js +104 -80
  12. package/dist/lib/cli/mysql-introspect.d.ts +4 -9
  13. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  14. package/dist/lib/cli/mysql-introspect.js +8 -7
  15. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  16. package/dist/lib/cli/pg-introspect.js +0 -1
  17. package/dist/lib/cli/schema-codegen.d.ts +3 -0
  18. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  19. package/dist/lib/cli/schema-codegen.js +3 -0
  20. package/dist/lib/cli/tables-codegen.d.ts +8 -1
  21. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  22. package/dist/lib/cli/tables-codegen.js +100 -118
  23. package/dist/lib/condition-methods.d.ts +14 -4
  24. package/dist/lib/condition-methods.d.ts.map +1 -1
  25. package/dist/lib/condition-methods.js +18 -4
  26. package/dist/lib/db.d.ts +1 -1
  27. package/dist/lib/db.d.ts.map +1 -1
  28. package/dist/lib/db.js +16 -4
  29. package/dist/lib/dialect.d.ts +2 -1
  30. package/dist/lib/dialect.d.ts.map +1 -1
  31. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  32. package/dist/lib/engine/bulk/bulk-upsert.js +42 -35
  33. package/dist/lib/engine/rest/delete.d.ts.map +1 -1
  34. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  35. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  36. package/dist/lib/engine/rest/insert.js +1 -1
  37. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  38. package/dist/lib/engine/rest/update.js +25 -18
  39. package/dist/lib/engine/search/aggregations.d.ts +33 -0
  40. package/dist/lib/engine/search/aggregations.d.ts.map +1 -0
  41. package/dist/lib/engine/search/aggregations.js +109 -0
  42. package/dist/lib/engine/search/conditions.d.ts +31 -0
  43. package/dist/lib/engine/search/conditions.d.ts.map +1 -0
  44. package/dist/lib/engine/search/conditions.js +131 -0
  45. package/dist/lib/engine/search/fields.d.ts +49 -0
  46. package/dist/lib/engine/search/fields.d.ts.map +1 -0
  47. package/dist/lib/engine/search/fields.js +142 -0
  48. package/dist/lib/engine/search/join-families.d.ts +35 -0
  49. package/dist/lib/engine/search/join-families.d.ts.map +1 -0
  50. package/dist/lib/engine/search/join-families.js +345 -0
  51. package/dist/lib/engine/search/joins.d.ts +55 -0
  52. package/dist/lib/engine/search/joins.d.ts.map +1 -0
  53. package/dist/lib/engine/search/joins.js +81 -0
  54. package/dist/lib/engine/search/order-by.d.ts +35 -0
  55. package/dist/lib/engine/search/order-by.d.ts.map +1 -0
  56. package/dist/lib/engine/search/order-by.js +104 -0
  57. package/dist/lib/engine/search/search.d.ts.map +1 -1
  58. package/dist/lib/engine/search/search.js +106 -749
  59. package/dist/lib/engine/validate.d.ts +0 -5
  60. package/dist/lib/engine/validate.d.ts.map +1 -1
  61. package/dist/lib/engine/write-helpers.d.ts +25 -17
  62. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  63. package/dist/lib/engine/write-helpers.js +86 -44
  64. package/dist/lib/read-access.d.ts +37 -1
  65. package/dist/lib/read-access.d.ts.map +1 -1
  66. package/dist/lib/read-access.js +64 -1
  67. package/dist/lib/schema/helpers.d.ts +0 -15
  68. package/dist/lib/schema/helpers.d.ts.map +1 -1
  69. package/dist/lib/schema/helpers.js +7 -4
  70. package/dist/lib/schema/search.d.ts.map +1 -1
  71. package/dist/lib/schema/search.js +120 -121
  72. package/dist/lib/sql-api.d.ts.map +1 -1
  73. package/dist/lib/table-helpers.d.ts +16 -1
  74. package/dist/lib/table-helpers.d.ts.map +1 -1
  75. package/dist/lib/table-helpers.js +55 -1
  76. package/dist/lib/tenant.d.ts +26 -0
  77. package/dist/lib/tenant.d.ts.map +1 -1
  78. package/dist/lib/tenant.js +46 -0
  79. package/dist/routes/auto/route-helpers.d.ts +0 -7
  80. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  81. package/dist/routes/auto/route-helpers.js +46 -30
  82. package/dist/routes/auto/search.routes.js +0 -1
  83. package/dist/types/join.d.ts +9 -0
  84. package/dist/types/join.d.ts.map +1 -1
  85. package/dist/types/table.d.ts +1 -1
  86. package/docs/GLOSSARY.md +28 -0
  87. package/docs/adr/0010-joins-do-not-run-route-guards.md +100 -0
  88. package/docs/adr/0011-join-fields-allowlist.md +95 -0
  89. package/docs/adr/0012-anti-duplication-tooling.md +59 -0
  90. package/docs/adr/README.md +5 -1
  91. package/package.json +29 -2
package/AGENTS_BACKEND.md CHANGED
@@ -357,6 +357,8 @@ buildRelation(mainSchema, mainField, joinSchema, joinField, options?)
357
357
  // unique?: boolean; // default false.
358
358
  // // false → 1:N (child→main). Allowed in joinMustExist/joinMultiple/joinGroup.
359
359
  // // true → N:1 (parent→main). Allowed in joinLeft only.
360
+ // fields?: string[]; // allowlist of the target's fields reachable through this relation.
361
+ // // Read joins only. Must include joinField. Everything else is 400.
360
362
  // }
361
363
  ```
362
364
 
@@ -367,6 +369,27 @@ buildRelation(mainSchema, mainField, joinSchema, joinField, options?)
367
369
 
368
370
  **When to declare an explicit `alias`**: only when (a) you join the same table multiple times in the same `allowedReadJoins`/`allowedWriteJoins`, or (b) you want a friendlier name in the public API surface than the SQL table name (e.g. `'orders'` instead of `'customer_order'`). Otherwise omit it — the default is `joinSchema.tableName`.
369
371
 
372
+ **⚠️ A relation in `allowedReadJoins` is a read grant on the target table.** The join runs inside the query of the *host* table's route, so the target's `onRequests` never fire and its `operations` whitelist does not apply — a table with `operations: []` is still fully readable through any relation pointing at it. What does cross a join: `readExclude`, `tenantScope`, and the schema the relation was declared with.
373
+
374
+ To expose a table broadly but keep some of its columns narrow, give the relation a `fields` allowlist:
375
+
376
+ ```typescript
377
+ allowedReadJoins: [
378
+ buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', { unique: true, fields: ['id', 'name'] }),
379
+ ],
380
+ ```
381
+
382
+ The relation is declared against a schema narrowed to that list, so every read surface validates against it — `selection`, `filters`, `conditions`, `orderBy`, aggregations, and the generated request/response schemas. A field outside the list is `400 Unknown field` everywhere, not just missing from the projection, and the default `'*'` selection is spelled out into the allowed columns rather than emitting a real `SELECT *`.
383
+
384
+ Rules, all enforced at declaration time:
385
+
386
+ - **Must include the join field** — it is what correlates the fetched rows with the main ones.
387
+ - **Not allowed on `allowedWriteJoins`** — the allowlist restricts reading only; write paths resolve `upsertMap` by schema identity and must write every column the caller sent. Declare two relations if the same pair is joined for reading and for writing.
388
+ - **Computed fields on the target resolve against the narrowed schema** — one that reads a field outside the list is a `400`, so a computed cannot be used to reach past the allowlist. One that stays inside keeps working.
389
+ - **Fail-closed** — a column added to the target later is not reachable through the relation until it is added to `fields`.
390
+
391
+ `fields` is not a replacement for `readExclude` (which hides a column from the owning table's own routes too). See [ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md) and [ADR 0011](./docs/adr/0011-join-fields-allowlist.md).
392
+
370
393
  **Choosing `unique`**: if `joinField` is the PK (or part of the composite PK) of `joinSchema`, the relation is N:1 — you almost certainly want `unique: true` so the alias is usable in `joinLeft`.
371
394
 
372
395
  **Owned child tables (translations, `*_info` details): use a writeJoin, not a standalone table.** A table that only exists as a child of a parent — e.g. `product_info` with composite PK `(product_id, lang)` — should be an `allowedWriteJoins` on the parent, not its own `DbTables` entry. The engine auto-fills the FK (`product_id`); add it to `upsertMap` (conflict key = the composite PK) to upsert children passing only their own fields. Avoids redundant endpoints/validators.
@@ -431,11 +454,15 @@ export const TableCustomer = defineTable({
431
454
 
432
455
  `extraFilters` accepts either a `Type.Object({...})` or a plain `Record<string, TSchema>`. Extra filter fields appear in Swagger but are NOT auto-applied as `WHERE col = value` — they are handled exclusively by the `extendedCondition` callback. The `filters` parameter in the callback is fully typed with autocomplete for all schema fields + extra filter keys.
433
456
 
457
+ **Not available on `joinLeft`.** `extendedCondition` writes its own column references, which cannot be qualified with the `LEFT JOIN` alias the parent gets there, so `buildLeftJoinClauses` handles schema and computed fields only. The generated body schema does not advertise the parent's `extraFilters` under `joinLeft.<alias>.filters`, and sending one is a `400` rather than a silent no-op. Use `joinMustExist` on the same relation when you need an extra filter on the parent.
458
+
459
+ **Unknown filter keys are a `400`** (`Unknown filter field: <key>`), on the main table and on every join. A filter that does not match a schema field, an `extraFilters` key or a computed field is rejected instead of dropped: silently ignoring it would return *more* rows than the caller asked for. The check lives in the engine, so it applies to `sqlApi.search()` too, and it runs before any query — the outcome never depends on whether the main query matched rows.
460
+
434
461
  ---
435
462
 
436
463
  ## Computed Fields (extension system)
437
464
 
438
- `computedFields` lets you declare **virtual fields** as SQL expressions on a per-table basis. Each computed becomes usable like a regular schema field across the search API: `filters` (equality), `conditions` (operators), `orderBy` (1-parte), `computeMin/Max/Sum/Avg`, and (opt-in) in `selectComputed` for the main response. Same machinery serves JSON column extraction, derived strings, dialect-aware date/calendar bucketing — without growing the library case-by-case.
465
+ `computedFields` lets you declare **virtual fields** as SQL expressions on a per-table basis. Each computed becomes usable like a regular schema field across the search API: `filters` (equality), `conditions` (operators), `orderBy` (1-part), `computeMin/Max/Sum/Avg`, and (opt-in) in `selectComputed` for the main response. Same machinery serves JSON column extraction, derived strings, dialect-aware date/calendar bucketing — without growing the library case-by-case.
439
466
 
440
467
  ```typescript
441
468
  import { defineTable, exportTableInfo, Type } from 'fastify-auto-sqlapi';
@@ -519,7 +546,7 @@ For `joinLeft` specifically, the computed expr is automatically alias-prefixed b
519
546
 
520
547
  - **Bound `values` in computed expressions** work anywhere the expression lands in the `WHERE` clause or in `ORDER BY`: `filters` and `conditions` (main, `joinMustExist`, `joinMultiple`, `joinGroup`, `joinLeft`) and `orderBy`. The ConditionBuilder assigns the placeholder positions there, so no coordination is needed from the caller.
521
548
  - They are still rejected with 400 on `selectComputed`, `computeMin/Max/Sum/Avg`, `joinGroup.aggregations.by` and `defaultOrder`. Those expressions are emitted in the `SELECT` / `GROUP BY` list, which precedes the `WHERE` values in the parameter order — there is no correct position for them there. Most use cases (JSON extraction, concat, dateTrunc, simple ops) need no bound values at all.
522
- - **Computed CAN be used in `joinGroup.aggregations.by`** (just pass the computed name as a string). Bound `values` are not supported in this position (rejected with 400 — it is emitted in the `SELECT`/`GROUP BY` list). The existing FK-correlation rule for `orderBy <alias>.<fn>.<field>` still applies: 3-parti aggregation orderBy on a `by` that isn't the correlation FK is rejected — by definition a computed-by produces multiple groups per main row, so this is never valid.
549
+ - **Computed CAN be used in `joinGroup.aggregations.by`** (just pass the computed name as a string). Bound `values` are not supported in this position (rejected with 400 — it is emitted in the `SELECT`/`GROUP BY` list). The existing FK-correlation rule for `orderBy <alias>.<fn>.<field>` still applies: 3-part aggregation orderBy on a `by` that isn't the correlation FK is rejected — by definition a computed-by produces multiple groups per main row, so this is never valid.
523
550
  - **Computed cannot be used as aggregation function** (`sum`/`min`/`max`/...). The values inside `aggregations.sum: ['<name>']` must be schema field names. To aggregate on a derived expression, declare the derivation as a computed and pass the computed name as the field, BUT only via `computeMin/Max/Sum/Avg` (top-level main aggregates), not the joinGroup ones.
524
551
  - **No chained computed** (a computed referencing another computed). Flat-only.
525
552
  - **Read-only**. Computed fields are not usable in insert/update bodies — the consumer's `expr` is for SELECT/WHERE/ORDER BY, never for writes.
@@ -608,8 +635,8 @@ const sqlApi = createSqlApi(db, dbTables, { dialect: 'mysql' });
608
635
  ```typescript
609
636
  // Search — full filter, join, pagination, aggregation support
610
637
  // orderBy supports two dotted notations:
611
- // - 3-parti `<alias>.<fn>.<field>` for joinGroup aggregations (must be declared in the request body)
612
- // - 2-parti `<alias>.<field>` for joinLeft parent fields
638
+ // - 3-part `<alias>.<fn>.<field>` for joinGroup aggregations (must be declared in the request body)
639
+ // - 2-part `<alias>.<field>` for joinLeft parent fields
613
640
  // See AGENTS_FRONTEND.md and BREAKING_CHANGES.md for the full reference.
614
641
  sqlApi.search(tableName, {
615
642
  filters?, conditions?,
@@ -777,7 +804,7 @@ Tables without `tenantScope` are unaffected — no filtering regardless of `getT
777
804
  - **All fields Optional in response**: `RETURNING *` may return any subset. Response schemas use `Type.Partial`.
778
805
  - **Nullable columns use `Nullable(T)` (type-array form)**: the generator emits `Type.Optional(Nullable(T))` for nullable columns — `Nullable` (exported by the package) produces `{ type: ['integer', 'null'] }`. Do NOT replace it with `Type.Union([T, Type.Null()])`: under Fastify's default Ajv `coerceTypes` a union corrupts values through its branches (`null` → `0` with the Null branch last, `0`/`""` → `null` with it first), while the type array validates and serializes NULL correctly. A bare `Type.Optional(T)` is also wrong: NULL serializes as `0`/`""`. In `filters`, an explicit `null` on a schema field builds `WHERE col IS NULL`.
779
806
  - **`excludeFromCreation`**: **IMPORTANT** — auto-increment PKs (e.g. `id` serial/auto_increment) MUST be listed here, otherwise INSERT will try to send them and fail. The CLI auto-detects this and adds it by default. Also useful for `createdAt`/`updatedAt` columns managed by DB defaults or hooks. It strips **client-supplied** values only: the payload is sanitized before `beforeInsert` runs, so a value the hook assigns to an excluded field (e.g. a server-generated TEXT id) DOES reach the INSERT. Same for the engine's FK auto-fill on secondaries — an excluded FK column does not suppress it. It is an **ergonomics tool for creation, NOT a field-level security mechanism**: it does not apply to updates — every Schema field is updatable by default, by design. Field-level update rules (`isAdmin`, roles, owner/tenant columns, state fields) are product logic: enforce them in `beforeUpdate` (silent strip) or `validate` (loud 400), or move the privileged transition to a dedicated endpoint and keep it off the auto routes via `operations`.
780
- - **`readExclude`**: fields hidden from every read — not projected by search/get, omitted from read response schemas and from this table's default (`*`) join selection when it is a join target. Referencing one from `filters`, `conditions`, `orderBy`, `joinGroup` aggregations or an explicit join `selection` returns 400: hiding a field from output while allowing it to be filtered would leak its value by bisection. **Writes are unaffected** — insert/update/bulk still accept the field, so a column can be writable but never readable (password hash, access token). Primary keys cannot be excluded. Complementary to trimming the Schema, which removes the column from reads *and* writes.
807
+ - **`readExclude`**: fields hidden from every read — not projected by search/get, omitted from read response schemas and from this table's default (`*`) join selection when it is a join target. Referencing one from `filters`, `conditions`, `orderBy`, `joinGroup` aggregations or an explicit join `selection` returns 400: hiding a field from output while allowing it to be filtered would leak its value by bisection. **Writes are unaffected** — insert/update/bulk still accept the field, so a column can be writable but never readable (password hash, access token). Primary keys cannot be excluded. Complementary to trimming the Schema, which removes the column from reads *and* writes. It is **per table and static**: a column hidden here is hidden from every caller, including admins. To keep a column readable on the table's own routes but not through a relation, trim the Schema the *relation* is declared with instead — see the read-grant warning under [buildRelation](#buildrelation-signature).
781
808
  - **`upsertMap`**: when present for a schema, INSERT becomes upsert. PostgreSQL: `ON CONFLICT (...) DO UPDATE`. MySQL/MariaDB: `ON DUPLICATE KEY UPDATE`. Applies to both main and secondary tables.
782
809
  - **`schemaOverrides`**: override auto-generated schema fields with stricter TypeBox types (e.g. `{ email: Type.String({ format: 'email' }) }`). Overrides are merged into the body schema for insert, update, and bulk-upsert. The original Schema file is never modified. In updates, overridden fields are still wrapped in Optional (validates only when present). Overrides appear in Swagger.
783
810
  - **Validation receives camelCase**: `validate` receives the original camelCase record (as sent by the client) and secondaries. Field names match the schema definition, with full TypeScript inference (`main.startDate`, not `main.start_date`). It returns `ValidationError[]` — tuples of `[field, code]` or `[field, code, message]`. If any errors are returned, the request is rejected with 400 before hooks or SQL execute.
@@ -144,7 +144,7 @@ Real `LEFT JOIN` for N:1 (parent) relationships. The parent rows are returned in
144
144
 
145
145
  The engine attaches a real `LEFT JOIN <parentTable> AS <alias>` to the main query **only when needed**:
146
146
  - when the request includes `filters` or `conditions` on the parent (effectively turns into INNER JOIN behavior on those aliases — main rows whose parent doesn't match are excluded);
147
- - when `orderBy` uses 2-parti dotted notation referring to a parent field (`alias.field`).
147
+ - when `orderBy` uses 2-part dotted notation referring to a parent field (`alias.field`).
148
148
 
149
149
  Otherwise only a side query `WHERE pk IN (distinct fk values)` is issued — no LEFT JOIN, no row duplication. Either way, the response shape is the same.
150
150
 
@@ -158,7 +158,7 @@ Otherwise only a side query `WHERE pk IN (distinct fk values)` is issued — no
158
158
  | `<alias>.<field>` | `joinLeft` (`unique: true` aliases) | orders main query by a parent field via LEFT JOIN |
159
159
  | `<alias>.<fn>.<field>` | `joinGroup` (declared in the same body) | orders main query by an aggregation via correlated scalar subquery |
160
160
 
161
- Ambiguity: 2-parti and 3-parti are disambiguated by counting dots. The 2-parti is rejected unless the alias is in `joinLeft` allowlist; 3-parti is rejected unless the alias is in the request `joinGroup` declaration.
161
+ Ambiguity: 2-part and 3-part are disambiguated by counting dots. The 2-part is rejected unless the alias is in `joinLeft` allowlist; 3-part is rejected unless the alias is in the request `joinGroup` declaration.
162
162
 
163
163
  ## Response shape
164
164
 
@@ -248,7 +248,7 @@ If you used the join table's name as the secondaries key, change it to the new a
248
248
 
249
249
  `<table>.<fn>.<field>` still works for joinGroup, but `<table>` must now be the alias declared in `buildRelation`. If the alias differs from the old `tableName`, update the string.
250
250
 
251
- If you need to order by a parent field, **add a `joinLeft`-eligible relation** (with `unique: true`) and use 2-parti notation `alias.field` (NEW capability — not available before).
251
+ If you need to order by a parent field, **add a `joinLeft`-eligible relation** (with `unique: true`) and use 2-part notation `alias.field` (NEW capability — not available before).
252
252
 
253
253
  ## Validation summary (400 responses)
254
254
 
@@ -257,8 +257,8 @@ The engine rejects, with `statusCode: 400`, any of:
257
257
  - `joinLeft` referencing an alias declared with `unique: false`
258
258
  - `joinMultiple`/`joinMustExist`/`joinGroup` referencing an alias declared with `unique: true`
259
259
  - request keys referencing an alias not declared in `allowedReadJoins`
260
- - `orderBy` 2-parti `<alias>.<field>` whose alias is not in `joinLeft`-eligible (i.e. `unique:true`) declarations
261
- - `orderBy` 3-parti `<alias>.<fn>.<field>` whose alias or `(fn, field)` pair is not declared in `joinGroup` for the same request
260
+ - `orderBy` 2-part `<alias>.<field>` whose alias is not in `joinLeft`-eligible (i.e. `unique:true`) declarations
261
+ - `orderBy` 3-part `<alias>.<fn>.<field>` whose alias or `(fn, field)` pair is not declared in `joinGroup` for the same request
262
262
  - `defineTable` with two `allowedReadJoins`/`allowedWriteJoins` entries resolving to the same alias (thrown at startup, not at request time)
263
263
 
264
264
  ---
@@ -380,3 +380,78 @@ endpoints previously behaved correctly for you — they are now gone and you mus
380
380
  - **First PK column is truly unique on its own?** Then the composite declaration was
381
381
  redundant: declare `primary: 'id'` (single) and keep the second column as a plain field —
382
382
  every by-id operation comes back.
383
+
384
+ ---
385
+
386
+ # Breaking Change — unknown filter keys are rejected
387
+
388
+ `filters` keys that match nothing are now a `400`, on the main table and on every join family.
389
+ Previously they were dropped without a word.
390
+
391
+ ## Why this changed
392
+
393
+ The engine only ever visited filter keys matching a schema field, an `extraFilters` entry or a
394
+ computed field. Anything else — a typo, a renamed column, a field that exists on the table but
395
+ not on the trimmed Schema a relation was declared with — was never looked at, and the query ran
396
+ as if the filter had not been sent.
397
+
398
+ That fails in the dangerous direction. A rejected filter returns *more* rows than the caller
399
+ asked for, and nothing in the response says so; in an application where filters are also how
400
+ visibility is narrowed, a typo becomes a data exposure. It was also the odd one out: an unknown
401
+ field in `selection`, `conditions`, `orderBy` or a `joinGroup` aggregation has always been a
402
+ 400.
403
+
404
+ ## What changed
405
+
406
+ | Request | Before | After |
407
+ |---|---|---|
408
+ | `filters: { nosuchfield: 'x' }` | `200`, filter ignored | `400 Unknown filter field: nosuchfield` |
409
+ | `joinMultiple.<alias>.filters: { nosuchfield: 1 }` | `200`, filter ignored | `400 Unknown filter field: nosuchfield` |
410
+ | `joinLeft.<alias>.filters: { <extraFilter> }` | `200`, filter ignored | `400 Filter '<key>' is an extraFilter: …` |
411
+ | `filters: { knownField: undefined }` | ignored | ignored (unchanged) |
412
+ | `filters: { knownField: null }` | `IS NULL` | `IS NULL` (unchanged) |
413
+
414
+ Two related fixes ship with it:
415
+
416
+ - The check runs in the engine, not in the route schema, so `sqlApi.search()` behaves the same
417
+ as the HTTP routes. (A closed `additionalProperties: false` schema would not have worked:
418
+ Fastify runs Ajv with `removeAdditional: true`, so it would have *stripped* the key and
419
+ reproduced the same silent behaviour one layer up.)
420
+ - Join filters are validated **before any query runs**. They used to be validated inside the
421
+ side query, which is skipped entirely when the main result set is empty — so the same request
422
+ answered 400 or 200 depending on the data it matched.
423
+
424
+ ## Who is affected
425
+
426
+ Callers that send filter keys the target table does not declare. Over-sending is the common
427
+ case: a frontend that spreads a whole form state into `filters`, or reuses one filter object
428
+ across two tables.
429
+
430
+ ## Migration
431
+
432
+ Send only keys the table declares. `GET /agent/manifest` and the Swagger body schema both list
433
+ them per table (schema fields + `extraFilters` + computed fields).
434
+
435
+ For a form-state object, filter it before sending rather than relying on the server to ignore
436
+ the extras:
437
+
438
+ ```typescript
439
+ const ALLOWED = ['name', 'status', 'createdFrom'] as const;
440
+ const filters = Object.fromEntries(
441
+ Object.entries(formState).filter(([k, v]) => ALLOWED.includes(k) && v !== '')
442
+ );
443
+ ```
444
+
445
+ Passing `undefined` for an absent value is still safe and needs no filtering: only keys with a
446
+ defined value are validated.
447
+
448
+ ## joinLeft and extraFilters
449
+
450
+ `joinLeft.<alias>.filters` used to advertise the parent table's `extraFilters` in Swagger and
451
+ accept them at runtime, applying nothing: `buildLeftJoinClauses` builds its condition inline and
452
+ never runs the target's `extendedCondition`, whose column references cannot be qualified with
453
+ the `LEFT JOIN` alias the parent gets there. They are no longer advertised, and sending one
454
+ returns a `400` naming the reason.
455
+
456
+ Use `joinMustExist` on the same relation when you need an extra filter on the parent — that path
457
+ delegates to the target's own `filters()` and does run `extendedCondition`.
package/CHANGELOG.md CHANGED
@@ -9,8 +9,53 @@ Migration instructions for breaking changes live in **[BREAKING_CHANGES.md](./BR
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ### Changed
13
+
14
+ - **Unknown `filters` keys are now rejected with `400 Unknown filter field: <key>`** instead
15
+ of being dropped in silence — on the main table and on every join family. The engine only
16
+ ever visited keys matching a schema field, an `extraFilters` entry or a computed field, so a
17
+ mistyped filter name came back as an unfiltered (wider) result set with no error: the
18
+ dangerous direction to fail in, and inconsistent with `selection`, `conditions`, `orderBy`
19
+ and aggregations, which have always answered 400. The check lives in the engine, so it
20
+ covers `sqlApi.search()` as well as the HTTP routes. `undefined` values still mean "filter
21
+ not supplied" and are ignored; an explicit `null` still filters by `IS NULL`. See
22
+ [BREAKING_CHANGES.md](./BREAKING_CHANGES.md#breaking-change--unknown-filter-keys-are-rejected).
23
+ - **`joinLeft.<alias>.filters` no longer advertises the parent's `extraFilters`.** They were
24
+ present in the generated body schema and in Swagger, but `buildLeftJoinClauses` never runs
25
+ the target's `extendedCondition` (its column references cannot be qualified with the
26
+ `LEFT JOIN` alias), so the filter applied nothing. They are now rejected with a `400` naming
27
+ the reason and pointing at `joinMustExist` on the same relation.
28
+
29
+ ### Fixed
30
+
31
+ - **Join validation no longer depends on the main result set.** `joinMultiple` / `joinLeft` /
32
+ `joinGroup` skip their side query entirely when the main query matched no rows, so everything
33
+ validated inside it was skipped with it — the same request answered 400 or 200 depending on
34
+ the data. This covered filter keys and `readExclude` on join filters, an explicit `selection`
35
+ naming an unknown field, and `joinGroup` aggregation fields including `aggregations.by`. Join
36
+ filters are now validated up front, and the selection and aggregation lists are resolved
37
+ before the empty-result bail-out.
38
+
12
39
  ### Added
13
40
 
41
+ - **`fields` allowlist on `buildRelation`** — restricts which of the target table's fields are
42
+ reachable through one relation, without hiding them from the table's own routes the way
43
+ `readExclude` would. The relation is declared against a schema narrowed to the list, so
44
+ `selection`, `filters`, `conditions`, `orderBy`, aggregations and the generated
45
+ request/response schemas all reject anything outside it with `400 Unknown field`; the default
46
+ `'*'` selection is spelled out into the allowed columns instead of emitting a real `SELECT *`;
47
+ and computed fields on the target resolve against the narrowed schema, so one reading an
48
+ excluded column is a 400 rather than a way around the list. Declaration-time checks: the list
49
+ must include the join field, and is rejected on `allowedWriteJoins` (write paths resolve
50
+ `upsertMap` by schema identity and must write every column the caller sent). Fail-closed by
51
+ design — see [ADR 0011](./docs/adr/0011-join-fields-allowlist.md) for allowlist vs blocklist.
52
+ - **[ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md)** — a declared join is a read
53
+ grant: `onRequests` and `operations` are route-level and do not follow a join, so
54
+ `allowedReadJoins` grants read access to the target table under the *host* table's
55
+ authorization. Documents what does cross a join (`readExclude`, `tenantScope`, the relation's
56
+ schema), the trimmed-schema + explicit-`selection` pattern for column-level narrowing, and
57
+ the rejected alternatives (`canBeJoined` hook, `selection` as a ceiling).
58
+
14
59
  - **`excludeTables` config option** — blacklist for `sqlapi-generate-schema`. Tables
15
60
  listed in `excludeTables` in `sqlapi.config.ts` (exact names or `*` globs, e.g.
16
61
  `knex_*`) are skipped during schema generation; their previously generated schema files
package/README.md CHANGED
@@ -266,6 +266,8 @@ const TableCustomer = defineTable({
266
266
  buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order'
267
267
  buildRelation(SchemaSession, 'userId', SchemaUser, 'id', { unique: true }), // alias = 'user', N:1
268
268
  buildRelation(SchemaSession, 'updatedBy', SchemaUser, 'id', { alias: 'updater', unique: true }),
269
+ // `fields` restricts what this relation exposes of the target — read joins only
270
+ buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', { unique: true, fields: ['id', 'name'] }),
269
271
  ],
270
272
  allowedWriteJoins: [
271
273
  buildRelation(SchemaCustomer, 'id', SchemaOrder, 'customerId'), // alias = 'order'
@@ -423,6 +425,43 @@ are always available to your own code.
423
425
  **3. Multi-tenancy** — `getTenantId` + `tenantScope` filter every query by tenant (see
424
426
  [Multi-Tenant](#multi-tenant)).
425
427
 
428
+ ### A declared join is a read grant
429
+
430
+ ⚠️ **Neither of the first two layers follows a join.** `onRequests` and `operations` are
431
+ properties of the routes generated *for* a table; a join is resolved inside the query of the
432
+ **host** table's route, so the target's hooks never run and its `operations` whitelist does not
433
+ apply. A table with `operations: []` — no HTTP routes at all — is still fully readable through
434
+ any relation pointing at it.
435
+
436
+ So `allowedReadJoins` is a security decision: declaring a relation grants read access to the
437
+ target table under the **host** table's authorization.
438
+
439
+ What does follow a join: `readExclude`, `tenantScope`, and the schema the relation was declared
440
+ with. To expose a table broadly while keeping some columns narrow, give the relation a `fields`
441
+ allowlist:
442
+
443
+ ```typescript
444
+ allowedReadJoins: [
445
+ buildRelation(SchemaAgent, 'userId', SchemaUser, 'id', {
446
+ unique: true,
447
+ fields: ['id', 'name'], // everything else on `user` is unreachable here
448
+ }),
449
+ ],
450
+ ```
451
+
452
+ Every read surface validates against it — `selection`, `filters`, `conditions`, `orderBy`,
453
+ aggregations, and the generated request/response schemas — so `email` is `400 Unknown field`
454
+ everywhere, not just missing from the projection. It is fail-closed: a column added to `user`
455
+ later is not reachable through this relation until it is added to the list.
456
+
457
+ `fields` restricts **reading** only, and only through that relation. It is not a replacement for
458
+ `readExclude` (which hides a column from the table's own routes too) or for `onRequests` (which
459
+ decides who may call them).
460
+
461
+ Full rationale and the rejected alternatives in
462
+ [ADR 0010](./docs/adr/0010-joins-do-not-run-route-guards.md) and
463
+ [ADR 0011](./docs/adr/0011-join-fields-allowlist.md).
464
+
426
465
  ### Request limits
427
466
 
428
467
  Two hard caps bound how much work a single request can trigger (defense against accidental or
@@ -439,11 +478,14 @@ capped — they run your own trusted code.
439
478
 
440
479
  ### Write whitelist
441
480
 
442
- Insert/update/bulk-upsert bodies reject **unknown properties** (`additionalProperties: false`):
443
- only columns present in the generated Schema (as narrowed by `schemaOverrides` /
444
- `excludeFromCreation`) can be written. An unexpected field is a `400`, never a silently-written
445
- column — the Schema is the write whitelist. Trim the generated Schema (or use `excludeFromCreation`)
446
- to keep sensitive columns out of it.
481
+ Insert/update/bulk-upsert bodies are `additionalProperties: false`: only columns present in the
482
+ generated Schema (as narrowed by `schemaOverrides` / `excludeFromCreation`) can be written. The
483
+ Schema is the write whitelist. Trim the generated Schema (or use `excludeFromCreation`) to keep
484
+ sensitive columns out of it.
485
+
486
+ An unexpected field is **stripped, not rejected**: Fastify runs Ajv with `removeAdditional: true`,
487
+ so the request succeeds and the column is simply never written. Mass assignment is prevented
488
+ either way, but do not expect a `400` to tell a client its extra field was ignored.
447
489
 
448
490
  ### Field-level update rules are product logic — use the hooks
449
491
 
@@ -543,12 +585,12 @@ Search with filters, advanced conditions, pagination, ordering, joins, and aggre
543
585
  }
544
586
  ```
545
587
 
546
- - **`filters`** — equality-based, flat key/value. Supports schema fields + `extraFilters`.
588
+ - **`filters`** — equality-based, flat key/value. Supports schema fields, `extraFilters` and computed fields. A key that is none of those is a `400 Unknown filter field: <key>` — a mistyped filter must never come back as a wider result set. An explicit `null` filters by `IS NULL`; `undefined` means "not supplied" and is ignored.
547
589
  - **`conditions`** — array of `{ field, method, params }`. Methods: `isEqual`, `isNotEqual`, `isGreater`, `isGreaterOrEqual`, `isLess`, `isLessOrEqual`, `isLike`, `isILike`, `isIn`, `isNotIn`, `isBetween`, `isNotBetween`, `isNull`, `isNotNull`.
548
590
  - **`joinMustExist`** — EXISTS-based filter: "main rows where at least one related row matches". Accepts `{ filters, conditions }` (both optional). Aliases must come from `allowedReadJoins` declarations with `unique: false`.
549
591
  - **`joinMultiple`** — fetches related child rows in a side query. Accepts `{ filters, conditions, selection }`. Same `unique: false` aliases.
550
592
  - **`joinGroup`** — aggregations on the related table. Supports `sum`, `min`, `max`, `avg`, `count`, `distinctCount`, and optional `by` for GROUP BY (a schema field name or a computed-field name declared on the join table — e.g. for date bucketing declare a computed using `db.dateTrunc('month', qiCol('orderDate'))`). Accepts `{ filters, conditions }`. Same `unique: false` aliases.
551
- - **`joinLeft`** — embeds an N:1 parent. Real `LEFT JOIN` is added on demand (only when the request has `filters`/`conditions` on the parent or uses 2-parti `orderBy` on this alias). Aliases must be declared with `unique: true`. Accepts `{ filters, conditions, selection }`.
593
+ - **`joinLeft`** — embeds an N:1 parent. Real `LEFT JOIN` is added on demand (only when the request has `filters`/`conditions` on the parent or uses 2-part `orderBy` on this alias). Aliases must be declared with `unique: true`. Accepts `{ filters, conditions, selection }`. Its `filters` accept schema and computed fields only: the parent's `extraFilters` need an alias-aware `extendedCondition`, so they are rejected with a `400` here instead of being silently ignored — use `joinMustExist` on the same relation when you need one.
552
594
 
553
595
  **Dot-notation in `orderBy` and `conditions`**:
554
596
 
@@ -6,6 +6,83 @@ import { buildConnectionString, introspectTables } from '../lib/cli/pg-introspec
6
6
  import { buildMysqlConnectionConfig, introspectMysqlTables } from '../lib/cli/mysql-introspect.js';
7
7
  import { applyExcludeTables, buildTableMap, generateSchemaFile } from '../lib/cli/schema-codegen.js';
8
8
  import { loadEnvFile, CONSOLE_COLORS, display, displayAsTableRow, parseArgs, runCli } from './utils.js';
9
+ const STATUS_COLOR = {
10
+ created: CONSOLE_COLORS.green,
11
+ updated: CONSOLE_COLORS.cyan,
12
+ untouched: CONSOLE_COLORS.gray,
13
+ };
14
+ function introspect(dialect, schema) {
15
+ if (dialect === 'mysql' || dialect === 'mariadb') {
16
+ const connConfig = buildMysqlConnectionConfig();
17
+ return introspectMysqlTables(connConfig, connConfig.database);
18
+ }
19
+ return introspectTables(buildConnectionString(), schema);
20
+ }
21
+ /** Narrow the map to the `--tables` selection. Returns false when nothing matched. */
22
+ function keepOnlyRequestedTables(tableMap, tableNames) {
23
+ if (!tableNames.length)
24
+ return true;
25
+ for (const schemaName of Object.keys(tableMap)) {
26
+ if (!tableNames.includes(tableMap[schemaName].name)) {
27
+ delete tableMap[schemaName];
28
+ }
29
+ }
30
+ return Object.keys(tableMap).length > 0;
31
+ }
32
+ function fileStatus(filename, content) {
33
+ if (!fs.existsSync(filename))
34
+ return 'created';
35
+ return fs.readFileSync(filename, 'utf-8') === content ? 'untouched' : 'updated';
36
+ }
37
+ function writeSchemaFiles(tableMap, schemasDir) {
38
+ const report = { created: false, touched: false, untouched: 0, files: new Set() };
39
+ for (const schemaName of Object.keys(tableMap)) {
40
+ const { name: tableName, fields, colMap, primary } = tableMap[schemaName];
41
+ const filename = path.join(schemasDir, `${schemaName}.ts`);
42
+ report.files.add(`${schemaName}.ts`);
43
+ const content = generateSchemaFile(schemaName, tableName, fields, colMap, primary);
44
+ const status = fileStatus(filename, content);
45
+ if (status === 'created')
46
+ report.created = true;
47
+ if (status === 'untouched') {
48
+ report.untouched++;
49
+ continue;
50
+ }
51
+ fs.writeFileSync(filename, content);
52
+ displayAsTableRow(filename, status, 90, STATUS_COLOR[status]);
53
+ report.touched = true;
54
+ }
55
+ return report;
56
+ }
57
+ /**
58
+ * Delete `Schema*.ts` files no longer backed by a table — including the ones whose table
59
+ * was just excluded by config. Only safe when generating every table: with `--tables` the
60
+ * unlisted schemas are legitimately absent from `generated`.
61
+ */
62
+ function removeOrphanSchemas(schemasDir, generated) {
63
+ let removed = false;
64
+ for (const file of fs.readdirSync(schemasDir)) {
65
+ if (file.startsWith('Schema') && file.endsWith('.ts') && !generated.has(file)) {
66
+ const filePath = path.join(schemasDir, file);
67
+ fs.unlinkSync(filePath);
68
+ displayAsTableRow(filePath, 'removed', 90, CONSOLE_COLORS.red);
69
+ removed = true;
70
+ }
71
+ }
72
+ return removed;
73
+ }
74
+ function reportOutcome(report) {
75
+ console.log('');
76
+ if (report.untouched > 0) {
77
+ display(`${report.untouched} schema(s) untouched`, CONSOLE_COLORS.gray);
78
+ }
79
+ if (report.created) {
80
+ display("Don't forget to git add your new Schema files.", CONSOLE_COLORS.magenta);
81
+ }
82
+ if (!report.touched) {
83
+ display('All schemas are already up to date.', CONSOLE_COLORS.magenta);
84
+ }
85
+ }
9
86
  await runCli('fastify-auto-sqlapi: generating schemas', async () => {
10
87
  const cliArgs = parseArgs({
11
88
  output: { type: 'value' },
@@ -15,99 +92,34 @@ await runCli('fastify-auto-sqlapi: generating schemas', async () => {
15
92
  const config = await loadConfig();
16
93
  loadEnvFile(config.envFile);
17
94
  const dialect = (cliArgs.dialect || config.dialect || 'postgres');
18
- const schema = config.schema || (dialect === 'postgres' ? 'public' : config.schema || 'public');
95
+ const schema = config.schema || 'public';
19
96
  const outputDir = path.resolve(process.cwd(), cliArgs.output || config.outputDir);
20
97
  const schemasDir = path.join(outputDir, 'schemas');
21
98
  if (!fs.existsSync(schemasDir)) {
22
99
  fs.mkdirSync(schemasDir, { recursive: true });
23
100
  display(`Created directory: ${schemasDir}`, CONSOLE_COLORS.green);
24
101
  }
25
- let rows;
26
- if (dialect === 'mysql' || dialect === 'mariadb') {
27
- const connConfig = buildMysqlConnectionConfig();
28
- rows = await introspectMysqlTables(connConfig, connConfig.database);
29
- }
30
- else {
31
- const connectionString = buildConnectionString();
32
- rows = await introspectTables(connectionString, schema);
33
- }
102
+ const rows = await introspect(dialect, schema);
34
103
  if (rows.length === 0) {
35
104
  display(`No tables found in schema "${schema}".`, CONSOLE_COLORS.magenta);
36
105
  return;
37
106
  }
38
107
  const tableMap = buildTableMap(rows);
39
- // Config blacklist: excluded tables are invisible to the generator, so their
40
- // schema files (if any) fall through to the orphan cleanup below.
108
+ // Config blacklist: excluded tables are invisible to the generator, so their schema files
109
+ // (if any) fall through to the orphan cleanup below. Deliberately no early return when
110
+ // everything is excluded — that cleanup still has to run.
41
111
  const excluded = applyExcludeTables(tableMap, config.excludeTables);
42
112
  if (excluded.length) {
43
113
  display(`Excluded by config (excludeTables): ${excluded.join(', ')}`, CONSOLE_COLORS.gray);
44
114
  }
45
- // Note: no early return when everything is excluded — the orphan cleanup below
46
- // must still run to remove the schema files of freshly excluded tables.
47
- // Filter tables if --tables flag is provided
48
115
  const tableNames = cliArgs.tables;
49
- if (tableNames.length) {
50
- for (const schemaName of Object.keys(tableMap)) {
51
- if (!tableNames.includes(tableMap[schemaName].name)) {
52
- delete tableMap[schemaName];
53
- }
54
- }
55
- if (Object.keys(tableMap).length === 0) {
56
- display(`No matching tables found for: ${tableNames.join(', ')}`, CONSOLE_COLORS.magenta);
57
- return;
58
- }
59
- }
60
- let somethingCreated = false;
61
- let somethingTouched = false;
62
- let untouched = 0;
63
- const generatedFiles = new Set();
64
- for (const schemaName of Object.keys(tableMap)) {
65
- const { name: tableName, fields, colMap, primary } = tableMap[schemaName];
66
- const filename = path.join(schemasDir, `${schemaName}.ts`);
67
- generatedFiles.add(`${schemaName}.ts`);
68
- const content = generateSchemaFile(schemaName, tableName, fields, colMap, primary);
69
- let status = 'created';
70
- let color = CONSOLE_COLORS.green;
71
- if (fs.existsSync(filename)) {
72
- status = 'untouched';
73
- color = CONSOLE_COLORS.gray;
74
- if (fs.readFileSync(filename, 'utf-8') !== content) {
75
- status = 'updated';
76
- color = CONSOLE_COLORS.cyan;
77
- }
78
- }
79
- else {
80
- somethingCreated = true;
81
- }
82
- if (status !== 'untouched') {
83
- fs.writeFileSync(filename, content);
84
- displayAsTableRow(filename, status, 90, color);
85
- somethingTouched = true;
86
- }
87
- else {
88
- untouched++;
89
- }
90
- }
91
- // Remove orphan Schema*.ts files (only when generating all tables)
92
- if (!tableNames.length) {
93
- const existingFiles = fs.readdirSync(schemasDir);
94
- for (const file of existingFiles) {
95
- if (file.startsWith('Schema') && file.endsWith('.ts') && !generatedFiles.has(file)) {
96
- const filePath = path.join(schemasDir, file);
97
- fs.unlinkSync(filePath);
98
- displayAsTableRow(filePath, 'removed', 90, CONSOLE_COLORS.red);
99
- somethingTouched = true;
100
- }
101
- }
102
- }
103
- console.log('');
104
- if (untouched > 0) {
105
- display(`${untouched} schema(s) untouched`, CONSOLE_COLORS.gray);
106
- }
107
- if (somethingCreated) {
108
- display("Don't forget to git add your new Schema files.", CONSOLE_COLORS.magenta);
116
+ if (!keepOnlyRequestedTables(tableMap, tableNames)) {
117
+ display(`No matching tables found for: ${tableNames.join(', ')}`, CONSOLE_COLORS.magenta);
118
+ return;
109
119
  }
110
- if (!somethingTouched) {
111
- display('All schemas are already up to date.', CONSOLE_COLORS.magenta);
120
+ const report = writeSchemaFiles(tableMap, schemasDir);
121
+ if (!tableNames.length && removeOrphanSchemas(schemasDir, report.files)) {
122
+ report.touched = true;
112
123
  }
124
+ reportOutcome(report);
113
125
  });