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.
- package/AGENTS.md +15 -2
- package/AGENTS_BACKEND.md +144 -20
- package/AGENTS_FRONTEND.md +161 -67
- package/README.md +47 -26
- package/dist/bin/generate-schema.js +9 -26
- package/dist/bin/generate-tables.js +7 -30
- package/dist/bin/utils.d.ts +28 -0
- package/dist/bin/utils.d.ts.map +1 -1
- package/dist/bin/utils.js +69 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/lib/cli/load-dependency.d.ts +8 -0
- package/dist/lib/cli/load-dependency.d.ts.map +1 -0
- package/dist/lib/cli/load-dependency.js +21 -0
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +2 -18
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +2 -17
- package/dist/lib/db.d.ts +1 -0
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +33 -55
- package/dist/lib/dialect.d.ts +17 -0
- package/dist/lib/dialect.d.ts.map +1 -1
- package/dist/lib/dialect.js +32 -0
- package/dist/lib/engine/bulk/bulk-delete.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-delete.js +8 -2
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +15 -43
- package/dist/lib/engine/rest/delete.d.ts.map +1 -1
- package/dist/lib/engine/rest/delete.js +14 -12
- package/dist/lib/engine/rest/get.d.ts.map +1 -1
- package/dist/lib/engine/rest/get.js +3 -5
- package/dist/lib/engine/rest/insert.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.js +11 -31
- package/dist/lib/engine/rest/update.d.ts.map +1 -1
- package/dist/lib/engine/rest/update.js +13 -46
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +553 -229
- package/dist/lib/engine/write-helpers.d.ts +39 -2
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +55 -12
- package/dist/lib/errors.d.ts +11 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +12 -0
- package/dist/lib/schema/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/schema/bulk-upsert.js +9 -25
- package/dist/lib/schema/helpers.d.ts +28 -1
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +38 -0
- package/dist/lib/schema/insert.d.ts.map +1 -1
- package/dist/lib/schema/insert.js +9 -19
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +138 -50
- package/dist/lib/schema/update.d.ts.map +1 -1
- package/dist/lib/schema/update.js +9 -25
- package/dist/lib/sql-api.d.ts +6 -6
- package/dist/lib/sql-api.d.ts.map +1 -1
- package/dist/lib/sql-api.js +10 -10
- package/dist/lib/table-helpers.d.ts +6 -1
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +42 -2
- package/dist/lib/tenant.d.ts +30 -0
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +78 -9
- package/dist/routes/auto/bulk-delete.routes.d.ts.map +1 -1
- package/dist/routes/auto/bulk-delete.routes.js +17 -23
- package/dist/routes/auto/bulk-upsert.routes.d.ts.map +1 -1
- package/dist/routes/auto/bulk-upsert.routes.js +16 -22
- package/dist/routes/auto/delete.routes.d.ts.map +1 -1
- package/dist/routes/auto/delete.routes.js +18 -24
- package/dist/routes/auto/get.routes.d.ts.map +1 -1
- package/dist/routes/auto/get.routes.js +16 -23
- package/dist/routes/auto/insert.routes.d.ts.map +1 -1
- package/dist/routes/auto/insert.routes.js +19 -26
- package/dist/routes/auto/plugin.d.ts.map +1 -1
- package/dist/routes/auto/plugin.js +40 -5
- package/dist/routes/auto/route-helpers.d.ts +27 -2
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +39 -1
- package/dist/routes/auto/search.routes.d.ts.map +1 -1
- package/dist/routes/auto/search.routes.js +47 -47
- package/dist/routes/auto/update.routes.d.ts.map +1 -1
- package/dist/routes/auto/update.routes.js +20 -27
- package/dist/types/cli.d.ts +22 -0
- package/dist/types/cli.d.ts.map +1 -0
- package/dist/types/cli.js +1 -0
- package/dist/types/computed.d.ts +29 -0
- package/dist/types/computed.d.ts.map +1 -0
- package/dist/types/computed.js +1 -0
- package/dist/types/conditions.d.ts +16 -0
- package/dist/types/conditions.d.ts.map +1 -0
- package/dist/types/conditions.js +1 -0
- package/dist/types/db.d.ts +22 -0
- package/dist/types/db.d.ts.map +1 -0
- package/dist/types/db.js +1 -0
- package/dist/types/dml.d.ts +66 -0
- package/dist/types/dml.d.ts.map +1 -0
- package/dist/types/dml.js +1 -0
- package/dist/types/join.d.ts +44 -0
- package/dist/types/join.d.ts.map +1 -0
- package/dist/types/join.js +1 -0
- package/dist/types/plugin.d.ts +20 -0
- package/dist/types/plugin.d.ts.map +1 -0
- package/dist/types/plugin.js +1 -0
- package/dist/types/schema.d.ts +10 -0
- package/dist/types/schema.d.ts.map +1 -0
- package/dist/types/schema.js +1 -0
- package/dist/types/search.d.ts +45 -0
- package/dist/types/search.d.ts.map +1 -0
- package/dist/types/search.js +1 -0
- package/dist/types/table.d.ts +76 -0
- package/dist/types/table.d.ts.map +1 -0
- package/dist/types/table.js +7 -0
- package/dist/types/tenant.d.ts +19 -0
- package/dist/types/tenant.d.ts.map +1 -0
- package/dist/types/tenant.js +1 -0
- package/dist/types/validation.d.ts +21 -0
- package/dist/types/validation.d.ts.map +1 -0
- package/dist/types/validation.js +1 -0
- package/dist/types.d.ts +12 -277
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +12 -7
- 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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
|
46
|
-
|
|
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: ${
|
|
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
|
-
|
|
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[
|
|
65
|
-
if (
|
|
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
|
-
|
|
74
|
-
const
|
|
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,
|
|
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
|
-
//
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
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
|
-
|
|
97
|
-
err.statusCode = 400;
|
|
98
|
-
throw err;
|
|
171
|
+
err400('Cannot combine distinctResults with aggregation orderBy');
|
|
99
172
|
}
|
|
100
|
-
const [,
|
|
101
|
-
const { expr, values } = buildAggOrderExpr(db, dbTables, tableConf,
|
|
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
|
-
//
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
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 {
|
|
266
|
+
return { joinClauses, whereExtras, values };
|
|
119
267
|
}
|
|
120
|
-
|
|
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
|
|
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
|
|
152
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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 [
|
|
169
|
-
const joinDef =
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
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[
|
|
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
|
-
|
|
187
|
-
|
|
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[
|
|
390
|
+
result[alias] = mapRowsToCamelCase(rows, joinSchema);
|
|
201
391
|
}
|
|
202
392
|
return result;
|
|
203
393
|
}
|
|
204
|
-
|
|
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 [
|
|
207
|
-
const joinDef =
|
|
208
|
-
|
|
209
|
-
|
|
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[
|
|
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
|
|
221
|
-
selectParts.push(`${
|
|
222
|
-
groupByParts.push(
|
|
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
|
-
|
|
243
|
-
|
|
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(
|
|
440
|
+
selectParts.push(`${aggExpr(fn, db.qi(col))} as "${kind}_${f}"`);
|
|
246
441
|
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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[
|
|
450
|
+
result[alias] = {};
|
|
262
451
|
continue;
|
|
263
452
|
}
|
|
264
|
-
|
|
265
|
-
const
|
|
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
|
-
|
|
270
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
|
369
|
-
*
|
|
370
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
err.statusCode = 400;
|
|
391
|
-
throw err;
|
|
664
|
+
err400(`Invalid dotted field in condition: ${c.field} (expected <alias>.<fn>.<field>)`);
|
|
392
665
|
}
|
|
393
|
-
const [
|
|
394
|
-
|
|
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
|
-
// ───
|
|
409
|
-
function
|
|
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 [
|
|
413
|
-
const joinDef =
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
const
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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 ${
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
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)
|
|
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,
|
|
764
|
+
({ where, values } = appendAggConditions(where, values, conditions, db, dbTables, tableConf, joinGroup));
|
|
463
765
|
}
|
|
464
766
|
}
|
|
465
|
-
//
|
|
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,
|
|
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
|
-
//
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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,
|
|
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 (
|
|
486
|
-
result.
|
|
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
|
-
|
|
489
|
-
|
|
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;
|