fastify-auto-sqlapi 0.1.14 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/AGENTS_BACKEND.md +4 -4
  2. package/BREAKING_CHANGES.md +5 -5
  3. package/README.md +1 -1
  4. package/dist/bin/generate-schema.js +89 -77
  5. package/dist/bin/generate-tables.js +72 -62
  6. package/dist/bin/utils.d.ts.map +1 -1
  7. package/dist/bin/utils.js +47 -33
  8. package/dist/lib/agent/manifest.d.ts +3 -2
  9. package/dist/lib/agent/manifest.d.ts.map +1 -1
  10. package/dist/lib/agent/manifest.js +102 -80
  11. package/dist/lib/cli/mysql-introspect.d.ts +4 -9
  12. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  13. package/dist/lib/cli/mysql-introspect.js +8 -7
  14. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  15. package/dist/lib/cli/pg-introspect.js +0 -1
  16. package/dist/lib/cli/schema-codegen.d.ts +3 -0
  17. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  18. package/dist/lib/cli/schema-codegen.js +3 -0
  19. package/dist/lib/cli/tables-codegen.d.ts +8 -1
  20. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  21. package/dist/lib/cli/tables-codegen.js +100 -118
  22. package/dist/lib/condition-methods.d.ts +14 -4
  23. package/dist/lib/condition-methods.d.ts.map +1 -1
  24. package/dist/lib/condition-methods.js +18 -4
  25. package/dist/lib/db.d.ts +1 -1
  26. package/dist/lib/db.d.ts.map +1 -1
  27. package/dist/lib/db.js +16 -4
  28. package/dist/lib/dialect.d.ts +2 -1
  29. package/dist/lib/dialect.d.ts.map +1 -1
  30. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  31. package/dist/lib/engine/bulk/bulk-upsert.js +42 -35
  32. package/dist/lib/engine/rest/delete.d.ts.map +1 -1
  33. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  34. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  35. package/dist/lib/engine/rest/insert.js +1 -1
  36. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  37. package/dist/lib/engine/rest/update.js +25 -18
  38. package/dist/lib/engine/search/aggregations.d.ts +33 -0
  39. package/dist/lib/engine/search/aggregations.d.ts.map +1 -0
  40. package/dist/lib/engine/search/aggregations.js +109 -0
  41. package/dist/lib/engine/search/conditions.d.ts +31 -0
  42. package/dist/lib/engine/search/conditions.d.ts.map +1 -0
  43. package/dist/lib/engine/search/conditions.js +131 -0
  44. package/dist/lib/engine/search/fields.d.ts +49 -0
  45. package/dist/lib/engine/search/fields.d.ts.map +1 -0
  46. package/dist/lib/engine/search/fields.js +142 -0
  47. package/dist/lib/engine/search/join-families.d.ts +35 -0
  48. package/dist/lib/engine/search/join-families.d.ts.map +1 -0
  49. package/dist/lib/engine/search/join-families.js +345 -0
  50. package/dist/lib/engine/search/joins.d.ts +55 -0
  51. package/dist/lib/engine/search/joins.d.ts.map +1 -0
  52. package/dist/lib/engine/search/joins.js +81 -0
  53. package/dist/lib/engine/search/order-by.d.ts +35 -0
  54. package/dist/lib/engine/search/order-by.d.ts.map +1 -0
  55. package/dist/lib/engine/search/order-by.js +104 -0
  56. package/dist/lib/engine/search/search.d.ts.map +1 -1
  57. package/dist/lib/engine/search/search.js +101 -787
  58. package/dist/lib/engine/validate.d.ts +0 -5
  59. package/dist/lib/engine/validate.d.ts.map +1 -1
  60. package/dist/lib/engine/write-helpers.d.ts +25 -17
  61. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  62. package/dist/lib/engine/write-helpers.js +86 -44
  63. package/dist/lib/read-access.d.ts +12 -1
  64. package/dist/lib/read-access.d.ts.map +1 -1
  65. package/dist/lib/read-access.js +20 -4
  66. package/dist/lib/schema/helpers.d.ts +0 -15
  67. package/dist/lib/schema/helpers.d.ts.map +1 -1
  68. package/dist/lib/schema/helpers.js +7 -4
  69. package/dist/lib/schema/search.d.ts.map +1 -1
  70. package/dist/lib/schema/search.js +107 -128
  71. package/dist/lib/sql-api.d.ts.map +1 -1
  72. package/dist/lib/tenant.d.ts +26 -0
  73. package/dist/lib/tenant.d.ts.map +1 -1
  74. package/dist/lib/tenant.js +46 -0
  75. package/dist/routes/auto/route-helpers.d.ts +0 -7
  76. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  77. package/dist/routes/auto/route-helpers.js +46 -30
  78. package/dist/routes/auto/search.routes.js +0 -1
  79. package/dist/types/table.d.ts +1 -1
  80. package/docs/GLOSSARY.md +28 -0
  81. package/docs/adr/0012-anti-duplication-tooling.md +59 -0
  82. package/docs/adr/README.md +3 -1
  83. 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 { assertReadable, assertFiltersReadable, assertKnownFilterKeys, readableSelectColumns, explicitSelectColumns, } from '../../read-access.js';
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
- // ─── Schema field validation ────────────────────────────────
8
- function validateSchemaField(field, schema, tableConf) {
9
- if (!(field in schema.fields)) {
10
- const err = new Error(`Unknown field: ${field}`);
11
- err.statusCode = 400;
12
- throw err;
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
- * Tenant-scope a join side-query. When the joined table declares its own `tenantScope`,
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
- * `joinTableRef` is the raw name the join table goes by in the statement: its table name
67
- * in plain side-queries, or the subquery alias inside an aliased, correlated EXISTS.
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
- * Unknown aliases are left to `requireJoin`, which reports them with its own message.
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 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
- }
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
- const qualifiedCol = `${subRef}.${db.qi(fieldCol)}`;
271
- const expr = `COALESCE((SELECT ${aggExpr(aggFn, qualifiedCol)} FROM ${refs.joinTable} AS ${subRef} WHERE ${subRef}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol}${filterWhere}), 0)`;
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
- return aliases;
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?.[name];
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
- // ─── joinMultiple (virtual fetch, child rows in side query) ─
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
- * Equality filters targeting computed fields: `filters.<computedName>` becomes
769
- * `<expr> = <value>` on the same ConditionBuilder used for schema fields, which
770
- * `tableConf.filters()` has already populated.
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 applyComputedFilters(condition, filters, tableConf, db) {
773
- const computed = tableConf.computedFields;
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,27 +129,29 @@ 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
- // Build main condition
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
156
  if (tenant) {
870
157
  condition.append(buildTenantCondition(db, tenant.scope, tenant.ids, tableConf.Schema.tableName));
@@ -872,11 +159,62 @@ export async function searchEngine(dbTables, params) {
872
159
  tenantJoins.push(buildTenantJoin(db, tenant.scope, tableConf.Schema.tableName));
873
160
  }
874
161
  }
162
+ return { condition, tenantJoins };
163
+ }
164
+ /**
165
+ * joinLeft aliases that need a real LEFT JOIN on the main query: those referenced by a
166
+ * 2-part `orderBy`, plus those the request filters or puts conditions on.
167
+ */
168
+ function collectLeftJoinAliases(params) {
169
+ const { orderBy, joinLeft, tableConf } = params;
170
+ const aliases = new Set();
171
+ if (orderBy) {
172
+ for (const a of collectOrderByLeftAliases(orderBy, tableConf))
173
+ aliases.add(a);
174
+ }
175
+ if (joinLeft) {
176
+ for (const [alias, ref] of Object.entries(joinLeft)) {
177
+ if (ref?.filters || ref?.conditions?.length)
178
+ aliases.add(alias);
179
+ }
180
+ }
181
+ return aliases;
182
+ }
183
+ /**
184
+ * Run the side queries for the join families that return rows of their own, and hang each
185
+ * one off `result`. These run after the main query because they are correlated to the rows
186
+ * it returned.
187
+ */
188
+ async function attachJoinResults(result, dbTables, params, main) {
189
+ const { db, tableConf, joinMultiple, joinLeft, joinGroup, tenant } = params;
190
+ if (joinMultiple && Object.keys(joinMultiple).length > 0) {
191
+ result.joinMultiple = await executeJoinMultiple(db, dbTables, tableConf, main, joinMultiple, tenant);
192
+ }
193
+ if (joinLeft && Object.keys(joinLeft).length > 0) {
194
+ result.joinLeft = await executeJoinLeft(db, dbTables, tableConf, main, joinLeft, tenant);
195
+ }
196
+ if (joinGroup && Object.keys(joinGroup).length > 0) {
197
+ result.joinGroup = await executeJoinGroup(db, dbTables, tableConf, main, joinGroup, tenant);
198
+ }
199
+ }
200
+ export async function searchEngine(dbTables, params) {
201
+ const { db, tableConf, conditions, joinMustExist, joinGroup, joinLeft, orderBy, paginator, computeMin, computeMax, computeSum, computeAvg, tenant, } = params;
202
+ assertRequestWithinLimits(params);
203
+ assertJoinRequestsAllowed(dbTables, params);
204
+ const { condition, tenantJoins } = buildMainCondition(params);
205
+ const aliasesNeedingLeftJoin = collectLeftJoinAliases(params);
875
206
  // From here the statement is assembled fragment by fragment. `bound` owns the values and
876
207
  // hands each fragment the placeholder index it must start from, so no offset is computed
877
208
  // at any call site. Fragments must be emitted in the order their placeholders appear in
878
209
  // the final SQL — MySQL binds `?` positionally.
879
210
  const bound = new QueryParams();
211
+ // LEFT JOIN clauses come first: they sit before the WHERE in the statement, and their ON
212
+ // clause carries the joined table's tenant scope.
213
+ const extraJoinClauses = [...tenantJoins];
214
+ if (aliasesNeedingLeftJoin.size > 0) {
215
+ const on = bound.emit((startIndex) => buildLeftJoinOnClauses(db, dbTables, tableConf, aliasesNeedingLeftJoin, startIndex, tenant));
216
+ extraJoinClauses.push(...on.joinClauses);
217
+ }
880
218
  let where = bound.emitCondition(condition, db);
881
219
  // joinMustExist (EXISTS)
882
220
  if (joinMustExist && Object.keys(joinMustExist).length > 0) {
@@ -884,27 +222,11 @@ export async function searchEngine(dbTables, params) {
884
222
  }
885
223
  // Aggregation conditions (HAVING-style)
886
224
  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);
225
+ where += appendAggConditions(bound, conditions, db, dbTables, tableConf, joinGroup, tenant);
896
226
  }
897
- if (joinLeft) {
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];
227
+ // Extra WHERE for the parents the request filters on.
905
228
  if (aliasesNeedingLeftJoin.size > 0) {
906
- const lj = bound.emit((startIndex) => buildLeftJoinClauses(db, dbTables, tableConf, aliasesNeedingLeftJoin, joinLeft, startIndex));
907
- extraJoinClauses.push(...lj.joinClauses);
229
+ const lj = bound.emit((startIndex) => buildLeftJoinFilters(db, dbTables, tableConf, aliasesNeedingLeftJoin, joinLeft, startIndex));
908
230
  for (const w of lj.whereExtras)
909
231
  where += ` AND ${w}`;
910
232
  }
@@ -915,7 +237,7 @@ export async function searchEngine(dbTables, params) {
915
237
  // WHERE and LEFT JOIN values.
916
238
  let safeOrderBy;
917
239
  if (orderBy) {
918
- safeOrderBy = bound.emit((startIndex) => validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, startIndex)).sql;
240
+ safeOrderBy = bound.emit((startIndex) => validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, startIndex, tenant)).sql;
919
241
  }
920
242
  const mainValues = bound.snapshot();
921
243
  const main = await executeMainQuery(db, tableConf, where, mainValues, safeOrderBy, paginator, extraJoinClauses, params.selectComputed, params.maxRows);
@@ -924,15 +246,7 @@ export async function searchEngine(dbTables, params) {
924
246
  pagination = await buildPagination(db, tableConf, where, whereAndJoinValues, paginator, extraJoinClauses, computeMin, computeMax, computeSum, computeAvg);
925
247
  }
926
248
  const result = { main };
927
- if (joinMultiple && Object.keys(joinMultiple).length > 0) {
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
- }
249
+ await attachJoinResults(result, dbTables, params, main);
936
250
  if (pagination) {
937
251
  result.pagination = pagination;
938
252
  }