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
@@ -0,0 +1,345 @@
1
+ /**
2
+ * The four join families, each executed against the rows the main query returned.
3
+ *
4
+ * joinMustExist child -> main, 1:N EXISTS filter on the main query (returns no rows)
5
+ * joinMultiple child -> main, 1:N side query, rows under result.joinMultiple.<alias>
6
+ * joinGroup child -> main, 1:N aggregations under result.joinGroup.<alias>
7
+ * joinLeft parent -> main, N:1 real LEFT JOIN on demand + side query for the rows
8
+ */
9
+ import { ConditionBuilder, Expression } from 'node-condition-builder';
10
+ import { assertReadable, assertFiltersReadable, hasOwnField, ownField } from '../../read-access.js';
11
+ import { ALLOWED_SET } from '../../condition-methods.js';
12
+ import { err400, validateSchemaField, evaluateComputedField, mapRowsToCamelCase, buildSelectionColumns, } from './fields.js';
13
+ import { appendJoinTenantScope, subqueryAlias, requireJoin, extractJoinRefs, } from './joins.js';
14
+ import { tenantForTable, buildTenantRowGuard } from '../../tenant.js';
15
+ import { dispatchConditionMethod, buildJoinRefCondition } from './conditions.js';
16
+ import { AGG_FN, aggExpr } from './aggregations.js';
17
+ /**
18
+ * Resolve a joinLeft field to an alias-qualified SQL reference: a schema column, or a
19
+ * computed field's expression. Returns null when the field is neither — the caller decides
20
+ * whether that is a silent skip (filters) or a 400 (conditions).
21
+ *
22
+ * References go through the alias, not the table name, so they hit the LEFT JOIN'd row.
23
+ */
24
+ function resolveJoinLeftField(field, scope) {
25
+ const { db, alias, aliasIdent, joinSchema, computed } = scope;
26
+ if (hasOwnField(joinSchema.fields, field)) {
27
+ // Wrapped so this helper has a single return type. A value-less Expression renders
28
+ // verbatim (see renderField), so the emitted SQL is identical to the raw identifier.
29
+ return new Expression(`${aliasIdent}.${db.qi(joinSchema.col(field))}`);
30
+ }
31
+ const fn = ownField(computed, field);
32
+ if (fn) {
33
+ // Bound values are allowed: this ConditionBuilder's SQL lands in the WHERE clause.
34
+ return evaluateComputedField(field, fn, joinSchema, db, alias, '', true);
35
+ }
36
+ return null;
37
+ }
38
+ /** `joinLeft.filters` — equality only. Unknown keys are ignored, as they always were. */
39
+ function applyJoinLeftFilters(cb, filters, scope) {
40
+ for (const [field, value] of Object.entries(filters)) {
41
+ if (value === null || value === undefined)
42
+ continue;
43
+ const ref = resolveJoinLeftField(field, scope);
44
+ if (ref !== null)
45
+ cb.isEqual(ref, value);
46
+ }
47
+ }
48
+ /** `joinLeft.conditions` — operator methods. An unknown field is a 400, not a no-op. */
49
+ function applyJoinLeftConditions(cb, conditions, scope, dbTables) {
50
+ for (const c of conditions) {
51
+ if (!ALLOWED_SET.has(c.method)) {
52
+ err400(`Invalid condition method: ${c.method}`);
53
+ }
54
+ if (hasOwnField(scope.joinSchema.fields, c.field)) {
55
+ assertReadable(dbTables[scope.joinSchema.tableName], c.field);
56
+ }
57
+ else if (!ownField(scope.computed, c.field)) {
58
+ err400(`Unknown field: ${c.field}`);
59
+ }
60
+ const ref = resolveJoinLeftField(c.field, scope);
61
+ if (ref !== null)
62
+ dispatchConditionMethod(cb, c.method, ref, c.params ?? []);
63
+ }
64
+ }
65
+ /**
66
+ * The LEFT JOIN clauses themselves, each carrying the joined table's own tenant scope.
67
+ *
68
+ * The scope belongs in the ON clause, not the WHERE. In the WHERE it would discard main rows
69
+ * whose parent belongs to another tenant, silently turning the LEFT JOIN into an INNER JOIN.
70
+ * In the ON clause a foreign parent simply fails to match and reads as NULL, which closes the
71
+ * oracle: the parent rows are already withheld from the response (`executeJoinLeft` scopes its
72
+ * own side query), so an unscoped join would let a caller filter on values it cannot see and
73
+ * recover them one predicate at a time.
74
+ *
75
+ * Emitted before the WHERE fragments because that is where it lands in the statement — MySQL
76
+ * binds `?` positionally.
77
+ */
78
+ export function buildLeftJoinOnClauses(db, dbTables, tableConf, aliasesNeedingJoin, startIdx, tenant) {
79
+ const joinClauses = [];
80
+ const values = [];
81
+ let currentIdx = startIdx;
82
+ for (const alias of aliasesNeedingJoin) {
83
+ const joinDef = requireJoin(tableConf, alias, true);
84
+ const refs = extractJoinRefs(db, tableConf, joinDef);
85
+ const aliasIdent = db.qi(alias);
86
+ // The guard references the join through its ALIAS: one table may be joined under several
87
+ // aliases, and each predicate must bind to the row this clause produced.
88
+ const guard = buildTenantRowGuard(db, tenantForTable(tenant, dbTables[joinDef.joinSchema.tableName]), alias);
89
+ let scoped = '';
90
+ if (guard) {
91
+ scoped = ` AND ${guard.build(currentIdx, db.ph)}`;
92
+ const guardValues = guard.getValues();
93
+ values.push(...guardValues);
94
+ currentIdx += guardValues.length;
95
+ }
96
+ joinClauses.push(`LEFT JOIN ${refs.joinTable} AS ${aliasIdent} ON ${aliasIdent}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol}${scoped}`);
97
+ }
98
+ return { joinClauses, values };
99
+ }
100
+ /** The WHERE fragments a `joinLeft` request's own filters and conditions contribute. */
101
+ export function buildLeftJoinFilters(db, dbTables, tableConf, aliasesNeedingJoin, joinLeft, startIdx) {
102
+ const whereExtras = [];
103
+ const values = [];
104
+ let currentIdx = startIdx;
105
+ for (const alias of aliasesNeedingJoin) {
106
+ const joinDef = requireJoin(tableConf, alias, true);
107
+ const { joinSchema } = joinDef;
108
+ const aliasIdent = db.qi(alias);
109
+ const ref = joinLeft?.[alias];
110
+ if (!ref || !(ref.filters || ref.conditions?.length))
111
+ continue;
112
+ // extraFilters declared on joinTableConf are not supported for joinLeft (they would
113
+ // require alias-aware handlers); only schema and computed fields apply.
114
+ // `assertJoinFilterKeys` rejects them up front rather than letting them through as a
115
+ // silent no-op, and the generated body schema does not advertise them for unique:true
116
+ // relations.
117
+ const joinTableConf = dbTables[joinSchema.tableName];
118
+ const scope = {
119
+ db,
120
+ alias,
121
+ aliasIdent,
122
+ joinSchema,
123
+ computed: joinTableConf?.computedFields,
124
+ };
125
+ const cb = new ConditionBuilder('AND', db.cbDialect);
126
+ assertFiltersReadable(ref.filters, joinTableConf);
127
+ if (ref.filters)
128
+ applyJoinLeftFilters(cb, ref.filters, scope);
129
+ if (ref.conditions?.length)
130
+ applyJoinLeftConditions(cb, ref.conditions, scope, dbTables);
131
+ const sql = cb.build(currentIdx, db.ph);
132
+ const vals = cb.getValues();
133
+ if (sql) {
134
+ whereExtras.push(sql);
135
+ values.push(...vals);
136
+ currentIdx += vals.length;
137
+ }
138
+ }
139
+ return { whereExtras, values };
140
+ }
141
+ // ─── Row-returning join families (side query per alias) ─────
142
+ /**
143
+ * Correlate a join side query to the rows the main query returned (`fk IN (ids)`) and apply
144
+ * the tenant scope, then freeze it into SQL.
145
+ *
146
+ * Placeholders start at 1 because every side query is a statement of its own.
147
+ */
148
+ function scopeJoinSideQuery(cb, db, joinSchema, joinField, ids, joinTableConf, tenant) {
149
+ cb.isIn(`${db.qi(joinSchema.tableName)}.${db.qi(joinSchema.col(joinField))}`, ids);
150
+ const tenantJoins = [];
151
+ appendJoinTenantScope(db, joinTableConf, tenant, joinSchema.tableName, cb, tenantJoins);
152
+ const where = cb.build(1, db.ph);
153
+ return { where, values: cb.getValues(), tenantJoins };
154
+ }
155
+ async function fetchJoinRows(db, dbTables, tableConf, mainResults, requests, mode, tenant) {
156
+ const result = {};
157
+ for (const [alias, ref] of Object.entries(requests)) {
158
+ const joinDef = requireJoin(tableConf, alias, mode.requireUnique);
159
+ const { joinSchema, joinField, mainField, selection: defaultSelection } = joinDef;
160
+ const joinTableConf = dbTables[joinSchema.tableName];
161
+ // The selection is resolved before the empty-result bail-out: it is validated against
162
+ // the join schema, and that rejection must not depend on whether the main query
163
+ // matched rows.
164
+ const selection = ref?.selection ?? defaultSelection;
165
+ const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf, joinDef.fields !== undefined);
166
+ // For joinLeft, mainField on the main table is the FK pointing at joinField (the
167
+ // parent's PK), so the same collect-then-look-up works for both directions.
168
+ const ids = collectIds(mainResults, mainField);
169
+ if (ids.length === 0) {
170
+ result[alias] = [];
171
+ continue;
172
+ }
173
+ const cb = mode.applyRefCondition
174
+ ? buildJoinRefCondition(joinTableConf, joinSchema, ref || {}, db)
175
+ : new ConditionBuilder('AND', db.cbDialect);
176
+ const { where, values, tenantJoins } = scopeJoinSideQuery(cb, db, joinSchema, joinField, ids, joinTableConf, tenant);
177
+ const rows = await db.select({
178
+ tableName: joinSchema.tableName,
179
+ columns,
180
+ where,
181
+ values,
182
+ joins: tenantJoins.length > 0 ? tenantJoins : undefined,
183
+ });
184
+ result[alias] = mapRowsToCamelCase(rows, joinSchema);
185
+ }
186
+ return result;
187
+ }
188
+ /** joinMultiple: 1:N children, returned as rows under `result.joinMultiple.<alias>`. */
189
+ export function executeJoinMultiple(db, dbTables, tableConf, mainResults, joinMultiple, tenant) {
190
+ return fetchJoinRows(db, dbTables, tableConf, mainResults, joinMultiple, {
191
+ requireUnique: false,
192
+ applyRefCondition: true,
193
+ }, tenant);
194
+ }
195
+ /** joinLeft: N:1 parents, returned as rows under `result.joinLeft.<alias>`. */
196
+ export function executeJoinLeft(db, dbTables, tableConf, mainResults, joinLeft, tenant) {
197
+ return fetchJoinRows(db, dbTables, tableConf, mainResults, joinLeft, {
198
+ requireUnique: true,
199
+ applyRefCondition: false,
200
+ }, tenant);
201
+ }
202
+ /**
203
+ * Resolve `aggregations.by` to a SQL expression. Accepts:
204
+ * - a schema field name → quoted column reference
205
+ * - a computed-field name declared on the join table → its expr (no bound
206
+ * values supported in this position; reject with 400 if the computed
207
+ * returns values)
208
+ */
209
+ function buildByExpression(by, joinSchema, joinTableConf, db) {
210
+ if (typeof by !== 'string') {
211
+ err400(`Invalid 'by' specification: expected a field or computed name`);
212
+ }
213
+ if (hasOwnField(joinSchema.fields, by)) {
214
+ // validateSchemaField also enforces readExclude: GROUP BY on a hidden field
215
+ // would return its distinct values verbatim in `rows[].by`.
216
+ const col = validateSchemaField(by, joinSchema, joinTableConf);
217
+ return `${db.qi(joinSchema.tableName)}.${db.qi(col)}`;
218
+ }
219
+ const fn = ownField(joinTableConf?.computedFields, by);
220
+ if (fn) {
221
+ return evaluateComputedField(by, fn, joinSchema, db, undefined, `Computed field '${by}' with bound values cannot be used in aggregations.by`).value;
222
+ }
223
+ err400(`Unknown field: ${by}`);
224
+ }
225
+ // ─── joinGroup (aggregations) ───────────────────────────────
226
+ /**
227
+ * SELECT and GROUP BY fragments for one joinGroup alias.
228
+ *
229
+ * Every field is validated against the join schema here, so an unknown or unreachable one
230
+ * is a 400 regardless of what the data happens to contain.
231
+ */
232
+ function buildJoinGroupSelect(aggregations, joinSchema, joinTableConf, db) {
233
+ const selectParts = [];
234
+ const groupByParts = [];
235
+ if (aggregations.by) {
236
+ const byExpr = buildByExpression(aggregations.by, joinSchema, joinTableConf, db);
237
+ selectParts.push(`${byExpr} as "by"`);
238
+ groupByParts.push(byExpr);
239
+ }
240
+ const addAgg = (kind, fields) => {
241
+ if (!fields)
242
+ return;
243
+ const fn = AGG_FN[kind];
244
+ for (const f of fields) {
245
+ const col = validateSchemaField(f, joinSchema, joinTableConf);
246
+ const colRef = `${db.qi(joinSchema.tableName)}.${db.qi(col)}`;
247
+ selectParts.push(`${aggExpr(fn, colRef)} as "${kind}_${f}"`);
248
+ }
249
+ };
250
+ addAgg('distinctCount', aggregations.distinctCount);
251
+ addAgg('min', aggregations.min);
252
+ addAgg('max', aggregations.max);
253
+ addAgg('sum', aggregations.sum);
254
+ addAgg('avg', aggregations.avg);
255
+ addAgg('count', aggregations.count);
256
+ return { selectParts, groupByParts };
257
+ }
258
+ /**
259
+ * Shape aggregation rows into the response: a single row without `by` collapses to
260
+ * `{ fn: { field: value } }`; anything else stays a list under `rows`.
261
+ */
262
+ function formatJoinGroupRows(rows, hasBy) {
263
+ const formatted = {};
264
+ if (rows.length === 0)
265
+ return formatted;
266
+ const row = rows.length === 1 && !hasBy ? rows[0] : rows;
267
+ if (Array.isArray(row)) {
268
+ formatted.rows = row;
269
+ return formatted;
270
+ }
271
+ for (const [key, value] of Object.entries(row)) {
272
+ if (key === 'by')
273
+ continue;
274
+ const [fn, field] = key.split('_');
275
+ if (!formatted[fn])
276
+ formatted[fn] = {};
277
+ formatted[fn][field] = value;
278
+ }
279
+ return formatted;
280
+ }
281
+ export async function executeJoinGroup(db, dbTables, tableConf, mainResults, joinGroup, tenant) {
282
+ const result = {};
283
+ for (const [alias, groupReq] of Object.entries(joinGroup)) {
284
+ const joinDef = requireJoin(tableConf, alias, false);
285
+ const { joinSchema, joinField, mainField } = joinDef;
286
+ const joinTableConf = dbTables[joinSchema.tableName];
287
+ const ids = collectIds(mainResults, mainField);
288
+ const { aggregations, filters: groupFilters, conditions: groupConditions } = groupReq;
289
+ const { selectParts, groupByParts } = buildJoinGroupSelect(aggregations, joinSchema, joinTableConf, db);
290
+ // Bail out only here: `by` and every aggregation field have now been validated.
291
+ if (selectParts.length === 0 || ids.length === 0) {
292
+ result[alias] = {};
293
+ continue;
294
+ }
295
+ const groupRef = { filters: groupFilters, conditions: groupConditions };
296
+ const cb = buildJoinRefCondition(joinTableConf, joinSchema, groupRef, db);
297
+ const { where, values, tenantJoins } = scopeJoinSideQuery(cb, db, joinSchema, joinField, ids, joinTableConf, tenant);
298
+ const groupBy = groupByParts.length > 0 ? `GROUP BY ${groupByParts.join(', ')}` : '';
299
+ const fromJoins = tenantJoins.length > 0 ? ` ${tenantJoins.join(' ')}` : '';
300
+ const sql = `SELECT ${selectParts.join(', ')} FROM ${db.qi(joinSchema.tableName)}${fromJoins} WHERE ${where} ${groupBy}`;
301
+ const queryResult = await db.query(sql, values);
302
+ result[alias] = formatJoinGroupRows(queryResult.rows, Boolean(aggregations.by));
303
+ }
304
+ return result;
305
+ }
306
+ // ─── ID collection ──────────────────────────────────────────
307
+ function collectIds(mainResults, mainField) {
308
+ if (Array.isArray(mainField)) {
309
+ const seen = new Set();
310
+ const ids = [];
311
+ for (const r of mainResults) {
312
+ const key = mainField.map((f) => r[f]).join('|');
313
+ if (!seen.has(key) && mainField.every((f) => r[f] != null)) {
314
+ seen.add(key);
315
+ ids.push(...mainField.map((f) => r[f]));
316
+ }
317
+ }
318
+ return ids;
319
+ }
320
+ return [...new Set(mainResults.map((r) => r[mainField]).filter((v) => v != null))];
321
+ }
322
+ // ─── joinMustExist (EXISTS subquery filter on main) ─────────
323
+ export function buildJoinMustExistClauses(params, db, dbTables, tableConf, joinMustExist, tenant) {
324
+ let where = '';
325
+ for (const [alias, ref] of Object.entries(joinMustExist)) {
326
+ const joinDef = requireJoin(tableConf, alias, false);
327
+ const { joinSchema } = joinDef;
328
+ const joinTableConf = dbTables[joinSchema.tableName];
329
+ const refs = extractJoinRefs(db, tableConf, joinDef);
330
+ // The EXISTS FROM is aliased so the correlation to the outer table survives a
331
+ // self-referencing relation; everything inside references the alias.
332
+ const subAlias = subqueryAlias(alias, tableConf);
333
+ const subRef = db.qi(subAlias);
334
+ const filterCondition = buildJoinRefCondition(joinTableConf, joinSchema, ref, db, subAlias);
335
+ const tenantJoins = [];
336
+ appendJoinTenantScope(db, joinTableConf, tenant, subAlias, filterCondition, tenantJoins);
337
+ const filterWhere = params.emitCondition(filterCondition, db);
338
+ const innerWhere = filterWhere
339
+ ? `${subRef}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol} AND ${filterWhere}`
340
+ : `${subRef}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol}`;
341
+ const existsJoins = tenantJoins.length > 0 ? ` ${tenantJoins.join(' ')}` : '';
342
+ where += ` AND EXISTS (SELECT 1 FROM ${refs.joinTable} AS ${subRef}${existsJoins} WHERE ${innerWhere})`;
343
+ }
344
+ return where;
345
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Join resolution for the search engine: finding the relation an alias refers to,
3
+ * validating what a request may ask of it, and quoting its identifiers.
4
+ *
5
+ * Relations are *declared* at config time by `buildRelation` (see lib/table-helpers.ts);
6
+ * this module resolves them per request.
7
+ */
8
+ import { ConditionBuilder } from 'node-condition-builder';
9
+ import type { QueryClient } from '../../db.js';
10
+ import type { DbTables, FilterRecord, ITable, JoinDefinition, TenantContext } from '../../../types.js';
11
+ /**
12
+ * Tenant-scope a join side-query. When the joined table declares its own `tenantScope`,
13
+ * appends the tenant condition to `cb` (direct: `col IN ids`; indirect: condition on the
14
+ * through table) and, for indirect scopes, pushes the INNER JOIN clause into `joins`.
15
+ * No-op without a tenant context (admin) or when the join table is not tenant-scoped.
16
+ *
17
+ * `joinTableRef` is the raw name the join table goes by in the statement: its table name
18
+ * in plain side-queries, or the subquery alias inside an aliased, correlated EXISTS.
19
+ */
20
+ export declare function appendJoinTenantScope(db: QueryClient, joinTableConf: ITable | undefined, tenant: TenantContext | undefined, joinTableRef: string, cb: ConditionBuilder, joins?: string[]): void;
21
+ /**
22
+ * Alias for the FROM of a correlated subquery (EXISTS, aggregate). The subquery must not
23
+ * say `FROM <table>` bare: on a self-referencing relation the inner table would shadow the
24
+ * outer one and the correlation would silently compare each row with itself. The request
25
+ * alias is used, suffixed when it would itself collide with the outer table's name.
26
+ */
27
+ export declare function subqueryAlias(alias: string, tableConf: ITable): string;
28
+ /**
29
+ * Validate the filter keys of every join reference in a request map.
30
+ *
31
+ * Runs up front, before any query: the executors skip their side-query entirely when the
32
+ * main result set is empty (`ids.length === 0`), so validating there alone would make the
33
+ * same request answer 400 or 200 depending on the data it happens to match.
34
+ *
35
+ * Unknown aliases are left to `requireJoin`, which reports them with its own message.
36
+ */
37
+ export declare function assertJoinFilterKeys(dbTables: DbTables, tableConf: ITable, refs: Record<string, {
38
+ filters?: FilterRecord;
39
+ }> | undefined, allowExtraFilters: boolean): void;
40
+ export declare function requireJoin(tableConf: ITable, alias: string, requireUnique: boolean): JoinDefinition;
41
+ export interface JoinRefs {
42
+ mainColName: string;
43
+ /** db.qi'd column reference on main side (FK or PK) */
44
+ mainCol: string;
45
+ /** db.qi'd table name on main side */
46
+ mainTable: string;
47
+ /** db.qi'd table name on join side */
48
+ joinTable: string;
49
+ /** db.qi'd column reference on join side (FK or PK) */
50
+ fkCol: string;
51
+ }
52
+ /** Extract quoted identifiers for a join reference. Used by joinMustExist, joinLeft and joinGroup
53
+ * SQL builders to avoid the 4-line FK/PK destructuring boilerplate at every call site. */
54
+ export declare function extractJoinRefs(db: QueryClient, tableConf: ITable, joinDef: JoinDefinition): JoinRefs;
55
+ //# sourceMappingURL=joins.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"joins.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/joins.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAI/C,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,MAAM,EACN,cAAc,EACd,aAAa,EACd,MAAM,mBAAmB,CAAC;AAE3B;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,EAAE,EAAE,WAAW,EACf,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,YAAY,EAAE,MAAM,EACpB,EAAE,EAAE,gBAAgB,EACpB,KAAK,CAAC,EAAE,MAAM,EAAE,GACf,IAAI,CAON;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAEtE;AAMD;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE;IAAE,OAAO,CAAC,EAAE,YAAY,CAAA;CAAE,CAAC,GAAG,SAAS,EAC5D,iBAAiB,EAAE,OAAO,GACzB,IAAI,CAUN;AAED,wBAAgB,WAAW,CACzB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,aAAa,EAAE,OAAO,GACrB,cAAc,CAYhB;AAED,MAAM,WAAW,QAAQ;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB,sCAAsC;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,sCAAsC;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,uDAAuD;IACvD,KAAK,EAAE,MAAM,CAAC;CACf;AAED;0FAC0F;AAC1F,wBAAgB,eAAe,CAC7B,EAAE,EAAE,WAAW,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,GACtB,QAAQ,CAUV"}
@@ -0,0 +1,81 @@
1
+ import { buildTenantCondition, buildTenantJoin } from '../../tenant.js';
2
+ import { assertFiltersReadable, assertKnownFilterKeys } from '../../read-access.js';
3
+ import { err400 } from './fields.js';
4
+ /**
5
+ * Tenant-scope a join side-query. When the joined table declares its own `tenantScope`,
6
+ * appends the tenant condition to `cb` (direct: `col IN ids`; indirect: condition on the
7
+ * through table) and, for indirect scopes, pushes the INNER JOIN clause into `joins`.
8
+ * No-op without a tenant context (admin) or when the join table is not tenant-scoped.
9
+ *
10
+ * `joinTableRef` is the raw name the join table goes by in the statement: its table name
11
+ * in plain side-queries, or the subquery alias inside an aliased, correlated EXISTS.
12
+ */
13
+ export function appendJoinTenantScope(db, joinTableConf, tenant, joinTableRef, cb, joins) {
14
+ const scope = joinTableConf?.tenantScope;
15
+ if (!tenant || !scope)
16
+ return;
17
+ cb.append(buildTenantCondition(db, scope, tenant.ids, joinTableRef));
18
+ if ('through' in scope) {
19
+ joins?.push(buildTenantJoin(db, scope, joinTableRef));
20
+ }
21
+ }
22
+ /**
23
+ * Alias for the FROM of a correlated subquery (EXISTS, aggregate). The subquery must not
24
+ * say `FROM <table>` bare: on a self-referencing relation the inner table would shadow the
25
+ * outer one and the correlation would silently compare each row with itself. The request
26
+ * alias is used, suffixed when it would itself collide with the outer table's name.
27
+ */
28
+ export function subqueryAlias(alias, tableConf) {
29
+ return alias === tableConf.Schema.tableName ? `${alias}_sub` : alias;
30
+ }
31
+ function findJoinByAlias(tableConf, alias) {
32
+ return tableConf.allowedReadJoins?.find((j) => j.alias === alias);
33
+ }
34
+ /**
35
+ * Validate the filter keys of every join reference in a request map.
36
+ *
37
+ * Runs up front, before any query: the executors skip their side-query entirely when the
38
+ * main result set is empty (`ids.length === 0`), so validating there alone would make the
39
+ * same request answer 400 or 200 depending on the data it happens to match.
40
+ *
41
+ * Unknown aliases are left to `requireJoin`, which reports them with its own message.
42
+ */
43
+ export function assertJoinFilterKeys(dbTables, tableConf, refs, allowExtraFilters) {
44
+ if (!refs)
45
+ return;
46
+ for (const [alias, ref] of Object.entries(refs)) {
47
+ const joinDef = findJoinByAlias(tableConf, alias);
48
+ if (!joinDef)
49
+ continue;
50
+ const joinTableConf = dbTables[joinDef.joinSchema.tableName];
51
+ const filters = ref?.filters;
52
+ assertFiltersReadable(filters, joinTableConf);
53
+ assertKnownFilterKeys(filters, joinDef.joinSchema, joinTableConf, allowExtraFilters);
54
+ }
55
+ }
56
+ export function requireJoin(tableConf, alias, requireUnique) {
57
+ const joinDef = findJoinByAlias(tableConf, alias);
58
+ if (!joinDef) {
59
+ err400(`Unknown join alias: ${alias}`);
60
+ }
61
+ if (requireUnique && !joinDef.unique) {
62
+ err400(`Join alias '${alias}' is not declared with unique:true; use joinMultiple/joinMustExist/joinGroup instead`);
63
+ }
64
+ if (!requireUnique && joinDef.unique) {
65
+ err400(`Join alias '${alias}' is declared with unique:true; use joinLeft instead`);
66
+ }
67
+ return joinDef;
68
+ }
69
+ /** Extract quoted identifiers for a join reference. Used by joinMustExist, joinLeft and joinGroup
70
+ * SQL builders to avoid the 4-line FK/PK destructuring boilerplate at every call site. */
71
+ export function extractJoinRefs(db, tableConf, joinDef) {
72
+ const { joinSchema, joinField, mainField } = joinDef;
73
+ const mainColName = Array.isArray(mainField) ? mainField[0] : mainField;
74
+ return {
75
+ mainColName,
76
+ mainCol: db.qi(tableConf.Schema.col(mainColName)),
77
+ mainTable: db.qi(tableConf.Schema.tableName),
78
+ joinTable: db.qi(joinSchema.tableName),
79
+ fkCol: db.qi(joinSchema.col(joinField)),
80
+ };
81
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Parsing and validation of ordering, in its three dotted forms:
3
+ * 1-part `field` schema field or computed
4
+ * 2-part `alias.field` a joinLeft parent field, via a real LEFT JOIN
5
+ * 3-part `alias.fn.field` a joinGroup aggregation, via a correlated subquery
6
+ *
7
+ * The request `orderBy` is validated strictly; `defaultOrder` from the table config is
8
+ * deliberately lenient — see `convertConfiguredOrder`.
9
+ */
10
+ import type { QueryClient } from '../../db.js';
11
+ import type { DbTables, ITable, JoinGroupRequest, TenantContext } from '../../../types.js';
12
+ export interface OrderByResult {
13
+ sql: string;
14
+ values: unknown[];
15
+ /** Aliases referenced in 2-part notation (joinLeft) — need a LEFT JOIN. */
16
+ leftJoinAliases: Set<string>;
17
+ }
18
+ /**
19
+ * Pre-scan `orderBy` for the joinLeft aliases referenced in 2-part notation (`<alias>.<field>`).
20
+ * Needed BEFORE building the LEFT JOIN clauses (which must know their aliases) while the actual
21
+ * orderBy SQL — with its parameter placeholders — is only baked later, once the LEFT JOIN value
22
+ * count is known. Validates each alias against `allowedReadJoins` (throws 400), mirroring
23
+ * `validateOrderBy`; 3-part aggregation entries are ignored (they use joinGroup, not a LEFT JOIN).
24
+ */
25
+ export declare function collectOrderByLeftAliases(orderBy: string, tableConf: ITable): Set<string>;
26
+ export declare function validateOrderBy(orderBy: string, tableConf: ITable, db: QueryClient, dbTables: DbTables, joinGroup: Record<string, JoinGroupRequest> | undefined, startIdx: number, tenant?: TenantContext): OrderByResult;
27
+ /**
28
+ * Converts a configured order (`defaultOrder` or the primary-key fallback) to SQL.
29
+ * Unlike the request `orderBy` (strictly validated by `validateOrderBy`), this is
30
+ * lenient for backward compatibility: tokens matching a camelCase schema field are
31
+ * mapped to their quoted DB column, computed fields (without bound values) expand
32
+ * to their expression, and anything else passes through unchanged as raw SQL.
33
+ */
34
+ export declare function convertConfiguredOrder(order: string, tableConf: ITable, db: QueryClient): string;
35
+ //# sourceMappingURL=order-by.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"order-by.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/order-by.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAY/C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAE3F,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,OAAO,EAAE,CAAC;IAClB,2EAA2E;IAC3E,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAC9B;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAazF;AAED,wBAAgB,eAAe,CAC7B,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAAG,SAAS,EACvD,QAAQ,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,aAAa,GACrB,aAAa,CAuDf;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,EAAE,EAAE,WAAW,GACd,MAAM,CAwBR"}
@@ -0,0 +1,104 @@
1
+ import { err400, validateSchemaField, resolveFieldRef, refValues, renderRef, evaluateComputedField, } from './fields.js';
2
+ import { requireJoin } from './joins.js';
3
+ import { hasOwnField, ownField } from '../../read-access.js';
4
+ import { buildAggOrderExpr } from './aggregations.js';
5
+ /**
6
+ * Pre-scan `orderBy` for the joinLeft aliases referenced in 2-part notation (`<alias>.<field>`).
7
+ * Needed BEFORE building the LEFT JOIN clauses (which must know their aliases) while the actual
8
+ * orderBy SQL — with its parameter placeholders — is only baked later, once the LEFT JOIN value
9
+ * count is known. Validates each alias against `allowedReadJoins` (throws 400), mirroring
10
+ * `validateOrderBy`; 3-part aggregation entries are ignored (they use joinGroup, not a LEFT JOIN).
11
+ */
12
+ export function collectOrderByLeftAliases(orderBy, tableConf) {
13
+ const aliases = new Set();
14
+ for (const part of orderBy.split(',')) {
15
+ const trimmed = part.trim();
16
+ if (/^(\w+)\.(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i.test(trimmed))
17
+ continue; // 3-part aggregation
18
+ const m = /^(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i.exec(trimmed);
19
+ if (m) {
20
+ const alias = m[1];
21
+ requireJoin(tableConf, alias, true);
22
+ aliases.add(alias);
23
+ }
24
+ }
25
+ return aliases;
26
+ }
27
+ export function validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, startIdx, tenant) {
28
+ const parts = orderBy.split(',');
29
+ const outParts = [];
30
+ const outValues = [];
31
+ const leftJoinAliases = new Set();
32
+ let currentIdx = startIdx;
33
+ for (const part of parts) {
34
+ const trimmed = part.trim();
35
+ // 3-part: <alias>.<fn>.<field> [ASC|DESC] (aggregation via joinGroup)
36
+ const dotted3 = /^(\w+)\.(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i.exec(trimmed);
37
+ if (dotted3) {
38
+ if (tableConf.distinctResults) {
39
+ err400('Cannot combine distinctResults with aggregation orderBy');
40
+ }
41
+ const [, alias, fn, field, dir] = dotted3;
42
+ const expr = buildAggOrderExpr(db, dbTables, tableConf, alias, fn, field, joinGroup, tenant);
43
+ const aggVals = [...expr.values];
44
+ outParts.push(`${renderRef(expr, currentIdx, db)} ${(dir || 'ASC').toUpperCase()}`);
45
+ outValues.push(...aggVals);
46
+ currentIdx += aggVals.length;
47
+ continue;
48
+ }
49
+ // 2-part: <alias>.<field> [ASC|DESC] (joinLeft inline ordering)
50
+ const dotted2 = /^(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i.exec(trimmed);
51
+ if (dotted2) {
52
+ const [, alias, field, dir] = dotted2;
53
+ const joinDef = requireJoin(tableConf, alias, true);
54
+ const col = validateSchemaField(field, joinDef.joinSchema, dbTables[joinDef.joinSchema.tableName]);
55
+ // Reference the LEFT JOIN'd table via its alias (SQL identifier).
56
+ outParts.push(`${db.qi(alias)}.${db.qi(col)} ${(dir || 'ASC').toUpperCase()}`);
57
+ leftJoinAliases.add(alias);
58
+ continue;
59
+ }
60
+ // 1-part: <field> [ASC|DESC] — schema field or computed
61
+ const plain = /^(\w+)(?:\s+(ASC|DESC))?$/i.exec(trimmed);
62
+ if (!plain) {
63
+ err400(`Invalid orderBy: ${trimmed}`);
64
+ }
65
+ const [, field, dir] = plain;
66
+ const ref = resolveFieldRef(field, tableConf.Schema, tableConf, db);
67
+ // A computed field binds its own values here: ORDER BY is emitted after the WHERE, so
68
+ // its placeholders continue from `currentIdx`.
69
+ const refVals = refValues(ref.expr);
70
+ outParts.push(`${renderRef(ref.expr, currentIdx, db)} ${(dir || 'ASC').toUpperCase()}`);
71
+ outValues.push(...refVals);
72
+ currentIdx += refVals.length;
73
+ }
74
+ return { sql: outParts.join(', '), values: outValues, leftJoinAliases };
75
+ }
76
+ /**
77
+ * Converts a configured order (`defaultOrder` or the primary-key fallback) to SQL.
78
+ * Unlike the request `orderBy` (strictly validated by `validateOrderBy`), this is
79
+ * lenient for backward compatibility: tokens matching a camelCase schema field are
80
+ * mapped to their quoted DB column, computed fields (without bound values) expand
81
+ * to their expression, and anything else passes through unchanged as raw SQL.
82
+ */
83
+ export function convertConfiguredOrder(order, tableConf, db) {
84
+ return order
85
+ .split(',')
86
+ .map((part) => {
87
+ const trimmed = part.trim();
88
+ const match = /^(\w+)(?:\s+(ASC|DESC))?$/i.exec(trimmed);
89
+ if (match) {
90
+ const [, field, dir] = match;
91
+ const suffix = dir ? ` ${dir.toUpperCase()}` : '';
92
+ if (hasOwnField(tableConf.Schema.fields, field)) {
93
+ return `${db.qi(tableConf.Schema.tableName)}.${db.qi(tableConf.Schema.col(field))}${suffix}`;
94
+ }
95
+ const computed = ownField(tableConf.computedFields, field);
96
+ if (computed) {
97
+ const ev = evaluateComputedField(field, computed, tableConf.Schema, db, undefined, `Computed field '${field}' with bound values cannot be used in defaultOrder`);
98
+ return `${ev.value}${suffix}`;
99
+ }
100
+ }
101
+ return trimmed;
102
+ })
103
+ .join(', ');
104
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EAcb,MAAM,mBAAmB,CAAC;AA8lC3B,wBAAsB,YAAY,CAChC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,CAAC,CA4HvB"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EAGb,MAAM,mBAAmB,CAAC;AAwP3B,wBAAsB,YAAY,CAChC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,CAAC,CAsFvB"}