fastify-auto-sqlapi 0.3.0 → 0.4.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/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);
@@ -1,6 +1,18 @@
1
1
  import type { ApiRequest } from '../../types/request.js';
2
2
  import type { QueryClient } from '../db.js';
3
- import type { ITable, DbTables, TenantContext } from '../../types.js';
3
+ import type { ITable, DbTables, TenantContext, SchemaDefinition } from '../../types.js';
4
+ /**
5
+ * Every upserted record must carry its key (ADR 0021), checked on the record as it reaches the
6
+ * database — after hooks, tenant injection and the FK auto-fill of secondaries:
7
+ * - a natural key needs every field present and not null: a NULL never conflicts, so the row
8
+ * could only be inserted and never found again by its key;
9
+ * - on the primary key, a record naming no PK field at all is a plain insert with a generated
10
+ * key; one naming it only in part, or as null, is refused like a natural key;
11
+ * - two records of one request with the same key are refused: PostgreSQL cannot affect a row
12
+ * twice in one statement, MySQL would let the last one win.
13
+ * Throws 400 before any SQL runs. Column names and record keys are DB column names.
14
+ */
15
+ export declare function assertUpsertKeys(schema: SchemaDefinition, conflictCols: string[], pkCols: string[], records: Record<string, unknown>[]): void;
4
16
  export declare function findSecondaryTableConf(dbTables: DbTables, joinTableName: string): ITable | undefined;
