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.
- package/AGENTS_BACKEND.md +32 -5
- package/BREAKING_CHANGES.md +80 -5
- package/CHANGELOG.md +45 -0
- package/README.md +49 -7
- package/dist/bin/generate-schema.js +89 -77
- package/dist/bin/generate-tables.js +72 -62
- package/dist/bin/utils.d.ts.map +1 -1
- package/dist/bin/utils.js +47 -33
- package/dist/lib/agent/manifest.d.ts +3 -2
- package/dist/lib/agent/manifest.d.ts.map +1 -1
- package/dist/lib/agent/manifest.js +104 -80
- package/dist/lib/cli/mysql-introspect.d.ts +4 -9
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +8 -7
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +0 -1
- package/dist/lib/cli/schema-codegen.d.ts +3 -0
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +3 -0
- package/dist/lib/cli/tables-codegen.d.ts +8 -1
- package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
- package/dist/lib/cli/tables-codegen.js +100 -118
- package/dist/lib/condition-methods.d.ts +14 -4
- package/dist/lib/condition-methods.d.ts.map +1 -1
- package/dist/lib/condition-methods.js +18 -4
- package/dist/lib/db.d.ts +1 -1
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +16 -4
- package/dist/lib/dialect.d.ts +2 -1
- package/dist/lib/dialect.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +42 -35
- package/dist/lib/engine/rest/delete.d.ts.map +1 -1
- package/dist/lib/engine/rest/get.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.js +1 -1
- package/dist/lib/engine/rest/update.d.ts.map +1 -1
- package/dist/lib/engine/rest/update.js +25 -18
- package/dist/lib/engine/search/aggregations.d.ts +33 -0
- package/dist/lib/engine/search/aggregations.d.ts.map +1 -0
- package/dist/lib/engine/search/aggregations.js +109 -0
- package/dist/lib/engine/search/conditions.d.ts +31 -0
- package/dist/lib/engine/search/conditions.d.ts.map +1 -0
- package/dist/lib/engine/search/conditions.js +131 -0
- package/dist/lib/engine/search/fields.d.ts +49 -0
- package/dist/lib/engine/search/fields.d.ts.map +1 -0
- package/dist/lib/engine/search/fields.js +142 -0
- package/dist/lib/engine/search/join-families.d.ts +35 -0
- package/dist/lib/engine/search/join-families.d.ts.map +1 -0
- package/dist/lib/engine/search/join-families.js +345 -0
- package/dist/lib/engine/search/joins.d.ts +55 -0
- package/dist/lib/engine/search/joins.d.ts.map +1 -0
- package/dist/lib/engine/search/joins.js +81 -0
- package/dist/lib/engine/search/order-by.d.ts +35 -0
- package/dist/lib/engine/search/order-by.d.ts.map +1 -0
- package/dist/lib/engine/search/order-by.js +104 -0
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +106 -749
- package/dist/lib/engine/validate.d.ts +0 -5
- package/dist/lib/engine/validate.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.d.ts +25 -17
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +86 -44
- package/dist/lib/read-access.d.ts +37 -1
- package/dist/lib/read-access.d.ts.map +1 -1
- package/dist/lib/read-access.js +64 -1
- package/dist/lib/schema/helpers.d.ts +0 -15
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +7 -4
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +120 -121
- package/dist/lib/sql-api.d.ts.map +1 -1
- package/dist/lib/table-helpers.d.ts +16 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +55 -1
- package/dist/lib/tenant.d.ts +26 -0
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +46 -0
- package/dist/routes/auto/route-helpers.d.ts +0 -7
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +46 -30
- package/dist/routes/auto/search.routes.js +0 -1
- package/dist/types/join.d.ts +9 -0
- package/dist/types/join.d.ts.map +1 -1
- package/dist/types/table.d.ts +1 -1
- package/docs/GLOSSARY.md +28 -0
- package/docs/adr/0010-joins-do-not-run-route-guards.md +100 -0
- package/docs/adr/0011-join-fields-allowlist.md +95 -0
- package/docs/adr/0012-anti-duplication-tooling.md +59 -0
- package/docs/adr/README.md +5 -1
- 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-
|
|
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-
|
|
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-
|
|
612
|
-
// - 2-
|
|
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.
|
package/BREAKING_CHANGES.md
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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-
|
|
261
|
-
- `orderBy` 3-
|
|
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
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
|
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-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
});
|