fastify-auto-sqlapi 0.0.5 → 0.1.2

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 (123) hide show
  1. package/AGENTS.md +15 -2
  2. package/AGENTS_BACKEND.md +144 -20
  3. package/AGENTS_FRONTEND.md +161 -67
  4. package/README.md +47 -26
  5. package/dist/bin/generate-schema.js +9 -26
  6. package/dist/bin/generate-tables.js +7 -30
  7. package/dist/bin/utils.d.ts +28 -0
  8. package/dist/bin/utils.d.ts.map +1 -1
  9. package/dist/bin/utils.js +69 -0
  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 -18
  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/db.d.ts +1 -0
  20. package/dist/lib/db.d.ts.map +1 -1
  21. package/dist/lib/db.js +33 -55
  22. package/dist/lib/dialect.d.ts +17 -0
  23. package/dist/lib/dialect.d.ts.map +1 -1
  24. package/dist/lib/dialect.js +32 -0
  25. package/dist/lib/engine/bulk/bulk-delete.d.ts.map +1 -1
  26. package/dist/lib/engine/bulk/bulk-delete.js +8 -2
  27. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  28. package/dist/lib/engine/bulk/bulk-upsert.js +15 -43
  29. package/dist/lib/engine/rest/delete.d.ts.map +1 -1
  30. package/dist/lib/engine/rest/delete.js +14 -12
  31. package/dist/lib/engine/rest/get.d.ts.map +1 -1
  32. package/dist/lib/engine/rest/get.js +3 -5
  33. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  34. package/dist/lib/engine/rest/insert.js +11 -31
  35. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  36. package/dist/lib/engine/rest/update.js +13 -46
  37. package/dist/lib/engine/search/search.d.ts.map +1 -1
  38. package/dist/lib/engine/search/search.js +553 -229
  39. package/dist/lib/engine/write-helpers.d.ts +39 -2
  40. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  41. package/dist/lib/engine/write-helpers.js +55 -12
  42. package/dist/lib/errors.d.ts +11 -0
  43. package/dist/lib/errors.d.ts.map +1 -0
  44. package/dist/lib/errors.js +12 -0
  45. package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
  46. package/dist/lib/schema/bulk-upsert.js +9 -25
  47. package/dist/lib/schema/helpers.d.ts +28 -1
  48. package/dist/lib/schema/helpers.d.ts.map +1 -1
  49. package/dist/lib/schema/helpers.js +38 -0
  50. package/dist/lib/schema/insert.d.ts.map +1 -1
  51. package/dist/lib/schema/insert.js +9 -19
  52. package/dist/lib/schema/search.d.ts.map +1 -1
  53. package/dist/lib/schema/search.js +138 -50
  54. package/dist/lib/schema/update.d.ts.map +1 -1
  55. package/dist/lib/schema/update.js +9 -25
  56. package/dist/lib/sql-api.d.ts +6 -6
  57. package/dist/lib/sql-api.d.ts.map +1 -1
  58. package/dist/lib/sql-api.js +10 -10
  59. package/dist/lib/table-helpers.d.ts +6 -1
  60. package/dist/lib/table-helpers.d.ts.map +1 -1
  61. package/dist/lib/table-helpers.js +42 -2
  62. package/dist/lib/tenant.d.ts +30 -0
  63. package/dist/lib/tenant.d.ts.map +1 -1
  64. package/dist/lib/tenant.js +78 -9
  65. package/dist/routes/auto/bulk-delete.routes.d.ts.map +1 -1
  66. package/dist/routes/auto/bulk-delete.routes.js +17 -23
  67. package/dist/routes/auto/bulk-upsert.routes.d.ts.map +1 -1
  68. package/dist/routes/auto/bulk-upsert.routes.js +16 -22
  69. package/dist/routes/auto/delete.routes.d.ts.map +1 -1
  70. package/dist/routes/auto/delete.routes.js +18 -24
  71. package/dist/routes/auto/get.routes.d.ts.map +1 -1
  72. package/dist/routes/auto/get.routes.js +16 -23
  73. package/dist/routes/auto/insert.routes.d.ts.map +1 -1
  74. package/dist/routes/auto/insert.routes.js +19 -26
  75. package/dist/routes/auto/plugin.d.ts.map +1 -1
  76. package/dist/routes/auto/plugin.js +40 -5
  77. package/dist/routes/auto/route-helpers.d.ts +27 -2
  78. package/dist/routes/auto/route-helpers.d.ts.map +1 -1
  79. package/dist/routes/auto/route-helpers.js +39 -1
  80. package/dist/routes/auto/search.routes.d.ts.map +1 -1
  81. package/dist/routes/auto/search.routes.js +47 -47
  82. package/dist/routes/auto/update.routes.d.ts.map +1 -1
  83. package/dist/routes/auto/update.routes.js +20 -27
  84. package/dist/types/cli.d.ts +22 -0
  85. package/dist/types/cli.d.ts.map +1 -0
  86. package/dist/types/cli.js +1 -0
  87. package/dist/types/computed.d.ts +29 -0
  88. package/dist/types/computed.d.ts.map +1 -0
  89. package/dist/types/computed.js +1 -0
  90. package/dist/types/conditions.d.ts +16 -0
  91. package/dist/types/conditions.d.ts.map +1 -0
  92. package/dist/types/conditions.js +1 -0
  93. package/dist/types/db.d.ts +22 -0
  94. package/dist/types/db.d.ts.map +1 -0
  95. package/dist/types/db.js +1 -0
  96. package/dist/types/dml.d.ts +66 -0
  97. package/dist/types/dml.d.ts.map +1 -0
  98. package/dist/types/dml.js +1 -0
  99. package/dist/types/join.d.ts +44 -0
  100. package/dist/types/join.d.ts.map +1 -0
  101. package/dist/types/join.js +1 -0
  102. package/dist/types/plugin.d.ts +20 -0
  103. package/dist/types/plugin.d.ts.map +1 -0
  104. package/dist/types/plugin.js +1 -0
  105. package/dist/types/schema.d.ts +10 -0
  106. package/dist/types/schema.d.ts.map +1 -0
  107. package/dist/types/schema.js +1 -0
  108. package/dist/types/search.d.ts +45 -0
  109. package/dist/types/search.d.ts.map +1 -0
  110. package/dist/types/search.js +1 -0
  111. package/dist/types/table.d.ts +76 -0
  112. package/dist/types/table.d.ts.map +1 -0
  113. package/dist/types/table.js +7 -0
  114. package/dist/types/tenant.d.ts +19 -0
  115. package/dist/types/tenant.d.ts.map +1 -0
  116. package/dist/types/tenant.js +1 -0
  117. package/dist/types/validation.d.ts +21 -0
  118. package/dist/types/validation.d.ts.map +1 -0
  119. package/dist/types/validation.js +1 -0
  120. package/dist/types.d.ts +12 -277
  121. package/dist/types.d.ts.map +1 -1
  122. package/dist/types.js +12 -7
  123. package/package.json +3 -3
