fastify-auto-sqlapi 0.1.14 → 0.2.1
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 +52 -7
- package/BREAKING_CHANGES.md +86 -5
- package/CHANGELOG.md +57 -0
- package/README.md +110 -3
- 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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- 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 +102 -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 +7 -2
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +24 -6
- 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 +44 -36
- 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/get.js +5 -4
- 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 +26 -19
- 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 -791
- 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/errors.d.ts +24 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +59 -0
- package/dist/lib/read-access.d.ts +12 -1
- package/dist/lib/read-access.d.ts.map +1 -1
- package/dist/lib/read-access.js +20 -4
- 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 +107 -128
- package/dist/lib/sql-api.d.ts.map +1 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +48 -0
- package/dist/lib/tenant.d.ts +66 -5
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +209 -48
- package/dist/routes/auto/plugin.d.ts.map +1 -1
- package/dist/routes/auto/plugin.js +6 -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 +58 -33
- package/dist/routes/auto/search.routes.js +0 -1
- package/dist/types/plugin.d.ts +19 -0
- package/dist/types/plugin.d.ts.map +1 -1
- package/dist/types/table.d.ts +1 -1
- package/dist/types/tenant.d.ts +26 -2
- package/dist/types/tenant.d.ts.map +1 -1
- package/docs/GLOSSARY.md +30 -0
- package/docs/adr/0006-raw-db-errors.md +2 -1
- package/docs/adr/0010-joins-do-not-run-route-guards.md +4 -1
- package/docs/adr/0012-anti-duplication-tooling.md +59 -0
- package/docs/adr/0013-sanitized-db-errors.md +114 -0
- package/docs/adr/README.md +4 -1
- package/package.json +29 -2
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a request's `filters` and `conditions` into ConditionBuilder clauses.
|
|
3
|
+
*
|
|
4
|
+
* The method vocabulary itself lives in lib/condition-methods.ts, which is a pure data
|
|
5
|
+
* module shared with the TypeBox schema builders — the dispatch below is kept out of it so
|
|
6
|
+
* that module keeps its zero dependencies.
|
|
7
|
+
*/
|
|
8
|
+
import { ConditionBuilder } from 'node-condition-builder';
|
|
9
|
+
import { assertFiltersReadable, ownField } from '../../read-access.js';
|
|
10
|
+
import { ALLOWED_SET, SINGLE_VALUE_SET, BETWEEN_SET, IN_SET, NULL_SET, } from '../../condition-methods.js';
|
|
11
|
+
import { err400, validateSchemaField, evaluateComputedField, resolveFieldRef, } from './fields.js';
|
|
12
|
+
/**
|
|
13
|
+
* Apply one condition method to a builder, after checking the request actually supplied the
|
|
14
|
+
* operands that method needs.
|
|
15
|
+
*
|
|
16
|
+
* `params` is optional in the request schema, so `{field, method}` with no params is a
|
|
17
|
+
* well-formed request that reaches here — reading `params[0]` off it throws a TypeError the
|
|
18
|
+
* error handler can only report as a 500. Arity is checked here rather than at the four call
|
|
19
|
+
* sites so no path can skip it.
|
|
20
|
+
*/
|
|
21
|
+
export function dispatchConditionMethod(cb, method, colOrExpr, params) {
|
|
22
|
+
if (params !== undefined && !Array.isArray(params)) {
|
|
23
|
+
err400(`Condition method '${method}': 'params' must be an array`);
|
|
24
|
+
}
|
|
25
|
+
const args = params ?? [];
|
|
26
|
+
const require = (n) => {
|
|
27
|
+
if (args.length < n) {
|
|
28
|
+
err400(`Condition method '${method}' requires ${n} param(s), got ${args.length}`);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
if (SINGLE_VALUE_SET.has(method)) {
|
|
32
|
+
require(1);
|
|
33
|
+
cb[method](colOrExpr, args[0]);
|
|
34
|
+
}
|
|
35
|
+
else if (BETWEEN_SET.has(method)) {
|
|
36
|
+
require(2);
|
|
37
|
+
cb[method](colOrExpr, args[0], args[1]);
|
|
38
|
+
}
|
|
39
|
+
else if (IN_SET.has(method)) {
|
|
40
|
+
require(1);
|
|
41
|
+
// An explicit `undefined` stays a documented no-op; any other non-array would make the
|
|
42
|
+
// builder iterate a non-iterable.
|
|
43
|
+
if (args[0] !== undefined && !Array.isArray(args[0])) {
|
|
44
|
+
err400(`Condition method '${method}' requires an array as its first param`);
|
|
45
|
+
}
|
|
46
|
+
cb[method](colOrExpr, args[0]);
|
|
47
|
+
}
|
|
48
|
+
else if (NULL_SET.has(method)) {
|
|
49
|
+
cb[method](colOrExpr, true);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Equality filters targeting *computed* fields: `filters.<computedName>` becomes
|
|
54
|
+
* `<expr> = <value>` on the builder `tableConf.filters()` has already populated with the plain
|
|
55
|
+
* column filters. A name that is not a declared computed field is skipped — it was either
|
|
56
|
+
* handled there or rejected up front by `assertKnownFilterKeys`.
|
|
57
|
+
*
|
|
58
|
+
* `qualifier` prefixes the columns the computed expression references: the join's qualifier
|
|
59
|
+
* inside a side query, undefined on the main table (where the schema's own name is used).
|
|
60
|
+
*/
|
|
61
|
+
function applyComputedEqualityFilters(cb, filters, computed, schema, db, qualifier) {
|
|
62
|
+
if (!computed)
|
|
63
|
+
return;
|
|
64
|
+
for (const [name, value] of Object.entries(filters)) {
|
|
65
|
+
if (value === null || value === undefined)
|
|
66
|
+
continue;
|
|
67
|
+
const fn = ownField(computed, name);
|
|
68
|
+
if (!fn)
|
|
69
|
+
continue;
|
|
70
|
+
const expr = evaluateComputedField(name, fn, schema, db, qualifier, '', true);
|
|
71
|
+
cb.isEqual(expr, value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Operator conditions on a join reference, against a schema or a computed field. */
|
|
75
|
+
function applyJoinRefConditions(cb, conditions, joinTableConf, joinSchema, db, colQualifier) {
|
|
76
|
+
const computed = joinTableConf?.computedFields;
|
|
77
|
+
for (const c of conditions) {
|
|
78
|
+
if (!ALLOWED_SET.has(c.method)) {
|
|
79
|
+
err400(`Invalid condition method: ${c.method}`);
|
|
80
|
+
}
|
|
81
|
+
// Computed fields resolve to an Expression carrying their own bound values, which the
|
|
82
|
+
// ConditionBuilder places together with the compared value.
|
|
83
|
+
const fn = ownField(computed, c.field);
|
|
84
|
+
const operand = fn
|
|
85
|
+
? evaluateComputedField(c.field, fn, joinSchema, db, colQualifier, '', true)
|
|
86
|
+
: `${db.qi(colQualifier)}.${db.qi(validateSchemaField(c.field, joinSchema, joinTableConf))}`;
|
|
87
|
+
dispatchConditionMethod(cb, c.method, operand, c.params ?? []);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function buildJoinRefCondition(joinTableConf, joinSchema, ref, db, qualifier) {
|
|
91
|
+
assertFiltersReadable(ref.filters, joinTableConf);
|
|
92
|
+
// Filter keys are checked by assertJoinFilterKeys, up front in searchEngine — this side
|
|
93
|
+
// query may never run, so it is not a place a request can be rejected from.
|
|
94
|
+
// Bare table name for plain side-queries; the subquery alias when the condition lands
|
|
95
|
+
// inside an aliased, correlated FROM (there the table name would resolve to the outer query).
|
|
96
|
+
const colQualifier = qualifier ?? joinSchema.tableName;
|
|
97
|
+
const cb = (ref.filters && joinTableConf)
|
|
98
|
+
? joinTableConf.filters(ref.filters, db.cbDialect, colQualifier)
|
|
99
|
+
: new ConditionBuilder('AND', db.cbDialect);
|
|
100
|
+
if (ref.filters) {
|
|
101
|
+
applyComputedEqualityFilters(cb, ref.filters, joinTableConf?.computedFields, joinSchema, db, colQualifier);
|
|
102
|
+
}
|
|
103
|
+
if (ref.conditions?.length) {
|
|
104
|
+
applyJoinRefConditions(cb, ref.conditions, joinTableConf, joinSchema, db, colQualifier);
|
|
105
|
+
}
|
|
106
|
+
return cb;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Apply non-dotted conditions to the main `condition` ConditionBuilder when
|
|
110
|
+
* possible. For schema fields → straight dispatch. For computed fields → return
|
|
111
|
+
* side-channel clauses to be appended to WHERE later (with correct placeholder
|
|
112
|
+
* offsets), since ConditionBuilder cannot bind values for an LHS expression.
|
|
113
|
+
*/
|
|
114
|
+
export function applyConditions(condition, conditions, schema, tableConf, db) {
|
|
115
|
+
for (const c of conditions) {
|
|
116
|
+
// Skip dot-notation fields — those become aggregation conditions processed later
|
|
117
|
+
if (c.field.includes('.'))
|
|
118
|
+
continue;
|
|
119
|
+
if (!ALLOWED_SET.has(c.method)) {
|
|
120
|
+
err400(`Invalid condition method: ${c.method}`);
|
|
121
|
+
}
|
|
122
|
+
// A computed field resolves to an Expression carrying its own bound values; the
|
|
123
|
+
// ConditionBuilder places them, so no placeholder offset is computed here.
|
|
124
|
+
const ref = resolveFieldRef(c.field, schema, tableConf, db);
|
|
125
|
+
dispatchConditionMethod(condition, c.method, ref.expr, c.params);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Computed-field equality filters on the main table. */
|
|
129
|
+
export function applyComputedFilters(condition, filters, tableConf, db) {
|
|
130
|
+
applyComputedEqualityFilters(condition, filters, tableConf.computedFields, tableConf.Schema, db);
|
|
131
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field resolution for the search engine: turning a name in a request body into a SQL
|
|
3
|
+
* operand, and rows back into API records.
|
|
4
|
+
*
|
|
5
|
+
* This is the leaf of the search modules — it knows about schemas, computed fields and
|
|
6
|
+
* read access, and nothing about joins, ordering or query execution.
|
|
7
|
+
*/
|
|
8
|
+
import { Expression } from 'node-condition-builder';
|
|
9
|
+
import type { QueryClient } from '../../db.js';
|
|
10
|
+
import type { ITable, SchemaDefinition, ComputedFieldFn } from '../../../types.js';
|
|
11
|
+
export declare function validateSchemaField(field: string, schema: SchemaDefinition, tableConf?: ITable): string;
|
|
12
|
+
export declare function err400(msg: string): never;
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a field reference in a request body to a SQL operand. A schema column becomes
|
|
15
|
+
* the quoted column name; a computed field becomes an Expression carrying its own bound
|
|
16
|
+
* values, so it can be handed straight to a ConditionBuilder. Throws 400 if neither.
|
|
17
|
+
*/
|
|
18
|
+
export interface ResolvedFieldRef {
|
|
19
|
+
expr: string | Expression;
|
|
20
|
+
computed: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare function resolveFieldRef(field: string, schema: SchemaDefinition, tableConf: ITable | undefined, db: QueryClient, alias?: string): ResolvedFieldRef;
|
|
23
|
+
/**
|
|
24
|
+
* Evaluate a computed field into an Expression carrying its own bound values.
|
|
25
|
+
*
|
|
26
|
+
* Inside a WHERE clause the ConditionBuilder assigns the placeholder indexes, so bound
|
|
27
|
+
* values are always safe there. `allowBoundValues=false` is for the positions that sit
|
|
28
|
+
* *before* the WHERE values in the parameter order (SELECT projections, aggregations),
|
|
29
|
+
* where they cannot be placed correctly; those still reject with 400.
|
|
30
|
+
*/
|
|
31
|
+
export declare function evaluateComputedField(name: string, fn: ComputedFieldFn, schema: SchemaDefinition, db: QueryClient, alias: string | undefined, message: string, allowBoundValues?: boolean): Expression;
|
|
32
|
+
/** Bound values carried by a resolved field reference. */
|
|
33
|
+
export declare function refValues(expr: string | Expression): unknown[];
|
|
34
|
+
/** Render a resolved field reference to SQL, binding any values from `startIndex`. */
|
|
35
|
+
export declare function renderRef(expr: string | Expression, startIndex: number, db: QueryClient): string;
|
|
36
|
+
/** Map rows from snake_case columns to camelCase fields, preserving any computed-field columns
|
|
37
|
+
* that are projected by their declared name (skipped by camelcaseObject). */
|
|
38
|
+
export declare function mapRowsToCamelCase(rows: Record<string, unknown>[], schema: SchemaDefinition, computedFieldNames?: Iterable<string>): Record<string, unknown>[];
|
|
39
|
+
/**
|
|
40
|
+
* Build the SELECT columns list for a join fetch: '*' or comma-separated quoted columns.
|
|
41
|
+
* A default '*' selection narrows to the join table's readable columns when it declares
|
|
42
|
+
* `readExclude`; an explicit selection naming an excluded field is rejected with 400.
|
|
43
|
+
*
|
|
44
|
+
* On a relation narrowed by a `fields` allowlist the '*' is always spelled out. Emitting a
|
|
45
|
+
* real `SELECT *` there would fetch the excluded columns and leave them to be dropped by
|
|
46
|
+
* the response serializer — which does not run for a caller of `sqlApi.search()`.
|
|
47
|
+
*/
|
|
48
|
+
export declare function buildSelectionColumns(selection: string, joinSchema: SchemaDefinition, db: QueryClient, joinTableConf?: ITable, restricted?: boolean): string;
|
|
49
|
+
//# sourceMappingURL=fields.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fields.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/fields.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAS/C,OAAO,KAAK,EACV,MAAM,EACN,gBAAgB,EAGhB,eAAe,EAChB,MAAM,mBAAmB,CAAC;AAI3B,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,gBAAgB,EACxB,SAAS,CAAC,EAAE,MAAM,GACjB,MAAM,CAQR;AAED,wBAAgB,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,KAAK,CAIzC;AA6BD;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,GAAG,UAAU,CAAC;IAC1B,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,gBAAgB,EACxB,SAAS,EAAE,MAAM,GAAG,SAAS,EAC7B,EAAE,EAAE,WAAW,EACf,KAAK,CAAC,EAAE,MAAM,GACb,gBAAgB,CAiBlB;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,eAAe,EACnB,MAAM,EAAE,gBAAgB,EACxB,EAAE,EAAE,WAAW,EACf,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,OAAO,EAAE,MAAM,EACf,gBAAgB,UAAQ,GACvB,UAAU,CAMZ;AA0BD,0DAA0D;AAC1D,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,EAAE,CAE9D;AAED,sFAAsF;AACtF,wBAAgB,SAAS,CACvB,IAAI,EAAE,MAAM,GAAG,UAAU,EACzB,UAAU,EAAE,MAAM,EAClB,EAAE,EAAE,WAAW,GACd,MAAM,CAER;AAED;6EAC6E;AAC7E,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC/B,MAAM,EAAE,gBAAgB,EACxB,kBAAkB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,GACpC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAW3B;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,gBAAgB,EAC5B,EAAE,EAAE,WAAW,EACf,aAAa,CAAC,EAAE,MAAM,EACtB,UAAU,UAAQ,GACjB,MAAM,CAUR"}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field resolution for the search engine: turning a name in a request body into a SQL
|
|
3
|
+
* operand, and rows back into API records.
|
|
4
|
+
*
|
|
5
|
+
* This is the leaf of the search modules — it knows about schemas, computed fields and
|
|
6
|
+
* read access, and nothing about joins, ordering or query execution.
|
|
7
|
+
*/
|
|
8
|
+
import { Expression } from 'node-condition-builder';
|
|
9
|
+
import { camelcaseObject } from '../../naming.js';
|
|
10
|
+
import { assertReadable, readableSelectColumns, explicitSelectColumns, hasOwnField, ownField, } from '../../read-access.js';
|
|
11
|
+
// ─── Schema field validation ────────────────────────────────
|
|
12
|
+
export function validateSchemaField(field, schema, tableConf) {
|
|
13
|
+
if (!hasOwnField(schema.fields, field)) {
|
|
14
|
+
const err = new Error(`Unknown field: ${field}`);
|
|
15
|
+
err.statusCode = 400;
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
assertReadable(tableConf, field);
|
|
19
|
+
return schema.col(field);
|
|
20
|
+
}
|
|
21
|
+
export function err400(msg) {
|
|
22
|
+
const e = new Error(msg);
|
|
23
|
+
e.statusCode = 400;
|
|
24
|
+
throw e;
|
|
25
|
+
}
|
|
26
|
+
// ─── Computed fields resolution ─────────────────────────────
|
|
27
|
+
/**
|
|
28
|
+
* Build a ComputedFieldContext bound to a specific table/schema. `alias`
|
|
29
|
+
* (if provided) qualifies the columns produced by `qiCol` — needed when the
|
|
30
|
+
* computed is embedded in a `LEFT JOIN <table> AS <alias>` (joinLeft).
|
|
31
|
+
*/
|
|
32
|
+
function buildComputedContext(db, schema, alias) {
|
|
33
|
+
return {
|
|
34
|
+
db,
|
|
35
|
+
qiCol(field, opts) {
|
|
36
|
+
if (!hasOwnField(schema.fields, field)) {
|
|
37
|
+
err400(`Unknown field referenced by computed expression: ${field}`);
|
|
38
|
+
}
|
|
39
|
+
const col = db.qi(schema.col(field));
|
|
40
|
+
// Default to the owning table: the statement may carry joins, and a bare
|
|
41
|
+
// column shared with a joined table would be ambiguous.
|
|
42
|
+
const qualifier = opts?.qualifier ?? alias ?? schema.tableName;
|
|
43
|
+
return qualifier ? `${db.qi(qualifier)}.${col}` : col;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function resolveFieldRef(field, schema, tableConf, db, alias) {
|
|
48
|
+
if (hasOwnField(schema.fields, field)) {
|
|
49
|
+
assertReadable(tableConf, field);
|
|
50
|
+
const col = db.qi(schema.col(field));
|
|
51
|
+
// Qualified with the alias or the owning table — never bare, so the reference
|
|
52
|
+
// stays unambiguous when the statement carries joins.
|
|
53
|
+
const expr = `${db.qi(alias ?? schema.tableName)}.${col}`;
|
|
54
|
+
return { expr, computed: false };
|
|
55
|
+
}
|
|
56
|
+
const fn = ownField(tableConf?.computedFields, field);
|
|
57
|
+
if (fn) {
|
|
58
|
+
const ctx = buildComputedContext(db, schema, alias);
|
|
59
|
+
return { expr: computedExpression(field, fn(ctx)), computed: true };
|
|
60
|
+
}
|
|
61
|
+
err400(`Unknown field: ${field}`);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Evaluate a computed field into an Expression carrying its own bound values.
|
|
65
|
+
*
|
|
66
|
+
* Inside a WHERE clause the ConditionBuilder assigns the placeholder indexes, so bound
|
|
67
|
+
* values are always safe there. `allowBoundValues=false` is for the positions that sit
|
|
68
|
+
* *before* the WHERE values in the parameter order (SELECT projections, aggregations),
|
|
69
|
+
* where they cannot be placed correctly; those still reject with 400.
|
|
70
|
+
*/
|
|
71
|
+
export function evaluateComputedField(name, fn, schema, db, alias, message, allowBoundValues = false) {
|
|
72
|
+
const ev = fn(buildComputedContext(db, schema, alias));
|
|
73
|
+
if (!allowBoundValues && (ev.values?.length ?? 0) > 0) {
|
|
74
|
+
err400(message);
|
|
75
|
+
}
|
|
76
|
+
return computedExpression(name, ev);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Build the Expression for a computed field, checking that it marks each bound value
|
|
80
|
+
* with a `?`.
|
|
81
|
+
*
|
|
82
|
+
* A mismatch is always a table-configuration bug and would otherwise produce a query that
|
|
83
|
+
* binds values nobody references — silently returning wrong rows. Expressions without
|
|
84
|
+
* bound values are exempt: they are emitted verbatim, so a `?` there is a literal operator
|
|
85
|
+
* (PostgreSQL jsonb) rather than a marker.
|
|
86
|
+
*/
|
|
87
|
+
function computedExpression(name, ev) {
|
|
88
|
+
const values = ev.values ?? [];
|
|
89
|
+
if (values.length > 0) {
|
|
90
|
+
const markers = (ev.expr.match(/\\\?|\?/g) ?? []).filter((m) => m === '?').length;
|
|
91
|
+
if (markers !== values.length) {
|
|
92
|
+
throw new Error(`Computed field '${name}' declares ${values.length} bound value(s) but its expression ` +
|
|
93
|
+
`contains ${markers} '?' marker(s). Mark each bound value with '?' (use '\\?' for a ` +
|
|
94
|
+
`literal question mark) so the engine can assign placeholder positions.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return new Expression(ev.expr, values);
|
|
98
|
+
}
|
|
99
|
+
/** Bound values carried by a resolved field reference. */
|
|
100
|
+
export function refValues(expr) {
|
|
101
|
+
return expr instanceof Expression ? [...expr.values] : [];
|
|
102
|
+
}
|
|
103
|
+
/** Render a resolved field reference to SQL, binding any values from `startIndex`. */
|
|
104
|
+
export function renderRef(expr, startIndex, db) {
|
|
105
|
+
return expr instanceof Expression ? expr.render(startIndex, db.ph) : expr;
|
|
106
|
+
}
|
|
107
|
+
/** Map rows from snake_case columns to camelCase fields, preserving any computed-field columns
|
|
108
|
+
* that are projected by their declared name (skipped by camelcaseObject). */
|
|
109
|
+
export function mapRowsToCamelCase(rows, schema, computedFieldNames) {
|
|
110
|
+
const computed = computedFieldNames ? new Set(computedFieldNames) : null;
|
|
111
|
+
return rows.map((r) => {
|
|
112
|
+
const camel = camelcaseObject(r, schema);
|
|
113
|
+
if (computed) {
|
|
114
|
+
for (const name of computed) {
|
|
115
|
+
if (name in r)
|
|
116
|
+
camel[name] = r[name];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return camel;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Build the SELECT columns list for a join fetch: '*' or comma-separated quoted columns.
|
|
124
|
+
* A default '*' selection narrows to the join table's readable columns when it declares
|
|
125
|
+
* `readExclude`; an explicit selection naming an excluded field is rejected with 400.
|
|
126
|
+
*
|
|
127
|
+
* On a relation narrowed by a `fields` allowlist the '*' is always spelled out. Emitting a
|
|
128
|
+
* real `SELECT *` there would fetch the excluded columns and leave them to be dropped by
|
|
129
|
+
* the response serializer — which does not run for a caller of `sqlApi.search()`.
|
|
130
|
+
*/
|
|
131
|
+
export function buildSelectionColumns(selection, joinSchema, db, joinTableConf, restricted = false) {
|
|
132
|
+
if (selection === '*') {
|
|
133
|
+
if (restricted)
|
|
134
|
+
return explicitSelectColumns(joinTableConf, joinSchema, db);
|
|
135
|
+
return readableSelectColumns(joinTableConf, joinSchema, db) ?? '*';
|
|
136
|
+
}
|
|
137
|
+
const table = db.qi(joinSchema.tableName);
|
|
138
|
+
return selection
|
|
139
|
+
.split(',')
|
|
140
|
+
.map((c) => `${table}.${db.qi(validateSchemaField(c.trim(), joinSchema, joinTableConf))}`)
|
|
141
|
+
.join(', ');
|
|
142
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { QueryClient } from '../../db.js';
|
|
2
|
+
import type { QueryParams } from '../query-params.js';
|
|
3
|
+
import type { DbTables, ITable, JoinFetchRequest, JoinGroupRequest, JoinRefFilter, TenantContext } from '../../../types.js';
|
|
4
|
+
interface LeftJoinOnBuild {
|
|
5
|
+
joinClauses: string[];
|
|
6
|
+
values: unknown[];
|
|
7
|
+
}
|
|
8
|
+
interface LeftJoinFilterBuild {
|
|
9
|
+
whereExtras: string[];
|
|
10
|
+
values: unknown[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The LEFT JOIN clauses themselves, each carrying the joined table's own tenant scope.
|
|
14
|
+
*
|
|
15
|
+
* The scope belongs in the ON clause, not the WHERE. In the WHERE it would discard main rows
|
|
16
|
+
* whose parent belongs to another tenant, silently turning the LEFT JOIN into an INNER JOIN.
|
|
17
|
+
* In the ON clause a foreign parent simply fails to match and reads as NULL, which closes the
|
|
18
|
+
* oracle: the parent rows are already withheld from the response (`executeJoinLeft` scopes its
|
|
19
|
+
* own side query), so an unscoped join would let a caller filter on values it cannot see and
|
|
20
|
+
* recover them one predicate at a time.
|
|
21
|
+
*
|
|
22
|
+
* Emitted before the WHERE fragments because that is where it lands in the statement — MySQL
|
|
23
|
+
* binds `?` positionally.
|
|
24
|
+
*/
|
|
25
|
+
export declare function buildLeftJoinOnClauses(db: QueryClient, dbTables: DbTables, tableConf: ITable, aliasesNeedingJoin: Set<string>, startIdx: number, tenant?: TenantContext): LeftJoinOnBuild;
|
|
26
|
+
/** The WHERE fragments a `joinLeft` request's own filters and conditions contribute. */
|
|
27
|
+
export declare function buildLeftJoinFilters(db: QueryClient, dbTables: DbTables, tableConf: ITable, aliasesNeedingJoin: Set<string>, joinLeft: Record<string, JoinFetchRequest> | undefined, startIdx: number): LeftJoinFilterBuild;
|
|
28
|
+
/** joinMultiple: 1:N children, returned as rows under `result.joinMultiple.<alias>`. */
|
|
29
|
+
export declare function executeJoinMultiple(db: QueryClient, dbTables: DbTables, tableConf: ITable, mainResults: Record<string, unknown>[], joinMultiple: Record<string, JoinFetchRequest>, tenant?: TenantContext): Promise<Record<string, Record<string, unknown>[]>>;
|
|
30
|
+
/** joinLeft: N:1 parents, returned as rows under `result.joinLeft.<alias>`. */
|
|
31
|
+
export declare function executeJoinLeft(db: QueryClient, dbTables: DbTables, tableConf: ITable, mainResults: Record<string, unknown>[], joinLeft: Record<string, JoinFetchRequest>, tenant?: TenantContext): Promise<Record<string, Record<string, unknown>[]>>;
|
|
32
|
+
export declare function executeJoinGroup(db: QueryClient, dbTables: DbTables, tableConf: ITable, mainResults: Record<string, unknown>[], joinGroup: Record<string, JoinGroupRequest>, tenant?: TenantContext): Promise<Record<string, Record<string, unknown>>>;
|
|
33
|
+
export declare function buildJoinMustExistClauses(params: QueryParams, db: QueryClient, dbTables: DbTables, tableConf: ITable, joinMustExist: Record<string, JoinRefFilter>, tenant?: TenantContext): string;
|
|
34
|
+
export {};
|
|
35
|
+
//# sourceMappingURL=join-families.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"join-families.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/join-families.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAmBtD,OAAO,KAAK,EAEV,QAAQ,EAER,MAAM,EACN,gBAAgB,EAChB,gBAAgB,EAChB,aAAa,EAGb,aAAa,EAEd,MAAM,mBAAmB,CAAC;AAI3B,UAAU,eAAe;IACvB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,MAAM,EAAE,OAAO,EAAE,CAAC;CACnB;AAED,UAAU,mBAAmB;IAC3B,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,MAAM,EAAE,OAAO,EAAE,CAAC;CACnB;AAsED;;;;;;;;;;;;GAYG;AACH,wBAAgB,sBAAsB,CACpC,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,kBAAkB,EAAE,GAAG,CAAC,MAAM,CAAC,EAC/B,QAAQ,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,aAAa,GACrB,eAAe,CAgCjB;AAED,wFAAwF;AACxF,wBAAgB,oBAAoB,CAClC,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,kBAAkB,EAAE,GAAG,CAAC,MAAM,CAAC,EAC/B,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAAG,SAAS,EACtD,QAAQ,EAAE,MAAM,GACf,mBAAmB,CA0CrB;AAmGD,wFAAwF;AACxF,wBAAgB,mBAAmB,CACjC,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EACtC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,EAC9C,MAAM,CAAC,EAAE,aAAa,GACrB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CAKpD;AAED,+EAA+E;AAC/E,wBAAgB,eAAe,CAC7B,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EACtC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,EAC1C,MAAM,CAAC,EAAE,aAAa,GACrB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CAKpD;AAsGD,wBAAsB,gBAAgB,CACpC,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EACtC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,EAC3C,MAAM,CAAC,EAAE,aAAa,GACrB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAqClD;AAyBD,wBAAgB,yBAAyB,CACvC,MAAM,EAAE,WAAW,EACnB,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,EAC5C,MAAM,CAAC,EAAE,aAAa,GACrB,MAAM,CA4BR"}
|