fastify-auto-sqlapi 0.0.3 → 0.0.5

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 (54) hide show
  1. package/AGENTS.md +2 -2
  2. package/AGENTS_BACKEND.md +111 -15
  3. package/AGENTS_FRONTEND.md +126 -1
  4. package/README.md +135 -18
  5. package/dist/bin/generate-schema.js +3 -3
  6. package/dist/bin/generate-tables.js +1 -1
  7. package/dist/bin/utils.d.ts +1 -1
  8. package/dist/bin/utils.d.ts.map +1 -1
  9. package/dist/bin/utils.js +2 -2
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  13. package/dist/lib/cli/mysql-introspect.js +9 -2
  14. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  15. package/dist/lib/cli/pg-introspect.js +8 -1
  16. package/dist/lib/cli/schema-codegen.d.ts +1 -1
  17. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  18. package/dist/lib/cli/schema-codegen.js +11 -4
  19. package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
  20. package/dist/lib/cli/tables-codegen.js +30 -1
  21. package/dist/lib/db.d.ts.map +1 -1
  22. package/dist/lib/db.js +10 -4
  23. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  24. package/dist/lib/engine/bulk/bulk-upsert.js +41 -27
  25. package/dist/lib/engine/rest/get.js +1 -1
  26. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  27. package/dist/lib/engine/rest/insert.js +25 -21
  28. package/dist/lib/engine/rest/update.d.ts.map +1 -1
  29. package/dist/lib/engine/rest/update.js +42 -38
  30. package/dist/lib/engine/search/search.d.ts.map +1 -1
  31. package/dist/lib/engine/search/search.js +235 -39
  32. package/dist/lib/engine/validate.d.ts +11 -0
  33. package/dist/lib/engine/validate.d.ts.map +1 -0
  34. package/dist/lib/engine/validate.js +29 -0
  35. package/dist/lib/engine/write-helpers.js +4 -4
  36. package/dist/lib/naming.d.ts +11 -2
  37. package/dist/lib/naming.d.ts.map +1 -1
  38. package/dist/lib/naming.js +33 -5
  39. package/dist/lib/schema/bulk-upsert.js +2 -2
  40. package/dist/lib/schema/helpers.d.ts +5 -0
  41. package/dist/lib/schema/helpers.d.ts.map +1 -1
  42. package/dist/lib/schema/helpers.js +15 -0
  43. package/dist/lib/schema/insert.js +2 -2
  44. package/dist/lib/schema/search.d.ts.map +1 -1
  45. package/dist/lib/schema/search.js +16 -10
  46. package/dist/lib/schema/update.d.ts.map +1 -1
  47. package/dist/lib/schema/update.js +4 -3
  48. package/dist/lib/table-helpers.d.ts.map +1 -1
  49. package/dist/lib/table-helpers.js +9 -1
  50. package/dist/routes/auto/plugin.d.ts.map +1 -1
  51. package/dist/routes/auto/plugin.js +12 -1
  52. package/dist/types.d.ts +47 -8
  53. package/dist/types.d.ts.map +1 -1
  54. package/package.json +8 -8
@@ -1,53 +1,58 @@
1
- import { camelcaseObject, snakecaseRecord } from '../../naming.js';
1
+ import { snakecaseRecord } from '../../naming.js';
2
2
  import { processSecondaries, processDeletions } from '../write-helpers.js';
3
3
  import { stripTenantColumn, buildTenantCondition, buildTenantJoin } from '../../tenant.js';
4
+ import { runValidation } from '../validate.js';
4
5
  import { ConditionBuilder } from 'node-condition-builder';
5
6
  import { primaryAsString } from '../../../types.js';
