dzql 0.5.29 → 0.5.30
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/package.json
CHANGED
|
@@ -243,6 +243,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
243
243
|
|
|
244
244
|
/**
|
|
245
245
|
* Generate SAVE function for composite primary keys
|
|
246
|
+
* Uses INSERT ... ON CONFLICT DO UPDATE (UPSERT) to avoid race conditions
|
|
246
247
|
* @private
|
|
247
248
|
*/
|
|
248
249
|
_generateCompositePKSaveFunction(helpers) {
|
|
@@ -259,22 +260,12 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
259
260
|
|
|
260
261
|
const pkDescription = this.primaryKey.join(', ');
|
|
261
262
|
|
|
262
|
-
// Generate WHERE clause for composite PK lookup
|
|
263
|
+
// Generate WHERE clause for composite PK lookup (used for permission checks and v_before)
|
|
263
264
|
// e.g., "entity_type = (p_data->>'entity_type') AND entity_id = (p_data->>'entity_id')::int"
|
|
264
265
|
const whereClause = this.primaryKey.map(col => {
|
|
265
266
|
return `${col} = (p_data->>'${col}')${this._getTypeCast(col)}`;
|
|
266
267
|
}).join(' AND ');
|
|
267
268
|
|
|
268
|
-
// For use inside format() string literal with %L placeholders
|
|
269
|
-
// e.g., "entity_type = %L AND entity_id = %L"
|
|
270
|
-
const whereClauseForFormat = this.primaryKey.map(col => `${col} = %L`).join(' AND ');
|
|
271
|
-
|
|
272
|
-
// Format arguments for the WHERE clause (to be passed to format())
|
|
273
|
-
// e.g., "(p_data->>'entity_type'), (p_data->>'entity_id')::int"
|
|
274
|
-
const whereClauseFormatArgs = this.primaryKey.map(col => {
|
|
275
|
-
return `(p_data->>'${col}')${this._getTypeCast(col)}`;
|
|
276
|
-
}).join(', ');
|
|
277
|
-
|
|
278
269
|
// Check if all PK fields are present in p_data
|
|
279
270
|
const pkNullCheck = this.primaryKey.map(col => `p_data->>'${col}' IS NULL`).join(' OR ');
|
|
280
271
|
|
|
@@ -286,7 +277,12 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
286
277
|
const m2mExpansionCompositePK = this._generateM2MExpansionCompositePK();
|
|
287
278
|
const m2mExpansionForBeforeCompositePK = this._generateM2MExpansionForBeforeCompositePK();
|
|
288
279
|
|
|
280
|
+
// Generate ON CONFLICT clause for composite PK
|
|
281
|
+
// e.g., "(entity_type, entity_id)"
|
|
282
|
+
const onConflictPK = `(${this.primaryKey.join(', ')})`;
|
|
283
|
+
|
|
289
284
|
return `-- SAVE operation for ${this.tableName} (composite primary key: ${pkDescription})
|
|
285
|
+
-- Uses UPSERT (INSERT ... ON CONFLICT DO UPDATE) to prevent race conditions
|
|
290
286
|
CREATE OR REPLACE FUNCTION save_${this.tableName}(
|
|
291
287
|
p_user_id INT,
|
|
292
288
|
p_data JSONB
|
|
@@ -298,68 +294,76 @@ DECLARE
|
|
|
298
294
|
v_before JSONB;
|
|
299
295
|
v_is_insert BOOLEAN := false;
|
|
300
296
|
v_notify_users INT[];
|
|
297
|
+
v_columns TEXT;
|
|
298
|
+
v_values TEXT;
|
|
299
|
+
v_update_set TEXT;
|
|
301
300
|
${m2mVariables}
|
|
302
301
|
BEGIN
|
|
303
302
|
${m2mExtraction}
|
|
304
|
-
--
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
v_is_insert := true;
|
|
308
|
-
ELSE
|
|
309
|
-
-- Try to fetch existing record by composite PK
|
|
303
|
+
-- Check if this might be an update (all PK fields present)
|
|
304
|
+
-- We still need to check for existing record for permission checks and v_before
|
|
305
|
+
IF NOT (${pkNullCheck}) THEN
|
|
310
306
|
SELECT * INTO v_existing
|
|
311
307
|
FROM ${this.tableName}
|
|
312
308
|
WHERE ${whereClause};
|
|
313
309
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
310
|
+
IF FOUND THEN
|
|
311
|
+
-- Check update permission on existing record
|
|
312
|
+
IF NOT can_update_${this.tableName}(p_user_id, to_jsonb(v_existing)) THEN
|
|
313
|
+
RAISE EXCEPTION 'Permission denied: update on ${this.tableName}';
|
|
314
|
+
END IF;
|
|
315
|
+
-- Store before state for M2M expansion
|
|
316
|
+
v_before := to_jsonb(v_existing);
|
|
317
|
+
${m2mExpansionForBeforeCompositePK}
|
|
318
|
+
ELSE
|
|
319
|
+
-- Record doesn't exist yet, this will be an insert
|
|
320
|
+
IF NOT can_create_${this.tableName}(p_user_id, p_data) THEN
|
|
321
|
+
RAISE EXCEPTION 'Permission denied: create on ${this.tableName}';
|
|
322
|
+
END IF;
|
|
321
323
|
END IF;
|
|
322
324
|
ELSE
|
|
323
|
-
|
|
324
|
-
|
|
325
|
+
-- Missing PK fields - must be a new insert
|
|
326
|
+
IF NOT can_create_${this.tableName}(p_user_id, p_data) THEN
|
|
327
|
+
RAISE EXCEPTION 'Permission denied: create on ${this.tableName}';
|
|
325
328
|
END IF;
|
|
326
329
|
END IF;
|
|
327
|
-
|
|
328
|
-
-- Expand M2M for existing record (for UPDATE events "before" field)
|
|
329
|
-
IF NOT v_is_insert THEN
|
|
330
|
-
v_before := to_jsonb(v_existing);
|
|
331
|
-
${m2mExpansionForBeforeCompositePK}
|
|
332
|
-
END IF;
|
|
333
330
|
${fieldDefaults}
|
|
334
|
-
--
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
331
|
+
-- Build column and value lists for UPSERT
|
|
332
|
+
SELECT
|
|
333
|
+
string_agg(quote_ident(key), ', '),
|
|
334
|
+
string_agg(quote_nullable(value), ', ')
|
|
335
|
+
INTO v_columns, v_values
|
|
336
|
+
FROM jsonb_each_text(p_data) kv(key, value);
|
|
337
|
+
|
|
338
|
+
-- Build UPDATE SET clause (excluding PK columns)
|
|
339
|
+
SELECT string_agg(quote_ident(key) || ' = EXCLUDED.' || quote_ident(key), ', ')
|
|
340
|
+
INTO v_update_set
|
|
341
|
+
FROM jsonb_each_text(p_data) kv(key, value)
|
|
342
|
+
WHERE ${this.primaryKey.map(col => `key != '${col}'`).join(' AND ')};
|
|
343
|
+
|
|
344
|
+
-- Perform atomic UPSERT to avoid race conditions
|
|
345
|
+
IF v_update_set IS NOT NULL AND v_update_set != '' THEN
|
|
346
|
+
-- Has non-PK fields to update
|
|
347
|
+
EXECUTE format(
|
|
348
|
+
'INSERT INTO ${this.tableName} (%s) VALUES (%s) ' ||
|
|
349
|
+
'ON CONFLICT ${onConflictPK} DO UPDATE SET %s ' ||
|
|
350
|
+
'RETURNING *',
|
|
351
|
+
v_columns, v_values, v_update_set
|
|
344
352
|
) INTO v_result;
|
|
345
353
|
ELSE
|
|
346
|
-
--
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
)
|
|
354
|
-
FROM jsonb_each_text(p_data) kv(key, value)
|
|
355
|
-
WHERE ${this.primaryKey.map(col => `key != '${col}'`).join(' AND ')}
|
|
356
|
-
) INTO v_result;
|
|
357
|
-
ELSE
|
|
358
|
-
-- No fields to update (only M2M fields were provided), just fetch existing
|
|
359
|
-
v_result := v_existing;
|
|
360
|
-
END IF;
|
|
354
|
+
-- Only PK fields provided - insert or return existing
|
|
355
|
+
EXECUTE format(
|
|
356
|
+
'INSERT INTO ${this.tableName} (%s) VALUES (%s) ' ||
|
|
357
|
+
'ON CONFLICT ${onConflictPK} DO UPDATE SET ${this.primaryKey[0]} = EXCLUDED.${this.primaryKey[0]} ' ||
|
|
358
|
+
'RETURNING *',
|
|
359
|
+
v_columns, v_values
|
|
360
|
+
) INTO v_result;
|
|
361
361
|
END IF;
|
|
362
362
|
|
|
363
|
+
-- Determine if this was an insert by checking if v_existing was found
|
|
364
|
+
-- (if v_existing was NOT FOUND, then this was an insert)
|
|
365
|
+
v_is_insert := v_existing.${this.primaryKey[0]} IS NULL;
|
|
366
|
+
|
|
363
367
|
${m2mSyncCompositePK}
|
|
364
368
|
|
|
365
369
|
-- Prepare output with M2M fields (BEFORE event creation for real-time notifications!)
|