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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.5.27",
3
+ "version": "0.5.29",
4
4
  "description": "PostgreSQL-powered framework with zero boilerplate CRUD operations and real-time WebSocket synchronization",
5
5
  "type": "module",
6
6
  "main": "src/server/index.js",
@@ -247,7 +247,39 @@ $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
247
247
  scopeTables: scopeTables
248
248
  });
249
249
 
250
- // Dashboard mode: empty paramSchema means aggregate root as array
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
- cases.push(this._generateRootAffectedCase());
517
- seenEntities.add(this.rootEntity);
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([this.rootEntity]);
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
- paths[this.rootEntity] = '.';
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 NOT NULL,
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
- v_tables := ARRAY[p_root_entity];
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
- IF p_root_entity IS NULL OR p_root_entity = '' THEN
89
- RAISE EXCEPTION 'Root entity cannot be empty';
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(),