dzql 0.5.27 → 0.5.29
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
|
@@ -247,7 +247,39 @@ $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
|
|
|
247
247
|
scopeTables: scopeTables
|
|
248
248
|
});
|
|
249
249
|
|
|
250
|
-
//
|
|
250
|
+
// Pure collection mode: no rootEntity, only relations
|
|
251
|
+
if (!this.rootEntity) {
|
|
252
|
+
// Build relation-only selects (strip leading comma)
|
|
253
|
+
const collectionSelects = this._generateCollectionOnlySelects();
|
|
254
|
+
|
|
255
|
+
return `CREATE OR REPLACE FUNCTION get_${this.name}(
|
|
256
|
+
p_params JSONB,
|
|
257
|
+
p_user_id INT
|
|
258
|
+
) RETURNS JSONB AS $$
|
|
259
|
+
DECLARE
|
|
260
|
+
v_data JSONB;
|
|
261
|
+
BEGIN
|
|
262
|
+
-- Check access control
|
|
263
|
+
IF NOT ${this.name}_can_subscribe(p_user_id, p_params) THEN
|
|
264
|
+
RAISE EXCEPTION 'Permission denied';
|
|
265
|
+
END IF;
|
|
266
|
+
|
|
267
|
+
-- Build document with collections only (no root entity)
|
|
268
|
+
SELECT jsonb_build_object(
|
|
269
|
+
${collectionSelects}
|
|
270
|
+
)
|
|
271
|
+
INTO v_data;
|
|
272
|
+
|
|
273
|
+
-- Return data with embedded schema for atomic updates
|
|
274
|
+
RETURN jsonb_build_object(
|
|
275
|
+
'data', v_data,
|
|
276
|
+
'schema', '${schemaJson}'::jsonb
|
|
277
|
+
);
|
|
278
|
+
END;
|
|
279
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Dashboard mode: empty paramSchema with rootEntity means aggregate root as array
|
|
251
283
|
if (params.length === 0) {
|
|
252
284
|
return `CREATE OR REPLACE FUNCTION get_${this.name}(
|
|
253
285
|
p_params JSONB,
|
|
@@ -388,6 +420,33 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
388
420
|
return selects;
|
|
389
421
|
}
|
|
390
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Generate collection-only selects (no root entity)
|
|
425
|
+
* Used when rootEntity is null/empty - pure collection mode
|
|
426
|
+
* @private
|
|
427
|
+
*/
|
|
428
|
+
_generateCollectionOnlySelects() {
|
|
429
|
+
if (Object.keys(this.relations).length === 0) {
|
|
430
|
+
return "'_empty', '{}'::jsonb";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const selects = Object.entries(this.relations).map(([relName, relConfig]) => {
|
|
434
|
+
const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
|
|
435
|
+
const relFilter = typeof relConfig === 'object' ? relConfig.filter : null;
|
|
436
|
+
|
|
437
|
+
// For collection mode, filter should be TRUE or we fetch all
|
|
438
|
+
const filterSQL = relFilter === 'TRUE' ? 'TRUE' : 'TRUE';
|
|
439
|
+
|
|
440
|
+
return `'${relName}', COALESCE((
|
|
441
|
+
SELECT jsonb_agg(row_to_json(rel.*))
|
|
442
|
+
FROM ${relEntity} rel
|
|
443
|
+
WHERE ${filterSQL}
|
|
444
|
+
), '[]'::jsonb)`;
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
return selects.join(',\n ');
|
|
448
|
+
}
|
|
449
|
+
|
|
391
450
|
/**
|
|
392
451
|
* Generate JOIN clause for via relations
|
|
393
452
|
* Handles multi-hop via chains by looking up each intermediate table
|
|
@@ -512,9 +571,11 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
512
571
|
const cases = [];
|
|
513
572
|
const seenEntities = new Set();
|
|
514
573
|
|
|
515
|
-
// Case 1: Root entity changed
|
|
516
|
-
|
|
517
|
-
|
|
574
|
+
// Case 1: Root entity changed (only if rootEntity exists)
|
|
575
|
+
if (this.rootEntity) {
|
|
576
|
+
cases.push(this._generateRootAffectedCase());
|
|
577
|
+
seenEntities.add(this.rootEntity);
|
|
578
|
+
}
|
|
518
579
|
|
|
519
580
|
// Case 2: Related entities changed (skip duplicates)
|
|
520
581
|
for (const [relName, relConfig] of Object.entries(this.relations)) {
|
|
@@ -664,7 +725,10 @@ $$ LANGUAGE plpgsql IMMUTABLE;`;
|
|
|
664
725
|
* @returns {string[]} Array of table names
|
|
665
726
|
*/
|
|
666
727
|
extractScopeTables() {
|
|
667
|
-
const tables = new Set(
|
|
728
|
+
const tables = new Set();
|
|
729
|
+
if (this.rootEntity) {
|
|
730
|
+
tables.add(this.rootEntity);
|
|
731
|
+
}
|
|
668
732
|
|
|
669
733
|
const extractFromRelations = (relations) => {
|
|
670
734
|
for (const [relName, relConfig] of Object.entries(relations || {})) {
|
|
@@ -701,8 +765,10 @@ $$ LANGUAGE plpgsql IMMUTABLE;`;
|
|
|
701
765
|
buildPathMapping() {
|
|
702
766
|
const paths = {};
|
|
703
767
|
|
|
704
|
-
// Root entity maps to top level
|
|
705
|
-
|
|
768
|
+
// Root entity maps to top level (only if it exists)
|
|
769
|
+
if (this.rootEntity) {
|
|
770
|
+
paths[this.rootEntity] = '.';
|
|
771
|
+
}
|
|
706
772
|
|
|
707
773
|
const buildPaths = (relations, parentPath = '') => {
|
|
708
774
|
for (const [relName, relConfig] of Object.entries(relations || {})) {
|
|
@@ -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(),
|