dzql 0.5.33 → 0.6.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.
Files changed (142) hide show
  1. package/.env.sample +28 -0
  2. package/compose.yml +28 -0
  3. package/dist/client/index.ts +1 -0
  4. package/dist/client/stores/useMyProfileStore.ts +114 -0
  5. package/dist/client/stores/useOrgDashboardStore.ts +131 -0
  6. package/dist/client/stores/useVenueDetailStore.ts +117 -0
  7. package/dist/client/ws.ts +716 -0
  8. package/dist/db/migrations/000_core.sql +92 -0
  9. package/dist/db/migrations/20251229T212912022Z_schema.sql +3020 -0
  10. package/dist/db/migrations/20251229T212912022Z_subscribables.sql +371 -0
  11. package/dist/runtime/manifest.json +1562 -0
  12. package/docs/README.md +309 -36
  13. package/docs/feature-requests/applyPatch-bug-report.md +85 -0
  14. package/docs/feature-requests/connection-ready-profile.md +57 -0
  15. package/docs/feature-requests/hidden-bug-report.md +111 -0
  16. package/docs/feature-requests/hidden-fields-subscribables.md +34 -0
  17. package/docs/feature-requests/subscribable-param-key-bug.md +38 -0
  18. package/docs/feature-requests/todo.md +146 -0
  19. package/docs/for_ai.md +653 -0
  20. package/docs/project-setup.md +456 -0
  21. package/examples/blog.ts +50 -0
  22. package/examples/invalid.ts +18 -0
  23. package/examples/venues.js +485 -0
  24. package/package.json +23 -60
  25. package/src/cli/codegen/client.ts +99 -0
  26. package/src/cli/codegen/manifest.ts +95 -0
  27. package/src/cli/codegen/pinia.ts +174 -0
  28. package/src/cli/codegen/realtime.ts +58 -0
  29. package/src/cli/codegen/sql.ts +698 -0
  30. package/src/cli/codegen/subscribable_sql.ts +547 -0
  31. package/src/cli/codegen/subscribable_store.ts +184 -0
  32. package/src/cli/codegen/types.ts +142 -0
  33. package/src/cli/compiler/analyzer.ts +52 -0
  34. package/src/cli/compiler/graph_rules.ts +251 -0
  35. package/src/cli/compiler/ir.ts +233 -0
  36. package/src/cli/compiler/loader.ts +132 -0
  37. package/src/cli/compiler/permissions.ts +227 -0
  38. package/src/cli/index.ts +166 -0
  39. package/src/client/index.ts +1 -0
  40. package/src/client/ws.ts +286 -0
  41. package/src/runtime/auth.ts +39 -0
  42. package/src/runtime/db.ts +33 -0
  43. package/src/runtime/errors.ts +51 -0
  44. package/src/runtime/index.ts +98 -0
  45. package/src/runtime/js_functions.ts +63 -0
  46. package/src/runtime/manifest_loader.ts +29 -0
  47. package/src/runtime/namespace.ts +483 -0
  48. package/src/runtime/server.ts +87 -0
  49. package/src/runtime/ws.ts +197 -0
  50. package/src/shared/ir.ts +197 -0
  51. package/tests/client.test.ts +38 -0
  52. package/tests/codegen.test.ts +71 -0
  53. package/tests/compiler.test.ts +45 -0
  54. package/tests/graph_rules.test.ts +173 -0
  55. package/tests/integration/db.test.ts +174 -0
  56. package/tests/integration/e2e.test.ts +65 -0
  57. package/tests/integration/features.test.ts +922 -0
  58. package/tests/integration/full_stack.test.ts +262 -0
  59. package/tests/integration/setup.ts +45 -0
  60. package/tests/ir.test.ts +32 -0
  61. package/tests/namespace.test.ts +395 -0
  62. package/tests/permissions.test.ts +55 -0
  63. package/tests/pinia.test.ts +48 -0
  64. package/tests/realtime.test.ts +22 -0
  65. package/tests/runtime.test.ts +80 -0
  66. package/tests/subscribable_gen.test.ts +72 -0
  67. package/tests/subscribable_reactivity.test.ts +258 -0
  68. package/tests/venues_gen.test.ts +25 -0
  69. package/tsconfig.json +20 -0
  70. package/tsconfig.tsbuildinfo +1 -0
  71. package/README.md +0 -90
  72. package/bin/cli.js +0 -727
  73. package/docs/compiler/ADVANCED_FILTERS.md +0 -183
  74. package/docs/compiler/CODING_STANDARDS.md +0 -415
  75. package/docs/compiler/COMPARISON.md +0 -673
  76. package/docs/compiler/QUICKSTART.md +0 -326
  77. package/docs/compiler/README.md +0 -134
  78. package/docs/examples/README.md +0 -38
  79. package/docs/examples/blog.sql +0 -160
  80. package/docs/examples/venue-detail-simple.sql +0 -8
  81. package/docs/examples/venue-detail-subscribable.sql +0 -45
  82. package/docs/for-ai/claude-guide.md +0 -1210
  83. package/docs/getting-started/quickstart.md +0 -125
  84. package/docs/getting-started/subscriptions-quick-start.md +0 -203
  85. package/docs/getting-started/tutorial.md +0 -1104
  86. package/docs/guides/atomic-updates.md +0 -299
  87. package/docs/guides/client-stores.md +0 -730
  88. package/docs/guides/composite-primary-keys.md +0 -158
  89. package/docs/guides/custom-functions.md +0 -362
  90. package/docs/guides/drop-semantics.md +0 -554
  91. package/docs/guides/field-defaults.md +0 -240
  92. package/docs/guides/interpreter-vs-compiler.md +0 -237
  93. package/docs/guides/many-to-many.md +0 -929
  94. package/docs/guides/subscriptions.md +0 -537
  95. package/docs/reference/api.md +0 -1373
  96. package/docs/reference/client.md +0 -224
  97. package/src/client/stores/index.js +0 -8
  98. package/src/client/stores/useAppStore.js +0 -285
  99. package/src/client/stores/useWsStore.js +0 -289
  100. package/src/client/ws.js +0 -762
  101. package/src/compiler/cli/compile-example.js +0 -33
  102. package/src/compiler/cli/compile-subscribable.js +0 -43
  103. package/src/compiler/cli/debug-compile.js +0 -44
  104. package/src/compiler/cli/debug-parse.js +0 -26
  105. package/src/compiler/cli/debug-path-parser.js +0 -18
  106. package/src/compiler/cli/debug-subscribable-parser.js +0 -21
  107. package/src/compiler/cli/index.js +0 -174
  108. package/src/compiler/codegen/auth-codegen.js +0 -153
  109. package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
  110. package/src/compiler/codegen/graph-rules-codegen.js +0 -450
  111. package/src/compiler/codegen/notification-codegen.js +0 -232
  112. package/src/compiler/codegen/operation-codegen.js +0 -1382
  113. package/src/compiler/codegen/permission-codegen.js +0 -318
  114. package/src/compiler/codegen/subscribable-codegen.js +0 -827
  115. package/src/compiler/compiler.js +0 -371
  116. package/src/compiler/index.js +0 -11
  117. package/src/compiler/parser/entity-parser.js +0 -440
  118. package/src/compiler/parser/path-parser.js +0 -290
  119. package/src/compiler/parser/subscribable-parser.js +0 -244
  120. package/src/database/dzql-core.sql +0 -161
  121. package/src/database/migrations/001_schema.sql +0 -60
  122. package/src/database/migrations/002_functions.sql +0 -890
  123. package/src/database/migrations/003_operations.sql +0 -1135
  124. package/src/database/migrations/004_search.sql +0 -581
  125. package/src/database/migrations/005_entities.sql +0 -730
  126. package/src/database/migrations/006_auth.sql +0 -94
  127. package/src/database/migrations/007_events.sql +0 -133
  128. package/src/database/migrations/008_hello.sql +0 -18
  129. package/src/database/migrations/008a_meta.sql +0 -172
  130. package/src/database/migrations/009_subscriptions.sql +0 -240
  131. package/src/database/migrations/010_atomic_updates.sql +0 -157
  132. package/src/database/migrations/010_fix_m2m_events.sql +0 -94
  133. package/src/index.js +0 -40
  134. package/src/server/api.js +0 -9
  135. package/src/server/db.js +0 -442
  136. package/src/server/index.js +0 -317
  137. package/src/server/logger.js +0 -259
  138. package/src/server/mcp.js +0 -594
  139. package/src/server/meta-route.js +0 -251
  140. package/src/server/namespace.js +0 -292
  141. package/src/server/subscriptions.js +0 -351
  142. package/src/server/ws.js +0 -573
