fastify-auto-sqlapi 0.1.12 → 0.1.14
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 +28 -1
- package/BREAKING_CHANGES.md +75 -0
- package/CHANGELOG.md +55 -0
- package/README.md +140 -42
- package/dist/bin/generate-schema.js +9 -1
- package/dist/lib/agent/manifest.d.ts.map +1 -1
- package/dist/lib/agent/manifest.js +2 -0
- package/dist/lib/cli/schema-codegen.d.ts +7 -0
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +25 -0
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +57 -14
- package/dist/lib/read-access.d.ts +25 -0
- package/dist/lib/read-access.d.ts.map +1 -1
- package/dist/lib/read-access.js +47 -0
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +31 -11
- 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/types/cli.d.ts +6 -0
- package/dist/types/cli.d.ts.map +1 -1
- package/dist/types/join.d.ts +9 -0
- package/dist/types/join.d.ts.map +1 -1
- package/docs/adr/0009-schema-exclusion-config-blacklist.md +47 -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/README.md +3 -0
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import { ConditionBuilder, Expression } from 'node-condition-builder';
|
|
|
2
2
|
import { camelcaseObject } from '../../naming.js';
|
|
3
3
|
import { QueryParams } from '../query-params.js';
|
|
4
4
|
import { buildTenantCondition, buildTenantJoin } from '../../tenant.js';
|
|
5
|
-
import { assertReadable, assertFiltersReadable, readableSelectColumns } from '../../read-access.js';
|
|
5
|
+
import { assertReadable, assertFiltersReadable, assertKnownFilterKeys, readableSelectColumns, explicitSelectColumns, } from '../../read-access.js';
|
|
6
6
|
import { primaryAsString } from '../../../types.js';
|
|
7
7
|
// ─── Schema field validation ────────────────────────────────
|
|
8
8
|
function validateSchemaField(field, schema, tableConf) {
|
|
@@ -87,6 +87,28 @@ function subqueryAlias(alias, tableConf) {
|
|
|
87
87
|
function findJoinByAlias(tableConf, alias) {
|
|
88
88
|
return tableConf.allowedReadJoins?.find((j) => j.alias === alias);
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Validate the filter keys of every join reference in a request map.
|
|
92
|
+
*
|
|
93
|
+
* Runs up front, before any query: the executors skip their side-query entirely when the
|
|
94
|
+
* main result set is empty (`ids.length === 0`), so validating there alone would make the
|
|
95
|
+
* same request answer 400 or 200 depending on the data it happens to match.
|
|
96
|
+
*
|
|
97
|
+
* Unknown aliases are left to `requireJoin`, which reports them with its own message.
|
|
98
|
+
*/
|
|
99
|
+
function assertJoinFilterKeys(dbTables, tableConf, refs, allowExtraFilters) {
|
|
100
|
+
if (!refs)
|
|
101
|
+
return;
|
|
102
|
+
for (const [alias, ref] of Object.entries(refs)) {
|
|
103
|
+
const joinDef = findJoinByAlias(tableConf, alias);
|
|
104
|
+
if (!joinDef)
|
|
105
|
+
continue;
|
|
106
|
+
const joinTableConf = dbTables[joinDef.joinSchema.tableName];
|
|
107
|
+
const filters = ref?.filters;
|
|
108
|
+
assertFiltersReadable(filters, joinTableConf);
|
|
109
|
+
assertKnownFilterKeys(filters, joinDef.joinSchema, joinTableConf, allowExtraFilters);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
90
112
|
function requireJoin(tableConf, alias, requireUnique) {
|
|
91
113
|
const joinDef = findJoinByAlias(tableConf, alias);
|
|
92
114
|
if (!joinDef) {
|
|
@@ -176,9 +198,15 @@ function mapRowsToCamelCase(rows, schema, computedFieldNames) {
|
|
|
176
198
|
* Build the SELECT columns list for a join fetch: '*' or comma-separated quoted columns.
|
|
177
199
|
* A default '*' selection narrows to the join table's readable columns when it declares
|
|
178
200
|
* `readExclude`; an explicit selection naming an excluded field is rejected with 400.
|
|
201
|
+
*
|
|
202
|
+
* On a relation narrowed by a `fields` allowlist the '*' is always spelled out. Emitting a
|
|
203
|
+
* real `SELECT *` there would fetch the excluded columns and leave them to be dropped by
|
|
204
|
+
* the response serializer — which does not run for a caller of `sqlApi.search()`.
|
|
179
205
|
*/
|
|
180
|
-
function buildSelectionColumns(selection, joinSchema, db, joinTableConf) {
|
|
206
|
+
function buildSelectionColumns(selection, joinSchema, db, joinTableConf, restricted = false) {
|
|
181
207
|
if (selection === '*') {
|
|
208
|
+
if (restricted)
|
|
209
|
+
return explicitSelectColumns(joinTableConf, joinSchema, db);
|
|
182
210
|
return readableSelectColumns(joinTableConf, joinSchema, db) ?? '*';
|
|
183
211
|
}
|
|
184
212
|
const table = db.qi(joinSchema.tableName);
|
|
@@ -331,7 +359,9 @@ function buildLeftJoinClauses(db, dbTables, tableConf, aliasesNeedingJoin, joinL
|
|
|
331
359
|
// (so SQL references the LEFT JOIN'd table, not a bare table name).
|
|
332
360
|
// Note: extraFilters declared on joinTableConf are not supported here for
|
|
333
361
|
// joinLeft (they would require alias-aware handlers); only schema fields
|
|
334
|
-
// and computed fields apply.
|
|
362
|
+
// and computed fields apply. `assertJoinFilterKeys` rejects them up front rather
|
|
363
|
+
// than letting them through as a silent no-op, and the generated body schema does
|
|
364
|
+
// not advertise them for unique:true relations.
|
|
335
365
|
const cb = new ConditionBuilder('AND', db.cbDialect);
|
|
336
366
|
const joinTableConf = dbTables[joinSchema.tableName];
|
|
337
367
|
const computed = joinTableConf?.computedFields;
|
|
@@ -487,6 +517,11 @@ async function executeJoinMultiple(db, dbTables, tableConf, mainResults, joinMul
|
|
|
487
517
|
const joinDef = requireJoin(tableConf, alias, false);
|
|
488
518
|
const { joinSchema, joinField, mainField, selection: defaultSelection } = joinDef;
|
|
489
519
|
const joinTableConf = dbTables[joinSchema.tableName];
|
|
520
|
+
// The selection is resolved before the empty-result bail-out: it is validated against
|
|
521
|
+
// the join schema, and that rejection must not depend on whether the main query
|
|
522
|
+
// matched rows.
|
|
523
|
+
const selection = ref?.selection ?? defaultSelection;
|
|
524
|
+
const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf, joinDef.fields !== undefined);
|
|
490
525
|
const ids = collectIds(mainResults, mainField);
|
|
491
526
|
if (ids.length === 0) {
|
|
492
527
|
result[alias] = [];
|
|
@@ -499,8 +534,6 @@ async function executeJoinMultiple(db, dbTables, tableConf, mainResults, joinMul
|
|
|
499
534
|
appendJoinTenantScope(db, joinTableConf, tenant, joinSchema.tableName, cb, tenantJoins);
|
|
500
535
|
const where = cb.build(1, db.ph);
|
|
501
536
|
const values = cb.getValues();
|
|
502
|
-
const selection = ref?.selection ?? defaultSelection;
|
|
503
|
-
const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf);
|
|
504
537
|
const rows = await db.select({
|
|
505
538
|
tableName: joinSchema.tableName,
|
|
506
539
|
columns,
|
|
@@ -519,6 +552,10 @@ async function executeJoinLeft(db, dbTables, tableConf, mainResults, joinLeft, t
|
|
|
519
552
|
const joinDef = requireJoin(tableConf, alias, true);
|
|
520
553
|
const { joinSchema, joinField, mainField, selection: defaultSelection } = joinDef;
|
|
521
554
|
const joinTableConf = dbTables[joinSchema.tableName];
|
|
555
|
+
// Resolved before the bail-out, for the same reason as joinMultiple: an invalid
|
|
556
|
+
// selection is a 400 whether or not the main query matched anything.
|
|
557
|
+
const selection = ref?.selection ?? defaultSelection;
|
|
558
|
+
const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf, joinDef.fields !== undefined);
|
|
522
559
|
// For joinLeft (N:1), mainField on main is the FK pointing to joinField (PK) on parent.
|
|
523
560
|
// We collect the FK values from the main results and look up parents by their PK.
|
|
524
561
|
const ids = collectIds(mainResults, mainField);
|
|
@@ -533,8 +570,6 @@ async function executeJoinLeft(db, dbTables, tableConf, mainResults, joinLeft, t
|
|
|
533
570
|
appendJoinTenantScope(db, joinTableConf, tenant, joinSchema.tableName, cb, tenantJoins);
|
|
534
571
|
const where = cb.build(1, db.ph);
|
|
535
572
|
const values = cb.getValues();
|
|
536
|
-
const selection = ref?.selection ?? defaultSelection;
|
|
537
|
-
const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf);
|
|
538
573
|
const rows = await db.select({
|
|
539
574
|
tableName: joinSchema.tableName,
|
|
540
575
|
columns,
|
|
@@ -577,10 +612,6 @@ async function executeJoinGroup(db, dbTables, tableConf, mainResults, joinGroup,
|
|
|
577
612
|
const { joinSchema, joinField, mainField } = joinDef;
|
|
578
613
|
const joinTableConf = dbTables[joinSchema.tableName];
|
|
579
614
|
const ids = collectIds(mainResults, mainField);
|
|
580
|
-
if (ids.length === 0) {
|
|
581
|
-
result[alias] = {};
|
|
582
|
-
continue;
|
|
583
|
-
}
|
|
584
615
|
const { aggregations, filters: groupFilters, conditions: groupConditions } = groupReq;
|
|
585
616
|
const selectParts = [];
|
|
586
617
|
const groupByParts = [];
|
|
@@ -604,7 +635,9 @@ async function executeJoinGroup(db, dbTables, tableConf, mainResults, joinGroup,
|
|
|
604
635
|
addAgg('sum', aggregations.sum);
|
|
605
636
|
addAgg('avg', aggregations.avg);
|
|
606
637
|
addAgg('count', aggregations.count);
|
|
607
|
-
|
|
638
|
+
// Bail out only here: `by` and every aggregation field have now been validated against
|
|
639
|
+
// the join schema, so an unknown or unreachable field is a 400 regardless of the data.
|
|
640
|
+
if (selectParts.length === 0 || ids.length === 0) {
|
|
608
641
|
result[alias] = {};
|
|
609
642
|
continue;
|
|
610
643
|
}
|
|
@@ -676,6 +709,8 @@ function dispatchConditionMethod(cb, method, colOrExpr, params) {
|
|
|
676
709
|
}
|
|
677
710
|
function buildJoinRefCondition(joinTableConf, joinSchema, ref, db, qualifier) {
|
|
678
711
|
assertFiltersReadable(ref.filters, joinTableConf);
|
|
712
|
+
// Filter keys are checked by assertJoinFilterKeys, up front in searchEngine — this side
|
|
713
|
+
// query may never run, so it is not a place a request can be rejected from.
|
|
679
714
|
// Bare table name for plain side-queries; the subquery alias when the condition lands
|
|
680
715
|
// inside an aliased, correlated FROM (there the table name would resolve to the outer query).
|
|
681
716
|
const colQualifier = qualifier ?? joinSchema.tableName;
|
|
@@ -690,7 +725,7 @@ function buildJoinRefCondition(joinTableConf, joinSchema, ref, db, qualifier) {
|
|
|
690
725
|
continue;
|
|
691
726
|
if (!computed[name])
|
|
692
727
|
continue;
|
|
693
|
-
const expr = evaluateComputedField(name, computed[name],
|
|
728
|
+
const expr = evaluateComputedField(name, computed[name], joinSchema, db, colQualifier, '', true);
|
|
694
729
|
cb.isEqual(expr, value);
|
|
695
730
|
}
|
|
696
731
|
}
|
|
@@ -702,7 +737,7 @@ function buildJoinRefCondition(joinTableConf, joinSchema, ref, db, qualifier) {
|
|
|
702
737
|
// Computed fields resolve to an Expression carrying their own bound values, which
|
|
703
738
|
// the ConditionBuilder places together with the compared value.
|
|
704
739
|
const operand = computed?.[c.field]
|
|
705
|
-
? evaluateComputedField(c.field, computed[c.field],
|
|
740
|
+
? evaluateComputedField(c.field, computed[c.field], joinSchema, db, colQualifier, '', true)
|
|
706
741
|
: `${db.qi(colQualifier)}.${db.qi(validateSchemaField(c.field, joinSchema, joinTableConf))}`;
|
|
707
742
|
dispatchConditionMethod(cb, c.method, operand, c.params ?? []);
|
|
708
743
|
}
|
|
@@ -809,8 +844,16 @@ export async function searchEngine(dbTables, params) {
|
|
|
809
844
|
if (joinLeft)
|
|
810
845
|
for (const a of Object.keys(joinLeft))
|
|
811
846
|
requireJoin(tableConf, a, true);
|
|
847
|
+
// Join filter keys, validated before any query so the outcome never depends on whether
|
|
848
|
+
// the main query returned rows. joinLeft is the odd one: its condition is built inline
|
|
849
|
+
// and never runs the target's extendedCondition, so extraFilters are not accepted there.
|
|
850
|
+
assertJoinFilterKeys(dbTables, tableConf, joinMustExist, true);
|
|
851
|
+
assertJoinFilterKeys(dbTables, tableConf, joinMultiple, true);
|
|
852
|
+
assertJoinFilterKeys(dbTables, tableConf, joinGroup, true);
|
|
853
|
+
assertJoinFilterKeys(dbTables, tableConf, joinLeft, false);
|
|
812
854
|
// Build main condition
|
|
813
855
|
assertFiltersReadable(filters, tableConf);
|
|
856
|
+
assertKnownFilterKeys(filters, tableConf.Schema, tableConf);
|
|
814
857
|
const condition = tableConf.filters(filters || {}, db.cbDialect);
|
|
815
858
|
// Filters and conditions targeting computed fields go on the same ConditionBuilder:
|
|
816
859
|
// a computed resolves to an Expression carrying its own values, and the builder is
|
|
@@ -17,6 +17,26 @@ export declare function assertReadable(tableConf: ITable | undefined, field: str
|
|
|
17
17
|
* excluded pass through untouched (extraFilters and computed names included).
|
|
18
18
|
*/
|
|
19
19
|
export declare function assertFiltersReadable(filters: Record<string, unknown> | undefined, tableConf: ITable | undefined): void;
|
|
20
|
+
/**
|
|
21
|
+
* Throws 400 when a filter map carries a key the engine would never visit.
|
|
22
|
+
*
|
|
23
|
+
* Filters are matched against the schema fields, the table's `extraFilters` and its computed
|
|
24
|
+
* fields; anything else used to be dropped without a word, so a mistyped key returned MORE
|
|
25
|
+
* rows than the caller asked for and nothing said so. That is the dangerous direction to fail
|
|
26
|
+
* in — filters are also how consumers narrow visibility — and it contradicted every
|
|
27
|
+
* neighbouring path, where an unknown field in `selection`, `conditions`, `orderBy` or an
|
|
28
|
+
* aggregation is already a 400.
|
|
29
|
+
*
|
|
30
|
+
* `schema` is the schema the relation was declared with, which for a join may be a trimmed
|
|
31
|
+
* copy: a field outside it is unknown here exactly as it is for `selection`.
|
|
32
|
+
*
|
|
33
|
+
* `allowExtraFilters` is false on the joinLeft path, whose condition is built inline
|
|
34
|
+
* (`buildLeftJoinClauses`) and never runs the table's `extendedCondition`.
|
|
35
|
+
*
|
|
36
|
+
* Call AFTER `assertFiltersReadable`, so a read-excluded field keeps its own message
|
|
37
|
+
* instead of being reported as unknown.
|
|
38
|
+
*/
|
|
39
|
+
export declare function assertKnownFilterKeys(filters: Record<string, unknown> | undefined, schema: SchemaDefinition, tableConf: ITable | undefined, allowExtraFilters?: boolean): void;
|
|
20
40
|
/** Schema field names that may be read, in schema declaration order. */
|
|
21
41
|
export declare function readableFieldNames(tableConf: ITable | undefined, schema: SchemaDefinition): string[];
|
|
22
42
|
/**
|
|
@@ -28,4 +48,9 @@ export declare function readableFieldNames(tableConf: ITable | undefined, schema
|
|
|
28
48
|
* would be ambiguous.
|
|
29
49
|
*/
|
|
30
50
|
export declare function readableSelectColumns(tableConf: ITable | undefined, schema: SchemaDefinition, db: QueryClient): string | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* The same list, always spelled out. Used where `*` is not an acceptable fallback: a
|
|
53
|
+
* relation narrowed by a `fields` allowlist must project the allowlist, not the table.
|
|
54
|
+
*/
|
|
55
|
+
export declare function explicitSelectColumns(tableConf: ITable | undefined, schema: SchemaDefinition, db: QueryClient): string;
|
|
31
56
|
//# sourceMappingURL=read-access.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"read-access.d.ts","sourceRoot":"","sources":["../../src/lib/read-access.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAG5D;;;;;;;;GAQG;AAEH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAEpF;AAED,+DAA+D;AAC/D,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAIjF;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC5C,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,IAAI,CAKN;AAED,wEAAwE;AACxE,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,GACvB,MAAM,EAAE,CAIV;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,EACxB,EAAE,EAAE,WAAW,GACd,MAAM,GAAG,SAAS,
|
|
1
|
+
{"version":3,"file":"read-access.d.ts","sourceRoot":"","sources":["../../src/lib/read-access.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,KAAK,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAG5D;;;;;;;;GAQG;AAEH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAEpF;AAED,+DAA+D;AAC/D,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAIjF;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC5C,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B,IAAI,CAKN;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC5C,MAAM,EAAE,gBAAgB,EACxB,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,iBAAiB,UAAO,GACvB,IAAI,CAkBN;AAED,wEAAwE;AACxE,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,GACvB,MAAM,EAAE,CAIV;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,EACxB,EAAE,EAAE,WAAW,GACd,MAAM,GAAG,SAAS,CAGpB;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,MAAM,EAAE,gBAAgB,EACxB,EAAE,EAAE,WAAW,GACd,MAAM,CAKR"}
|
package/dist/lib/read-access.js
CHANGED
|
@@ -28,6 +28,46 @@ export function assertFiltersReadable(filters, tableConf) {
|
|
|
28
28
|
assertReadable(tableConf, key);
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Throws 400 when a filter map carries a key the engine would never visit.
|
|
33
|
+
*
|
|
34
|
+
* Filters are matched against the schema fields, the table's `extraFilters` and its computed
|
|
35
|
+
* fields; anything else used to be dropped without a word, so a mistyped key returned MORE
|
|
36
|
+
* rows than the caller asked for and nothing said so. That is the dangerous direction to fail
|
|
37
|
+
* in — filters are also how consumers narrow visibility — and it contradicted every
|
|
38
|
+
* neighbouring path, where an unknown field in `selection`, `conditions`, `orderBy` or an
|
|
39
|
+
* aggregation is already a 400.
|
|
40
|
+
*
|
|
41
|
+
* `schema` is the schema the relation was declared with, which for a join may be a trimmed
|
|
42
|
+
* copy: a field outside it is unknown here exactly as it is for `selection`.
|
|
43
|
+
*
|
|
44
|
+
* `allowExtraFilters` is false on the joinLeft path, whose condition is built inline
|
|
45
|
+
* (`buildLeftJoinClauses`) and never runs the table's `extendedCondition`.
|
|
46
|
+
*
|
|
47
|
+
* Call AFTER `assertFiltersReadable`, so a read-excluded field keeps its own message
|
|
48
|
+
* instead of being reported as unknown.
|
|
49
|
+
*/
|
|
50
|
+
export function assertKnownFilterKeys(filters, schema, tableConf, allowExtraFilters = true) {
|
|
51
|
+
if (!filters)
|
|
52
|
+
return;
|
|
53
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
54
|
+
// `undefined` means "filter not supplied" everywhere else in the engine; an absent
|
|
55
|
+
// filter cannot be a wrong one. `null` does filter (IS NULL) and is validated.
|
|
56
|
+
if (value === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
if (key in schema.fields)
|
|
59
|
+
continue;
|
|
60
|
+
if (tableConf?.computedFields?.[key])
|
|
61
|
+
continue;
|
|
62
|
+
if (tableConf?.extraFilters && key in tableConf.extraFilters) {
|
|
63
|
+
if (allowExtraFilters)
|
|
64
|
+
continue;
|
|
65
|
+
throw httpError(400, `Filter '${key}' is an extraFilter: extraFilters are not applied on joinLeft ` +
|
|
66
|
+
`(only schema fields and computed fields are). Use joinMustExist on the same relation.`);
|
|
67
|
+
}
|
|
68
|
+
throw httpError(400, `Unknown filter field: ${key}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
31
71
|
/** Schema field names that may be read, in schema declaration order. */
|
|
32
72
|
export function readableFieldNames(tableConf, schema) {
|
|
33
73
|
const fields = Object.keys(schema.fields);
|
|
@@ -46,6 +86,13 @@ export function readableFieldNames(tableConf, schema) {
|
|
|
46
86
|
export function readableSelectColumns(tableConf, schema, db) {
|
|
47
87
|
if (!tableConf?.readExclude?.length)
|
|
48
88
|
return undefined;
|
|
89
|
+
return explicitSelectColumns(tableConf, schema, db);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The same list, always spelled out. Used where `*` is not an acceptable fallback: a
|
|
93
|
+
* relation narrowed by a `fields` allowlist must project the allowlist, not the table.
|
|
94
|
+
*/
|
|
95
|
+
export function explicitSelectColumns(tableConf, schema, db) {
|
|
49
96
|
const table = db.qi(schema.tableName);
|
|
50
97
|
return readableFieldNames(tableConf, schema)
|
|
51
98
|
.map((f) => `${table}.${db.qi(schema.col(f))}`)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAIrE,OAAO,KAAK,EAAE,QAAQ,EAA4B,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,OAAO,EAAgB,MAAM,mBAAmB,CAAC;AAIrE,OAAO,KAAK,EAAE,QAAQ,EAA4B,MAAM,gBAAgB,CAAC;AAsCzE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CA+HlF;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,eAAe,GAAE,MAAmC,GAAG,OAAO,CAe9F;AAED,qFAAqF;AACrF,eAAO,MAAM,sBAAsB,kDAAqB,CAAC;AAEzD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAsElF"}
|
|
@@ -12,6 +12,18 @@ function readableFields(schema, tableConf) {
|
|
|
12
12
|
}
|
|
13
13
|
return out;
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* A `filters` map.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately NOT `additionalProperties: false`, unlike the write bodies. Fastify runs Ajv
|
|
19
|
+
* with `removeAdditional: true`, so a closed schema would silently *strip* an unknown filter
|
|
20
|
+
* key before the handler ever sees it — reproducing the very bug it looks like it fixes, and
|
|
21
|
+
* leaving the engine's rejection unreachable over HTTP. Unknown keys are rejected by
|
|
22
|
+
* `assertKnownFilterKeys` in the engine instead, which also covers `sqlApi.search()`.
|
|
23
|
+
*/
|
|
24
|
+
function filtersObject(fields) {
|
|
25
|
+
return Type.Partial(Type.Object(fields));
|
|
26
|
+
}
|
|
15
27
|
const JoinGroupResultItem = Type.Object({
|
|
16
28
|
sum: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
17
29
|
min: Type.Optional(Type.Record(Type.String(), Type.Any())),
|
|
@@ -54,7 +66,7 @@ export function SearchTableBodyPost(dbTables, tableName) {
|
|
|
54
66
|
...tableConf.extraFilters,
|
|
55
67
|
...computedTypes,
|
|
56
68
|
};
|
|
57
|
-
const filtersSchema = Type.Optional(
|
|
69
|
+
const filtersSchema = Type.Optional(filtersObject(filterFields));
|
|
58
70
|
// Per-alias entries split by unique flag:
|
|
59
71
|
// - unique:false → joinMustExist / joinMultiple / joinGroup
|
|
60
72
|
// - unique:true → joinLeft
|
|
@@ -82,23 +94,31 @@ export function SearchTableBodyPost(dbTables, tableName) {
|
|
|
82
94
|
}
|
|
83
95
|
}
|
|
84
96
|
}
|
|
85
|
-
const
|
|
86
|
-
? { ...readableFields(joinSchema, joinTableConf), ...
|
|
97
|
+
const joinSchemaFields = joinTableConf
|
|
98
|
+
? { ...readableFields(joinSchema, joinTableConf), ...joinComputedTypes }
|
|
87
99
|
: { ...joinSchema.fields };
|
|
100
|
+
// `extraFilters` are only honoured where the engine delegates to the join table's
|
|
101
|
+
// own `filters()` — buildJoinRefCondition, i.e. the unique:false paths. joinLeft
|
|
102
|
+
// builds its condition inline (buildLeftJoinClauses) and handles schema and computed
|
|
103
|
+
// fields only, so advertising extraFilters there would promise a silent no-op.
|
|
104
|
+
const joinFilterFields = { ...joinSchemaFields, ...(joinTableConf?.extraFilters ?? {}) };
|
|
88
105
|
const joinRefShape = {
|
|
89
|
-
filters: Type.Optional(
|
|
106
|
+
filters: Type.Optional(filtersObject(joinFilterFields)),
|
|
90
107
|
conditions: Type.Optional(Type.Array(conditionItemSchema)),
|
|
91
108
|
};
|
|
92
|
-
const joinFetchShape = {
|
|
93
|
-
...joinRefShape,
|
|
94
|
-
selection: Type.Optional(Type.String()),
|
|
95
|
-
};
|
|
96
109
|
if (unique) {
|
|
97
|
-
joinLeftProps[alias] = Type.Object(
|
|
110
|
+
joinLeftProps[alias] = Type.Object({
|
|
111
|
+
filters: Type.Optional(filtersObject(joinSchemaFields)),
|
|
112
|
+
conditions: Type.Optional(Type.Array(conditionItemSchema)),
|
|
113
|
+
selection: Type.Optional(Type.String()),
|
|
114
|
+
});
|
|
98
115
|
}
|
|
99
116
|
else {
|
|
100
117
|
joinMustExistProps[alias] = Type.Object(joinRefShape);
|
|
101
|
-
joinMultipleProps[alias] = Type.Object(
|
|
118
|
+
joinMultipleProps[alias] = Type.Object({
|
|
119
|
+
...joinRefShape,
|
|
120
|
+
selection: Type.Optional(Type.String()),
|
|
121
|
+
});
|
|
102
122
|
joinGroupProps[alias] = Type.Object({
|
|
103
123
|
aggregations: Type.Object({
|
|
104
124
|
by: Type.Optional(Type.String()),
|
|
@@ -109,7 +129,7 @@ export function SearchTableBodyPost(dbTables, tableName) {
|
|
|
109
129
|
avg: Type.Optional(Type.Array(Type.String())),
|
|
110
130
|
count: Type.Optional(Type.Array(Type.String())),
|
|
111
131
|
}),
|
|
112
|
-
filters: Type.Optional(
|
|
132
|
+
filters: Type.Optional(filtersObject(joinFilterFields)),
|
|
113
133
|
conditions: Type.Optional(Type.Array(conditionItemSchema)),
|
|
114
134
|
});
|
|
115
135
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ConditionBuilder, type ConditionValueOrUndefined } from 'node-condition-builder';
|
|
2
|
-
import type
|
|
2
|
+
import { type TSchema, type TObject } from '@sinclair/typebox';
|
|
3
3
|
import type { SchemaDefinition, JoinDefinition, TableFilterFn, ITable } from '../types.js';
|
|
4
4
|
type ExtraProps<EF> = EF extends TObject<infer P> ? P : EF extends Record<string, TSchema> ? EF : Record<string, never>;
|
|
5
5
|
export declare function exportTableInfo<F extends Record<string, TSchema>, EF extends TObject | Record<string, TSchema> = Record<never, TSchema>>(Schema: SchemaDefinition<F>, extraFilters?: EF, extendedCondition?: (condition: ConditionBuilder, filters: {
|
|
@@ -14,6 +14,21 @@ export interface BuildRelationOptions {
|
|
|
14
14
|
alias?: string;
|
|
15
15
|
selection?: string;
|
|
16
16
|
unique?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Allowlist of the target table's fields reachable through this relation.
|
|
19
|
+
*
|
|
20
|
+
* The relation is declared against a schema narrowed to these fields, so every read
|
|
21
|
+
* surface — `selection`, `filters`, `conditions`, `orderBy`, aggregations, and the
|
|
22
|
+
* generated request/response schemas — rejects anything outside the list with
|
|
23
|
+
* `400 Unknown field`. Use it to expose a table broadly while keeping some of its
|
|
24
|
+
* columns out of one relation, without hiding them from the table's own routes the way
|
|
25
|
+
* `readExclude` would.
|
|
26
|
+
*
|
|
27
|
+
* Fail-closed: a column added to the table later is not reachable through the relation
|
|
28
|
+
* until it is added here. Must include the join field, and is not allowed on a write
|
|
29
|
+
* join.
|
|
30
|
+
*/
|
|
31
|
+
fields?: string[];
|
|
17
32
|
}
|
|
18
33
|
export declare function buildRelation<M extends Record<string, TSchema>, J extends Record<string, TSchema>>(mainSchema: SchemaDefinition<M>, mainField: string & keyof M | (string & keyof M)[], joinSchema: SchemaDefinition<J>, joinField: string & keyof J, options?: BuildRelationOptions): JoinDefinition;
|
|
19
34
|
export declare function buildUpsertRule<F extends Record<string, TSchema>>(schema: SchemaDefinition<F>, columns: (string & keyof F)[]): [SchemaDefinition, string[]];
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EACP,MAAM,aAAa,CAAC;AAarB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAOX;AAqFD,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAgDD,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAWhB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ConditionBuilder } from 'node-condition-builder';
|
|
2
|
+
import { Type } from '@sinclair/typebox';
|
|
2
3
|
import { getDialect } from './dialect.js';
|
|
3
4
|
// Quote an identifier using the given ConditionBuilder dialect (or the global
|
|
4
5
|
// default when not provided). Needed so that DB columns with uppercase letters
|
|
@@ -46,10 +47,27 @@ export function exportTableInfo(Schema, extraFilters = {}, extendedCondition) {
|
|
|
46
47
|
export function defineTable(config) {
|
|
47
48
|
validateAliasUniqueness(config.allowedReadJoins, 'allowedReadJoins');
|
|
48
49
|
validateAliasUniqueness(config.allowedWriteJoins, 'allowedWriteJoins');
|
|
50
|
+
validateWriteJoinsUnrestricted(config.allowedWriteJoins);
|
|
49
51
|
validateComputedFields(config);
|
|
50
52
|
validateReadExclude(config);
|
|
51
53
|
return config;
|
|
52
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* `fields` narrows a relation for reading; it has no meaning on a write join and would be
|
|
57
|
+
* actively harmful there. Write paths resolve the secondary's upsert rule by looking the
|
|
58
|
+
* schema object up in `upsertMap` **by identity**, and a narrowed relation carries a copy —
|
|
59
|
+
* the lookup would miss and the upsert would silently degrade to a plain insert. The
|
|
60
|
+
* narrowed schema would also drop columns the caller legitimately sends.
|
|
61
|
+
*/
|
|
62
|
+
function validateWriteJoinsUnrestricted(joins) {
|
|
63
|
+
for (const join of joins ?? []) {
|
|
64
|
+
if (join.fields) {
|
|
65
|
+
throw new Error(`defineTable: relation '${join.alias}' in allowedWriteJoins declares 'fields'. ` +
|
|
66
|
+
`The fields allowlist restricts reading only — remove it, and use a separate ` +
|
|
67
|
+
`buildRelation for the read side if that relation is also in allowedReadJoins.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
53
71
|
function validateReadExclude(config) {
|
|
54
72
|
const excluded = config.readExclude;
|
|
55
73
|
if (!excluded?.length)
|
|
@@ -97,14 +115,50 @@ function validateAliasUniqueness(joins, label) {
|
|
|
97
115
|
seen.add(j.alias);
|
|
98
116
|
}
|
|
99
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Copy of `schema` exposing only `fields`, in schema declaration order.
|
|
120
|
+
*
|
|
121
|
+
* `tableName`, `col` and `colMap` are carried over untouched: the engine resolves the
|
|
122
|
+
* target's configuration through `dbTables[joinSchema.tableName]`, so a narrowed relation
|
|
123
|
+
* must still answer to its real table name, and column mapping must keep working for the
|
|
124
|
+
* fields that survive.
|
|
125
|
+
*/
|
|
126
|
+
function narrowSchemaToFields(schema, fields, joinField) {
|
|
127
|
+
const declared = Object.keys(schema.fields);
|
|
128
|
+
for (const field of fields) {
|
|
129
|
+
if (!declared.includes(field)) {
|
|
130
|
+
throw new Error(`buildRelation: 'fields' entry '${field}' is not a field of schema ` +
|
|
131
|
+
`'${schema.tableName}'. Available: ${declared.join(', ')}.`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// The join field is what ties the fetched rows back to the main result set. Dropping it
|
|
135
|
+
// from the projection would hand the caller rows it cannot match to anything.
|
|
136
|
+
if (!fields.includes(joinField)) {
|
|
137
|
+
throw new Error(`buildRelation: 'fields' must include the join field '${joinField}' on ` +
|
|
138
|
+
`'${schema.tableName}' — it is what correlates the joined rows with the main ones.`);
|
|
139
|
+
}
|
|
140
|
+
const picked = {};
|
|
141
|
+
for (const field of declared) {
|
|
142
|
+
if (fields.includes(field))
|
|
143
|
+
picked[field] = schema.fields[field];
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
...schema,
|
|
147
|
+
fields: picked,
|
|
148
|
+
validation: Type.Object(picked),
|
|
149
|
+
partialValidation: Type.Partial(Type.Object(picked)),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
100
152
|
export function buildRelation(mainSchema, mainField, joinSchema, joinField, options) {
|
|
153
|
+
const fields = options?.fields;
|
|
101
154
|
return {
|
|
102
|
-
joinSchema,
|
|
155
|
+
joinSchema: fields ? narrowSchemaToFields(joinSchema, fields, joinField) : joinSchema,
|
|
103
156
|
joinField,
|
|
104
157
|
mainField,
|
|
105
158
|
alias: options?.alias ?? joinSchema.tableName,
|
|
106
159
|
selection: options?.selection ?? '*',
|
|
107
160
|
unique: options?.unique ?? false,
|
|
161
|
+
...(fields ? { fields: [...fields] } : {}),
|
|
108
162
|
};
|
|
109
163
|
}
|
|
110
164
|
export function buildUpsertRule(schema, columns) {
|
package/dist/types/cli.d.ts
CHANGED
|
@@ -4,6 +4,12 @@ export interface SqlApiConfig {
|
|
|
4
4
|
schema?: string;
|
|
5
5
|
dialect?: DialectName;
|
|
6
6
|
envFile?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Tables to skip during schema generation (`sqlapi-generate-schema`).
|
|
9
|
+
* Matched against the DB table name; `*` is a wildcard (e.g. "knex_*").
|
|
10
|
+
* Excluded schemas already on disk are removed as orphans on the next full run.
|
|
11
|
+
*/
|
|
12
|
+
excludeTables?: string[];
|
|
7
13
|
}
|
|
8
14
|
export interface ColumnInfo {
|
|
9
15
|
table_name: string;
|
package/dist/types/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/types/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/types/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,wFAAwF;IACxF,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,MAAM,WAAW,QAAQ;IACvB,CAAC,UAAU,EAAE,MAAM,GAAG;QACpB,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,iFAAiF;QACjF,OAAO,EAAE,MAAM,EAAE,CAAC;KACnB,CAAC;CACH"}
|
package/dist/types/join.d.ts
CHANGED
|
@@ -8,6 +8,15 @@ export interface JoinDefinition {
|
|
|
8
8
|
alias: string;
|
|
9
9
|
selection: string;
|
|
10
10
|
unique: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Allowlist of the target table's fields reachable through this relation, when
|
|
13
|
+
* `buildRelation` was given one. `joinSchema` is already narrowed to it — this is kept
|
|
14
|
+
* so the engine can tell a restricted relation from a full one, which is what stops the
|
|
15
|
+
* default `'*'` selection from falling back to a real `SELECT *`.
|
|
16
|
+
*
|
|
17
|
+
* Read joins only: a write join declaring it is rejected by `defineTable`.
|
|
18
|
+
*/
|
|
19
|
+
fields?: string[];
|
|
11
20
|
}
|
|
12
21
|
export interface AggregationRequest {
|
|
13
22
|
/**
|
package/dist/types/join.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"join.d.ts","sourceRoot":"","sources":["../../src/types/join.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,gBAAgB,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"join.d.ts","sourceRoot":"","sources":["../../src/types/join.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,gBAAgB,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,OAAO,CAAC;IAChB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,EAAE,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,YAAY,EAAE,kBAAkB,CAAC;IACjC,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;CAChC;AAED;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;CAChC;AAED,kEAAkE;AAClE,MAAM,WAAW,gBAAiB,SAAQ,aAAa;IACrD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# 0009. Schema exclusion is a config blacklist — excluded schemas are removed as orphans
|
|
2
|
+
|
|
3
|
+
- **Status**: accepted
|
|
4
|
+
- **Date**: 2026-07-27
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
`sqlapi-generate-schema` regenerates every table by default (ADR 0007: schemas are
|
|
9
|
+
disposable mirrors). There was no way to keep unwanted tables — migration bookkeeping
|
|
10
|
+
(`knex_migrations*`), PostGIS internals (`spatial_ref_sys`), vendor tables — out of the
|
|
11
|
+
generated schemas short of deleting the files after every run.
|
|
12
|
+
|
|
13
|
+
## Decision
|
|
14
|
+
|
|
15
|
+
A blacklist lives in the existing config file: `excludeTables: string[]` in
|
|
16
|
+
`sqlapi.config.ts`, matched against the DB table name, with `*` as the only wildcard.
|
|
17
|
+
|
|
18
|
+
Excluded tables are **invisible to the generator**: their schemas are not generated, and a
|
|
19
|
+
previously generated `Schema*.ts` for an excluded table is removed by the orphan cleanup
|
|
20
|
+
on the next full run — exactly as if the table had been dropped. This keeps the ADR 0007
|
|
21
|
+
invariant intact: everything under `schemas/` is a disposable mirror; excluding a table
|
|
22
|
+
must not turn its schema file into a hand-maintained exception.
|
|
23
|
+
|
|
24
|
+
This does not contradict ADR 0007's rejection of a skip-list for *tables*: there, explicit
|
|
25
|
+
invocation solves exclusion for free. For schemas the default is "all", so a blacklist is
|
|
26
|
+
the only way to exclude — and the same ADR is why `excludeTables` does **not** apply to
|
|
27
|
+
`sqlapi-generate-tables`.
|
|
28
|
+
|
|
29
|
+
## Alternatives considered
|
|
30
|
+
|
|
31
|
+
- **`.schemaignore` file next to the schemas** — rejected: a second config format and a
|
|
32
|
+
second source of truth to parse and document, when a typed config file already exists.
|
|
33
|
+
- **CLI-only `--exclude` flag** — rejected as the primary mechanism: the exclusion is a
|
|
34
|
+
durable property of the project, not of one invocation; forgetting the flag once would
|
|
35
|
+
resurrect the unwanted schemas.
|
|
36
|
+
- **Keeping excluded schema files on disk (generator "never touches" them)** — rejected:
|
|
37
|
+
it would silently support hand-maintained files under `schemas/`, breaking the "always
|
|
38
|
+
safe to regenerate everything" property of ADR 0007.
|
|
39
|
+
|
|
40
|
+
## Consequences
|
|
41
|
+
|
|
42
|
+
- Adding a table to `excludeTables` deletes its generated schema file on the next full
|
|
43
|
+
run. This is intended: excluded means gone, not frozen.
|
|
44
|
+
- Hand-written schemas do not belong in `schemas/`; exclusion is not a mechanism to
|
|
45
|
+
protect local edits.
|
|
46
|
+
- An invalid `excludeTables` value (not an array of strings) fails the run with an
|
|
47
|
+
explicit error rather than being silently ignored.
|