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
|
@@ -7,18 +7,25 @@ export class EntityParser {
|
|
|
7
7
|
/**
|
|
8
8
|
* Parse a dzql.register_entity() call from SQL
|
|
9
9
|
* @param {string} sql - SQL containing register_entity call
|
|
10
|
-
* @
|
|
10
|
+
* @param {number} startOffset - Optional starting position in SQL
|
|
11
|
+
* @returns {Object} Parsed entity configuration with custom functions
|
|
11
12
|
*/
|
|
12
|
-
parseFromSQL(sql) {
|
|
13
|
+
parseFromSQL(sql, startOffset = 0) {
|
|
13
14
|
// Extract the register_entity call
|
|
14
|
-
const
|
|
15
|
+
const searchSql = sql.substring(startOffset);
|
|
16
|
+
const registerMatch = searchSql.match(/dzql\.register_entity\s*\(([\s\S]*?)\);/i);
|
|
15
17
|
if (!registerMatch) {
|
|
16
18
|
throw new Error('No register_entity call found in SQL');
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
const params = this._parseParameters(registerMatch[1]);
|
|
22
|
+
const config = this._buildEntityConfig(params);
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
// Extract custom functions after this register_entity call
|
|
25
|
+
const registerEndPos = startOffset + registerMatch.index + registerMatch[0].length;
|
|
26
|
+
config.customFunctions = this._extractCustomFunctions(sql, registerEndPos);
|
|
27
|
+
|
|
28
|
+
return config;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
/**
|
|
@@ -73,6 +80,11 @@ export class EntityParser {
|
|
|
73
80
|
* @private
|
|
74
81
|
*/
|
|
75
82
|
_buildEntityConfig(params) {
|
|
83
|
+
const graphRules = params[8] ? this._parseJSON(params[8]) : {};
|
|
84
|
+
|
|
85
|
+
// Extract many_to_many from graph_rules if present
|
|
86
|
+
const manyToMany = graphRules.many_to_many || {};
|
|
87
|
+
|
|
76
88
|
const config = {
|
|
77
89
|
tableName: this._cleanString(params[0]),
|
|
78
90
|
labelField: this._cleanString(params[1]),
|
|
@@ -82,7 +94,9 @@ export class EntityParser {
|
|
|
82
94
|
temporalFields: params[5] ? this._parseJSON(params[5]) : {},
|
|
83
95
|
notificationPaths: params[6] ? this._parseJSON(params[6]) : {},
|
|
84
96
|
permissionPaths: params[7] ? this._parseJSON(params[7]) : {},
|
|
85
|
-
graphRules:
|
|
97
|
+
graphRules: graphRules,
|
|
98
|
+
fieldDefaults: params[9] ? this._parseJSON(params[9]) : {},
|
|
99
|
+
manyToMany: manyToMany
|
|
86
100
|
};
|
|
87
101
|
|
|
88
102
|
return config;
|
|
@@ -250,12 +264,56 @@ export class EntityParser {
|
|
|
250
264
|
return cleaned === 'true' || cleaned === 't';
|
|
251
265
|
}
|
|
252
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Extract custom functions defined after register_entity() call
|
|
269
|
+
* @private
|
|
270
|
+
* @param {string} sql - Full SQL content
|
|
271
|
+
* @param {number} startPos - Position after register_entity call
|
|
272
|
+
* @returns {Array<string>} Array of custom function SQL statements
|
|
273
|
+
*/
|
|
274
|
+
_extractCustomFunctions(sql, startPos) {
|
|
275
|
+
// Find the next register_entity call or end of file
|
|
276
|
+
const nextEntityMatch = sql.substring(startPos).match(/dzql\.register_entity\s*\(/i);
|
|
277
|
+
const endPos = nextEntityMatch ? startPos + nextEntityMatch.index : sql.length;
|
|
278
|
+
|
|
279
|
+
// Extract the SQL between this entity and the next
|
|
280
|
+
const customSql = sql.substring(startPos, endPos).trim();
|
|
281
|
+
if (!customSql) return [];
|
|
282
|
+
|
|
283
|
+
const functions = [];
|
|
284
|
+
|
|
285
|
+
// Extract CREATE [OR REPLACE] FUNCTION statements
|
|
286
|
+
// Match from CREATE to the final semicolon of the function (including $$ delimiters)
|
|
287
|
+
const functionPattern = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+[\s\S]*?(?:\$\$|\$[A-Za-z_][A-Za-z0-9_]*\$)\s*(?:LANGUAGE|;)[\s\S]*?;/gi;
|
|
288
|
+
let match;
|
|
289
|
+
while ((match = functionPattern.exec(customSql)) !== null) {
|
|
290
|
+
functions.push(match[0].trim());
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Extract INSERT INTO dzql.registry statements
|
|
294
|
+
const registryPattern = /INSERT\s+INTO\s+dzql\.registry\s+[\s\S]*?;/gi;
|
|
295
|
+
while ((match = registryPattern.exec(customSql)) !== null) {
|
|
296
|
+
functions.push(match[0].trim());
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Extract SELECT dzql.register_function() calls
|
|
300
|
+
const registerFunctionPattern = /SELECT\s+dzql\.register_function\s*\([\s\S]*?\)\s*;/gi;
|
|
301
|
+
while ((match = registerFunctionPattern.exec(customSql)) !== null) {
|
|
302
|
+
functions.push(match[0].trim());
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return functions;
|
|
306
|
+
}
|
|
307
|
+
|
|
253
308
|
/**
|
|
254
309
|
* Parse entity definition from JS object (for programmatic use)
|
|
255
310
|
* @param {Object} entity - Entity definition object
|
|
256
311
|
* @returns {Object} Normalized entity configuration
|
|
257
312
|
*/
|
|
258
313
|
parseFromObject(entity) {
|
|
314
|
+
const graphRules = entity.graphRules || {};
|
|
315
|
+
const manyToMany = entity.manyToMany || graphRules.many_to_many || {};
|
|
316
|
+
|
|
259
317
|
return {
|
|
260
318
|
tableName: entity.tableName || entity.table,
|
|
261
319
|
labelField: entity.labelField || 'name',
|
|
@@ -265,7 +323,10 @@ export class EntityParser {
|
|
|
265
323
|
temporalFields: entity.temporalFields || {},
|
|
266
324
|
notificationPaths: entity.notificationPaths || {},
|
|
267
325
|
permissionPaths: entity.permissionPaths || {},
|
|
268
|
-
graphRules:
|
|
326
|
+
graphRules: graphRules,
|
|
327
|
+
fieldDefaults: entity.fieldDefaults || {},
|
|
328
|
+
manyToMany: manyToMany,
|
|
329
|
+
customFunctions: entity.customFunctions || []
|
|
269
330
|
};
|
|
270
331
|
}
|
|
271
332
|
}
|
|
@@ -279,17 +340,16 @@ export function parseEntitiesFromSQL(sql) {
|
|
|
279
340
|
const parser = new EntityParser();
|
|
280
341
|
const entities = [];
|
|
281
342
|
|
|
282
|
-
// Find all register_entity calls
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
return entities;
|
|
287
|
-
}
|
|
343
|
+
// Find all register_entity calls with their positions
|
|
344
|
+
const registerPattern = /dzql\.register_entity\s*\(/gi;
|
|
345
|
+
let match;
|
|
346
|
+
let currentPos = 0;
|
|
288
347
|
|
|
289
|
-
|
|
348
|
+
while ((match = registerPattern.exec(sql)) !== null) {
|
|
290
349
|
try {
|
|
291
|
-
const entity = parser.parseFromSQL(
|
|
350
|
+
const entity = parser.parseFromSQL(sql, match.index);
|
|
292
351
|
entities.push(entity);
|
|
352
|
+
currentPos = match.index + 1; // Move past this match
|
|
293
353
|
} catch (error) {
|
|
294
354
|
console.warn('Failed to parse entity:', error.message);
|
|
295
355
|
}
|
|
@@ -23,7 +23,9 @@ CREATE TABLE IF NOT EXISTS dzql.entities (
|
|
|
23
23
|
temporal_fields jsonb DEFAULT '{}', -- valid_from/valid_to field names for temporal filtering
|
|
24
24
|
notification_paths jsonb DEFAULT '{}', -- paths to determine who gets notified
|
|
25
25
|
permission_paths jsonb DEFAULT '{}', -- paths to determine who has permission for operations
|
|
26
|
-
graph_rules jsonb DEFAULT '{}'
|
|
26
|
+
graph_rules jsonb DEFAULT '{}', -- graph evolution rules for automatic relationship management
|
|
27
|
+
field_defaults jsonb DEFAULT '{}', -- default values to auto-populate on INSERT
|
|
28
|
+
many_to_many jsonb DEFAULT '{}' -- many-to-many relationship configurations
|
|
27
29
|
);
|
|
28
30
|
|
|
29
31
|
-- === Registry (allowlist of callable functions) ===
|
|
@@ -744,6 +744,11 @@ BEGIN
|
|
|
744
744
|
-- Validate top-level trigger types
|
|
745
745
|
FOR l_trigger_key, l_trigger_rules IN SELECT * FROM jsonb_each(p_rules)
|
|
746
746
|
LOOP
|
|
747
|
+
-- Skip validation for many_to_many (different structure)
|
|
748
|
+
IF l_trigger_key = 'many_to_many' THEN
|
|
749
|
+
CONTINUE;
|
|
750
|
+
END IF;
|
|
751
|
+
|
|
747
752
|
-- Check valid trigger types
|
|
748
753
|
IF l_trigger_key NOT IN ('on_create', 'on_update', 'on_delete', 'on_field_change') THEN
|
|
749
754
|
RAISE WARNING 'Invalid trigger type: %', l_trigger_key;
|
|
@@ -223,6 +223,62 @@ BEGIN
|
|
|
223
223
|
END LOOP;
|
|
224
224
|
END IF;
|
|
225
225
|
|
|
226
|
+
-- Expand many-to-many relationships (if configured)
|
|
227
|
+
IF l_entity_config.many_to_many IS NOT NULL AND l_entity_config.many_to_many != '{}'::jsonb THEN
|
|
228
|
+
DECLARE
|
|
229
|
+
l_m2m_key text;
|
|
230
|
+
l_m2m_config jsonb;
|
|
231
|
+
l_id_field text;
|
|
232
|
+
l_junction_table text;
|
|
233
|
+
l_local_key text;
|
|
234
|
+
l_foreign_key text;
|
|
235
|
+
l_target_entity text;
|
|
236
|
+
l_expand boolean;
|
|
237
|
+
l_record_id text;
|
|
238
|
+
l_id_array jsonb;
|
|
239
|
+
l_expanded_objects jsonb;
|
|
240
|
+
BEGIN
|
|
241
|
+
-- Get the primary key value from the result
|
|
242
|
+
l_record_id := l_result->>l_pk_cols[1]; -- Assume single PK for now
|
|
243
|
+
|
|
244
|
+
FOR l_m2m_key IN SELECT jsonb_object_keys(l_entity_config.many_to_many)
|
|
245
|
+
LOOP
|
|
246
|
+
l_m2m_config := l_entity_config.many_to_many->l_m2m_key;
|
|
247
|
+
l_id_field := l_m2m_config->>'id_field';
|
|
248
|
+
l_junction_table := l_m2m_config->>'junction_table';
|
|
249
|
+
l_local_key := l_m2m_config->>'local_key';
|
|
250
|
+
l_foreign_key := l_m2m_config->>'foreign_key';
|
|
251
|
+
l_target_entity := l_m2m_config->>'target_entity';
|
|
252
|
+
l_expand := COALESCE((l_m2m_config->>'expand')::boolean, false);
|
|
253
|
+
|
|
254
|
+
-- Always include array of IDs
|
|
255
|
+
EXECUTE format('
|
|
256
|
+
SELECT COALESCE(jsonb_agg(%I), ''[]''::jsonb)
|
|
257
|
+
FROM %I
|
|
258
|
+
WHERE %I = $1::int
|
|
259
|
+
', l_foreign_key, l_junction_table, l_local_key)
|
|
260
|
+
INTO l_id_array
|
|
261
|
+
USING l_record_id;
|
|
262
|
+
|
|
263
|
+
l_result := l_result || jsonb_build_object(l_id_field, l_id_array);
|
|
264
|
+
|
|
265
|
+
-- Conditionally include expanded objects if expand: true
|
|
266
|
+
IF l_expand THEN
|
|
267
|
+
EXECUTE format('
|
|
268
|
+
SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
|
|
269
|
+
FROM %I jt
|
|
270
|
+
JOIN %I t ON t.id = jt.%I
|
|
271
|
+
WHERE jt.%I = $1::int
|
|
272
|
+
', l_junction_table, l_target_entity, l_foreign_key, l_local_key)
|
|
273
|
+
INTO l_expanded_objects
|
|
274
|
+
USING l_record_id;
|
|
275
|
+
|
|
276
|
+
l_result := l_result || jsonb_build_object(l_m2m_key, l_expanded_objects);
|
|
277
|
+
END IF;
|
|
278
|
+
END LOOP;
|
|
279
|
+
END;
|
|
280
|
+
END IF;
|
|
281
|
+
|
|
226
282
|
RETURN l_result;
|
|
227
283
|
END $$;
|
|
228
284
|
|
|
@@ -333,7 +389,29 @@ BEGIN
|
|
|
333
389
|
LOOP
|
|
334
390
|
-- Don't update any primary key columns
|
|
335
391
|
IF NOT (l_col_name = ANY(l_pk_cols)) THEN
|
|
336
|
-
|
|
392
|
+
-- Skip M2M ID fields (they're not real table columns)
|
|
393
|
+
IF l_entity_config.many_to_many IS NOT NULL THEN
|
|
394
|
+
DECLARE
|
|
395
|
+
l_m2m_id_field text;
|
|
396
|
+
l_skip boolean := false;
|
|
397
|
+
BEGIN
|
|
398
|
+
FOR l_m2m_id_field IN
|
|
399
|
+
SELECT value->>'id_field'
|
|
400
|
+
FROM jsonb_each(l_entity_config.many_to_many)
|
|
401
|
+
LOOP
|
|
402
|
+
IF l_col_name = l_m2m_id_field THEN
|
|
403
|
+
l_skip := true;
|
|
404
|
+
EXIT;
|
|
405
|
+
END IF;
|
|
406
|
+
END LOOP;
|
|
407
|
+
|
|
408
|
+
IF NOT l_skip THEN
|
|
409
|
+
l_set_clauses := l_set_clauses || format('%I = %L', l_col_name, l_merged_data ->> l_col_name);
|
|
410
|
+
END IF;
|
|
411
|
+
END;
|
|
412
|
+
ELSE
|
|
413
|
+
l_set_clauses := l_set_clauses || format('%I = %L', l_col_name, l_merged_data ->> l_col_name);
|
|
414
|
+
END IF;
|
|
337
415
|
END IF;
|
|
338
416
|
END LOOP;
|
|
339
417
|
|
|
@@ -359,6 +437,38 @@ BEGIN
|
|
|
359
437
|
l_args_json := l_args_json || jsonb_build_object('user_id', p_user_id);
|
|
360
438
|
END IF;
|
|
361
439
|
|
|
440
|
+
-- Apply field defaults for INSERT (if configured)
|
|
441
|
+
IF l_entity_config.field_defaults IS NOT NULL AND l_entity_config.field_defaults != '{}' THEN
|
|
442
|
+
FOR l_col_name IN SELECT jsonb_object_keys(l_entity_config.field_defaults)
|
|
443
|
+
LOOP
|
|
444
|
+
-- Only apply default if field is not already provided
|
|
445
|
+
IF NOT (l_args_json ? l_col_name) THEN
|
|
446
|
+
DECLARE
|
|
447
|
+
l_default_value text;
|
|
448
|
+
l_resolved_value text;
|
|
449
|
+
BEGIN
|
|
450
|
+
l_default_value := l_entity_config.field_defaults->>l_col_name;
|
|
451
|
+
|
|
452
|
+
-- Resolve variable if it starts with @
|
|
453
|
+
IF l_default_value LIKE '@%' THEN
|
|
454
|
+
l_resolved_value := dzql.resolve_graph_variable(
|
|
455
|
+
l_default_value,
|
|
456
|
+
NULL, -- no before record for INSERT
|
|
457
|
+
l_args_json, -- current data being inserted
|
|
458
|
+
p_user_id
|
|
459
|
+
);
|
|
460
|
+
ELSE
|
|
461
|
+
-- Use literal value
|
|
462
|
+
l_resolved_value := l_default_value;
|
|
463
|
+
END IF;
|
|
464
|
+
|
|
465
|
+
-- Add to l_args_json
|
|
466
|
+
l_args_json := l_args_json || jsonb_build_object(l_col_name, l_resolved_value);
|
|
467
|
+
END;
|
|
468
|
+
END IF;
|
|
469
|
+
END LOOP;
|
|
470
|
+
END IF;
|
|
471
|
+
|
|
362
472
|
-- Check create permission on new values
|
|
363
473
|
l_operation := 'create';
|
|
364
474
|
l_permission_record := l_args_json;
|
|
@@ -371,6 +481,28 @@ BEGIN
|
|
|
371
481
|
|
|
372
482
|
FOR l_col_name IN SELECT jsonb_object_keys(l_args_json)
|
|
373
483
|
LOOP
|
|
484
|
+
-- Skip M2M ID fields (they're not real table columns)
|
|
485
|
+
IF l_entity_config.many_to_many IS NOT NULL THEN
|
|
486
|
+
DECLARE
|
|
487
|
+
l_m2m_id_field text;
|
|
488
|
+
l_skip boolean := false;
|
|
489
|
+
BEGIN
|
|
490
|
+
FOR l_m2m_id_field IN
|
|
491
|
+
SELECT value->>'id_field'
|
|
492
|
+
FROM jsonb_each(l_entity_config.many_to_many)
|
|
493
|
+
LOOP
|
|
494
|
+
IF l_col_name = l_m2m_id_field THEN
|
|
495
|
+
l_skip := true;
|
|
496
|
+
EXIT;
|
|
497
|
+
END IF;
|
|
498
|
+
END LOOP;
|
|
499
|
+
|
|
500
|
+
IF l_skip THEN
|
|
501
|
+
CONTINUE;
|
|
502
|
+
END IF;
|
|
503
|
+
END;
|
|
504
|
+
END IF;
|
|
505
|
+
|
|
374
506
|
IF l_args_json ->> l_col_name IS NOT NULL AND l_args_json ->> l_col_name != '' THEN
|
|
375
507
|
l_cols := l_cols || quote_ident(l_col_name);
|
|
376
508
|
l_vals := l_vals || quote_literal(l_args_json ->> l_col_name);
|
|
@@ -391,6 +523,109 @@ BEGIN
|
|
|
391
523
|
|
|
392
524
|
END IF;
|
|
393
525
|
|
|
526
|
+
-- Sync many-to-many relationships (if configured)
|
|
527
|
+
IF l_entity_config.many_to_many IS NOT NULL AND l_entity_config.many_to_many != '{}'::jsonb THEN
|
|
528
|
+
DECLARE
|
|
529
|
+
l_m2m_key text;
|
|
530
|
+
l_m2m_config jsonb;
|
|
531
|
+
l_id_field text;
|
|
532
|
+
l_junction_table text;
|
|
533
|
+
l_local_key text;
|
|
534
|
+
l_foreign_key text;
|
|
535
|
+
l_record_id text;
|
|
536
|
+
BEGIN
|
|
537
|
+
-- Get the primary key value from the result
|
|
538
|
+
l_record_id := l_result->>l_pk_cols[1]; -- Assume single PK for now
|
|
539
|
+
|
|
540
|
+
FOR l_m2m_key IN SELECT jsonb_object_keys(l_entity_config.many_to_many)
|
|
541
|
+
LOOP
|
|
542
|
+
l_m2m_config := l_entity_config.many_to_many->l_m2m_key;
|
|
543
|
+
l_id_field := l_m2m_config->>'id_field';
|
|
544
|
+
|
|
545
|
+
-- Only sync if the ID field is present in the data
|
|
546
|
+
IF l_args_json ? l_id_field THEN
|
|
547
|
+
l_junction_table := l_m2m_config->>'junction_table';
|
|
548
|
+
l_local_key := l_m2m_config->>'local_key';
|
|
549
|
+
l_foreign_key := l_m2m_config->>'foreign_key';
|
|
550
|
+
|
|
551
|
+
-- Delete relationships not in new list
|
|
552
|
+
EXECUTE format('
|
|
553
|
+
DELETE FROM %I
|
|
554
|
+
WHERE %I = $1::int
|
|
555
|
+
AND %I <> ALL($2::int[])
|
|
556
|
+
', l_junction_table, l_local_key, l_foreign_key)
|
|
557
|
+
USING l_record_id,
|
|
558
|
+
ARRAY(SELECT jsonb_array_elements_text(l_args_json->l_id_field))::int[];
|
|
559
|
+
|
|
560
|
+
-- Insert new relationships (ignore conflicts)
|
|
561
|
+
EXECUTE format('
|
|
562
|
+
INSERT INTO %I (%I, %I)
|
|
563
|
+
SELECT $1::int, value::int
|
|
564
|
+
FROM jsonb_array_elements_text($2)
|
|
565
|
+
ON CONFLICT DO NOTHING
|
|
566
|
+
', l_junction_table, l_local_key, l_foreign_key)
|
|
567
|
+
USING l_record_id, l_args_json->l_id_field;
|
|
568
|
+
END IF;
|
|
569
|
+
END LOOP;
|
|
570
|
+
END;
|
|
571
|
+
END IF;
|
|
572
|
+
|
|
573
|
+
-- Expand many-to-many relationships in result (after sync)
|
|
574
|
+
IF l_entity_config.many_to_many IS NOT NULL AND l_entity_config.many_to_many != '{}'::jsonb THEN
|
|
575
|
+
DECLARE
|
|
576
|
+
l_m2m_key text;
|
|
577
|
+
l_m2m_config jsonb;
|
|
578
|
+
l_id_field text;
|
|
579
|
+
l_junction_table text;
|
|
580
|
+
l_local_key text;
|
|
581
|
+
l_foreign_key text;
|
|
582
|
+
l_target_entity text;
|
|
583
|
+
l_expand boolean;
|
|
584
|
+
l_record_id text;
|
|
585
|
+
l_id_array jsonb;
|
|
586
|
+
l_expanded_objects jsonb;
|
|
587
|
+
BEGIN
|
|
588
|
+
-- Get the primary key value from the result
|
|
589
|
+
l_record_id := l_result->>l_pk_cols[1]; -- Assume single PK for now
|
|
590
|
+
|
|
591
|
+
FOR l_m2m_key IN SELECT jsonb_object_keys(l_entity_config.many_to_many)
|
|
592
|
+
LOOP
|
|
593
|
+
l_m2m_config := l_entity_config.many_to_many->l_m2m_key;
|
|
594
|
+
l_id_field := l_m2m_config->>'id_field';
|
|
595
|
+
l_junction_table := l_m2m_config->>'junction_table';
|
|
596
|
+
l_local_key := l_m2m_config->>'local_key';
|
|
597
|
+
l_foreign_key := l_m2m_config->>'foreign_key';
|
|
598
|
+
l_target_entity := l_m2m_config->>'target_entity';
|
|
599
|
+
l_expand := COALESCE((l_m2m_config->>'expand')::boolean, false);
|
|
600
|
+
|
|
601
|
+
-- Always include array of IDs
|
|
602
|
+
EXECUTE format('
|
|
603
|
+
SELECT COALESCE(jsonb_agg(%I), ''[]''::jsonb)
|
|
604
|
+
FROM %I
|
|
605
|
+
WHERE %I = $1::int
|
|
606
|
+
', l_foreign_key, l_junction_table, l_local_key)
|
|
607
|
+
INTO l_id_array
|
|
608
|
+
USING l_record_id;
|
|
609
|
+
|
|
610
|
+
l_result := l_result || jsonb_build_object(l_id_field, l_id_array);
|
|
611
|
+
|
|
612
|
+
-- Conditionally include expanded objects if expand: true
|
|
613
|
+
IF l_expand THEN
|
|
614
|
+
EXECUTE format('
|
|
615
|
+
SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
|
|
616
|
+
FROM %I jt
|
|
617
|
+
JOIN %I t ON t.id = jt.%I
|
|
618
|
+
WHERE jt.%I = $1::int
|
|
619
|
+
', l_junction_table, l_target_entity, l_foreign_key, l_local_key)
|
|
620
|
+
INTO l_expanded_objects
|
|
621
|
+
USING l_record_id;
|
|
622
|
+
|
|
623
|
+
l_result := l_result || jsonb_build_object(l_m2m_key, l_expanded_objects);
|
|
624
|
+
END IF;
|
|
625
|
+
END LOOP;
|
|
626
|
+
END;
|
|
627
|
+
END IF;
|
|
628
|
+
|
|
394
629
|
-- Execute graph rules for the appropriate operation
|
|
395
630
|
l_graph_rules_result := dzql.execute_graph_rules(
|
|
396
631
|
p_entity,
|
|
@@ -398,6 +398,70 @@ BEGIN
|
|
|
398
398
|
END IF;
|
|
399
399
|
END LOOP;
|
|
400
400
|
|
|
401
|
+
-- Expand many-to-many relationships for this record (if configured)
|
|
402
|
+
IF l_entity_config.many_to_many IS NOT NULL AND l_entity_config.many_to_many != '{}'::jsonb THEN
|
|
403
|
+
DECLARE
|
|
404
|
+
l_m2m_key text;
|
|
405
|
+
l_m2m_config jsonb;
|
|
406
|
+
l_id_field text;
|
|
407
|
+
l_junction_table text;
|
|
408
|
+
l_local_key text;
|
|
409
|
+
l_foreign_key text;
|
|
410
|
+
l_target_entity text;
|
|
411
|
+
l_expand boolean;
|
|
412
|
+
l_record_id text;
|
|
413
|
+
l_id_array jsonb;
|
|
414
|
+
l_expanded_objects jsonb;
|
|
415
|
+
l_pk_cols text[];
|
|
416
|
+
BEGIN
|
|
417
|
+
-- Get primary key columns for this entity
|
|
418
|
+
SELECT array_agg(a.attname ORDER BY a.attnum)
|
|
419
|
+
INTO l_pk_cols
|
|
420
|
+
FROM pg_index idx
|
|
421
|
+
JOIN pg_attribute a ON a.attrelid = idx.indrelid AND a.attnum = ANY(idx.indkey)
|
|
422
|
+
WHERE idx.indrelid = p_entity::regclass AND idx.indisprimary;
|
|
423
|
+
|
|
424
|
+
-- Get the primary key value from the record
|
|
425
|
+
l_record_id := l_record->>l_pk_cols[1]; -- Assume single PK for now
|
|
426
|
+
|
|
427
|
+
FOR l_m2m_key IN SELECT jsonb_object_keys(l_entity_config.many_to_many)
|
|
428
|
+
LOOP
|
|
429
|
+
l_m2m_config := l_entity_config.many_to_many->l_m2m_key;
|
|
430
|
+
l_id_field := l_m2m_config->>'id_field';
|
|
431
|
+
l_junction_table := l_m2m_config->>'junction_table';
|
|
432
|
+
l_local_key := l_m2m_config->>'local_key';
|
|
433
|
+
l_foreign_key := l_m2m_config->>'foreign_key';
|
|
434
|
+
l_target_entity := l_m2m_config->>'target_entity';
|
|
435
|
+
l_expand := COALESCE((l_m2m_config->>'expand')::boolean, false);
|
|
436
|
+
|
|
437
|
+
-- Always include array of IDs
|
|
438
|
+
EXECUTE format('
|
|
439
|
+
SELECT COALESCE(jsonb_agg(%I), ''[]''::jsonb)
|
|
440
|
+
FROM %I
|
|
441
|
+
WHERE %I = $1::int
|
|
442
|
+
', l_foreign_key, l_junction_table, l_local_key)
|
|
443
|
+
INTO l_id_array
|
|
444
|
+
USING l_record_id;
|
|
445
|
+
|
|
446
|
+
l_record := l_record || jsonb_build_object(l_id_field, l_id_array);
|
|
447
|
+
|
|
448
|
+
-- Conditionally include expanded objects if expand: true
|
|
449
|
+
IF l_expand THEN
|
|
450
|
+
EXECUTE format('
|
|
451
|
+
SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
|
|
452
|
+
FROM %I jt
|
|
453
|
+
JOIN %I t ON t.id = jt.%I
|
|
454
|
+
WHERE jt.%I = $1::int
|
|
455
|
+
', l_junction_table, l_target_entity, l_foreign_key, l_local_key)
|
|
456
|
+
INTO l_expanded_objects
|
|
457
|
+
USING l_record_id;
|
|
458
|
+
|
|
459
|
+
l_record := l_record || jsonb_build_object(l_m2m_key, l_expanded_objects);
|
|
460
|
+
END IF;
|
|
461
|
+
END LOOP;
|
|
462
|
+
END;
|
|
463
|
+
END IF;
|
|
464
|
+
|
|
401
465
|
l_processed_data := l_processed_data || l_record;
|
|
402
466
|
END LOOP;
|
|
403
467
|
|
|
@@ -579,7 +579,8 @@ CREATE OR REPLACE FUNCTION dzql.register_entity(
|
|
|
579
579
|
p_temporal_fields jsonb DEFAULT '{}',
|
|
580
580
|
p_notification_paths jsonb DEFAULT '{}',
|
|
581
581
|
p_permission_paths jsonb DEFAULT '{}',
|
|
582
|
-
p_graph_rules jsonb DEFAULT '{}'
|
|
582
|
+
p_graph_rules jsonb DEFAULT '{}',
|
|
583
|
+
p_field_defaults jsonb DEFAULT '{}'
|
|
583
584
|
) RETURNS void
|
|
584
585
|
LANGUAGE plpgsql AS $$
|
|
585
586
|
DECLARE
|
|
@@ -587,6 +588,7 @@ DECLARE
|
|
|
587
588
|
l_rule_name text;
|
|
588
589
|
l_rule_config jsonb;
|
|
589
590
|
l_action jsonb;
|
|
591
|
+
l_many_to_many jsonb;
|
|
590
592
|
BEGIN
|
|
591
593
|
-- Validate permission paths if provided
|
|
592
594
|
IF p_permission_paths IS NOT NULL AND p_permission_paths != '{}' THEN
|
|
@@ -602,11 +604,14 @@ BEGIN
|
|
|
602
604
|
END IF;
|
|
603
605
|
END IF;
|
|
604
606
|
|
|
607
|
+
-- Extract many_to_many from graph_rules if present
|
|
608
|
+
l_many_to_many := COALESCE(p_graph_rules->'many_to_many', '{}'::jsonb);
|
|
609
|
+
|
|
605
610
|
-- Insert or update entity configuration
|
|
606
611
|
INSERT INTO dzql.entities
|
|
607
|
-
(table_name, label_field, searchable_fields, fk_includes, soft_delete, temporal_fields, notification_paths, permission_paths, graph_rules)
|
|
612
|
+
(table_name, label_field, searchable_fields, fk_includes, soft_delete, temporal_fields, notification_paths, permission_paths, graph_rules, field_defaults, many_to_many)
|
|
608
613
|
VALUES
|
|
609
|
-
(p_table_name, p_label_field, p_searchable_fields, p_fk_includes, p_soft_delete, p_temporal_fields, p_notification_paths, p_permission_paths, p_graph_rules)
|
|
614
|
+
(p_table_name, p_label_field, p_searchable_fields, p_fk_includes, p_soft_delete, p_temporal_fields, p_notification_paths, p_permission_paths, p_graph_rules, p_field_defaults, l_many_to_many)
|
|
610
615
|
ON CONFLICT (table_name) DO UPDATE SET
|
|
611
616
|
label_field = EXCLUDED.label_field,
|
|
612
617
|
searchable_fields = EXCLUDED.searchable_fields,
|
|
@@ -615,7 +620,9 @@ BEGIN
|
|
|
615
620
|
temporal_fields = EXCLUDED.temporal_fields,
|
|
616
621
|
notification_paths = EXCLUDED.notification_paths,
|
|
617
622
|
permission_paths = EXCLUDED.permission_paths,
|
|
618
|
-
graph_rules = EXCLUDED.graph_rules
|
|
623
|
+
graph_rules = EXCLUDED.graph_rules,
|
|
624
|
+
field_defaults = EXCLUDED.field_defaults,
|
|
625
|
+
many_to_many = EXCLUDED.many_to_many;
|
|
619
626
|
|
|
620
627
|
-- Create API functions for this entity
|
|
621
628
|
PERFORM dzql.create_entity_functions(p_table_name);
|