dzql 0.3.0 → 0.3.2

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.
@@ -28,6 +28,7 @@ export class OperationCodegen {
28
28
  */
29
29
  generateGetFunction() {
30
30
  const fkExpansions = this._generateFKExpansions();
31
+ const m2mExpansionForGet = this._generateM2MExpansionForGet();
31
32
  const filterSensitiveFields = this._generateSensitiveFieldFilter();
32
33
 
33
34
  return `-- GET operation for ${this.tableName}
@@ -58,6 +59,7 @@ BEGIN
58
59
  END IF;
59
60
 
60
61
  ${fkExpansions}
62
+ ${m2mExpansionForGet}
61
63
  ${filterSensitiveFields}
62
64
 
63
65
  RETURN v_result;
@@ -72,6 +74,10 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
72
74
  const graphRulesCall = this._generateGraphRulesCall();
73
75
  const notificationSQL = this._generateNotificationSQL();
74
76
  const filterSensitiveFields = this._generateSensitiveFieldFilter('v_output');
77
+ const m2mVariables = this._generateM2MVariableDeclarations();
78
+ const m2mExtraction = this._generateM2MExtraction();
79
+ const m2mSync = this._generateM2MSync();
80
+ const m2mExpansion = this._generateM2MExpansion();
75
81
 
76
82
  return `-- SAVE operation for ${this.tableName}
77
83
  CREATE OR REPLACE FUNCTION save_${this.tableName}(
@@ -84,7 +90,9 @@ DECLARE
84
90
  v_output JSONB;
85
91
  v_is_insert BOOLEAN := false;
86
92
  v_notify_users INT[];
93
+ ${m2mVariables}
87
94
  BEGIN
95
+ ${m2mExtraction}
88
96
  -- Determine if this is insert or update
89
97
  IF p_data->>'id' IS NULL THEN
90
98
  v_is_insert := true;
@@ -120,23 +128,32 @@ BEGIN
120
128
  FROM jsonb_each_text(p_data) kv(key, value)
121
129
  ) INTO v_result;
122
130
  ELSE
123
- -- Dynamic UPDATE from JSONB
124
- EXECUTE (
125
- SELECT format(
126
- 'UPDATE ${this.tableName} SET %s WHERE id = %L RETURNING *',
127
- string_agg(quote_ident(key) || ' = ' || quote_nullable(value), ', '),
128
- (p_data->>'id')::int
129
- )
130
- FROM jsonb_each_text(p_data) kv(key, value)
131
- WHERE key != 'id'
132
- ) INTO v_result;
131
+ -- Dynamic UPDATE from JSONB (only if there are fields to update)
132
+ IF (SELECT COUNT(*) FROM jsonb_object_keys(p_data) WHERE jsonb_object_keys != 'id') > 0 THEN
133
+ EXECUTE (
134
+ SELECT format(
135
+ 'UPDATE ${this.tableName} SET %s WHERE id = %L RETURNING *',
136
+ string_agg(quote_ident(key) || ' = ' || quote_nullable(value), ', '),
137
+ (p_data->>'id')::int
138
+ )
139
+ FROM jsonb_each_text(p_data) kv(key, value)
140
+ WHERE key != 'id'
141
+ ) INTO v_result;
142
+ ELSE
143
+ -- No fields to update (only M2M fields were provided), just fetch existing
144
+ v_result := v_existing;
145
+ END IF;
133
146
  END IF;
134
147
 
148
+ ${m2mSync}
149
+
150
+ -- Prepare output with M2M fields (BEFORE event creation for real-time notifications!)
151
+ v_output := to_jsonb(v_result);
152
+ ${m2mExpansion}
153
+
135
154
  ${graphRulesCall}
136
155
  ${notificationSQL}
137
156
 
138
- -- Prepare output (removing sensitive fields)
139
- v_output := to_jsonb(v_result);
140
157
  ${filterSensitiveFields}
141
158
 
142
159
  RETURN v_output;
@@ -233,6 +250,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
233
250
  `${field} ILIKE '%' || p_search || '%'`
234
251
  ).join(' OR ');
235
252
  const filterSensitiveFieldsArray = this._generateSensitiveFieldFilterArray();
253
+ const m2mSearchExpansion = this._generateM2MExpansionForSearch();
236
254
 
237
255
  return `-- SEARCH operation for ${this.tableName}
238
256
  CREATE OR REPLACE FUNCTION search_${this.tableName}(
@@ -311,8 +329,8 @@ BEGIN
311
329
 
312
330
  -- Get data
313
331
  EXECUTE format('
314
- SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY %I %s), ''[]''::jsonb)
315
- FROM ${this.tableName} t
332
+ SELECT COALESCE(jsonb_agg(${m2mSearchExpansion.selectExpression} ORDER BY %I %s), ''[]''::jsonb)
333
+ FROM ${this.tableName} t${m2mSearchExpansion.lateralJoins}
316
334
  WHERE %s
317
335
  LIMIT %L OFFSET %L
318
336
  ', v_sort_field, v_sort_order, v_where_clause, p_limit, v_offset) INTO v_data;
@@ -329,6 +347,251 @@ END;
329
347
  $$ LANGUAGE plpgsql SECURITY DEFINER;`;
330
348
  }
