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.
- package/docs/compiler/README.md +50 -11
- package/docs/compiler/dzql-compiler-m2m-change-request 2.md +562 -0
- package/docs/compiler/dzql-compiler-m2m-change-request.md +375 -0
- package/docs/guides/many-to-many.md +19 -1
- package/package.json +2 -2
- package/src/compiler/codegen/operation-codegen.js +281 -18
- package/src/compiler/parser/entity-parser.js +7 -2
|
@@ -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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|