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.
- package/AGENTS.md +2 -2
- package/AGENTS_BACKEND.md +111 -15
- package/AGENTS_FRONTEND.md +126 -1
- package/README.md +135 -18
- package/dist/bin/generate-schema.js +3 -3
- package/dist/bin/generate-tables.js +1 -1
- package/dist/bin/utils.d.ts +1 -1
- package/dist/bin/utils.d.ts.map +1 -1
- package/dist/bin/utils.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +9 -2
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +8 -1
- package/dist/lib/cli/schema-codegen.d.ts +1 -1
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +11 -4
- package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
- package/dist/lib/cli/tables-codegen.js +30 -1
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +10 -4
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +41 -27
- package/dist/lib/engine/rest/get.js +1 -1
- package/dist/lib/engine/rest/insert.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.js +25 -21
- package/dist/lib/engine/rest/update.d.ts.map +1 -1
- package/dist/lib/engine/rest/update.js +42 -38
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +235 -39
- package/dist/lib/engine/validate.d.ts +11 -0
- package/dist/lib/engine/validate.d.ts.map +1 -0
- package/dist/lib/engine/validate.js +29 -0
- package/dist/lib/engine/write-helpers.js +4 -4
- package/dist/lib/naming.d.ts +11 -2
- package/dist/lib/naming.d.ts.map +1 -1
- package/dist/lib/naming.js +33 -5
- package/dist/lib/schema/bulk-upsert.js +2 -2
- package/dist/lib/schema/helpers.d.ts +5 -0
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +15 -0
- package/dist/lib/schema/insert.js +2 -2
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +16 -10
- package/dist/lib/schema/update.d.ts.map +1 -1
- package/dist/lib/schema/update.js +4 -3
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +9 -1
- package/dist/routes/auto/plugin.d.ts.map +1 -1
- package/dist/routes/auto/plugin.js +12 -1
- package/dist/types.d.ts +47 -8
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -8
|
@@ -1,53 +1,58 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
9
|
+
const schema = tableConf.Schema;
|
|
9
10
|
const pk = primaryAsString(tableConf.primary);
|
|
10
|
-
const pkCol =
|
|
11
|
-
const
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
//
|
|
94
|
+
// 8. Secondaries (upsert/insert with FK auto-fill — camelCase)
|
|
90
95
|
let secondaryResults;
|
|
91
96
|
if (secondaries && Object.keys(secondaries).length > 0) {
|
|
92
|
-
|
|
93
|
-
const mainForFK = { ...camelcaseObject(snaked) };
|
|
97
|
+
const mainForFK = { ...inputRecord };
|
|
94
98
|
secondaryResults = await processSecondaries(db, tableConf, dbTables, mainForFK, secondaries);
|
|
95
99
|
}
|
|
96
|
-
//
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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] =
|
|
114
|
+
const [, field, dir] = plainMatch;
|
|
23
115
|
const col = validateSchemaField(field, tableConf.Schema);
|
|
24
|
-
|
|
25
|
-
}
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
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;
|
package/dist/lib/naming.d.ts
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
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
|
package/dist/lib/naming.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|