dzql 0.5.28 → 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!)
|
|
@@ -129,6 +129,8 @@ export class SubscribableParser {
|
|
|
129
129
|
*/
|
|
130
130
|
_cleanString(str) {
|
|
131
131
|
if (!str) return '';
|
|
132
|
+
// Handle SQL NULL keyword - return empty string for null values
|
|
133
|
+
if (str.trim().toUpperCase() === 'NULL') return '';
|
|
132
134
|
// Remove outer quotes, SQL comments, then any remaining quotes and whitespace
|
|
133
135
|
let cleaned = str.replace(/^['"]|['"]$/g, ''); // Remove outer quotes
|
|
134
136
|
cleaned = cleaned.replace(/--[^\n]*/g, ''); // Remove SQL comments
|
|
@@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS dzql.subscribables (
|
|
|
11
11
|
name TEXT PRIMARY KEY,
|
|
12
12
|
permission_paths JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
13
13
|
param_schema JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
14
|
-
root_entity TEXT
|
|
14
|
+
root_entity TEXT, -- NULL allowed for dashboard mode (pure collections)
|
|
15
15
|
relations JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
16
16
|
scope_tables TEXT[] NOT NULL DEFAULT '{}',
|
|
17
17
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
@@ -31,7 +31,7 @@ COMMENT ON COLUMN dzql.subscribables.param_schema IS
|
|
|
31
31
|
'Parameter schema defining subscription key (e.g., {"venue_id": "int"})';
|
|
32
32
|
|
|
33
33
|
COMMENT ON COLUMN dzql.subscribables.root_entity IS
|
|
34
|
-
'Root table for the subscribable document';
|
|
34
|
+
'Root table for the subscribable document. NULL for dashboard mode (pure collections).';
|
|
35
35
|
|
|
36
36
|
COMMENT ON COLUMN dzql.subscribables.relations IS
|
|
37
37
|
'Related entities to include (e.g., {"org": "organisations", "sites": {"entity": "sites", "filter": "venue_id=$venue_id"}})';
|
|
@@ -16,8 +16,12 @@ DECLARE
|
|
|
16
16
|
v_entity TEXT;
|
|
17
17
|
v_nested JSONB;
|
|
18
18
|
BEGIN
|
|
19
|
-
-- Start with root entity
|
|
20
|
-
|
|
19
|
+
-- Start with root entity (may be NULL for dashboard mode)
|
|
20
|
+
IF p_root_entity IS NOT NULL AND p_root_entity != '' THEN
|
|
21
|
+
v_tables := ARRAY[p_root_entity];
|
|
22
|
+
ELSE
|
|
23
|
+
v_tables := ARRAY[]::TEXT[];
|
|
24
|
+
END IF;
|
|
21
25
|
|
|
22
26
|
-- Return early if no relations
|
|
23
27
|
IF p_relations IS NULL OR p_relations = '{}'::jsonb THEN
|
|
@@ -85,8 +89,11 @@ BEGIN
|
|
|
85
89
|
RAISE EXCEPTION 'Subscribable name cannot be empty';
|
|
86
90
|
END IF;
|
|
87
91
|
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
-- NULL root_entity is allowed for dashboard mode (pure collections)
|
|
93
|
+
-- But if relations is also empty, that's an error
|
|
94
|
+
IF (p_root_entity IS NULL OR p_root_entity = '') AND
|
|
95
|
+
(p_relations IS NULL OR p_relations = '{}'::jsonb) THEN
|
|
96
|
+
RAISE EXCEPTION 'Subscribable must have either a root entity or relations';
|
|
90
97
|
END IF;
|
|
91
98
|
|
|
92
99
|
-- Extract scope tables from root entity and relations
|
|
@@ -106,7 +113,7 @@ BEGIN
|
|
|
106
113
|
p_name,
|
|
107
114
|
COALESCE(p_permission_paths, '{}'::jsonb),
|
|
108
115
|
COALESCE(p_param_schema, '{}'::jsonb),
|
|
109
|
-
p_root_entity,
|
|
116
|
+
NULLIF(p_root_entity, ''), -- Store empty string as NULL
|
|
110
117
|
COALESCE(p_relations, '{}'::jsonb),
|
|
111
118
|
v_scope_tables,
|
|
112
119
|
NOW(),
|