fastify-auto-sqlapi 0.1.13 → 0.2.0

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