fastify-auto-sqlapi 0.0.4 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/AGENTS.md +15 -2
  2. package/AGENTS_BACKEND.md +160 -32
  3. package/AGENTS_FRONTEND.md +220 -49
  4. package/README.md +147 -27
  5. package/dist/bin/generate-schema.js +12 -29
  6. package/dist/bin/generate-tables.js +10 -33
  7. package/dist/bin/utils.d.ts +29 -1
  8. package/dist/bin/utils.d.ts.map +1 -1
  9. package/dist/bin/utils.js +71 -2
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/lib/cli/load-dependency.d.ts +8 -0
  13. package/dist/lib/cli/load-dependency.d.ts.map +1 -0
  14. package/dist/lib/cli/load-dependency.js +21 -0
  15. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  16. package/dist/lib/cli/mysql-introspect.js +2 -11
  17. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  18. package/dist/lib/cli/pg-introspect.js +2 -17
  19. package/dist/lib/cli/schema-codegen.d.ts +1 -1
  20. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  21. package/dist/lib/cli/schema-codegen.js +11 -4
  22. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  23. package/dist/lib/cli/tables-codegen.js +4 -3
  24. package/dist/lib/db.d.ts +1 -0
  25. package/dist/lib/db.d.ts.map +1 -1
  26. package/dist/lib/db.js +33 -49
  27. package/dist/lib/dialect.d.ts +17 -0
  28. package/dist/lib/dialect.d.ts.map +1 -1
  29. package/dist/lib/dialect.js +32 -0
  30. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  31. package/dist/lib/engine/bulk/bulk-upsert.js +26 -50
  32. package/dist/lib/engine/rest/delete.d.ts.map +1 -1
  33. package/dist/lib/engine/rest/delete.js +5 -10
  34. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  35. package/dist/lib/engine/rest/get.js +4 -6
  36. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  37. package/dist/lib/engine/rest/insert.js +20 -39
  38. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  39. package/dist/lib/engine/rest/update.js +35 -67
  40. package/dist/lib/engine/search/search.d.ts.map +1 -1
  41. package/dist/lib/engine/search/search.js +650 -130
  42. package/dist/lib/engine/write-helpers.d.ts +39 -2
  43. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  44. package/dist/lib/engine/write-helpers.js +58 -15
  45. package/dist/lib/errors.d.ts +11 -0
  46. package/dist/lib/errors.d.ts.map +1 -0
  47. package/dist/lib/errors.js +12 -0
  48. package/dist/lib/naming.d.ts +11 -2
  49. package/dist/lib/naming.d.ts.map +1 -1
  50. package/dist/lib/naming.js +33 -5
  51. package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
  52. package/dist/lib/schema/bulk-upsert.js +9 -25
  53. package/dist/lib/schema/helpers.d.ts +28 -1
  54. package/dist/lib/schema/helpers.d.ts.map +1 -1
  55. package/dist/lib/schema/helpers.js +38 -0
  56. package/dist/lib/schema/insert.d.ts.map +1 -1
  57. package/dist/lib/schema/insert.js +9 -19
  58. package/dist/lib/schema/search.d.ts.map +1 -1
  59. package/dist/lib/schema/search.js +147 -53
  60. package/dist/lib/schema/update.d.ts.map +1 -1
  61. package/dist/lib/schema/update.js +9 -25
  62. package/dist/lib/sql-api.d.ts +6 -6
  63. package/dist/lib/sql-api.d.ts.map +1 -1
  64. package/dist/lib/sql-api.js +8 -8
  65. package/dist/lib/table-helpers.d.ts +6 -1
  66. package/dist/lib/table-helpers.d.ts.map +1 -1
  67. package/dist/lib/table-helpers.js +51 -3
  68. package/dist/lib/tenant.d.ts +23 -0
  69. package/dist/lib/tenant.d.ts.map +1 -1
  70. package/dist/lib/tenant.js +57 -9
  71. package/dist/routes/auto/bulk-delete.routes.d.ts.map +1 -1
  72. package/dist/routes/auto/bulk-delete.routes.js +17 -23
  73. package/dist/routes/auto/bulk-upsert.routes.d.ts.map +1 -1
  74. package/dist/routes/auto/bulk-upsert.routes.js +16 -22
  75. package/dist/routes/auto/delete.routes.d.ts.map +1 -1
  76. package/dist/routes/auto/delete.routes.js +18 -24
  77. package/dist/routes/auto/get.routes.d.ts.map +1 -1
  78. package/dist/routes/auto/get.routes.js +16 -23
  79. package/dist/routes/auto/insert.routes.d.ts.map +1 -1
  80. package/dist/routes/auto/insert.routes.js +19 -26
  81. package/dist/routes/auto/route-helpers.d.ts +27 -2
  82. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  83. package/dist/routes/auto/route-helpers.js +39 -1
  84. package/dist/routes/auto/search.routes.d.ts.map +1 -1
  85. package/dist/routes/auto/search.routes.js +47 -47
  86. package/dist/routes/auto/update.routes.d.ts.map +1 -1
  87. package/dist/routes/auto/update.routes.js +20 -27
  88. package/dist/types/cli.d.ts +22 -0
  89. package/dist/types/cli.d.ts.map +1 -0
  90. package/dist/types/cli.js +1 -0
  91. package/dist/types/computed.d.ts +29 -0
  92. package/dist/types/computed.d.ts.map +1 -0
  93. package/dist/types/computed.js +1 -0
  94. package/dist/types/conditions.d.ts +16 -0
  95. package/dist/types/conditions.d.ts.map +1 -0
  96. package/dist/types/conditions.js +1 -0
  97. package/dist/types/db.d.ts +22 -0
  98. package/dist/types/db.d.ts.map +1 -0
  99. package/dist/types/db.js +1 -0
  100. package/dist/types/dml.d.ts +64 -0
  101. package/dist/types/dml.d.ts.map +1 -0
  102. package/dist/types/dml.js +1 -0
  103. package/dist/types/join.d.ts +44 -0
  104. package/dist/types/join.d.ts.map +1 -0
  105. package/dist/types/join.js +1 -0
  106. package/dist/types/plugin.d.ts +20 -0
  107. package/dist/types/plugin.d.ts.map +1 -0
  108. package/dist/types/plugin.js +1 -0
  109. package/dist/types/schema.d.ts +10 -0
  110. package/dist/types/schema.d.ts.map +1 -0
  111. package/dist/types/schema.js +1 -0
  112. package/dist/types/search.d.ts +45 -0
  113. package/dist/types/search.d.ts.map +1 -0
  114. package/dist/types/search.js +1 -0
  115. package/dist/types/table.d.ts +52 -0
  116. package/dist/types/table.d.ts.map +1 -0
  117. package/dist/types/table.js +7 -0
  118. package/dist/types/tenant.d.ts +19 -0
  119. package/dist/types/tenant.d.ts.map +1 -0
  120. package/dist/types/tenant.js +1 -0
  121. package/dist/types/validation.d.ts +21 -0
  122. package/dist/types/validation.d.ts.map +1 -0
  123. package/dist/types/validation.js +1 -0
  124. package/dist/types.d.ts +12 -258
  125. package/dist/types.d.ts.map +1 -1
  126. package/dist/types.js +12 -7
  127. package/package.json +8 -5