@@ -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,59 +11,140 @@ function validateSchemaField(field, schema) {
10
11
  }
11
12
  return schema.col(field);
12
13
  }
13
- const AGG_FN_SQL = {
14
- sum: 'SUM',
15
- min: 'MIN',
16
- max: 'MAX',
17
- avg: 'AVG',
18
- count: 'COUNT',
19
- distinctCount: 'COUNT DISTINCT', // marker, handled specially
20
- };
21
- function buildAggOrderExpr(db, dbTables, tableConf, joinTableName, fn, field, joinGroups, startIdx) {
22
- const err400 = (msg) => {
23
- const e = new Error(msg);
24
- e.statusCode = 400;
25
- throw e;
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
+ },
26
36
  };
27
- // Whitelist fn
28
- if (!(fn in AGG_FN_SQL)) {
29
- err400(`Invalid aggregation function: ${fn}`);
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 };
30
43
  }
31
- // Require joinGroups declaration for the referenced table + fn + field
32
- const groupReq = joinGroups?.[joinTableName];
33
- if (!groupReq) {
34
- err400(`orderBy references undeclared joinGroup: ${joinTableName}`);
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 };
35
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}`);
36
133
  const declaredFields = groupReq.aggregations[fn];
37
134
  if (!Array.isArray(declaredFields) || !declaredFields.includes(field)) {
38
- err400(`orderBy references undeclared aggregation: ${joinTableName}.${fn}.${field}`);
39
- }
40
- // Resolve join definition
41
- const joinDef = tableConf.allowedReadJoins?.find(([js]) => js.tableName === joinTableName);
42
- if (!joinDef) {
43
- err400(`Unknown join table in orderBy: ${joinTableName}`);
135
+ err400(`orderBy/conditions reference undeclared aggregation: ${alias}.${fn}.${field}`);
44
136
  }
45
- const [joinSchema, joinField, mainField] = joinDef;
46
- // If joinGroup has `by`, only accept it when `by` is the correlation FK (joinField).
47
- // In that case each main row corresponds to exactly one group, so the scalar
48
- // subquery is semantically equivalent to no-by. Any other `by` produces multiple
49
- // groups per main row → not a single scalar, reject.
137
+ const joinDef = requireJoin(tableConf, alias, false);
138
+ const { joinSchema, joinField } = joinDef;
50
139
  if (groupReq.aggregations.by && groupReq.aggregations.by !== joinField) {
51
- err400(`Cannot order by aggregation on joinGroup with 'by' clause on non-FK column: ${joinTableName} (grouped by '${groupReq.aggregations.by}', correlation FK is '${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}')`);
52
141
  }
53
- // Validate field exists on join schema
54
142
  const fieldCol = validateSchemaField(field, joinSchema);
55
- // Build correlation: joinTable.fk = mainTable.main_pk
56
- const mainColName = Array.isArray(mainField) ? mainField[0] : mainField;
57
- const mainCol = db.qi(tableConf.Schema.col(mainColName));
58
- const mainTable = db.qi(tableConf.Schema.tableName);
59
- const joinTable = db.qi(joinSchema.tableName);
60
- const fkCol = db.qi(joinSchema.col(joinField));
61
- // Rebuild joinGroup filters + conditions with correct placeholder offset
143
+ const refs = extractJoinRefs(db, tableConf, joinDef);
62
144
  let filterWhere = '';
63
145
  let filterVals = [];
64
- const joinTableConf = dbTables[joinTableName];
65
- if ((groupReq.filters || groupReq.conditions?.length) && joinSchema) {
146
+ const joinTableConf = dbTables[joinSchema.tableName];
147
+ if (groupReq.filters || groupReq.conditions?.length) {
66
148
  const cb = buildJoinRefCondition(joinTableConf, joinSchema, { filters: groupReq.filters, conditions: groupReq.conditions }, db);
67
149
  const built = cb.build(startIdx, db.ph);
68
150
  if (built) {
@@ -70,61 +152,143 @@ function buildAggOrderExpr(db, dbTables, tableConf, joinTableName, fn, field, jo
70
152
  filterVals = cb.getValues();
71
153
  }
72
154
  }
73
- // Generate SQL expression
74
- const qField = db.qi(fieldCol);
75
- const fnSql = AGG_FN_SQL[fn];
76
- const aggExpr = fnSql === 'COUNT DISTINCT'
77
- ? `COUNT(DISTINCT ${joinTable}.${qField})`
78
- : `${fnSql}(${joinTable}.${qField})`;
79
- // COALESCE with 0 so main rows without matching joined records get a sortable
80
- // numeric value instead of NULL. Works on all dialects (PG/MySQL/MariaDB) and
81
- // keeps "no data" at the bottom on DESC (top-N queries), and at the top on ASC.
82
- const expr = `COALESCE((SELECT ${aggExpr} FROM ${joinTable} WHERE ${joinTable}.${fkCol} = ${mainTable}.${mainCol}${filterWhere}), 0)`;
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)`;
83
157
  return { expr, values: filterVals };
