dzql 0.5.26 → 0.5.28

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.26",
3
+ "version": "0.5.28",
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,6 +247,70 @@ $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
247
247
  scopeTables: scopeTables
248
248
  });
249
249
 
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
283
+ if (params.length === 0) {
284
+ return `CREATE OR REPLACE FUNCTION get_${this.name}(
285
+ p_params JSONB,
286
+ p_user_id INT
287
+ ) RETURNS JSONB AS $$
288
+ DECLARE
289
+ v_data JSONB;
290
+ BEGIN
291
+ -- Check access control
292
+ IF NOT ${this.name}_can_subscribe(p_user_id, p_params) THEN
293
+ RAISE EXCEPTION 'Permission denied';
294
+ END IF;
295
+
296
+ -- Build document with root as array (dashboard mode)
297
+ SELECT jsonb_build_object(
298
+ '${this.rootEntity}', COALESCE((
299
+ SELECT jsonb_agg(row_to_json(root.*))
300
+ FROM ${this.rootEntity} root
301
+ ), '[]'::jsonb)${relationSelects}
302
+ )
303
+ INTO v_data;
304
+
305
+ -- Return data with embedded schema for atomic updates
306
+ RETURN jsonb_build_object(
307
+ 'data', v_data,
308
+ 'schema', '${schemaJson}'::jsonb
309
+ );
310
+ END;
311
+ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
312
+ }
313
+
250
314
  return `CREATE OR REPLACE FUNCTION get_${this.name}(
251
315
  p_params JSONB,
252
316
  p_user_id INT
@@ -356,6 +420,33 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
356
420
  return selects;
357
421
  }
358
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
+
359
450
  /**
360
451
  * Generate JOIN clause for via relations
361
452
  * Handles multi-hop via chains by looking up each intermediate table
@@ -478,13 +569,21 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
478
569
  */
479
570
  _generateAffectedDocumentsFunction() {
480
571
  const cases = [];
572
+ const seenEntities = new Set();
481
573
 
482
- // Case 1: Root entity changed
483
- cases.push(this._generateRootAffectedCase());
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
+ }
484
579
 
485
- // Case 2: Related entities changed
580
+ // Case 2: Related entities changed (skip duplicates)
486
581
  for (const [relName, relConfig] of Object.entries(this.relations)) {
487
- cases.push(this._generateRelationAffectedCase(relName, relConfig));
582
+ const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
583
+ if (!seenEntities.has(relEntity)) {
584
+ cases.push(this._generateRelationAffectedCase(relName, relConfig));
585
+ seenEntities.add(relEntity);
586
+ }
488
587
  }
489
588
 
490
589
  const casesSQL = cases.join('\n\n ');
@@ -515,7 +614,15 @@ $$ LANGUAGE plpgsql IMMUTABLE;`;
515
614
  */
516
615
  _generateRootAffectedCase() {
517
616
  const params = Object.keys(this.paramSchema);
518
- const firstParam = params[0] || 'id';
617
+
618
+ // Dashboard mode: empty paramSchema means notify ALL subscribers
619
+ if (params.length === 0) {
620
+ return `-- Root entity (${this.rootEntity}) changed - dashboard mode, notify all
621
+ WHEN '${this.rootEntity}' THEN
622
+ v_affected := ARRAY['{}'::jsonb];`;
623
+ }
624
+
625
+ const firstParam = params[0];
519
626
 
520
627
  return `-- Root entity (${this.rootEntity}) changed
521
628
  WHEN '${this.rootEntity}' THEN
@@ -618,7 +725,10 @@ $$ LANGUAGE plpgsql IMMUTABLE;`;
618
725
  * @returns {string[]} Array of table names
619
726
  */
620
727
  extractScopeTables() {
621
- const tables = new Set([this.rootEntity]);
728
+ const tables = new Set();
729
+ if (this.rootEntity) {
730
+ tables.add(this.rootEntity);
731
+ }
622
732
 
623
733
  const extractFromRelations = (relations) => {
624
734
  for (const [relName, relConfig] of Object.entries(relations || {})) {
@@ -655,8 +765,10 @@ $$ LANGUAGE plpgsql IMMUTABLE;`;
655
765
  buildPathMapping() {
656
766
  const paths = {};
657
767
 
658
- // Root entity maps to top level
659
- paths[this.rootEntity] = '.';
768
+ // Root entity maps to top level (only if it exists)
769
+ if (this.rootEntity) {
770
+ paths[this.rootEntity] = '.';
771
+ }
660
772
 
661
773
  const buildPaths = (relations, parentPath = '') => {
662
774
  for (const [relName, relConfig] of Object.entries(relations || {})) {