@@ -2,6 +2,7 @@ import { ConditionBuilder } from 'node-condition-builder';
2
2
  import { camelcaseObject } from '../../naming.js';
3
3
  import { buildTenantCondition, buildTenantJoin } from '../../tenant.js';
4
4
  import { primaryAsString } from '../../../types.js';
5
+ // ─── Schema field validation ────────────────────────────────
5
6
  function validateSchemaField(field, schema) {
6
7
  if (!(field in schema.fields)) {
7
8
  const err = new Error(`Unknown field: ${field}`);
@@ -10,28 +11,284 @@ function validateSchemaField(field, schema) {
10
11
  }
11
12
  return schema.col(field);
12
13
  }
13
- function validateOrderBy(orderBy, tableConf, db) {
14
- return orderBy.split(',').map((part) => {
14
+ function err400(msg) {
15
+ const e = new Error(msg);
16
+ e.statusCode = 400;
17
+ throw e;
18
+ }
19
+ // ─── Computed fields resolution ─────────────────────────────
20
+ /**
21
+ * Build a ComputedFieldContext bound to a specific table/schema. `alias`
22
+ * (if provided) qualifies the columns produced by `qiCol` — needed when the
23
+ * computed is embedded in a `LEFT JOIN <table> AS <alias>` (joinLeft).
24
+ */
25
+ function buildComputedContext(db, schema, alias) {
26
+ return {
27
+ db,
28
+ qiCol(field, opts) {
29
+ if (!(field in schema.fields)) {
30
+ err400(`Unknown field referenced by computed expression: ${field}`);
31
+ }
32
+ const col = db.qi(schema.col(field));
33
+ const qualifier = opts?.qualifier ?? alias;
34
+ return qualifier ? `${db.qi(qualifier)}.${col}` : col;
35
+ },
36
+ };
37
+ }
38
+ function resolveFieldRef(field, schema, tableConf, db, alias) {
39
+ if (field in schema.fields) {
40
+ const col = db.qi(schema.col(field));
41
+ const expr = alias ? `${db.qi(alias)}.${col}` : col;
42
+ return { expr, values: [], computed: false };
43
+ }
44
+ const fn = tableConf?.computedFields?.[field];
45
+ if (fn) {
46
+ const ctx = buildComputedContext(db, schema, alias);
47
+ const out = fn(ctx);
48
+ return { expr: out.expr, values: out.values ?? [], computed: true };
49
+ }
50
+ err400(`Unknown field: ${field}`);
51
+ }
52
+ // ─── Join lookup ────────────────────────────────────────────
53
+ function findJoinByAlias(tableConf, alias) {
54
+ return tableConf.allowedReadJoins?.find((j) => j.alias === alias);
55
+ }
56
+ 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
+ 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
+ }
82
+ /**
83
+ * Evaluate a computed field. When `allowBoundValues=false`, throws 400 if the computed binds
84
+ * any parameters — used in positions where parameter binding cannot be coordinated (orderBy,
85
+ * compute*, joinLeft.filters, etc).
86
+ */
87
+ function evaluateComputedField(fn, schema, db, alias, message, allowBoundValues = false) {
88
+ const ev = fn(buildComputedContext(db, schema, alias));
89
+ if (!allowBoundValues && (ev.values?.length ?? 0) > 0) {
90
+ err400(message);
91
+ }
92
+ return ev;
93
+ }
94
+ /** Map rows from snake_case columns to camelCase fields, preserving any computed-field columns
95
+ * that are projected by their declared name (skipped by camelcaseObject). */
96
+ function mapRowsToCamelCase(rows, schema, computedFieldNames) {
97
+ const computed = computedFieldNames ? new Set(computedFieldNames) : null;
98
+ return rows.map((r) => {
99
+ const camel = camelcaseObject(r, schema);
100
+ if (computed) {
101
+ for (const name of computed) {
102
+ if (name in r)
103
+ camel[name] = r[name];
104
+ }
105
+ }
106
+ return camel;
107
+ });
108
+ }
109
+ /** Build the SELECT columns list for a join fetch: '*' or comma-separated quoted columns. */
110
+ function buildSelectionColumns(selection, joinSchema, db) {
111
+ if (selection === '*')
112
+ return '*';
113
+ return selection.split(',').map((c) => db.qi(joinSchema.col(c.trim()))).join(', ');
114
+ }
115
+ const AGG_FN = {
116
+ sum: { sql: 'SUM', distinct: false },
117
+ min: { sql: 'MIN', distinct: false },
118
+ max: { sql: 'MAX', distinct: false },
119
+ avg: { sql: 'AVG', distinct: false },
120
+ count: { sql: 'COUNT', distinct: false },
121
+ distinctCount: { sql: 'COUNT', distinct: true },
122
+ };
123
+ function aggExpr(fn, qualifiedCol) {
124
+ return fn.distinct ? `COUNT(DISTINCT ${qualifiedCol})` : `${fn.sql}(${qualifiedCol})`;
125
+ }
126
+ function buildAggOrderExpr(db, dbTables, tableConf, alias, fn, field, joinGroup, startIdx) {
127
+ const aggFn = AGG_FN[fn];
128
+ if (!aggFn)
129
+ err400(`Invalid aggregation function: ${fn}`);
130
+ const groupReq = joinGroup?.[alias];
131
+ if (!groupReq)
132
+ err400(`orderBy/conditions reference undeclared joinGroup: ${alias}`);
133
+ const declaredFields = groupReq.aggregations[fn];
134
+ if (!Array.isArray(declaredFields) || !declaredFields.includes(field)) {
135
+ err400(`orderBy/conditions reference undeclared aggregation: ${alias}.${fn}.${field}`);
136
+ }
137
+ const joinDef = requireJoin(tableConf, alias, false);
138
+ const { joinSchema, joinField } = joinDef;
139
+ if (groupReq.aggregations.by && groupReq.aggregations.by !== joinField) {
140
+ err400(`Cannot order by aggregation on joinGroup with 'by' clause on non-FK column: ${alias} (grouped by '${groupReq.aggregations.by}', correlation FK is '${joinField}')`);
141
+ }
142
+ const fieldCol = validateSchemaField(field, joinSchema);
143
+ const refs = extractJoinRefs(db, tableConf, joinDef);
144
+ let filterWhere = '';
145
+ let filterVals = [];
146
+ const joinTableConf = dbTables[joinSchema.tableName];
147
+ if (groupReq.filters || groupReq.conditions?.length) {
148
+ const cb = buildJoinRefCondition(joinTableConf, joinSchema, { filters: groupReq.filters, conditions: groupReq.conditions }, db);
149
+ const built = cb.build(startIdx, db.ph);
150
+ if (built) {
151
+ filterWhere = ` AND ${built}`;
152
+ filterVals = cb.getValues();
153
+ }
154
+ }
155
+ const qualifiedCol = `${refs.joinTable}.${db.qi(fieldCol)}`;
156
+ const expr = `COALESCE((SELECT ${aggExpr(aggFn, qualifiedCol)} FROM ${refs.joinTable} WHERE ${refs.joinTable}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol}${filterWhere}), 0)`;
157
+ return { expr, values: filterVals };
158
+ }
159
+ function validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, startIdx) {
160
+ const parts = orderBy.split(',');
161
+ const outParts = [];
162
+ const outValues = [];
163
+ const leftJoinAliases = new Set();
164
+ let currentIdx = startIdx;
165
+ for (const part of parts) {
15
166
  const trimmed = part.trim();
16
- const match = trimmed.match(/^(\w+)(?:\s+(ASC|DESC))?$/i);
17
- if (!match) {
18
- const err = new Error(`Invalid orderBy: ${trimmed}`);
19
- err.statusCode = 400;
20
- throw err;
167
+ // 3-parti: <alias>.<fn>.<field> [ASC|DESC] (aggregation via joinGroup)
168
+ const dotted3 = trimmed.match(/^(\w+)\.(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i);
169
+ if (dotted3) {
170
+ if (tableConf.distinctResults) {
171
+ err400('Cannot combine distinctResults with aggregation orderBy');
172
+ }
173
+ const [, alias, fn, field, dir] = dotted3;
174
+ const { expr, values } = buildAggOrderExpr(db, dbTables, tableConf, alias, fn, field, joinGroup, currentIdx);
175
+ outParts.push(`${expr} ${(dir || 'ASC').toUpperCase()}`);
176
+ outValues.push(...values);
177
+ currentIdx += values.length;
178
+ continue;
179
+ }
180
+ // 2-parti: <alias>.<field> [ASC|DESC] (joinLeft inline ordering)
181
+ const dotted2 = trimmed.match(/^(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i);
182
+ if (dotted2) {
183
+ const [, alias, field, dir] = dotted2;
184
+ const joinDef = requireJoin(tableConf, alias, true);
185
+ const col = validateSchemaField(field, joinDef.joinSchema);
186
+ // Reference the LEFT JOIN'd table via its alias (SQL identifier).
187
+ outParts.push(`${db.qi(alias)}.${db.qi(col)} ${(dir || 'ASC').toUpperCase()}`);
188
+ leftJoinAliases.add(alias);
189
+ continue;
21
190
  }
22
- const [, field, dir] = match;
23
- const col = validateSchemaField(field, tableConf.Schema);
24
- return `${db.qi(col)} ${(dir || 'ASC').toUpperCase()}`;
25
- }).join(', ');
191
+ // 1-parte: <field> [ASC|DESC] — schema field or computed
192
+ const plain = trimmed.match(/^(\w+)(?:\s+(ASC|DESC))?$/i);
193
+ if (!plain) {
194
+ err400(`Invalid orderBy: ${trimmed}`);
195
+ }
196
+ const [, field, dir] = plain;
197
+ const ref = resolveFieldRef(field, tableConf.Schema, tableConf, db);
198
+ outParts.push(`${ref.expr} ${(dir || 'ASC').toUpperCase()}`);
199
+ outValues.push(...ref.values);
200
+ currentIdx += ref.values.length;
201
+ }
202
+ return { sql: outParts.join(', '), values: outValues, leftJoinAliases };
203
+ }
204
+ function buildLeftJoinClauses(db, dbTables, tableConf, aliasesNeedingJoin, joinLeft, startIdx) {
205
+ const joinClauses = [];
206
+ const whereExtras = [];
207
+ const values = [];
208
+ let currentIdx = startIdx;
209
+ for (const alias of aliasesNeedingJoin) {
210
+ const joinDef = requireJoin(tableConf, alias, true);
211
+ const { joinSchema } = joinDef;
212
+ const refs = extractJoinRefs(db, tableConf, joinDef);
213
+ const aliasIdent = db.qi(alias);
214
+ joinClauses.push(`LEFT JOIN ${refs.joinTable} AS ${aliasIdent} ON ${aliasIdent}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol}`);
215
+ const ref = joinLeft?.[alias];
216
+ if (ref && (ref.filters || ref.conditions?.length)) {
217
+ // Build a ConditionBuilder where each column is prefixed with the alias
218
+ // (so SQL references the LEFT JOIN'd table, not a bare table name).
219
+ // Note: extraFilters declared on joinTableConf are not supported here for
220
+ // joinLeft (they would require alias-aware handlers); only schema fields
221
+ // and computed fields apply.
222
+ const cb = new ConditionBuilder('AND');
223
+ const joinTableConf = dbTables[joinSchema.tableName];
224
+ const computed = joinTableConf?.computedFields;
225
+ if (ref.filters) {
226
+ for (const [field, value] of Object.entries(ref.filters)) {
227
+ if (value === null || value === undefined)
228
+ continue;
229
+ if (field in joinSchema.fields) {
230
+ cb.isEqual(`${aliasIdent}.${db.qi(joinSchema.col(field))}`, value);
231
+ }
232
+ else if (computed?.[field]) {
233
+ // Computed field on the parent table: build expr with alias-qualified columns.
234
+ const ev = evaluateComputedField(computed[field], joinSchema, db, alias, `Computed field '${field}' with bound values is not supported in joinLeft.filters`);
235
+ cb.isEqual(ev.expr, value);
236
+ }
237
+ }
238
+ }
239
+ if (ref.conditions?.length) {
240
+ for (const c of ref.conditions) {
241
+ if (!ALLOWED_SET.has(c.method)) {
242
+ err400(`Invalid condition method: ${c.method}`);
243
+ }
244
+ if (c.field in joinSchema.fields) {
245
+ const col = `${aliasIdent}.${db.qi(joinSchema.col(c.field))}`;
246
+ dispatchConditionMethod(cb, c.method, col, c.params ?? []);
247
+ }
248
+ else if (computed?.[c.field]) {
249
+ const ev = evaluateComputedField(computed[c.field], joinSchema, db, alias, `Computed field '${c.field}' with bound values is not supported in joinLeft.conditions`);
250
+ dispatchConditionMethod(cb, c.method, ev.expr, c.params ?? []);
251
+ }
252
+ else {
253
+ err400(`Unknown field: ${c.field}`);
254
+ }
255
+ }
256
+ }
257
+ const sql = cb.build(currentIdx, db.ph);
258
+ const vals = cb.getValues();
259
+ if (sql) {
260
+ whereExtras.push(sql);
261
+ values.push(...vals);
262
+ currentIdx += vals.length;
263
+ }
264
+ }
265
+ }
266
+ return { joinClauses, whereExtras, values };
26
267
  }
27
- async function executeMainQuery(db, tableConf, where, values, orderBy, paginator, extraJoins = []) {
268
+ // ─── Main query execution ───────────────────────────────────
269
+ async function executeMainQuery(db, tableConf, where, values, orderBy, paginator, extraJoins = [], selectComputed) {
28
270
  const tableName = tableConf.Schema.tableName;
29
271
  const order = orderBy || tableConf.defaultOrder || primaryAsString(tableConf.primary);
30
272
  const limit = paginator
31
273
  ? `${paginator.itemsPerPage} OFFSET ${(paginator.page - 1) * paginator.itemsPerPage}`
32
274
  : null;
275
+ // Optional computed projections — bound values, if any, are NOT yet supported
276
+ // here (would require placeholder-aware composition with WHERE values).
277
+ let columns;
278
+ if (selectComputed?.length) {
279
+ const projections = ['*'];
280
+ for (const name of selectComputed) {
281
+ const fn = tableConf.computedFields?.[name];
282
+ if (!fn)
283
+ err400(`Unknown computed field in selectComputed: '${name}'`);
284
+ const out = evaluateComputedField(fn, tableConf.Schema, db, undefined, `Computed field '${name}' with bound values cannot be used in selectComputed`);
285
+ projections.push(`${out.expr} AS ${db.qi(name)}`);
286
+ }
287
+ columns = projections.join(', ');
288
+ }
33
289
  const rows = await db.select({
34
290
  tableName,
291
+ columns,
35
292
  where,
36
293
  values,
37
294
  orderBy: order,
@@ -39,7 +296,7 @@ async function executeMainQuery(db, tableConf, where, values, orderBy, paginator
39
296
  distinct: tableConf.distinctResults,
40
297
  joins: extraJoins.length > 0 ? extraJoins : undefined,
41
298
  });
42
- return rows.map((r) => camelcaseObject(r));
299
+ return mapRowsToCamelCase(rows, tableConf.Schema, selectComputed);
43
300
  }
44
301
  async function buildPagination(db, tableConf, where, values, paginator, extraJoins = [], computeMin, computeMax, computeSum, computeAvg) {
45
302
  const tableName = tableConf.Schema.tableName;
@@ -55,8 +312,11 @@ async function buildPagination(db, tableConf, where, values, paginator, extraJoi
55
312
  ];
56
313
  for (const { key, field, fn } of computations) {
57
314
  if (field) {
58
- const col = validateSchemaField(field, tableConf.Schema);
59
- const result = await db.query(`SELECT ${fn}(${db.qi(col)}) as value FROM ${db.qi(tableName)}${joinClause} WHERE ${where}`, values);
315
+ const ref = resolveFieldRef(field, tableConf.Schema, tableConf, db);
316
+ if (ref.values.length > 0) {
317
+ err400(`Computed field '${field}' with bound values cannot be used in compute${key.charAt(0).toUpperCase() + key.slice(1)}`);
318
+ }
319
+ const result = await db.query(`SELECT ${fn}(${ref.expr}) as value FROM ${db.qi(tableName)}${joinClause} WHERE ${where}`, values);
60
320
  computed[key] = { [field]: result.rows[0].value };
61
321
  }
62
322
  }
@@ -67,104 +327,144 @@ async function buildPagination(db, tableConf, where, values, paginator, extraJoi
67
327
  paginator,
68
328
  };
69
329
  }
70
- function findJoinDefinition(tableConf, joinTableName) {
71
- return tableConf.allowedReadJoins?.find(([joinSchema]) => joinSchema.tableName === joinTableName);
72
- }
73
- async function executeVirtualJoins(db, dbTables, tableConf, mainResults, joins) {
330
+ // ─── joinMultiple (virtual fetch, child rows in side query) ─
331
+ async function executeJoinMultiple(db, dbTables, tableConf, mainResults, joinMultiple) {
74
332
  const result = {};
75
- for (const [joinTableName, joinOpts] of Object.entries(joins)) {
76
- const joinDef = findJoinDefinition(tableConf, joinTableName);
77
- if (!joinDef)
333
+ for (const [alias, ref] of Object.entries(joinMultiple)) {
334
+ const joinDef = requireJoin(tableConf, alias, false);
335
+ const { joinSchema, joinField, mainField, selection: defaultSelection } = joinDef;
336
+ const joinTableConf = dbTables[joinSchema.tableName];
337
+ const ids = collectIds(mainResults, mainField);
338
+ if (ids.length === 0) {
339
+ result[alias] = [];
78
340
  continue;
79
- const [joinSchema, joinField, mainField, selection] = joinDef;
80
- const joinTableConf = dbTables[joinTableName];
81
- // Collect IDs from main results
341
+ }
342
+ const cb = buildJoinRefCondition(joinTableConf, joinSchema, ref || {}, db);
343
+ const fkCol = joinSchema.col(joinField);
344
+ cb.isIn(db.qi(fkCol), ids);
345
+ let where = cb.build(1, db.ph);
346
+ let values = cb.getValues();
347
+ // Side-channel: computed-field clauses on the join table
348
+ const computedClauses = collectJoinRefComputedClauses(joinTableConf, ref || {}, db);
349
+ if (computedClauses.length > 0) {
350
+ ({ where, values } = appendComputedConditions(where, values, computedClauses, db));
351
+ }
352
+ const selection = ref?.selection ?? defaultSelection;
353
+ const columns = buildSelectionColumns(selection, joinSchema, db);
354
+ const rows = await db.select({
355
+ tableName: joinSchema.tableName,
356
+ columns,
357
+ where,
358
+ values,
359
+ });
360
+ result[alias] = mapRowsToCamelCase(rows, joinSchema);
361
+ }
362
+ return result;
363
+ }
364
+ // ─── joinLeft (parent fetch via PK IN side query) ───────────
365
+ async function executeJoinLeft(db, tableConf, mainResults, joinLeft) {
366
+ const result = {};
367
+ for (const [alias, ref] of Object.entries(joinLeft)) {
368
+ const joinDef = requireJoin(tableConf, alias, true);
369
+ const { joinSchema, joinField, mainField, selection: defaultSelection } = joinDef;
370
+ // For joinLeft (N:1), mainField on main is the FK pointing to joinField (PK) on parent.
371
+ // We collect the FK values from the main results and look up parents by their PK.
82
372
  const ids = collectIds(mainResults, mainField);
83
373
  if (ids.length === 0) {
84
- result[joinTableName] = [];
374
+ result[alias] = [];
85
375
  continue;
86
376
  }
87
- // Build join filters
88
- const cb = joinTableConf
89
- ? joinTableConf.filters(joinOpts?.filters || {})
90
- : new ConditionBuilder('AND');
91
377
  const fkCol = joinSchema.col(joinField);
378
+ const cb = new ConditionBuilder('AND');
92
379
  cb.isIn(db.qi(fkCol), ids);
93
380
  const where = cb.build(1, db.ph);
94
381
  const values = cb.getValues();
95
- const columns = selection === '*' ? '*' : selection;
382
+ const selection = ref?.selection ?? defaultSelection;
383
+ const columns = buildSelectionColumns(selection, joinSchema, db);
96
384
  const rows = await db.select({
97
385
  tableName: joinSchema.tableName,
98
386
  columns,
99
387
  where,
100
388
  values,
101
389
  });
102
- result[joinTableName] = rows.map((r) => camelcaseObject(r));
390
+ result[alias] = mapRowsToCamelCase(rows, joinSchema);
103
391
  }
104
392
  return result;
105
393
  }
106
- async function executeJoinGroups(db, dbTables, tableConf, mainResults, joinGroups) {
394
+ /**
395
+ * Resolve `aggregations.by` to a SQL expression. Accepts:
396
+ * - a schema field name → quoted column reference
397
+ * - a computed-field name declared on the join table → its expr (no bound
398
+ * values supported in this position; reject with 400 if the computed
399
+ * returns values)
400
+ */
401
+ function buildByExpression(by, joinSchema, joinTableConf, db) {
402
+ if (typeof by !== 'string') {
403
+ err400(`Invalid 'by' specification: expected a field or computed name`);
404
+ }
405
+ if (by in joinSchema.fields) {
406
+ return db.qi(joinSchema.col(by));
407
+ }
408
+ const fn = joinTableConf?.computedFields?.[by];
409
+ if (fn) {
410
+ return evaluateComputedField(fn, joinSchema, db, undefined, `Computed field '${by}' with bound values cannot be used in aggregations.by`).expr;
411
+ }
412
+ err400(`Unknown field: ${by}`);
413
+ }
414
+ // ─── joinGroup (aggregations) ───────────────────────────────
415
+ async function executeJoinGroup(db, dbTables, tableConf, mainResults, joinGroup) {
107
416
  const result = {};
108
- for (const [joinTableName, groupReq] of Object.entries(joinGroups)) {
109
- const joinDef = findJoinDefinition(tableConf, joinTableName);
110
- if (!joinDef)
111
- continue;
112
- const [joinSchema, joinField, mainField] = joinDef;
417
+ for (const [alias, groupReq] of Object.entries(joinGroup)) {
418
+ const joinDef = requireJoin(tableConf, alias, false);
419
+ const { joinSchema, joinField, mainField } = joinDef;
420
+ const joinTableConf = dbTables[joinSchema.tableName];
113
421
  const ids = collectIds(mainResults, mainField);
114
422
  if (ids.length === 0) {
115
- result[joinTableName] = {};
423
+ result[alias] = {};
116
424
  continue;
117
425
  }
118
- const { aggregations, filters: groupFilters } = groupReq;
426
+ const { aggregations, filters: groupFilters, conditions: groupConditions } = groupReq;
119
427
  const selectParts = [];
120
428
  const groupByParts = [];
121
429
  if (aggregations.by) {
122
- const byCol = validateSchemaField(aggregations.by, joinSchema);
123
- selectParts.push(`${db.qi(byCol)} as "by"`);
124
- groupByParts.push(db.qi(byCol));
125
- }
126
- if (aggregations.distinctCount) {
127
- for (const f of aggregations.distinctCount) {
128
- const col = validateSchemaField(f, joinSchema);
129
- selectParts.push(`COUNT(DISTINCT ${db.qi(col)}) as "distinctCount_${f}"`);
130
- }
131
- }
132
- if (aggregations.min) {
133
- for (const f of aggregations.min) {
134
- const col = validateSchemaField(f, joinSchema);
135
- selectParts.push(`MIN(${db.qi(col)}) as "min_${f}"`);
136
- }
137
- }
138
- if (aggregations.max) {
139
- for (const f of aggregations.max) {
140
- const col = validateSchemaField(f, joinSchema);
141
- selectParts.push(`MAX(${db.qi(col)}) as "max_${f}"`);
142
- }
430
+ const byExpr = buildByExpression(aggregations.by, joinSchema, joinTableConf, db);
431
+ selectParts.push(`${byExpr} as "by"`);
432
+ groupByParts.push(byExpr);
143
433
  }
144
- if (aggregations.sum) {
145
- for (const f of aggregations.sum) {
434
+ const addAgg = (kind, fields) => {
435
+ if (!fields)
436
+ return;
437
+ const fn = AGG_FN[kind];
438
+ for (const f of fields) {
146
439
  const col = validateSchemaField(f, joinSchema);
147
- selectParts.push(`SUM(${db.qi(col)}) as "sum_${f}"`);
440
+ selectParts.push(`${aggExpr(fn, db.qi(col))} as "${kind}_${f}"`);
148
441
  }
149
- }
442
+ };
443
+ addAgg('distinctCount', aggregations.distinctCount);
444
+ addAgg('min', aggregations.min);
445
+ addAgg('max', aggregations.max);
446
+ addAgg('sum', aggregations.sum);
447
+ addAgg('avg', aggregations.avg);
448
+ addAgg('count', aggregations.count);
150
449
  if (selectParts.length === 0) {
151
- result[joinTableName] = {};
450
+ result[alias] = {};
152
451
  continue;
153
452
  }
154
- // Build WHERE clause
155
- const joinTableConf = dbTables[joinTableName];
156
- const cb = joinTableConf && groupFilters
157
- ? joinTableConf.filters(groupFilters)
158
- : new ConditionBuilder('AND');
453
+ const groupRef = { filters: groupFilters, conditions: groupConditions };
454
+ const cb = buildJoinRefCondition(joinTableConf, joinSchema, groupRef, db);
159
455
  const fkCol = joinSchema.col(joinField);
160
456
  cb.isIn(db.qi(fkCol), ids);
161
- const where = cb.build(1, db.ph);
162
- const values = cb.getValues();
457
+ let where = cb.build(1, db.ph);
458
+ let values = cb.getValues();
459
+ // Side-channel: computed-field clauses on the join table
460
+ const computedClauses = collectJoinRefComputedClauses(joinTableConf, groupRef, db);
461
+ if (computedClauses.length > 0) {
462
+ ({ where, values } = appendComputedConditions(where, values, computedClauses, db));
463
+ }
163
464
  const groupBy = groupByParts.length > 0 ? `GROUP BY ${groupByParts.join(', ')}` : '';
164
465
  const sql = `SELECT ${selectParts.join(', ')} FROM ${db.qi(joinSchema.tableName)} WHERE ${where} ${groupBy}`;
165
466
  const queryResult = await db.query(sql, values);
166
467
  const rows = queryResult.rows;
167
- // Format: { distinctCount: {field: value}, min: {field: value}, ... }
168
468
  const formatted = {};
169
469
  if (rows.length > 0) {
170
470
  const row = rows.length === 1 && !aggregations.by ? rows[0] : rows;
@@ -182,13 +482,13 @@ async function executeJoinGroups(db, dbTables, tableConf, mainResults, joinGroup
182
482
  }
183
483
  }
184
484
  }
185
- result[joinTableName] = formatted;
485
+ result[alias] = formatted;
186
486
  }
187
487
  return result;
188
488
  }
489
+ // ─── ID collection ──────────────────────────────────────────
189
490
  function collectIds(mainResults, mainField) {
190
491
  if (Array.isArray(mainField)) {
191
- // Composite key: collect tuples
192
492
  const seen = new Set();
193
493
  const ids = [];
194
494
  for (const r of mainResults) {
@@ -200,68 +500,242 @@ function collectIds(mainResults, mainField) {
200
500
  }
201
501
  return ids;
202
502
  }
203
- const unique = [...new Set(mainResults.map((r) => r[mainField]).filter((v) => v != null))];
204
- return unique;
503
+ return [...new Set(mainResults.map((r) => r[mainField]).filter((v) => v != null))];
205
504
  }
206
505
  // ─── Conditions (advanced filters) ──────────────────────────
207
506
  import { ALLOWED_SET, SINGLE_VALUE_SET, BETWEEN_SET, IN_SET, NULL_SET, } from '../../condition-methods.js';
208
- function applyConditions(condition, conditions, schema, db) {
507
+ function dispatchConditionMethod(cb, method, colOrExpr, params) {
508
+ if (SINGLE_VALUE_SET.has(method)) {
509
+ cb[method](colOrExpr, params[0]);
510
+ }
511
+ else if (BETWEEN_SET.has(method)) {
512
+ cb[method](colOrExpr, params[0], params[1]);
513
+ }
514
+ else if (IN_SET.has(method)) {
515
+ cb[method](colOrExpr, params[0]);
516
+ }
517
+ else if (NULL_SET.has(method)) {
518
+ cb[method](colOrExpr, true);
519
+ }
520
+ }
521
+ function buildJoinRefCondition(joinTableConf, joinSchema, ref, db) {
522
+ const cb = (ref.filters && joinTableConf)
523
+ ? joinTableConf.filters(ref.filters)
524
+ : new ConditionBuilder('AND');
525
+ if (ref.conditions?.length) {
526
+ for (const c of ref.conditions) {
527
+ if (!ALLOWED_SET.has(c.method)) {
528
+ err400(`Invalid condition method: ${c.method}`);
529
+ }
530
+ // Skip computed-field conditions here — they are processed via side-channel
531
+ // by `collectJoinRefComputedClauses` since ConditionBuilder cannot bind values
532
+ // for an LHS expression.
533
+ if (joinTableConf?.computedFields?.[c.field])
534
+ continue;
535
+ const col = db.qi(validateSchemaField(c.field, joinSchema));
536
+ dispatchConditionMethod(cb, c.method, col, c.params ?? []);
537
+ }
538
+ }
539
+ return cb;
540
+ }
541
+ /**
542
+ * Side-channel: gather equality + operator clauses targeting computed fields
543
+ * declared on the join table's `computedFields`. To be appended to a WHERE
544
+ * after the main `cb.build()` with correct placeholder offsets.
545
+ */
546
+ function collectJoinRefComputedClauses(joinTableConf, ref, db) {
547
+ const out = [];
548
+ const computed = joinTableConf?.computedFields;
549
+ if (!computed)
550
+ return out;
551
+ // Equality filters on computed names
552
+ if (ref.filters) {
553
+ for (const [name, value] of Object.entries(ref.filters)) {
554
+ if (value === null || value === undefined)
555
+ continue;
556
+ if (!computed[name])
557
+ continue;
558
+ const ev = evaluateComputedField(computed[name], joinTableConf.Schema, db, undefined, '', true);
559
+ out.push({ method: 'isEqual', expr: ev.expr, values: ev.values ?? [], params: [value] });
560
+ }
561
+ }
562
+ // Operator conditions on computed names
563
+ if (ref.conditions?.length) {
564
+ for (const c of ref.conditions) {
565
+ if (!computed[c.field])
566
+ continue;
567
+ if (!ALLOWED_SET.has(c.method))
568
+ err400(`Invalid condition method: ${c.method}`);
569
+ const ev = evaluateComputedField(computed[c.field], joinTableConf.Schema, db, undefined, '', true);
570
+ out.push({
571
+ method: c.method,
572
+ expr: ev.expr,
573
+ values: ev.values ?? [],
574
+ params: c.params ?? [],
575
+ });
576
+ }
577
+ }
578
+ return out;
579
+ }
580
+ /**
581
+ * Apply non-dotted conditions to the main `condition` ConditionBuilder when
582
+ * possible. For schema fields → straight dispatch. For computed fields → return
583
+ * side-channel clauses to be appended to WHERE later (with correct placeholder
584
+ * offsets), since ConditionBuilder cannot bind values for an LHS expression.
585
+ */
586
+ function applyConditions(condition, conditions, schema, tableConf, db) {
587
+ const computedClauses = [];
209
588
  for (const c of conditions) {
210
- // Validate method — whitelist only, blocks prototype poisoning
589
+ // Skip dot-notation fields — those become aggregation conditions processed later
590
+ if (c.field.includes('.'))
591
+ continue;
211
592
  if (!ALLOWED_SET.has(c.method)) {
212
- const err = new Error(`Invalid condition method: ${c.method}`);
213
- err.statusCode = 400;
214
- throw err;
593
+ err400(`Invalid condition method: ${c.method}`);
215
594
  }
216
- const col = db.qi(validateSchemaField(c.field, schema));
217
- if (SINGLE_VALUE_SET.has(c.method)) {
218
- condition[c.method](col, c.params[0]);
595
+ const ref = resolveFieldRef(c.field, schema, tableConf, db);
596
+ if (!ref.computed) {
597
+ dispatchConditionMethod(condition, c.method, ref.expr, c.params);
219
598
  }
220
- else if (BETWEEN_SET.has(c.method)) {
221
- condition[c.method](col, c.params[0], c.params[1]);
599
+ else {
600
+ computedClauses.push({
601
+ method: c.method,
602
+ expr: ref.expr,
603
+ values: ref.values,
604
+ params: c.params ?? [],
605
+ });
222
606
  }
223
- else if (IN_SET.has(c.method)) {
224
- condition[c.method](col, c.params[0]);
607
+ }
608
+ return computedClauses;
609
+ }
610
+ /**
611
+ * Equality filters targeting computed fields: when `filters.<computedName>` is
612
+ * set on the request body and the computed exists on `tableConf.computedFields`,
613
+ * generate a side-channel clause `<expr> = ?` with the user value as parameter.
614
+ * This complements the auto-generated equality on schema fields done by
615
+ * `tableConf.filters()`.
616
+ */
617
+ function collectComputedFilterClauses(filters, tableConf, db) {
618
+ const out = [];
619
+ const computed = tableConf.computedFields;
620
+ if (!computed)
621
+ return out;
622
+ for (const [name, value] of Object.entries(filters)) {
623
+ if (value === null || value === undefined)
624
+ continue;
625
+ if (!computed[name])
626
+ continue;
627
+ const ref = evaluateComputedField(computed[name], tableConf.Schema, db, undefined, '', true);
628
+ out.push({ method: 'isEqual', expr: ref.expr, values: ref.values ?? [], params: [value] });
629
+ }
630
+ return out;
631
+ }
632
+ /**
633
+ * Append computed-field condition clauses (side-channel from applyConditions)
634
+ * to the current WHERE, coordinating placeholder indices with the running
635
+ * `values` array.
636
+ */
637
+ function appendComputedConditions(currentWhere, currentValues, clauses, db) {
638
+ let where = currentWhere;
639
+ let values = [...currentValues];
640
+ for (const c of clauses) {
641
+ // Append the computed's own bound values first; the expr placeholders
642
+ // (if any) reference them via stable indices set by the consumer.
643
+ values = [...values, ...c.values];
644
+ const tmpCb = new ConditionBuilder('AND');
645
+ dispatchConditionMethod(tmpCb, c.method, c.expr, c.params);
646
+ const startIdx = values.length + 1;
647
+ const clauseSql = tmpCb.build(startIdx, db.ph);
648
+ where += ` AND ${clauseSql}`;
649
+ values.push(...tmpCb.getValues());
650
+ }
651
+ return { where, values };
652
+ }
653
+ function appendAggConditions(currentWhere, currentValues, conditions, db, dbTables, tableConf, joinGroup) {
654
+ let where = currentWhere;
655
+ let values = [...currentValues];
656
+ for (const c of conditions) {
657
+ if (!c.field.includes('.'))
658
+ continue;
659
+ if (!ALLOWED_SET.has(c.method)) {
660
+ err400(`Invalid condition method: ${c.method}`);
225
661
  }
226
- else if (NULL_SET.has(c.method)) {
227
- condition[c.method](col, true);
662
+ const parts = c.field.split('.');
663
+ if (parts.length !== 3) {
664
+ err400(`Invalid dotted field in condition: ${c.field} (expected <alias>.<fn>.<field>)`);
228
665
  }
666
+ const [alias, fn, field] = parts;
667
+ const { expr, values: exprValues } = buildAggOrderExpr(db, dbTables, tableConf, alias, fn, field, joinGroup, values.length + 1);
668
+ values = [...values, ...exprValues];
669
+ const tmpCb = new ConditionBuilder('AND');
670
+ dispatchConditionMethod(tmpCb, c.method, expr, c.params);
671
+ const startIdx = values.length + 1;
672
+ const clause = tmpCb.build(startIdx, db.ph);
673
+ where += ` AND ${clause}`;
674
+ values.push(...tmpCb.getValues());
229
675
  }
676
+ return { where, values };
230
677
  }
231
- // ─── Join filters (EXISTS subquery) ─────────────────────────
232
- function buildJoinFiltersExists(db, dbTables, tableConf, joinFilters, currentWhere, currentValues) {
678
+ // ─── joinMustExist (EXISTS subquery filter on main) ─────────
679
+ function buildJoinMustExistClauses(db, dbTables, tableConf, joinMustExist, currentWhere, currentValues) {
233
680
  let where = currentWhere;
234
681
  const values = [...currentValues];
235
- for (const [joinTableName, filterValues] of Object.entries(joinFilters)) {
236
- const joinDef = findJoinDefinition(tableConf, joinTableName);
237
- if (!joinDef)
238
- continue;
239
- const [joinSchema, joinField, mainField] = joinDef;
240
- const joinTableConf = dbTables[joinTableName];
241
- if (!joinTableConf)
242
- continue;
243
- const filterCondition = joinTableConf.filters(filterValues);
682
+ for (const [alias, ref] of Object.entries(joinMustExist)) {
683
+ const joinDef = requireJoin(tableConf, alias, false);
684
+ const { joinSchema } = joinDef;
685
+ const joinTableConf = dbTables[joinSchema.tableName];
686
+ const refs = extractJoinRefs(db, tableConf, joinDef);
687
+ const filterCondition = buildJoinRefCondition(joinTableConf, joinSchema, ref, db);
244
688
  const startIdx = values.length + 1;
245
- const filterWhere = filterCondition.build(startIdx, db.ph);
246
- const filterVals = filterCondition.getValues();
247
- const fkCol = db.qi(joinSchema.col(joinField));
248
- const mainColName = Array.isArray(mainField) ? mainField[0] : mainField;
249
- const mainCol = db.qi(tableConf.Schema.col(mainColName));
250
- const mainTable = db.qi(tableConf.Schema.tableName);
251
- where += ` AND EXISTS (SELECT 1 FROM ${db.qi(joinSchema.tableName)} WHERE ${fkCol} = ${mainTable}.${mainCol} AND ${filterWhere})`;
689
+ let filterWhere = filterCondition.build(startIdx, db.ph);
690
+ let filterVals = [...filterCondition.getValues()];
691
+ // Side-channel: computed-field clauses
692
+ const computedClauses = collectJoinRefComputedClauses(joinTableConf, ref, db);
693
+ if (computedClauses.length > 0) {
694
+ let cwhere = filterWhere;
695
+ let cvalues = filterVals;
696
+ ({ where: cwhere, values: cvalues } = appendComputedConditions(cwhere, [...values, ...cvalues], computedClauses, db));
697
+ // appendComputedConditions starts with currentValues; we need just the
698
+ // delta belonging to this EXISTS subquery to update filterVals.
699
+ filterVals = cvalues.slice(values.length);
700
+ filterWhere = cwhere;
701
+ }
702
+ const innerWhere = filterWhere
703
+ ? `${refs.fkCol} = ${refs.mainTable}.${refs.mainCol} AND ${filterWhere}`
704
+ : `${refs.fkCol} = ${refs.mainTable}.${refs.mainCol}`;
705
+ where += ` AND EXISTS (SELECT 1 FROM ${refs.joinTable} WHERE ${innerWhere})`;
252
706
  values.push(...filterVals);
253
707
  }
254
708
  return { where, values };
255
709
  }
710
+ // ─── searchEngine entrypoint ────────────────────────────────
256
711
  export async function searchEngine(dbTables, params) {
257
- const { db, tableConf, filters, conditions, joinFilters, joins, joinGroups, orderBy, paginator, computeMin, computeMax, computeSum, computeAvg, tenant } = params;
712
+ const { db, tableConf, filters, conditions, joinMustExist, joinMultiple, joinGroup, joinLeft, orderBy, paginator, computeMin, computeMax, computeSum, computeAvg, tenant, } = params;
713
+ // Validate aliases used as keys against allowedReadJoins + unique flag (early 400s)
714
+ if (joinMustExist)
715
+ for (const a of Object.keys(joinMustExist))
716
+ requireJoin(tableConf, a, false);
717
+ if (joinMultiple)
718
+ for (const a of Object.keys(joinMultiple))
719
+ requireJoin(tableConf, a, false);
720
+ if (joinGroup)
721
+ for (const a of Object.keys(joinGroup))
722
+ requireJoin(tableConf, a, false);
723
+ if (joinLeft)
724
+ for (const a of Object.keys(joinLeft))
725
+ requireJoin(tableConf, a, true);
258
726
  // Build main condition
259
727
  const condition = tableConf.filters(filters || {});
260
- // Advanced conditions
728
+ // Filters that target computed fields (declared on tableConf.computedFields)
729
+ // are applied via the side-channel pattern further down.
730
+ let computedFilterClauses = [];
731
+ if (filters && tableConf.computedFields) {
732
+ computedFilterClauses = collectComputedFilterClauses(filters, tableConf, db);
733
+ }
734
+ let computedConditionClauses = [];
261
735
  if (conditions?.length) {
262
- applyConditions(condition, conditions, tableConf.Schema, db);
736
+ computedConditionClauses = applyConditions(condition, conditions, tableConf.Schema, tableConf, db);
263
737
  }
264
- // Tenant filtering
738
+ // Tenant
265
739
  const tenantJoins = [];
266
740
  if (tenant) {
267
741
  condition.append(buildTenantCondition(db, tenant.scope, tenant.ids));
@@ -271,27 +745,73 @@ export async function searchEngine(dbTables, params) {
271
745
  }
272
746
  let where = condition.build(1, db.ph);
273
747
  let values = [...condition.getValues()];
274
- // Join filters: add EXISTS subqueries
275
- if (joinFilters && Object.keys(joinFilters).length > 0) {
276
- ({ where, values } = buildJoinFiltersExists(db, dbTables, tableConf, joinFilters, where, values));
277
- }
278
- // Validate and sanitize orderBy (user input -> SQL identifier)
279
- const safeOrderBy = orderBy ? validateOrderBy(orderBy, tableConf, db) : undefined;
280
- // Main query
281
- const main = await executeMainQuery(db, tableConf, where, values, safeOrderBy, paginator, tenantJoins);
282
- // Pagination
748
+ // Append computed filter (equality) and condition (operator) clauses with
749
+ // correct placeholder offsets.
750
+ if (computedFilterClauses.length > 0) {
751
+ ({ where, values } = appendComputedConditions(where, values, computedFilterClauses, db));
752
+ }
753
+ if (computedConditionClauses.length > 0) {
754
+ ({ where, values } = appendComputedConditions(where, values, computedConditionClauses, db));
755
+ }
756
+ // joinMustExist (EXISTS)
757
+ if (joinMustExist && Object.keys(joinMustExist).length > 0) {
758
+ ({ where, values } = buildJoinMustExistClauses(db, dbTables, tableConf, joinMustExist, where, values));
759
+ }
760
+ // Aggregation conditions (HAVING-style)
761
+ if (conditions?.length) {
762
+ const hasAggConditions = conditions.some((c) => c.field.includes('.'));
763
+ if (hasAggConditions) {
764
+ ({ where, values } = appendAggConditions(where, values, conditions, db, dbTables, tableConf, joinGroup));
765
+ }
766
+ }
767
+ // orderBy parsing — collects 2-parti aliases that need LEFT JOIN on the main query
768
+ let safeOrderBy;
769
+ let orderByValues = [];
770
+ let orderByLeftAliases = new Set();
771
+ if (orderBy) {
772
+ const obResult = validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, values.length + 1);
773
+ safeOrderBy = obResult.sql;
774
+ orderByValues = obResult.values;
775
+ orderByLeftAliases = obResult.leftJoinAliases;
776
+ }
777
+ // Determine which joinLeft aliases need a LEFT JOIN on the main query:
778
+ // - any alias used in 2-parti orderBy
779
+ // - any alias in joinLeft body that has filters or conditions on the parent
780
+ const aliasesNeedingLeftJoin = new Set(orderByLeftAliases);
781
+ if (joinLeft) {
782
+ for (const [alias, ref] of Object.entries(joinLeft)) {
783
+ if (ref?.filters || ref?.conditions?.length)
784
+ aliasesNeedingLeftJoin.add(alias);
785
+ }
786
+ }
787
+ // Build LEFT JOIN clauses + extra WHERE for filtered parents
788
+ const extraJoinClauses = [...tenantJoins];
789
+ let leftJoinValues = [];
790
+ if (aliasesNeedingLeftJoin.size > 0) {
791
+ const lj = buildLeftJoinClauses(db, dbTables, tableConf, aliasesNeedingLeftJoin, joinLeft, values.length + 1);
792
+ extraJoinClauses.push(...lj.joinClauses);
793
+ leftJoinValues = lj.values;
794
+ for (const w of lj.whereExtras)
795
+ where += ` AND ${w}`;
796
+ }
797
+ // Bind WHERE values + LEFT JOIN values + orderBy aggregation values for main query.
798
+ // Pagination COUNT/compute include WHERE + LEFT JOIN values (no orderBy).
799
+ const whereAndJoinValues = [...values, ...leftJoinValues];
800
+ const mainValues = [...whereAndJoinValues, ...orderByValues];
801
+ const main = await executeMainQuery(db, tableConf, where, mainValues, safeOrderBy, paginator, extraJoinClauses, params.selectComputed);
283
802
  let pagination;
284
803
  if (paginator) {
285
- pagination = await buildPagination(db, tableConf, where, values, paginator, tenantJoins, computeMin, computeMax, computeSum, computeAvg);
804
+ pagination = await buildPagination(db, tableConf, where, whereAndJoinValues, paginator, extraJoinClauses, computeMin, computeMax, computeSum, computeAvg);
286
805
  }
287
- // Virtual joins (only if requested)
288
806
  const result = { main };
289
- if (joins && Object.keys(joins).length > 0) {
290
- result.joins = await executeVirtualJoins(db, dbTables, tableConf, main, joins);
807
+ if (joinMultiple && Object.keys(joinMultiple).length > 0) {
808
+ result.joinMultiple = await executeJoinMultiple(db, dbTables, tableConf, main, joinMultiple);
809
+ }
810
+ if (joinLeft && Object.keys(joinLeft).length > 0) {
811
+ result.joinLeft = await executeJoinLeft(db, tableConf, main, joinLeft);
291
812
  }
292
- // Join groups (only if requested)
293
- if (joinGroups && Object.keys(joinGroups).length > 0) {
294
- result.joinGroups = await executeJoinGroups(db, dbTables, tableConf, main, joinGroups);
813
+ if (joinGroup && Object.keys(joinGroup).length > 0) {
814
+ result.joinGroup = await executeJoinGroup(db, dbTables, tableConf, main, joinGroup);
295
815
  }
296
816
  if (pagination) {
297
817
  result.pagination = pagination;