84
158
  }
85
- function validateOrderBy(orderBy, tableConf, db, dbTables, joinGroups, startIdx) {
159
+ function validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, startIdx) {
86
160
  const parts = orderBy.split(',');
87
161
  const outParts = [];
88
162
  const outValues = [];
163
+ const leftJoinAliases = new Set();
89
164
  let currentIdx = startIdx;
90
165
  for (const part of parts) {
91
166
  const trimmed = part.trim();
92
- // Dotted notation: <joinTable>.<fn>.<field> [ASC|DESC]
93
- const dottedMatch = trimmed.match(/^(\w+)\.(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i);
94
- if (dottedMatch) {
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) {
95
170
  if (tableConf.distinctResults) {
96
- const err = new Error('Cannot combine distinctResults with aggregation orderBy');
97
- err.statusCode = 400;
98
- throw err;
171
+ err400('Cannot combine distinctResults with aggregation orderBy');
99
172
  }
100
- const [, joinTableName, fn, field, dir] = dottedMatch;
101
- const { expr, values } = buildAggOrderExpr(db, dbTables, tableConf, joinTableName, fn, field, joinGroups, currentIdx);
173
+ const [, alias, fn, field, dir] = dotted3;
174
+ const { expr, values } = buildAggOrderExpr(db, dbTables, tableConf, alias, fn, field, joinGroup, currentIdx);
102
175
  outParts.push(`${expr} ${(dir || 'ASC').toUpperCase()}`);
103
176
  outValues.push(...values);
104
177
  currentIdx += values.length;
105
178
  continue;
106
179
  }
107
- // Plain field: <field> [ASC|DESC]
108
- const plainMatch = trimmed.match(/^(\w+)(?:\s+(ASC|DESC))?$/i);
109
- if (!plainMatch) {
110
- const err = new Error(`Invalid orderBy: ${trimmed}`);
111
- err.statusCode = 400;
112
- throw err;
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;
190
+ }
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
+ }
113
264
  }
114
- const [, field, dir] = plainMatch;
115
- const col = validateSchemaField(field, tableConf.Schema);
116
- outParts.push(`${db.qi(col)} ${(dir || 'ASC').toUpperCase()}`);
117
265
  }
