fastify-auto-sqlapi 0.3.0 → 0.4.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 +24 -111
- package/AGENTS_BACKEND.md +271 -1381
- package/AGENTS_FRONTEND.md +72 -47
- package/BREAKING_CHANGES.md +129 -0
- package/CHANGELOG.md +86 -1
- package/README.md +1 -1
- package/dist/bin/generate-schema.js +2 -2
- package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
- package/dist/lib/cli/mysql-introspect.js +24 -1
- package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
- package/dist/lib/cli/pg-introspect.js +21 -2
- 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 +31 -3
- package/dist/lib/db.d.ts.map +1 -1
- package/dist/lib/db.js +187 -60
- package/dist/lib/dialect.d.ts +15 -10
- package/dist/lib/dialect.d.ts.map +1 -1
- package/dist/lib/dialect.js +12 -31
- package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
- package/dist/lib/engine/bulk/bulk-upsert.js +2 -1
- package/dist/lib/engine/rest/insert.d.ts.map +1 -1
- package/dist/lib/engine/rest/insert.js +2 -1
- package/dist/lib/engine/search/aggregations.d.ts +7 -0
- package/dist/lib/engine/search/aggregations.d.ts.map +1 -1
- package/dist/lib/engine/search/aggregations.js +8 -7
- package/dist/lib/engine/write-helpers.d.ts +13 -1
- package/dist/lib/engine/write-helpers.d.ts.map +1 -1
- package/dist/lib/engine/write-helpers.js +35 -0
- package/dist/lib/table-helpers.d.ts.map +1 -1
- package/dist/lib/table-helpers.js +41 -0
- package/dist/types/cli.d.ts +11 -0
- package/dist/types/cli.d.ts.map +1 -1
- package/dist/types/schema.d.ts +7 -0
- package/dist/types/schema.d.ts.map +1 -1
- package/docs/adr/0021-upsert-targets-a-verified-unique-key.md +153 -0
- package/docs/adr/README.md +1 -0
- package/package.json +1 -1
|
@@ -62,8 +62,21 @@ export function convertColType(udtName, columnInfo) {
|
|
|
62
62
|
return wrap('Type.String()');
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Turn the per-column index names into the table's unique keys: one field list per distinct
|
|
67
|
+
* column set, fields in table order, keys sorted so regenerating yields a stable file.
|
|
68
|
+
*/
|
|
69
|
+
function collectUniqueKeys(indexes) {
|
|
70
|
+
const bySet = new Map();
|
|
71
|
+
for (const fields of indexes.values()) {
|
|
72
|
+
bySet.set(JSON.stringify([...fields].sort()), fields);
|
|
73
|
+
}
|
|
74
|
+
return [...bySet.values()].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
75
|
+
}
|
|
65
76
|
export function buildTableMap(rows) {
|
|
66
|
-
|
|
77
|
+
// schema name → unique index name → fields (camelCase), in table column order
|
|
78
|
+
const uniqueIndexes = new Map();
|
|
79
|
+
const tableMap = rows.reduce((acc, row) => {
|
|
67
80
|
const schemaName = toSchemaName(row.table_name);
|
|
68
81
|
if (!acc[schemaName]) {
|
|
69
82
|
acc[schemaName] = {
|
|
@@ -73,7 +86,9 @@ export function buildTableMap(rows) {
|
|
|
73
86
|
primary: [],
|
|
74
87
|
generated: [],
|
|
75
88
|
isView: row.is_view === true,
|
|
89
|
+
uniqueKeys: [],
|
|
76
90
|
};
|
|
91
|
+
uniqueIndexes.set(schemaName, new Map());
|
|
77
92
|
}
|
|
78
93
|
const fieldName = toCamelCase(row.column_name);
|
|
79
94
|
// Two columns mapping to the same camelCase field (e.g. user_name + userName) would
|
|
@@ -92,8 +107,16 @@ export function buildTableMap(rows) {
|
|
|
92
107
|
if (row.is_generated) {
|
|
93
108
|
acc[schemaName].generated.push(fieldName);
|
|
94
109
|
}
|
|
110
|
+
const indexes = uniqueIndexes.get(schemaName);
|
|
111
|
+
for (const indexName of row.unique_keys ?? []) {
|
|
112
|
+
indexes.set(indexName, [...(indexes.get(indexName) ?? []), fieldName]);
|
|
113
|
+
}
|
|
95
114
|
return acc;
|
|
96
115
|
}, {});
|
|
116
|
+
for (const [schemaName, indexes] of uniqueIndexes) {
|
|
117
|
+
tableMap[schemaName].uniqueKeys = collectUniqueKeys(indexes);
|
|
118
|
+
}
|
|
119
|
+
return tableMap;
|
|
97
120
|
}
|
|
98
121
|
/** Compile a config glob — `*` is the only wildcard, everything else is literal. */
|
|
99
122
|
function globToRegExp(pattern) {
|
|
@@ -174,6 +197,8 @@ export function applyExcludeColumns(tableMap, excludeColumns) {
|
|
|
174
197
|
delete table.fields[field];
|
|
175
198
|
delete table.colMap[field];
|
|
176
199
|
table.generated = table.generated.filter((f) => f !== field);
|
|
200
|
+
// A key naming a column the API cannot write can never be an upsert key
|
|
201
|
+
table.uniqueKeys = table.uniqueKeys?.filter((key) => !key.includes(field));
|
|
177
202
|
excluded.push(`${table.name}.${columnName}`);
|
|
178
203
|
}
|
|
179
204
|
if (Object.keys(table.fields).length === 0) {
|
|
@@ -183,7 +208,7 @@ export function applyExcludeColumns(tableMap, excludeColumns) {
|
|
|
183
208
|
}
|
|
184
209
|
return excluded;
|
|
185
210
|
}
|
|
186
|
-
export function generateSchemaFile(schemaName, tableName, fields, colMap, primary, generated, isView) {
|
|
211
|
+
export function generateSchemaFile(schemaName, tableName, fields, colMap, primary, generated, isView, uniqueKeys) {
|
|
187
212
|
const fieldsStr = JSON.stringify(fields, null, 2).replace(/"(.*?)"\s*:\s*"Type(.*?)"/g, '$1: Type$2');
|
|
188
213
|
const colMapStr = JSON.stringify(colMap, null, 2);
|
|
189
214
|
const primaryKeyLine = primary?.length ? `\n primaryKey: ${JSON.stringify(primary)},` : '';
|
|
@@ -193,6 +218,9 @@ export function generateSchemaFile(schemaName, tableName, fields, colMap, primar
|
|
|
193
218
|
// A view has no PRIMARY KEY constraint to introspect, so the table generator has to be told
|
|
194
219
|
// it may not read the absence of one as "no key exists on this data".
|
|
195
220
|
const isViewLine = isView ? `\n isView: true,` : '';
|
|
221
|
+
// Emitted even when empty: `defineTable` refuses an upsert rule it cannot verify, and an
|
|
222
|
+
// absent property is what says "generated before unique keys were known" (ADR 0021).
|
|
223
|
+
const uniqueKeysLine = uniqueKeys ? `\n uniqueKeys: ${JSON.stringify(uniqueKeys)},` : '';
|
|
196
224
|
const usesNullable = Object.values(fields).some((t) => t.includes('Nullable('));
|
|
197
225
|
const valueImports = usesNullable ? 'Type, Nullable' : 'Type';
|
|
198
226
|
return `// Auto-generated by fastify-auto-sqlapi. Do not edit.
|
|
@@ -220,7 +248,7 @@ export const Schema = {
|
|
|
220
248
|
fields: _Schema,
|
|
221
249
|
validation,
|
|
222
250
|
tableName: "${tableName}",
|
|
223
|
-
partialValidation,${primaryKeyLine}${generatedLine}${isViewLine}
|
|
251
|
+
partialValidation,${primaryKeyLine}${generatedLine}${isViewLine}${uniqueKeysLine}
|
|
224
252
|
};
|
|
225
253
|
export const ${schemaName} = Schema;
|
|
226
254
|
export type TypeSchema = Static<typeof Schema.validation>;
|
package/dist/lib/db.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjF,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/db.js
CHANGED
|
@@ -4,6 +4,8 @@ import { getDialect } from './dialect.js';
|
|
|
4
4
|
// PostgreSQL's hard limit of 65535 bind parameters per statement (≈ 130 columns × 500 rows).
|
|
5
5
|
// Override per-call via the `chunkSize` argument when working with extremely wide tables.
|
|
6
6
|
const DEFAULT_CHUNK_SIZE = 500;
|
|
7
|
+
// Column tagging each row of an upsert key lookup with its record's position in the chunk
|
|
8
|
+
const LOOKUP_POSITION = 'sqlapi_lookup_position';
|
|
7
9
|
/** @deprecated Use db.qi() instead */
|
|
8
10
|
export const escapeIdent = (f) => f.replace(/"/g, '""');
|
|
9
11
|
export class QueryClient {
|
|
@@ -90,39 +92,34 @@ export class QueryClient {
|
|
|
90
92
|
#q(fields) {
|
|
91
93
|
return fields.map((f) => this.dialect.qi(f)).join(', ');
|
|
92
94
|
}
|
|
95
|
+
// The SQL for one value: a raw Expression inline, anything else bound as the next placeholder
|
|
96
|
+
#bind(val, values) {
|
|
97
|
+
if (val instanceof Expression)
|
|
98
|
+
return val.value;
|
|
99
|
+
values.push(val);
|
|
100
|
+
return this.dialect.ph(values.length);
|
|
101
|
+
}
|
|
93
102
|
// Appends record fields/values to a shared values array, auto-indexing placeholders
|
|
94
103
|
#params(record, values) {
|
|
95
104
|
const fields = [];
|
|
96
105
|
const placeholders = [];
|
|
97
106
|
for (const [field, val] of Object.entries(record)) {
|
|
98
107
|
fields.push(field);
|
|
99
|
-
|
|
100
|
-
placeholders.push(val.value);
|
|
101
|
-
}
|
|
102
|
-
else {
|
|
103
|
-
values.push(val);
|
|
104
|
-
placeholders.push(this.dialect.ph(values.length));
|
|
105
|
-
}
|
|
108
|
+
placeholders.push(this.#bind(val, values));
|
|
106
109
|
}
|
|
107
110
|
return { fields, placeholders };
|
|
108
111
|
}
|
|
112
|
+
// Builds `col = placeholder` pairs joined by `separator` (', ' for SET, ' AND ' for WHERE)
|
|
113
|
+
#equalities(record, values, separator) {
|
|
114
|
+
const { fields, placeholders } = this.#params(record, values);
|
|
115
|
+
return fields.map((f, i) => `${this.dialect.qi(f)} = ${placeholders[i]}`).join(separator);
|
|
116
|
+
}
|
|
109
117
|
// Builds multi-row VALUES string for bulk operations
|
|
110
118
|
#bulkValues(fields, records) {
|
|
111
119
|
const values = [];
|
|
112
120
|
const rowPlaceholders = [];
|
|
113
121
|
for (const record of records) {
|
|
114
|
-
|
|
115
|
-
for (const field of fields) {
|
|
116
|
-
const val = record[field];
|
|
117
|
-
if (val instanceof Expression) {
|
|
118
|
-
cols.push(val.value);
|
|
119
|
-
}
|
|
120
|
-
else {
|
|
121
|
-
values.push(val);
|
|
122
|
-
cols.push(this.dialect.ph(values.length));
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
rowPlaceholders.push(`(${cols.join(', ')})`);
|
|
122
|
+
rowPlaceholders.push(`(${fields.map((field) => this.#bind(record[field], values)).join(', ')})`);
|
|
126
123
|
}
|
|
127
124
|
return { values, rows: rowPlaceholders.join(', ') };
|
|
128
125
|
}
|
|
@@ -170,30 +167,179 @@ export class QueryClient {
|
|
|
170
167
|
return this.#extractPkRows(result, pkCol, [record])[0];
|
|
171
168
|
}
|
|
172
169
|
async insertOrUpdate(table, record, conflictKeys, pkCol) {
|
|
173
|
-
|
|
174
|
-
const { fields, placeholders } = this.#params(record, values);
|
|
175
|
-
if (!fields.length)
|
|
170
|
+
if (!Object.keys(record).length)
|
|
176
171
|
throw new Error('Cannot execute empty insert');
|
|
177
|
-
const
|
|
178
|
-
|
|
172
|
+
const [pkRow] = await this.#bulkWrite(table, [record], pkCol, 1, { conflictKeys, keepAsIs: conflictKeys });
|
|
173
|
+
return pkRow;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Group records by their exact field set, keeping each record's input position. A bulk write
|
|
177
|
+
* is N single writes issued efficiently (ADR 0003), so every record writes only its own
|
|
178
|
+
* fields: records with different fields cannot share a statement, or a field missing from
|
|
179
|
+
* the statement is dropped and a field missing from the record is bound as NULL — which
|
|
180
|
+
* skips the column default on insert and overwrites the stored value on upsert.
|
|
181
|
+
*/
|
|
182
|
+
#groupByFields(records) {
|
|
183
|
+
const groups = new Map();
|
|
184
|
+
records.forEach((record, i) => {
|
|
185
|
+
const fields = Object.keys(record);
|
|
186
|
+
const key = JSON.stringify([...fields].sort());
|
|
187
|
+
const group = groups.get(key);
|
|
188
|
+
if (group)
|
|
189
|
+
group.indexes.push(i);
|
|
190
|
+
else
|
|
191
|
+
groups.set(key, { fields, indexes: [i] });
|
|
192
|
+
});
|
|
193
|
+
return [...groups.values()];
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Rows found by each record's key, keyed by the record's position. One statement for the
|
|
197
|
+
* whole chunk: a `UNION ALL` part per record, each row tagged with its position, so the order
|
|
198
|
+
* the database returns them in does not matter and value comparison stays the database's
|
|
199
|
+
* (types, collations). Records whose key holds a null are not looked up: NULL never matches.
|
|
200
|
+
*/
|
|
201
|
+
async #findByKey(table, records, pkCol, keys) {
|
|
202
|
+
const found = new Map();
|
|
203
|
+
const values = [];
|
|
204
|
+
const position = this.dialect.qi(LOOKUP_POSITION);
|
|
205
|
+
const from = `${this.#quotedPkCols(pkCol)} FROM ${this.dialect.qi(table)}`;
|
|
206
|
+
const lookups = records.flatMap((record, i) => {
|
|
207
|
+
if (keys.some((k) => record[k] == null))
|
|
208
|
+
return [];
|
|
209
|
+
const where = this.#equalities(Object.fromEntries(keys.map((k) => [k, record[k]])), values, ' AND ');
|
|
210
|
+
return [`SELECT ${i} AS ${position}, ${from} WHERE ${where}`];
|
|
211
|
+
});
|
|
212
|
+
if (!lookups.length)
|
|
213
|
+
return found;
|
|
214
|
+
const result = await this.query(lookups.join(' UNION ALL '), values);
|
|
215
|
+
for (const { [LOOKUP_POSITION]: i, ...pk } of result.rows) {
|
|
216
|
+
found.set(Number(i), [...(found.get(Number(i)) ?? []), pk]);
|
|
217
|
+
}
|
|
218
|
+
for (const rows of found.values()) {
|
|
219
|
+
if (rows.length > 1) {
|
|
220
|
+
throw new Error(`An upsert key (${keys.join(', ')}) of "${table}" matches several rows: it is not unique ` +
|
|
221
|
+
`in the database — nothing was written (ADR 0021)`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return found;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* PK rows for postgres upsert records that update nothing on conflict: `DO NOTHING` returns no
|
|
228
|
+
* row for an existing one. The record gives its PK when it holds every PK column; otherwise
|
|
229
|
+
* the row — inserted or untouched — is looked up by its conflict keys.
|
|
230
|
+
*/
|
|
231
|
+
async #untouchedPkRows(table, records, pkCol, conflictKeys) {
|
|
232
|
+
const pkCols = Array.isArray(pkCol) ? pkCol : [pkCol];
|
|
233
|
+
const holdsPk = (record) => pkCols.every((c) => record[c] != null && !(record[c] instanceof Expression));
|
|
234
|
+
const found = await this.#findByKey(table, records.map((r) => (holdsPk(r) ? {} : r)), pkCol, conflictKeys);
|
|
235
|
+
return records.map((record, i) => {
|
|
236
|
+
if (holdsPk(record))
|
|
237
|
+
return Object.fromEntries(pkCols.map((c) => [c, record[c]]));
|
|
238
|
+
const [row] = found.get(i) ?? [];
|
|
239
|
+
if (!row) {
|
|
240
|
+
throw new Error(`Cannot resolve the primary key of an upserted "${table}" row: no row matches its conflict keys (${conflictKeys.join(', ')})`);
|
|
241
|
+
}
|
|
242
|
+
return row;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* One multi-row INSERT for records sharing `fields`; `tail` is the upsert clause, if any.
|
|
247
|
+
* Returns the PK rows in the records' order.
|
|
248
|
+
*/
|
|
249
|
+
async #insertRows(table, fields, records, pkCol, tail = '') {
|
|
250
|
+
const { values, rows } = this.#bulkValues(fields, records);
|
|
179
251
|
const result = await this.query(`${this.#insertHead(table, fields)}
|
|
180
|
-
VALUES
|
|
181
|
-
${
|
|
182
|
-
return this.#extractPkRows(result, pkCol,
|
|
252
|
+
VALUES ${rows}
|
|
253
|
+
${tail}${this.dialect.returningPk(this.#quotedPkCols(pkCol))}`, values);
|
|
254
|
+
return this.#extractPkRows(result, pkCol, records);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Update existing rows by their key in one statement: each column takes a `CASE` over the
|
|
258
|
+
* keys, and the WHERE names the same keys, so a row can only be reached through its own key.
|
|
259
|
+
* Every row must be matched: one deleted since it was read is an error, never a skipped write.
|
|
260
|
+
*/
|
|
261
|
+
async #updateByKey(table, columns, records, keys) {
|
|
262
|
+
const values = [];
|
|
263
|
+
const match = (record) => `(${this.#equalities(Object.fromEntries(keys.map((k) => [k, record[k]])), values, ' AND ')})`;
|
|
264
|
+
const set = columns.map((col) => {
|
|
265
|
+
const cases = records.map((r) => `WHEN ${match(r)} THEN ${this.#bind(r[col], values)}`);
|
|
266
|
+
return `${this.dialect.qi(col)} = CASE ${cases.join(' ')} END`;
|
|
267
|
+
});
|
|
268
|
+
const where = records.map(match).join(' OR ');
|
|
269
|
+
const result = await this.query(`UPDATE ${this.dialect.qi(table)} SET ${set.join(', ')} WHERE ${where}`, values);
|
|
270
|
+
if (result.affectedRows !== records.length) {
|
|
271
|
+
throw new Error(`Upsert on "${table}": ${records.length - result.affectedRows} of ${records.length} rows ` +
|
|
272
|
+
`to update were not found — deleted after the upsert read them`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Upsert by key for dialects whose upsert syntax names no conflict target (mysql/mariadb,
|
|
277
|
+
* ADR 0021): probe which keys exist, insert the rest, update the found rows by their key.
|
|
278
|
+
* Only the row the key names can be written — a collision on another unique index is an
|
|
279
|
+
* error, as it is on postgres. PKs come from the probe and from the insert.
|
|
280
|
+
*/
|
|
281
|
+
async #upsertByKey(table, fields, records, pkCol, upsert) {
|
|
282
|
+
// A group missing a key field can only insert: nothing to probe
|
|
283
|
+
const found = upsert.conflictKeys.every((k) => fields.includes(k))
|
|
284
|
+
? await this.#findByKey(table, records, pkCol, upsert.conflictKeys)
|
|
285
|
+
: new Map();
|
|
286
|
+
const pkRows = new Array(records.length);
|
|
287
|
+
const inserts = records.flatMap((_, i) => (found.has(i) ? [] : [i]));
|
|
288
|
+
if (inserts.length) {
|
|
289
|
+
const inserted = await this.#insertRows(table, fields, inserts.map((i) => records[i]), pkCol);
|
|
290
|
+
inserts.forEach((i, k) => { pkRows[i] = inserted[k]; });
|
|
291
|
+
}
|
|
292
|
+
const updates = [...found.keys()];
|
|
293
|
+
const columns = fields.filter((f) => !upsert.keepAsIs.includes(f));
|
|
294
|
+
if (updates.length && columns.length) {
|
|
295
|
+
await this.#updateByKey(table, columns, updates.map((i) => records[i]), upsert.conflictKeys);
|
|
296
|
+
}
|
|
297
|
+
for (const i of updates)
|
|
298
|
+
pkRows[i] = found.get(i)[0];
|
|
299
|
+
return pkRows;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* One chunk of records sharing `fields`: a plain insert, or an upsert — `ON CONFLICT (key)`
|
|
303
|
+
* where the dialect has it, by key otherwise. PK rows in the records' order.
|
|
304
|
+
*/
|
|
305
|
+
async #writeChunk(table, fields, records, pkCol, upsert) {
|
|
306
|
+
if (!upsert)
|
|
307
|
+
return this.#insertRows(table, fields, records, pkCol);
|
|
308
|
+
const onConflict = this.dialect.onConflict;
|
|
309
|
+
if (!onConflict)
|
|
310
|
+
return this.#upsertByKey(table, fields, records, pkCol, upsert);
|
|
311
|
+
const updateSet = onConflict.updateSet(fields, upsert.keepAsIs);
|
|
312
|
+
const pkRows = await this.#insertRows(table, fields, records, pkCol, onConflict.sql(this.#q(upsert.conflictKeys), updateSet));
|
|
313
|
+
// Nothing to update: `DO NOTHING` returns no row for an existing one. A group missing a key
|
|
314
|
+
// field can only insert, and RETURNING names every row it inserted.
|
|
315
|
+
if (!updateSet && upsert.conflictKeys.every((k) => fields.includes(k))) {
|
|
316
|
+
return this.#untouchedPkRows(table, records, pkCol, upsert.conflictKeys);
|
|
317
|
+
}
|
|
318
|
+
return pkRows;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Write `records`, one chunk at a time within each same-fields group (a single chunk when
|
|
322
|
+
* every record has the same fields). With `upsert`, an existing row gets the record's fields
|
|
323
|
+
* except `keepAsIs` — the conflict keys and the columns never overwritten. PK rows are
|
|
324
|
+
* returned in input order.
|
|
325
|
+
*/
|
|
326
|
+
async #bulkWrite(table, records, pkCol, chunkSize, upsert) {
|
|
327
|
+
const results = new Array(records.length);
|
|
328
|
+
for (const { fields, indexes } of this.#groupByFields(records)) {
|
|
329
|
+
for (let i = 0; i < indexes.length; i += chunkSize) {
|
|
330
|
+
const chunkIndexes = indexes.slice(i, i + chunkSize);
|
|
331
|
+
const pkRows = await this.#writeChunk(table, fields, chunkIndexes.map((j) => records[j]), pkCol, upsert);
|
|
332
|
+
pkRows.forEach((row, k) => {
|
|
333
|
+
results[chunkIndexes[k]] = row;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return results;
|
|
183
338
|
}
|
|
184
339
|
async bulkInsert(table, records, pkCol, chunkSize = DEFAULT_CHUNK_SIZE) {
|
|
185
340
|
if (!records.length)
|
|
186
341
|
return [];
|
|
187
|
-
|
|
188
|
-
const returning = this.dialect.returningPk(this.#quotedPkCols(pkCol));
|
|
189
|
-
const results = [];
|
|
190
|
-
for (let i = 0; i < records.length; i += chunkSize) {
|
|
191
|
-
const chunk = records.slice(i, i + chunkSize);
|
|
192
|
-
const { values, rows } = this.#bulkValues(fields, chunk);
|
|
193
|
-
const result = await this.query(`${this.#insertHead(table, fields)} VALUES ${rows}${returning}`, values);
|
|
194
|
-
results.push(...this.#extractPkRows(result, pkCol, chunk));
|
|
195
|
-
}
|
|
196
|
-
return results;
|
|
342
|
+
return this.#bulkWrite(table, records, pkCol, chunkSize);
|
|
197
343
|
}
|
|
198
344
|
/**
|
|
199
345
|
* `immutableCols` are written when the row is inserted but left untouched when the statement
|
|
@@ -203,34 +349,18 @@ export class QueryClient {
|
|
|
203
349
|
async bulkInsertOrUpdate(table, records, conflictKeys, pkCol, chunkSize = DEFAULT_CHUNK_SIZE, immutableCols = []) {
|
|
204
350
|
if (!records.length)
|
|
205
351
|
return [];
|
|
206
|
-
const fields = Object.keys(records[0]);
|
|
207
352
|
const keepAsIs = immutableCols.length ? [...conflictKeys, ...immutableCols] : conflictKeys;
|
|
208
|
-
|
|
209
|
-
const returning = this.dialect.returningPk(this.#quotedPkCols(pkCol));
|
|
210
|
-
const results = [];
|
|
211
|
-
for (let i = 0; i < records.length; i += chunkSize) {
|
|
212
|
-
const chunk = records.slice(i, i + chunkSize);
|
|
213
|
-
const { values, rows } = this.#bulkValues(fields, chunk);
|
|
214
|
-
const result = await this.query(`${this.#insertHead(table, fields)}
|
|
215
|
-
VALUES ${rows}
|
|
216
|
-
${upsertClause}${returning}`, values);
|
|
217
|
-
results.push(...this.#extractPkRows(result, pkCol, chunk));
|
|
218
|
-
}
|
|
219
|
-
return results;
|
|
353
|
+
return this.#bulkWrite(table, records, pkCol, chunkSize, { conflictKeys, keepAsIs });
|
|
220
354
|
}
|
|
221
355
|
async update(table, record, where, extraCondition) {
|
|
222
356
|
const values = [];
|
|
223
|
-
const
|
|
224
|
-
const
|
|
357
|
+
const setClause = this.#equalities(record, values, ', ');
|
|
358
|
+
const whereClause = this.#equalities(where, values, ' AND ');
|
|
225
359
|
let extraWhere = '';
|
|
226
360
|
if (extraCondition) {
|
|
227
361
|
extraWhere = ` AND ${extraCondition.build(values.length + 1, this.dialect.ph)}`;
|
|
228
362
|
values.push(...extraCondition.getValues());
|
|
229
363
|
}
|
|
230
|
-
const setClause = setF.map((f, i) => `${this.dialect.qi(f)} = ${setP[i]}`).join(', ');
|
|
231
|
-
const whereClause = whereF
|
|
232
|
-
.map((f, i) => `${this.dialect.qi(f)} = ${whereP[i]}`)
|
|
233
|
-
.join(' AND ');
|
|
234
364
|
const result = await this.query(`UPDATE ${this.dialect.qi(table)}
|
|
235
365
|
SET ${setClause}
|
|
236
366
|
WHERE ${whereClause}${extraWhere}`, values);
|
|
@@ -238,15 +368,12 @@ export class QueryClient {
|
|
|
238
368
|
}
|
|
239
369
|
async delete(table, where, extraCondition) {
|
|
240
370
|
const values = [];
|
|
241
|
-
const
|
|
371
|
+
const whereClause = this.#equalities(where, values, ' AND ');
|
|
242
372
|
let extraWhere = '';
|
|
243
373
|
if (extraCondition) {
|
|
244
374
|
extraWhere = ` AND ${extraCondition.build(values.length + 1, this.dialect.ph)}`;
|
|
245
375
|
values.push(...extraCondition.getValues());
|
|
246
376
|
}
|
|
247
|
-
const whereClause = fields
|
|
248
|
-
.map((f, i) => `${this.dialect.qi(f)} = ${placeholders[i]}`)
|
|
249
|
-
.join(' AND ');
|
|
250
377
|
const result = await this.query(`DELETE FROM ${this.dialect.qi(table)}
|
|
251
378
|
WHERE ${whereClause}${extraWhere}`, values);
|
|
252
379
|
return result.affectedRows;
|
package/dist/lib/dialect.d.ts
CHANGED
|
@@ -9,18 +9,23 @@ export interface SqlDialect {
|
|
|
9
9
|
/** ConditionBuilder dialect name */
|
|
10
10
|
cbDialect: 'postgres' | 'mysql';
|
|
11
11
|
supportsReturning: boolean;
|
|
12
|
-
/** ON CONFLICT / ON DUPLICATE KEY syntax */
|
|
13
|
-
upsertSql(quotedConflictKeys: string, quotedUpdateSet: string): string;
|
|
14
|
-
/** RETURNING pk_col suffix or empty string */
|
|
15
|
-
returningPk(quotedPkCol: string): string;
|
|
16
12
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* Returns '' when there is nothing to update (i.e. all `fields` are conflict keys).
|
|
13
|
+
* The upsert clause of a dialect whose syntax names its conflict target — postgres
|
|
14
|
+
* `ON CONFLICT (key)`, which matches exactly the declared key. Absent on mysql/mariadb:
|
|
15
|
+
* `ON DUPLICATE KEY UPDATE` fires on ANY unique index, so there the QueryClient upserts by
|
|
16
|
+
* key explicitly — probe, insert, update (ADR 0021).
|
|
22
17
|
*/
|
|
23
|
-
|
|
18
|
+
onConflict?: {
|
|
19
|
+
/** `ON CONFLICT (keys) DO UPDATE SET …`, or `DO NOTHING` when the update set is empty. */
|
|
20
|
+
sql(quotedConflictKeys: string, quotedUpdateSet: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* The SET body copying the incoming row into the conflicting one (`"col" = EXCLUDED."col"`)
|
|
23
|
+
* for every field not in `keepAsIs`; '' when nothing is left to update.
|
|
24
|
+
*/
|
|
25
|
+
updateSet(fields: string[], keepAsIs: string[]): string;
|
|
26
|
+
};
|
|
27
|
+
/** RETURNING pk_col suffix or empty string */
|
|
28
|
+
returningPk(quotedPkCol: string): string;
|
|
24
29
|
/**
|
|
25
30
|
* Truncate a timestamp/date column to the start of the given unit and
|
|
26
31
|
* return it as an ISO-style string. The output is always a STRING:
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dialect.d.ts","sourceRoot":"","sources":["../../src/lib/dialect.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,OAAO,GAAG,SAAS,CAAC;AAE3D,KAAK,YAAY,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,KAAK,GAAG,MAAM,CAAC;AAElE,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,WAAW,CAAC;IAClB,gEAAgE;IAChE,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,sDAAsD;IACtD,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1B,oCAAoC;IACpC,SAAS,EAAE,UAAU,GAAG,OAAO,CAAC;IAChC,iBAAiB,EAAE,OAAO,CAAC;IAC3B,
|
|
1
|
+
{"version":3,"file":"dialect.d.ts","sourceRoot":"","sources":["../../src/lib/dialect.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,OAAO,GAAG,SAAS,CAAC;AAE3D,KAAK,YAAY,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,KAAK,GAAG,MAAM,CAAC;AAElE,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,WAAW,CAAC;IAClB,gEAAgE;IAChE,EAAE,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,sDAAsD;IACtD,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1B,oCAAoC;IACpC,SAAS,EAAE,UAAU,GAAG,OAAO,CAAC;IAChC,iBAAiB,EAAE,OAAO,CAAC;IAC3B;;;;;OAKG;IACH,UAAU,CAAC,EAAE;QACX,0FAA0F;QAC1F,GAAG,CAAC,kBAAkB,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,GAAG,MAAM,CAAC;QACjE;;;WAGG;QACH,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC;KACzD,CAAC;IACF,8CAA8C;IAC9C,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAAC;IACzC;;;;;;OAMG;IACH,SAAS,CAAC,IAAI,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC;CAC7D;AAsED,wBAAgB,UAAU,CAAC,IAAI,EAAE,WAAW,GAAG,UAAU,CAIxD"}
|
package/dist/lib/dialect.js
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
|
+
const pgQi = (id) => `"${id.replace(/"/g, '""')}"`;
|
|
1
2
|
const PostgresDialect = {
|
|
2
3
|
name: 'postgres',
|
|
3
|
-
qi:
|
|
4
|
+
qi: pgQi,
|
|
4
5
|
ph: (i) => `$${i}`,
|
|
5
6
|
cbDialect: 'postgres',
|
|
6
7
|
supportsReturning: true,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return '';
|
|
16
|
-
|
|
8
|
+
onConflict: {
|
|
9
|
+
sql(quotedConflictKeys, quotedUpdateSet) {
|
|
10
|
+
if (!quotedUpdateSet)
|
|
11
|
+
return `ON CONFLICT (${quotedConflictKeys}) DO NOTHING`;
|
|
12
|
+
return `ON CONFLICT (${quotedConflictKeys}) DO UPDATE SET ${quotedUpdateSet}`;
|
|
13
|
+
},
|
|
14
|
+
updateSet(fields, keepAsIs) {
|
|
15
|
+
const updateCols = fields.filter((f) => !keepAsIs.includes(f));
|
|
16
|
+
return updateCols.map((f) => `${pgQi(f)} = EXCLUDED.${pgQi(f)}`).join(', ');
|
|
17
|
+
},
|
|
17
18
|
},
|
|
18
19
|
returningPk(quotedPkCol) {
|
|
19
20
|
return ` RETURNING ${quotedPkCol}`;
|
|
@@ -33,26 +34,12 @@ function mysqlDateTrunc(unit, col) {
|
|
|
33
34
|
case 'hour': return `DATE_FORMAT(${col}, '%Y-%m-%dT%H:00:00')`;
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
|
-
function mysqlUpsertUpdateSet(fields, conflictKeys) {
|
|
37
|
-
const updateCols = fields.filter((f) => !conflictKeys.includes(f));
|
|
38
|
-
if (!updateCols.length)
|
|
39
|
-
return '';
|
|
40
|
-
return updateCols.map((f) => `${this.qi(f)} = VALUES(${this.qi(f)})`).join(', ');
|
|
41
|
-
}
|
|
42
37
|
const MysqlDialect = {
|
|
43
38
|
name: 'mysql',
|
|
44
39
|
qi: (id) => `\`${id.replace(/`/g, '``')}\``,
|
|
45
40
|
ph: () => '?',
|
|
46
41
|
cbDialect: 'mysql',
|
|
47
42
|
supportsReturning: false,
|
|
48
|
-
upsertSql(_quotedConflictKeys, quotedUpdateSet) {
|
|
49
|
-
// MySQL requires at least one assignment in ON DUPLICATE KEY UPDATE; when there's nothing
|
|
50
|
-
// user-meaningful to update, assign the first conflict key to itself (no-op).
|
|
51
|
-
if (!quotedUpdateSet)
|
|
52
|
-
return `ON DUPLICATE KEY UPDATE ${_quotedConflictKeys.split(', ')[0]} = ${_quotedConflictKeys.split(', ')[0]}`;
|
|
53
|
-
return `ON DUPLICATE KEY UPDATE ${quotedUpdateSet}`;
|
|
54
|
-
},
|
|
55
|
-
upsertUpdateSet: mysqlUpsertUpdateSet,
|
|
56
43
|
returningPk() {
|
|
57
44
|
return '';
|
|
58
45
|
},
|
|
@@ -64,12 +51,6 @@ const MariadbDialect = {
|
|
|
64
51
|
ph: () => '?',
|
|
65
52
|
cbDialect: 'mysql',
|
|
66
53
|
supportsReturning: true,
|
|
67
|
-
upsertSql(_quotedConflictKeys, quotedUpdateSet) {
|
|
68
|
-
if (!quotedUpdateSet)
|
|
69
|
-
return `ON DUPLICATE KEY UPDATE ${_quotedConflictKeys.split(', ')[0]} = ${_quotedConflictKeys.split(', ')[0]}`;
|
|
70
|
-
return `ON DUPLICATE KEY UPDATE ${quotedUpdateSet}`;
|
|
71
|
-
},
|
|
72
|
-
upsertUpdateSet: mysqlUpsertUpdateSet,
|
|
73
54
|
returningPk(quotedPkCol) {
|
|
74
55
|
return ` RETURNING ${quotedPkCol}`;
|
|
75
56
|
},
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bulk-upsert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/bulk/bulk-upsert.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"bulk-upsert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/bulk/bulk-upsert.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAEV,gBAAgB,EAChB,gBAAgB,EAEjB,MAAM,mBAAmB,CAAC;AAgE3B,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CA0C5F"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { camelcaseObject } from '../../naming.js';
|
|
2
|
-
import { processSecondaries, processDeletions, prepareInsertRecord } from '../write-helpers.js';
|
|
2
|
+
import { processSecondaries, processDeletions, prepareInsertRecord, assertUpsertKeys, } from '../write-helpers.js';
|
|
3
3
|
import { enforceTenantOnWrites, assertTenantOwnsConflicts, tenantImmutableColumns, } from '../../tenant.js';
|
|
4
4
|
import { runBulkValidation } from '../validate.js';
|
|
5
5
|
import { primaryAsCols } from '../../../types.js';
|
|
@@ -12,6 +12,7 @@ function upsertMains(params, preparedMains, pkCol) {
|
|
|
12
12
|
return db.bulkInsert(schema.tableName, preparedMains, pkCol);
|
|
13
13
|
}
|
|
14
14
|
const conflictCols = upsertKeys.map((k) => schema.col(k));
|
|
15
|
+
assertUpsertKeys(schema, conflictCols, [pkCol].flat(), preparedMains);
|
|
15
16
|
// Tenant isolation: a conflicting upsert must not overwrite rows owned by another tenant, nor
|
|
16
17
|
// re-assign the owner of a row it legitimately updates.
|
|
17
18
|
return assertTenantOwnsConflicts(db, tenant, schema.tableName, conflictCols, preparedMains)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/insert.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAEb,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,
|
|
1
|
+
{"version":3,"file":"insert.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/rest/insert.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAEb,MAAM,mBAAmB,CAAC;AAE3B,wBAAsB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAoE9E"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { camelcaseObject } from '../../naming.js';
|
|
2
|
-
import { processSecondaries, prepareInsertRecord } from '../write-helpers.js';
|
|
2
|
+
import { processSecondaries, prepareInsertRecord, assertUpsertKeys } from '../write-helpers.js';
|
|
3
3
|
import { enforceTenantOnWrites, assertTenantOwnsConflicts } from '../../tenant.js';
|
|
4
4
|
import { primaryAsCols } from '../../../types.js';
|
|
5
5
|
export async function insertEngine(params) {
|
|
@@ -20,6 +20,7 @@ export async function insertEngine(params) {
|
|
|
20
20
|
const upsertKeys = tableConf.upsertMap?.get(schema);
|
|
21
21
|
if (upsertKeys) {
|
|
22
22
|
const conflictCols = upsertKeys.map((k) => schema.col(k));
|
|
23
|
+
assertUpsertKeys(schema, conflictCols, [pkCol].flat(), [mainRecord]);
|
|
23
24
|
// Tenant isolation: a conflicting upsert must not overwrite a row owned by another tenant.
|
|
24
25
|
await assertTenantOwnsConflicts(tx, tenant, schema.tableName, conflictCols, [mainRecord]);
|
|
25
26
|
pkResult = await tx.insertOrUpdate(schema.tableName, mainRecord, conflictCols, pkCol);
|
|
@@ -11,9 +11,16 @@ import { Expression } from 'node-condition-builder';
|
|
|
11
11
|
import type { QueryClient } from '../../db.js';
|
|
12
12
|
import type { QueryParams } from '../query-params.js';
|
|
13
13
|
import type { DbTables, ITable, JoinGroupRequest, SearchCondition, TenantContext } from '../../../types.js';
|
|
14
|
+
/**
|
|
15
|
+
* One aggregation function, as SQL. `zeroWhenEmpty`: over no rows it means 0 (nothing counted,
|
|
16
|
+
* nothing summed), so the correlated subquery coalesces its NULL to 0. min/max/avg over no rows
|
|
17
|
+
* have no value and stay NULL — a 0 would be invented data on numeric columns and a type error
|
|
18
|
+
* on text/date ones.
|
|
19
|
+
*/
|
|
14
20
|
export interface AggFn {
|
|
15
21
|
sql: string;
|
|
16
22
|
distinct: boolean;
|
|
23
|
+
zeroWhenEmpty: boolean;
|
|
17
24
|
}
|
|
18
25
|
export declare const AGG_FN: Record<string, AggFn>;
|
|
19
26
|
export declare function aggExpr(fn: AggFn, qualifiedCol: string): string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"aggregations.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/aggregations.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAoB,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACtE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAMtD,OAAO,KAAK,EACV,QAAQ,EACR,MAAM,EACN,gBAAgB,EAChB,eAAe,EACf,aAAa,EACd,MAAM,mBAAmB,CAAC;AAE3B,MAAM,WAAW,KAAK;IAAG,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE;
|
|
1
|
+
{"version":3,"file":"aggregations.d.ts","sourceRoot":"","sources":["../../../../src/lib/engine/search/aggregations.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAoB,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACtE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAMtD,OAAO,KAAK,EACV,QAAQ,EACR,MAAM,EACN,gBAAgB,EAChB,eAAe,EACf,aAAa,EACd,MAAM,mBAAmB,CAAC;AAE3B;;;;;GAKG;AACH,MAAM,WAAW,KAAK;IAAG,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAC;IAAC,aAAa,EAAE,OAAO,CAAA;CAAE;AAEjF,eAAO,MAAM,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAOxC,CAAC;AAEF,wBAAgB,OAAO,CAAC,EAAE,EAAE,KAAK,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAC/B,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAAG,SAAS,EACvD,MAAM,CAAC,EAAE,aAAa,GACrB,UAAU,CAiEZ;AAED,0FAA0F;AAC1F,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,WAAW,EACnB,UAAU,EAAE,eAAe,EAAE,EAC7B,EAAE,EAAE,WAAW,EACf,QAAQ,EAAE,QAAQ,EAClB,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,GAAG,SAAS,EACvD,MAAM,CAAC,EAAE,aAAa,GACrB,MAAM,CAyBR"}
|
|
@@ -14,12 +14,12 @@ import { err400, validateSchemaField } from './fields.js';
|
|
|
14
14
|
import { requireJoin, extractJoinRefs, subqueryAlias } from './joins.js';
|
|
15
15
|
import { buildJoinRefCondition, dispatchConditionMethod } from './conditions.js';
|
|
16
16
|
export const AGG_FN = {
|
|
17
|
-
sum: { sql: 'SUM', distinct: false },
|
|
18
|
-
min: { sql: 'MIN', distinct: false },
|
|
19
|
-
max: { sql: 'MAX', distinct: false },
|
|
20
|
-
avg: { sql: 'AVG', distinct: false },
|
|
21
|
-
count: { sql: 'COUNT', distinct: false },
|
|
22
|
-
distinctCount: { sql: 'COUNT', distinct: true },
|
|
17
|
+
sum: { sql: 'SUM', distinct: false, zeroWhenEmpty: true },
|
|
18
|
+
min: { sql: 'MIN', distinct: false, zeroWhenEmpty: false },
|
|
19
|
+
max: { sql: 'MAX', distinct: false, zeroWhenEmpty: false },
|
|
20
|
+
avg: { sql: 'AVG', distinct: false, zeroWhenEmpty: false },
|
|
21
|
+
count: { sql: 'COUNT', distinct: false, zeroWhenEmpty: true },
|
|
22
|
+
distinctCount: { sql: 'COUNT', distinct: true, zeroWhenEmpty: true },
|
|
23
23
|
};
|
|
24
24
|
export function aggExpr(fn, qualifiedCol) {
|
|
25
25
|
return fn.distinct ? `COUNT(DISTINCT ${qualifiedCol})` : `${fn.sql}(${qualifiedCol})`;
|
|
@@ -81,7 +81,8 @@ export function buildAggOrderExpr(db, dbTables, tableConf, alias, fn, field, joi
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
const qualifiedCol = `${subRef}.${db.qi(fieldCol)}`;
|
|
84
|
-
const
|
|
84
|
+
const subquery = `(SELECT ${aggExpr(aggFn, qualifiedCol)} FROM ${refs.joinTable} AS ${subRef} WHERE ${subRef}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol}${tenantWhere}${filterWhere})`;
|
|
85
|
+
const expr = aggFn.zeroWhenEmpty ? `COALESCE(${subquery}, 0)` : subquery;
|
|
85
86
|
return new Expression(expr, [...tenantVals, ...filterVals]);
|
|
86
87
|
}
|
|
87
88
|
/** HAVING-style conditions: the dotted `<alias>.<fn>.<field>` entries of `conditions`. */
|