331
349
 
350
+ /**
351
+ * Generate M2M variable declarations
352
+ * COMPILE TIME: Loop to generate static variable declarations
353
+ * RUNTIME: No loops, just variables
354
+ * @private
355
+ */
356
+ _generateM2MVariableDeclarations() {
357
+ const manyToMany = this.entity.manyToMany || {};
358
+ if (Object.keys(manyToMany).length === 0) return '';
359
+
360
+ const declarations = [];
361
+
362
+ // COMPILE TIME LOOP: Generate separate variable for each M2M relationship
363
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
364
+ const idField = config.id_field;
365
+ declarations.push(` v_${idField} INT[]; -- M2M: ${relationKey}`);
366
+ }
367
+
368
+ return declarations.join('\n');
369
+ }
370
+
371
+ /**
372
+ * Generate M2M extraction logic
373
+ * COMPILE TIME: Loop to generate code
374
+ * RUNTIME: Separate IF blocks (NO loops!)
375
+ * @private
376
+ */
377
+ _generateM2MExtraction() {
378
+ const manyToMany = this.entity.manyToMany || {};
379
+ if (Object.keys(manyToMany).length === 0) return '';
380
+
381
+ const extractions = [];
382
+
383
+ // COMPILE TIME LOOP: Generate separate extraction block for each M2M
384
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
385
+ const idField = config.id_field;
386
+
387
+ // Each M2M gets its own static IF block (no runtime loops!)
388
+ extractions.push(`
389
+ -- Extract M2M field: ${idField} (${relationKey})
390
+ IF p_data ? '${idField}' THEN
391
+ v_${idField} := ARRAY(SELECT jsonb_array_elements_text(p_data->'${idField}')::int);
392
+ p_data := p_data - '${idField}'; -- Remove from data (not a table column)
393
+ END IF;`);
394
+ }
395
+
396
+ return extractions.join('');
397
+ }
398
+
399
+ /**
400
+ * Generate M2M junction table sync logic
401
+ * COMPILE TIME: Loop to generate code
402
+ * RUNTIME: Direct SQL execution (NO loops!)
403
+ * @private
404
+ */
405
+ _generateM2MSync() {
406
+ const manyToMany = this.entity.manyToMany || {};
407
+ if (Object.keys(manyToMany).length === 0) return '';
408
+
409
+ const syncs = [];
410
+
411
+ // COMPILE TIME LOOP: Generate separate sync block for EACH relationship
412
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
413
+ const idField = config.id_field;
414
+ const junctionTable = config.junction_table;
415
+ const localKey = config.local_key;
416
+ const foreignKey = config.foreign_key;
417
+
418
+ // Static SQL - all names known at compile time!
419
+ syncs.push(`
420
+ -- ============================================================================
421
+ -- M2M Sync: ${relationKey} (junction: ${junctionTable})
422
+ -- ============================================================================
423
+ IF v_${idField} IS NOT NULL THEN
424
+ -- Delete relationships not in new list
425
+ DELETE FROM ${junctionTable}
426
+ WHERE ${localKey} = v_result.id
427
+ AND (${foreignKey} <> ALL(v_${idField}) OR v_${idField} = '{}');
428
+
429
+ -- Insert new relationships (idempotent)
430
+ IF array_length(v_${idField}, 1) > 0 THEN
431
+ INSERT INTO ${junctionTable} (${localKey}, ${foreignKey})
432
+ SELECT v_result.id, unnest(v_${idField})
433
+ ON CONFLICT (${localKey}, ${foreignKey}) DO NOTHING;
434
+ END IF;
435
+ END IF;`);
436
+ }
437
+
438
+ return syncs.join('');
439
+ }
440
+
441
+ /**
442
+ * Generate M2M expansion in output (for SAVE function)
443
+ * COMPILE TIME: Loop to generate code
444
+ * RUNTIME: Direct SQL queries (NO loops!)
445
+ * Expands M2M fields into v_output BEFORE event creation (for real-time notifications)
446
+ * @private
447
+ */
448
+ _generateM2MExpansion() {
449
+ const manyToMany = this.entity.manyToMany || {};
450
+ if (Object.keys(manyToMany).length === 0) return '';
451
+
452
+ const expansions = [];
453
+
454
+ // COMPILE TIME LOOP: Generate code for each M2M relationship
455
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
456
+ const idField = config.id_field;
457
+ const junctionTable = config.junction_table;
458
+ const localKey = config.local_key;
459
+ const foreignKey = config.foreign_key;
460
+ const targetEntity = config.target_entity;
461
+ const expand = config.expand || false;
462
+
463
+ // Always add ID array (static SQL) - use v_result.id since v_output is v_result as jsonb
464
+ expansions.push(`
465
+ -- Add M2M IDs: ${idField}
466
+ v_output := v_output || jsonb_build_object('${idField}',
467
+ (SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
468
+ FROM ${junctionTable} WHERE ${localKey} = v_result.id)
469
+ );`);
470
+
471
+ // Conditionally expand full objects (known at compile time!)
472
+ if (expand) {
473
+ expansions.push(`
474
+ -- Expand M2M objects: ${relationKey} (expand=true)
475
+ v_output := v_output || jsonb_build_object('${relationKey}',
476
+ (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
477
+ FROM ${junctionTable} jt
478
+ JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
479
+ WHERE jt.${localKey} = v_result.id)
480
+ );`);
481
+ }
482
+ }
483
+
484
+ return expansions.join('');
485
+ }
486
+
487
+ /**
488
+ * Generate M2M expansion for SEARCH operation
489
+ * COMPILE TIME: Loop to generate LATERAL joins
490
+ * RUNTIME: Static joins (NO loops!)
491
+ * @private
492
+ */
493
+ _generateM2MExpansionForSearch() {
494
+ const manyToMany = this.entity.manyToMany || {};
495
+
496
+ if (Object.keys(manyToMany).length === 0) {
497
+ return {
498
+ lateralJoins: '',
499
+ selectExpression: 'to_jsonb(t.*)'
500
+ };
501
+ }
502
+
503
+ const lateralJoins = [];
504
+ const mergeExpressions = [];
505
+
506
+ // COMPILE TIME LOOP: Generate LATERAL join for each M2M relationship
507
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
508
+ const idField = config.id_field;
509
+ const junctionTable = config.junction_table;
510
+ const localKey = config.local_key;
511
+ const foreignKey = config.foreign_key;
512
+ const targetEntity = config.target_entity;
513
+ const expand = config.expand || false;
514
+
515
+ // LATERAL join for ID array (static SQL)
516
+ lateralJoins.push(`
517
+ LEFT JOIN LATERAL (
518
+ SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), ''[]''::jsonb) as ${idField}
519
+ FROM ${junctionTable}
520
+ WHERE ${localKey} = t.id
521
+ ) m2m_${idField} ON true`);
522
+
523
+ mergeExpressions.push(`jsonb_build_object(''${idField}'', m2m_${idField}.${idField})`);
524
+
525
+ // Optionally expand full objects
526
+ if (expand) {
527
+ lateralJoins.push(`
528
+ LEFT JOIN LATERAL (
529
+ SELECT COALESCE(jsonb_agg(to_jsonb(target.*) ORDER BY target.id), ''[]''::jsonb) as ${relationKey}
530
+ FROM ${junctionTable} jt
531
+ JOIN ${targetEntity} target ON target.id = jt.${foreignKey}
532
+ WHERE jt.${localKey} = t.id
533
+ ) m2m_${relationKey} ON true`);
534
+
535
+ mergeExpressions.push(`jsonb_build_object(''${relationKey}'', m2m_${relationKey}.${relationKey})`);
536
+ }
537
+ }
538
+
539
+ // Build the select expression that merges M2M fields
540
+ const selectExpression = mergeExpressions.length > 0
541
+ ? `to_jsonb(t.*) || ${mergeExpressions.join(' || ')}`
542
+ : 'to_jsonb(t.*)';
543
+
544
+ return {
545
+ lateralJoins: lateralJoins.join(''),
546
+ selectExpression
547
+ };
548
+ }
549
+
550
+ /**
551
+ * Generate M2M expansion for GET operation
552
+ * COMPILE TIME: Loop to generate code
553
+ * RUNTIME: Direct SQL queries (NO loops!)
554
+ * @private
555
+ */
556
+ _generateM2MExpansionForGet() {
557
+ const manyToMany = this.entity.manyToMany || {};
558
+ if (Object.keys(manyToMany).length === 0) return '';
559
+
560
+ const expansions = [];
561
+
562
+ // COMPILE TIME LOOP: Generate code for each M2M relationship
563
+ for (const [relationKey, config] of Object.entries(manyToMany)) {
564
+ const idField = config.id_field;
565
+ const junctionTable = config.junction_table;
566
+ const localKey = config.local_key;
567
+ const foreignKey = config.foreign_key;
568
+ const targetEntity = config.target_entity;
569
+ const expand = config.expand || false;
570
+
571
+ // Always add ID array (static SQL)
572
+ expansions.push(`
573
+ -- Add M2M IDs: ${idField}
574
+ v_result := v_result || jsonb_build_object('${idField}',
575
+ (SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
576
+ FROM ${junctionTable} WHERE ${localKey} = v_record.id)
577
+ );`);
578
+
579
+ // Conditionally expand full objects (known at compile time!)
580
+ if (expand) {
581
+ expansions.push(`
582
+ -- Expand M2M objects: ${relationKey} (expand=true)
583
+ v_result := v_result || jsonb_build_object('${relationKey}',
584
+ (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
585
+ FROM ${junctionTable} jt
586
+ JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
587
+ WHERE jt.${localKey} = v_record.id)
588
+ );`);
589
+ }
590
+ }
591
+
592
+ return expansions.join('');
593
+ }
594
+
332
595
  /**
333
596
  * Generate FK expansions for GET
334
597
  * @private
@@ -460,10 +723,10 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
460
723
 
461
724
  if (operation === 'save') {
462
725
  return `
463
- -- Resolve notification recipients
464
- ${hasNotificationPaths ? `v_notify_users := _resolve_notification_paths_${this.tableName}(p_user_id, to_jsonb(v_result));` : 'v_notify_users := ARRAY[]::INT[];'}
726
+ -- Resolve notification recipients (use v_output with M2M fields!)
727
+ ${hasNotificationPaths ? `v_notify_users := _resolve_notification_paths_${this.tableName}(p_user_id, v_output);` : 'v_notify_users := ARRAY[]::INT[];'}
465
728
 
466
- -- Create event for real-time notifications
729
+ -- Create event for real-time notifications (v_output includes M2M fields!)
467
730
  INSERT INTO dzql.events (
468
731
  table_name,
469
732
  op,
@@ -477,7 +740,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
477
740
  CASE WHEN v_is_insert THEN 'insert' ELSE 'update' END,
478
741
  jsonb_build_object('id', v_result.id),
479
742
  CASE WHEN NOT v_is_insert THEN to_jsonb(v_existing) ELSE NULL END,
480
- to_jsonb(v_result),
743
+ v_output,
481
744
  p_user_id,
482
745
  v_notify_users
483
746
  );`;
@@ -176,6 +176,9 @@ export class EntityParser {
176
176
  _parseJSON(str) {
177
177
  if (!str || str === '{}' || str === "'{}'") return {};
178
178
 
179
+ // Strip SQL comments before parsing
180
+ str = str.replace(/--[^\n]*/g, '').trim();
181
+
179
182
  // Handle jsonb_build_object(...) calls
180
183
  if (str.includes('jsonb_build_object')) {
181
184
  return this._parseJSONBuildObject(str);
@@ -184,9 +187,11 @@ export class EntityParser {
184
187
  // Handle JSON string literals
185
188
  if (str.startsWith("'") && str.endsWith("'")) {
186
189
  try {
187
- return JSON.parse(str.slice(1, -1).replace(/''/g, "'"));
190
+ // Remove outer quotes and unescape SQL quotes
191
+ const jsonStr = str.slice(1, -1).replace(/''/g, "'");
192
+ return JSON.parse(jsonStr);
188
193
  } catch (e) {
189
- console.warn('Failed to parse JSON:', str, e);
194
+ console.warn('Failed to parse JSON:', str.substring(0, 100) + '...', e);
190
195
  return {};
191
196
  }
192
197
  }