118
- return { sql: outParts.join(', '), values: outValues };
266
+ return { joinClauses, whereExtras, values };
119
267
  }
120
- 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) {
121
270
  const tableName = tableConf.Schema.tableName;
122
271
  const order = orderBy || tableConf.defaultOrder || primaryAsString(tableConf.primary);
123
272
  const limit = paginator
124
273
  ? `${paginator.itemsPerPage} OFFSET ${(paginator.page - 1) * paginator.itemsPerPage}`
125
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
+ }
126
289
  const rows = await db.select({
127
290
  tableName,
291
+ columns,
128
292
  where,
129
293
  values,
130
294
  orderBy: order,
@@ -132,7 +296,7 @@ async function executeMainQuery(db, tableConf, where, values, orderBy, paginator
132
296
  distinct: tableConf.distinctResults,
133
297
  joins: extraJoins.length > 0 ? extraJoins : undefined,
134
298
  });
135
- return rows.map((r) => camelcaseObject(r, tableConf.Schema));
299
+ return mapRowsToCamelCase(rows, tableConf.Schema, selectComputed);
136
300
  }
137
301
  async function buildPagination(db, tableConf, where, values, paginator, extraJoins = [], computeMin, computeMax, computeSum, computeAvg) {
138
302
  const tableName = tableConf.Schema.tableName;
@@ -148,8 +312,11 @@ async function buildPagination(db, tableConf, where, values, paginator, extraJoi
148
312
  ];
149
313
  for (const { key, field, fn } of computations) {
150
314
  if (field) {
151
- const col = validateSchemaField(field, tableConf.Schema);
152
- 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);
153
320
  computed[key] = { [field]: result.rows[0].value };
154
321
  }
155
322
  }
@@ -160,119 +327,144 @@ async function buildPagination(db, tableConf, where, values, paginator, extraJoi
160
327
  paginator,
161
328
  };
162
329
  }
163
- function findJoinDefinition(tableConf, joinTableName) {
164
- return tableConf.allowedReadJoins?.find(([joinSchema]) => joinSchema.tableName === joinTableName);
165
- }
166
- 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) {
167
332
  const result = {};
168
- for (const [joinTableName, joinRef] of Object.entries(joins)) {
169
- const joinDef = findJoinDefinition(tableConf, joinTableName);
170
- 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] = [];
171
340
  continue;
172
- const [joinSchema, joinField, mainField, selection] = joinDef;
173
- const joinTableConf = dbTables[joinTableName];
174
- // 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.
175
372
  const ids = collectIds(mainResults, mainField);
176
373
  if (ids.length === 0) {
177
- result[joinTableName] = [];
374
+ result[alias] = [];
178
375
  continue;
179
376
  }
180
- // Build join filters (equality) + rich conditions
181
- const cb = buildJoinRefCondition(joinTableConf, joinSchema, joinRef || {}, db);
182
377
  const fkCol = joinSchema.col(joinField);
378
+ const cb = new ConditionBuilder('AND');
183
379
  cb.isIn(db.qi(fkCol), ids);
184
380
  const where = cb.build(1, db.ph);
185
381
  const values = cb.getValues();
186
- // selection is a comma-separated list of field names (camelCase API names).
187
- // Resolve each to its DB column via schema.col() and quote it.
188
- const columns = selection === '*'
189
- ? '*'
190
- : selection.split(',').map((c) => {
191
- const field = c.trim();
192
- return db.qi(joinSchema.col(field));
193
- }).join(', ');
382
+ const selection = ref?.selection ?? defaultSelection;
383
+ const columns = buildSelectionColumns(selection, joinSchema, db);
194
384
  const rows = await db.select({
195
385
  tableName: joinSchema.tableName,
196
386
  columns,
197
387
  where,
198
388
  values,
199
389
  });
200
- result[joinTableName] = rows.map((r) => camelcaseObject(r, joinSchema));
390
+ result[alias] = mapRowsToCamelCase(rows, joinSchema);
201
391
  }
