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/AGENTS.md +24 -111
- package/AGENTS_BACKEND.md +271 -1381
- package/AGENTS_FRONTEND.md +70 -47
- package/BREAKING_CHANGES.md +79 -0
- package/CHANGELOG.md +63 -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/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
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);
|
|
@@ -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;
|
|
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,
|
|
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)
|
package/dist/types/cli.d.ts
CHANGED
|
@@ -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
|