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.
Files changed (38) hide show
  1. package/AGENTS.md +24 -111
  2. package/AGENTS_BACKEND.md +271 -1381
  3. package/AGENTS_FRONTEND.md +72 -47
  4. package/BREAKING_CHANGES.md +129 -0
  5. package/CHANGELOG.md +86 -1
  6. package/README.md +1 -1
  7. package/dist/bin/generate-schema.js +2 -2
  8. package/dist/lib/cli/mysql-introspect.d.ts.map +1 -1
  9. package/dist/lib/cli/mysql-introspect.js +24 -1
  10. package/dist/lib/cli/pg-introspect.d.ts.map +1 -1
  11. package/dist/lib/cli/pg-introspect.js +21 -2
  12. package/dist/lib/cli/schema-codegen.d.ts +1 -1
  13. package/dist/lib/cli/schema-codegen.d.ts.map +1 -1
  14. package/dist/lib/cli/schema-codegen.js +31 -3
  15. package/dist/lib/db.d.ts.map +1 -1
  16. package/dist/lib/db.js +187 -60
  17. package/dist/lib/dialect.d.ts +15 -10
  18. package/dist/lib/dialect.d.ts.map +1 -1
  19. package/dist/lib/dialect.js +12 -31
  20. package/dist/lib/engine/bulk/bulk-upsert.d.ts.map +1 -1
  21. package/dist/lib/engine/bulk/bulk-upsert.js +2 -1
  22. package/dist/lib/engine/rest/insert.d.ts.map +1 -1
  23. package/dist/lib/engine/rest/insert.js +2 -1
  24. package/dist/lib/engine/search/aggregations.d.ts +7 -0
  25. package/dist/lib/engine/search/aggregations.d.ts.map +1 -1
  26. package/dist/lib/engine/search/aggregations.js +8 -7
  27. package/dist/lib/engine/write-helpers.d.ts +13 -1
  28. package/dist/lib/engine/write-helpers.d.ts.map +1 -1
  29. package/dist/lib/engine/write-helpers.js +35 -0
  30. package/dist/lib/table-helpers.d.ts.map +1 -1
  31. package/dist/lib/table-helpers.js +41 -0
  32. package/dist/types/cli.d.ts +11 -0
  33. package/dist/types/cli.d.ts.map +1 -1
  34. package/dist/types/schema.d.ts +7 -0
  35. package/dist/types/schema.d.ts.map +1 -1
  36. package/docs/adr/0021-upsert-targets-a-verified-unique-key.md +153 -0
  37. package/docs/adr/README.md +1 -0
  38. 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
- return rows.reduce((acc, row) => {
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>;
@@ -1 +1 @@
1
- {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/lib/db.ts"],"names":[],"mappings":"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"}
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
- if (val instanceof Expression) {
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
- const cols = [];
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
- const values = [];
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 upsertClause = this.dialect.upsertSql(this.#q(conflictKeys), this.dialect.upsertUpdateSet(fields, conflictKeys));
178
- const returning = this.dialect.returningPk(this.#quotedPkCols(pkCol));
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 (${placeholders.join(', ')})
181
- ${upsertClause}${returning}`, values);
182
- return this.#extractPkRows(result, pkCol, [record])[0];
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
- const fields = Object.keys(records[0]);
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
- const upsertClause = this.dialect.upsertSql(this.#q(conflictKeys), this.dialect.upsertUpdateSet(fields, keepAsIs));
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 { fields: setF, placeholders: setP } = this.#params(record, values);
224
- const { fields: whereF, placeholders: whereP } = this.#params(where, values);
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 { fields, placeholders } = this.#params(where, values);
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;
@@ -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
- * Build the SET body for an upsert: the column list (separated by ', ') that the dialect
18
- * uses to copy "incoming row" values into a conflicting row.
19
- * - postgres: `"col" = EXCLUDED."col"`
20
- * - mysql/mariadb: `` `col` = VALUES(`col`) ``
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
- upsertUpdateSet(fields: string[], conflictKeys: string[]): string;
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,4CAA4C;IAC5C,SAAS,CAAC,kBAAkB,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,GAAG,MAAM,CAAC;IACvE,8CAA8C;IAC9C,WAAW,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAAC;IACzC;;;;;;OAMG;IACH,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC;IAClE;;;;;;OAMG;IACH,SAAS,CAAC,IAAI,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAAC;CAC7D;AAqFD,wBAAgB,UAAU,CAAC,IAAI,EAAE,WAAW,GAAG,UAAU,CAIxD"}
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"}
@@ -1,19 +1,20 @@
1
+ const pgQi = (id) => `"${id.replace(/"/g, '""')}"`;
1
2
  const PostgresDialect = {
2
3
  name: 'postgres',
3
- qi: (id) => `"${id.replace(/"/g, '""')}"`,
4
+ qi: pgQi,
4
5
  ph: (i) => `$${i}`,
5
6
  cbDialect: 'postgres',
6
7
  supportsReturning: true,
7
- upsertSql(quotedConflictKeys, quotedUpdateSet) {
8
- if (!quotedUpdateSet)
9
- return `ON CONFLICT (${quotedConflictKeys}) DO NOTHING`;
10
- return `ON CONFLICT (${quotedConflictKeys}) DO UPDATE SET ${quotedUpdateSet}`;
11
- },
12
- upsertUpdateSet(fields, conflictKeys) {
13
- const updateCols = fields.filter((f) => !conflictKeys.includes(f));
14
- if (!updateCols.length)
15
- return '';
16
- return updateCols.map((f) => `${this.qi(f)} = EXCLUDED.${this.qi(f)}`).join(', ');
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":"AASA,OAAO,KAAK,EAEV,gBAAgB,EAChB,gBAAgB,EAEjB,MAAM,mBAAmB,CAAC;AA+D3B,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CA0C5F"}
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,CAmE9E"}
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;AAEzD,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,CAgEZ;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"}
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 expr = `COALESCE((SELECT ${aggExpr(aggFn, qualifiedCol)} FROM ${refs.joinTable} AS ${subRef} WHERE ${subRef}.${refs.fkCol} = ${refs.mainTable}.${refs.mainCol}${tenantWhere}${filterWhere}), 0)`;
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`. */