@@ -1,1135 +0,0 @@
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
- -- 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
-
282
- RETURN l_result;
283
- END $$;
284
-
285
- -- === Generic SAVE Operation ===
286
- -- Generic SAVE with upsert capability and graph rules
287
- CREATE OR REPLACE FUNCTION dzql.generic_save(
288
- p_entity text,
289
- p_args jsonb,
290
- p_user_id int
291
- ) RETURNS jsonb
292
- LANGUAGE plpgsql
293
- SECURITY INVOKER
294
- AS $$
295
- DECLARE
296
- l_entity_config record;
297
- l_pk_cols text[];
298
- l_cols text[];
299
- l_vals text[];
300
- l_set_clauses text[];
301
- l_col_name text;
302
- l_sql_stmt text;
303
- l_existing_record jsonb;
304
- l_merged_data jsonb;
305
- l_result jsonb;
306
- l_record_id text;
307
- l_args_json jsonb;
308
- l_operation text;
309
- l_permission_record jsonb;
310
- l_graph_rules_result jsonb;
311
- l_is_insert boolean := false;
312
- l_pk_where text;
313
- l_pk_where_clauses text[] := array[]::text[];
314
- i int;
315
- BEGIN
316
- -- Ensure p_args is proper JSONB
317
- l_args_json := p_args::jsonb;
318
-
319
- -- Get entity configuration
320
- SELECT * INTO l_entity_config FROM dzql.entities WHERE table_name = p_entity;
321
-
322
- IF l_entity_config IS NULL THEN
323
- RAISE EXCEPTION 'DZQL: entity % not configured', p_entity;
324
- END IF;
325
-
326
- -- Get primary key columns
327
- SELECT array_agg(a.attname ORDER BY a.attnum)
328
- INTO l_pk_cols
329
- FROM pg_index i
330
- JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
331
- WHERE i.indrelid = p_entity::regclass AND i.indisprimary;
332
-
333
- IF l_pk_cols IS NULL THEN
334
- RAISE EXCEPTION 'DZQL: entity % has no primary key', p_entity;
335
- END IF;
336
-
337
- -- Check if this is an update (has all PKs) or insert (missing any PK)
338
- -- Check if any PK column is missing
339
- FOR i IN 1..array_length(l_pk_cols, 1) LOOP
340
- IF l_args_json ->> l_pk_cols[i] IS NULL THEN
341
- l_is_insert := true;
342
- EXIT;
343
- END IF;
344
- END LOOP;
345
-
346
- -- If all PK columns provided, check if record exists
347
- IF NOT l_is_insert THEN
348
- -- Build composite WHERE clause for existing record check
349
- FOR i IN 1..array_length(l_pk_cols, 1) LOOP
350
- l_pk_where_clauses := l_pk_where_clauses ||
351
- format('%I = %L', l_pk_cols[i], l_args_json ->> l_pk_cols[i]);
352
- END LOOP;
353
- l_pk_where := array_to_string(l_pk_where_clauses, ' AND ');
354
-
355
- -- Get existing record using composite WHERE clause
356
- l_sql_stmt := format('SELECT to_jsonb(t.*) FROM %I t WHERE %s', p_entity, l_pk_where);
357
- EXECUTE l_sql_stmt INTO l_existing_record;
358
-
359
- IF l_existing_record IS NULL THEN
360
- -- Record doesn't exist. For composite keys, treat as INSERT.
361
- -- For single-column PKs, this is an error (user provided non-existent ID).
362
- IF array_length(l_pk_cols, 1) > 1 THEN
363
- -- Composite key: treat as INSERT
364
- l_is_insert := true;
365
- ELSE
366
- -- Single PK: this is an error
367
- RAISE EXCEPTION 'DZQL: record with %=% not found in %',
368
- l_pk_cols[1], l_args_json ->> l_pk_cols[1], p_entity;
369
- END IF;
370
- END IF;
371
- END IF;
372
-
373
- -- Expand M2M relationships in existing record (for UPDATE events)
374
- IF NOT l_is_insert AND l_existing_record IS NOT NULL AND l_entity_config.many_to_many IS NOT NULL AND l_entity_config.many_to_many != '{}'::jsonb THEN
375
- DECLARE
376
- l_m2m_key text;
377
- l_m2m_config jsonb;
378
- l_id_field text;
379
- l_junction_table text;
380
- l_local_key text;
381
- l_foreign_key text;
382
- l_target_entity text;
383
- l_expand boolean;
384
- l_record_id text;
385
- l_id_array jsonb;
386
- l_expanded_objects jsonb;
387
- BEGIN
388
- -- Get the primary key value from the existing record
389
- l_record_id := l_existing_record->>l_pk_cols[1]; -- Assume single PK for now
390
-
391
- FOR l_m2m_key IN SELECT jsonb_object_keys(l_entity_config.many_to_many)
392
- LOOP
393
- l_m2m_config := l_entity_config.many_to_many->l_m2m_key;
394
- l_id_field := l_m2m_config->>'id_field';
395
- l_junction_table := l_m2m_config->>'junction_table';
396
- l_local_key := l_m2m_config->>'local_key';
397
- l_foreign_key := l_m2m_config->>'foreign_key';
398
- l_target_entity := l_m2m_config->>'target_entity';
399
- l_expand := COALESCE((l_m2m_config->>'expand')::boolean, false);
400
-
401
- -- Always include array of IDs
402
- EXECUTE format('
403
- SELECT COALESCE(jsonb_agg(%I), ''[]''::jsonb)
404
- FROM %I
405
- WHERE %I = $1::int
406
- ', l_foreign_key, l_junction_table, l_local_key)
407
- INTO l_id_array
408
- USING l_record_id;
409
-
410
- l_existing_record := l_existing_record || jsonb_build_object(l_id_field, l_id_array);
411
-
412
- -- Conditionally include expanded objects if expand: true
413
- IF l_expand THEN
414
- EXECUTE format('
415
- SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
416
- FROM %I jt
417
- JOIN %I t ON t.id = jt.%I
418
- WHERE jt.%I = $1::int
419
- ', l_junction_table, l_target_entity, l_foreign_key, l_local_key)
420
- INTO l_expanded_objects
421
- USING l_record_id;
422
-
423
- l_existing_record := l_existing_record || jsonb_build_object(l_m2m_key, l_expanded_objects);
424
- END IF;
425
- END LOOP;
426
- END;
427
- END IF;
428
-
429
- IF NOT l_is_insert THEN
430
- -- UPDATE: Merge with existing record
431
-
432
- -- Check update permission on existing record
433
- l_operation := 'update';
434
- l_permission_record := l_existing_record;
435
- IF NOT dzql.check_permission(p_user_id, l_operation, p_entity, l_permission_record) THEN
436
- RAISE EXCEPTION 'Permission denied: % on %', l_operation, p_entity;
437
- END IF;
438
-
439
- -- Merge existing data with new data (new data takes precedence)
440
- l_merged_data := l_existing_record || l_args_json;
441
-
442
- -- Build SET clauses for UPDATE
443
- l_set_clauses := array[]::text[];
444
- FOR l_col_name IN SELECT jsonb_object_keys(l_merged_data)
445
- LOOP
446
- -- Don't update any primary key columns
447
- IF NOT (l_col_name = ANY(l_pk_cols)) THEN
448
- -- Skip M2M ID fields and expanded fields (they're not real table columns)
449
- IF l_entity_config.many_to_many IS NOT NULL THEN
450
- DECLARE
451
- l_m2m_id_field text;
452
- l_m2m_key text;
453
- l_skip boolean := false;
454
- BEGIN
455
- -- Skip M2M ID fields (e.g., tag_ids)
456
- FOR l_m2m_id_field IN
457
- SELECT value->>'id_field'
458
- FROM jsonb_each(l_entity_config.many_to_many)
459
- LOOP
460
- IF l_col_name = l_m2m_id_field THEN
461
- l_skip := true;
462
- EXIT;
463
- END IF;
464
- END LOOP;
465
-
466
- -- Skip M2M expanded fields (e.g., tags)
467
- IF NOT l_skip THEN
468
- FOR l_m2m_key IN
469
- SELECT key
470
- FROM jsonb_each(l_entity_config.many_to_many)
471
- LOOP
472
- IF l_col_name = l_m2m_key THEN
473
- l_skip := true;
474
- EXIT;
475
- END IF;
476
- END LOOP;
477
- END IF;
478
-
479
- IF NOT l_skip THEN
480
- l_set_clauses := l_set_clauses || format('%I = %L', l_col_name, l_merged_data ->> l_col_name);
481
- END IF;
482
- END;
483
- ELSE
484
- l_set_clauses := l_set_clauses || format('%I = %L', l_col_name, l_merged_data ->> l_col_name);
485
- END IF;
486
- END IF;
487
- END LOOP;
488
-
489
- -- Execute UPDATE using composite WHERE clause
490
- l_sql_stmt := format('UPDATE %I SET %s WHERE %s RETURNING to_jsonb(%I.*)',
491
- p_entity,
492
- array_to_string(l_set_clauses, ', '),
493
- l_pk_where,
494
- p_entity);
495
- EXECUTE l_sql_stmt INTO l_result;
496
-
497
-
498
- ELSE
499
- -- INSERT: Use provided values, let database handle defaults
500
-
501
- -- Auto-inject user_id if table has a user_id column and it's not provided
502
- IF EXISTS (
503
- SELECT 1 FROM information_schema.columns
504
- WHERE table_schema = 'public'
505
- AND table_name = p_entity
506
- AND column_name = 'user_id'
507
- ) AND (l_args_json->>'user_id' IS NULL) THEN
508
- l_args_json := l_args_json || jsonb_build_object('user_id', p_user_id);
509
- END IF;
510
-
511
- -- Apply field defaults for INSERT (if configured)
512
- IF l_entity_config.field_defaults IS NOT NULL AND l_entity_config.field_defaults != '{}' THEN
513
- FOR l_col_name IN SELECT jsonb_object_keys(l_entity_config.field_defaults)
514
- LOOP
515
- -- Only apply default if field is not already provided
516
- IF NOT (l_args_json ? l_col_name) THEN
517
- DECLARE
518
- l_default_value text;
519
- l_resolved_value text;
520
- BEGIN
521
- l_default_value := l_entity_config.field_defaults->>l_col_name;
522
-
523
- -- Resolve variable if it starts with @
524
- IF l_default_value LIKE '@%' THEN
525
- l_resolved_value := dzql.resolve_graph_variable(
526
- l_default_value,
527
- NULL, -- no before record for INSERT
528
- l_args_json, -- current data being inserted
529
- p_user_id
530
- );
531
- ELSE
532
- -- Use literal value
533
- l_resolved_value := l_default_value;
534
- END IF;
535
-
536
- -- Add to l_args_json
537
- l_args_json := l_args_json || jsonb_build_object(l_col_name, l_resolved_value);
538
- END;
539
- END IF;
540
- END LOOP;
541
- END IF;
542
-
543
- -- Check create permission on new values
544
- l_operation := 'create';
545
- l_permission_record := l_args_json;
546
- IF NOT dzql.check_permission(p_user_id, l_operation, p_entity, l_permission_record) THEN
547
- RAISE EXCEPTION 'Permission denied: % on %', l_operation, p_entity;
548
- END IF;
549
-
550
- l_cols := array[]::text[];
551
- l_vals := array[]::text[];
552
-
553
- FOR l_col_name IN SELECT jsonb_object_keys(l_args_json)
554
- LOOP
555
- -- Skip M2M ID fields (they're not real table columns)
556
- IF l_entity_config.many_to_many IS NOT NULL THEN
557
- DECLARE
558
- l_m2m_id_field text;
559
- l_skip boolean := false;
560
- BEGIN
561
- FOR l_m2m_id_field IN
562
- SELECT value->>'id_field'
563
- FROM jsonb_each(l_entity_config.many_to_many)
564
- LOOP
565
- IF l_col_name = l_m2m_id_field THEN
566
- l_skip := true;
567
- EXIT;
568
- END IF;
569
- END LOOP;
570
-
571
- IF l_skip THEN
572
- CONTINUE;
573
- END IF;
574
- END;
575
- END IF;
576
-
577
- IF l_args_json ->> l_col_name IS NOT NULL AND l_args_json ->> l_col_name != '' THEN
578
- l_cols := l_cols || quote_ident(l_col_name);
579
- l_vals := l_vals || quote_literal(l_args_json ->> l_col_name);
580
- END IF;
581
- END LOOP;
582
-
583
- IF array_length(l_cols, 1) = 0 THEN
584
- RAISE EXCEPTION 'DZQL: no valid columns provided for insert into %', p_entity;
585
- END IF;
586
-
587
- -- Execute INSERT
588
- l_sql_stmt := format('INSERT INTO %I (%s) VALUES (%s) RETURNING to_jsonb(%I.*)',
589
- p_entity,
590
- array_to_string(l_cols, ', '),
591
- array_to_string(l_vals, ', '),
592
- p_entity);
593
- EXECUTE l_sql_stmt INTO l_result;
594
-
595
- END IF;
596
-
597
- -- Sync many-to-many relationships (if configured)
598
- IF l_entity_config.many_to_many IS NOT NULL AND l_entity_config.many_to_many != '{}'::jsonb THEN
599
- DECLARE
600
- l_m2m_key text;
601
- l_m2m_config jsonb;
602
- l_id_field text;
603
- l_junction_table text;
604
- l_local_key text;
605
- l_foreign_key text;
606
- l_record_id text;
607
- BEGIN
608
- -- Get the primary key value from the result
609
- l_record_id := l_result->>l_pk_cols[1]; -- Assume single PK for now
610
-
611
- FOR l_m2m_key IN SELECT jsonb_object_keys(l_entity_config.many_to_many)
612
- LOOP
613
- l_m2m_config := l_entity_config.many_to_many->l_m2m_key;
614
- l_id_field := l_m2m_config->>'id_field';
615
-
616
- -- Only sync if the ID field is present in the data
617
- IF l_args_json ? l_id_field THEN
618
- l_junction_table := l_m2m_config->>'junction_table';
619
- l_local_key := l_m2m_config->>'local_key';
620
- l_foreign_key := l_m2m_config->>'foreign_key';
621
-
622
- -- Delete relationships not in new list
623
- EXECUTE format('
624
- DELETE FROM %I
625
- WHERE %I = $1::int
626
- AND %I <> ALL($2::int[])
627
- ', l_junction_table, l_local_key, l_foreign_key)
628
- USING l_record_id,
629
- ARRAY(SELECT jsonb_array_elements_text(l_args_json->l_id_field))::int[];
630
-
631
- -- Insert new relationships (ignore conflicts)
632
- EXECUTE format('
633
- INSERT INTO %I (%I, %I)
634
- SELECT $1::int, value::int
635
- FROM jsonb_array_elements_text($2)
636
- ON CONFLICT DO NOTHING
637
- ', l_junction_table, l_local_key, l_foreign_key)
638
- USING l_record_id, l_args_json->l_id_field;
639
- END IF;
640
- END LOOP;
641
- END;
642
- END IF;
643
-
644
- -- Expand many-to-many relationships in result (after sync)
645
- IF l_entity_config.many_to_many IS NOT NULL AND l_entity_config.many_to_many != '{}'::jsonb THEN
646
- DECLARE
647
- l_m2m_key text;
648
- l_m2m_config jsonb;
649
- l_id_field text;
650
- l_junction_table text;
651
- l_local_key text;
652
- l_foreign_key text;
653
- l_target_entity text;
654
- l_expand boolean;
655
- l_record_id text;
656
- l_id_array jsonb;
657
- l_expanded_objects jsonb;
658
- BEGIN
659
- -- Get the primary key value from the result
660
- l_record_id := l_result->>l_pk_cols[1]; -- Assume single PK for now
661
-
662
- FOR l_m2m_key IN SELECT jsonb_object_keys(l_entity_config.many_to_many)
663
- LOOP
664
- l_m2m_config := l_entity_config.many_to_many->l_m2m_key;
665
- l_id_field := l_m2m_config->>'id_field';
666
- l_junction_table := l_m2m_config->>'junction_table';
667
- l_local_key := l_m2m_config->>'local_key';
668
- l_foreign_key := l_m2m_config->>'foreign_key';
669
- l_target_entity := l_m2m_config->>'target_entity';
670
- l_expand := COALESCE((l_m2m_config->>'expand')::boolean, false);
671
-
672
- -- Always include array of IDs
673
- EXECUTE format('
674
- SELECT COALESCE(jsonb_agg(%I), ''[]''::jsonb)
675
- FROM %I
676
- WHERE %I = $1::int
677
- ', l_foreign_key, l_junction_table, l_local_key)
678
- INTO l_id_array
679
- USING l_record_id;
680
-
681
- l_result := l_result || jsonb_build_object(l_id_field, l_id_array);
682
-
683
- -- Conditionally include expanded objects if expand: true
684
- IF l_expand THEN
685
- EXECUTE format('
686
- SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
687
- FROM %I jt
688
- JOIN %I t ON t.id = jt.%I
689
- WHERE jt.%I = $1::int
690
- ', l_junction_table, l_target_entity, l_foreign_key, l_local_key)
691
- INTO l_expanded_objects
692
- USING l_record_id;
693
-
694
- l_result := l_result || jsonb_build_object(l_m2m_key, l_expanded_objects);
695
- END IF;
696
- END LOOP;
697
- END;
698
- END IF;
699
-
700
- -- Execute graph rules for the appropriate operation
701
- l_graph_rules_result := dzql.execute_graph_rules(
702
- p_entity,
703
- CASE WHEN l_is_insert THEN 'insert' ELSE 'update' END,
704
- CASE WHEN l_is_insert THEN NULL ELSE l_existing_record END,
705
- l_result,
706
- p_user_id
707
- );
708
-
709
- -- Create event for the operation (INSERT or UPDATE)
710
- INSERT INTO dzql.events (
711
- table_name,
712
- op,
713
- pk,
714
- data,
715
- user_id,
716
- notify_users
717
- ) VALUES (
718
- p_entity,
719
- CASE WHEN l_is_insert THEN 'insert' ELSE 'update' END,
720
- (
721
- SELECT jsonb_object_agg(col, l_result ->> col)
722
- FROM unnest(l_pk_cols) AS col
723
- ),
724
- l_result,
725
- p_user_id,
726
- dzql.resolve_notification_paths(p_entity, l_result)
727
- );
728
-
729
- -- Add graph rules execution summary to result if rules were executed
730
- IF l_graph_rules_result IS NOT NULL AND l_graph_rules_result != '{}' THEN
731
- l_result := l_result || jsonb_build_object('_graph_rules', l_graph_rules_result);
732
- END IF;
733
-
734
- RETURN l_result;
735
- END $$;
736
-
737
- -- === Generic DELETE Operation ===
738
- -- Generic DELETE with cascading support and graph rules
739
- CREATE OR REPLACE FUNCTION dzql.generic_delete(
740
- p_entity text,
741
- p_args jsonb,
742
- p_user_id int
743
- ) RETURNS jsonb
744
- LANGUAGE plpgsql
745
- SECURITY INVOKER
746
- AS $$
747
- DECLARE
748
- l_entity_config record;
749
- l_pk_cols text[];
750
- l_pk_where text;
751
- l_pk_where_clauses text[] := array[]::text[];
752
- l_record jsonb;
753
- l_graph_rules_result jsonb;
754
- i int;
755
- l_pk_provided boolean := true;
756
- BEGIN
757
- -- Get entity configuration
758
- SELECT * INTO l_entity_config FROM dzql.entities WHERE table_name = p_entity;
759
-
760
- IF l_entity_config IS NULL THEN
761
- RAISE EXCEPTION 'DZQL: entity % not configured', p_entity;
762
- END IF;
763
-
764
- -- Get primary key columns
765
- SELECT array_agg(a.attname ORDER BY a.attnum)
766
- INTO l_pk_cols
767
- FROM pg_index i
768
- JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
769
- WHERE i.indrelid = p_entity::regclass AND i.indisprimary;
770
-
771
- IF l_pk_cols IS NULL THEN
772
- RAISE EXCEPTION 'DZQL: entity % has no primary key', p_entity;
773
- END IF;
774
-
775
- -- Build composite WHERE clause from provided PK values
776
- FOR i IN 1..array_length(l_pk_cols, 1) LOOP
777
- DECLARE
778
- l_pk_value text := p_args ->> l_pk_cols[i];
779
- BEGIN
780
- IF l_pk_value IS NULL THEN
781
- l_pk_provided := false;
782
- EXIT;
783
- END IF;
784
- l_pk_where_clauses := l_pk_where_clauses ||
785
- format('%I = %L', l_pk_cols[i], l_pk_value);
786
- END;
787
- END LOOP;
788
-
789
- IF NOT l_pk_provided THEN
790
- RAISE EXCEPTION 'DZQL: no primary key provided for entity %', p_entity;
791
- END IF;
792
-
793
- l_pk_where := array_to_string(l_pk_where_clauses, ' AND ');
794
-
795
- -- Get existing record for permission check and graph rules
796
- EXECUTE format('SELECT to_jsonb(t.*) FROM %I t WHERE %s', p_entity, l_pk_where)
797
- INTO l_record;
798
-
799
- IF l_record IS NULL THEN
800
- RAISE EXCEPTION 'DZQL: record not found in %', p_entity;
801
- END IF;
802
-
803
- -- Check delete permission on existing record
804
- IF NOT dzql.check_permission(p_user_id, 'delete', p_entity, l_record) THEN
805
- RAISE EXCEPTION 'Permission denied: delete on %', p_entity;
806
- END IF;
807
-
808
- -- Apply CASCADE/SET NULL/RESTRICT rules from child entities
809
- DECLARE
810
- l_child_entity record;
811
- l_child_graph_rules jsonb;
812
- l_delete_rules jsonb;
813
- l_rule_name text;
814
- l_rule_action text;
815
- l_fk_field text;
816
- l_fk_key text;
817
- l_child_count int;
818
- BEGIN
819
- -- Find all entities that reference this entity
820
- FOR l_child_entity IN
821
- SELECT * FROM dzql.entities
822
- WHERE fk_includes IS NOT NULL
823
- AND fk_includes != '{}'
824
- LOOP
825
- -- Check if this child entity has an FK pointing to the entity being deleted
826
- FOR l_fk_key IN SELECT jsonb_object_keys(l_child_entity.fk_includes)
827
- LOOP
828
- IF l_child_entity.fk_includes->>l_fk_key = p_entity THEN
829
- -- This child entity references the parent being deleted
830
- l_child_graph_rules := l_child_entity.graph_rules;
831
-
832
- IF l_child_graph_rules IS NOT NULL AND l_child_graph_rules != '{}' THEN
833
- l_delete_rules := l_child_graph_rules->'delete';
834
-
835
- IF l_delete_rules IS NOT NULL AND l_delete_rules != '{}' THEN
836
- -- Check rules for this child entity
837
- FOR l_rule_name, l_rule_action IN SELECT * FROM jsonb_each_text(l_delete_rules)
838
- LOOP
839
- -- The rule_name should match the child entity name
840
- IF l_rule_name = l_child_entity.table_name THEN
841
- -- Determine FK field name (try direct match then _id suffix)
842
- l_fk_field := l_fk_key;
843
- IF NOT EXISTS (
844
- SELECT 1 FROM information_schema.columns
845
- WHERE table_name = l_child_entity.table_name
846
- AND column_name = l_fk_field
847
- ) THEN
848
- l_fk_field := l_fk_key || '_id';
849
- END IF;
850
-
851
- -- Apply the rule action
852
- CASE l_rule_action
853
- WHEN 'CASCADE' THEN
854
- -- Delete child records via generic_delete to trigger events
855
- DECLARE
856
- l_child_record record;
857
- BEGIN
858
- FOR l_child_record IN
859
- EXECUTE format(
860
- 'SELECT * FROM %I WHERE %I = %L',
861
- l_child_entity.table_name,
862
- l_fk_field,
863
- l_record->>'id'
864
- )
865
- LOOP
866
- -- Call generic_delete for each child to ensure events are created
867
- PERFORM dzql.generic_delete(
868
- l_child_entity.table_name,
869
- jsonb_build_object('id', l_child_record.id),
870
- p_user_id
871
- );
872
- END LOOP;
873
- END;
874
-
875
- WHEN 'SET NULL' THEN
876
- -- Set FK to NULL in child records
877
- EXECUTE format(
878
- 'UPDATE %I SET %I = NULL WHERE %I = %L',
879
- l_child_entity.table_name,
880
- l_fk_field,
881
- l_fk_field,
882
- l_record->>'id'
883
- );
884
-
885
- WHEN 'RESTRICT' THEN
886
- -- Check if children exist, prevent delete if so
887
- EXECUTE format(
888
- 'SELECT COUNT(*) FROM %I WHERE %I = %L',
889
- l_child_entity.table_name,
890
- l_fk_field,
891
- l_record->>'id'
892
- ) INTO l_child_count;
893
-
894
- IF l_child_count > 0 THEN
895
- RAISE EXCEPTION 'Cannot delete % - % child records exist in %',
896
- p_entity, l_child_count, l_child_entity.table_name;
897
- END IF;
898
- END CASE;
899
- END IF;
900
- END LOOP;
901
- END IF;
902
- END IF;
903
- END IF;
904
- END LOOP;
905
- END LOOP;
906
- END;
907
-
908
- -- Execute graph rules for delete
909
- l_graph_rules_result := dzql.execute_graph_rules(
910
- p_entity,
911
- 'delete',
912
- l_record,
913
- NULL,
914
- p_user_id
915
- );
916
-
917
- -- Perform the actual delete using composite WHERE clause
918
- IF l_entity_config.soft_delete THEN
919
- EXECUTE format('UPDATE %I SET deleted_at = now() WHERE %s', p_entity, l_pk_where);
920
- ELSE
921
- EXECUTE format('DELETE FROM %I WHERE %s', p_entity, l_pk_where);
922
- END IF;
923
-
924
-
925
-
926
-
927
- -- Create event for the delete operation
928
- -- Include l_record as data so _affected_documents can resolve which subscriptions to update
929
- INSERT INTO dzql.events (
930
- table_name,
931
- op,
932
- pk,
933
- data,
934
- user_id,
935
- notify_users
936
- ) VALUES (
937
- p_entity,
938
- 'delete',
939
- (
940
- SELECT jsonb_object_agg(col, l_record ->> col)
941
- FROM unnest(l_pk_cols) AS col
942
- ),
943
- l_record,
944
- p_user_id,
945
- dzql.resolve_notification_paths(p_entity, l_record)
946
- );
947
-
948
- -- Add graph rules execution summary to result if rules were executed
949
- IF l_graph_rules_result IS NOT NULL AND l_graph_rules_result != '{}' THEN
950
- l_record := l_record || jsonb_build_object('graph_rules', l_graph_rules_result);
951
- END IF;
952
-
953
- RETURN l_record;
954
- END $$;
955
-
956
- -- === Generic LOOKUP Operation ===
957
- -- Generic LOOKUP for autocomplete/dropdown data
958
- CREATE OR REPLACE FUNCTION dzql.generic_lookup(
959
- p_entity text,
960
- p_args jsonb,
961
- p_user_id int
962
- ) RETURNS jsonb
963
- LANGUAGE plpgsql
964
- SECURITY INVOKER
965
- AS $$
966
- DECLARE
967
- l_entity_config record;
968
- l_filter text;
969
- l_label_field text;
970
- l_where_clause text;
971
- l_temporal_filter text;
972
- l_on_date timestamptz;
973
- l_sql_stmt text;
974
- l_result jsonb;
975
- l_pk_cols text[];
976
- l_pk_value_expr text;
977
- l_is_compound_key boolean;
978
- l_fk_includes jsonb;
979
- l_key text;
980
- l_value text;
981
- l_fk_result jsonb;
982
- l_record jsonb;
983
- l_processed_data jsonb[] := '{}';
984
- l_label_obj jsonb;
985
- i int;
986
- BEGIN
987
- -- Get entity configuration
988
- SELECT * INTO l_entity_config FROM dzql.entities WHERE table_name = p_entity;
989
-
990
- IF l_entity_config IS NULL THEN
991
- RAISE EXCEPTION 'DZQL: entity % not configured', p_entity;
992
- END IF;
993
-
994
- -- Get primary key columns
995
- SELECT array_agg(a.attname ORDER BY a.attnum)
996
- INTO l_pk_cols
997
- FROM pg_index i
998
- JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
999
- WHERE i.indrelid = p_entity::regclass AND i.indisprimary;
1000
-
1001
- IF l_pk_cols IS NULL THEN
1002
- RAISE EXCEPTION 'DZQL: entity % has no primary key', p_entity;
1003
- END IF;
1004
-
1005
- -- Check if this is a compound key
1006
- l_is_compound_key := array_length(l_pk_cols, 1) > 1;
1007
-
1008
- -- Build primary key value expression
1009
- IF l_is_compound_key THEN
1010
- -- Composite primary key - concatenate values
1011
- l_pk_value_expr := format('CONCAT(%s)', array_to_string(array(SELECT format('%I', col) FROM unnest(l_pk_cols) AS col), ', ''-'', '));
1012
- ELSE
1013
- -- Single primary key
1014
- l_pk_value_expr := l_pk_cols[1];
1015
- END IF;
1016
-
1017
- -- Extract parameters
1018
- l_filter := p_args ->> 'p_filter';
1019
- l_on_date := (p_args ->> 'on_date')::timestamptz;
1020
- l_label_field := l_entity_config.label_field;
1021
-
1022
- -- Build WHERE clause for filter
1023
- IF l_filter IS NOT NULL AND l_filter != '' THEN
1024
- l_where_clause := format('%I ILIKE %L', l_label_field, '%' || l_filter || '%');
1025
- ELSE
1026
- l_where_clause := '1=1';
1027
- END IF;
1028
-
1029
- -- Add temporal filter
1030
- l_temporal_filter := dzql.apply_temporal_filter(
1031
- p_entity::regclass,
1032
- l_entity_config.temporal_fields,
1033
- l_on_date
1034
- );
1035
-
1036
- l_where_clause := l_where_clause || l_temporal_filter;
1037
-
1038
- -- Add soft delete filter if enabled for this entity
1039
- IF l_entity_config.soft_delete THEN
1040
- l_where_clause := l_where_clause || ' AND t.deleted_at IS NULL';
1041
- END IF;
1042
-
1043
- IF l_is_compound_key AND l_entity_config.fk_includes IS NOT NULL AND l_entity_config.fk_includes != '{}' THEN
1044
- -- For compound keys with FK includes, build full dereferenced labels
1045
- l_sql_stmt := format(
1046
- 'SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY %I), ''[]''::jsonb)
1047
- FROM %I t WHERE %s AND dzql.check_permission(%L, ''view'', %L, to_jsonb(t.*)) LIMIT 50',
1048
- l_label_field, p_entity, l_where_clause, p_user_id, p_entity
1049
- );
1050
-
1051
- EXECUTE l_sql_stmt INTO l_result;
1052
-
1053
- -- Process FK dereferencing for each record
1054
- l_fk_includes := l_entity_config.fk_includes;
1055
- IF l_result IS NOT NULL AND jsonb_array_length(l_result) > 0 THEN
1056
- -- Process each record in the result array
1057
- FOR i IN 0..jsonb_array_length(l_result) - 1 LOOP
1058
- l_record := l_result->i;
1059
- l_label_obj := l_record; -- Start with base record
1060
-
1061
- -- Dereference foreign keys for this record, getting only label fields
1062
- FOR l_key, l_value IN SELECT key, value FROM jsonb_each_text(l_fk_includes)
1063
- LOOP
1064
- -- Handle direct FK only for compound keys (resolve_direct_fk returns full record)
1065
- l_fk_result := dzql.resolve_direct_fk(l_record, l_key, l_value, l_on_date);
1066
-
1067
- -- Extract just the label_field from the target entity
1068
- IF l_fk_result IS NOT NULL THEN
1069
- -- Get the target entity's label_field
1070
- SELECT label_field INTO l_label_field FROM dzql.entities WHERE table_name = l_value;
1071
- IF l_label_field IS NOT NULL THEN
1072
- l_label_obj := l_label_obj || jsonb_build_object(l_key, l_fk_result ->> l_label_field);
1073
- END IF;
1074
- END IF;
1075
- END LOOP;
1076
-
1077
- -- Build the lookup entry with dereferenced label
1078
- l_processed_data := l_processed_data || jsonb_build_object(
1079
- 'label', l_label_obj,
1080
- 'value', (
1081
- SELECT string_agg(l_record ->> col, '-' ORDER BY ordinality)
1082
- FROM unnest(l_pk_cols) WITH ORDINALITY AS col
1083
- )
1084
- );
1085
- END LOOP;
1086
-
1087
- -- Convert processed data to final result
1088
- l_result := to_jsonb(l_processed_data);
1089
- ELSE
1090
- l_result := '[]'::jsonb;
1091
- END IF;
1092
- ELSE
1093
- -- For simple entities, use the original approach
1094
- l_sql_stmt := format(
1095
- 'SELECT COALESCE(jsonb_agg(jsonb_build_object(''label'', %I, ''value'', %s) ORDER BY %I), ''[]''::jsonb)
1096
- FROM %I t WHERE %s AND dzql.check_permission(%L, ''view'', %L, to_jsonb(t.*)) LIMIT 50',
1097
- l_label_field, l_pk_value_expr, l_label_field, p_entity, l_where_clause, p_user_id, p_entity
1098
- );
1099
-
1100
- EXECUTE l_sql_stmt INTO l_result;
1101
- END IF;
1102
-
1103
- RETURN COALESCE(l_result, '[]'::jsonb);
1104
- EXCEPTION
1105
- WHEN OTHERS THEN
1106
- RAISE EXCEPTION 'DZQL: lookup error for entity %: %', p_entity, SQLERRM;
1107
- END $$;
1108
-
1109
- -- === Generic Dispatcher Function ===
1110
- -- Routes operations to their specific implementation functions
1111
- CREATE OR REPLACE FUNCTION dzql.generic_exec(
1112
- p_operation text,
1113
- p_entity text,
1114
- p_args jsonb,
1115
- p_user_id int
1116
- ) RETURNS jsonb
1117
- LANGUAGE plpgsql
1118
- SECURITY INVOKER
1119
- AS $$
1120
- BEGIN
1121
- CASE lower(p_operation)
1122
- WHEN 'get' THEN
1123
- RETURN dzql.generic_get(p_entity, p_args, p_user_id);
1124
- WHEN 'save' THEN
1125
- RETURN dzql.generic_save(p_entity, p_args, p_user_id);
1126
- WHEN 'delete' THEN
1127
- RETURN dzql.generic_delete(p_entity, p_args, p_user_id);
1128
- WHEN 'lookup' THEN
1129
- RETURN dzql.generic_lookup(p_entity, p_args, p_user_id);
1130
- WHEN 'search' THEN
1131
- RETURN dzql.generic_search(p_entity, p_args, p_user_id);
1132
- ELSE
1133
- RAISE EXCEPTION 'DZQL: unknown operation %', p_operation;
1134
- END CASE;
1135
- END $$;