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,725 @@
1
+ -- DZQL Core Operations - Version 3.0.0
2
+ -- Generic CRUD operations for entities (get, save, delete, lookup)
3
+
4
+ -- === Foreign Key Resolution Helpers ===
5
+ -- Resolve direct foreign key (field -> table lookup)
6
+ CREATE OR REPLACE FUNCTION dzql.resolve_direct_fk(
7
+ p_record jsonb,
8
+ p_fk_field text,
9
+ p_target_table text,
10
+ p_on_date timestamptz DEFAULT NULL
11
+ ) RETURNS jsonb
12
+ LANGUAGE plpgsql AS $$
13
+ DECLARE
14
+ l_fk_id text;
15
+ l_temporal_config record;
16
+ l_temporal_filter text;
17
+ l_sql text;
18
+ l_result jsonb;
19
+ BEGIN
20
+ -- Get foreign key value
21
+ -- First try the field directly, then try with _id suffix
22
+ l_fk_id := p_record->>p_fk_field;
23
+
24
+ IF l_fk_id IS NULL THEN
25
+ -- Try with _id suffix (e.g., 'org' -> 'org_id')
26
+ l_fk_id := p_record->>(p_fk_field || '_id');
27
+ END IF;
28
+
29
+ IF l_fk_id IS NULL THEN
30
+ RETURN NULL;
31
+ END IF;
32
+
33
+ -- Get temporal configuration for target table
34
+ SELECT temporal_fields INTO l_temporal_config
35
+ FROM dzql.entities
36
+ WHERE table_name = p_target_table;
37
+
38
+ -- Build temporal filter
39
+ l_temporal_filter := dzql.apply_temporal_filter(
40
+ p_target_table::regclass,
41
+ COALESCE(l_temporal_config.temporal_fields, '{}'::jsonb),
42
+ p_on_date
43
+ );
44
+
45
+ -- Build and execute query
46
+ l_sql := format('SELECT to_jsonb(t.*) FROM %I t WHERE id = %L%s',
47
+ p_target_table, l_fk_id, l_temporal_filter);
48
+
49
+ EXECUTE l_sql INTO l_result;
50
+
51
+ RETURN l_result;
52
+ END $$;
53
+
54
+ -- Resolve reverse foreign key (table.field -> this record)
55
+ CREATE OR REPLACE FUNCTION dzql.resolve_reverse_fk(
56
+ p_record jsonb,
57
+ p_result_field text,
58
+ p_table_field text,
59
+ p_on_date timestamptz DEFAULT NULL
60
+ ) RETURNS jsonb
61
+ LANGUAGE plpgsql AS $$
62
+ DECLARE
63
+ l_parts text[];
64
+ l_target_table text;
65
+ l_target_field text;
66
+ l_record_id text;
67
+ l_temporal_config record;
68
+ l_temporal_filter text;
69
+ l_sql text;
70
+ l_result jsonb;
71
+ BEGIN
72
+ -- Parse "table.field" format
73
+ l_parts := string_to_array(p_table_field, '.');
74
+ IF array_length(l_parts, 1) != 2 THEN
75
+ RETURN NULL;
76
+ END IF;
77
+
78
+ l_target_table := l_parts[1];
79
+ l_target_field := l_parts[2];
80
+ l_record_id := p_record->>'id';
81
+
82
+ IF l_record_id IS NULL THEN
83
+ RETURN NULL;
84
+ END IF;
85
+
86
+ -- Get temporal configuration for target table
87
+ SELECT temporal_fields INTO l_temporal_config
88
+ FROM dzql.entities
89
+ WHERE table_name = l_target_table;
90
+
91
+ -- Build temporal filter
92
+ l_temporal_filter := dzql.apply_temporal_filter(
93
+ l_target_table::regclass,
94
+ COALESCE(l_temporal_config.temporal_fields, '{}'::jsonb),
95
+ p_on_date
96
+ );
97
+
98
+ -- Build and execute query
99
+ l_sql := format('SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb) FROM %I t WHERE %I = %L%s',
100
+ l_target_table, l_target_field, l_record_id, l_temporal_filter);
101
+
102
+ EXECUTE l_sql INTO l_result;
103
+
104
+ RETURN l_result;
105
+ END $$;
106
+
107
+ -- === Generic GET Operation ===
108
+ -- Generic GET with foreign key dereferencing and temporal filtering
109
+ CREATE OR REPLACE FUNCTION dzql.generic_get(
110
+ p_entity text,
111
+ p_args jsonb,
112
+ p_user_id int
113
+ ) RETURNS jsonb
114
+ LANGUAGE plpgsql
115
+ SECURITY INVOKER
116
+ AS $$
117
+ DECLARE
118
+ l_entity_config record;
119
+ l_pk_field text;
120
+ l_pk_value text;
121
+ l_on_date timestamptz;
122
+ l_base_sql text;
123
+ l_temporal_filter text;
124
+ l_result jsonb;
125
+ l_fk_includes jsonb;
126
+ l_key text;
127
+ l_value text;
128
+ l_fk_result jsonb;
129
+ l_pk_cols text[];
130
+ l_is_compound_key boolean;
131
+ l_lookup_result jsonb;
132
+ BEGIN
133
+ -- Get entity configuration
134
+ SELECT * INTO l_entity_config FROM dzql.entities WHERE table_name = p_entity;
135
+
136
+ IF l_entity_config IS NULL THEN
137
+ RAISE EXCEPTION 'DZQL: entity % not configured', p_entity;
138
+ END IF;
139
+
140
+ -- Get primary key columns to check if this is a compound key
141
+ SELECT array_agg(a.attname ORDER BY a.attnum)
142
+ INTO l_pk_cols
143
+ FROM pg_index i
144
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
145
+ WHERE i.indrelid = p_entity::regclass AND i.indisprimary;
146
+
147
+ -- Check if this is a compound key
148
+ l_is_compound_key := array_length(l_pk_cols, 1) > 1;
149
+
150
+ -- For compound keys, use LOOKUP logic and return the label
151
+ IF l_is_compound_key THEN
152
+ l_lookup_result := dzql.generic_lookup(p_entity, p_args, p_user_id);
153
+
154
+ IF l_lookup_result IS NOT NULL AND jsonb_array_length(l_lookup_result) > 0 THEN
155
+ RETURN l_lookup_result->0->'label';
156
+ ELSE
157
+ RETURN NULL;
158
+ END IF;
159
+ END IF;
160
+
161
+ -- Extract primary key
162
+ l_pk_field := 'id';
163
+ l_pk_value := p_args ->> ('p_' || p_entity || '_id');
164
+ IF l_pk_value IS NULL THEN
165
+ l_pk_value := p_args ->> 'p_id';
166
+ END IF;
167
+ IF l_pk_value IS NULL THEN
168
+ l_pk_value := p_args ->> 'id';
169
+ END IF;
170
+
171
+ IF l_pk_value IS NULL THEN
172
+ RAISE EXCEPTION 'DZQL: no primary key provided for entity %', p_entity;
173
+ END IF;
174
+
175
+ -- Extract temporal parameter
176
+ l_on_date := (p_args ->> 'on_date')::timestamptz;
177
+
178
+ -- Build temporal filter
179
+ l_temporal_filter := dzql.apply_temporal_filter(
180
+ p_entity::regclass,
181
+ l_entity_config.temporal_fields,
182
+ l_on_date
183
+ );
184
+
185
+ -- Build base query
186
+ l_base_sql := format('SELECT to_jsonb(t.*) FROM %I t WHERE %I = %L%s',
187
+ p_entity, l_pk_field, l_pk_value, l_temporal_filter);
188
+
189
+ EXECUTE l_base_sql INTO l_result;
190
+
191
+ IF l_result IS NULL THEN
192
+ RAISE EXCEPTION 'DZQL: record not found in %', p_entity;
193
+ END IF;
194
+
195
+ -- Check view permission
196
+ IF NOT dzql.check_permission(p_user_id, 'view', p_entity, l_result) THEN
197
+ RAISE EXCEPTION 'Permission denied: view on %', p_entity;
198
+ END IF;
199
+
200
+ -- Dereference foreign keys
201
+ l_fk_includes := l_entity_config.fk_includes;
202
+ IF l_fk_includes IS NOT NULL AND l_fk_includes != '{}' THEN
203
+ FOR l_key, l_value IN SELECT key, value FROM jsonb_each_text(l_fk_includes)
204
+ LOOP
205
+ -- Handle different FK reference formats
206
+ IF l_value LIKE '%.%' THEN
207
+ -- Format: "table.field" for reverse foreign keys
208
+ l_fk_result := dzql.resolve_reverse_fk(l_result, l_key, l_value, l_on_date);
209
+ ELSIF l_key = l_value THEN
210
+ -- When key equals value (e.g., "sites": "sites"), it's a reverse FK
211
+ -- The target table has a field named {entity_singular}_id pointing back to this entity
212
+ -- Convert plural entity name to singular (simple rule: remove trailing 's')
213
+ l_fk_result := dzql.resolve_reverse_fk(l_result, l_key,
214
+ l_value || '.' || regexp_replace(p_entity, 's$', '') || '_id', l_on_date);
215
+ ELSE
216
+ -- Format: "table" for direct foreign keys
217
+ l_fk_result := dzql.resolve_direct_fk(l_result, l_key, l_value, l_on_date);
218
+ END IF;
219
+
220
+ IF l_fk_result IS NOT NULL THEN
221
+ l_result := l_result || jsonb_build_object(l_key, l_fk_result);
222
+ END IF;
223
+ END LOOP;
224
+ END IF;
225
+
226
+ RETURN l_result;
227
+ END $$;
228
+
229
+ -- === Generic SAVE Operation ===
230
+ -- Generic SAVE with upsert capability and graph rules
231
+ CREATE OR REPLACE FUNCTION dzql.generic_save(
232
+ p_entity text,
233
+ p_args jsonb,
234
+ p_user_id int
235
+ ) RETURNS jsonb
236
+ LANGUAGE plpgsql
237
+ SECURITY INVOKER
238
+ AS $$
239
+ DECLARE
240
+ l_entity_config record;
241
+ l_pk_cols text[];
242
+ l_cols text[];
243
+ l_vals text[];
244
+ l_set_clauses text[];
245
+ l_col_name text;
246
+ l_sql_stmt text;
247
+ l_existing_record jsonb;
248
+ l_merged_data jsonb;
249
+ l_result jsonb;
250
+ l_record_id text;
251
+ l_args_json jsonb;
252
+ l_operation text;
253
+ l_permission_record jsonb;
254
+ l_graph_rules_result jsonb;
255
+ l_is_insert boolean := false;
256
+ l_pk_where text;
257
+ l_pk_where_clauses text[] := array[]::text[];
258
+ i int;
259
+ BEGIN
260
+ -- Ensure p_args is proper JSONB
261
+ l_args_json := p_args::jsonb;
262
+
263
+ -- Get entity configuration
264
+ SELECT * INTO l_entity_config FROM dzql.entities WHERE table_name = p_entity;
265
+
266
+ IF l_entity_config IS NULL THEN
267
+ RAISE EXCEPTION 'DZQL: entity % not configured', p_entity;
268
+ END IF;
269
+
270
+ -- Get primary key columns
271
+ SELECT array_agg(a.attname ORDER BY a.attnum)
272
+ INTO l_pk_cols
273
+ FROM pg_index i
274
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
275
+ WHERE i.indrelid = p_entity::regclass AND i.indisprimary;
276
+
277
+ IF l_pk_cols IS NULL THEN
278
+ RAISE EXCEPTION 'DZQL: entity % has no primary key', p_entity;
279
+ END IF;
280
+
281
+ -- Check if this is an update (has all PKs) or insert (missing any PK)
282
+ -- Check if any PK column is missing
283
+ FOR i IN 1..array_length(l_pk_cols, 1) LOOP
284
+ IF l_args_json ->> l_pk_cols[i] IS NULL THEN
285
+ l_is_insert := true;
286
+ EXIT;
287
+ END IF;
288
+ END LOOP;
289
+
290
+ -- If all PK columns provided, check if record exists
291
+ IF NOT l_is_insert THEN
292
+ -- Build composite WHERE clause for existing record check
293
+ FOR i IN 1..array_length(l_pk_cols, 1) LOOP
294
+ l_pk_where_clauses := l_pk_where_clauses ||
295
+ format('%I = %L', l_pk_cols[i], l_args_json ->> l_pk_cols[i]);
296
+ END LOOP;
297
+ l_pk_where := array_to_string(l_pk_where_clauses, ' AND ');
298
+
299
+ -- Get existing record using composite WHERE clause
300
+ l_sql_stmt := format('SELECT to_jsonb(t.*) FROM %I t WHERE %s', p_entity, l_pk_where);
301
+ EXECUTE l_sql_stmt INTO l_existing_record;
302
+
303
+ IF l_existing_record IS NULL THEN
304
+ -- User provided ID but record doesn't exist - this is an error
305
+ RAISE EXCEPTION 'DZQL: record with id % not found in %', l_args_json ->> l_pk_cols[1], p_entity;
306
+ END IF;
307
+ END IF;
308
+
309
+ IF NOT l_is_insert THEN
310
+ -- UPDATE: Merge with existing record
311
+
312
+ -- Check update permission on existing record
313
+ l_operation := 'update';
314
+ l_permission_record := l_existing_record;
315
+ IF NOT dzql.check_permission(p_user_id, l_operation, p_entity, l_permission_record) THEN
316
+ RAISE EXCEPTION 'Permission denied: % on %', l_operation, p_entity;
317
+ END IF;
318
+
319
+ -- Merge existing data with new data (new data takes precedence)
320
+ l_merged_data := l_existing_record || l_args_json;
321
+
322
+ -- Build SET clauses for UPDATE
323
+ l_set_clauses := array[]::text[];
324
+ FOR l_col_name IN SELECT jsonb_object_keys(l_merged_data)
325
+ LOOP
326
+ -- Don't update any primary key columns
327
+ IF NOT (l_col_name = ANY(l_pk_cols)) THEN
328
+ l_set_clauses := l_set_clauses || format('%I = %L', l_col_name, l_merged_data ->> l_col_name);
329
+ END IF;
330
+ END LOOP;
331
+
332
+ -- Execute UPDATE using composite WHERE clause
333
+ l_sql_stmt := format('UPDATE %I SET %s WHERE %s RETURNING to_jsonb(%I.*)',
334
+ p_entity,
335
+ array_to_string(l_set_clauses, ', '),
336
+ l_pk_where,
337
+ p_entity);
338
+ EXECUTE l_sql_stmt INTO l_result;
339
+
340
+ -- Execute graph rules for update
341
+ l_graph_rules_result := dzql.execute_graph_rules(
342
+ p_entity,
343
+ 'update',
344
+ l_existing_record,
345
+ l_result,
346
+ p_user_id
347
+ );
348
+
349
+ ELSE
350
+ -- INSERT: Use provided values, let database handle defaults
351
+
352
+ -- Check create permission on new values
353
+ l_operation := 'create';
354
+ l_permission_record := l_args_json;
355
+ IF NOT dzql.check_permission(p_user_id, l_operation, p_entity, l_permission_record) THEN
356
+ RAISE EXCEPTION 'Permission denied: % on %', l_operation, p_entity;
357
+ END IF;
358
+
359
+ l_cols := array[]::text[];
360
+ l_vals := array[]::text[];
361
+
362
+ FOR l_col_name IN SELECT jsonb_object_keys(l_args_json)
363
+ LOOP
364
+ IF l_args_json ->> l_col_name IS NOT NULL AND l_args_json ->> l_col_name != '' THEN
365
+ l_cols := l_cols || quote_ident(l_col_name);
366
+ l_vals := l_vals || quote_literal(l_args_json ->> l_col_name);
367
+ END IF;
368
+ END LOOP;
369
+
370
+ IF array_length(l_cols, 1) = 0 THEN
371
+ RAISE EXCEPTION 'DZQL: no valid columns provided for insert into %', p_entity;
372
+ END IF;
373
+
374
+ -- Execute INSERT
375
+ l_sql_stmt := format('INSERT INTO %I (%s) VALUES (%s) RETURNING to_jsonb(%I.*)',
376
+ p_entity,
377
+ array_to_string(l_cols, ', '),
378
+ array_to_string(l_vals, ', '),
379
+ p_entity);
380
+ EXECUTE l_sql_stmt INTO l_result;
381
+
382
+ -- Execute graph rules for insert
383
+ l_graph_rules_result := dzql.execute_graph_rules(
384
+ p_entity,
385
+ 'insert',
386
+ NULL,
387
+ l_result,
388
+ p_user_id
389
+ );
390
+ END IF;
391
+
392
+ -- Execute graph rules for the appropriate operation
393
+ l_graph_rules_result := dzql.execute_graph_rules(
394
+ p_entity,
395
+ CASE WHEN l_is_insert THEN 'insert' ELSE 'update' END,
396
+ CASE WHEN l_is_insert THEN NULL ELSE l_existing_record END,
397
+ l_result,
398
+ p_user_id
399
+ );
400
+
401
+ -- Create event for the operation (INSERT or UPDATE)
402
+ INSERT INTO dzql.events (
403
+ table_name,
404
+ op,
405
+ pk,
406
+ before,
407
+ after,
408
+ user_id,
409
+ notify_users
410
+ ) VALUES (
411
+ p_entity,
412
+ CASE WHEN l_is_insert THEN 'insert' ELSE 'update' END,
413
+ (
414
+ SELECT jsonb_object_agg(col, l_result ->> col)
415
+ FROM unnest(l_pk_cols) AS col
416
+ ),
417
+ CASE WHEN NOT l_is_insert THEN l_existing_record ELSE NULL END,
418
+ l_result,
419
+ p_user_id,
420
+ dzql.resolve_notification_paths(p_entity, l_result)
421
+ );
422
+
423
+ -- Add graph rules execution summary to result if rules were executed
424
+ IF l_graph_rules_result IS NOT NULL AND l_graph_rules_result != '{}' THEN
425
+ l_result := l_result || jsonb_build_object('_graph_rules', l_graph_rules_result);
426
+ END IF;
427
+
428
+ RETURN l_result;
429
+ END $$;
430
+
431
+ -- === Generic DELETE Operation ===
432
+ -- Generic DELETE with cascading support and graph rules
433
+ CREATE OR REPLACE FUNCTION dzql.generic_delete(
434
+ p_entity text,
435
+ p_args jsonb,
436
+ p_user_id int
437
+ ) RETURNS jsonb
438
+ LANGUAGE plpgsql
439
+ SECURITY INVOKER
440
+ AS $$
441
+ DECLARE
442
+ l_entity_config record;
443
+ l_pk_cols text[];
444
+ l_pk_where text;
445
+ l_pk_where_clauses text[] := array[]::text[];
446
+ l_record jsonb;
447
+ l_graph_rules_result jsonb;
448
+ i int;
449
+ l_pk_provided boolean := true;
450
+ BEGIN
451
+ -- Get entity configuration
452
+ SELECT * INTO l_entity_config FROM dzql.entities WHERE table_name = p_entity;
453
+
454
+ IF l_entity_config IS NULL THEN
455
+ RAISE EXCEPTION 'DZQL: entity % not configured', p_entity;
456
+ END IF;
457
+
458
+ -- Get primary key columns
459
+ SELECT array_agg(a.attname ORDER BY a.attnum)
460
+ INTO l_pk_cols
461
+ FROM pg_index i
462
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
463
+ WHERE i.indrelid = p_entity::regclass AND i.indisprimary;
464
+
465
+ IF l_pk_cols IS NULL THEN
466
+ RAISE EXCEPTION 'DZQL: entity % has no primary key', p_entity;
467
+ END IF;
468
+
469
+ -- Build composite WHERE clause from provided PK values
470
+ FOR i IN 1..array_length(l_pk_cols, 1) LOOP
471
+ DECLARE
472
+ l_pk_value text := p_args ->> l_pk_cols[i];
473
+ BEGIN
474
+ IF l_pk_value IS NULL THEN
475
+ l_pk_provided := false;
476
+ EXIT;
477
+ END IF;
478
+ l_pk_where_clauses := l_pk_where_clauses ||
479
+ format('%I = %L', l_pk_cols[i], l_pk_value);
480
+ END;
481
+ END LOOP;
482
+
483
+ IF NOT l_pk_provided THEN
484
+ RAISE EXCEPTION 'DZQL: no primary key provided for entity %', p_entity;
485
+ END IF;
486
+
487
+ l_pk_where := array_to_string(l_pk_where_clauses, ' AND ');
488
+
489
+ -- Get existing record for permission check and graph rules
490
+ EXECUTE format('SELECT to_jsonb(t.*) FROM %I t WHERE %s', p_entity, l_pk_where)
491
+ INTO l_record;
492
+
493
+ IF l_record IS NULL THEN
494
+ RAISE EXCEPTION 'DZQL: record not found in %', p_entity;
495
+ END IF;
496
+
497
+ -- Check delete permission on existing record
498
+ IF NOT dzql.check_permission(p_user_id, 'delete', p_entity, l_record) THEN
499
+ RAISE EXCEPTION 'Permission denied: delete on %', p_entity;
500
+ END IF;
501
+
502
+ -- Execute graph rules for delete
503
+ l_graph_rules_result := dzql.execute_graph_rules(
504
+ p_entity,
505
+ 'delete',
506
+ l_record,
507
+ NULL,
508
+ p_user_id
509
+ );
510
+
511
+ -- Perform the actual delete using composite WHERE clause
512
+ IF l_entity_config.soft_delete THEN
513
+ EXECUTE format('UPDATE %I SET deleted_at = now() WHERE %s', p_entity, l_pk_where);
514
+ ELSE
515
+ EXECUTE format('DELETE FROM %I WHERE %s', p_entity, l_pk_where);
516
+ END IF;
517
+
518
+
519
+
520
+
521
+ -- Create event for the delete operation
522
+ INSERT INTO dzql.events (
523
+ table_name,
524
+ op,
525
+ pk,
526
+ before,
527
+ after,
528
+ user_id,
529
+ notify_users
530
+ ) VALUES (
531
+ p_entity,
532
+ 'delete',
533
+ (
534
+ SELECT jsonb_object_agg(col, l_record ->> col)
535
+ FROM unnest(l_pk_cols) AS col
536
+ ),
537
+ l_record,
538
+ NULL,
539
+ p_user_id,
540
+ dzql.resolve_notification_paths(p_entity, l_record)
541
+ );
542
+
543
+ -- Add graph rules execution summary to result if rules were executed
544
+ IF l_graph_rules_result IS NOT NULL AND l_graph_rules_result != '{}' THEN
545
+ l_record := l_record || jsonb_build_object('graph_rules', l_graph_rules_result);
546
+ END IF;
547
+
548
+ RETURN l_record;
549
+ END $$;
550
+
551
+ -- === Generic LOOKUP Operation ===
552
+ -- Generic LOOKUP for autocomplete/dropdown data
553
+ CREATE OR REPLACE FUNCTION dzql.generic_lookup(
554
+ p_entity text,
555
+ p_args jsonb,
556
+ p_user_id int
557
+ ) RETURNS jsonb
558
+ LANGUAGE plpgsql
559
+ SECURITY INVOKER
560
+ AS $$
561
+ DECLARE
562
+ l_entity_config record;
563
+ l_filter text;
564
+ l_label_field text;
565
+ l_where_clause text;
566
+ l_temporal_filter text;
567
+ l_on_date timestamptz;
568
+ l_sql_stmt text;
569
+ l_result jsonb;
570
+ l_pk_cols text[];
571
+ l_pk_value_expr text;
572
+ l_is_compound_key boolean;
573
+ l_fk_includes jsonb;
574
+ l_key text;
575
+ l_value text;
576
+ l_fk_result jsonb;
577
+ l_record jsonb;
578
+ l_processed_data jsonb[] := '{}';
579
+ l_label_obj jsonb;
580
+ i int;
581
+ BEGIN
582
+ -- Get entity configuration
583
+ SELECT * INTO l_entity_config FROM dzql.entities WHERE table_name = p_entity;
584
+
585
+ IF l_entity_config IS NULL THEN
586
+ RAISE EXCEPTION 'DZQL: entity % not configured', p_entity;
587
+ END IF;
588
+
589
+ -- Get primary key columns
590
+ SELECT array_agg(a.attname ORDER BY a.attnum)
591
+ INTO l_pk_cols
592
+ FROM pg_index i
593
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
594
+ WHERE i.indrelid = p_entity::regclass AND i.indisprimary;
595
+
596
+ IF l_pk_cols IS NULL THEN
597
+ RAISE EXCEPTION 'DZQL: entity % has no primary key', p_entity;
598
+ END IF;
599
+
600
+ -- Check if this is a compound key
601
+ l_is_compound_key := array_length(l_pk_cols, 1) > 1;
602
+
603
+ -- Build primary key value expression
604
+ IF l_is_compound_key THEN
605
+ -- Composite primary key - concatenate values
606
+ l_pk_value_expr := format('CONCAT(%s)', array_to_string(array(SELECT format('%I', col) FROM unnest(l_pk_cols) AS col), ', ''-'', '));
607
+ ELSE
608
+ -- Single primary key
609
+ l_pk_value_expr := l_pk_cols[1];
610
+ END IF;
611
+
612
+ -- Extract parameters
613
+ l_filter := p_args ->> 'p_filter';
614
+ l_on_date := (p_args ->> 'on_date')::timestamptz;
615
+ l_label_field := l_entity_config.label_field;
616
+
617
+ -- Build WHERE clause for filter
618
+ IF l_filter IS NOT NULL AND l_filter != '' THEN
619
+ l_where_clause := format('%I ILIKE %L', l_label_field, '%' || l_filter || '%');
620
+ ELSE
621
+ l_where_clause := '1=1';
622
+ END IF;
623
+
624
+ -- Add temporal filter
625
+ l_temporal_filter := dzql.apply_temporal_filter(
626
+ p_entity::regclass,
627
+ l_entity_config.temporal_fields,
628
+ l_on_date
629
+ );
630
+
631
+ l_where_clause := l_where_clause || l_temporal_filter;
632
+
633
+ IF l_is_compound_key AND l_entity_config.fk_includes IS NOT NULL AND l_entity_config.fk_includes != '{}' THEN
634
+ -- For compound keys with FK includes, build full dereferenced labels
635
+ l_sql_stmt := format(
636
+ 'SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY %I), ''[]''::jsonb)
637
+ FROM %I t WHERE %s AND dzql.check_permission(%L, ''view'', %L, to_jsonb(t.*)) LIMIT 50',
638
+ l_label_field, p_entity, l_where_clause, p_user_id, p_entity
639
+ );
640
+
641
+ EXECUTE l_sql_stmt INTO l_result;
642
+
643
+ -- Process FK dereferencing for each record
644
+ l_fk_includes := l_entity_config.fk_includes;
645
+ IF l_result IS NOT NULL AND jsonb_array_length(l_result) > 0 THEN
646
+ -- Process each record in the result array
647
+ FOR i IN 0..jsonb_array_length(l_result) - 1 LOOP
648
+ l_record := l_result->i;
649
+ l_label_obj := l_record; -- Start with base record
650
+
651
+ -- Dereference foreign keys for this record, getting only label fields
652
+ FOR l_key, l_value IN SELECT key, value FROM jsonb_each_text(l_fk_includes)
653
+ LOOP
654
+ -- Handle direct FK only for compound keys (resolve_direct_fk returns full record)
655
+ l_fk_result := dzql.resolve_direct_fk(l_record, l_key, l_value, l_on_date);
656
+
657
+ -- Extract just the label_field from the target entity
658
+ IF l_fk_result IS NOT NULL THEN
659
+ -- Get the target entity's label_field
660
+ SELECT label_field INTO l_label_field FROM dzql.entities WHERE table_name = l_value;
661
+ IF l_label_field IS NOT NULL THEN
662
+ l_label_obj := l_label_obj || jsonb_build_object(l_key, l_fk_result ->> l_label_field);
663
+ END IF;
664
+ END IF;
665
+ END LOOP;
666
+
667
+ -- Build the lookup entry with dereferenced label
668
+ l_processed_data := l_processed_data || jsonb_build_object(
669
+ 'label', l_label_obj,
670
+ 'value', (
671
+ SELECT string_agg(l_record ->> col, '-' ORDER BY ordinality)
672
+ FROM unnest(l_pk_cols) WITH ORDINALITY AS col
673
+ )
674
+ );
675
+ END LOOP;
676
+
677
+ -- Convert processed data to final result
678
+ l_result := to_jsonb(l_processed_data);
679
+ ELSE
680
+ l_result := '[]'::jsonb;
681
+ END IF;
682
+ ELSE
683
+ -- For simple entities, use the original approach
684
+ l_sql_stmt := format(
685
+ 'SELECT COALESCE(jsonb_agg(jsonb_build_object(''label'', %I, ''value'', %s) ORDER BY %I), ''[]''::jsonb)
686
+ FROM %I t WHERE %s AND dzql.check_permission(%L, ''view'', %L, to_jsonb(t.*)) LIMIT 50',
687
+ l_label_field, l_pk_value_expr, l_label_field, p_entity, l_where_clause, p_user_id, p_entity
688
+ );
689
+
690
+ EXECUTE l_sql_stmt INTO l_result;
691
+ END IF;
692
+
693
+ RETURN COALESCE(l_result, '[]'::jsonb);
694
+ EXCEPTION
695
+ WHEN OTHERS THEN
696
+ RAISE EXCEPTION 'DZQL: lookup error for entity %: %', p_entity, SQLERRM;
697
+ END $$;
698
+
699
+ -- === Generic Dispatcher Function ===
700
+ -- Routes operations to their specific implementation functions
701
+ CREATE OR REPLACE FUNCTION dzql.generic_exec(
702
+ p_operation text,
703
+ p_entity text,
704
+ p_args jsonb,
705
+ p_user_id int
706
+ ) RETURNS jsonb
707
+ LANGUAGE plpgsql
708
+ SECURITY INVOKER
709
+ AS $$
710
+ BEGIN
711
+ CASE lower(p_operation)
712
+ WHEN 'get' THEN
713
+ RETURN dzql.generic_get(p_entity, p_args, p_user_id);
714
+ WHEN 'save' THEN
715
+ RETURN dzql.generic_save(p_entity, p_args, p_user_id);
716
+ WHEN 'delete' THEN
717
+ RETURN dzql.generic_delete(p_entity, p_args, p_user_id);
718
+ WHEN 'lookup' THEN
719
+ RETURN dzql.generic_lookup(p_entity, p_args, p_user_id);
720
+ WHEN 'search' THEN
721
+ RETURN dzql.generic_search(p_entity, p_args, p_user_id);
722
+ ELSE
723
+ RAISE EXCEPTION 'DZQL: unknown operation %', p_operation;
724
+ END CASE;
725
+ END $$;