202
392
  return result;
203
393
  }
204
- 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) {
205
416
  const result = {};
206
- for (const [joinTableName, groupReq] of Object.entries(joinGroups)) {
207
- const joinDef = findJoinDefinition(tableConf, joinTableName);
208
- if (!joinDef)
209
- continue;
210
- 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];
211
421
  const ids = collectIds(mainResults, mainField);
212
422
  if (ids.length === 0) {
213
- result[joinTableName] = {};
423
+ result[alias] = {};
214
424
  continue;
215
425
  }
216
426
  const { aggregations, filters: groupFilters, conditions: groupConditions } = groupReq;
217
427
  const selectParts = [];
218
428
  const groupByParts = [];
219
429
  if (aggregations.by) {
220
- const byCol = validateSchemaField(aggregations.by, joinSchema);
221
- selectParts.push(`${db.qi(byCol)} as "by"`);
222
- groupByParts.push(db.qi(byCol));
223
- }
224
- if (aggregations.distinctCount) {
225
- for (const f of aggregations.distinctCount) {
226
- const col = validateSchemaField(f, joinSchema);
227
- selectParts.push(`COUNT(DISTINCT ${db.qi(col)}) as "distinctCount_${f}"`);
228
- }
229
- }
230
- if (aggregations.min) {
231
- for (const f of aggregations.min) {
232
- const col = validateSchemaField(f, joinSchema);
233
- selectParts.push(`MIN(${db.qi(col)}) as "min_${f}"`);
234
- }
235
- }
236
- if (aggregations.max) {
237
- for (const f of aggregations.max) {
238
- const col = validateSchemaField(f, joinSchema);
239
- selectParts.push(`MAX(${db.qi(col)}) as "max_${f}"`);
240
- }
430
+ const byExpr = buildByExpression(aggregations.by, joinSchema, joinTableConf, db);
431
+ selectParts.push(`${byExpr} as "by"`);
432
+ groupByParts.push(byExpr);
241
433
  }
242
- if (aggregations.sum) {
243
- 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) {
244
439
  const col = validateSchemaField(f, joinSchema);
245
- selectParts.push(`SUM(${db.qi(col)}) as "sum_${f}"`);
440
+ selectParts.push(`${aggExpr(fn, db.qi(col))} as "${kind}_${f}"`);
246
441
  }
247
- }
248
- if (aggregations.avg) {
249
- for (const f of aggregations.avg) {
250
- const col = validateSchemaField(f, joinSchema);
251
- selectParts.push(`AVG(${db.qi(col)}) as "avg_${f}"`);
252
- }
253
- }
254
- if (aggregations.count) {
255
- for (const f of aggregations.count) {
256
- const col = validateSchemaField(f, joinSchema);
257
- selectParts.push(`COUNT(${db.qi(col)}) as "count_${f}"`);
258
- }
259
- }
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);
260
449
  if (selectParts.length === 0) {
261
- result[joinTableName] = {};
450
+ result[alias] = {};
262
451
  continue;
263
452
  }
264
- // Build WHERE clause: equality filters + rich conditions
265
- const joinTableConf = dbTables[joinTableName];
266
- const cb = buildJoinRefCondition(joinTableConf, joinSchema, { filters: groupFilters, conditions: groupConditions }, db);
453
+ const groupRef = { filters: groupFilters, conditions: groupConditions };
454
+ const cb = buildJoinRefCondition(joinTableConf, joinSchema, groupRef, db);
267
455
  const fkCol = joinSchema.col(joinField);
