dzql 0.2.3 → 0.3.1
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/dzql-compiler-m2m-change-request 2.md +562 -0
- package/docs/compiler/dzql-compiler-m2m-change-request.md +375 -0
- package/docs/guides/custom-functions.md +362 -0
- package/docs/guides/field-defaults.md +240 -0
- package/docs/guides/many-to-many.md +894 -0
- package/docs/reference/api.md +147 -3
- package/package.json +2 -2
- package/src/compiler/codegen/operation-codegen.js +281 -18
- package/src/compiler/compiler.js +23 -13
- package/src/compiler/parser/entity-parser.js +74 -14
- package/src/database/migrations/001_schema.sql +3 -1
- package/src/database/migrations/002_functions.sql +5 -0
- package/src/database/migrations/003_operations.sql +236 -1
- package/src/database/migrations/004_search.sql +64 -0
- package/src/database/migrations/005_entities.sql +11 -4
package/docs/reference/api.md
CHANGED
|
@@ -235,7 +235,8 @@ SELECT dzql.register_entity(
|
|
|
235
235
|
p_temporal_fields JSONB DEFAULT '{}'::jsonb,
|
|
236
236
|
p_notification_paths JSONB DEFAULT '{}'::jsonb,
|
|
237
237
|
p_permission_paths JSONB DEFAULT '{}'::jsonb,
|
|
238
|
-
p_graph_rules JSONB DEFAULT '{}'::jsonb
|
|
238
|
+
p_graph_rules JSONB DEFAULT '{}'::jsonb,
|
|
239
|
+
p_field_defaults JSONB DEFAULT '{}'::jsonb
|
|
239
240
|
);
|
|
240
241
|
```
|
|
241
242
|
|
|
@@ -251,7 +252,8 @@ SELECT dzql.register_entity(
|
|
|
251
252
|
| `p_temporal_fields` | JSONB | no | Temporal field config (valid_from/valid_to) |
|
|
252
253
|
| `p_notification_paths` | JSONB | no | Who receives real-time updates |
|
|
253
254
|
| `p_permission_paths` | JSONB | no | CRUD permission rules |
|
|
254
|
-
| `p_graph_rules` | JSONB | no | Automatic relationship management |
|
|
255
|
+
| `p_graph_rules` | JSONB | no | Automatic relationship management + M2M |
|
|
256
|
+
| `p_field_defaults` | JSONB | no | Auto-populate fields on INSERT |
|
|
255
257
|
|
|
256
258
|
### FK Includes
|
|
257
259
|
|
|
@@ -302,7 +304,76 @@ const rights = await ws.api.get.contractor_rights({id: 1});
|
|
|
302
304
|
const past = await ws.api.get.contractor_rights({id: 1, on_date: '2023-01-01'});
|
|
303
305
|
```
|
|
304
306
|
|
|
305
|
-
###
|
|
307
|
+
### Field Defaults
|
|
308
|
+
|
|
309
|
+
Auto-populate fields on INSERT with values or variables:
|
|
310
|
+
|
|
311
|
+
```sql
|
|
312
|
+
'{
|
|
313
|
+
"owner_id": "@user_id", -- Current user ID
|
|
314
|
+
"created_by": "@user_id", -- Current user ID
|
|
315
|
+
"created_at": "@now", -- Current timestamp
|
|
316
|
+
"status": "draft" -- Literal value
|
|
317
|
+
}'
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
**Available variables:**
|
|
321
|
+
- `@user_id` - Current user ID from `p_user_id`
|
|
322
|
+
- `@now` - Current timestamp
|
|
323
|
+
- `@today` - Current date
|
|
324
|
+
- Literal values - Any JSON value (`"draft"`, `0`, `true`)
|
|
325
|
+
|
|
326
|
+
**Behavior:**
|
|
327
|
+
- Only applied on INSERT (not UPDATE)
|
|
328
|
+
- Explicit values override defaults
|
|
329
|
+
- Reduces client boilerplate
|
|
330
|
+
|
|
331
|
+
See [Field Defaults Guide](../guides/field-defaults.md) for details.
|
|
332
|
+
|
|
333
|
+
### Many-to-Many Relationships
|
|
334
|
+
|
|
335
|
+
Configure M2M relationships via `graph_rules.many_to_many`:
|
|
336
|
+
|
|
337
|
+
```sql
|
|
338
|
+
'{
|
|
339
|
+
"many_to_many": {
|
|
340
|
+
"tags": {
|
|
341
|
+
"junction_table": "brand_tags",
|
|
342
|
+
"local_key": "brand_id",
|
|
343
|
+
"foreign_key": "tag_id",
|
|
344
|
+
"target_entity": "tags",
|
|
345
|
+
"id_field": "tag_ids",
|
|
346
|
+
"expand": false
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}'
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**Client usage:**
|
|
353
|
+
```javascript
|
|
354
|
+
// Save with relationships in single call
|
|
355
|
+
await api.save_brands({
|
|
356
|
+
data: {
|
|
357
|
+
name: "My Brand",
|
|
358
|
+
tag_ids: [1, 2, 3] // Junction table synced atomically
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
// Response includes tag_ids array
|
|
363
|
+
{ id: 5, name: "My Brand", tag_ids: [1, 2, 3] }
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
**Configuration:**
|
|
367
|
+
- `junction_table` - Name of junction table
|
|
368
|
+
- `local_key` - FK to this entity
|
|
369
|
+
- `foreign_key` - FK to target entity
|
|
370
|
+
- `target_entity` - Target table name
|
|
371
|
+
- `id_field` - Field name for ID array
|
|
372
|
+
- `expand` - Include full objects (default: false)
|
|
373
|
+
|
|
374
|
+
See [Many-to-Many Guide](../guides/many-to-many.md) for details.
|
|
375
|
+
|
|
376
|
+
### Example Registration (Basic)
|
|
306
377
|
|
|
307
378
|
```sql
|
|
308
379
|
SELECT dzql.register_entity(
|
|
@@ -332,10 +403,83 @@ SELECT dzql.register_entity(
|
|
|
332
403
|
}]
|
|
333
404
|
}
|
|
334
405
|
}
|
|
406
|
+
}',
|
|
407
|
+
'{}' -- field defaults (none)
|
|
408
|
+
);
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
### Example Registration (With All Features)
|
|
412
|
+
|
|
413
|
+
```sql
|
|
414
|
+
SELECT dzql.register_entity(
|
|
415
|
+
'resources',
|
|
416
|
+
'title',
|
|
417
|
+
ARRAY['title', 'description'],
|
|
418
|
+
'{"org": "organisations"}', -- FK includes
|
|
419
|
+
false, -- soft delete
|
|
420
|
+
'{}', -- temporal
|
|
421
|
+
'{}', -- notifications
|
|
422
|
+
'{ -- permissions
|
|
423
|
+
"view": [],
|
|
424
|
+
"create": [],
|
|
425
|
+
"update": ["@owner_id"],
|
|
426
|
+
"delete": ["@owner_id"]
|
|
427
|
+
}',
|
|
428
|
+
'{ -- graph rules
|
|
429
|
+
"many_to_many": {
|
|
430
|
+
"tags": {
|
|
431
|
+
"junction_table": "resource_tags",
|
|
432
|
+
"local_key": "resource_id",
|
|
433
|
+
"foreign_key": "tag_id",
|
|
434
|
+
"target_entity": "tags",
|
|
435
|
+
"id_field": "tag_ids",
|
|
436
|
+
"expand": false
|
|
437
|
+
},
|
|
438
|
+
"collaborators": {
|
|
439
|
+
"junction_table": "resource_collaborators",
|
|
440
|
+
"local_key": "resource_id",
|
|
441
|
+
"foreign_key": "user_id",
|
|
442
|
+
"target_entity": "users",
|
|
443
|
+
"id_field": "collaborator_ids",
|
|
444
|
+
"expand": true
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}',
|
|
448
|
+
'{ -- field defaults
|
|
449
|
+
"owner_id": "@user_id",
|
|
450
|
+
"created_by": "@user_id",
|
|
451
|
+
"created_at": "@now",
|
|
452
|
+
"status": "draft"
|
|
335
453
|
}'
|
|
336
454
|
);
|
|
337
455
|
```
|
|
338
456
|
|
|
457
|
+
**Client usage:**
|
|
458
|
+
```javascript
|
|
459
|
+
// Single call with all features!
|
|
460
|
+
const resource = await api.save_resources({
|
|
461
|
+
data: {
|
|
462
|
+
title: "My Resource",
|
|
463
|
+
tag_ids: [1, 2, 3],
|
|
464
|
+
collaborator_ids: [10, 20]
|
|
465
|
+
// owner_id, created_by, created_at, status auto-populated
|
|
466
|
+
}
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
// Response
|
|
470
|
+
{
|
|
471
|
+
id: 1,
|
|
472
|
+
title: "My Resource",
|
|
473
|
+
owner_id: 123, // From field defaults
|
|
474
|
+
created_by: 123, // From field defaults
|
|
475
|
+
created_at: "2025-11-20...", // From field defaults
|
|
476
|
+
status: "draft", // From field defaults
|
|
477
|
+
tag_ids: [1, 2, 3], // M2M IDs
|
|
478
|
+
collaborator_ids: [10, 20], // M2M IDs
|
|
479
|
+
collaborators: [...] // Full objects (expand: true)
|
|
480
|
+
}
|
|
481
|
+
```
|
|
482
|
+
|
|
339
483
|
---
|
|
340
484
|
|
|
341
485
|
## Search Operators
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dzql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
24
|
"test": "bun test",
|
|
25
|
-
"prepublishOnly": "echo '✅ Publishing DZQL v0.
|
|
25
|
+
"prepublishOnly": "echo '✅ Publishing DZQL v0.3.1...'"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"jose": "^6.1.0",
|
|
@@ -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
|
);`;
|
package/src/compiler/compiler.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Main compiler class that orchestrates parsing and code generation
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { EntityParser } from './parser/entity-parser.js';
|
|
6
|
+
import { EntityParser, parseEntitiesFromSQL } from './parser/entity-parser.js';
|
|
7
7
|
import { SubscribableParser } from './parser/subscribable-parser.js';
|
|
8
8
|
import { generatePermissionFunctions } from './codegen/permission-codegen.js';
|
|
9
9
|
import { generateOperations } from './codegen/operation-codegen.js';
|
|
@@ -65,6 +65,12 @@ export class DZQLCompiler {
|
|
|
65
65
|
sections.push(this._generateGraphRuleFunctions(normalizedEntity));
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Custom functions (pass-through from entity definition)
|
|
69
|
+
if (normalizedEntity.customFunctions &&
|
|
70
|
+
normalizedEntity.customFunctions.length > 0) {
|
|
71
|
+
sections.push(this._generateCustomFunctionsSection(normalizedEntity));
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
// Combine all sections
|
|
69
75
|
const sql = sections.join('\n\n');
|
|
70
76
|
|
|
@@ -180,9 +186,10 @@ export class DZQLCompiler {
|
|
|
180
186
|
* @returns {Object} Compilation results
|
|
181
187
|
*/
|
|
182
188
|
compileFromSQL(sqlContent) {
|
|
183
|
-
|
|
189
|
+
// Use parseEntitiesFromSQL to properly extract custom functions
|
|
190
|
+
const entities = parseEntitiesFromSQL(sqlContent);
|
|
184
191
|
|
|
185
|
-
if (
|
|
192
|
+
if (entities.length === 0) {
|
|
186
193
|
return {
|
|
187
194
|
results: [],
|
|
188
195
|
errors: [],
|
|
@@ -190,16 +197,6 @@ export class DZQLCompiler {
|
|
|
190
197
|
};
|
|
191
198
|
}
|
|
192
199
|
|
|
193
|
-
const entities = [];
|
|
194
|
-
for (const call of registerCalls) {
|
|
195
|
-
try {
|
|
196
|
-
const entity = this.parser.parseFromSQL(call);
|
|
197
|
-
entities.push(entity);
|
|
198
|
-
} catch (error) {
|
|
199
|
-
console.warn('Failed to parse entity:', error.message);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
200
|
return this.compileAll(entities);
|
|
204
201
|
}
|
|
205
202
|
|
|
@@ -258,6 +255,19 @@ export class DZQLCompiler {
|
|
|
258
255
|
);
|
|
259
256
|
}
|
|
260
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Generate custom functions section (pass-through from entity definition)
|
|
260
|
+
* @private
|
|
261
|
+
*/
|
|
262
|
+
_generateCustomFunctionsSection(entity) {
|
|
263
|
+
const header = `-- ============================================================================
|
|
264
|
+
-- Custom Functions for: ${entity.tableName}
|
|
265
|
+
-- Pass-through from entity definition
|
|
266
|
+
-- ============================================================================`;
|
|
267
|
+
|
|
268
|
+
return header + '\n\n' + entity.customFunctions.join('\n\n');
|
|
269
|
+
}
|
|
270
|
+
|
|
261
271
|
/**
|
|
262
272
|
* Calculate SHA-256 checksum of SQL
|
|
263
273
|
* @private
|