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
|
@@ -1,446 +1,42 @@
|
|
|
1
|
-
import { ConditionBuilder, Expression } from 'node-condition-builder';
|
|
2
|
-
import { camelcaseObject } from '../../naming.js';
|
|
3
1
|
import { QueryParams } from '../query-params.js';
|
|
4
2
|
import { buildTenantCondition, buildTenantJoin } from '../../tenant.js';
|
|
5
|
-
import {
|
|
3
|
+
import { assertFiltersReadable, assertKnownFilterKeys, readableSelectColumns, ownField, } from '../../read-access.js';
|
|
4
|
+
import { MAX_CONDITIONS, MAX_ORDER_BY_PARTS } from '../../condition-methods.js';
|
|
6
5
|
import { primaryAsString } from '../../../types.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
assertReadable(tableConf, field);
|
|
15
|
-
return schema.col(field);
|
|
16
|
-
}
|
|
17
|
-
function err400(msg) {
|
|
18
|
-
const e = new Error(msg);
|
|
19
|
-
e.statusCode = 400;
|
|
20
|
-
throw e;
|
|
21
|
-
}
|
|
22
|
-
// ─── Computed fields resolution ─────────────────────────────
|
|
23
|
-
/**
|
|
24
|
-
* Build a ComputedFieldContext bound to a specific table/schema. `alias`
|
|
25
|
-
* (if provided) qualifies the columns produced by `qiCol` — needed when the
|
|
26
|
-
* computed is embedded in a `LEFT JOIN <table> AS <alias>` (joinLeft).
|
|
27
|
-
*/
|
|
28
|
-
function buildComputedContext(db, schema, alias) {
|
|
29
|
-
return {
|
|
30
|
-
db,
|
|
31
|
-
qiCol(field, opts) {
|
|
32
|
-
if (!(field in schema.fields)) {
|
|
33
|
-
err400(`Unknown field referenced by computed expression: ${field}`);
|
|
34
|
-
}
|
|
35
|
-
const col = db.qi(schema.col(field));
|
|
36
|
-
// Default to the owning table: the statement may carry joins, and a bare
|
|
37
|
-
// column shared with a joined table would be ambiguous.
|
|
38
|
-
const qualifier = opts?.qualifier ?? alias ?? schema.tableName;
|
|
39
|
-
return qualifier ? `${db.qi(qualifier)}.${col}` : col;
|
|
40
|
-
},
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
function resolveFieldRef(field, schema, tableConf, db, alias) {
|
|
44
|
-
if (field in schema.fields) {
|
|
45
|
-
assertReadable(tableConf, field);
|
|
46
|
-
const col = db.qi(schema.col(field));
|
|
47
|
-
// Qualified with the alias or the owning table — never bare, so the reference
|
|
48
|
-
// stays unambiguous when the statement carries joins.
|
|
49
|
-
const expr = `${db.qi(alias ?? schema.tableName)}.${col}`;
|
|
50
|
-
return { expr, computed: false };
|
|
51
|
-
}
|
|
52
|
-
const fn = tableConf?.computedFields?.[field];
|
|
53
|
-
if (fn) {
|
|
54
|
-
const ctx = buildComputedContext(db, schema, alias);
|
|
55
|
-
return { expr: computedExpression(field, fn(ctx)), computed: true };
|
|
56
|
-
}
|
|
57
|
-
err400(`Unknown field: ${field}`);
|
|
58
|
-
}
|
|
59
|
-
// ─── Join lookup ────────────────────────────────────────────
|
|
6
|
+
import { err400, resolveFieldRef, evaluateComputedField, refValues, renderRef, mapRowsToCamelCase, } from './fields.js';
|
|
7
|
+
import { assertJoinFilterKeys, requireJoin } from './joins.js';
|
|
8
|
+
import { collectOrderByLeftAliases, validateOrderBy, convertConfiguredOrder } from './order-by.js';
|
|
9
|
+
import { applyConditions, applyComputedFilters } from './conditions.js';
|
|
10
|
+
import { appendAggConditions } from './aggregations.js';
|
|
11
|
+
import { buildLeftJoinOnClauses, buildLeftJoinFilters, executeJoinMultiple, executeJoinLeft, executeJoinGroup, buildJoinMustExistClauses, } from './join-families.js';
|
|
60
12
|
/**
|
|
61
|
-
*
|
|
62
|
-
* appends the tenant condition to `cb` (direct: `col IN ids`; indirect: condition on the
|
|
63
|
-
* through table) and, for indirect scopes, pushes the INNER JOIN clause into `joins`.
|
|
64
|
-
* No-op without a tenant context (admin) or when the join table is not tenant-scoped.
|
|
13
|
+
* Guard the request shapes that reach SQL as text rather than as bound values.
|
|
65
14
|
*
|
|
66
|
-
* `
|
|
67
|
-
*
|
|
68
|
-
|
|
69
|
-
function appendJoinTenantScope(db, joinTableConf, tenant, joinTableRef, cb, joins) {
|
|
70
|
-
const scope = joinTableConf?.tenantScope;
|
|
71
|
-
if (!tenant || !scope)
|
|
72
|
-
return;
|
|
73
|
-
cb.append(buildTenantCondition(db, scope, tenant.ids, joinTableRef));
|
|
74
|
-
if ('through' in scope) {
|
|
75
|
-
joins?.push(buildTenantJoin(db, scope, joinTableRef));
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Alias for the FROM of a correlated subquery (EXISTS, aggregate). The subquery must not
|
|
80
|
-
* say `FROM <table>` bare: on a self-referencing relation the inner table would shadow the
|
|
81
|
-
* outer one and the correlation would silently compare each row with itself. The request
|
|
82
|
-
* alias is used, suffixed when it would itself collide with the outer table's name.
|
|
83
|
-
*/
|
|
84
|
-
function subqueryAlias(alias, tableConf) {
|
|
85
|
-
return alias === tableConf.Schema.tableName ? `${alias}_sub` : alias;
|
|
86
|
-
}
|
|
87
|
-
function findJoinByAlias(tableConf, alias) {
|
|
88
|
-
return tableConf.allowedReadJoins?.find((j) => j.alias === alias);
|
|
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.
|
|
15
|
+
* `paginator` is rendered straight into LIMIT/OFFSET: `Paginator` is a compile-time type only,
|
|
16
|
+
* and `sqlApi.search()` is a documented public API a consumer may hand an unvalidated
|
|
17
|
+
* querystring, so the integer check has to exist at runtime.
|
|
96
18
|
*
|
|
97
|
-
*
|
|
19
|
+
* The complexity caps are also declared by the request schemas; these are the backstop that
|
|
20
|
+
* covers programmatic callers, which no schema validates.
|
|
98
21
|
*/
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
}
|
|
112
|
-
function requireJoin(tableConf, alias, requireUnique) {
|
|
113
|
-
const joinDef = findJoinByAlias(tableConf, alias);
|
|
114
|
-
if (!joinDef) {
|
|
115
|
-
err400(`Unknown join alias: ${alias}`);
|
|
116
|
-
}
|
|
117
|
-
if (requireUnique && !joinDef.unique) {
|
|
118
|
-
err400(`Join alias '${alias}' is not declared with unique:true; use joinMultiple/joinMustExist/joinGroup instead`);
|
|
119
|
-
}
|
|
120
|
-
if (!requireUnique && joinDef.unique) {
|
|
121
|
-
err400(`Join alias '${alias}' is declared with unique:true; use joinLeft instead`);
|
|
122
|
-
}
|
|
123
|
-
return joinDef;
|
|
124
|
-
}
|
|
125
|
-
/** Extract quoted identifiers for a join reference. Used by joinMustExist, joinLeft and joinGroup
|
|
126
|
-
* SQL builders to avoid the 4-line FK/PK destructuring boilerplate at every call site. */
|
|
127
|
-
function extractJoinRefs(db, tableConf, joinDef) {
|
|
128
|
-
const { joinSchema, joinField, mainField } = joinDef;
|
|
129
|
-
const mainColName = Array.isArray(mainField) ? mainField[0] : mainField;
|
|
130
|
-
return {
|
|
131
|
-
mainColName,
|
|
132
|
-
mainCol: db.qi(tableConf.Schema.col(mainColName)),
|
|
133
|
-
mainTable: db.qi(tableConf.Schema.tableName),
|
|
134
|
-
joinTable: db.qi(joinSchema.tableName),
|
|
135
|
-
fkCol: db.qi(joinSchema.col(joinField)),
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* Evaluate a computed field into an Expression carrying its own bound values.
|
|
140
|
-
*
|
|
141
|
-
* Inside a WHERE clause the ConditionBuilder assigns the placeholder indexes, so bound
|
|
142
|
-
* values are always safe there. `allowBoundValues=false` is for the positions that sit
|
|
143
|
-
* *before* the WHERE values in the parameter order (SELECT projections, aggregations),
|
|
144
|
-
* where they cannot be placed correctly; those still reject with 400.
|
|
145
|
-
*/
|
|
146
|
-
function evaluateComputedField(name, fn, schema, db, alias, message, allowBoundValues = false) {
|
|
147
|
-
const ev = fn(buildComputedContext(db, schema, alias));
|
|
148
|
-
if (!allowBoundValues && (ev.values?.length ?? 0) > 0) {
|
|
149
|
-
err400(message);
|
|
150
|
-
}
|
|
151
|
-
return computedExpression(name, ev);
|
|
152
|
-
}
|
|
153
|
-
/**
|
|
154
|
-
* Build the Expression for a computed field, checking that it marks each bound value
|
|
155
|
-
* with a `?`.
|
|
156
|
-
*
|
|
157
|
-
* A mismatch is always a table-configuration bug and would otherwise produce a query that
|
|
158
|
-
* binds values nobody references — silently returning wrong rows. Expressions without
|
|
159
|
-
* bound values are exempt: they are emitted verbatim, so a `?` there is a literal operator
|
|
160
|
-
* (PostgreSQL jsonb) rather than a marker.
|
|
161
|
-
*/
|
|
162
|
-
function computedExpression(name, ev) {
|
|
163
|
-
const values = ev.values ?? [];
|
|
164
|
-
if (values.length > 0) {
|
|
165
|
-
const markers = (ev.expr.match(/\\\?|\?/g) ?? []).filter((m) => m === '?').length;
|
|
166
|
-
if (markers !== values.length) {
|
|
167
|
-
throw new Error(`Computed field '${name}' declares ${values.length} bound value(s) but its expression ` +
|
|
168
|
-
`contains ${markers} '?' marker(s). Mark each bound value with '?' (use '\\?' for a ` +
|
|
169
|
-
`literal question mark) so the engine can assign placeholder positions.`);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
return new Expression(ev.expr, values);
|
|
173
|
-
}
|
|
174
|
-
/** Bound values carried by a resolved field reference. */
|
|
175
|
-
function refValues(expr) {
|
|
176
|
-
return expr instanceof Expression ? [...expr.values] : [];
|
|
177
|
-
}
|
|
178
|
-
/** Render a resolved field reference to SQL, binding any values from `startIndex`. */
|
|
179
|
-
function renderRef(expr, startIndex, db) {
|
|
180
|
-
return expr instanceof Expression ? expr.render(startIndex, db.ph) : expr;
|
|
181
|
-
}
|
|
182
|
-
/** Map rows from snake_case columns to camelCase fields, preserving any computed-field columns
|
|
183
|
-
* that are projected by their declared name (skipped by camelcaseObject). */
|
|
184
|
-
function mapRowsToCamelCase(rows, schema, computedFieldNames) {
|
|
185
|
-
const computed = computedFieldNames ? new Set(computedFieldNames) : null;
|
|
186
|
-
return rows.map((r) => {
|
|
187
|
-
const camel = camelcaseObject(r, schema);
|
|
188
|
-
if (computed) {
|
|
189
|
-
for (const name of computed) {
|
|
190
|
-
if (name in r)
|
|
191
|
-
camel[name] = r[name];
|
|
22
|
+
function assertRequestWithinLimits(params) {
|
|
23
|
+
const { paginator, conditions, orderBy } = params;
|
|
24
|
+
if (paginator) {
|
|
25
|
+
for (const key of ['page', 'itemsPerPage']) {
|
|
26
|
+
const value = paginator[key];
|
|
27
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
28
|
+
err400(`Invalid paginator: '${key}' must be an integer >= 1`);
|
|
192
29
|
}
|
|
193
30
|
}
|
|
194
|
-
return camel;
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
/**
|
|
198
|
-
* Build the SELECT columns list for a join fetch: '*' or comma-separated quoted columns.
|
|
199
|
-
* A default '*' selection narrows to the join table's readable columns when it declares
|
|
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()`.
|
|
205
|
-
*/
|
|
206
|
-
function buildSelectionColumns(selection, joinSchema, db, joinTableConf, restricted = false) {
|
|
207
|
-
if (selection === '*') {
|
|
208
|
-
if (restricted)
|
|
209
|
-
return explicitSelectColumns(joinTableConf, joinSchema, db);
|
|
210
|
-
return readableSelectColumns(joinTableConf, joinSchema, db) ?? '*';
|
|
211
|
-
}
|
|
212
|
-
const table = db.qi(joinSchema.tableName);
|
|
213
|
-
return selection
|
|
214
|
-
.split(',')
|
|
215
|
-
.map((c) => `${table}.${db.qi(validateSchemaField(c.trim(), joinSchema, joinTableConf))}`)
|
|
216
|
-
.join(', ');
|
|
217
|
-
}
|
|
218
|
-
const AGG_FN = {
|
|
219
|
-
sum: { sql: 'SUM', distinct: false },
|
|
220
|
-
min: { sql: 'MIN', distinct: false },
|
|
221
|
-
max: { sql: 'MAX', distinct: false },
|
|
222
|
-
avg: { sql: 'AVG', distinct: false },
|
|
223
|
-
count: { sql: 'COUNT', distinct: false },
|
|
224
|
-
distinctCount: { sql: 'COUNT', distinct: true },
|
|
225
|
-
};
|
|
226
|
-
function aggExpr(fn, qualifiedCol) {
|
|
227
|
-
return fn.distinct ? `COUNT(DISTINCT ${qualifiedCol})` : `${fn.sql}(${qualifiedCol})`;
|
|
228
|
-
}
|
|
229
|
-
/**
|
|
230
|
-
* Correlated aggregate subquery for `<alias>.<fn>.<field>`, as an Expression carrying the
|
|
231
|
-
* values of its optional filter. Markers are `?`, so whoever embeds it — a ConditionBuilder
|
|
232
|
-
* or an ORDER BY list — decides the placeholder positions.
|
|
233
|
-
*/
|
|
234
|
-
function buildAggOrderExpr(db, dbTables, tableConf, alias, fn, field, joinGroup) {
|
|
235
|
-
const aggFn = AGG_FN[fn];
|
|
236
|
-
if (!aggFn)
|
|
237
|
-
err400(`Invalid aggregation function: ${fn}`);
|
|
238
|
-
const groupReq = joinGroup?.[alias];
|
|
239
|
-
if (!groupReq)
|
|
240
|
-
err400(`orderBy/conditions reference undeclared joinGroup: ${alias}`);
|
|
241
|
-
const declaredFields = groupReq.aggregations[fn];
|
|
242
|
-
if (!Array.isArray(declaredFields) || !declaredFields.includes(field)) {
|
|
243
|
-
err400(`orderBy/conditions reference undeclared aggregation: ${alias}.${fn}.${field}`);
|
|
244
|
-
}
|
|
245
|
-
const joinDef = requireJoin(tableConf, alias, false);
|
|
246
|
-
const { joinSchema, joinField } = joinDef;
|
|
247
|
-
if (groupReq.aggregations.by && groupReq.aggregations.by !== joinField) {
|
|
248
|
-
err400(`Cannot order by aggregation on joinGroup with 'by' clause on non-FK column: ${alias} (grouped by '${groupReq.aggregations.by}', correlation FK is '${joinField}')`);
|
|
249
|
-
}
|
|
250
|
-
// Pass the join table's conf: without it validateSchemaField skips the readExclude
|
|
251
|
-
// check, and a HAVING-style condition on a hidden field becomes a 400-vs-200 oracle
|
|
252
|
-
// (executeJoinGroup's own validation is skipped when the main result set is empty).
|
|
253
|
-
const fieldCol = validateSchemaField(field, joinSchema, dbTables[joinSchema.tableName]);
|
|
254
|
-
const refs = extractJoinRefs(db, tableConf, joinDef);
|
|
255
|
-
// The subquery FROM is aliased so the correlation to the outer table survives a
|
|
256
|
-
// self-referencing relation; everything inside references the alias.
|
|
257
|
-
const subAlias = subqueryAlias(alias, tableConf);
|
|
258
|
-
const subRef = db.qi(subAlias);
|
|
259
|
-
let filterWhere = '';
|
|
260
|
-
let filterVals = [];
|
|
261
|
-
const joinTableConf = dbTables[joinSchema.tableName];
|
|
262
|
-
if (groupReq.filters || groupReq.conditions?.length) {
|
|
263
|
-
const cb = buildJoinRefCondition(joinTableConf, joinSchema, { filters: groupReq.filters, conditions: groupReq.conditions }, db, subAlias);
|
|
264
|
-
const fragment = cb.toExpression();
|
|
265
|
-
if (fragment.value) {
|
|
266
|
-
filterWhere = ` AND ${fragment.value}`;
|
|
267
|
-
filterVals = [...fragment.values];
|
|
268
|
-
}
|
|
269
31
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
return new Expression(expr, filterVals);
|
|
273
|
-
}
|
|
274
|
-
/**
|
|
275
|
-
* Pre-scan `orderBy` for the joinLeft aliases referenced in 2-parti notation (`<alias>.<field>`).
|
|
276
|
-
* Needed BEFORE building the LEFT JOIN clauses (which must know their aliases) while the actual
|
|
277
|
-
* orderBy SQL — with its parameter placeholders — is only baked later, once the LEFT JOIN value
|
|
278
|
-
* count is known. Validates each alias against `allowedReadJoins` (throws 400), mirroring
|
|
279
|
-
* `validateOrderBy`; 3-parti aggregation entries are ignored (they use joinGroup, not a LEFT JOIN).
|
|
280
|
-
*/
|
|
281
|
-
function collectOrderByLeftAliases(orderBy, tableConf) {
|
|
282
|
-
const aliases = new Set();
|
|
283
|
-
for (const part of orderBy.split(',')) {
|
|
284
|
-
const trimmed = part.trim();
|
|
285
|
-
if (/^(\w+)\.(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i.test(trimmed))
|
|
286
|
-
continue; // 3-parti aggregation
|
|
287
|
-
const m = trimmed.match(/^(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i);
|
|
288
|
-
if (m) {
|
|
289
|
-
const alias = m[1];
|
|
290
|
-
requireJoin(tableConf, alias, true);
|
|
291
|
-
aliases.add(alias);
|
|
292
|
-
}
|
|
32
|
+
if (conditions && conditions.length > MAX_CONDITIONS) {
|
|
33
|
+
err400(`Too many conditions: ${conditions.length} (max ${MAX_CONDITIONS})`);
|
|
293
34
|
}
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
function validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, startIdx) {
|
|
297
|
-
const parts = orderBy.split(',');
|
|
298
|
-
const outParts = [];
|
|
299
|
-
const outValues = [];
|
|
300
|
-
const leftJoinAliases = new Set();
|
|
301
|
-
let currentIdx = startIdx;
|
|
302
|
-
for (const part of parts) {
|
|
303
|
-
const trimmed = part.trim();
|
|
304
|
-
// 3-parti: <alias>.<fn>.<field> [ASC|DESC] (aggregation via joinGroup)
|
|
305
|
-
const dotted3 = trimmed.match(/^(\w+)\.(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i);
|
|
306
|
-
if (dotted3) {
|
|
307
|
-
if (tableConf.distinctResults) {
|
|
308
|
-
err400('Cannot combine distinctResults with aggregation orderBy');
|
|
309
|
-
}
|
|
310
|
-
const [, alias, fn, field, dir] = dotted3;
|
|
311
|
-
const expr = buildAggOrderExpr(db, dbTables, tableConf, alias, fn, field, joinGroup);
|
|
312
|
-
const aggVals = [...expr.values];
|
|
313
|
-
outParts.push(`${renderRef(expr, currentIdx, db)} ${(dir || 'ASC').toUpperCase()}`);
|
|
314
|
-
outValues.push(...aggVals);
|
|
315
|
-
currentIdx += aggVals.length;
|
|
316
|
-
continue;
|
|
317
|
-
}
|
|
318
|
-
// 2-parti: <alias>.<field> [ASC|DESC] (joinLeft inline ordering)
|
|
319
|
-
const dotted2 = trimmed.match(/^(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i);
|
|
320
|
-
if (dotted2) {
|
|
321
|
-
const [, alias, field, dir] = dotted2;
|
|
322
|
-
const joinDef = requireJoin(tableConf, alias, true);
|
|
323
|
-
const col = validateSchemaField(field, joinDef.joinSchema, dbTables[joinDef.joinSchema.tableName]);
|
|
324
|
-
// Reference the LEFT JOIN'd table via its alias (SQL identifier).
|
|
325
|
-
outParts.push(`${db.qi(alias)}.${db.qi(col)} ${(dir || 'ASC').toUpperCase()}`);
|
|
326
|
-
leftJoinAliases.add(alias);
|
|
327
|
-
continue;
|
|
328
|
-
}
|
|
329
|
-
// 1-parte: <field> [ASC|DESC] — schema field or computed
|
|
330
|
-
const plain = trimmed.match(/^(\w+)(?:\s+(ASC|DESC))?$/i);
|
|
331
|
-
if (!plain) {
|
|
332
|
-
err400(`Invalid orderBy: ${trimmed}`);
|
|
333
|
-
}
|
|
334
|
-
const [, field, dir] = plain;
|
|
335
|
-
const ref = resolveFieldRef(field, tableConf.Schema, tableConf, db);
|
|
336
|
-
// A computed field binds its own values here: ORDER BY is emitted after the WHERE, so
|
|
337
|
-
// its placeholders continue from `currentIdx`.
|
|
338
|
-
const refVals = refValues(ref.expr);
|
|
339
|
-
outParts.push(`${renderRef(ref.expr, currentIdx, db)} ${(dir || 'ASC').toUpperCase()}`);
|
|
340
|
-
outValues.push(...refVals);
|
|
341
|
-
currentIdx += refVals.length;
|
|
35
|
+
if (orderBy && orderBy.split(',').length > MAX_ORDER_BY_PARTS) {
|
|
36
|
+
err400(`Too many orderBy parts: max ${MAX_ORDER_BY_PARTS}`);
|
|
342
37
|
}
|
|
343
|
-
return { sql: outParts.join(', '), values: outValues, leftJoinAliases };
|
|
344
|
-
}
|
|
345
|
-
function buildLeftJoinClauses(db, dbTables, tableConf, aliasesNeedingJoin, joinLeft, startIdx) {
|
|
346
|
-
const joinClauses = [];
|
|
347
|
-
const whereExtras = [];
|
|
348
|
-
const values = [];
|
|
349
|
-
let currentIdx = startIdx;
|
|
350
|
-
for (const alias of aliasesNeedingJoin) {
|
|
351
|
-
const joinDef = requireJoin(tableConf, alias, true);
|
|
352
|
-
const { joinSchema } = joinDef;
|
|
353
|
-
const refs = extractJoinRefs(db, tableConf, joinDef);
|
|
354
|
-
const aliasIdent = db.qi(alias);
|
|
355
|
-
joinClauses.push(`LEFT JOIN ${refs.joinTable} AS ${aliasIdent} ON ${aliasIdent}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol}`);
|
|
356
|
-
const ref = joinLeft?.[alias];
|
|
357
|
-
if (ref && (ref.filters || ref.conditions?.length)) {
|
|
358
|
-
// Build a ConditionBuilder where each column is prefixed with the alias
|
|
359
|
-
// (so SQL references the LEFT JOIN'd table, not a bare table name).
|
|
360
|
-
// Note: extraFilters declared on joinTableConf are not supported here for
|
|
361
|
-
// joinLeft (they would require alias-aware handlers); only schema fields
|
|
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.
|
|
365
|
-
const cb = new ConditionBuilder('AND', db.cbDialect);
|
|
366
|
-
const joinTableConf = dbTables[joinSchema.tableName];
|
|
367
|
-
const computed = joinTableConf?.computedFields;
|
|
368
|
-
assertFiltersReadable(ref.filters, joinTableConf);
|
|
369
|
-
if (ref.filters) {
|
|
370
|
-
for (const [field, value] of Object.entries(ref.filters)) {
|
|
371
|
-
if (value === null || value === undefined)
|
|
372
|
-
continue;
|
|
373
|
-
if (field in joinSchema.fields) {
|
|
374
|
-
cb.isEqual(`${aliasIdent}.${db.qi(joinSchema.col(field))}`, value);
|
|
375
|
-
}
|
|
376
|
-
else if (computed?.[field]) {
|
|
377
|
-
// Computed field on the parent table: expr with alias-qualified columns. Bound
|
|
378
|
-
// values are placed by this ConditionBuilder, whose SQL lands in the WHERE.
|
|
379
|
-
const expr = evaluateComputedField(field, computed[field], joinSchema, db, alias, '', true);
|
|
380
|
-
cb.isEqual(expr, value);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
if (ref.conditions?.length) {
|
|
385
|
-
for (const c of ref.conditions) {
|
|
386
|
-
if (!ALLOWED_SET.has(c.method)) {
|
|
387
|
-
err400(`Invalid condition method: ${c.method}`);
|
|
388
|
-
}
|
|
389
|
-
if (c.field in joinSchema.fields) {
|
|
390
|
-
assertReadable(dbTables[joinSchema.tableName], c.field);
|
|
391
|
-
const col = `${aliasIdent}.${db.qi(joinSchema.col(c.field))}`;
|
|
392
|
-
dispatchConditionMethod(cb, c.method, col, c.params ?? []);
|
|
393
|
-
}
|
|
394
|
-
else if (computed?.[c.field]) {
|
|
395
|
-
const expr = evaluateComputedField(c.field, computed[c.field], joinSchema, db, alias, '', true);
|
|
396
|
-
dispatchConditionMethod(cb, c.method, expr, c.params ?? []);
|
|
397
|
-
}
|
|
398
|
-
else {
|
|
399
|
-
err400(`Unknown field: ${c.field}`);
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
const sql = cb.build(currentIdx, db.ph);
|
|
404
|
-
const vals = cb.getValues();
|
|
405
|
-
if (sql) {
|
|
406
|
-
whereExtras.push(sql);
|
|
407
|
-
values.push(...vals);
|
|
408
|
-
currentIdx += vals.length;
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
return { joinClauses, whereExtras, values };
|
|
413
38
|
}
|
|
414
39
|
// ─── Main query execution ───────────────────────────────────
|
|
415
|
-
/**
|
|
416
|
-
* Converts a configured order (`defaultOrder` or the primary-key fallback) to SQL.
|
|
417
|
-
* Unlike the request `orderBy` (strictly validated by `validateOrderBy`), this is
|
|
418
|
-
* lenient for backward compatibility: tokens matching a camelCase schema field are
|
|
419
|
-
* mapped to their quoted DB column, computed fields (without bound values) expand
|
|
420
|
-
* to their expression, and anything else passes through unchanged as raw SQL.
|
|
421
|
-
*/
|
|
422
|
-
function convertConfiguredOrder(order, tableConf, db) {
|
|
423
|
-
return order
|
|
424
|
-
.split(',')
|
|
425
|
-
.map((part) => {
|
|
426
|
-
const trimmed = part.trim();
|
|
427
|
-
const match = trimmed.match(/^(\w+)(?:\s+(ASC|DESC))?$/i);
|
|
428
|
-
if (match) {
|
|
429
|
-
const [, field, dir] = match;
|
|
430
|
-
const suffix = dir ? ` ${dir.toUpperCase()}` : '';
|
|
431
|
-
if (field in tableConf.Schema.fields) {
|
|
432
|
-
return `${db.qi(tableConf.Schema.tableName)}.${db.qi(tableConf.Schema.col(field))}${suffix}`;
|
|
433
|
-
}
|
|
434
|
-
const computed = tableConf.computedFields?.[field];
|
|
435
|
-
if (computed) {
|
|
436
|
-
const ev = evaluateComputedField(field, computed, tableConf.Schema, db, undefined, `Computed field '${field}' with bound values cannot be used in defaultOrder`);
|
|
437
|
-
return `${ev.value}${suffix}`;
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
return trimmed;
|
|
441
|
-
})
|
|
442
|
-
.join(', ');
|
|
443
|
-
}
|
|
444
40
|
async function executeMainQuery(db, tableConf, where, values, orderBy, paginator, extraJoins = [], selectComputed, maxRows) {
|
|
445
41
|
const tableName = tableConf.Schema.tableName;
|
|
446
42
|
// Request orderBy arrives already validated and mapped by validateOrderBy;
|
|
@@ -459,7 +55,7 @@ async function executeMainQuery(db, tableConf, where, values, orderBy, paginator
|
|
|
459
55
|
if (selectComputed?.length) {
|
|
460
56
|
const projections = [readableColumns ?? '*'];
|
|
461
57
|
for (const name of selectComputed) {
|
|
462
|
-
const fn = tableConf.computedFields
|
|
58
|
+
const fn = ownField(tableConf.computedFields, name);
|
|
463
59
|
if (!fn)
|
|
464
60
|
err400(`Unknown computed field in selectComputed: '${name}'`);
|
|
465
61
|
const out = evaluateComputedField(name, fn, tableConf.Schema, db, undefined, `Computed field '${name}' with bound values cannot be used in selectComputed`);
|
|
@@ -510,328 +106,17 @@ async function buildPagination(db, tableConf, where, values, paginator, extraJoi
|
|
|
510
106
|
paginator,
|
|
511
107
|
};
|
|
512
108
|
}
|
|
513
|
-
// ───
|
|
514
|
-
async function executeJoinMultiple(db, dbTables, tableConf, mainResults, joinMultiple, tenant) {
|
|
515
|
-
const result = {};
|
|
516
|
-
for (const [alias, ref] of Object.entries(joinMultiple)) {
|
|
517
|
-
const joinDef = requireJoin(tableConf, alias, false);
|
|
518
|
-
const { joinSchema, joinField, mainField, selection: defaultSelection } = joinDef;
|
|
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);
|
|
525
|
-
const ids = collectIds(mainResults, mainField);
|
|
526
|
-
if (ids.length === 0) {
|
|
527
|
-
result[alias] = [];
|
|
528
|
-
continue;
|
|
529
|
-
}
|
|
530
|
-
const cb = buildJoinRefCondition(joinTableConf, joinSchema, ref || {}, db);
|
|
531
|
-
const fkCol = joinSchema.col(joinField);
|
|
532
|
-
cb.isIn(`${db.qi(joinSchema.tableName)}.${db.qi(fkCol)}`, ids);
|
|
533
|
-
const tenantJoins = [];
|
|
534
|
-
appendJoinTenantScope(db, joinTableConf, tenant, joinSchema.tableName, cb, tenantJoins);
|
|
535
|
-
const where = cb.build(1, db.ph);
|
|
536
|
-
const values = cb.getValues();
|
|
537
|
-
const rows = await db.select({
|
|
538
|
-
tableName: joinSchema.tableName,
|
|
539
|
-
columns,
|
|
540
|
-
where,
|
|
541
|
-
values,
|
|
542
|
-
joins: tenantJoins.length > 0 ? tenantJoins : undefined,
|
|
543
|
-
});
|
|
544
|
-
result[alias] = mapRowsToCamelCase(rows, joinSchema);
|
|
545
|
-
}
|
|
546
|
-
return result;
|
|
547
|
-
}
|
|
548
|
-
// ─── joinLeft (parent fetch via PK IN side query) ───────────
|
|
549
|
-
async function executeJoinLeft(db, dbTables, tableConf, mainResults, joinLeft, tenant) {
|
|
550
|
-
const result = {};
|
|
551
|
-
for (const [alias, ref] of Object.entries(joinLeft)) {
|
|
552
|
-
const joinDef = requireJoin(tableConf, alias, true);
|
|
553
|
-
const { joinSchema, joinField, mainField, selection: defaultSelection } = joinDef;
|
|
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);
|
|
559
|
-
// For joinLeft (N:1), mainField on main is the FK pointing to joinField (PK) on parent.
|
|
560
|
-
// We collect the FK values from the main results and look up parents by their PK.
|
|
561
|
-
const ids = collectIds(mainResults, mainField);
|
|
562
|
-
if (ids.length === 0) {
|
|
563
|
-
result[alias] = [];
|
|
564
|
-
continue;
|
|
565
|
-
}
|
|
566
|
-
const fkCol = joinSchema.col(joinField);
|
|
567
|
-
const cb = new ConditionBuilder('AND', db.cbDialect);
|
|
568
|
-
cb.isIn(`${db.qi(joinSchema.tableName)}.${db.qi(fkCol)}`, ids);
|
|
569
|
-
const tenantJoins = [];
|
|
570
|
-
appendJoinTenantScope(db, joinTableConf, tenant, joinSchema.tableName, cb, tenantJoins);
|
|
571
|
-
const where = cb.build(1, db.ph);
|
|
572
|
-
const values = cb.getValues();
|
|
573
|
-
const rows = await db.select({
|
|
574
|
-
tableName: joinSchema.tableName,
|
|
575
|
-
columns,
|
|
576
|
-
where,
|
|
577
|
-
values,
|
|
578
|
-
joins: tenantJoins.length > 0 ? tenantJoins : undefined,
|
|
579
|
-
});
|
|
580
|
-
result[alias] = mapRowsToCamelCase(rows, joinSchema);
|
|
581
|
-
}
|
|
582
|
-
return result;
|
|
583
|
-
}
|
|
584
|
-
/**
|
|
585
|
-
* Resolve `aggregations.by` to a SQL expression. Accepts:
|
|
586
|
-
* - a schema field name → quoted column reference
|
|
587
|
-
* - a computed-field name declared on the join table → its expr (no bound
|
|
588
|
-
* values supported in this position; reject with 400 if the computed
|
|
589
|
-
* returns values)
|
|
590
|
-
*/
|
|
591
|
-
function buildByExpression(by, joinSchema, joinTableConf, db) {
|
|
592
|
-
if (typeof by !== 'string') {
|
|
593
|
-
err400(`Invalid 'by' specification: expected a field or computed name`);
|
|
594
|
-
}
|
|
595
|
-
if (by in joinSchema.fields) {
|
|
596
|
-
// validateSchemaField also enforces readExclude: GROUP BY on a hidden field
|
|
597
|
-
// would return its distinct values verbatim in `rows[].by`.
|
|
598
|
-
const col = validateSchemaField(by, joinSchema, joinTableConf);
|
|
599
|
-
return `${db.qi(joinSchema.tableName)}.${db.qi(col)}`;
|
|
600
|
-
}
|
|
601
|
-
const fn = joinTableConf?.computedFields?.[by];
|
|
602
|
-
if (fn) {
|
|
603
|
-
return evaluateComputedField(by, fn, joinSchema, db, undefined, `Computed field '${by}' with bound values cannot be used in aggregations.by`).value;
|
|
604
|
-
}
|
|
605
|
-
err400(`Unknown field: ${by}`);
|
|
606
|
-
}
|
|
607
|
-
// ─── joinGroup (aggregations) ───────────────────────────────
|
|
608
|
-
async function executeJoinGroup(db, dbTables, tableConf, mainResults, joinGroup, tenant) {
|
|
609
|
-
const result = {};
|
|
610
|
-
for (const [alias, groupReq] of Object.entries(joinGroup)) {
|
|
611
|
-
const joinDef = requireJoin(tableConf, alias, false);
|
|
612
|
-
const { joinSchema, joinField, mainField } = joinDef;
|
|
613
|
-
const joinTableConf = dbTables[joinSchema.tableName];
|
|
614
|
-
const ids = collectIds(mainResults, mainField);
|
|
615
|
-
const { aggregations, filters: groupFilters, conditions: groupConditions } = groupReq;
|
|
616
|
-
const selectParts = [];
|
|
617
|
-
const groupByParts = [];
|
|
618
|
-
if (aggregations.by) {
|
|
619
|
-
const byExpr = buildByExpression(aggregations.by, joinSchema, joinTableConf, db);
|
|
620
|
-
selectParts.push(`${byExpr} as "by"`);
|
|
621
|
-
groupByParts.push(byExpr);
|
|
622
|
-
}
|
|
623
|
-
const addAgg = (kind, fields) => {
|
|
624
|
-
if (!fields)
|
|
625
|
-
return;
|
|
626
|
-
const fn = AGG_FN[kind];
|
|
627
|
-
for (const f of fields) {
|
|
628
|
-
const col = validateSchemaField(f, joinSchema, joinTableConf);
|
|
629
|
-
selectParts.push(`${aggExpr(fn, `${db.qi(joinSchema.tableName)}.${db.qi(col)}`)} as "${kind}_${f}"`);
|
|
630
|
-
}
|
|
631
|
-
};
|
|
632
|
-
addAgg('distinctCount', aggregations.distinctCount);
|
|
633
|
-
addAgg('min', aggregations.min);
|
|
634
|
-
addAgg('max', aggregations.max);
|
|
635
|
-
addAgg('sum', aggregations.sum);
|
|
636
|
-
addAgg('avg', aggregations.avg);
|
|
637
|
-
addAgg('count', aggregations.count);
|
|
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) {
|
|
641
|
-
result[alias] = {};
|
|
642
|
-
continue;
|
|
643
|
-
}
|
|
644
|
-
const groupRef = { filters: groupFilters, conditions: groupConditions };
|
|
645
|
-
const cb = buildJoinRefCondition(joinTableConf, joinSchema, groupRef, db);
|
|
646
|
-
const fkCol = joinSchema.col(joinField);
|
|
647
|
-
cb.isIn(`${db.qi(joinSchema.tableName)}.${db.qi(fkCol)}`, ids);
|
|
648
|
-
const tenantJoins = [];
|
|
649
|
-
appendJoinTenantScope(db, joinTableConf, tenant, joinSchema.tableName, cb, tenantJoins);
|
|
650
|
-
const where = cb.build(1, db.ph);
|
|
651
|
-
const values = cb.getValues();
|
|
652
|
-
const groupBy = groupByParts.length > 0 ? `GROUP BY ${groupByParts.join(', ')}` : '';
|
|
653
|
-
const fromJoins = tenantJoins.length > 0 ? ` ${tenantJoins.join(' ')}` : '';
|
|
654
|
-
const sql = `SELECT ${selectParts.join(', ')} FROM ${db.qi(joinSchema.tableName)}${fromJoins} WHERE ${where} ${groupBy}`;
|
|
655
|
-
const queryResult = await db.query(sql, values);
|
|
656
|
-
const rows = queryResult.rows;
|
|
657
|
-
const formatted = {};
|
|
658
|
-
if (rows.length > 0) {
|
|
659
|
-
const row = rows.length === 1 && !aggregations.by ? rows[0] : rows;
|
|
660
|
-
if (Array.isArray(row)) {
|
|
661
|
-
formatted.rows = row;
|
|
662
|
-
}
|
|
663
|
-
else {
|
|
664
|
-
for (const [key, value] of Object.entries(row)) {
|
|
665
|
-
if (key === 'by')
|
|
666
|
-
continue;
|
|
667
|
-
const [fn, field] = key.split('_');
|
|
668
|
-
if (!formatted[fn])
|
|
669
|
-
formatted[fn] = {};
|
|
670
|
-
formatted[fn][field] = value;
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
result[alias] = formatted;
|
|
675
|
-
}
|
|
676
|
-
return result;
|
|
677
|
-
}
|
|
678
|
-
// ─── ID collection ──────────────────────────────────────────
|
|
679
|
-
function collectIds(mainResults, mainField) {
|
|
680
|
-
if (Array.isArray(mainField)) {
|
|
681
|
-
const seen = new Set();
|
|
682
|
-
const ids = [];
|
|
683
|
-
for (const r of mainResults) {
|
|
684
|
-
const key = mainField.map((f) => r[f]).join('|');
|
|
685
|
-
if (!seen.has(key) && mainField.every((f) => r[f] != null)) {
|
|
686
|
-
seen.add(key);
|
|
687
|
-
ids.push(...mainField.map((f) => r[f]));
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
return ids;
|
|
691
|
-
}
|
|
692
|
-
return [...new Set(mainResults.map((r) => r[mainField]).filter((v) => v != null))];
|
|
693
|
-
}
|
|
694
|
-
// ─── Conditions (advanced filters) ──────────────────────────
|
|
695
|
-
import { ALLOWED_SET, SINGLE_VALUE_SET, BETWEEN_SET, IN_SET, NULL_SET, } from '../../condition-methods.js';
|
|
696
|
-
function dispatchConditionMethod(cb, method, colOrExpr, params) {
|
|
697
|
-
if (SINGLE_VALUE_SET.has(method)) {
|
|
698
|
-
cb[method](colOrExpr, params[0]);
|
|
699
|
-
}
|
|
700
|
-
else if (BETWEEN_SET.has(method)) {
|
|
701
|
-
cb[method](colOrExpr, params[0], params[1]);
|
|
702
|
-
}
|
|
703
|
-
else if (IN_SET.has(method)) {
|
|
704
|
-
cb[method](colOrExpr, params[0]);
|
|
705
|
-
}
|
|
706
|
-
else if (NULL_SET.has(method)) {
|
|
707
|
-
cb[method](colOrExpr, true);
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
function buildJoinRefCondition(joinTableConf, joinSchema, ref, db, qualifier) {
|
|
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.
|
|
714
|
-
// Bare table name for plain side-queries; the subquery alias when the condition lands
|
|
715
|
-
// inside an aliased, correlated FROM (there the table name would resolve to the outer query).
|
|
716
|
-
const colQualifier = qualifier ?? joinSchema.tableName;
|
|
717
|
-
const cb = (ref.filters && joinTableConf)
|
|
718
|
-
? joinTableConf.filters(ref.filters, db.cbDialect, colQualifier)
|
|
719
|
-
: new ConditionBuilder('AND', db.cbDialect);
|
|
720
|
-
// Equality filters targeting the join table's computed fields
|
|
721
|
-
const computed = joinTableConf?.computedFields;
|
|
722
|
-
if (computed && ref.filters) {
|
|
723
|
-
for (const [name, value] of Object.entries(ref.filters)) {
|
|
724
|
-
if (value === null || value === undefined)
|
|
725
|
-
continue;
|
|
726
|
-
if (!computed[name])
|
|
727
|
-
continue;
|
|
728
|
-
const expr = evaluateComputedField(name, computed[name], joinSchema, db, colQualifier, '', true);
|
|
729
|
-
cb.isEqual(expr, value);
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
if (ref.conditions?.length) {
|
|
733
|
-
for (const c of ref.conditions) {
|
|
734
|
-
if (!ALLOWED_SET.has(c.method)) {
|
|
735
|
-
err400(`Invalid condition method: ${c.method}`);
|
|
736
|
-
}
|
|
737
|
-
// Computed fields resolve to an Expression carrying their own bound values, which
|
|
738
|
-
// the ConditionBuilder places together with the compared value.
|
|
739
|
-
const operand = computed?.[c.field]
|
|
740
|
-
? evaluateComputedField(c.field, computed[c.field], joinSchema, db, colQualifier, '', true)
|
|
741
|
-
: `${db.qi(colQualifier)}.${db.qi(validateSchemaField(c.field, joinSchema, joinTableConf))}`;
|
|
742
|
-
dispatchConditionMethod(cb, c.method, operand, c.params ?? []);
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
return cb;
|
|
746
|
-
}
|
|
747
|
-
/**
|
|
748
|
-
* Apply non-dotted conditions to the main `condition` ConditionBuilder when
|
|
749
|
-
* possible. For schema fields → straight dispatch. For computed fields → return
|
|
750
|
-
* side-channel clauses to be appended to WHERE later (with correct placeholder
|
|
751
|
-
* offsets), since ConditionBuilder cannot bind values for an LHS expression.
|
|
752
|
-
*/
|
|
753
|
-
function applyConditions(condition, conditions, schema, tableConf, db) {
|
|
754
|
-
for (const c of conditions) {
|
|
755
|
-
// Skip dot-notation fields — those become aggregation conditions processed later
|
|
756
|
-
if (c.field.includes('.'))
|
|
757
|
-
continue;
|
|
758
|
-
if (!ALLOWED_SET.has(c.method)) {
|
|
759
|
-
err400(`Invalid condition method: ${c.method}`);
|
|
760
|
-
}
|
|
761
|
-
// A computed field resolves to an Expression carrying its own bound values; the
|
|
762
|
-
// ConditionBuilder places them, so no placeholder offset is computed here.
|
|
763
|
-
const ref = resolveFieldRef(c.field, schema, tableConf, db);
|
|
764
|
-
dispatchConditionMethod(condition, c.method, ref.expr, c.params);
|
|
765
|
-
}
|
|
766
|
-
}
|
|
109
|
+
// ─── searchEngine entrypoint ────────────────────────────────
|
|
767
110
|
/**
|
|
768
|
-
*
|
|
769
|
-
*
|
|
770
|
-
*
|
|
111
|
+
* Reject unknown aliases, non-unique aliases used as `joinLeft`, and unsupported join
|
|
112
|
+
* filter keys — all before any query runs, so a 400 never depends on whether the main
|
|
113
|
+
* query happened to return rows.
|
|
114
|
+
*
|
|
115
|
+
* `joinLeft` is the odd one out: its condition is built inline and never runs the target's
|
|
116
|
+
* `extendedCondition`, so `extraFilters` are not accepted there.
|
|
771
117
|
*/
|
|
772
|
-
function
|
|
773
|
-
const
|
|
774
|
-
if (!computed)
|
|
775
|
-
return;
|
|
776
|
-
for (const [name, value] of Object.entries(filters)) {
|
|
777
|
-
if (value === null || value === undefined)
|
|
778
|
-
continue;
|
|
779
|
-
if (!computed[name])
|
|
780
|
-
continue;
|
|
781
|
-
const expr = evaluateComputedField(name, computed[name], tableConf.Schema, db, undefined, '', true);
|
|
782
|
-
condition.isEqual(expr, value);
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
function appendAggConditions(params, conditions, db, dbTables, tableConf, joinGroup) {
|
|
786
|
-
let where = '';
|
|
787
|
-
for (const c of conditions) {
|
|
788
|
-
if (!c.field.includes('.'))
|
|
789
|
-
continue;
|
|
790
|
-
if (!ALLOWED_SET.has(c.method)) {
|
|
791
|
-
err400(`Invalid condition method: ${c.method}`);
|
|
792
|
-
}
|
|
793
|
-
const parts = c.field.split('.');
|
|
794
|
-
if (parts.length !== 3) {
|
|
795
|
-
err400(`Invalid dotted field in condition: ${c.field} (expected <alias>.<fn>.<field>)`);
|
|
796
|
-
}
|
|
797
|
-
const [alias, fn, field] = parts;
|
|
798
|
-
// The aggregate carries the values of its own filter; passing it as the left-hand side
|
|
799
|
-
// lets the ConditionBuilder place those and the compared value in one step.
|
|
800
|
-
const expr = buildAggOrderExpr(db, dbTables, tableConf, alias, fn, field, joinGroup);
|
|
801
|
-
const tmpCb = new ConditionBuilder('AND', db.cbDialect);
|
|
802
|
-
dispatchConditionMethod(tmpCb, c.method, expr, c.params);
|
|
803
|
-
where += ` AND ${params.emitCondition(tmpCb, db)}`;
|
|
804
|
-
}
|
|
805
|
-
return where;
|
|
806
|
-
}
|
|
807
|
-
// ─── joinMustExist (EXISTS subquery filter on main) ─────────
|
|
808
|
-
function buildJoinMustExistClauses(params, db, dbTables, tableConf, joinMustExist, tenant) {
|
|
809
|
-
let where = '';
|
|
810
|
-
for (const [alias, ref] of Object.entries(joinMustExist)) {
|
|
811
|
-
const joinDef = requireJoin(tableConf, alias, false);
|
|
812
|
-
const { joinSchema } = joinDef;
|
|
813
|
-
const joinTableConf = dbTables[joinSchema.tableName];
|
|
814
|
-
const refs = extractJoinRefs(db, tableConf, joinDef);
|
|
815
|
-
// The EXISTS FROM is aliased so the correlation to the outer table survives a
|
|
816
|
-
// self-referencing relation; everything inside references the alias.
|
|
817
|
-
const subAlias = subqueryAlias(alias, tableConf);
|
|
818
|
-
const subRef = db.qi(subAlias);
|
|
819
|
-
const filterCondition = buildJoinRefCondition(joinTableConf, joinSchema, ref, db, subAlias);
|
|
820
|
-
const tenantJoins = [];
|
|
821
|
-
appendJoinTenantScope(db, joinTableConf, tenant, subAlias, filterCondition, tenantJoins);
|
|
822
|
-
const filterWhere = params.emitCondition(filterCondition, db);
|
|
823
|
-
const innerWhere = filterWhere
|
|
824
|
-
? `${subRef}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol} AND ${filterWhere}`
|
|
825
|
-
: `${subRef}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol}`;
|
|
826
|
-
const existsJoins = tenantJoins.length > 0 ? ` ${tenantJoins.join(' ')}` : '';
|
|
827
|
-
where += ` AND EXISTS (SELECT 1 FROM ${refs.joinTable} AS ${subRef}${existsJoins} WHERE ${innerWhere})`;
|
|
828
|
-
}
|
|
829
|
-
return where;
|
|
830
|
-
}
|
|
831
|
-
// ─── searchEngine entrypoint ────────────────────────────────
|
|
832
|
-
export async function searchEngine(dbTables, params) {
|
|
833
|
-
const { db, tableConf, filters, conditions, joinMustExist, joinMultiple, joinGroup, joinLeft, orderBy, paginator, computeMin, computeMax, computeSum, computeAvg, tenant, } = params;
|
|
834
|
-
// Validate aliases used as keys against allowedReadJoins + unique flag (early 400s)
|
|
118
|
+
function assertJoinRequestsAllowed(dbTables, params) {
|
|
119
|
+
const { tableConf, joinMustExist, joinMultiple, joinGroup, joinLeft } = params;
|
|
835
120
|
if (joinMustExist)
|
|
836
121
|
for (const a of Object.keys(joinMustExist))
|
|
837
122
|
requireJoin(tableConf, a, false);
|
|
@@ -844,39 +129,93 @@ export async function searchEngine(dbTables, params) {
|
|
|
844
129
|
if (joinLeft)
|
|
845
130
|
for (const a of Object.keys(joinLeft))
|
|
846
131
|
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
132
|
assertJoinFilterKeys(dbTables, tableConf, joinMustExist, true);
|
|
851
133
|
assertJoinFilterKeys(dbTables, tableConf, joinMultiple, true);
|
|
852
134
|
assertJoinFilterKeys(dbTables, tableConf, joinGroup, true);
|
|
853
135
|
assertJoinFilterKeys(dbTables, tableConf, joinLeft, false);
|
|
854
|
-
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The main table's WHERE builder, plus any JOIN the tenant scope needs.
|
|
139
|
+
*
|
|
140
|
+
* Filters and conditions on computed fields go on the same ConditionBuilder: a computed
|
|
141
|
+
* resolves to an Expression carrying its own values, and the builder is what assigns
|
|
142
|
+
* every placeholder index.
|
|
143
|
+
*/
|
|
144
|
+
function buildMainCondition(params) {
|
|
145
|
+
const { db, tableConf, filters, conditions, tenant } = params;
|
|
855
146
|
assertFiltersReadable(filters, tableConf);
|
|
856
147
|
assertKnownFilterKeys(filters, tableConf.Schema, tableConf);
|
|
857
148
|
const condition = tableConf.filters(filters || {}, db.cbDialect);
|
|
858
|
-
// Filters and conditions targeting computed fields go on the same ConditionBuilder:
|
|
859
|
-
// a computed resolves to an Expression carrying its own values, and the builder is
|
|
860
|
-
// what assigns every placeholder index.
|
|
861
149
|
if (filters && tableConf.computedFields) {
|
|
862
150
|
applyComputedFilters(condition, filters, tableConf, db);
|
|
863
151
|
}
|
|
864
152
|
if (conditions?.length) {
|
|
865
153
|
applyConditions(condition, conditions, tableConf.Schema, tableConf, db);
|
|
866
154
|
}
|
|
867
|
-
// Tenant
|
|
868
155
|
const tenantJoins = [];
|
|
869
|
-
if (tenant) {
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
156
|
+
if (tenant?.scope) {
|
|
157
|
+
const scope = tenant.scope;
|
|
158
|
+
condition.append(buildTenantCondition(db, scope, tenant.ids, tableConf.Schema.tableName));
|
|
159
|
+
if ('through' in scope) {
|
|
160
|
+
tenantJoins.push(buildTenantJoin(db, scope, tableConf.Schema.tableName));
|
|
873
161
|
}
|
|
874
162
|
}
|
|
163
|
+
return { condition, tenantJoins };
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* joinLeft aliases that need a real LEFT JOIN on the main query: those referenced by a
|
|
167
|
+
* 2-part `orderBy`, plus those the request filters or puts conditions on.
|
|
168
|
+
*/
|
|
169
|
+
function collectLeftJoinAliases(params) {
|
|
170
|
+
const { orderBy, joinLeft, tableConf } = params;
|
|
171
|
+
const aliases = new Set();
|
|
172
|
+
if (orderBy) {
|
|
173
|
+
for (const a of collectOrderByLeftAliases(orderBy, tableConf))
|
|
174
|
+
aliases.add(a);
|
|
175
|
+
}
|
|
176
|
+
if (joinLeft) {
|
|
177
|
+
for (const [alias, ref] of Object.entries(joinLeft)) {
|
|
178
|
+
if (ref?.filters || ref?.conditions?.length)
|
|
179
|
+
aliases.add(alias);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return aliases;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Run the side queries for the join families that return rows of their own, and hang each
|
|
186
|
+
* one off `result`. These run after the main query because they are correlated to the rows
|
|
187
|
+
* it returned.
|
|
188
|
+
*/
|
|
189
|
+
async function attachJoinResults(result, dbTables, params, main) {
|
|
190
|
+
const { db, tableConf, joinMultiple, joinLeft, joinGroup, tenant } = params;
|
|
191
|
+
if (joinMultiple && Object.keys(joinMultiple).length > 0) {
|
|
192
|
+
result.joinMultiple = await executeJoinMultiple(db, dbTables, tableConf, main, joinMultiple, tenant);
|
|
193
|
+
}
|
|
194
|
+
if (joinLeft && Object.keys(joinLeft).length > 0) {
|
|
195
|
+
result.joinLeft = await executeJoinLeft(db, dbTables, tableConf, main, joinLeft, tenant);
|
|
196
|
+
}
|
|
197
|
+
if (joinGroup && Object.keys(joinGroup).length > 0) {
|
|
198
|
+
result.joinGroup = await executeJoinGroup(db, dbTables, tableConf, main, joinGroup, tenant);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export async function searchEngine(dbTables, params) {
|
|
202
|
+
const { db, tableConf, conditions, joinMustExist, joinGroup, joinLeft, orderBy, paginator, computeMin, computeMax, computeSum, computeAvg, tenant, } = params;
|
|
203
|
+
assertRequestWithinLimits(params);
|
|
204
|
+
assertJoinRequestsAllowed(dbTables, params);
|
|
205
|
+
const { condition, tenantJoins } = buildMainCondition(params);
|
|
206
|
+
const aliasesNeedingLeftJoin = collectLeftJoinAliases(params);
|
|
875
207
|
// From here the statement is assembled fragment by fragment. `bound` owns the values and
|
|
876
208
|
// hands each fragment the placeholder index it must start from, so no offset is computed
|
|
877
209
|
// at any call site. Fragments must be emitted in the order their placeholders appear in
|
|
878
210
|
// the final SQL — MySQL binds `?` positionally.
|
|
879
211
|
const bound = new QueryParams();
|
|
212
|
+
// LEFT JOIN clauses come first: they sit before the WHERE in the statement, and their ON
|
|
213
|
+
// clause carries the joined table's tenant scope.
|
|
214
|
+
const extraJoinClauses = [...tenantJoins];
|
|
215
|
+
if (aliasesNeedingLeftJoin.size > 0) {
|
|
216
|
+
const on = bound.emit((startIndex) => buildLeftJoinOnClauses(db, dbTables, tableConf, aliasesNeedingLeftJoin, startIndex, tenant));
|
|
217
|
+
extraJoinClauses.push(...on.joinClauses);
|
|
218
|
+
}
|
|
880
219
|
let where = bound.emitCondition(condition, db);
|
|
881
220
|
// joinMustExist (EXISTS)
|
|
882
221
|
if (joinMustExist && Object.keys(joinMustExist).length > 0) {
|
|
@@ -884,27 +223,11 @@ export async function searchEngine(dbTables, params) {
|
|
|
884
223
|
}
|
|
885
224
|
// Aggregation conditions (HAVING-style)
|
|
886
225
|
if (conditions?.length && conditions.some((c) => c.field.includes('.'))) {
|
|
887
|
-
where += appendAggConditions(bound, conditions, db, dbTables, tableConf, joinGroup);
|
|
888
|
-
}
|
|
889
|
-
// Determine which joinLeft aliases need a LEFT JOIN on the main query:
|
|
890
|
-
// - any alias used in 2-parti orderBy
|
|
891
|
-
// - any alias in joinLeft body that has filters or conditions on the parent
|
|
892
|
-
const aliasesNeedingLeftJoin = new Set();
|
|
893
|
-
if (orderBy) {
|
|
894
|
-
for (const a of collectOrderByLeftAliases(orderBy, tableConf))
|
|
895
|
-
aliasesNeedingLeftJoin.add(a);
|
|
226
|
+
where += appendAggConditions(bound, conditions, db, dbTables, tableConf, joinGroup, tenant);
|
|
896
227
|
}
|
|
897
|
-
|
|
898
|
-
for (const [alias, ref] of Object.entries(joinLeft)) {
|
|
899
|
-
if (ref?.filters || ref?.conditions?.length)
|
|
900
|
-
aliasesNeedingLeftJoin.add(alias);
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
// LEFT JOIN clauses + extra WHERE for filtered parents.
|
|
904
|
-
const extraJoinClauses = [...tenantJoins];
|
|
228
|
+
// Extra WHERE for the parents the request filters on.
|
|
905
229
|
if (aliasesNeedingLeftJoin.size > 0) {
|
|
906
|
-
const lj = bound.emit((startIndex) =>
|
|
907
|
-
extraJoinClauses.push(...lj.joinClauses);
|
|
230
|
+
const lj = bound.emit((startIndex) => buildLeftJoinFilters(db, dbTables, tableConf, aliasesNeedingLeftJoin, joinLeft, startIndex));
|
|
908
231
|
for (const w of lj.whereExtras)
|
|
909
232
|
where += ` AND ${w}`;
|
|
910
233
|
}
|
|
@@ -915,7 +238,7 @@ export async function searchEngine(dbTables, params) {
|
|
|
915
238
|
// WHERE and LEFT JOIN values.
|
|
916
239
|
let safeOrderBy;
|
|
917
240
|
if (orderBy) {
|
|
918
|
-
safeOrderBy = bound.emit((startIndex) => validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, startIndex)).sql;
|
|
241
|
+
safeOrderBy = bound.emit((startIndex) => validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, startIndex, tenant)).sql;
|
|
919
242
|
}
|
|
920
243
|
const mainValues = bound.snapshot();
|
|
921
244
|
const main = await executeMainQuery(db, tableConf, where, mainValues, safeOrderBy, paginator, extraJoinClauses, params.selectComputed, params.maxRows);
|
|
@@ -924,15 +247,7 @@ export async function searchEngine(dbTables, params) {
|
|
|
924
247
|
pagination = await buildPagination(db, tableConf, where, whereAndJoinValues, paginator, extraJoinClauses, computeMin, computeMax, computeSum, computeAvg);
|
|
925
248
|
}
|
|
926
249
|
const result = { main };
|
|
927
|
-
|
|
928
|
-
result.joinMultiple = await executeJoinMultiple(db, dbTables, tableConf, main, joinMultiple, tenant);
|
|
929
|
-
}
|
|
930
|
-
if (joinLeft && Object.keys(joinLeft).length > 0) {
|
|
931
|
-
result.joinLeft = await executeJoinLeft(db, dbTables, tableConf, main, joinLeft, tenant);
|
|
932
|
-
}
|
|
933
|
-
if (joinGroup && Object.keys(joinGroup).length > 0) {
|
|
934
|
-
result.joinGroup = await executeJoinGroup(db, dbTables, tableConf, main, joinGroup, tenant);
|
|
935
|
-
}
|
|
250
|
+
await attachJoinResults(result, dbTables, params, main);
|
|
936
251
|
if (pagination) {
|
|
937
252
|
result.pagination = pagination;
|
|
938
253
|
}
|