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