268
456
  cb.isIn(db.qi(fkCol), ids);
269
- const where = cb.build(1, db.ph);
270
- 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
+ }
271
464
  const groupBy = groupByParts.length > 0 ? `GROUP BY ${groupByParts.join(', ')}` : '';
272
465
  const sql = `SELECT ${selectParts.join(', ')} FROM ${db.qi(joinSchema.tableName)} WHERE ${where} ${groupBy}`;
273
466
  const queryResult = await db.query(sql, values);
274
467
  const rows = queryResult.rows;
275
- // Format: { distinctCount: {field: value}, min: {field: value}, ... }
276
468
  const formatted = {};
277
469
  if (rows.length > 0) {
278
470
  const row = rows.length === 1 && !aggregations.by ? rows[0] : rows;
@@ -290,13 +482,13 @@ async function executeJoinGroups(db, dbTables, tableConf, mainResults, joinGroup
290
482
  }
291
483
  }
292
484
  }
293
- result[joinTableName] = formatted;
485
+ result[alias] = formatted;
294
486
  }
295
487
  return result;
296
488
  }
489
+ // ─── ID collection ──────────────────────────────────────────
297
490
  function collectIds(mainResults, mainField) {
298
491
  if (Array.isArray(mainField)) {
299
- // Composite key: collect tuples
300
492
  const seen = new Set();
301
493
  const ids = [];
302
494
  for (const r of mainResults) {
@@ -308,8 +500,7 @@ function collectIds(mainResults, mainField) {
308
500
  }
309
501
  return ids;
310
502
  }
311
- const unique = [...new Set(mainResults.map((r) => r[mainField]).filter((v) => v != null))];
312
- return unique;
503
+ return [...new Set(mainResults.map((r) => r[mainField]).filter((v) => v != null))];
313
504
  }
314
505
  // ─── Conditions (advanced filters) ──────────────────────────
315
506
  import { ALLOWED_SET, SINGLE_VALUE_SET, BETWEEN_SET, IN_SET, NULL_SET, } from '../../condition-methods.js';
@@ -327,11 +518,6 @@ function dispatchConditionMethod(cb, method, colOrExpr, params) {
327
518
  cb[method](colOrExpr, true);
328
519
  }
329
520
  }
330
- /**
331
- * Build a ConditionBuilder that applies both equality filters and rich conditions
332
- * to a join schema. Used by joinFilters (EXISTS), virtual joins data fetching,
333
- * and joinGroups (aggregation WHERE + orderBy scalar subquery).
334
- */
335
521
  function buildJoinRefCondition(joinTableConf, joinSchema, ref, db) {
336
522
  const cb = (ref.filters && joinTableConf)
337
523
  ? joinTableConf.filters(ref.filters)
@@ -339,63 +525,147 @@ function buildJoinRefCondition(joinTableConf, joinSchema, ref, db) {
339
525
  if (ref.conditions?.length) {
340
526
  for (const c of ref.conditions) {
341
527
  if (!ALLOWED_SET.has(c.method)) {
342
- const err = new Error(`Invalid condition method: ${c.method}`);
343
- err.statusCode = 400;
344
- throw err;
528
+ err400(`Invalid condition method: ${c.method}`);
345
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;
346
535
  const col = db.qi(validateSchemaField(c.field, joinSchema));
347
536
  dispatchConditionMethod(cb, c.method, col, c.params ?? []);
348
537
  }
349
538
  }
350
539
  return cb;
351
540
  }
352
- function applyConditions(condition, conditions, schema, db) {
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 = [];
353
588
  for (const c of conditions) {
354
589
  // Skip dot-notation fields — those become aggregation conditions processed later
355
590
  if (c.field.includes('.'))
356
591
  continue;
357
- // Validate method — whitelist only, blocks prototype poisoning
358
592
  if (!ALLOWED_SET.has(c.method)) {
359
- const err = new Error(`Invalid condition method: ${c.method}`);
360
- err.statusCode = 400;
361
- throw err;
593
+ err400(`Invalid condition method: ${c.method}`);
594
+ }
595
+ const ref = resolveFieldRef(c.field, schema, tableConf, db);
596
+ if (!ref.computed) {
597
+ dispatchConditionMethod(condition, c.method, ref.expr, c.params);
362
598
  }
363
- const col = db.qi(validateSchemaField(c.field, schema));
364
- dispatchConditionMethod(condition, c.method, col, c.params);
599
+ else {
600
+ computedClauses.push({
601
+ method: c.method,
602
+ expr: ref.expr,
603
+ values: ref.values,
604
+ params: c.params ?? [],
605
+ });
606
+ }
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] });
365
629
  }