5
17
  /**
6
18
  * The context a per-alias write pass runs in. Shared by `processSecondaries` and
@@ -1 +1 @@
1
- {"version":3,"file":"write-helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/engine/write-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAEzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAU5C,OAAO,KAAK,EACV,MAAM,EACN,QAAQ,EAGR,aAAa,EACd,MAAM,gBAAgB,CAAC;AAmCxB,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,MAAM,GACpB,MAAM,GAAG,SAAS,CAKpB;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,QAAQ,CAAC;IACnB,sFAAsF;IACtF,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,wFAAwF;IACxF,OAAO,EAAE,UAAU,CAAC;CACrB;AAmCD,UAAU,UAAU;IAClB,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,UAAU,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,EACvD,OAAO,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,GACnC,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC,CAgB7E;AAkCD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,aAAa,EACnB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACrD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CA0DpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,aAAa,EACnB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACnD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CAoBpD"}
1
+ {"version":3,"file":"write-helpers.d.ts","sourceRoot":"","sources":["../../../src/lib/engine/write-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAEzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAW5C,OAAO,KAAK,EACV,MAAM,EACN,QAAQ,EAGR,aAAa,EACb,gBAAgB,EACjB,MAAM,gBAAgB,CAAC;AAExB;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,gBAAgB,EACxB,YAAY,EAAE,MAAM,EAAE,EACtB,MAAM,EAAE,MAAM,EAAE,EAChB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GACjC,IAAI,CA0BN;AAmCD,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,MAAM,GACpB,MAAM,GAAG,SAAS,CAKpB;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,QAAQ,CAAC;IACnB,sFAAsF;IACtF,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,wFAAwF;IACxF,OAAO,EAAE,UAAU,CAAC;CACrB;AAmCD,UAAU,UAAU;IAClB,EAAE,EAAE,WAAW,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,UAAU,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,EACvD,OAAO,CAAC,EAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAE,GACnC,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC,CAgB7E;AAkCD;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,aAAa,EACnB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACrD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CA8DpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,aAAa,EACnB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,GACnD,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,CAoBpD"}
@@ -1,7 +1,41 @@
1
1
  import { camelcaseObject, snakecaseRecord } from '../naming.js';
2
2
  import { runValidation } from './validate.js';
3
3
  import { removeWriteExcluded } from '../write-access.js';
4
+ import { httpError } from '../errors.js';
4
5
  import { tenantForTable, buildTenantRowGuard, enforceTenantOnWrites, assertTenantOwnsConflicts, tenantImmutableColumns, } from '../tenant.js';
6
+ /**
7
+ * Every upserted record must carry its key (ADR 0021), checked on the record as it reaches the
8
+ * database — after hooks, tenant injection and the FK auto-fill of secondaries:
9
+ * - a natural key needs every field present and not null: a NULL never conflicts, so the row
10
+ * could only be inserted and never found again by its key;
11
+ * - on the primary key, a record naming no PK field at all is a plain insert with a generated
12
+ * key; one naming it only in part, or as null, is refused like a natural key;
13
+ * - two records of one request with the same key are refused: PostgreSQL cannot affect a row
14
+ * twice in one statement, MySQL would let the last one win.
15
+ * Throws 400 before any SQL runs. Column names and record keys are DB column names.
16
+ */
17
+ export function assertUpsertKeys(schema, conflictCols, pkCols, records) {
18
+ const isPrimaryKey = conflictCols.length === pkCols.length
19
+ && conflictCols.every((c) => pkCols.includes(c));
20
+ const fieldOf = (col) => Object.keys(schema.fields).find((f) => schema.col(f) === col) ?? col;
21
+ const rule = `Upsert on "${schema.tableName}" by (${conflictCols.map(fieldOf).join(', ')})`;
22
+ const seen = new Map();
23
+ records.forEach((record, i) => {
24
+ if (isPrimaryKey && conflictCols.every((c) => record[c] === undefined))
25
+ return;
26
+ const missing = conflictCols.filter((c) => record[c] == null);
27
+ if (missing.length) {
28
+ throw httpError(400, `${rule}: the row at index ${i} has no value for ${missing.map(fieldOf).join(', ')} — ` +
29
+ `an upserted row must carry its whole key`);
30
+ }
31
+ const key = JSON.stringify(conflictCols.map((c) => record[c]));
32
+ const first = seen.get(key);
33
+ if (first !== undefined) {
34
+ throw httpError(400, `${rule}: the rows at index ${first} and ${i} have the same key`);
35
+ }
36
+ seen.set(key, i);
37
+ });
38
+ }
5
39
  /**
6
40
  * Resolve the write join an alias names, or undefined when the table does not declare it.
7
41
  *
@@ -144,6 +178,7 @@ export function processSecondaries(pass, secondaries) {
144
178
  let pkRows;
145
179
  if (upsertKeys) {
146
180
  const conflictCols = upsertKeys.map((k) => joinSchema.col(k));
181
+ assertUpsertKeys(joinSchema, conflictCols, (joinSchema.primaryKey ?? [childPk].flat()).map((f) => joinSchema.col(f)), preparedRecords);
147
182
  // A conflicting upsert must not overwrite — or re-parent — a child row owned by
148
183
  // another tenant, which the ON CONFLICT key alone would happily match.
149
184
  await assertTenantOwnsConflicts(db, childTenant, joinSchema.tableName, conflictCols, preparedRecords);
@@ -1 +1 @@
1
- {"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EAEP,MAAM,aAAa,CAAC;AAcrB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAWX;AAiSD,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAgDD,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAWhB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
1
+ {"version":3,"file":"table-helpers.d.ts","sourceRoot":"","sources":["../../src/lib/table-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,yBAAyB,EAAiC,MAAM,wBAAwB,CAAC;AACzH,OAAO,EAAQ,KAAK,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACrE,OAAO,KAAK,EACV,gBAAgB,EAChB,cAAc,EACd,aAAa,EACb,MAAM,EAEP,MAAM,aAAa,CAAC;AAcrB,KAAK,UAAU,CAAC,EAAE,IAAI,EAAE,SAAS,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAExH,wBAAgB,eAAe,CAC7B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,EAAE,SAAS,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,EAErE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,YAAY,GAAE,EAAa,EAC3B,iBAAiB,CAAC,EAAE,CAClB,SAAS,EAAE,gBAAgB,EAC3B,OAAO,EAAE;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,MAAM,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,yBAAyB;CAAE,KAC3E,IAAI,GACR;IAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAAC,OAAO,EAAE,aAAa,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAsChG;AAED,wBAAgB,WAAW,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,MAAM,CAAC,CAAC,CAAC,CAYX;AAmVD,MAAM,WAAW,oBAAoB;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;CACnB;AAgDD,wBAAgB,aAAa,CAC3B,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACjC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAEjC,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,EAClD,UAAU,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC/B,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,EAC3B,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAWhB;AAED,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/D,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,EAAE,GAC5B,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAE9B;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,KAAK,EAAE,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,GACvC,GAAG,CAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC,CAEjC"}
@@ -55,6 +55,7 @@ export function defineTable(config) {
55
55
  validateWriteExclude(config);
56
56
  validateTenantScope(config);
57
57
  validateTenantColumnNames(config);
58
+ validateUpsertMap(config);
58
59
  return config;
59
60
  }
60
61
  /**
@@ -255,6 +256,46 @@ function validateWriteExclude(config) {
255
256
  }
256
257
  }
257
258
  }
259
+ const formatKey = (fields) => `(${fields.join(', ')})`;
260
+ const sameFieldSet = (a, b) => a.length === b.length && a.every((f) => b.includes(f));
261
+ /**
262
+ * An `upsertMap` rule must name a unique key the database enforces — the Schema's
263
+ * `primaryKey` or one of its `uniqueKeys`, as a set: same fields, any order, nothing extra
264
+ * (ADR 0021). Anything else fails every write on PostgreSQL, and on MySQL duplicates rows or
265
+ * updates a row matched through another key, in silence.
266
+ *
267
+ * Fail-closed: a rule the Schema cannot vouch for is refused. The Schema is what is read, not
268
+ * a table config, so a child written only through a write join needs no `defineTable`.
269
+ */
270
+ function validateUpsertMap(config) {
271
+ for (const [schema, keys] of config.upsertMap ?? []) {
272
+ const problem = upsertKeyProblem(schema, keys);
273
+ if (problem) {
274
+ throw new Error(`defineTable: upsertMap rule on '${schema.tableName}' ${formatKey(keys)} ${problem} (ADR 0021).`);
275
+ }
276
+ }
277
+ }
278
+ /** Why `keys` is not a verified unique key of `schema`, or null when it is one. */
279
+ function upsertKeyProblem(schema, keys) {
280
+ if (schema.isView)
281
+ return 'targets a view — a view has no unique key to upsert on';
282
+ const primaryKey = schema.primaryKey ?? [];
283
+ if (primaryKey.length && sameFieldSet(keys, primaryKey))
284
+ return null;
285
+ if (schema.uniqueKeys === undefined) {
286
+ return (`cannot be verified: the Schema of '${schema.tableName}' carries no uniqueKeys` +
287
+ `${primaryKey.length ? '' : ' and no primaryKey'} — regenerate the schemas with ` +
288
+ `sqlapi-generate-schema, an upsert must target a unique key the database enforces`);
289
+ }
290
+ if (schema.uniqueKeys.some((unique) => sameFieldSet(keys, unique)))
291
+ return null;
292
+ const available = [
293
+ ...(primaryKey.length ? [`${formatKey(primaryKey)} primary key`] : []),
294
+ ...schema.uniqueKeys.map(formatKey),
295
+ ];
296
+ return (`is not a unique key of the table. An upsert must target exactly one: ` +
297
+ `${available.length ? available.join(', ') : 'the table has none'}`);
298
+ }
258
299
  function validateComputedFields(config) {
259
300
  const computed = config.computedFields;
260
301
  if (!computed)
@@ -53,6 +53,12 @@ export interface ColumnInfo {
53
53
  * simple enough for the engine to make updatable accepts a write.
54
54
  */
55
55
  is_view?: boolean;
56
+ /**
57
+ * Names of the unique indexes (constraints included) this column belongs to, the primary key
58
+ * excluded. Only indexes over plain columns with no predicate: a partial or expression index
59
+ * cannot be named by a column list, so it cannot be an upsert key (ADR 0021).
60
+ */
61
+ unique_keys?: string[];
56
62
  }
57
63
  export interface TableMap {
58
64
  [schemaName: string]: {
@@ -65,6 +71,11 @@ export interface TableMap {
65
71
  generated: string[];
66
72
  /** True when this relation is a view (or a materialized view), not a base table. */
67
73
  isView: boolean;
74
+ /**
75
+ * Unique keys other than the primary key, each as its fields (camelCase). Always set by
76
+ * `buildTableMap`; absent only on a map built by hand.
77
+ */
78
+ uniqueKeys?: string[][];
68
79
  };
69
80
  }
70
81
  //# sourceMappingURL=cli.d.ts.map