6
7
  export async function updateEngine(params) {
7
8
  const { db, tableConf, dbTables, request, record, secondaries, deletions, tenant } = params;
8
- // 1. Prepare: extract PK, snakecase, build update fields
9
+ const schema = tableConf.Schema;
9
10
  const pk = primaryAsString(tableConf.primary);
10
- const pkCol = tableConf.Schema.col(pk);
11
- const snaked = snakecaseRecord(record);
12
- const pkValue = snaked[pkCol];
11
+ const pkCol = schema.col(pk);
12
+ const pkValue = record[pk];
13
13
  if (pkValue == null) {
14
14
  const error = new Error(`Primary key "${pk}" is required`);
15
15
  error.statusCode = 400;
16
16
  throw error;
17
17
  }
18
+ // 1. Tenant ownership check (indirect) — do it early so user mutations don't run on records they can't access
19
+ if (tenant && 'through' in tenant.scope) {
20
+ const scope = tenant.scope;
21
+ const tableName = schema.tableName;
22
+ const cb = new ConditionBuilder('AND');
23
+ cb.isEqual(`${db.qi(tableName)}.${db.qi(pkCol)}`, pkValue);
24
+ cb.append(buildTenantCondition(db, scope, tenant.ids));
25
+ const checkWhere = cb.build(1, db.ph);
26
+ const checkValues = cb.getValues();
27
+ const joinSql = buildTenantJoin(db, scope, tableName);
28
+ const checkSql = `SELECT 1 FROM ${db.qi(tableName)} ${joinSql} WHERE ${checkWhere} LIMIT 1`;
29
+ const checkResult = await db.query(checkSql, checkValues);
30
+ if (checkResult.rows.length === 0) {
31
+ const error = new Error('Record not found');
32
+ error.statusCode = 404;
33
+ throw error;
34
+ }
35
+ }
36
+ // 2. Mutable copy of input in camelCase
37
+ const inputRecord = { ...record };
38
+ // 3. Custom validation (camelCase)
39
+ await runValidation(db, request, tableConf, inputRecord, secondaries);
40
+ // 4. beforeUpdate hook (camelCase — user can mutate with schema field names)
41
+ if (tableConf.beforeUpdate) {
42
+ await tableConf.beforeUpdate(db, request, inputRecord);
43
+ }
44
+ // 5. Convert to DB format (after all user mutations)
45
+ const snaked = snakecaseRecord(inputRecord, schema);
18
46
  const updateFields = { ...snaked };
19
47
  delete updateFields[pkCol];
20
- // 1b. Tenant: strip tenant column from update fields + validate ownership
48
+ // 6. Strip tenant column (user cannot change tenant of an existing record)
21
49
  if (tenant) {
22
50
  stripTenantColumn(updateFields, tenant.scope);
23
- if ('through' in tenant.scope) {
24
- // Indirect: verify the record belongs to tenant via subquery
25
- const scope = tenant.scope;
26
- const tableName = tableConf.Schema.tableName;
27
- const cb = new ConditionBuilder('AND');
28
- cb.isEqual(`${db.qi(tableName)}.${db.qi(pkCol)}`, pkValue);
29
- cb.append(buildTenantCondition(db, scope, tenant.ids));
30
- const checkWhere = cb.build(1, db.ph);
31
- const checkValues = cb.getValues();
32
- const joinSql = buildTenantJoin(db, scope, tableName);
33
- const checkSql = `SELECT 1 FROM ${db.qi(tableName)} ${joinSql} WHERE ${checkWhere} LIMIT 1`;
34
- const checkResult = await db.query(checkSql, checkValues);
35
- if (checkResult.rows.length === 0) {
36
- const error = new Error('Record not found');
37
- error.statusCode = 404;
38
- throw error;
39
- }
40
- }
41
- }
42
- // 2. beforeUpdate hook
43
- if (tableConf.beforeUpdate) {
44
- await tableConf.beforeUpdate(db, request, updateFields);
45
51
  }
46
- // 3. Update main
52
+ // 7. Update main
47
53
  const hasFieldsToUpdate = Object.keys(updateFields).length > 0;
48
54
  let extraCondition;
49
55
  if (tenant && !('through' in tenant.scope)) {
50
- // Direct: add tenant to WHERE
51
56
  extraCondition = new ConditionBuilder('AND');
52
57
  if (tenant.ids.length === 1) {
53
58
  extraCondition.isEqual(tenant.scope.column, tenant.ids[0]);
@@ -57,7 +62,7 @@ export async function updateEngine(params) {
57
62
  }
58
63
  }
59
64
  if (hasFieldsToUpdate) {
60
- const affectedRows = await db.update(tableConf.Schema.tableName, updateFields, { [pkCol]: pkValue }, extraCondition);
65
+ const affectedRows = await db.update(schema.tableName, updateFields, { [pkCol]: pkValue }, extraCondition);
61
66
  if (affectedRows === 0) {
62
67
  const error = new Error(`Record not found`);
63
68
  error.statusCode = 404;
@@ -66,14 +71,14 @@ export async function updateEngine(params) {
66
71
  }
67
72
  else {
68
73
  // No fields to update: verify the record exists (for secondaries/deletions)
69
- let whereSql = `${db.qi(tableConf.Schema.tableName)}.${db.qi(pkCol)} = ${db.ph(1)}`;
74
+ let whereSql = `${db.qi(schema.tableName)}.${db.qi(pkCol)} = ${db.ph(1)}`;
70
75
  const whereValues = [pkValue];
71
76
  if (extraCondition) {
72
77
  whereSql += ` AND ${extraCondition.build(2, db.ph)}`;
73
78
  whereValues.push(...extraCondition.getValues());
74
79
  }
75
80
  const rows = await db.select({
76
- tableName: tableConf.Schema.tableName,
81
+ tableName: schema.tableName,
77
82
  where: whereSql,
78
83
  values: whereValues,
79
84
  limit: '1',
@@ -86,19 +91,18 @@ export async function updateEngine(params) {
86
91
  }
87
92
  // Build the main response (PK-only)
88
93
  const mainResult = { [pk]: pkValue };
89
- // 4. Secondaries (upsert/insert with FK auto-fill)
94
+ // 8. Secondaries (upsert/insert with FK auto-fill — camelCase)
90
95
  let secondaryResults;
91
96
  if (secondaries && Object.keys(secondaries).length > 0) {
92
- // Merge input fields + PK for FK auto-fill
93
- const mainForFK = { ...camelcaseObject(snaked) };
97
+ const mainForFK = { ...inputRecord };
94
98
  secondaryResults = await processSecondaries(db, tableConf, dbTables, mainForFK, secondaries);
95
99
  }
96
- // 5. Deletions
100
+ // 9. Deletions
97
101
  let deletionResults;
98
102
  if (deletions && Object.keys(deletions).length > 0) {
99
103
  deletionResults = await processDeletions(db, tableConf, deletions);
100
104
  }
101
- // 6. Return PK-only
105
+ // 10. Return PK-only
102
106
  const result = { main: mainResult };
103
107
  if (secondaryResults && Object.keys(secondaryResults).length > 0) {
104
108
  result.secondaries = secondaryResults;
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACV,QAAQ,EAER,YAAY,EACZ,YAAY,EASb,MAAM,mBAAmB,CAAC;AA+V3B,wBAAsB,YAAY,CAChC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,CAAC,CAyDvB"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EACV,QAAQ,EAER,YAAY,EACZ,YAAY,EAUb,MAAM,mBAAmB,CAAC;AA+lB3B,wBAAsB,YAAY,CAChC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,CAAC,CA2EvB"}
@@ -10,19 +10,112 @@ function validateSchemaField(field, schema) {
10
10
  }
11
11
  return schema.col(field);
12
12
  }
13
- function validateOrderBy(orderBy, tableConf, db) {
14
- return orderBy.split(',').map((part) => {
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;
26
+ };
27
+ // Whitelist fn
28
+ if (!(fn in AGG_FN_SQL)) {
29
+ err400(`Invalid aggregation function: ${fn}`);
30
+ }
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}`);
35
+ }
36
+ const declaredFields = groupReq.aggregations[fn];
37
+ 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}`);
44
+ }
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.
50
+ 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}')`);
52
+ }
53
+ // Validate field exists on join schema
54
+ 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
62
+ let filterWhere = '';
63
+ let filterVals = [];
64
+ const joinTableConf = dbTables[joinTableName];
65
+ if ((groupReq.filters || groupReq.conditions?.length) && joinSchema) {
66
+ const cb = buildJoinRefCondition(joinTableConf, joinSchema, { filters: groupReq.filters, conditions: groupReq.conditions }, db);
67
+ const built = cb.build(startIdx, db.ph);
68
+ if (built) {
69
+ filterWhere = ` AND ${built}`;
70
+ filterVals = cb.getValues();
71
+ }
72
+ }
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)`;
83
+ return { expr, values: filterVals };
84
+ }
85
+ function validateOrderBy(orderBy, tableConf, db, dbTables, joinGroups, startIdx) {
86
+ const parts = orderBy.split(',');
87
+ const outParts = [];
88
+ const outValues = [];
89
+ let currentIdx = startIdx;
90
+ for (const part of parts) {
15
91
  const trimmed = part.trim();
16
- const match = trimmed.match(/^(\w+)(?:\s+(ASC|DESC))?$/i);
17
- if (!match) {
92
+ // Dotted notation: <joinTable>.<fn>.<field> [ASC|DESC]
93
+ const dottedMatch = trimmed.match(/^(\w+)\.(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i);
94
+ if (dottedMatch) {
95
+ if (tableConf.distinctResults) {
96
+ const err = new Error('Cannot combine distinctResults with aggregation orderBy');
97
+ err.statusCode = 400;
98
+ throw err;
99
+ }
100
+ const [, joinTableName, fn, field, dir] = dottedMatch;
101
+ const { expr, values } = buildAggOrderExpr(db, dbTables, tableConf, joinTableName, fn, field, joinGroups, currentIdx);
102
+ outParts.push(`${expr} ${(dir || 'ASC').toUpperCase()}`);
103
+ outValues.push(...values);
104
+ currentIdx += values.length;
105
+ continue;
106
+ }
107
+ // Plain field: <field> [ASC|DESC]
108
+ const plainMatch = trimmed.match(/^(\w+)(?:\s+(ASC|DESC))?$/i);
109
+ if (!plainMatch) {
18
110
  const err = new Error(`Invalid orderBy: ${trimmed}`);
19
111
  err.statusCode = 400;
20
112
  throw err;
21
113
  }
22
- const [, field, dir] = match;
114
+ const [, field, dir] = plainMatch;
23
115
  const col = validateSchemaField(field, tableConf.Schema);
24
- return `${db.qi(col)} ${(dir || 'ASC').toUpperCase()}`;
25
- }).join(', ');
116
+ outParts.push(`${db.qi(col)} ${(dir || 'ASC').toUpperCase()}`);
117
+ }
118
+ return { sql: outParts.join(', '), values: outValues };
26
119
  }
27
120
  async function executeMainQuery(db, tableConf, where, values, orderBy, paginator, extraJoins = []) {
28
121
  const tableName = tableConf.Schema.tableName;
@@ -39,7 +132,7 @@ async function executeMainQuery(db, tableConf, where, values, orderBy, paginator
39
132
  distinct: tableConf.distinctResults,
40
133
  joins: extraJoins.length > 0 ? extraJoins : undefined,
41
134
  });
42
- return rows.map((r) => camelcaseObject(r));
135
+ return rows.map((r) => camelcaseObject(r, tableConf.Schema));
43
136
  }
44
137
  async function buildPagination(db, tableConf, where, values, paginator, extraJoins = [], computeMin, computeMax, computeSum, computeAvg) {
45
138
  const tableName = tableConf.Schema.tableName;
@@ -72,7 +165,7 @@ function findJoinDefinition(tableConf, joinTableName) {
72
165
  }
73
166
  async function executeVirtualJoins(db, dbTables, tableConf, mainResults, joins) {
74
167
  const result = {};
75
- for (const [joinTableName, joinOpts] of Object.entries(joins)) {
168
+ for (const [joinTableName, joinRef] of Object.entries(joins)) {
76
169
  const joinDef = findJoinDefinition(tableConf, joinTableName);
77
170
  if (!joinDef)
78
171
  continue;
@@ -84,22 +177,27 @@ async function executeVirtualJoins(db, dbTables, tableConf, mainResults, joins)
84
177
  result[joinTableName] = [];
85
178
  continue;
86
179
  }
87
- // Build join filters
88
- const cb = joinTableConf
89
- ? joinTableConf.filters(joinOpts?.filters || {})
90
- : new ConditionBuilder('AND');
180
+ // Build join filters (equality) + rich conditions
181
+ const cb = buildJoinRefCondition(joinTableConf, joinSchema, joinRef || {}, db);
91
182
  const fkCol = joinSchema.col(joinField);
92
183
  cb.isIn(db.qi(fkCol), ids);
93
184
  const where = cb.build(1, db.ph);
94
185
  const values = cb.getValues();
95
- const columns = selection === '*' ? '*' : selection;
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(', ');
96
194
  const rows = await db.select({
97
195
  tableName: joinSchema.tableName,
98
196
  columns,
99
197
  where,
100
198
  values,
101
199
  });
102
- result[joinTableName] = rows.map((r) => camelcaseObject(r));
200
+ result[joinTableName] = rows.map((r) => camelcaseObject(r, joinSchema));
103
201
  }
104
202
  return result;
105
203
  }
@@ -115,7 +213,7 @@ async function executeJoinGroups(db, dbTables, tableConf, mainResults, joinGroup
115
213
  result[joinTableName] = {};
116
214
  continue;
117
215
  }
118
- const { aggregations, filters: groupFilters } = groupReq;
216
+ const { aggregations, filters: groupFilters, conditions: groupConditions } = groupReq;
119
217
  const selectParts = [];
120
218
  const groupByParts = [];
121
219
  if (aggregations.by) {
@@ -147,15 +245,25 @@ async function executeJoinGroups(db, dbTables, tableConf, mainResults, joinGroup
147
245
  selectParts.push(`SUM(${db.qi(col)}) as "sum_${f}"`);
148
246
  }
149
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
+ }
150
260
  if (selectParts.length === 0) {
151
261
  result[joinTableName] = {};
152
262
  continue;
153
263
  }
154
- // Build WHERE clause
264
+ // Build WHERE clause: equality filters + rich conditions
155
265
  const joinTableConf = dbTables[joinTableName];
156
- const cb = joinTableConf && groupFilters
157
- ? joinTableConf.filters(groupFilters)
158
- : new ConditionBuilder('AND');
266
+ const cb = buildJoinRefCondition(joinTableConf, joinSchema, { filters: groupFilters, conditions: groupConditions }, db);
159
267
  const fkCol = joinSchema.col(joinField);
160
268
  cb.isIn(db.qi(fkCol), ids);
161
269
  const where = cb.build(1, db.ph);
@@ -205,8 +313,47 @@ function collectIds(mainResults, mainField) {
205
313
  }
206
314
  // ─── Conditions (advanced filters) ──────────────────────────
207
315
  import { ALLOWED_SET, SINGLE_VALUE_SET, BETWEEN_SET, IN_SET, NULL_SET, } from '../../condition-methods.js';
316
+ function dispatchConditionMethod(cb, method, colOrExpr, params) {
317
+ if (SINGLE_VALUE_SET.has(method)) {
318
+ cb[method](colOrExpr, params[0]);
319
+ }
320
+ else if (BETWEEN_SET.has(method)) {
321
+ cb[method](colOrExpr, params[0], params[1]);
322
+ }
323
+ else if (IN_SET.has(method)) {
324
+ cb[method](colOrExpr, params[0]);
325
+ }
326
+ else if (NULL_SET.has(method)) {
327
+ cb[method](colOrExpr, true);
328
+ }
329
+ }
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
+ function buildJoinRefCondition(joinTableConf, joinSchema, ref, db) {
336
+ const cb = (ref.filters && joinTableConf)
337
+ ? joinTableConf.filters(ref.filters)
338
+ : new ConditionBuilder('AND');
339
+ if (ref.conditions?.length) {
340
+ for (const c of ref.conditions) {
341
+ if (!ALLOWED_SET.has(c.method)) {
342
+ const err = new Error(`Invalid condition method: ${c.method}`);
343
+ err.statusCode = 400;
344
+ throw err;
345
+ }
346
+ const col = db.qi(validateSchemaField(c.field, joinSchema));
347
+ dispatchConditionMethod(cb, c.method, col, c.params ?? []);
348
+ }
349
+ }
350
+ return cb;
351
+ }
208
352
  function applyConditions(condition, conditions, schema, db) {
209
353
  for (const c of conditions) {
354
+ // Skip dot-notation fields — those become aggregation conditions processed later
355
+ if (c.field.includes('.'))
356
+ continue;
210
357
  // Validate method — whitelist only, blocks prototype poisoning
211
358
  if (!ALLOWED_SET.has(c.method)) {
212
359
  const err = new Error(`Invalid condition method: ${c.method}`);
@@ -214,33 +361,61 @@ function applyConditions(condition, conditions, schema, db) {
214
361
  throw err;
215
362
  }
216
363
  const col = db.qi(validateSchemaField(c.field, schema));
217
- if (SINGLE_VALUE_SET.has(c.method)) {
218
- condition[c.method](col, c.params[0]);
219
- }
220
- else if (BETWEEN_SET.has(c.method)) {
221
- condition[c.method](col, c.params[0], c.params[1]);
222
- }
223
- else if (IN_SET.has(c.method)) {
224
- condition[c.method](col, c.params[0]);
364
+ dispatchConditionMethod(condition, c.method, col, c.params);
365
+ }
366
+ }
367
+ /**
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.
375
+ */
376
+ function appendAggConditions(currentWhere, currentValues, conditions, db, dbTables, tableConf, joinGroups) {
377
+ let where = currentWhere;
378
+ let values = [...currentValues];
379
+ for (const c of conditions) {
380
+ if (!c.field.includes('.'))
381
+ continue;
382
+ if (!ALLOWED_SET.has(c.method)) {
383
+ const err = new Error(`Invalid condition method: ${c.method}`);
384
+ err.statusCode = 400;
385
+ throw err;
225
386
  }
226
- else if (NULL_SET.has(c.method)) {
227
- condition[c.method](col, true);
387
+ const parts = c.field.split('.');
388
+ 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;
228
392
  }
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);
396
+ 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
+ const tmpCb = new ConditionBuilder('AND');
400
+ dispatchConditionMethod(tmpCb, c.method, expr, c.params);
401
+ const startIdx = values.length + 1;
402
+ const clause = tmpCb.build(startIdx, db.ph);
403
+ where += ` AND ${clause}`;
404
+ values.push(...tmpCb.getValues());
229
405
  }
406
+ return { where, values };
230
407
  }
231
408
  // ─── Join filters (EXISTS subquery) ─────────────────────────
232
409
  function buildJoinFiltersExists(db, dbTables, tableConf, joinFilters, currentWhere, currentValues) {
233
410
  let where = currentWhere;
234
411
  const values = [...currentValues];
235
- for (const [joinTableName, filterValues] of Object.entries(joinFilters)) {
412
+ for (const [joinTableName, ref] of Object.entries(joinFilters)) {
236
413
  const joinDef = findJoinDefinition(tableConf, joinTableName);
237
414
  if (!joinDef)
238
415
  continue;
239
416
  const [joinSchema, joinField, mainField] = joinDef;
240
417
  const joinTableConf = dbTables[joinTableName];
241
- if (!joinTableConf)
242
- continue;
243
- const filterCondition = joinTableConf.filters(filterValues);
418
+ const filterCondition = buildJoinRefCondition(joinTableConf, joinSchema, ref, db);
244
419
  const startIdx = values.length + 1;
245
420
  const filterWhere = filterCondition.build(startIdx, db.ph);
246
421
  const filterVals = filterCondition.getValues();
@@ -248,7 +423,11 @@ function buildJoinFiltersExists(db, dbTables, tableConf, joinFilters, currentWhe
248
423
  const mainColName = Array.isArray(mainField) ? mainField[0] : mainField;
249
424
  const mainCol = db.qi(tableConf.Schema.col(mainColName));
250
425
  const mainTable = db.qi(tableConf.Schema.tableName);
251
- where += ` AND EXISTS (SELECT 1 FROM ${db.qi(joinSchema.tableName)} WHERE ${fkCol} = ${mainTable}.${mainCol} AND ${filterWhere})`;
426
+ // If there are no inner conditions, just the correlation suffices.
427
+ 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})`;
252
431
  values.push(...filterVals);
253
432
  }
254
433
  return { where, values };
@@ -275,11 +454,28 @@ export async function searchEngine(dbTables, params) {
275
454
  if (joinFilters && Object.keys(joinFilters).length > 0) {
276
455
  ({ where, values } = buildJoinFiltersExists(db, dbTables, tableConf, joinFilters, where, values));
277
456
  }
457
+ // Aggregation conditions (HAVING-style): dotted fields in `conditions` are
458
+ // translated into scalar subqueries using the joinGroup aggregation.
459
+ if (conditions?.length) {
460
+ const hasAggConditions = conditions.some((c) => c.field.includes('.'));
461
+ if (hasAggConditions) {
462
+ ({ where, values } = appendAggConditions(where, values, conditions, db, dbTables, tableConf, joinGroups));
463
+ }
464
+ }
278
465
  // 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
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.
468
+ let safeOrderBy;
469
+ let orderByValues = [];
470
+ if (orderBy) {
471
+ const obResult = validateOrderBy(orderBy, tableConf, db, dbTables, joinGroups, values.length + 1);
472
+ safeOrderBy = obResult.sql;
473
+ orderByValues = obResult.values;
474
+ }
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)
283
479
  let pagination;
284
480
  if (paginator) {
285
481
  pagination = await buildPagination(db, tableConf, where, values, paginator, tenantJoins, computeMin, computeMax, computeSum, computeAvg);
@@ -0,0 +1,11 @@
1
+ import type { FastifyRequest } from 'fastify';
2
+ import type { QueryClient } from '../db.js';
3
+ import type { ITable, BulkValidatorItem } from '../../types.js';
4
+ export interface ValidationResponseField {
5
+ path: string;
6
+ code: string;
7
+ message: string;
8
+ }
9
+ export declare function runValidation(db: QueryClient, request: FastifyRequest, tableConf: ITable, main: Record<string, unknown>, secondaries?: Record<string, Record<string, unknown>[]>): Promise<void>;
10
+ export declare function runBulkValidation(db: QueryClient, request: FastifyRequest, tableConf: ITable, items: BulkValidatorItem[]): Promise<void>;
11
+ //# sourceMappingURL=validate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../../src/lib/engine/validate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAC5C,OAAO,KAAK,EAAE,MAAM,EAAmB,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAEjF,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAUD,wBAAsB,aAAa,CACjC,EAAE,EAAE,WAAW,EACf,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACtD,OAAO,CAAC,IAAI,CAAC,CAOf;AAED,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,WAAW,EACf,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,iBAAiB,EAAE,GACzB,OAAO,CAAC,IAAI,CAAC,CAOf"}
@@ -0,0 +1,29 @@
1
+ function toResponseFields(errors) {
2
+ return errors.map(([path, code, message]) => ({
3
+ path,
4
+ code,
5
+ message: message ?? code,
6
+ }));
7
+ }
8
+ export async function runValidation(db, request, tableConf, main, secondaries) {
9
+ if (!tableConf.validate)
10
+ return;
11
+ const errors = await tableConf.validate(db, request, main, secondaries);
12
+ if (errors.length > 0) {
13
+ throwValidationError(toResponseFields(errors));
14
+ }
15
+ }
16
+ export async function runBulkValidation(db, request, tableConf, items) {
17
+ if (!tableConf.validateBulk)
18
+ return;
19
+ const errors = await tableConf.validateBulk(db, request, items);
20
+ if (errors.length > 0) {
21
+ throwValidationError(toResponseFields(errors));
22
+ }
23
+ }
24
+ function throwValidationError(fields) {
25
+ const error = new Error('Validation failed');
26
+ error.statusCode = 400;
27
+ error.validationErrors = fields;
28
+ throw error;
29
+ }
@@ -35,7 +35,7 @@ export async function processSecondaries(db, tableConf, dbTables, mainRecord, se
35
35
  ? secondaryPk.map((f) => joinSchema.col(f))
36
36
  : joinSchema.col(secondaryPk);
37
37
  const preparedRecords = records.map((rec) => {
38
- let prepared = snakecaseRecord(rec);
38
+ let prepared = snakecaseRecord(rec, joinSchema);
39
39
  // Auto-fill FK from main record
40
40
  const mainValue = Array.isArray(mainField)
41
41
  ? mainRecord[mainField[0]]
@@ -56,7 +56,7 @@ export async function processSecondaries(db, tableConf, dbTables, mainRecord, se
56
56
  else {
57
57
  pkRows = await db.bulkInsert(joinSchema.tableName, preparedRecords, secondaryPkCol);
58
58
  }
59
- results[joinTableName] = pkRows.map((r) => camelcaseObject(r));
59
+ results[joinTableName] = pkRows.map((r) => camelcaseObject(r, joinSchema));
60
60
  }
61
61
  return results;
62
62
  }
@@ -71,10 +71,10 @@ export async function processDeletions(db, tableConf, deletions) {
71
71
  const [joinSchema] = joinDef;
72
72
  const deletedRows = [];
73
73
  for (const rec of records) {
74
- const snaked = snakecaseRecord(rec);
74
+ const snaked = snakecaseRecord(rec, joinSchema);
75
75
  const affectedRows = await db.delete(joinSchema.tableName, snaked);
76
76
  if (affectedRows > 0) {
77
- deletedRows.push(camelcaseObject(snaked));
77
+ deletedRows.push(camelcaseObject(snaked, joinSchema));
78
78
  }
79
79
  }
80
80
  results[joinTableName] = deletedRows;
@@ -1,6 +1,15 @@
1
+ import type { SchemaDefinition } from '../types.js';
1
2
  export declare function toCamelCase(str: string): string;
2
3
  export declare function toUnderscore(str: string): string;
3
- export declare function camelcaseObject<T extends Record<string, unknown>>(obj: T): Record<string, unknown>;
4
- export declare function snakecaseRecord(obj: Record<string, unknown>): Record<string, unknown>;
4
+ /**
5
+ * Convert API record (camelCase keys) to DB record (actual column names).
6
+ * Uses schema.colMap when available, falls back to toUnderscore.
7
+ */
8
+ export declare function snakecaseRecord(obj: Record<string, unknown>, schema?: SchemaDefinition): Record<string, unknown>;
9
+ /**
10
+ * Convert DB row (actual column names) to API record (camelCase field names).
11
+ * Uses schema.colMap when available, falls back to toCamelCase.
12
+ */
13
+ export declare function camelcaseObject<T extends Record<string, unknown>>(obj: T, schema?: SchemaDefinition): Record<string, unknown>;
5
14
  export declare function toSchemaName(tableName: string): string;
6
15
  //# sourceMappingURL=naming.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"naming.d.ts","sourceRoot":"","sources":["../../src/lib/naming.ts"],"names":[],"mappings":"AAAA,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI/C;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAMhD;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,GAAG,EAAE,CAAC,GACL,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAIzB;AAED,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC3B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAIzB;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAQtD"}
1
+ {"version":3,"file":"naming.d.ts","sourceRoot":"","sources":["../../src/lib/naming.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAEpD,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI/C;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAMhD;AAiBD;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5B,MAAM,CAAC,EAAE,gBAAgB,GACxB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAOzB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,GAAG,EAAE,CAAC,EACN,MAAM,CAAC,EAAE,gBAAgB,GACxB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAUzB;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAQtD"}