630
+ return out;
366
631
  }
367
632
  /**
368
- * Append aggregation-based conditions (HAVING-style) to the main WHERE clause.
369
- * For each condition with a dotted field like `session.count.id`, builds a
370
- * correlated scalar subquery (reusing buildAggOrderExpr) and uses it as the
371
- * left-hand side of a ConditionBuilder method.
372
- *
373
- * Runs after the base WHERE / joinFilters are built so placeholder offsets
374
- * are correct.
633
+ * Append computed-field condition clauses (side-channel from applyConditions)
634
+ * to the current WHERE, coordinating placeholder indices with the running
635
+ * `values` array.
375
636
  */
376
- function appendAggConditions(currentWhere, currentValues, conditions, db, dbTables, tableConf, joinGroups) {
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) {
377
654
  let where = currentWhere;
378
655
  let values = [...currentValues];
379
656
  for (const c of conditions) {
380
657
  if (!c.field.includes('.'))
381
658
  continue;
382
659
  if (!ALLOWED_SET.has(c.method)) {
383
- const err = new Error(`Invalid condition method: ${c.method}`);
384
- err.statusCode = 400;
385
- throw err;
660
+ err400(`Invalid condition method: ${c.method}`);
386
661
  }
387
662
  const parts = c.field.split('.');
388
663
  if (parts.length !== 3) {
389
- const err = new Error(`Invalid dotted field in condition: ${c.field} (expected <joinTable>.<fn>.<field>)`);
390
- err.statusCode = 400;
391
- throw err;
664
+ err400(`Invalid dotted field in condition: ${c.field} (expected <alias>.<fn>.<field>)`);
392
665
  }
393
- const [joinTableName, fn, field] = parts;
394
- // Build the scalar subquery; values for its filters are appended to the main values array.
395
- const { expr, values: exprValues } = buildAggOrderExpr(db, dbTables, tableConf, joinTableName, fn, field, joinGroups, values.length + 1);
666
+ const [alias, fn, field] = parts;
667
+ const { expr, values: exprValues } = buildAggOrderExpr(db, dbTables, tableConf, alias, fn, field, joinGroup, values.length + 1);
396
668
  values = [...values, ...exprValues];
397
- // Use a temporary ConditionBuilder to generate "<expr> <op> <placeholder>" with
398
- // the correct placeholder offset, then merge it back into the main values.
399
669
  const tmpCb = new ConditionBuilder('AND');
400
670
  dispatchConditionMethod(tmpCb, c.method, expr, c.params);
401
671
  const startIdx = values.length + 1;
@@ -405,42 +675,67 @@ function appendAggConditions(currentWhere, currentValues, conditions, db, dbTabl
405
675
  }
406
676
  return { where, values };
407
677
  }
408
- // ─── Join filters (EXISTS subquery) ─────────────────────────
409
- function buildJoinFiltersExists(db, dbTables, tableConf, joinFilters, currentWhere, currentValues) {
678
+ // ─── joinMustExist (EXISTS subquery filter on main) ─────────
679
+ function buildJoinMustExistClauses(db, dbTables, tableConf, joinMustExist, currentWhere, currentValues) {
410
680
  let where = currentWhere;
411
681
  const values = [...currentValues];
412
- for (const [joinTableName, ref] of Object.entries(joinFilters)) {
413
- const joinDef = findJoinDefinition(tableConf, joinTableName);
414
- if (!joinDef)
415
- continue;
416
- const [joinSchema, joinField, mainField] = joinDef;
417
- const joinTableConf = dbTables[joinTableName];
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);
418
687
  const filterCondition = buildJoinRefCondition(joinTableConf, joinSchema, ref, db);
419
688
  const startIdx = values.length + 1;
420
- const filterWhere = filterCondition.build(startIdx, db.ph);
421
- const filterVals = filterCondition.getValues();
422
- const fkCol = db.qi(joinSchema.col(joinField));
423
- const mainColName = Array.isArray(mainField) ? mainField[0] : mainField;
424
- const mainCol = db.qi(tableConf.Schema.col(mainColName));
425
- const mainTable = db.qi(tableConf.Schema.tableName);
426
- // If there are no inner conditions, just the correlation suffices.
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
+ }
427
702
  const innerWhere = filterWhere
428
- ? `${fkCol} = ${mainTable}.${mainCol} AND ${filterWhere}`
429
- : `${fkCol} = ${mainTable}.${mainCol}`;
430
- where += ` AND EXISTS (SELECT 1 FROM ${db.qi(joinSchema.tableName)} WHERE ${innerWhere})`;
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})`;
431
706
  values.push(...filterVals);
432
707
  }
433
708
  return { where, values };
434
709
  }
710
+ // ─── searchEngine entrypoint ────────────────────────────────
435
711
  export async function searchEngine(dbTables, params) {
436
- 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);
437
726
  // Build main condition
438
727
  const condition = tableConf.filters(filters || {});
439
- // 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 = [];
440
735
  if (conditions?.length) {
441
- applyConditions(condition, conditions, tableConf.Schema, db);
736
+ computedConditionClauses = applyConditions(condition, conditions, tableConf.Schema, tableConf, db);
442
737
  }
443
- // Tenant filtering
738
+ // Tenant
444
739
  const tenantJoins = [];
445
740
  if (tenant) {
446
741
  condition.append(buildTenantCondition(db, tenant.scope, tenant.ids));
@@ -450,44 +745,73 @@ export async function searchEngine(dbTables, params) {
450
745
  }
451
746
  let where = condition.build(1, db.ph);
452
747
  let values = [...condition.getValues()];
453
- // Join filters: add EXISTS subqueries
454
- if (joinFilters && Object.keys(joinFilters).length > 0) {
455
- ({ where, values } = buildJoinFiltersExists(db, dbTables, tableConf, joinFilters, where, values));
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));
456
759
  }
457
- // Aggregation conditions (HAVING-style): dotted fields in `conditions` are
458
- // translated into scalar subqueries using the joinGroup aggregation.
760
+ // Aggregation conditions (HAVING-style)
459
761
  if (conditions?.length) {
460
762
  const hasAggConditions = conditions.some((c) => c.field.includes('.'));
461
763
  if (hasAggConditions) {
462
- ({ where, values } = appendAggConditions(where, values, conditions, db, dbTables, tableConf, joinGroups));
764
+ ({ where, values } = appendAggConditions(where, values, conditions, db, dbTables, tableConf, joinGroup));
463
765
  }
464
766
  }
465
- // Validate and sanitize orderBy (user input -> SQL identifier)
466
- // Note: orderBy aggregation values are kept separate from WHERE values,
467
- // so that buildPagination (which doesn't use ORDER BY) only binds WHERE params.
767
+ // orderBy parsing — collects 2-parti aliases that need LEFT JOIN on the main query
468
768
  let safeOrderBy;
469
769
  let orderByValues = [];
770
+ let orderByLeftAliases = new Set();
470
771
  if (orderBy) {
471
- const obResult = validateOrderBy(orderBy, tableConf, db, dbTables, joinGroups, values.length + 1);
772
+ const obResult = validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, values.length + 1);
472
773
  safeOrderBy = obResult.sql;
473
774
  orderByValues = obResult.values;
775
+ orderByLeftAliases = obResult.leftJoinAliases;
474
776
  }
475
- // Main query: bind WHERE values + orderBy aggregation values
476
- const mainValues = [...values, ...orderByValues];
477
- const main = await executeMainQuery(db, tableConf, where, mainValues, safeOrderBy, paginator, tenantJoins);
478
- // Pagination: bind only WHERE values (COUNT/compute queries don't use ORDER BY)
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);
479
802
  let pagination;
480
803
  if (paginator) {
481
- 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);
482
805
  }
483
- // Virtual joins (only if requested)
484
806
  const result = { main };
485
- if (joins && Object.keys(joins).length > 0) {
486
- 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);
487
812
  }
488
- // Join groups (only if requested)
489
- if (joinGroups && Object.keys(joinGroups).length > 0) {
490
- 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);
491
815
  }
492
816
  if (pagination) {
493
817
  result.pagination = pagination;