fastify-auto-sqlapi 0.1.14 → 0.2.0
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_BACKEND.md +4 -4
- package/BREAKING_CHANGES.md +5 -5
- package/README.md +1 -1
- package/dist/bin/generate-schema.js +89 -77
- package/dist/bin/generate-tables.js +72 -62
- package/dist/bin/utils.d.ts.map +1 -1
- package/dist/bin/utils.js +47 -33
- package/dist/lib/agent/manifest.d.ts +3 -2
- package/dist/lib/agent/manifest.d.ts.map +1 -1
- package/dist/lib/agent/manifest.js +102 -80
- package/dist/lib/cli/mysql-introspect.d.ts +4 -9
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +8 -7
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +0 -1
- package/dist/lib/cli/schema-codegen.d.ts +3 -0
- package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
- package/dist/lib/cli/schema-codegen.js +3 -0
- package/dist/lib/cli/tables-codegen.d.ts +8 -1
- package/dist/lib/cli/tables-codegen.d.ts.map +1 -1
- package/dist/lib/cli/tables-codegen.js +100 -118
- package/dist/lib/condition-methods.d.ts +14 -4
- package/dist/lib/condition-methods.d.ts.map +1 -1
- package/dist/lib/condition-methods.js +18 -4
- package/dist/lib/db.d.ts +1 -1
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +16 -4
- package/dist/lib/dialect.d.ts +2 -1
- package/dist/lib/dialect.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +42 -35
- package/dist/lib/engine/rest/delete.d.ts.map +1 -1
- package/dist/lib/engine/rest/get.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.js +1 -1
- package/dist/lib/engine/rest/update.d.ts.map +1 -1
- package/dist/lib/engine/rest/update.js +25 -18
- package/dist/lib/engine/search/aggregations.d.ts +33 -0
- package/dist/lib/engine/search/aggregations.d.ts.map +1 -0
- package/dist/lib/engine/search/aggregations.js +109 -0
- package/dist/lib/engine/search/conditions.d.ts +31 -0
- package/dist/lib/engine/search/conditions.d.ts.map +1 -0
- package/dist/lib/engine/search/conditions.js +131 -0
- package/dist/lib/engine/search/fields.d.ts +49 -0
- package/dist/lib/engine/search/fields.d.ts.map +1 -0
- package/dist/lib/engine/search/fields.js +142 -0
- package/dist/lib/engine/search/join-families.d.ts +35 -0
- package/dist/lib/engine/search/join-families.d.ts.map +1 -0
- package/dist/lib/engine/search/join-families.js +345 -0
- package/dist/lib/engine/search/joins.d.ts +55 -0
- package/dist/lib/engine/search/joins.d.ts.map +1 -0
- package/dist/lib/engine/search/joins.js +81 -0
- package/dist/lib/engine/search/order-by.d.ts +35 -0
- package/dist/lib/engine/search/order-by.d.ts.map +1 -0
- package/dist/lib/engine/search/order-by.js +104 -0
- package/dist/lib/engine/search/search.d.ts.map +1 -1
- package/dist/lib/engine/search/search.js +101 -787
- package/dist/lib/engine/validate.d.ts +0 -5
- package/dist/lib/engine/validate.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.d.ts +25 -17
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +86 -44
- package/dist/lib/read-access.d.ts +12 -1
- package/dist/lib/read-access.d.ts.map +1 -1
- package/dist/lib/read-access.js +20 -4
- package/dist/lib/schema/helpers.d.ts +0 -15
- package/dist/lib/schema/helpers.d.ts.map +1 -1
- package/dist/lib/schema/helpers.js +7 -4
- package/dist/lib/schema/search.d.ts.map +1 -1
- package/dist/lib/schema/search.js +107 -128
- package/dist/lib/sql-api.d.ts.map +1 -1
- package/dist/lib/tenant.d.ts +26 -0
- package/dist/lib/tenant.d.ts.map +1 -1
- package/dist/lib/tenant.js +46 -0
- package/dist/routes/auto/route-helpers.d.ts +0 -7
- package/dist/routes/auto/route-helpers.d.ts.map +1 -1
- package/dist/routes/auto/route-helpers.js +46 -30
- package/dist/routes/auto/search.routes.js +0 -1
- package/dist/types/table.d.ts +1 -1
- package/docs/GLOSSARY.md +28 -0
- package/docs/adr/0012-anti-duplication-tooling.md +59 -0
- package/docs/adr/README.md +3 -1
- package/package.json +29 -2
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four join families, each executed against the rows the main query returned.
|
|
3
|
+
*
|
|
4
|
+
* joinMustExist child -> main, 1:N EXISTS filter on the main query (returns no rows)
|
|
5
|
+
* joinMultiple child -> main, 1:N side query, rows under result.joinMultiple.<alias>
|
|
6
|
+
* joinGroup child -> main, 1:N aggregations under result.joinGroup.<alias>
|
|
7
|
+
* joinLeft parent -> main, N:1 real LEFT JOIN on demand + side query for the rows
|
|
8
|
+
*/
|
|
9
|
+
import { ConditionBuilder, Expression } from 'node-condition-builder';
|
|
10
|
+
import { assertReadable, assertFiltersReadable, hasOwnField, ownField } from '../../read-access.js';
|
|
11
|
+
import { ALLOWED_SET } from '../../condition-methods.js';
|
|
12
|
+
import { err400, validateSchemaField, evaluateComputedField, mapRowsToCamelCase, buildSelectionColumns, } from './fields.js';
|
|
13
|
+
import { appendJoinTenantScope, subqueryAlias, requireJoin, extractJoinRefs, } from './joins.js';
|
|
14
|
+
import { tenantForTable, buildTenantRowGuard } from '../../tenant.js';
|
|
15
|
+
import { dispatchConditionMethod, buildJoinRefCondition } from './conditions.js';
|
|
16
|
+
import { AGG_FN, aggExpr } from './aggregations.js';
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a joinLeft field to an alias-qualified SQL reference: a schema column, or a
|
|
19
|
+
* computed field's expression. Returns null when the field is neither — the caller decides
|
|
20
|
+
* whether that is a silent skip (filters) or a 400 (conditions).
|
|
21
|
+
*
|
|
22
|
+
* References go through the alias, not the table name, so they hit the LEFT JOIN'd row.
|
|
23
|
+
*/
|
|
24
|
+
function resolveJoinLeftField(field, scope) {
|
|
25
|
+
const { db, alias, aliasIdent, joinSchema, computed } = scope;
|
|
26
|
+
if (hasOwnField(joinSchema.fields, field)) {
|
|
27
|
+
// Wrapped so this helper has a single return type. A value-less Expression renders
|
|
28
|
+
// verbatim (see renderField), so the emitted SQL is identical to the raw identifier.
|
|
29
|
+
return new Expression(`${aliasIdent}.${db.qi(joinSchema.col(field))}`);
|
|
30
|
+
}
|
|
31
|
+
const fn = ownField(computed, field);
|
|
32
|
+
if (fn) {
|
|
33
|
+
// Bound values are allowed: this ConditionBuilder's SQL lands in the WHERE clause.
|
|
34
|
+
return evaluateComputedField(field, fn, joinSchema, db, alias, '', true);
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/** `joinLeft.filters` — equality only. Unknown keys are ignored, as they always were. */
|
|
39
|
+
function applyJoinLeftFilters(cb, filters, scope) {
|
|
40
|
+
for (const [field, value] of Object.entries(filters)) {
|
|
41
|
+
if (value === null || value === undefined)
|
|
42
|
+
continue;
|
|
43
|
+
const ref = resolveJoinLeftField(field, scope);
|
|
44
|
+
if (ref !== null)
|
|
45
|
+
cb.isEqual(ref, value);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** `joinLeft.conditions` — operator methods. An unknown field is a 400, not a no-op. */
|
|
49
|
+
function applyJoinLeftConditions(cb, conditions, scope, dbTables) {
|
|
50
|
+
for (const c of conditions) {
|
|
51
|
+
if (!ALLOWED_SET.has(c.method)) {
|
|
52
|
+
err400(`Invalid condition method: ${c.method}`);
|
|
53
|
+
}
|
|
54
|
+
if (hasOwnField(scope.joinSchema.fields, c.field)) {
|
|
55
|
+
assertReadable(dbTables[scope.joinSchema.tableName], c.field);
|
|
56
|
+
}
|
|
57
|
+
else if (!ownField(scope.computed, c.field)) {
|
|
58
|
+
err400(`Unknown field: ${c.field}`);
|
|
59
|
+
}
|
|
60
|
+
const ref = resolveJoinLeftField(c.field, scope);
|
|
61
|
+
if (ref !== null)
|
|
62
|
+
dispatchConditionMethod(cb, c.method, ref, c.params ?? []);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The LEFT JOIN clauses themselves, each carrying the joined table's own tenant scope.
|
|
67
|
+
*
|
|
68
|
+
* The scope belongs in the ON clause, not the WHERE. In the WHERE it would discard main rows
|
|
69
|
+
* whose parent belongs to another tenant, silently turning the LEFT JOIN into an INNER JOIN.
|
|
70
|
+
* In the ON clause a foreign parent simply fails to match and reads as NULL, which closes the
|
|
71
|
+
* oracle: the parent rows are already withheld from the response (`executeJoinLeft` scopes its
|
|
72
|
+
* own side query), so an unscoped join would let a caller filter on values it cannot see and
|
|
73
|
+
* recover them one predicate at a time.
|
|
74
|
+
*
|
|
75
|
+
* Emitted before the WHERE fragments because that is where it lands in the statement — MySQL
|
|
76
|
+
* binds `?` positionally.
|
|
77
|
+
*/
|
|
78
|
+
export function buildLeftJoinOnClauses(db, dbTables, tableConf, aliasesNeedingJoin, startIdx, tenant) {
|
|
79
|
+
const joinClauses = [];
|
|
80
|
+
const values = [];
|
|
81
|
+
let currentIdx = startIdx;
|
|
82
|
+
for (const alias of aliasesNeedingJoin) {
|
|
83
|
+
const joinDef = requireJoin(tableConf, alias, true);
|
|
84
|
+
const refs = extractJoinRefs(db, tableConf, joinDef);
|
|
85
|
+
const aliasIdent = db.qi(alias);
|
|
86
|
+
// The guard references the join through its ALIAS: one table may be joined under several
|
|
87
|
+
// aliases, and each predicate must bind to the row this clause produced.
|
|
88
|
+
const guard = buildTenantRowGuard(db, tenantForTable(tenant, dbTables[joinDef.joinSchema.tableName]), alias);
|
|
89
|
+
let scoped = '';
|
|
90
|
+
if (guard) {
|
|
91
|
+
scoped = ` AND ${guard.build(currentIdx, db.ph)}`;
|
|
92
|
+
const guardValues = guard.getValues();
|
|
93
|
+
values.push(...guardValues);
|
|
94
|
+
currentIdx += guardValues.length;
|
|
95
|
+
}
|
|
96
|
+
joinClauses.push(`LEFT JOIN ${refs.joinTable} AS ${aliasIdent} ON ${aliasIdent}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol}${scoped}`);
|
|
97
|
+
}
|
|
98
|
+
return { joinClauses, values };
|
|
99
|
+
}
|
|
100
|
+
/** The WHERE fragments a `joinLeft` request's own filters and conditions contribute. */
|
|
101
|
+
export function buildLeftJoinFilters(db, dbTables, tableConf, aliasesNeedingJoin, joinLeft, startIdx) {
|
|
102
|
+
const whereExtras = [];
|
|
103
|
+
const values = [];
|
|
104
|
+
let currentIdx = startIdx;
|
|
105
|
+
for (const alias of aliasesNeedingJoin) {
|
|
106
|
+
const joinDef = requireJoin(tableConf, alias, true);
|
|
107
|
+
const { joinSchema } = joinDef;
|
|
108
|
+
const aliasIdent = db.qi(alias);
|
|
109
|
+
const ref = joinLeft?.[alias];
|
|
110
|
+
if (!ref || !(ref.filters || ref.conditions?.length))
|
|
111
|
+
continue;
|
|
112
|
+
// extraFilters declared on joinTableConf are not supported for joinLeft (they would
|
|
113
|
+
// require alias-aware handlers); only schema and computed fields apply.
|
|
114
|
+
// `assertJoinFilterKeys` rejects them up front rather than letting them through as a
|
|
115
|
+
// silent no-op, and the generated body schema does not advertise them for unique:true
|
|
116
|
+
// relations.
|
|
117
|
+
const joinTableConf = dbTables[joinSchema.tableName];
|
|
118
|
+
const scope = {
|
|
119
|
+
db,
|
|
120
|
+
alias,
|
|
121
|
+
aliasIdent,
|
|
122
|
+
joinSchema,
|
|
123
|
+
computed: joinTableConf?.computedFields,
|
|
124
|
+
};
|
|
125
|
+
const cb = new ConditionBuilder('AND', db.cbDialect);
|
|
126
|
+
assertFiltersReadable(ref.filters, joinTableConf);
|
|
127
|
+
if (ref.filters)
|
|
128
|
+
applyJoinLeftFilters(cb, ref.filters, scope);
|
|
129
|
+
if (ref.conditions?.length)
|
|
130
|
+
applyJoinLeftConditions(cb, ref.conditions, scope, dbTables);
|
|
131
|
+
const sql = cb.build(currentIdx, db.ph);
|
|
132
|
+
const vals = cb.getValues();
|
|
133
|
+
if (sql) {
|
|
134
|
+
whereExtras.push(sql);
|
|
135
|
+
values.push(...vals);
|
|
136
|
+
currentIdx += vals.length;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { whereExtras, values };
|
|
140
|
+
}
|
|
141
|
+
// ─── Row-returning join families (side query per alias) ─────
|
|
142
|
+
/**
|
|
143
|
+
* Correlate a join side query to the rows the main query returned (`fk IN (ids)`) and apply
|
|
144
|
+
* the tenant scope, then freeze it into SQL.
|
|
145
|
+
*
|
|
146
|
+
* Placeholders start at 1 because every side query is a statement of its own.
|
|
147
|
+
*/
|
|
148
|
+
function scopeJoinSideQuery(cb, db, joinSchema, joinField, ids, joinTableConf, tenant) {
|
|
149
|
+
cb.isIn(`${db.qi(joinSchema.tableName)}.${db.qi(joinSchema.col(joinField))}`, ids);
|
|
150
|
+
const tenantJoins = [];
|
|
151
|
+
appendJoinTenantScope(db, joinTableConf, tenant, joinSchema.tableName, cb, tenantJoins);
|
|
152
|
+
const where = cb.build(1, db.ph);
|
|
153
|
+
return { where, values: cb.getValues(), tenantJoins };
|
|
154
|
+
}
|
|
155
|
+
async function fetchJoinRows(db, dbTables, tableConf, mainResults, requests, mode, tenant) {
|
|
156
|
+
const result = {};
|
|
157
|
+
for (const [alias, ref] of Object.entries(requests)) {
|
|
158
|
+
const joinDef = requireJoin(tableConf, alias, mode.requireUnique);
|
|
159
|
+
const { joinSchema, joinField, mainField, selection: defaultSelection } = joinDef;
|
|
160
|
+
const joinTableConf = dbTables[joinSchema.tableName];
|
|
161
|
+
// The selection is resolved before the empty-result bail-out: it is validated against
|
|
162
|
+
// the join schema, and that rejection must not depend on whether the main query
|
|
163
|
+
// matched rows.
|
|
164
|
+
const selection = ref?.selection ?? defaultSelection;
|
|
165
|
+
const columns = buildSelectionColumns(selection, joinSchema, db, joinTableConf, joinDef.fields !== undefined);
|
|
166
|
+
// For joinLeft, mainField on the main table is the FK pointing at joinField (the
|
|
167
|
+
// parent's PK), so the same collect-then-look-up works for both directions.
|
|
168
|
+
const ids = collectIds(mainResults, mainField);
|
|
169
|
+
if (ids.length === 0) {
|
|
170
|
+
result[alias] = [];
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const cb = mode.applyRefCondition
|
|
174
|
+
? buildJoinRefCondition(joinTableConf, joinSchema, ref || {}, db)
|
|
175
|
+
: new ConditionBuilder('AND', db.cbDialect);
|
|
176
|
+
const { where, values, tenantJoins } = scopeJoinSideQuery(cb, db, joinSchema, joinField, ids, joinTableConf, tenant);
|
|
177
|
+
const rows = await db.select({
|
|
178
|
+
tableName: joinSchema.tableName,
|
|
179
|
+
columns,
|
|
180
|
+
where,
|
|
181
|
+
values,
|
|
182
|
+
joins: tenantJoins.length > 0 ? tenantJoins : undefined,
|
|
183
|
+
});
|
|
184
|
+
result[alias] = mapRowsToCamelCase(rows, joinSchema);
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
/** joinMultiple: 1:N children, returned as rows under `result.joinMultiple.<alias>`. */
|
|
189
|
+
export function executeJoinMultiple(db, dbTables, tableConf, mainResults, joinMultiple, tenant) {
|
|
190
|
+
return fetchJoinRows(db, dbTables, tableConf, mainResults, joinMultiple, {
|
|
191
|
+
requireUnique: false,
|
|
192
|
+
applyRefCondition: true,
|
|
193
|
+
}, tenant);
|
|
194
|
+
}
|
|
195
|
+
/** joinLeft: N:1 parents, returned as rows under `result.joinLeft.<alias>`. */
|
|
196
|
+
export function executeJoinLeft(db, dbTables, tableConf, mainResults, joinLeft, tenant) {
|
|
197
|
+
return fetchJoinRows(db, dbTables, tableConf, mainResults, joinLeft, {
|
|
198
|
+
requireUnique: true,
|
|
199
|
+
applyRefCondition: false,
|
|
200
|
+
}, tenant);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Resolve `aggregations.by` to a SQL expression. Accepts:
|
|
204
|
+
* - a schema field name → quoted column reference
|
|
205
|
+
* - a computed-field name declared on the join table → its expr (no bound
|
|
206
|
+
* values supported in this position; reject with 400 if the computed
|
|
207
|
+
* returns values)
|
|
208
|
+
*/
|
|
209
|
+
function buildByExpression(by, joinSchema, joinTableConf, db) {
|
|
210
|
+
if (typeof by !== 'string') {
|
|
211
|
+
err400(`Invalid 'by' specification: expected a field or computed name`);
|
|
212
|
+
}
|
|
213
|
+
if (hasOwnField(joinSchema.fields, by)) {
|
|
214
|
+
// validateSchemaField also enforces readExclude: GROUP BY on a hidden field
|
|
215
|
+
// would return its distinct values verbatim in `rows[].by`.
|
|
216
|
+
const col = validateSchemaField(by, joinSchema, joinTableConf);
|
|
217
|
+
return `${db.qi(joinSchema.tableName)}.${db.qi(col)}`;
|
|
218
|
+
}
|
|
219
|
+
const fn = ownField(joinTableConf?.computedFields, by);
|
|
220
|
+
if (fn) {
|
|
221
|
+
return evaluateComputedField(by, fn, joinSchema, db, undefined, `Computed field '${by}' with bound values cannot be used in aggregations.by`).value;
|
|
222
|
+
}
|
|
223
|
+
err400(`Unknown field: ${by}`);
|
|
224
|
+
}
|
|
225
|
+
// ─── joinGroup (aggregations) ───────────────────────────────
|
|
226
|
+
/**
|
|
227
|
+
* SELECT and GROUP BY fragments for one joinGroup alias.
|
|
228
|
+
*
|
|
229
|
+
* Every field is validated against the join schema here, so an unknown or unreachable one
|
|
230
|
+
* is a 400 regardless of what the data happens to contain.
|
|
231
|
+
*/
|
|
232
|
+
function buildJoinGroupSelect(aggregations, joinSchema, joinTableConf, db) {
|
|
233
|
+
const selectParts = [];
|
|
234
|
+
const groupByParts = [];
|
|
235
|
+
if (aggregations.by) {
|
|
236
|
+
const byExpr = buildByExpression(aggregations.by, joinSchema, joinTableConf, db);
|
|
237
|
+
selectParts.push(`${byExpr} as "by"`);
|
|
238
|
+
groupByParts.push(byExpr);
|
|
239
|
+
}
|
|
240
|
+
const addAgg = (kind, fields) => {
|
|
241
|
+
if (!fields)
|
|
242
|
+
return;
|
|
243
|
+
const fn = AGG_FN[kind];
|
|
244
|
+
for (const f of fields) {
|
|
245
|
+
const col = validateSchemaField(f, joinSchema, joinTableConf);
|
|
246
|
+
const colRef = `${db.qi(joinSchema.tableName)}.${db.qi(col)}`;
|
|
247
|
+
selectParts.push(`${aggExpr(fn, colRef)} as "${kind}_${f}"`);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
addAgg('distinctCount', aggregations.distinctCount);
|
|
251
|
+
addAgg('min', aggregations.min);
|
|
252
|
+
addAgg('max', aggregations.max);
|
|
253
|
+
addAgg('sum', aggregations.sum);
|
|
254
|
+
addAgg('avg', aggregations.avg);
|
|
255
|
+
addAgg('count', aggregations.count);
|
|
256
|
+
return { selectParts, groupByParts };
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Shape aggregation rows into the response: a single row without `by` collapses to
|
|
260
|
+
* `{ fn: { field: value } }`; anything else stays a list under `rows`.
|
|
261
|
+
*/
|
|
262
|
+
function formatJoinGroupRows(rows, hasBy) {
|
|
263
|
+
const formatted = {};
|
|
264
|
+
if (rows.length === 0)
|
|
265
|
+
return formatted;
|
|
266
|
+
const row = rows.length === 1 && !hasBy ? rows[0] : rows;
|
|
267
|
+
if (Array.isArray(row)) {
|
|
268
|
+
formatted.rows = row;
|
|
269
|
+
return formatted;
|
|
270
|
+
}
|
|
271
|
+
for (const [key, value] of Object.entries(row)) {
|
|
272
|
+
if (key === 'by')
|
|
273
|
+
continue;
|
|
274
|
+
const [fn, field] = key.split('_');
|
|
275
|
+
if (!formatted[fn])
|
|
276
|
+
formatted[fn] = {};
|
|
277
|
+
formatted[fn][field] = value;
|
|
278
|
+
}
|
|
279
|
+
return formatted;
|
|
280
|
+
}
|
|
281
|
+
export async function executeJoinGroup(db, dbTables, tableConf, mainResults, joinGroup, tenant) {
|
|
282
|
+
const result = {};
|
|
283
|
+
for (const [alias, groupReq] of Object.entries(joinGroup)) {
|
|
284
|
+
const joinDef = requireJoin(tableConf, alias, false);
|
|
285
|
+
const { joinSchema, joinField, mainField } = joinDef;
|
|
286
|
+
const joinTableConf = dbTables[joinSchema.tableName];
|
|
287
|
+
const ids = collectIds(mainResults, mainField);
|
|
288
|
+
const { aggregations, filters: groupFilters, conditions: groupConditions } = groupReq;
|
|
289
|
+
const { selectParts, groupByParts } = buildJoinGroupSelect(aggregations, joinSchema, joinTableConf, db);
|
|
290
|
+
// Bail out only here: `by` and every aggregation field have now been validated.
|
|
291
|
+
if (selectParts.length === 0 || ids.length === 0) {
|
|
292
|
+
result[alias] = {};
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const groupRef = { filters: groupFilters, conditions: groupConditions };
|
|
296
|
+
const cb = buildJoinRefCondition(joinTableConf, joinSchema, groupRef, db);
|
|
297
|
+
const { where, values, tenantJoins } = scopeJoinSideQuery(cb, db, joinSchema, joinField, ids, joinTableConf, tenant);
|
|
298
|
+
const groupBy = groupByParts.length > 0 ? `GROUP BY ${groupByParts.join(', ')}` : '';
|
|
299
|
+
const fromJoins = tenantJoins.length > 0 ? ` ${tenantJoins.join(' ')}` : '';
|
|
300
|
+
const sql = `SELECT ${selectParts.join(', ')} FROM ${db.qi(joinSchema.tableName)}${fromJoins} WHERE ${where} ${groupBy}`;
|
|
301
|
+
const queryResult = await db.query(sql, values);
|
|
302
|
+
result[alias] = formatJoinGroupRows(queryResult.rows, Boolean(aggregations.by));
|
|
303
|
+
}
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
// ─── ID collection ──────────────────────────────────────────
|
|
307
|
+
function collectIds(mainResults, mainField) {
|
|
308
|
+
if (Array.isArray(mainField)) {
|
|
309
|
+
const seen = new Set();
|
|
310
|
+
const ids = [];
|
|
311
|
+
for (const r of mainResults) {
|
|
312
|
+
const key = mainField.map((f) => r[f]).join('|');
|
|
313
|
+
if (!seen.has(key) && mainField.every((f) => r[f] != null)) {
|
|
314
|
+
seen.add(key);
|
|
315
|
+
ids.push(...mainField.map((f) => r[f]));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return ids;
|
|
319
|
+
}
|
|
320
|
+
return [...new Set(mainResults.map((r) => r[mainField]).filter((v) => v != null))];
|
|
321
|
+
}
|
|
322
|
+
// ─── joinMustExist (EXISTS subquery filter on main) ─────────
|
|
323
|
+
export function buildJoinMustExistClauses(params, db, dbTables, tableConf, joinMustExist, tenant) {
|
|
324
|
+
let where = '';
|
|
325
|
+
for (const [alias, ref] of Object.entries(joinMustExist)) {
|
|
326
|
+
const joinDef = requireJoin(tableConf, alias, false);
|
|
327
|
+
const { joinSchema } = joinDef;
|
|
328
|
+
const joinTableConf = dbTables[joinSchema.tableName];
|
|
329
|
+
const refs = extractJoinRefs(db, tableConf, joinDef);
|
|
330
|
+
// The EXISTS FROM is aliased so the correlation to the outer table survives a
|
|
331
|
+
// self-referencing relation; everything inside references the alias.
|
|
332
|
+
const subAlias = subqueryAlias(alias, tableConf);
|
|
333
|
+
const subRef = db.qi(subAlias);
|
|
334
|
+
const filterCondition = buildJoinRefCondition(joinTableConf, joinSchema, ref, db, subAlias);
|
|
335
|
+
const tenantJoins = [];
|
|
336
|
+
appendJoinTenantScope(db, joinTableConf, tenant, subAlias, filterCondition, tenantJoins);
|
|
337
|
+
const filterWhere = params.emitCondition(filterCondition, db);
|
|
338
|
+
const innerWhere = filterWhere
|
|
339
|
+
? `${subRef}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol} AND ${filterWhere}`
|
|
340
|
+
: `${subRef}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol}`;
|
|
341
|
+
const existsJoins = tenantJoins.length > 0 ? ` ${tenantJoins.join(' ')}` : '';
|
|
342
|
+
where += ` AND EXISTS (SELECT 1 FROM ${refs.joinTable} AS ${subRef}${existsJoins} WHERE ${innerWhere})`;
|
|
343
|
+
}
|
|
344
|
+
return where;
|
|
345
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Join resolution for the search engine: finding the relation an alias refers to,
|
|
3
|
+
* validating what a request may ask of it, and quoting its identifiers.
|
|
4
|
+
*
|
|
5
|
+
* Relations are *declared* at config time by `buildRelation` (see lib/table-helpers.ts);
|
|
6
|
+
* this module resolves them per request.
|
|
7
|
+
*/
|
|
8
|
+
import { ConditionBuilder } from 'node-condition-builder';
|
|
9
|
+
import type { QueryClient } from '../../db.js';
|
|
10
|
+
import type { DbTables, FilterRecord, ITable, JoinDefinition, TenantContext } from '../../../types.js';
|
|
11
|
+
/**
|
|
12
|
+
* Tenant-scope a join side-query. When the joined table declares its own `tenantScope`,
|
|
13
|
+
* appends the tenant condition to `cb` (direct: `col IN ids`; indirect: condition on the
|
|
14
|
+
* through table) and, for indirect scopes, pushes the INNER JOIN clause into `joins`.
|
|
15
|
+
* No-op without a tenant context (admin) or when the join table is not tenant-scoped.
|
|
16
|
+
*
|
|
17
|
+
* `joinTableRef` is the raw name the join table goes by in the statement: its table name
|
|
18
|
+
* in plain side-queries, or the subquery alias inside an aliased, correlated EXISTS.
|
|
19
|
+
*/
|
|
20
|
+
export declare function appendJoinTenantScope(db: QueryClient, joinTableConf: ITable | undefined, tenant: TenantContext | undefined, joinTableRef: string, cb: ConditionBuilder, joins?: string[]): void;
|
|
21
|
+
/**
|
|
22
|
+
* Alias for the FROM of a correlated subquery (EXISTS, aggregate). The subquery must not
|
|
23
|
+
* say `FROM <table>` bare: on a self-referencing relation the inner table would shadow the
|
|
24
|
+
* outer one and the correlation would silently compare each row with itself. The request
|
|
25
|
+
* alias is used, suffixed when it would itself collide with the outer table's name.
|
|
26
|
+
*/
|
|
27
|
+
export declare function subqueryAlias(alias: string, tableConf: ITable): string;
|
|
28
|
+
/**
|
|
29
|
+
* Validate the filter keys of every join reference in a request map.
|
|
30
|
+
*
|
|
31
|
+
* Runs up front, before any query: the executors skip their side-query entirely when the
|
|
32
|
+
* main result set is empty (`ids.length === 0`), so validating there alone would make the
|
|
33
|
+
* same request answer 400 or 200 depending on the data it happens to match.
|
|
34
|
+
*
|
|
35
|
+
* Unknown aliases are left to `requireJoin`, which reports them with its own message.
|
|
36
|
+
*/
|
|
37
|
+
export declare function assertJoinFilterKeys(dbTables: DbTables, tableConf: ITable, refs: Record<string, {
|
|
38
|
+
filters?: FilterRecord;
|
|
39
|
+
}> | undefined, allowExtraFilters: boolean): void;
|
|
40
|
+
export declare function requireJoin(tableConf: ITable, alias: string, requireUnique: boolean): JoinDefinition;
|
|
41
|
+
export interface JoinRefs {
|
|
42
|
+
mainColName: string;
|
|
43
|
+
/** db.qi'd column reference on main side (FK or PK) */
|
|
44
|
+
mainCol: string;
|
|
45
|
+
/** db.qi'd table name on main side */
|
|
46
|
+
mainTable: string;
|
|
47
|
+
/** db.qi'd table name on join side */
|
|
48
|
+
joinTable: string;
|
|
49
|
+
/** db.qi'd column reference on join side (FK or PK) */
|
|
50
|
+
fkCol: string;
|
|
51
|
+
}
|
|
52
|
+
/** Extract quoted identifiers for a join reference. Used by joinMustExist, joinLeft and joinGroup
|
|
53
|
+
* SQL builders to avoid the 4-line FK/PK destructuring boilerplate at every call site. */
|
|
54
|
+
export declare function extractJoinRefs(db: QueryClient, tableConf: ITable, joinDef: JoinDefinition): JoinRefs;
|
|
55
|
+
//# sourceMappingURL=joins.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"joins.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/joins.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAI/C,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,MAAM,EACN,cAAc,EACd,aAAa,EACd,MAAM,mBAAmB,CAAC;AAE3B;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CACnC,EAAE,EAAE,WAAW,EACf,aAAa,EAAE,MAAM,GAAG,SAAS,EACjC,MAAM,EAAE,aAAa,GAAG,SAAS,EACjC,YAAY,EAAE,MAAM,EACpB,EAAE,EAAE,gBAAgB,EACpB,KAAK,CAAC,EAAE,MAAM,EAAE,GACf,IAAI,CAON;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAEtE;AAMD;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE;IAAE,OAAO,CAAC,EAAE,YAAY,CAAA;CAAE,CAAC,GAAG,SAAS,EAC5D,iBAAiB,EAAE,OAAO,GACzB,IAAI,CAUN;AAED,wBAAgB,WAAW,CACzB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,aAAa,EAAE,OAAO,GACrB,cAAc,CAYhB;AAED,MAAM,WAAW,QAAQ;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB,sCAAsC;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,sCAAsC;IACtC,SAAS,EAAE,MAAM,CAAC;IAClB,uDAAuD;IACvD,KAAK,EAAE,MAAM,CAAC;CACf;AAED;0FAC0F;AAC1F,wBAAgB,eAAe,CAC7B,EAAE,EAAE,WAAW,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,GACtB,QAAQ,CAUV"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { buildTenantCondition, buildTenantJoin } from '../../tenant.js';
|
|
2
|
+
import { assertFiltersReadable, assertKnownFilterKeys } from '../../read-access.js';
|
|
3
|
+
import { err400 } from './fields.js';
|
|
4
|
+
/**
|
|
5
|
+
* Tenant-scope a join side-query. When the joined table declares its own `tenantScope`,
|
|
6
|
+
* appends the tenant condition to `cb` (direct: `col IN ids`; indirect: condition on the
|
|
7
|
+
* through table) and, for indirect scopes, pushes the INNER JOIN clause into `joins`.
|
|
8
|
+
* No-op without a tenant context (admin) or when the join table is not tenant-scoped.
|
|
9
|
+
*
|
|
10
|
+
* `joinTableRef` is the raw name the join table goes by in the statement: its table name
|
|
11
|
+
* in plain side-queries, or the subquery alias inside an aliased, correlated EXISTS.
|
|
12
|
+
*/
|
|
13
|
+
export function appendJoinTenantScope(db, joinTableConf, tenant, joinTableRef, cb, joins) {
|
|
14
|
+
const scope = joinTableConf?.tenantScope;
|
|
15
|
+
if (!tenant || !scope)
|
|
16
|
+
return;
|
|
17
|
+
cb.append(buildTenantCondition(db, scope, tenant.ids, joinTableRef));
|
|
18
|
+
if ('through' in scope) {
|
|
19
|
+
joins?.push(buildTenantJoin(db, scope, joinTableRef));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Alias for the FROM of a correlated subquery (EXISTS, aggregate). The subquery must not
|
|
24
|
+
* say `FROM <table>` bare: on a self-referencing relation the inner table would shadow the
|
|
25
|
+
* outer one and the correlation would silently compare each row with itself. The request
|
|
26
|
+
* alias is used, suffixed when it would itself collide with the outer table's name.
|
|
27
|
+
*/
|
|
28
|
+
export function subqueryAlias(alias, tableConf) {
|
|
29
|
+
return alias === tableConf.Schema.tableName ? `${alias}_sub` : alias;
|
|
30
|
+
}
|
|
31
|
+
function findJoinByAlias(tableConf, alias) {
|
|
32
|
+
return tableConf.allowedReadJoins?.find((j) => j.alias === alias);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Validate the filter keys of every join reference in a request map.
|
|
36
|
+
*
|
|
37
|
+
* Runs up front, before any query: the executors skip their side-query entirely when the
|
|
38
|
+
* main result set is empty (`ids.length === 0`), so validating there alone would make the
|
|
39
|
+
* same request answer 400 or 200 depending on the data it happens to match.
|
|
40
|
+
*
|
|
41
|
+
* Unknown aliases are left to `requireJoin`, which reports them with its own message.
|
|
42
|
+
*/
|
|
43
|
+
export function assertJoinFilterKeys(dbTables, tableConf, refs, allowExtraFilters) {
|
|
44
|
+
if (!refs)
|
|
45
|
+
return;
|
|
46
|
+
for (const [alias, ref] of Object.entries(refs)) {
|
|
47
|
+
const joinDef = findJoinByAlias(tableConf, alias);
|
|
48
|
+
if (!joinDef)
|
|
49
|
+
continue;
|
|
50
|
+
const joinTableConf = dbTables[joinDef.joinSchema.tableName];
|
|
51
|
+
const filters = ref?.filters;
|
|
52
|
+
assertFiltersReadable(filters, joinTableConf);
|
|
53
|
+
assertKnownFilterKeys(filters, joinDef.joinSchema, joinTableConf, allowExtraFilters);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export 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
|
+
export 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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing and validation of ordering, in its three dotted forms:
|
|
3
|
+
* 1-part `field` schema field or computed
|
|
4
|
+
* 2-part `alias.field` a joinLeft parent field, via a real LEFT JOIN
|
|
5
|
+
* 3-part `alias.fn.field` a joinGroup aggregation, via a correlated subquery
|
|
6
|
+
*
|
|
7
|
+
* The request `orderBy` is validated strictly; `defaultOrder` from the table config is
|
|
8
|
+
* deliberately lenient — see `convertConfiguredOrder`.
|
|
9
|
+
*/
|
|
10
|
+
import type { QueryClient } from '../../db.js';
|
|
11
|
+
import type { DbTables, ITable, JoinGroupRequest, TenantContext } from '../../../types.js';
|
|
12
|
+
export interface OrderByResult {
|
|
13
|
+
sql: string;
|
|
14
|
+
values: unknown[];
|
|
15
|
+
/** Aliases referenced in 2-part notation (joinLeft) — need a LEFT JOIN. */
|
|
16
|
+
leftJoinAliases: Set<string>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Pre-scan `orderBy` for the joinLeft aliases referenced in 2-part notation (`<alias>.<field>`).
|
|
20
|
+
* Needed BEFORE building the LEFT JOIN clauses (which must know their aliases) while the actual
|
|
21
|
+
* orderBy SQL — with its parameter placeholders — is only baked later, once the LEFT JOIN value
|
|
22
|
+
* count is known. Validates each alias against `allowedReadJoins` (throws 400), mirroring
|
|
23
|
+
* `validateOrderBy`; 3-part aggregation entries are ignored (they use joinGroup, not a LEFT JOIN).
|
|
24
|
+
*/
|
|
25
|
+
export declare function collectOrderByLeftAliases(orderBy: string, tableConf: ITable): Set<string>;
|
|
26
|
+
export declare function validateOrderBy(orderBy: string, tableConf: ITable, db: QueryClient, dbTables: DbTables, joinGroup: Record<string, JoinGroupRequest> | undefined, startIdx: number, tenant?: TenantContext): OrderByResult;
|
|
27
|
+
/**
|
|
28
|
+
* Converts a configured order (`defaultOrder` or the primary-key fallback) to SQL.
|
|
29
|
+
* Unlike the request `orderBy` (strictly validated by `validateOrderBy`), this is
|
|
30
|
+
* lenient for backward compatibility: tokens matching a camelCase schema field are
|
|
31
|
+
* mapped to their quoted DB column, computed fields (without bound values) expand
|
|
32
|
+
* to their expression, and anything else passes through unchanged as raw SQL.
|
|
33
|
+
*/
|
|
34
|
+
export declare function convertConfiguredOrder(order: string, tableConf: ITable, db: QueryClient): string;
|
|
35
|
+
//# sourceMappingURL=order-by.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"order-by.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/order-by.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAY/C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAE3F,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,OAAO,EAAE,CAAC;IAClB,2EAA2E;IAC3E,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAC9B;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAazF;AAED,wBAAgB,eAAe,CAC7B,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAAG,SAAS,EACvD,QAAQ,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,aAAa,GACrB,aAAa,CAuDf;AAED;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,EACjB,EAAE,EAAE,WAAW,GACd,MAAM,CAwBR"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { err400, validateSchemaField, resolveFieldRef, refValues, renderRef, evaluateComputedField, } from './fields.js';
|
|
2
|
+
import { requireJoin } from './joins.js';
|
|
3
|
+
import { hasOwnField, ownField } from '../../read-access.js';
|
|
4
|
+
import { buildAggOrderExpr } from './aggregations.js';
|
|
5
|
+
/**
|
|
6
|
+
* Pre-scan `orderBy` for the joinLeft aliases referenced in 2-part notation (`<alias>.<field>`).
|
|
7
|
+
* Needed BEFORE building the LEFT JOIN clauses (which must know their aliases) while the actual
|
|
8
|
+
* orderBy SQL — with its parameter placeholders — is only baked later, once the LEFT JOIN value
|
|
9
|
+
* count is known. Validates each alias against `allowedReadJoins` (throws 400), mirroring
|
|
10
|
+
* `validateOrderBy`; 3-part aggregation entries are ignored (they use joinGroup, not a LEFT JOIN).
|
|
11
|
+
*/
|
|
12
|
+
export function collectOrderByLeftAliases(orderBy, tableConf) {
|
|
13
|
+
const aliases = new Set();
|
|
14
|
+
for (const part of orderBy.split(',')) {
|
|
15
|
+
const trimmed = part.trim();
|
|
16
|
+
if (/^(\w+)\.(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i.test(trimmed))
|
|
17
|
+
continue; // 3-part aggregation
|
|
18
|
+
const m = /^(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i.exec(trimmed);
|
|
19
|
+
if (m) {
|
|
20
|
+
const alias = m[1];
|
|
21
|
+
requireJoin(tableConf, alias, true);
|
|
22
|
+
aliases.add(alias);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return aliases;
|
|
26
|
+
}
|
|
27
|
+
export function validateOrderBy(orderBy, tableConf, db, dbTables, joinGroup, startIdx, tenant) {
|
|
28
|
+
const parts = orderBy.split(',');
|
|
29
|
+
const outParts = [];
|
|
30
|
+
const outValues = [];
|
|
31
|
+
const leftJoinAliases = new Set();
|
|
32
|
+
let currentIdx = startIdx;
|
|
33
|
+
for (const part of parts) {
|
|
34
|
+
const trimmed = part.trim();
|
|
35
|
+
// 3-part: <alias>.<fn>.<field> [ASC|DESC] (aggregation via joinGroup)
|
|
36
|
+
const dotted3 = /^(\w+)\.(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i.exec(trimmed);
|
|
37
|
+
if (dotted3) {
|
|
38
|
+
if (tableConf.distinctResults) {
|
|
39
|
+
err400('Cannot combine distinctResults with aggregation orderBy');
|
|
40
|
+
}
|
|
41
|
+
const [, alias, fn, field, dir] = dotted3;
|
|
42
|
+
const expr = buildAggOrderExpr(db, dbTables, tableConf, alias, fn, field, joinGroup, tenant);
|
|
43
|
+
const aggVals = [...expr.values];
|
|
44
|
+
outParts.push(`${renderRef(expr, currentIdx, db)} ${(dir || 'ASC').toUpperCase()}`);
|
|
45
|
+
outValues.push(...aggVals);
|
|
46
|
+
currentIdx += aggVals.length;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
// 2-part: <alias>.<field> [ASC|DESC] (joinLeft inline ordering)
|
|
50
|
+
const dotted2 = /^(\w+)\.(\w+)(?:\s+(ASC|DESC))?$/i.exec(trimmed);
|
|
51
|
+
if (dotted2) {
|
|
52
|
+
const [, alias, field, dir] = dotted2;
|
|
53
|
+
const joinDef = requireJoin(tableConf, alias, true);
|
|
54
|
+
const col = validateSchemaField(field, joinDef.joinSchema, dbTables[joinDef.joinSchema.tableName]);
|
|
55
|
+
// Reference the LEFT JOIN'd table via its alias (SQL identifier).
|
|
56
|
+
outParts.push(`${db.qi(alias)}.${db.qi(col)} ${(dir || 'ASC').toUpperCase()}`);
|
|
57
|
+
leftJoinAliases.add(alias);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// 1-part: <field> [ASC|DESC] — schema field or computed
|
|
61
|
+
const plain = /^(\w+)(?:\s+(ASC|DESC))?$/i.exec(trimmed);
|
|
62
|
+
if (!plain) {
|
|
63
|
+
err400(`Invalid orderBy: ${trimmed}`);
|
|
64
|
+
}
|
|
65
|
+
const [, field, dir] = plain;
|
|
66
|
+
const ref = resolveFieldRef(field, tableConf.Schema, tableConf, db);
|
|
67
|
+
// A computed field binds its own values here: ORDER BY is emitted after the WHERE, so
|
|
68
|
+
// its placeholders continue from `currentIdx`.
|
|
69
|
+
const refVals = refValues(ref.expr);
|
|
70
|
+
outParts.push(`${renderRef(ref.expr, currentIdx, db)} ${(dir || 'ASC').toUpperCase()}`);
|
|
71
|
+
outValues.push(...refVals);
|
|
72
|
+
currentIdx += refVals.length;
|
|
73
|
+
}
|
|
74
|
+
return { sql: outParts.join(', '), values: outValues, leftJoinAliases };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Converts a configured order (`defaultOrder` or the primary-key fallback) to SQL.
|
|
78
|
+
* Unlike the request `orderBy` (strictly validated by `validateOrderBy`), this is
|
|
79
|
+
* lenient for backward compatibility: tokens matching a camelCase schema field are
|
|
80
|
+
* mapped to their quoted DB column, computed fields (without bound values) expand
|
|
81
|
+
* to their expression, and anything else passes through unchanged as raw SQL.
|
|
82
|
+
*/
|
|
83
|
+
export function convertConfiguredOrder(order, tableConf, db) {
|
|
84
|
+
return order
|
|
85
|
+
.split(',')
|
|
86
|
+
.map((part) => {
|
|
87
|
+
const trimmed = part.trim();
|
|
88
|
+
const match = /^(\w+)(?:\s+(ASC|DESC))?$/i.exec(trimmed);
|
|
89
|
+
if (match) {
|
|
90
|
+
const [, field, dir] = match;
|
|
91
|
+
const suffix = dir ? ` ${dir.toUpperCase()}` : '';
|
|
92
|
+
if (hasOwnField(tableConf.Schema.fields, field)) {
|
|
93
|
+
return `${db.qi(tableConf.Schema.tableName)}.${db.qi(tableConf.Schema.col(field))}${suffix}`;
|
|
94
|
+
}
|
|
95
|
+
const computed = ownField(tableConf.computedFields, field);
|
|
96
|
+
if (computed) {
|
|
97
|
+
const ev = evaluateComputedField(field, computed, tableConf.Schema, db, undefined, `Computed field '${field}' with bound values cannot be used in defaultOrder`);
|
|
98
|
+
return `${ev.value}${suffix}`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return trimmed;
|
|
102
|
+
})
|
|
103
|
+
.join(', ');
|
|
104
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/search.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EACV,QAAQ,EACR,YAAY,EACZ,YAAY,EAGb,MAAM,mBAAmB,CAAC;AAwP3B,wBAAsB,YAAY,CAChC,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,YAAY,GACnB,OAAO,CAAC,YAAY,CAAC,CAsFvB"}
|