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,890 +0,0 @@
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
- -- === Condition Evaluation ===
153
- -- Evaluates a condition string with variable substitution
154
- CREATE OR REPLACE FUNCTION dzql.evaluate_condition(
155
- p_condition text,
156
- p_record_before jsonb,
157
- p_record_after jsonb,
158
- p_user_id int
159
- ) RETURNS boolean
160
- LANGUAGE plpgsql AS $$
161
- DECLARE
162
- l_resolved_condition text;
163
- l_result boolean;
164
- l_match_array text[];
165
- l_field_name text;
166
- l_field_value text;
167
- BEGIN
168
- -- Replace variables in condition
169
- l_resolved_condition := p_condition;
170
-
171
- -- Replace @before.field references
172
- IF p_record_before IS NOT NULL THEN
173
- LOOP
174
- l_match_array := regexp_match(l_resolved_condition, '@before\.(\w+)');
175
- EXIT WHEN l_match_array IS NULL;
176
-
177
- l_field_name := l_match_array[1];
178
- l_field_value := COALESCE(p_record_before->>l_field_name, 'null');
179
- l_resolved_condition := regexp_replace(
180
- l_resolved_condition,
181
- '@before\.' || l_field_name,
182
- quote_literal(l_field_value),
183
- 1
184
- );
185
- END LOOP;
186
- END IF;
187
-
188
- -- Replace @after.field references
189
- IF p_record_after IS NOT NULL THEN
190
- LOOP
191
- l_match_array := regexp_match(l_resolved_condition, '@after\.(\w+)');
192
- EXIT WHEN l_match_array IS NULL;
193
-
194
- l_field_name := l_match_array[1];
195
- l_field_value := COALESCE(p_record_after->>l_field_name, 'null');
196
- l_resolved_condition := regexp_replace(
197
- l_resolved_condition,
198
- '@after\.' || l_field_name,
199
- quote_literal(l_field_value),
200
- 1
201
- );
202
- END LOOP;
203
- END IF;
204
-
205
- -- Replace @user_id
206
- l_resolved_condition := replace(l_resolved_condition, '@user_id', p_user_id::text);
207
-
208
- -- Replace @id (use after if available, otherwise before)
209
- IF p_record_after IS NOT NULL THEN
210
- l_resolved_condition := replace(l_resolved_condition, '@id',
211
- COALESCE(p_record_after->>'id', 'null'));
212
- ELSIF p_record_before IS NOT NULL THEN
213
- l_resolved_condition := replace(l_resolved_condition, '@id',
214
- COALESCE(p_record_before->>'id', 'null'));
215
- END IF;
216
-
217
- -- Execute the condition
218
- BEGIN
219
- EXECUTE 'SELECT (' || l_resolved_condition || ')::boolean' INTO l_result;
220
- RETURN COALESCE(l_result, false);
221
- EXCEPTION WHEN OTHERS THEN
222
- -- If condition evaluation fails, log and return false
223
- RAISE WARNING 'Graph rule condition evaluation failed: % (condition: %)', SQLERRM, l_resolved_condition;
224
- RETURN false;
225
- END;
226
- END $$;
227
-
228
- -- === Path Resolution Functions ===
229
- -- Resolve notification/permission paths to user IDs
230
- -- === Path Resolution Functions ===
231
- -- Resolve a single path segment (handles @field, table[condition], and FK traversal)
232
- CREATE OR REPLACE FUNCTION dzql.resolve_path_segment(
233
- p_table_name text,
234
- p_record jsonb,
235
- p_segment text
236
- ) RETURNS jsonb
237
- LANGUAGE plpgsql AS $$
238
- DECLARE
239
- l_result jsonb;
240
- l_parts text[];
241
- l_field_name text;
242
- l_table_name text;
243
- l_condition text;
244
- l_is_active boolean := false;
245
- l_sql text;
246
- l_current_record jsonb;
247
- l_step text;
248
- l_hops text[];
249
- l_final_field text;
250
- BEGIN
251
- -- Handle simple field reference: @field_name
252
- IF p_segment LIKE '@%' THEN
253
- l_field_name := substring(p_segment from 2);
254
- IF p_record ? l_field_name AND p_record->>l_field_name IS NOT NULL THEN
255
- DECLARE
256
- l_field_value text;
257
- BEGIN
258
- l_field_value := p_record->>l_field_name;
259
- -- Try to convert to integer and return as array
260
- RETURN to_jsonb(array[l_field_value::int]);
261
- EXCEPTION WHEN OTHERS THEN
262
- -- If conversion fails, return as text array
263
- RETURN to_jsonb(array[l_field_value]);
264
- END;
265
- END IF;
266
- RETURN null;
267
- END IF;
268
-
269
- -- Handle conditional queries: table[condition]{active}.field
270
- IF p_segment ~ '\[.*\]' THEN
271
- -- Extract parts: table[condition]{active}.field
272
- l_table_name := split_part(p_segment, '[', 1);
273
- l_condition := split_part(split_part(p_segment, '[', 2), ']', 1);
274
- -- Get field name after the closing bracket
275
- l_step := split_part(p_segment, ']', 2);
276
- -- Remove {active} if present
277
- l_step := replace(l_step, '{active}', '');
278
- -- Get field name after the dot
279
- l_field_name := ltrim(l_step, '.');
280
- l_is_active := p_segment LIKE '%{active}%';
281
-
282
- -- Replace @ references in condition with actual values from the record (if we have one)
283
- IF p_record IS NOT NULL THEN
284
- DECLARE
285
- l_ref_field text;
286
- BEGIN
287
- WHILE l_condition ~ '@[a-z_]+' LOOP
288
- l_ref_field := substring(l_condition from '@([a-z_]+)');
289
- l_condition := replace(l_condition, '@' || l_ref_field,
290
- quote_literal(p_record->>l_ref_field));
291
- END LOOP;
292
- END;
293
- END IF;
294
-
295
- -- Build and execute query
296
- l_sql := format('SELECT array_agg(%I) FROM %I WHERE %s',
297
- l_field_name, l_table_name, l_condition);
298
-
299
- -- Add temporal filtering if {active}
300
- IF l_is_active THEN
301
- -- Get temporal field configuration for this table
302
- DECLARE
303
- l_temporal_config record;
304
- l_valid_from_field text;
305
- l_valid_to_field text;
306
- BEGIN
307
- SELECT temporal_fields INTO l_temporal_config
308
- FROM dzql.entities
309
- WHERE table_name = l_table_name;
310
-
311
- IF l_temporal_config.temporal_fields IS NOT NULL AND l_temporal_config.temporal_fields != '{}' THEN
312
- l_valid_from_field := l_temporal_config.temporal_fields->>'valid_from';
313
- l_valid_to_field := l_temporal_config.temporal_fields->>'valid_to';
314
-
315
- IF l_valid_from_field IS NOT NULL THEN
316
- l_sql := l_sql || format(' AND %I <= now() AND (%I > now() OR %I IS NULL)',
317
- l_valid_from_field, l_valid_to_field, l_valid_to_field);
318
- END IF;
319
- END IF;
320
- END;
321
- END IF;
322
-
323
- DECLARE
324
- l_ids int[];
325
- BEGIN
326
- EXECUTE l_sql INTO l_ids;
327
- IF l_ids IS NOT NULL THEN
328
- RETURN to_jsonb(l_ids);
329
- ELSE
330
- RETURN null;
331
- END IF;
332
- END;
333
- END IF;
334
-
335
- -- Handle foreign key traversal (single or multi-hop)
336
- IF p_segment ~ '\.' AND p_record IS NOT NULL THEN
337
- -- For paths like site_id.venue_id.org_id, we need to:
338
- -- 1. Follow site_id to get the site record
339
- -- 2. Follow venue_id from that to get the venue record
340
- -- 3. Extract org_id from the venue record
341
-
342
- l_current_record := p_record;
343
- l_parts := string_to_array(p_segment, '.');
344
-
345
- -- We need to track which table we're currently in for FK resolution
346
- DECLARE
347
- l_current_table_name text;
348
- BEGIN
349
- l_current_table_name := p_table_name;
350
-
351
- -- Process each part except the last (which is the field to extract)
352
- FOR i IN 1..(array_length(l_parts, 1) - 1) LOOP
353
- l_step := l_parts[i];
354
-
355
- -- If current record has this field, follow it as a foreign key
356
- IF l_current_record ? l_step THEN
357
- -- Get the FK value
358
- DECLARE
359
- l_fk_value text;
360
- l_fk_sql text;
361
- l_entity_config record;
362
- l_fk_target text;
363
- BEGIN
364
- l_fk_value := l_current_record->>l_step;
365
-
366
- -- Look up the current table's FK configuration
367
- IF l_current_table_name IS NOT NULL THEN
368
- SELECT fk_includes INTO l_entity_config
369
- FROM dzql.entities
370
- WHERE table_name = l_current_table_name;
371
-
372
- IF l_entity_config.fk_includes IS NOT NULL AND l_entity_config.fk_includes ? l_step THEN
373
- -- The fk_includes tells us which table this FK points to
374
- l_table_name := l_entity_config.fk_includes->>l_step;
375
- ELSIF l_entity_config.fk_includes IS NOT NULL THEN
376
- -- Check if the field without _id suffix is in fk_includes
377
- l_fk_target := regexp_replace(l_step, '_id$', '');
378
- IF l_entity_config.fk_includes ? l_fk_target THEN
379
- l_table_name := l_entity_config.fk_includes->>l_fk_target;
380
- END IF;
381
- END IF;
382
- END IF;
383
-
384
- -- If we still don't have a table name, try pattern matching
385
- IF l_table_name IS NULL THEN
386
- DECLARE
387
- l_pattern text;
388
- BEGIN
389
- -- Remove _id suffix and try to find a matching table
390
- l_pattern := regexp_replace(l_step, '_id$', '');
391
-
392
- -- Check for common patterns
393
- IF EXISTS(SELECT 1 FROM dzql.entities WHERE table_name = l_pattern || 's') THEN
394
- l_table_name := l_pattern || 's';
395
- ELSIF EXISTS(SELECT 1 FROM dzql.entities WHERE table_name = l_pattern) THEN
396
- l_table_name := l_pattern;
397
- END IF;
398
- END;
399
- END IF;
400
-
401
- IF l_table_name IS NULL THEN
402
- RAISE WARNING 'Could not determine target table for FK field % in table %', l_step, coalesce(l_current_table_name, 'unknown');
403
- RETURN null;
404
- END IF;
405
-
406
- -- Query the related table
407
- l_fk_sql := format('SELECT to_jsonb(t.*) FROM %I t WHERE t.id = %L', l_table_name, l_fk_value);
408
- EXECUTE l_fk_sql INTO l_current_record;
409
-
410
- IF l_current_record IS NULL THEN
411
- RETURN null; -- FK broken
412
- END IF;
413
-
414
- -- Update current table context for next iteration
415
- l_current_table_name := l_table_name;
416
- END;
417
- ELSE
418
- RETURN null; -- Field not found
419
- END IF;
420
- END LOOP;
421
- END;
422
-
423
- -- Extract the final field value
424
- l_final_field := l_parts[array_length(l_parts, 1)];
425
- IF l_current_record ? l_final_field THEN
426
- RETURN to_jsonb(array[l_current_record->>l_final_field]::int[]);
427
- END IF;
428
-
429
- RETURN null;
430
- END IF;
431
-
432
- RETURN null;
433
- END $$;
434
-
435
- -- Resolve notification/permission paths to user IDs
436
- CREATE OR REPLACE FUNCTION dzql.resolve_notification_path(
437
- p_table_name text,
438
- p_record jsonb,
439
- p_path text
440
- ) RETURNS int[]
441
- LANGUAGE plpgsql AS $$
442
- DECLARE
443
- l_result_ids int[] := '{}';
444
- l_continuation_parts text[];
445
- l_current_segment text;
446
- l_current_result jsonb;
447
- l_current_ids int[];
448
- l_sql text;
449
- l_field_name text;
450
- l_table_name text;
451
- l_condition text;
452
- l_is_active boolean := false;
453
- l_i int;
454
- BEGIN
455
- -- Split by continuation operator (->) if present
456
- IF p_path ~ '->' THEN
457
- l_continuation_parts := string_to_array(p_path, '->');
458
-
459
- -- Process first segment with the original record
460
- l_current_result := dzql.resolve_path_segment(p_table_name, p_record, l_continuation_parts[1]);
461
-
462
- -- Process each continuation segment
463
- FOR l_i IN 2..array_length(l_continuation_parts, 1) LOOP
464
- l_current_segment := l_continuation_parts[l_i];
465
-
466
- -- Replace $ with the result from previous segment
467
- IF l_current_result IS NOT NULL THEN
468
- -- Handle array results
469
- IF jsonb_typeof(l_current_result) = 'array' THEN
470
- -- For each ID in the array, resolve the path and collect results
471
- l_current_ids := '{}';
472
- DECLARE
473
- l_j int;
474
- l_temp_segment text;
475
- l_temp_ids int[];
476
- l_temp_value int;
477
- l_segment_table text;
478
- l_segment_record jsonb;
479
- l_replacement_value text;
480
- BEGIN
481
- FOR l_j IN 0..jsonb_array_length(l_current_result) - 1 LOOP
482
- l_replacement_value := (l_current_result->>l_j)::text;
483
-
484
- -- Check if $ appears in a condition context table[field=$]
485
- -- If so, we need to quote it properly for text values
486
- IF l_continuation_parts[l_i] ~ '\[.*\$.*\]' THEN
487
- -- Try to parse as integer first
488
- BEGIN
489
- -- If it's an integer, use it directly
490
- l_temp_segment := replace(l_continuation_parts[l_i], '$', l_replacement_value::int::text);
491
- EXCEPTION WHEN OTHERS THEN
492
- -- Not an integer, quote it as a literal
493
- l_temp_segment := replace(l_continuation_parts[l_i], '$', quote_literal(l_replacement_value));
494
- END;
495
- ELSE
496
- -- Not in a condition, use raw value
497
- l_temp_segment := replace(l_continuation_parts[l_i], '$', l_replacement_value);
498
- END IF;
499
-
500
- -- Handle table.field syntax in continuation segments
501
- IF l_temp_segment ~ '^[a-z_]+\.[a-z_]+' THEN
502
- l_segment_table := split_part(l_temp_segment, '.', 1);
503
- l_field_name := split_part(l_temp_segment, '.', 2);
504
- -- Query the table directly to get the field value
505
- l_sql := format('SELECT %I FROM %I WHERE id = %L', l_field_name, l_segment_table, (l_current_result->>l_j)::int);
506
- EXECUTE l_sql INTO l_temp_value;
507
- l_temp_ids := array[l_temp_value];
508
- ELSE
509
- l_temp_ids := array(SELECT jsonb_array_elements_text(dzql.resolve_path_segment(null, null, l_temp_segment))::int);
510
- END IF;
511
-
512
- l_current_ids := l_current_ids || coalesce(l_temp_ids, '{}');
513
- END LOOP;
514
- END;
515
- l_current_result := to_jsonb(l_current_ids);
516
- ELSE
517
- -- Single value result
518
- DECLARE
519
- l_replacement_value text;
520
- BEGIN
521
- l_replacement_value := l_current_result::text;
522
-
523
- -- Check if $ appears in a condition context table[field=$]
524
- -- If so, we need to quote it properly for text values
525
- IF l_current_segment ~ '\[.*\$.*\]' THEN
526
- -- Try to parse as integer first
527
- BEGIN
528
- -- If it's an integer, use it directly
529
- l_current_segment := replace(l_current_segment, '$', l_replacement_value::int::text);
530
- EXCEPTION WHEN OTHERS THEN
531
- -- Not an integer, quote it as a literal
532
- l_current_segment := replace(l_current_segment, '$', quote_literal(l_replacement_value));
533
- END;
534
- ELSE
535
- -- Not in a condition, use raw value
536
- l_current_segment := replace(l_current_segment, '$', l_replacement_value);
537
- END IF;
538
- END;
539
-
540
- -- Handle table.field syntax in continuation segments
541
- IF l_current_segment ~ '^[a-z_]+\.[a-z_]+' THEN
542
- l_table_name := split_part(l_current_segment, '.', 1);
543
- l_field_name := split_part(l_current_segment, '.', 2);
544
- -- Query the table directly to get the field value
545
- DECLARE
546
- l_field_value int;
547
- BEGIN
548
- l_sql := format('SELECT %I FROM %I WHERE id = %L', l_field_name, l_table_name, l_current_result::text::int);
549
- EXECUTE l_sql INTO l_field_value;
550
- l_current_result := to_jsonb(array[l_field_value]);
551
- END;
552
- ELSE
553
- l_current_result := dzql.resolve_path_segment(null, null, l_current_segment);
554
- END IF;
555
- END IF;
556
- ELSE
557
- RETURN '{}'; -- Path broken
558
- END IF;
559
- END LOOP;
560
-
561
- -- Convert final result to int array
562
- IF jsonb_typeof(l_current_result) = 'array' THEN
563
- l_result_ids := array(SELECT jsonb_array_elements_text(l_current_result)::int);
564
- ELSE
565
- l_result_ids := array[l_current_result::text::int];
566
- END IF;
567
-
568
- RETURN coalesce(l_result_ids, '{}');
569
- ELSE
570
- -- No continuation, process as single segment
571
- l_current_result := dzql.resolve_path_segment(p_table_name, p_record, p_path);
572
-
573
- -- Convert result to int array
574
- IF l_current_result IS NOT NULL THEN
575
- IF jsonb_typeof(l_current_result) = 'array' THEN
576
- l_result_ids := array(SELECT jsonb_array_elements_text(l_current_result)::int);
577
- ELSIF l_current_result::text != 'null' THEN
578
- l_result_ids := array[l_current_result::text::int];
579
- END IF;
580
- END IF;
581
-
582
- RETURN coalesce(l_result_ids, '{}');
583
- END IF;
584
- EXCEPTION
585
- WHEN others THEN
586
- RAISE WARNING 'Failed to resolve path %: %', p_path, SQLERRM;
587
- RETURN '{}';
588
- END $$;
589
-
590
- -- Backward compatibility alias
591
- CREATE OR REPLACE FUNCTION dzql.resolve_path_to_users(
592
- p_path text,
593
- p_record jsonb,
594
- p_table_name text DEFAULT NULL
595
- ) RETURNS int[]
596
- LANGUAGE plpgsql AS $$
597
- BEGIN
598
- RETURN dzql.resolve_notification_path(p_table_name, p_record, p_path);
599
- END $$;
600
-
601
- -- === Permission Validation ===
602
- CREATE OR REPLACE FUNCTION dzql.validate_permission_paths(
603
- p_table_name text,
604
- p_permission_paths jsonb
605
- ) RETURNS boolean
606
- LANGUAGE plpgsql AS $$
607
- DECLARE
608
- l_operation text;
609
- l_paths jsonb;
610
- BEGIN
611
- -- Check if permission_paths is valid JSON object
612
- IF jsonb_typeof(p_permission_paths) != 'object' THEN
613
- RETURN false;
614
- END IF;
615
-
616
- -- Check valid operation keys
617
- FOR l_operation IN SELECT key FROM jsonb_object_keys(p_permission_paths) AS key
618
- LOOP
619
- IF l_operation NOT IN ('create', 'update', 'delete', 'view') THEN
620
- RAISE WARNING 'Invalid permission operation: %', l_operation;
621
- RETURN false;
622
- END IF;
623
-
624
- l_paths := p_permission_paths->l_operation;
625
- IF jsonb_typeof(l_paths) != 'array' THEN
626
- RAISE WARNING 'Permission paths for % must be an array', l_operation;
627
- RETURN false;
628
- END IF;
629
- END LOOP;
630
-
631
- RETURN true;
632
- END $$;
633
-
634
- -- Check if user has permission for operation
635
- CREATE OR REPLACE FUNCTION dzql.check_permission(
636
- p_user_id int,
637
- p_operation text,
638
- p_entity text,
639
- p_record jsonb
640
- ) RETURNS boolean
641
- LANGUAGE plpgsql AS $$
642
- DECLARE
643
- l_entity_config record;
644
- l_permission_paths jsonb;
645
- l_path text;
646
- l_allowed_users int[];
647
- BEGIN
648
- -- Get entity configuration
649
- SELECT * INTO l_entity_config FROM dzql.entities WHERE table_name = p_entity;
650
-
651
- IF l_entity_config IS NULL THEN
652
- RETURN true; -- Entity not configured - permissive default like old version
653
- END IF;
654
-
655
- l_permission_paths := l_entity_config.permission_paths->p_operation;
656
-
657
- -- Empty array means public access
658
- IF l_permission_paths IS NULL OR jsonb_array_length(l_permission_paths) = 0 THEN
659
- RETURN true;
660
- END IF;
661
-
662
- -- Check each permission path
663
- FOR l_path IN SELECT jsonb_array_elements_text(l_permission_paths)
664
- LOOP
665
- l_allowed_users := dzql.resolve_notification_path(p_entity, p_record, l_path);
666
-
667
- IF p_user_id = ANY(l_allowed_users) THEN
668
- RETURN true;
669
- END IF;
670
- END LOOP;
671
-
672
- RETURN false;
673
- END $$;
674
-
675
- -- Resolve all notification paths for an entity to user IDs
676
- CREATE OR REPLACE FUNCTION dzql.resolve_notification_paths(
677
- p_table_name text,
678
- p_record jsonb
679
- ) RETURNS int[]
680
- LANGUAGE plpgsql AS $$
681
- DECLARE
682
- l_entity_config record;
683
- l_all_user_ids int[] := '{}';
684
- l_path_group_key text;
685
- l_path_array jsonb;
686
- l_path text;
687
- l_user_ids int[];
688
- BEGIN
689
- -- Get entity configuration
690
- SELECT * INTO l_entity_config
691
- FROM dzql.entities
692
- WHERE table_name = p_table_name;
693
-
694
- IF l_entity_config.notification_paths IS NULL THEN
695
- RETURN '{}';
696
- END IF;
697
-
698
- -- Process each notification path group (ownership, commercial, delegated, etc.)
699
- FOR l_path_group_key IN SELECT jsonb_object_keys(l_entity_config.notification_paths)
700
- LOOP
701
- l_path_array := l_entity_config.notification_paths->l_path_group_key;
702
-
703
- -- Process each path in the group
704
- FOR l_path IN SELECT jsonb_array_elements_text(l_path_array)
705
- LOOP
706
- l_user_ids := dzql.resolve_notification_path(p_table_name, p_record, l_path);
707
- l_all_user_ids := l_all_user_ids || l_user_ids;
708
- END LOOP;
709
- END LOOP;
710
-
711
- -- Remove duplicates and return user_ids directly (paths now resolve to user_ids)
712
- l_all_user_ids := array(SELECT DISTINCT unnest(l_all_user_ids));
713
-
714
- RETURN coalesce(l_all_user_ids, '{}');
715
- END $$;
716
-
717
- -- === Utility Functions ===
718
- -- Set current user for audit trail
719
- CREATE OR REPLACE FUNCTION dzql.set_current_user(p_user_id int)
720
- RETURNS void
721
- LANGUAGE sql AS $$
722
- SELECT set_config('app.current_user_id', p_user_id::text, false);
723
- $$;
724
-
725
- -- === Graph Rules Helper Functions ===
726
- -- Validate graph rules structure
727
- CREATE OR REPLACE FUNCTION dzql.validate_graph_rules(
728
- p_rules jsonb
729
- ) RETURNS boolean
730
- LANGUAGE plpgsql AS $$
731
- DECLARE
732
- l_trigger_key text;
733
- l_trigger_rules jsonb;
734
- l_rule_name text;
735
- l_rule_config jsonb;
736
- l_action jsonb;
737
- l_action_type text;
738
- BEGIN
739
- -- Check if rules is empty or null (valid)
740
- IF p_rules IS NULL OR p_rules = '{}' THEN
741
- RETURN true;
742
- END IF;
743
-
744
- -- Validate top-level trigger types
745
- FOR l_trigger_key, l_trigger_rules IN SELECT * FROM jsonb_each(p_rules)
746
- LOOP
747
- -- Skip validation for many_to_many (different structure)
748
- IF l_trigger_key = 'many_to_many' THEN
749
- CONTINUE;
750
- END IF;
751
-
752
- -- Check for simpler CASCADE/SET NULL/RESTRICT format: {"delete": {"entity_name": "CASCADE"}}
753
- IF l_trigger_key IN ('delete', 'update', 'create') AND jsonb_typeof(l_trigger_rules) = 'object' THEN
754
- -- This is the simpler format - validate CASCADE/SET NULL/RESTRICT values
755
- DECLARE
756
- l_entity_name text;
757
- l_action_value text;
758
- BEGIN
759
- FOR l_entity_name, l_action_value IN SELECT * FROM jsonb_each_text(l_trigger_rules)
760
- LOOP
761
- IF l_action_value NOT IN ('CASCADE', 'SET NULL', 'RESTRICT') THEN
762
- RAISE WARNING 'Invalid graph rule action for entity %: %. Must be CASCADE, SET NULL, or RESTRICT', l_entity_name, l_action_value;
763
- RETURN false;
764
- END IF;
765
- END LOOP;
766
- -- Valid simpler format - skip complex validation
767
- CONTINUE;
768
- END;
769
- END IF;
770
-
771
- -- Check valid trigger types for complex format
772
- IF l_trigger_key NOT IN ('on_create', 'on_update', 'on_delete', 'on_field_change') THEN
773
- RAISE WARNING 'Invalid trigger type: %', l_trigger_key;
774
- RETURN false;
775
- END IF;
776
-
777
- -- Validate each rule within the trigger
778
- FOR l_rule_name, l_rule_config IN SELECT * FROM jsonb_each(l_trigger_rules)
779
- LOOP
780
- -- Check required fields
781
- IF NOT l_rule_config ? 'actions' THEN
782
- RAISE WARNING 'Rule % missing required "actions" field', l_rule_name;
783
- RETURN false;
784
- END IF;
785
-
786
- -- Validate each action
787
- FOR l_action IN SELECT * FROM jsonb_array_elements(l_rule_config->'actions')
788
- LOOP
789
- l_action_type := l_action->>'type';
790
-
791
- -- Check valid action types (includes new 'validate' and 'execute' types)
792
- IF l_action_type NOT IN ('create', 'update', 'delete', 'validate', 'execute') THEN
793
- RAISE WARNING 'Invalid action type: %', l_action_type;
794
- RETURN false;
795
- END IF;
796
-
797
- -- Check required fields per action type
798
- IF l_action_type IN ('create', 'update') AND NOT l_action ? 'entity' THEN
799
- RAISE WARNING 'Action type % requires "entity" field', l_action_type;
800
- RETURN false;
801
- END IF;
802
-
803
- IF l_action_type = 'create' AND NOT l_action ? 'data' THEN
804
- RAISE WARNING 'Action type "create" requires "data" field';
805
- RETURN false;
806
- END IF;
807
-
808
- IF l_action_type IN ('update', 'delete') AND NOT l_action ? 'match' THEN
809
- RAISE WARNING 'Action type % requires "match" field', l_action_type;
810
- RETURN false;
811
- END IF;
812
-
813
- -- Validate new action types
814
- IF l_action_type IN ('validate', 'execute') AND NOT l_action ? 'function' THEN
815
- RAISE WARNING 'Action type % requires "function" field', l_action_type;
816
- RETURN false;
817
- END IF;
818
-
819
- IF l_action_type IN ('validate', 'execute') AND NOT l_action ? 'params' THEN
820
- RAISE WARNING 'Action type % requires "params" field', l_action_type;
821
- RETURN false;
822
- END IF;
823
- END LOOP;
824
- END LOOP;
825
- END LOOP;
826
-
827
- RETURN true;
828
- END $$;
829
-
830
- -- Resolve graph variables like @user_id, @field_name, etc.
831
- CREATE OR REPLACE FUNCTION dzql.resolve_graph_variable(
832
- p_variable text,
833
- p_record_before jsonb,
834
- p_record_after jsonb,
835
- p_user_id int
836
- ) RETURNS text
837
- LANGUAGE plpgsql AS $$
838
- DECLARE
839
- l_result text;
840
- l_field_name text;
841
- l_record jsonb;
842
- BEGIN
843
- -- Handle built-in variables
844
- CASE p_variable
845
- WHEN '@user_id' THEN
846
- l_result := p_user_id::text;
847
- WHEN '@now' THEN
848
- l_result := now()::text;
849
- WHEN '@today' THEN
850
- l_result := current_date::text;
851
- ELSE
852
- -- Handle field variables like @field_name or @field_name_before
853
- IF p_variable LIKE '@%_before' THEN
854
- l_field_name := substring(p_variable from 2 for length(p_variable) - 8);
855
- l_record := p_record_before;
856
- ELSE
857
- l_field_name := substring(p_variable from 2);
858
- l_record := COALESCE(p_record_after, p_record_before);
859
- END IF;
860
-
861
- l_result := l_record->>l_field_name;
862
- END CASE;
863
-
864
- RETURN l_result;
865
- END $$;
866
-
867
- -- Resolve data object with variable substitution
868
- CREATE OR REPLACE FUNCTION dzql.resolve_graph_data(
869
- p_data jsonb,
870
- p_record_before jsonb,
871
- p_record_after jsonb,
872
- p_user_id int
873
- ) RETURNS jsonb
874
- LANGUAGE plpgsql AS $$
875
- DECLARE
876
- l_result jsonb := '{}';
877
- l_key text;
878
- l_value text;
879
- BEGIN
880
- FOR l_key, l_value IN SELECT * FROM jsonb_each_text(p_data)
881
- LOOP
882
- IF l_value LIKE '@%' THEN
883
- l_value := dzql.resolve_graph_variable(l_value, p_record_before, p_record_after, p_user_id);
884
- END IF;
885
-
886
- l_result := l_result || jsonb_build_object(l_key, l_value);
887
- END LOOP;
888
-
889
- RETURN l_result;
890
- END $$;