dzql 0.1.0-alpha.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.
@@ -0,0 +1,742 @@
1
+ -- DZQL Core Functions - Version 3.0.0
2
+ -- Helper functions, utilities, and core DZQL functionality
3
+
4
+ -- === JSON Helpers ===
5
+ CREATE OR REPLACE FUNCTION dzql.jarr(x anyarray)
6
+ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
7
+ SELECT coalesce(to_jsonb(x), '[]'::jsonb);
8
+ $$;
9
+
10
+ -- Convert record to jsonb efficiently
11
+ CREATE OR REPLACE FUNCTION dzql.to_jsonb(rec anyelement)
12
+ RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
13
+ BEGIN
14
+ RETURN to_jsonb(rec);
15
+ END $$;
16
+
17
+ -- Extract object keys as array
18
+ CREATE OR REPLACE FUNCTION dzql.keys(obj jsonb)
19
+ RETURNS text[] LANGUAGE sql IMMUTABLE AS $$
20
+ SELECT array_agg(key) FROM jsonb_object_keys(obj) AS key;
21
+ $$;
22
+
23
+ -- Build JSON object from key-value pair
24
+ CREATE OR REPLACE FUNCTION dzql.j(k text, v jsonb)
25
+ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
26
+ SELECT jsonb_build_object(k, v);
27
+ $$;
28
+
29
+ -- Merge multiple JSONB objects
30
+ CREATE OR REPLACE FUNCTION dzql.merge(variadic parts jsonb[])
31
+ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
32
+ SELECT coalesce(jsonb_strip_nulls(jsonb_object_agg(k, v)), '{}'::jsonb)
33
+ FROM (
34
+ SELECT (t.parts).key as k, (t.parts).value as v
35
+ FROM (SELECT jsonb_each(coalesce(p,'{}'::jsonb)) parts FROM unnest(parts) p) t
36
+ ) s;
37
+ $$;
38
+
39
+ -- === Temporal Filtering Helper ===
40
+ CREATE OR REPLACE FUNCTION dzql.apply_temporal_filter(
41
+ p_table regclass,
42
+ p_temporal_fields jsonb,
43
+ p_on_date timestamptz DEFAULT NULL
44
+ ) RETURNS text
45
+ LANGUAGE plpgsql AS $$
46
+ DECLARE
47
+ l_valid_from text;
48
+ l_valid_to text;
49
+ l_filter_date timestamptz;
50
+ BEGIN
51
+ IF p_temporal_fields IS NULL OR p_temporal_fields = '{}' THEN
52
+ RETURN '';
53
+ END IF;
54
+
55
+ l_valid_from := p_temporal_fields->>'valid_from';
56
+ l_valid_to := p_temporal_fields->>'valid_to';
57
+ l_filter_date := coalesce(p_on_date, now());
58
+
59
+ IF l_valid_from IS NULL THEN
60
+ RETURN '';
61
+ END IF;
62
+
63
+ RETURN format(' AND %I <= %L AND (%I > %L OR %I IS NULL)',
64
+ l_valid_from, l_filter_date,
65
+ l_valid_to, l_filter_date, l_valid_to
66
+ );
67
+ END $$;
68
+
69
+ -- === Describe (introspection) ===
70
+ CREATE OR REPLACE FUNCTION dzql.describe()
71
+ RETURNS TABLE(
72
+ table_name text,
73
+ label_field text,
74
+ searchable_fields text[],
75
+ fk_includes jsonb,
76
+ temporal_fields jsonb,
77
+ notification_paths jsonb,
78
+ permission_paths jsonb,
79
+ graph_rules jsonb
80
+ ) LANGUAGE sql AS $$
81
+ SELECT e.table_name, e.label_field, e.searchable_fields, e.fk_includes,
82
+ e.temporal_fields, e.notification_paths, e.permission_paths, e.graph_rules
83
+ FROM dzql.entities e
84
+ ORDER BY e.table_name;
85
+ $$;
86
+
87
+ -- === Legacy Exec Dispatcher (for custom functions) ===
88
+ CREATE OR REPLACE FUNCTION dzql.exec(
89
+ exposed text,
90
+ args jsonb DEFAULT '{}',
91
+ p_user_id int DEFAULT NULL
92
+ ) RETURNS jsonb
93
+ LANGUAGE plpgsql
94
+ SECURITY INVOKER
95
+ AS $$
96
+ DECLARE
97
+ l_fn_regproc regproc;
98
+ l_result jsonb;
99
+ l_arg_names text[];
100
+ l_arg_values text[];
101
+ l_call_sql text;
102
+ l_key text;
103
+ l_value jsonb;
104
+ l_user_id int;
105
+ BEGIN
106
+ -- Get user_id from session if not provided
107
+ l_user_id := coalesce(p_user_id, nullif(current_setting('dzql.user_id', true), '')::int);
108
+
109
+ -- Check if function is registered
110
+ SELECT fn_regproc INTO l_fn_regproc FROM dzql.registry WHERE fn_regproc = exposed::regproc;
111
+ IF l_fn_regproc IS NULL THEN
112
+ RAISE EXCEPTION 'Function % not found or not registered', exposed;
113
+ END IF;
114
+
115
+ -- Build function call with user_id as first parameter
116
+ l_arg_names := array['p_user_id'];
117
+ l_arg_values := array[coalesce(l_user_id, 0)::text];
118
+
119
+ -- Add other parameters
120
+ IF args IS NOT NULL AND args != '{}' THEN
121
+ FOR l_key, l_value IN SELECT key, value FROM jsonb_each(args)
122
+ LOOP
123
+ l_arg_names := l_arg_names || ('p_' || l_key);
124
+ CASE jsonb_typeof(l_value)
125
+ WHEN 'string' THEN
126
+ l_arg_values := l_arg_values || (l_value #>> '{}');
127
+ WHEN 'number' THEN
128
+ l_arg_values := l_arg_values || (l_value #>> '{}');
129
+ WHEN 'boolean' THEN
130
+ l_arg_values := l_arg_values || (l_value #>> '{}');
131
+ WHEN 'null' THEN
132
+ l_arg_values := l_arg_values || 'NULL';
133
+ ELSE
134
+ l_arg_values := l_arg_values || (l_value::text);
135
+ END CASE;
136
+ END LOOP;
137
+ END IF;
138
+
139
+ -- Build and execute function call
140
+ l_call_sql := format('SELECT %I(%s)', exposed, array_to_string(l_arg_values, ','));
141
+
142
+ BEGIN
143
+ EXECUTE l_call_sql INTO l_result;
144
+ EXCEPTION
145
+ WHEN OTHERS THEN
146
+ RAISE EXCEPTION 'DZQL: exec error for %: %', exposed, SQLERRM;
147
+ END;
148
+
149
+ RETURN l_result;
150
+ END $$;
151
+
152
+ -- === Path Resolution Functions ===
153
+ -- Resolve notification/permission paths to user IDs
154
+ -- === Path Resolution Functions ===
155
+ -- Resolve a single path segment (handles @field, table[condition], and FK traversal)
156
+ CREATE OR REPLACE FUNCTION dzql.resolve_path_segment(
157
+ p_table_name text,
158
+ p_record jsonb,
159
+ p_segment text
160
+ ) RETURNS jsonb
161
+ LANGUAGE plpgsql AS $$
162
+ DECLARE
163
+ l_result jsonb;
164
+ l_parts text[];
165
+ l_field_name text;
166
+ l_table_name text;
167
+ l_condition text;
168
+ l_is_active boolean := false;
169
+ l_sql text;
170
+ l_current_record jsonb;
171
+ l_step text;
172
+ l_hops text[];
173
+ l_final_field text;
174
+ BEGIN
175
+ -- Handle simple field reference: @field_name
176
+ IF p_segment LIKE '@%' THEN
177
+ l_field_name := substring(p_segment from 2);
178
+ IF p_record ? l_field_name AND p_record->>l_field_name IS NOT NULL THEN
179
+ DECLARE
180
+ l_field_value text;
181
+ BEGIN
182
+ l_field_value := p_record->>l_field_name;
183
+ -- Try to convert to integer and return as array
184
+ RETURN to_jsonb(array[l_field_value::int]);
185
+ EXCEPTION WHEN OTHERS THEN
186
+ -- If conversion fails, return as text array
187
+ RETURN to_jsonb(array[l_field_value]);
188
+ END;
189
+ END IF;
190
+ RETURN null;
191
+ END IF;
192
+
193
+ -- Handle conditional queries: table[condition]{active}.field
194
+ IF p_segment ~ '\[.*\]' THEN
195
+ -- Extract parts: table[condition]{active}.field
196
+ l_table_name := split_part(p_segment, '[', 1);
197
+ l_condition := split_part(split_part(p_segment, '[', 2), ']', 1);
198
+ -- Get field name after the closing bracket
199
+ l_step := split_part(p_segment, ']', 2);
200
+ -- Remove {active} if present
201
+ l_step := replace(l_step, '{active}', '');
202
+ -- Get field name after the dot
203
+ l_field_name := ltrim(l_step, '.');
204
+ l_is_active := p_segment LIKE '%{active}%';
205
+
206
+ -- Replace @ references in condition with actual values from the record (if we have one)
207
+ IF p_record IS NOT NULL THEN
208
+ DECLARE
209
+ l_ref_field text;
210
+ BEGIN
211
+ WHILE l_condition ~ '@[a-z_]+' LOOP
212
+ l_ref_field := substring(l_condition from '@([a-z_]+)');
213
+ l_condition := replace(l_condition, '@' || l_ref_field,
214
+ quote_literal(p_record->>l_ref_field));
215
+ END LOOP;
216
+ END;
217
+ END IF;
218
+
219
+ -- Build and execute query
220
+ l_sql := format('SELECT array_agg(%I) FROM %I WHERE %s',
221
+ l_field_name, l_table_name, l_condition);
222
+
223
+ -- Add temporal filtering if {active}
224
+ IF l_is_active THEN
225
+ -- Get temporal field configuration for this table
226
+ DECLARE
227
+ l_temporal_config record;
228
+ l_valid_from_field text;
229
+ l_valid_to_field text;
230
+ BEGIN
231
+ SELECT temporal_fields INTO l_temporal_config
232
+ FROM dzql.entities
233
+ WHERE table_name = l_table_name;
234
+
235
+ IF l_temporal_config.temporal_fields IS NOT NULL AND l_temporal_config.temporal_fields != '{}' THEN
236
+ l_valid_from_field := l_temporal_config.temporal_fields->>'valid_from';
237
+ l_valid_to_field := l_temporal_config.temporal_fields->>'valid_to';
238
+
239
+ IF l_valid_from_field IS NOT NULL THEN
240
+ l_sql := l_sql || format(' AND %I <= now() AND (%I > now() OR %I IS NULL)',
241
+ l_valid_from_field, l_valid_to_field, l_valid_to_field);
242
+ END IF;
243
+ END IF;
244
+ END;
245
+ END IF;
246
+
247
+ DECLARE
248
+ l_ids int[];
249
+ BEGIN
250
+ EXECUTE l_sql INTO l_ids;
251
+ IF l_ids IS NOT NULL THEN
252
+ RETURN to_jsonb(l_ids);
253
+ ELSE
254
+ RETURN null;
255
+ END IF;
256
+ END;
257
+ END IF;
258
+
259
+ -- Handle foreign key traversal (single or multi-hop)
260
+ IF p_segment ~ '\.' AND p_record IS NOT NULL THEN
261
+ -- For paths like site_id.venue_id.org_id, we need to:
262
+ -- 1. Follow site_id to get the site record
263
+ -- 2. Follow venue_id from that to get the venue record
264
+ -- 3. Extract org_id from the venue record
265
+
266
+ l_current_record := p_record;
267
+ l_parts := string_to_array(p_segment, '.');
268
+
269
+ -- We need to track which table we're currently in for FK resolution
270
+ DECLARE
271
+ l_current_table_name text;
272
+ BEGIN
273
+ l_current_table_name := p_table_name;
274
+
275
+ -- Process each part except the last (which is the field to extract)
276
+ FOR i IN 1..(array_length(l_parts, 1) - 1) LOOP
277
+ l_step := l_parts[i];
278
+
279
+ -- If current record has this field, follow it as a foreign key
280
+ IF l_current_record ? l_step THEN
281
+ -- Get the FK value
282
+ DECLARE
283
+ l_fk_value text;
284
+ l_fk_sql text;
285
+ l_entity_config record;
286
+ l_fk_target text;
287
+ BEGIN
288
+ l_fk_value := l_current_record->>l_step;
289
+
290
+ -- Look up the current table's FK configuration
291
+ IF l_current_table_name IS NOT NULL THEN
292
+ SELECT fk_includes INTO l_entity_config
293
+ FROM dzql.entities
294
+ WHERE table_name = l_current_table_name;
295
+
296
+ IF l_entity_config.fk_includes IS NOT NULL AND l_entity_config.fk_includes ? l_step THEN
297
+ -- The fk_includes tells us which table this FK points to
298
+ l_table_name := l_entity_config.fk_includes->>l_step;
299
+ ELSIF l_entity_config.fk_includes IS NOT NULL THEN
300
+ -- Check if the field without _id suffix is in fk_includes
301
+ l_fk_target := regexp_replace(l_step, '_id$', '');
302
+ IF l_entity_config.fk_includes ? l_fk_target THEN
303
+ l_table_name := l_entity_config.fk_includes->>l_fk_target;
304
+ END IF;
305
+ END IF;
306
+ END IF;
307
+
308
+ -- If we still don't have a table name, try pattern matching
309
+ IF l_table_name IS NULL THEN
310
+ DECLARE
311
+ l_pattern text;
312
+ BEGIN
313
+ -- Remove _id suffix and try to find a matching table
314
+ l_pattern := regexp_replace(l_step, '_id$', '');
315
+
316
+ -- Check for common patterns
317
+ IF EXISTS(SELECT 1 FROM dzql.entities WHERE table_name = l_pattern || 's') THEN
318
+ l_table_name := l_pattern || 's';
319
+ ELSIF EXISTS(SELECT 1 FROM dzql.entities WHERE table_name = l_pattern) THEN
320
+ l_table_name := l_pattern;
321
+ END IF;
322
+ END;
323
+ END IF;
324
+
325
+ IF l_table_name IS NULL THEN
326
+ RAISE WARNING 'Could not determine target table for FK field % in table %', l_step, coalesce(l_current_table_name, 'unknown');
327
+ RETURN null;
328
+ END IF;
329
+
330
+ -- Query the related table
331
+ l_fk_sql := format('SELECT to_jsonb(t.*) FROM %I t WHERE t.id = %L', l_table_name, l_fk_value);
332
+ EXECUTE l_fk_sql INTO l_current_record;
333
+
334
+ IF l_current_record IS NULL THEN
335
+ RETURN null; -- FK broken
336
+ END IF;
337
+
338
+ -- Update current table context for next iteration
339
+ l_current_table_name := l_table_name;
340
+ END;
341
+ ELSE
342
+ RETURN null; -- Field not found
343
+ END IF;
344
+ END LOOP;
345
+ END;
346
+
347
+ -- Extract the final field value
348
+ l_final_field := l_parts[array_length(l_parts, 1)];
349
+ IF l_current_record ? l_final_field THEN
350
+ RETURN to_jsonb(array[l_current_record->>l_final_field]::int[]);
351
+ END IF;
352
+
353
+ RETURN null;
354
+ END IF;
355
+
356
+ RETURN null;
357
+ END $$;
358
+
359
+ -- Resolve notification/permission paths to user IDs
360
+ CREATE OR REPLACE FUNCTION dzql.resolve_notification_path(
361
+ p_table_name text,
362
+ p_record jsonb,
363
+ p_path text
364
+ ) RETURNS int[]
365
+ LANGUAGE plpgsql AS $$
366
+ DECLARE
367
+ l_result_ids int[] := '{}';
368
+ l_continuation_parts text[];
369
+ l_current_segment text;
370
+ l_current_result jsonb;
371
+ l_current_ids int[];
372
+ l_sql text;
373
+ l_field_name text;
374
+ l_table_name text;
375
+ l_condition text;
376
+ l_is_active boolean := false;
377
+ l_i int;
378
+ BEGIN
379
+ -- Split by continuation operator (->) if present
380
+ IF p_path ~ '->' THEN
381
+ l_continuation_parts := string_to_array(p_path, '->');
382
+
383
+ -- Process first segment with the original record
384
+ l_current_result := dzql.resolve_path_segment(p_table_name, p_record, l_continuation_parts[1]);
385
+
386
+ -- Process each continuation segment
387
+ FOR l_i IN 2..array_length(l_continuation_parts, 1) LOOP
388
+ l_current_segment := l_continuation_parts[l_i];
389
+
390
+ -- Replace $ with the result from previous segment
391
+ IF l_current_result IS NOT NULL THEN
392
+ -- Handle array results
393
+ IF jsonb_typeof(l_current_result) = 'array' THEN
394
+ -- For each ID in the array, resolve the path and collect results
395
+ l_current_ids := '{}';
396
+ DECLARE
397
+ l_j int;
398
+ l_temp_segment text;
399
+ l_temp_ids int[];
400
+ l_temp_value int;
401
+ l_segment_table text;
402
+ l_segment_record jsonb;
403
+ BEGIN
404
+ FOR l_j IN 0..jsonb_array_length(l_current_result) - 1 LOOP
405
+ l_temp_segment := replace(l_continuation_parts[l_i], '$', (l_current_result->>l_j)::text);
406
+
407
+ -- Handle table.field syntax in continuation segments
408
+ IF l_temp_segment ~ '^[a-z_]+\.[a-z_]+' THEN
409
+ l_segment_table := split_part(l_temp_segment, '.', 1);
410
+ l_field_name := split_part(l_temp_segment, '.', 2);
411
+ -- Query the table directly to get the field value
412
+ l_sql := format('SELECT %I FROM %I WHERE id = %L', l_field_name, l_segment_table, (l_current_result->>l_j)::int);
413
+ EXECUTE l_sql INTO l_temp_value;
414
+ l_temp_ids := array[l_temp_value];
415
+ ELSE
416
+ l_temp_ids := array(SELECT jsonb_array_elements_text(dzql.resolve_path_segment(null, null, l_temp_segment))::int);
417
+ END IF;
418
+
419
+ l_current_ids := l_current_ids || coalesce(l_temp_ids, '{}');
420
+ END LOOP;
421
+ END;
422
+ l_current_result := to_jsonb(l_current_ids);
423
+ ELSE
424
+ -- Single value result
425
+ l_current_segment := replace(l_current_segment, '$', l_current_result::text);
426
+
427
+ -- Handle table.field syntax in continuation segments
428
+ IF l_current_segment ~ '^[a-z_]+\.[a-z_]+' THEN
429
+ l_table_name := split_part(l_current_segment, '.', 1);
430
+ l_field_name := split_part(l_current_segment, '.', 2);
431
+ -- Query the table directly to get the field value
432
+ DECLARE
433
+ l_field_value int;
434
+ BEGIN
435
+ l_sql := format('SELECT %I FROM %I WHERE id = %L', l_field_name, l_table_name, l_current_result::text::int);
436
+ EXECUTE l_sql INTO l_field_value;
437
+ l_current_result := to_jsonb(array[l_field_value]);
438
+ END;
439
+ ELSE
440
+ l_current_result := dzql.resolve_path_segment(null, null, l_current_segment);
441
+ END IF;
442
+ END IF;
443
+ ELSE
444
+ RETURN '{}'; -- Path broken
445
+ END IF;
446
+ END LOOP;
447
+
448
+ -- Convert final result to int array
449
+ IF jsonb_typeof(l_current_result) = 'array' THEN
450
+ l_result_ids := array(SELECT jsonb_array_elements_text(l_current_result)::int);
451
+ ELSE
452
+ l_result_ids := array[l_current_result::text::int];
453
+ END IF;
454
+
455
+ RETURN coalesce(l_result_ids, '{}');
456
+ ELSE
457
+ -- No continuation, process as single segment
458
+ l_current_result := dzql.resolve_path_segment(p_table_name, p_record, p_path);
459
+
460
+ -- Convert result to int array
461
+ IF l_current_result IS NOT NULL THEN
462
+ IF jsonb_typeof(l_current_result) = 'array' THEN
463
+ l_result_ids := array(SELECT jsonb_array_elements_text(l_current_result)::int);
464
+ ELSIF l_current_result::text != 'null' THEN
465
+ l_result_ids := array[l_current_result::text::int];
466
+ END IF;
467
+ END IF;
468
+
469
+ RETURN coalesce(l_result_ids, '{}');
470
+ END IF;
471
+ EXCEPTION
472
+ WHEN others THEN
473
+ RAISE WARNING 'Failed to resolve path %: %', p_path, SQLERRM;
474
+ RETURN '{}';
475
+ END $$;
476
+
477
+ -- Backward compatibility alias
478
+ CREATE OR REPLACE FUNCTION dzql.resolve_path_to_users(
479
+ p_path text,
480
+ p_record jsonb,
481
+ p_table_name text DEFAULT NULL
482
+ ) RETURNS int[]
483
+ LANGUAGE plpgsql AS $$
484
+ BEGIN
485
+ RETURN dzql.resolve_notification_path(p_table_name, p_record, p_path);
486
+ END $$;
487
+
488
+ -- === Permission Validation ===
489
+ CREATE OR REPLACE FUNCTION dzql.validate_permission_paths(
490
+ p_table_name text,
491
+ p_permission_paths jsonb
492
+ ) RETURNS boolean
493
+ LANGUAGE plpgsql AS $$
494
+ DECLARE
495
+ l_operation text;
496
+ l_paths jsonb;
497
+ BEGIN
498
+ -- Check if permission_paths is valid JSON object
499
+ IF jsonb_typeof(p_permission_paths) != 'object' THEN
500
+ RETURN false;
501
+ END IF;
502
+
503
+ -- Check valid operation keys
504
+ FOR l_operation IN SELECT key FROM jsonb_object_keys(p_permission_paths) AS key
505
+ LOOP
506
+ IF l_operation NOT IN ('create', 'update', 'delete', 'view') THEN
507
+ RAISE WARNING 'Invalid permission operation: %', l_operation;
508
+ RETURN false;
509
+ END IF;
510
+
511
+ l_paths := p_permission_paths->l_operation;
512
+ IF jsonb_typeof(l_paths) != 'array' THEN
513
+ RAISE WARNING 'Permission paths for % must be an array', l_operation;
514
+ RETURN false;
515
+ END IF;
516
+ END LOOP;
517
+
518
+ RETURN true;
519
+ END $$;
520
+
521
+ -- Check if user has permission for operation
522
+ CREATE OR REPLACE FUNCTION dzql.check_permission(
523
+ p_user_id int,
524
+ p_operation text,
525
+ p_entity text,
526
+ p_record jsonb
527
+ ) RETURNS boolean
528
+ LANGUAGE plpgsql AS $$
529
+ DECLARE
530
+ l_entity_config record;
531
+ l_permission_paths jsonb;
532
+ l_path text;
533
+ l_allowed_users int[];
534
+ BEGIN
535
+ -- Get entity configuration
536
+ SELECT * INTO l_entity_config FROM dzql.entities WHERE table_name = p_entity;
537
+
538
+ IF l_entity_config IS NULL THEN
539
+ RETURN true; -- Entity not configured - permissive default like old version
540
+ END IF;
541
+
542
+ l_permission_paths := l_entity_config.permission_paths->p_operation;
543
+
544
+ -- Empty array means public access
545
+ IF l_permission_paths IS NULL OR jsonb_array_length(l_permission_paths) = 0 THEN
546
+ RETURN true;
547
+ END IF;
548
+
549
+ -- Check each permission path
550
+ FOR l_path IN SELECT jsonb_array_elements_text(l_permission_paths)
551
+ LOOP
552
+ l_allowed_users := dzql.resolve_notification_path(p_entity, p_record, l_path);
553
+
554
+ IF p_user_id = ANY(l_allowed_users) THEN
555
+ RETURN true;
556
+ END IF;
557
+ END LOOP;
558
+
559
+ RETURN false;
560
+ END $$;
561
+
562
+ -- Resolve all notification paths for an entity to user IDs
563
+ CREATE OR REPLACE FUNCTION dzql.resolve_notification_paths(
564
+ p_table_name text,
565
+ p_record jsonb
566
+ ) RETURNS int[]
567
+ LANGUAGE plpgsql AS $$
568
+ DECLARE
569
+ l_entity_config record;
570
+ l_all_user_ids int[] := '{}';
571
+ l_path_group_key text;
572
+ l_path_array jsonb;
573
+ l_path text;
574
+ l_user_ids int[];
575
+ BEGIN
576
+ -- Get entity configuration
577
+ SELECT * INTO l_entity_config
578
+ FROM dzql.entities
579
+ WHERE table_name = p_table_name;
580
+
581
+ IF l_entity_config.notification_paths IS NULL THEN
582
+ RETURN '{}';
583
+ END IF;
584
+
585
+ -- Process each notification path group (ownership, commercial, delegated, etc.)
586
+ FOR l_path_group_key IN SELECT jsonb_object_keys(l_entity_config.notification_paths)
587
+ LOOP
588
+ l_path_array := l_entity_config.notification_paths->l_path_group_key;
589
+
590
+ -- Process each path in the group
591
+ FOR l_path IN SELECT jsonb_array_elements_text(l_path_array)
592
+ LOOP
593
+ l_user_ids := dzql.resolve_notification_path(p_table_name, p_record, l_path);
594
+ l_all_user_ids := l_all_user_ids || l_user_ids;
595
+ END LOOP;
596
+ END LOOP;
597
+
598
+ -- Remove duplicates and return user_ids directly (paths now resolve to user_ids)
599
+ l_all_user_ids := array(SELECT DISTINCT unnest(l_all_user_ids));
600
+
601
+ RETURN coalesce(l_all_user_ids, '{}');
602
+ END $$;
603
+
604
+ -- === Utility Functions ===
605
+ -- Set current user for audit trail
606
+ CREATE OR REPLACE FUNCTION dzql.set_current_user(p_user_id int)
607
+ RETURNS void
608
+ LANGUAGE sql AS $$
609
+ SELECT set_config('app.current_user_id', p_user_id::text, false);
610
+ $$;
611
+
612
+ -- === Graph Rules Helper Functions ===
613
+ -- Validate graph rules structure
614
+ CREATE OR REPLACE FUNCTION dzql.validate_graph_rules(
615
+ p_rules jsonb
616
+ ) RETURNS boolean
617
+ LANGUAGE plpgsql AS $$
618
+ DECLARE
619
+ l_trigger_key text;
620
+ l_trigger_rules jsonb;
621
+ l_rule_name text;
622
+ l_rule_config jsonb;
623
+ l_action jsonb;
624
+ l_action_type text;
625
+ BEGIN
626
+ -- Check if rules is empty or null (valid)
627
+ IF p_rules IS NULL OR p_rules = '{}' THEN
628
+ RETURN true;
629
+ END IF;
630
+
631
+ -- Validate top-level trigger types
632
+ FOR l_trigger_key, l_trigger_rules IN SELECT * FROM jsonb_each(p_rules)
633
+ LOOP
634
+ -- Check valid trigger types
635
+ IF l_trigger_key NOT IN ('on_create', 'on_update', 'on_delete', 'on_field_change') THEN
636
+ RAISE WARNING 'Invalid trigger type: %', l_trigger_key;
637
+ RETURN false;
638
+ END IF;
639
+
640
+ -- Validate each rule within the trigger
641
+ FOR l_rule_name, l_rule_config IN SELECT * FROM jsonb_each(l_trigger_rules)
642
+ LOOP
643
+ -- Check required fields
644
+ IF NOT l_rule_config ? 'actions' THEN
645
+ RAISE WARNING 'Rule % missing required "actions" field', l_rule_name;
646
+ RETURN false;
647
+ END IF;
648
+
649
+ -- Validate each action
650
+ FOR l_action IN SELECT * FROM jsonb_array_elements(l_rule_config->'actions')
651
+ LOOP
652
+ l_action_type := l_action->>'type';
653
+
654
+ -- Check valid action types
655
+ IF l_action_type NOT IN ('create', 'update', 'delete') THEN
656
+ RAISE WARNING 'Invalid action type: %', l_action_type;
657
+ RETURN false;
658
+ END IF;
659
+
660
+ -- Check required fields per action type
661
+ IF l_action_type IN ('create', 'update') AND NOT l_action ? 'entity' THEN
662
+ RAISE WARNING 'Action type % requires "entity" field', l_action_type;
663
+ RETURN false;
664
+ END IF;
665
+
666
+ IF l_action_type = 'create' AND NOT l_action ? 'data' THEN
667
+ RAISE WARNING 'Action type "create" requires "data" field';
668
+ RETURN false;
669
+ END IF;
670
+
671
+ IF l_action_type IN ('update', 'delete') AND NOT l_action ? 'match' THEN
672
+ RAISE WARNING 'Action type % requires "match" field', l_action_type;
673
+ RETURN false;
674
+ END IF;
675
+ END LOOP;
676
+ END LOOP;
677
+ END LOOP;
678
+
679
+ RETURN true;
680
+ END $$;
681
+
682
+ -- Resolve graph variables like @user_id, @field_name, etc.
683
+ CREATE OR REPLACE FUNCTION dzql.resolve_graph_variable(
684
+ p_variable text,
685
+ p_record_before jsonb,
686
+ p_record_after jsonb,
687
+ p_user_id int
688
+ ) RETURNS text
689
+ LANGUAGE plpgsql AS $$
690
+ DECLARE
691
+ l_result text;
692
+ l_field_name text;
693
+ l_record jsonb;
694
+ BEGIN
695
+ -- Handle built-in variables
696
+ CASE p_variable
697
+ WHEN '@user_id' THEN
698
+ l_result := p_user_id::text;
699
+ WHEN '@now' THEN
700
+ l_result := now()::text;
701
+ WHEN '@today' THEN
702
+ l_result := current_date::text;
703
+ ELSE
704
+ -- Handle field variables like @field_name or @field_name_before
705
+ IF p_variable LIKE '@%_before' THEN
706
+ l_field_name := substring(p_variable from 2 for length(p_variable) - 8);
707
+ l_record := p_record_before;
708
+ ELSE
709
+ l_field_name := substring(p_variable from 2);
710
+ l_record := COALESCE(p_record_after, p_record_before);
711
+ END IF;
712
+
713
+ l_result := l_record->>l_field_name;
714
+ END CASE;
715
+
716
+ RETURN l_result;
717
+ END $$;
718
+
719
+ -- Resolve data object with variable substitution
720
+ CREATE OR REPLACE FUNCTION dzql.resolve_graph_data(
721
+ p_data jsonb,
722
+ p_record_before jsonb,
723
+ p_record_after jsonb,
724
+ p_user_id int
725
+ ) RETURNS jsonb
726
+ LANGUAGE plpgsql AS $$
727
+ DECLARE
728
+ l_result jsonb := '{}';
729
+ l_key text;
730
+ l_value text;
731
+ BEGIN
732
+ FOR l_key, l_value IN SELECT * FROM jsonb_each_text(p_data)
733
+ LOOP
734
+ IF l_value LIKE '@%' THEN
735
+ l_value := dzql.resolve_graph_variable(l_value, p_record_before, p_record_after, p_user_id);
736
+ END IF;
737
+
738
+ l_result := l_result || jsonb_build_object(l_key, l_value);
739
+ END LOOP;
740
+
741
+ RETURN l_result;
742
+ END $$;