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
@@ -0,0 +1,3020 @@
1
+
2
+ CREATE TABLE IF NOT EXISTS users (
3
+ id serial PRIMARY KEY,
4
+ name text NOT NULL,
5
+ email text UNIQUE NOT NULL,
6
+ password_hash text NOT NULL,
7
+ created_at timestamptz DEFAULT now()
8
+ );
9
+
10
+
11
+ CREATE OR REPLACE FUNCTION dzql_v2.save_users(p_user_id int, p_data jsonb)
12
+ RETURNS jsonb
13
+ LANGUAGE plpgsql
14
+ SECURITY DEFINER
15
+ SET search_path = dzql_v2, public
16
+ AS $$
17
+ DECLARE
18
+ v_result jsonb;
19
+ v_old_data jsonb;
20
+ v_commit_id bigint;
21
+ v_op text;
22
+
23
+ BEGIN
24
+ v_commit_id := nextval('dzql_v2.commit_seq');
25
+
26
+
27
+ -- Determine Operation & Check Permissions (supports composite PK)
28
+ IF ((p_data->>'id') IS NOT NULL) AND EXISTS(SELECT 1 FROM users WHERE id = (p_data->>'id')::int) THEN
29
+ v_op := 'update';
30
+
31
+ -- Fetch old data for update rules/events
32
+ SELECT to_jsonb(users.*) INTO v_old_data FROM users WHERE id = (p_data->>'id')::int;
33
+
34
+ IF NOT ((p_data->>'id')::int = p_user_id) THEN
35
+ RAISE EXCEPTION 'permission_denied';
36
+ END IF;
37
+
38
+ -- Perform Partial Update
39
+ UPDATE users SET
40
+ name = CASE WHEN (p_data ? 'name') THEN (p_data->>'name') ELSE name END,
41
+ email = CASE WHEN (p_data ? 'email') THEN (p_data->>'email') ELSE email END,
42
+ password_hash = CASE WHEN (p_data ? 'password_hash') THEN (p_data->>'password_hash') ELSE password_hash END,
43
+ created_at = CASE WHEN (p_data ? 'created_at') THEN (p_data->>'created_at')::timestamptz ELSE created_at END
44
+ WHERE id = (p_data->>'id')::int
45
+ RETURNING to_jsonb(users.*) INTO v_result;
46
+
47
+
48
+
49
+ ELSE
50
+ v_op := 'insert';
51
+ IF NOT (TRUE) THEN
52
+ RAISE EXCEPTION 'permission_denied';
53
+ END IF;
54
+
55
+ -- Perform Insert
56
+ INSERT INTO users (name, email, password_hash, created_at)
57
+ VALUES ((p_data->>'name'), (p_data->>'email'), (p_data->>'password_hash'), (p_data->>'created_at')::timestamptz)
58
+ RETURNING to_jsonb(users.*) INTO v_result;
59
+
60
+
61
+ END IF;
62
+
63
+
64
+
65
+ -- Emit Event
66
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
67
+ VALUES (
68
+ v_commit_id,
69
+ 'users',
70
+ v_op,
71
+ jsonb_build_object('id', v_result->'id'),
72
+ v_result,
73
+ v_old_data, -- NULL for insert
74
+ p_user_id
75
+ );
76
+
77
+ -- Notify Runtime
78
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
79
+
80
+ -- Remove hidden fields before returning to client
81
+ RETURN v_result - ARRAY['password_hash'];
82
+ END;
83
+ $$;
84
+
85
+
86
+ CREATE OR REPLACE FUNCTION dzql_v2.delete_users(p_user_id int, p_pk jsonb)
87
+ RETURNS jsonb
88
+ LANGUAGE plpgsql
89
+ SECURITY DEFINER
90
+ SET search_path = dzql_v2, public
91
+ AS $$
92
+ DECLARE
93
+ v_old_data jsonb;
94
+ v_commit_id bigint;
95
+ BEGIN
96
+ v_commit_id := nextval('dzql_v2.commit_seq');
97
+
98
+ -- Fetch old data FIRST for permission check
99
+ SELECT to_jsonb(users.*) INTO v_old_data FROM users WHERE id = (p_pk->>'id')::int;
100
+
101
+ IF v_old_data IS NULL THEN
102
+ RAISE EXCEPTION 'not_found';
103
+ END IF;
104
+
105
+ -- Permission Check (Delete)
106
+ IF NOT ((v_old_data->>'id')::int = p_user_id) THEN
107
+ RAISE EXCEPTION 'permission_denied';
108
+ END IF;
109
+
110
+ -- Graph Rules (Pre-delete cascades)
111
+
112
+
113
+ -- Perform Delete
114
+ DELETE FROM users WHERE id = (p_pk->>'id')::int;
115
+
116
+ -- Emit Event (always 'delete' operation for client-side removal)
117
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
118
+ VALUES (
119
+ v_commit_id,
120
+ 'users',
121
+ 'delete',
122
+ jsonb_build_object('id', v_old_data->'id'),
123
+ v_old_data, -- Include full data for subscription resolution
124
+ v_old_data,
125
+ p_user_id
126
+ );
127
+
128
+ -- Notify Runtime
129
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
130
+
131
+ -- Remove hidden fields before returning to client
132
+ RETURN v_old_data - ARRAY['password_hash'];
133
+ END;
134
+ $$;
135
+
136
+
137
+ CREATE OR REPLACE FUNCTION dzql_v2.get_users(p_user_id int, p_pk jsonb)
138
+ RETURNS jsonb
139
+ LANGUAGE plpgsql
140
+ SECURITY DEFINER
141
+ SET search_path = dzql_v2, public
142
+ AS $$
143
+ DECLARE
144
+ v_result jsonb;
145
+ BEGIN
146
+ SELECT jsonb_build_object('id', users.id, 'name', users.name, 'email', users.email, 'created_at', users.created_at) INTO v_result
147
+ FROM users
148
+ WHERE id = (p_pk->>'id')::int
149
+ AND (TRUE);
150
+
151
+ IF v_result IS NULL THEN
152
+ RETURN NULL;
153
+ END IF;
154
+
155
+
156
+ RETURN v_result;
157
+ END;
158
+ $$;
159
+
160
+
161
+ CREATE OR REPLACE FUNCTION dzql_v2.search_users(p_user_id int, p_query jsonb)
162
+ RETURNS jsonb
163
+ LANGUAGE plpgsql
164
+ SECURITY DEFINER
165
+ SET search_path = dzql_v2, public
166
+ AS $$
167
+ DECLARE
168
+ v_results jsonb;
169
+ v_filters jsonb;
170
+ v_sort_field text;
171
+ v_sort_order text;
172
+ v_where_clause text := '';
173
+ v_field text;
174
+ v_filter jsonb;
175
+ v_operator text;
176
+ v_value jsonb;
177
+ BEGIN
178
+ -- Extract query parameters
179
+ v_filters := COALESCE(p_query->'filters', '{}'::jsonb);
180
+ v_sort_field := COALESCE(p_query->>'sort_field', 'name');
181
+ v_sort_order := COALESCE(p_query->>'sort_order', 'asc');
182
+
183
+ -- Build WHERE clause from filters
184
+ FOR v_field, v_filter IN SELECT * FROM jsonb_each(v_filters)
185
+ LOOP
186
+ -- Handle simple value (exact match)
187
+ IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
188
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_filter #>> '{}');
189
+ ELSE
190
+ -- Handle operator-based filters
191
+ FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
192
+ LOOP
193
+ CASE v_operator
194
+ WHEN 'eq' THEN
195
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_value #>> '{}');
196
+ WHEN 'ne' THEN
197
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != %L', v_field, v_value #>> '{}');
198
+ WHEN 'gt' THEN
199
+ v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
200
+ WHEN 'gte' THEN
201
+ v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
202
+ WHEN 'lt' THEN
203
+ v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
204
+ WHEN 'lte' THEN
205
+ v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
206
+ WHEN 'in' THEN
207
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = ANY(%L)', v_field,
208
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
209
+ WHEN 'not_in' THEN
210
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != ALL(%L)', v_field,
211
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
212
+ WHEN 'like' THEN
213
+ v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
214
+ WHEN 'ilike' THEN
215
+ v_where_clause := v_where_clause || format(' AND %I ILIKE %L', v_field, v_value #>> '{}');
216
+ WHEN 'is_null' THEN
217
+ IF (v_value::text = 'true') THEN
218
+ v_where_clause := v_where_clause || format(' AND %I IS NULL', v_field);
219
+ END IF;
220
+ WHEN 'not_null' THEN
221
+ IF (v_value::text = 'true') THEN
222
+ v_where_clause := v_where_clause || format(' AND %I IS NOT NULL', v_field);
223
+ END IF;
224
+ ELSE
225
+ -- Unknown operator, skip
226
+ END CASE;
227
+ END LOOP;
228
+ END IF;
229
+ END LOOP;
230
+
231
+ -- Execute dynamic query (sort inside subquery for correct LIMIT behavior)
232
+ EXECUTE format('
233
+ SELECT COALESCE(jsonb_agg(jsonb_build_object(''id'', t.id, ''name'', t.name, ''email'', t.email, ''created_at'', t.created_at)), ''[]''::jsonb)
234
+ FROM (
235
+ SELECT * FROM users
236
+ WHERE (TRUE) %s
237
+ ORDER BY %I %s
238
+ LIMIT %L OFFSET %L
239
+ ) t
240
+ ', v_where_clause, v_sort_field, v_sort_order,
241
+ COALESCE((p_query->>'limit')::int, 10),
242
+ COALESCE((p_query->>'offset')::int, 0))
243
+ INTO v_results;
244
+
245
+ RETURN v_results;
246
+ END;
247
+ $$;
248
+
249
+
250
+ CREATE TABLE IF NOT EXISTS organisations (
251
+ id serial PRIMARY KEY,
252
+ name text UNIQUE NOT NULL,
253
+ description text
254
+ );
255
+
256
+
257
+ CREATE OR REPLACE FUNCTION dzql_v2.save_organisations(p_user_id int, p_data jsonb)
258
+ RETURNS jsonb
259
+ LANGUAGE plpgsql
260
+ SECURITY DEFINER
261
+ SET search_path = dzql_v2, public
262
+ AS $$
263
+ DECLARE
264
+ v_result jsonb;
265
+ v_old_data jsonb;
266
+ v_commit_id bigint;
267
+ v_op text;
268
+
269
+ BEGIN
270
+ v_commit_id := nextval('dzql_v2.commit_seq');
271
+
272
+
273
+ -- Determine Operation & Check Permissions (supports composite PK)
274
+ IF ((p_data->>'id') IS NOT NULL) AND EXISTS(SELECT 1 FROM organisations WHERE id = (p_data->>'id')::int) THEN
275
+ v_op := 'update';
276
+
277
+ -- Fetch old data for update rules/events
278
+ SELECT to_jsonb(organisations.*) INTO v_old_data FROM organisations WHERE id = (p_data->>'id')::int;
279
+
280
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (p_data->>'id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
281
+ RAISE EXCEPTION 'permission_denied';
282
+ END IF;
283
+
284
+ -- Perform Partial Update
285
+ UPDATE organisations SET
286
+ name = CASE WHEN (p_data ? 'name') THEN (p_data->>'name') ELSE name END,
287
+ description = CASE WHEN (p_data ? 'description') THEN (p_data->>'description') ELSE description END
288
+ WHERE id = (p_data->>'id')::int
289
+ RETURNING to_jsonb(organisations.*) INTO v_result;
290
+
291
+
292
+
293
+ ELSE
294
+ v_op := 'insert';
295
+ IF NOT (TRUE) THEN
296
+ RAISE EXCEPTION 'permission_denied';
297
+ END IF;
298
+
299
+ -- Perform Insert
300
+ INSERT INTO organisations (name, description)
301
+ VALUES ((p_data->>'name'), (p_data->>'description'))
302
+ RETURNING to_jsonb(organisations.*) INTO v_result;
303
+
304
+
305
+ -- Graph Rule: Create acts_for
306
+ INSERT INTO acts_for (user_id, org_id, valid_from)
307
+ VALUES (p_user_id, (v_result->>'id')::int, CURRENT_DATE);
308
+
309
+ END IF;
310
+
311
+
312
+
313
+ -- Emit Event
314
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
315
+ VALUES (
316
+ v_commit_id,
317
+ 'organisations',
318
+ v_op,
319
+ jsonb_build_object('id', v_result->'id'),
320
+ v_result,
321
+ v_old_data, -- NULL for insert
322
+ p_user_id
323
+ );
324
+
325
+ -- Notify Runtime
326
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
327
+
328
+ -- Remove hidden fields before returning to client
329
+ RETURN v_result;
330
+ END;
331
+ $$;
332
+
333
+
334
+ CREATE OR REPLACE FUNCTION dzql_v2.delete_organisations(p_user_id int, p_pk jsonb)
335
+ RETURNS jsonb
336
+ LANGUAGE plpgsql
337
+ SECURITY DEFINER
338
+ SET search_path = dzql_v2, public
339
+ AS $$
340
+ DECLARE
341
+ v_old_data jsonb;
342
+ v_commit_id bigint;
343
+ BEGIN
344
+ v_commit_id := nextval('dzql_v2.commit_seq');
345
+
346
+ -- Fetch old data FIRST for permission check
347
+ SELECT to_jsonb(organisations.*) INTO v_old_data FROM organisations WHERE id = (p_pk->>'id')::int;
348
+
349
+ IF v_old_data IS NULL THEN
350
+ RAISE EXCEPTION 'not_found';
351
+ END IF;
352
+
353
+ -- Permission Check (Delete)
354
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (v_old_data->>'id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
355
+ RAISE EXCEPTION 'permission_denied';
356
+ END IF;
357
+
358
+ -- Graph Rules (Pre-delete cascades)
359
+
360
+ -- Graph Rule: Delete acts_for
361
+ DELETE FROM acts_for WHERE org_id = (v_old_data->>'id')::int;
362
+
363
+ -- Graph Rule: Delete venues
364
+ DELETE FROM venues WHERE org_id = (v_old_data->>'id')::int;
365
+
366
+ -- Graph Rule: Update packages
367
+ UPDATE packages
368
+ SET sponsor_org_id = (v_old_data->>'id')::int
369
+ WHERE TRUE;
370
+
371
+
372
+ -- Perform Delete
373
+ DELETE FROM organisations WHERE id = (p_pk->>'id')::int;
374
+
375
+ -- Emit Event (always 'delete' operation for client-side removal)
376
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
377
+ VALUES (
378
+ v_commit_id,
379
+ 'organisations',
380
+ 'delete',
381
+ jsonb_build_object('id', v_old_data->'id'),
382
+ v_old_data, -- Include full data for subscription resolution
383
+ v_old_data,
384
+ p_user_id
385
+ );
386
+
387
+ -- Notify Runtime
388
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
389
+
390
+ -- Remove hidden fields before returning to client
391
+ RETURN v_old_data;
392
+ END;
393
+ $$;
394
+
395
+
396
+ CREATE OR REPLACE FUNCTION dzql_v2.get_organisations(p_user_id int, p_pk jsonb)
397
+ RETURNS jsonb
398
+ LANGUAGE plpgsql
399
+ SECURITY DEFINER
400
+ SET search_path = dzql_v2, public
401
+ AS $$
402
+ DECLARE
403
+ v_result jsonb;
404
+ BEGIN
405
+ SELECT to_jsonb(organisations.*) INTO v_result
406
+ FROM organisations
407
+ WHERE id = (p_pk->>'id')::int
408
+ AND (TRUE);
409
+
410
+ IF v_result IS NULL THEN
411
+ RETURN NULL;
412
+ END IF;
413
+
414
+
415
+ RETURN v_result;
416
+ END;
417
+ $$;
418
+
419
+
420
+ CREATE OR REPLACE FUNCTION dzql_v2.search_organisations(p_user_id int, p_query jsonb)
421
+ RETURNS jsonb
422
+ LANGUAGE plpgsql
423
+ SECURITY DEFINER
424
+ SET search_path = dzql_v2, public
425
+ AS $$
426
+ DECLARE
427
+ v_results jsonb;
428
+ v_filters jsonb;
429
+ v_sort_field text;
430
+ v_sort_order text;
431
+ v_where_clause text := '';
432
+ v_field text;
433
+ v_filter jsonb;
434
+ v_operator text;
435
+ v_value jsonb;
436
+ BEGIN
437
+ -- Extract query parameters
438
+ v_filters := COALESCE(p_query->'filters', '{}'::jsonb);
439
+ v_sort_field := COALESCE(p_query->>'sort_field', 'name');
440
+ v_sort_order := COALESCE(p_query->>'sort_order', 'asc');
441
+
442
+ -- Build WHERE clause from filters
443
+ FOR v_field, v_filter IN SELECT * FROM jsonb_each(v_filters)
444
+ LOOP
445
+ -- Handle simple value (exact match)
446
+ IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
447
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_filter #>> '{}');
448
+ ELSE
449
+ -- Handle operator-based filters
450
+ FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
451
+ LOOP
452
+ CASE v_operator
453
+ WHEN 'eq' THEN
454
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_value #>> '{}');
455
+ WHEN 'ne' THEN
456
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != %L', v_field, v_value #>> '{}');
457
+ WHEN 'gt' THEN
458
+ v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
459
+ WHEN 'gte' THEN
460
+ v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
461
+ WHEN 'lt' THEN
462
+ v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
463
+ WHEN 'lte' THEN
464
+ v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
465
+ WHEN 'in' THEN
466
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = ANY(%L)', v_field,
467
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
468
+ WHEN 'not_in' THEN
469
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != ALL(%L)', v_field,
470
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
471
+ WHEN 'like' THEN
472
+ v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
473
+ WHEN 'ilike' THEN
474
+ v_where_clause := v_where_clause || format(' AND %I ILIKE %L', v_field, v_value #>> '{}');
475
+ WHEN 'is_null' THEN
476
+ IF (v_value::text = 'true') THEN
477
+ v_where_clause := v_where_clause || format(' AND %I IS NULL', v_field);
478
+ END IF;
479
+ WHEN 'not_null' THEN
480
+ IF (v_value::text = 'true') THEN
481
+ v_where_clause := v_where_clause || format(' AND %I IS NOT NULL', v_field);
482
+ END IF;
483
+ ELSE
484
+ -- Unknown operator, skip
485
+ END CASE;
486
+ END LOOP;
487
+ END IF;
488
+ END LOOP;
489
+
490
+ -- Execute dynamic query (sort inside subquery for correct LIMIT behavior)
491
+ EXECUTE format('
492
+ SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
493
+ FROM (
494
+ SELECT * FROM organisations
495
+ WHERE (TRUE) %s
496
+ ORDER BY %I %s
497
+ LIMIT %L OFFSET %L
498
+ ) t
499
+ ', v_where_clause, v_sort_field, v_sort_order,
500
+ COALESCE((p_query->>'limit')::int, 10),
501
+ COALESCE((p_query->>'offset')::int, 0))
502
+ INTO v_results;
503
+
504
+ RETURN v_results;
505
+ END;
506
+ $$;
507
+
508
+
509
+ CREATE TABLE IF NOT EXISTS acts_for (
510
+ user_id int NOT NULL REFERENCES users(id),
511
+ org_id int NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
512
+ valid_from date NOT NULL DEFAULT current_date,
513
+ valid_to date,
514
+ active boolean DEFAULT true
515
+ );
516
+
517
+
518
+ CREATE OR REPLACE FUNCTION dzql_v2.save_acts_for(p_user_id int, p_data jsonb)
519
+ RETURNS jsonb
520
+ LANGUAGE plpgsql
521
+ SECURITY DEFINER
522
+ SET search_path = dzql_v2, public
523
+ AS $$
524
+ DECLARE
525
+ v_result jsonb;
526
+ v_old_data jsonb;
527
+ v_commit_id bigint;
528
+ v_op text;
529
+
530
+ BEGIN
531
+ v_commit_id := nextval('dzql_v2.commit_seq');
532
+
533
+
534
+ -- Determine Operation & Check Permissions (supports composite PK)
535
+ IF ((p_data->>'user_id') IS NOT NULL AND (p_data->>'org_id') IS NOT NULL AND (p_data->>'valid_from') IS NOT NULL) AND EXISTS(SELECT 1 FROM acts_for WHERE user_id = (p_data->>'user_id')::int AND org_id = (p_data->>'org_id')::int AND valid_from = (p_data->>'valid_from')::date) THEN
536
+ v_op := 'update';
537
+
538
+ -- Fetch old data for update rules/events
539
+ SELECT to_jsonb(acts_for.*) INTO v_old_data FROM acts_for WHERE user_id = (p_data->>'user_id')::int AND org_id = (p_data->>'org_id')::int AND valid_from = (p_data->>'valid_from')::date;
540
+
541
+ IF NOT (TRUE) THEN
542
+ RAISE EXCEPTION 'permission_denied';
543
+ END IF;
544
+
545
+ -- Perform Partial Update
546
+ UPDATE acts_for SET
547
+ valid_to = CASE WHEN (p_data ? 'valid_to') THEN (p_data->>'valid_to')::date ELSE valid_to END,
548
+ active = CASE WHEN (p_data ? 'active') THEN (p_data->>'active')::boolean ELSE active END
549
+ WHERE user_id = (p_data->>'user_id')::int AND org_id = (p_data->>'org_id')::int AND valid_from = (p_data->>'valid_from')::date
550
+ RETURNING to_jsonb(acts_for.*) INTO v_result;
551
+
552
+
553
+
554
+ ELSE
555
+ v_op := 'insert';
556
+ IF NOT (TRUE) THEN
557
+ RAISE EXCEPTION 'permission_denied';
558
+ END IF;
559
+
560
+ -- Perform Insert
561
+ INSERT INTO acts_for (user_id, org_id, valid_from, valid_to, active)
562
+ VALUES ((p_data->>'user_id')::int, (p_data->>'org_id')::int, (p_data->>'valid_from')::date, (p_data->>'valid_to')::date, (p_data->>'active')::boolean)
563
+ RETURNING to_jsonb(acts_for.*) INTO v_result;
564
+
565
+
566
+ END IF;
567
+
568
+
569
+
570
+ -- Emit Event
571
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
572
+ VALUES (
573
+ v_commit_id,
574
+ 'acts_for',
575
+ v_op,
576
+ jsonb_build_object('user_id', v_result->'user_id', 'org_id', v_result->'org_id', 'valid_from', v_result->'valid_from'),
577
+ v_result,
578
+ v_old_data, -- NULL for insert
579
+ p_user_id
580
+ );
581
+
582
+ -- Notify Runtime
583
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
584
+
585
+ -- Remove hidden fields before returning to client
586
+ RETURN v_result;
587
+ END;
588
+ $$;
589
+
590
+
591
+ CREATE OR REPLACE FUNCTION dzql_v2.delete_acts_for(p_user_id int, p_pk jsonb)
592
+ RETURNS jsonb
593
+ LANGUAGE plpgsql
594
+ SECURITY DEFINER
595
+ SET search_path = dzql_v2, public
596
+ AS $$
597
+ DECLARE
598
+ v_old_data jsonb;
599
+ v_commit_id bigint;
600
+ BEGIN
601
+ v_commit_id := nextval('dzql_v2.commit_seq');
602
+
603
+ -- Fetch old data FIRST for permission check
604
+ SELECT to_jsonb(acts_for.*) INTO v_old_data FROM acts_for WHERE user_id = (p_pk->>'user_id')::int AND org_id = (p_pk->>'org_id')::int AND valid_from = (p_pk->>'valid_from')::date;
605
+
606
+ IF v_old_data IS NULL THEN
607
+ RAISE EXCEPTION 'not_found';
608
+ END IF;
609
+
610
+ -- Permission Check (Delete)
611
+ IF NOT (TRUE) THEN
612
+ RAISE EXCEPTION 'permission_denied';
613
+ END IF;
614
+
615
+ -- Graph Rules (Pre-delete cascades)
616
+
617
+
618
+ -- Perform Delete
619
+ DELETE FROM acts_for WHERE user_id = (p_pk->>'user_id')::int AND org_id = (p_pk->>'org_id')::int AND valid_from = (p_pk->>'valid_from')::date;
620
+
621
+ -- Emit Event (always 'delete' operation for client-side removal)
622
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
623
+ VALUES (
624
+ v_commit_id,
625
+ 'acts_for',
626
+ 'delete',
627
+ jsonb_build_object('user_id', v_old_data->'user_id', 'org_id', v_old_data->'org_id', 'valid_from', v_old_data->'valid_from'),
628
+ v_old_data, -- Include full data for subscription resolution
629
+ v_old_data,
630
+ p_user_id
631
+ );
632
+
633
+ -- Notify Runtime
634
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
635
+
636
+ -- Remove hidden fields before returning to client
637
+ RETURN v_old_data;
638
+ END;
639
+ $$;
640
+
641
+
642
+ CREATE OR REPLACE FUNCTION dzql_v2.get_acts_for(p_user_id int, p_pk jsonb)
643
+ RETURNS jsonb
644
+ LANGUAGE plpgsql
645
+ SECURITY DEFINER
646
+ SET search_path = dzql_v2, public
647
+ AS $$
648
+ DECLARE
649
+ v_result jsonb;
650
+ BEGIN
651
+ SELECT to_jsonb(acts_for.*) INTO v_result
652
+ FROM acts_for
653
+ WHERE user_id = (p_pk->>'user_id')::int AND org_id = (p_pk->>'org_id')::int AND valid_from = (p_pk->>'valid_from')::date
654
+ AND (TRUE);
655
+
656
+ IF v_result IS NULL THEN
657
+ RETURN NULL;
658
+ END IF;
659
+
660
+
661
+ RETURN v_result;
662
+ END;
663
+ $$;
664
+
665
+
666
+ CREATE OR REPLACE FUNCTION dzql_v2.search_acts_for(p_user_id int, p_query jsonb)
667
+ RETURNS jsonb
668
+ LANGUAGE plpgsql
669
+ SECURITY DEFINER
670
+ SET search_path = dzql_v2, public
671
+ AS $$
672
+ DECLARE
673
+ v_results jsonb;
674
+ v_filters jsonb;
675
+ v_sort_field text;
676
+ v_sort_order text;
677
+ v_where_clause text := '';
678
+ v_field text;
679
+ v_filter jsonb;
680
+ v_operator text;
681
+ v_value jsonb;
682
+ BEGIN
683
+ -- Extract query parameters
684
+ v_filters := COALESCE(p_query->'filters', '{}'::jsonb);
685
+ v_sort_field := COALESCE(p_query->>'sort_field', 'org_id');
686
+ v_sort_order := COALESCE(p_query->>'sort_order', 'asc');
687
+
688
+ -- Build WHERE clause from filters
689
+ FOR v_field, v_filter IN SELECT * FROM jsonb_each(v_filters)
690
+ LOOP
691
+ -- Handle simple value (exact match)
692
+ IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
693
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_filter #>> '{}');
694
+ ELSE
695
+ -- Handle operator-based filters
696
+ FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
697
+ LOOP
698
+ CASE v_operator
699
+ WHEN 'eq' THEN
700
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_value #>> '{}');
701
+ WHEN 'ne' THEN
702
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != %L', v_field, v_value #>> '{}');
703
+ WHEN 'gt' THEN
704
+ v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
705
+ WHEN 'gte' THEN
706
+ v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
707
+ WHEN 'lt' THEN
708
+ v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
709
+ WHEN 'lte' THEN
710
+ v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
711
+ WHEN 'in' THEN
712
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = ANY(%L)', v_field,
713
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
714
+ WHEN 'not_in' THEN
715
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != ALL(%L)', v_field,
716
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
717
+ WHEN 'like' THEN
718
+ v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
719
+ WHEN 'ilike' THEN
720
+ v_where_clause := v_where_clause || format(' AND %I ILIKE %L', v_field, v_value #>> '{}');
721
+ WHEN 'is_null' THEN
722
+ IF (v_value::text = 'true') THEN
723
+ v_where_clause := v_where_clause || format(' AND %I IS NULL', v_field);
724
+ END IF;
725
+ WHEN 'not_null' THEN
726
+ IF (v_value::text = 'true') THEN
727
+ v_where_clause := v_where_clause || format(' AND %I IS NOT NULL', v_field);
728
+ END IF;
729
+ ELSE
730
+ -- Unknown operator, skip
731
+ END CASE;
732
+ END LOOP;
733
+ END IF;
734
+ END LOOP;
735
+
736
+ -- Execute dynamic query (sort inside subquery for correct LIMIT behavior)
737
+ EXECUTE format('
738
+ SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
739
+ FROM (
740
+ SELECT * FROM acts_for
741
+ WHERE (TRUE) %s
742
+ ORDER BY %I %s
743
+ LIMIT %L OFFSET %L
744
+ ) t
745
+ ', v_where_clause, v_sort_field, v_sort_order,
746
+ COALESCE((p_query->>'limit')::int, 10),
747
+ COALESCE((p_query->>'offset')::int, 0))
748
+ INTO v_results;
749
+
750
+ RETURN v_results;
751
+ END;
752
+ $$;
753
+
754
+
755
+ CREATE TABLE IF NOT EXISTS venues (
756
+ id serial PRIMARY KEY,
757
+ org_id int NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
758
+ name text UNIQUE NOT NULL,
759
+ address text NOT NULL,
760
+ description text
761
+ );
762
+
763
+
764
+ CREATE OR REPLACE FUNCTION dzql_v2.save_venues(p_user_id int, p_data jsonb)
765
+ RETURNS jsonb
766
+ LANGUAGE plpgsql
767
+ SECURITY DEFINER
768
+ SET search_path = dzql_v2, public
769
+ AS $$
770
+ DECLARE
771
+ v_result jsonb;
772
+ v_old_data jsonb;
773
+ v_commit_id bigint;
774
+ v_op text;
775
+
776
+ BEGIN
777
+ v_commit_id := nextval('dzql_v2.commit_seq');
778
+
779
+
780
+ -- Determine Operation & Check Permissions (supports composite PK)
781
+ IF ((p_data->>'id') IS NOT NULL) AND EXISTS(SELECT 1 FROM venues WHERE id = (p_data->>'id')::int) THEN
782
+ v_op := 'update';
783
+
784
+ -- Fetch old data for update rules/events
785
+ SELECT to_jsonb(venues.*) INTO v_old_data FROM venues WHERE id = (p_data->>'id')::int;
786
+
787
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (p_data->>'org_id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
788
+ RAISE EXCEPTION 'permission_denied';
789
+ END IF;
790
+
791
+ -- Perform Partial Update
792
+ UPDATE venues SET
793
+ org_id = CASE WHEN (p_data ? 'org_id') THEN (p_data->>'org_id')::int ELSE org_id END,
794
+ name = CASE WHEN (p_data ? 'name') THEN (p_data->>'name') ELSE name END,
795
+ address = CASE WHEN (p_data ? 'address') THEN (p_data->>'address') ELSE address END,
796
+ description = CASE WHEN (p_data ? 'description') THEN (p_data->>'description') ELSE description END
797
+ WHERE id = (p_data->>'id')::int
798
+ RETURNING to_jsonb(venues.*) INTO v_result;
799
+
800
+
801
+
802
+ ELSE
803
+ v_op := 'insert';
804
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (p_data->>'org_id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
805
+ RAISE EXCEPTION 'permission_denied';
806
+ END IF;
807
+
808
+ -- Perform Insert
809
+ INSERT INTO venues (org_id, name, address, description)
810
+ VALUES ((p_data->>'org_id')::int, (p_data->>'name'), (p_data->>'address'), (p_data->>'description'))
811
+ RETURNING to_jsonb(venues.*) INTO v_result;
812
+
813
+
814
+ END IF;
815
+
816
+
817
+
818
+ -- Emit Event
819
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
820
+ VALUES (
821
+ v_commit_id,
822
+ 'venues',
823
+ v_op,
824
+ jsonb_build_object('id', v_result->'id'),
825
+ v_result,
826
+ v_old_data, -- NULL for insert
827
+ p_user_id
828
+ );
829
+
830
+ -- Notify Runtime
831
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
832
+
833
+ -- Remove hidden fields before returning to client
834
+ RETURN v_result;
835
+ END;
836
+ $$;
837
+
838
+
839
+ CREATE OR REPLACE FUNCTION dzql_v2.delete_venues(p_user_id int, p_pk jsonb)
840
+ RETURNS jsonb
841
+ LANGUAGE plpgsql
842
+ SECURITY DEFINER
843
+ SET search_path = dzql_v2, public
844
+ AS $$
845
+ DECLARE
846
+ v_old_data jsonb;
847
+ v_commit_id bigint;
848
+ BEGIN
849
+ v_commit_id := nextval('dzql_v2.commit_seq');
850
+
851
+ -- Fetch old data FIRST for permission check
852
+ SELECT to_jsonb(venues.*) INTO v_old_data FROM venues WHERE id = (p_pk->>'id')::int;
853
+
854
+ IF v_old_data IS NULL THEN
855
+ RAISE EXCEPTION 'not_found';
856
+ END IF;
857
+
858
+ -- Permission Check (Delete)
859
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (v_old_data->>'org_id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
860
+ RAISE EXCEPTION 'permission_denied';
861
+ END IF;
862
+
863
+ -- Graph Rules (Pre-delete cascades)
864
+
865
+
866
+ -- Perform Delete
867
+ DELETE FROM venues WHERE id = (p_pk->>'id')::int;
868
+
869
+ -- Emit Event (always 'delete' operation for client-side removal)
870
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
871
+ VALUES (
872
+ v_commit_id,
873
+ 'venues',
874
+ 'delete',
875
+ jsonb_build_object('id', v_old_data->'id'),
876
+ v_old_data, -- Include full data for subscription resolution
877
+ v_old_data,
878
+ p_user_id
879
+ );
880
+
881
+ -- Notify Runtime
882
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
883
+
884
+ -- Remove hidden fields before returning to client
885
+ RETURN v_old_data;
886
+ END;
887
+ $$;
888
+
889
+
890
+ CREATE OR REPLACE FUNCTION dzql_v2.get_venues(p_user_id int, p_pk jsonb)
891
+ RETURNS jsonb
892
+ LANGUAGE plpgsql
893
+ SECURITY DEFINER
894
+ SET search_path = dzql_v2, public
895
+ AS $$
896
+ DECLARE
897
+ v_result jsonb;
898
+ BEGIN
899
+ SELECT to_jsonb(venues.*) INTO v_result
900
+ FROM venues
901
+ WHERE id = (p_pk->>'id')::int
902
+ AND (TRUE);
903
+
904
+ IF v_result IS NULL THEN
905
+ RETURN NULL;
906
+ END IF;
907
+
908
+
909
+ RETURN v_result;
910
+ END;
911
+ $$;
912
+
913
+
914
+ CREATE OR REPLACE FUNCTION dzql_v2.search_venues(p_user_id int, p_query jsonb)
915
+ RETURNS jsonb
916
+ LANGUAGE plpgsql
917
+ SECURITY DEFINER
918
+ SET search_path = dzql_v2, public
919
+ AS $$
920
+ DECLARE
921
+ v_results jsonb;
922
+ v_filters jsonb;
923
+ v_sort_field text;
924
+ v_sort_order text;
925
+ v_where_clause text := '';
926
+ v_field text;
927
+ v_filter jsonb;
928
+ v_operator text;
929
+ v_value jsonb;
930
+ BEGIN
931
+ -- Extract query parameters
932
+ v_filters := COALESCE(p_query->'filters', '{}'::jsonb);
933
+ v_sort_field := COALESCE(p_query->>'sort_field', 'name');
934
+ v_sort_order := COALESCE(p_query->>'sort_order', 'asc');
935
+
936
+ -- Build WHERE clause from filters
937
+ FOR v_field, v_filter IN SELECT * FROM jsonb_each(v_filters)
938
+ LOOP
939
+ -- Handle simple value (exact match)
940
+ IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
941
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_filter #>> '{}');
942
+ ELSE
943
+ -- Handle operator-based filters
944
+ FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
945
+ LOOP
946
+ CASE v_operator
947
+ WHEN 'eq' THEN
948
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_value #>> '{}');
949
+ WHEN 'ne' THEN
950
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != %L', v_field, v_value #>> '{}');
951
+ WHEN 'gt' THEN
952
+ v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
953
+ WHEN 'gte' THEN
954
+ v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
955
+ WHEN 'lt' THEN
956
+ v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
957
+ WHEN 'lte' THEN
958
+ v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
959
+ WHEN 'in' THEN
960
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = ANY(%L)', v_field,
961
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
962
+ WHEN 'not_in' THEN
963
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != ALL(%L)', v_field,
964
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
965
+ WHEN 'like' THEN
966
+ v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
967
+ WHEN 'ilike' THEN
968
+ v_where_clause := v_where_clause || format(' AND %I ILIKE %L', v_field, v_value #>> '{}');
969
+ WHEN 'is_null' THEN
970
+ IF (v_value::text = 'true') THEN
971
+ v_where_clause := v_where_clause || format(' AND %I IS NULL', v_field);
972
+ END IF;
973
+ WHEN 'not_null' THEN
974
+ IF (v_value::text = 'true') THEN
975
+ v_where_clause := v_where_clause || format(' AND %I IS NOT NULL', v_field);
976
+ END IF;
977
+ ELSE
978
+ -- Unknown operator, skip
979
+ END CASE;
980
+ END LOOP;
981
+ END IF;
982
+ END LOOP;
983
+
984
+ -- Execute dynamic query (sort inside subquery for correct LIMIT behavior)
985
+ EXECUTE format('
986
+ SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
987
+ FROM (
988
+ SELECT * FROM venues
989
+ WHERE (TRUE) %s
990
+ ORDER BY %I %s
991
+ LIMIT %L OFFSET %L
992
+ ) t
993
+ ', v_where_clause, v_sort_field, v_sort_order,
994
+ COALESCE((p_query->>'limit')::int, 10),
995
+ COALESCE((p_query->>'offset')::int, 0))
996
+ INTO v_results;
997
+
998
+ RETURN v_results;
999
+ END;
1000
+ $$;
1001
+
1002
+
1003
+ CREATE TABLE IF NOT EXISTS sites (
1004
+ id serial PRIMARY KEY,
1005
+ venue_id int NOT NULL REFERENCES venues(id),
1006
+ name text NOT NULL,
1007
+ description text
1008
+ );
1009
+
1010
+
1011
+ CREATE OR REPLACE FUNCTION dzql_v2.save_sites(p_user_id int, p_data jsonb)
1012
+ RETURNS jsonb
1013
+ LANGUAGE plpgsql
1014
+ SECURITY DEFINER
1015
+ SET search_path = dzql_v2, public
1016
+ AS $$
1017
+ DECLARE
1018
+ v_result jsonb;
1019
+ v_old_data jsonb;
1020
+ v_commit_id bigint;
1021
+ v_op text;
1022
+
1023
+ BEGIN
1024
+ v_commit_id := nextval('dzql_v2.commit_seq');
1025
+
1026
+
1027
+ -- Determine Operation & Check Permissions (supports composite PK)
1028
+ IF ((p_data->>'id') IS NOT NULL) AND EXISTS(SELECT 1 FROM sites WHERE id = (p_data->>'id')::int) THEN
1029
+ v_op := 'update';
1030
+
1031
+ -- Fetch old data for update rules/events
1032
+ SELECT to_jsonb(sites.*) INTO v_old_data FROM sites WHERE id = (p_data->>'id')::int;
1033
+
1034
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT org_id FROM venues WHERE id = (p_data->>'venue_id')::int) AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
1035
+ RAISE EXCEPTION 'permission_denied';
1036
+ END IF;
1037
+
1038
+ -- Perform Partial Update
1039
+ UPDATE sites SET
1040
+ venue_id = CASE WHEN (p_data ? 'venue_id') THEN (p_data->>'venue_id')::int ELSE venue_id END,
1041
+ name = CASE WHEN (p_data ? 'name') THEN (p_data->>'name') ELSE name END,
1042
+ description = CASE WHEN (p_data ? 'description') THEN (p_data->>'description') ELSE description END
1043
+ WHERE id = (p_data->>'id')::int
1044
+ RETURNING to_jsonb(sites.*) INTO v_result;
1045
+
1046
+
1047
+
1048
+ ELSE
1049
+ v_op := 'insert';
1050
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT org_id FROM venues WHERE id = (p_data->>'venue_id')::int) AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
1051
+ RAISE EXCEPTION 'permission_denied';
1052
+ END IF;
1053
+
1054
+ -- Perform Insert
1055
+ INSERT INTO sites (venue_id, name, description)
1056
+ VALUES ((p_data->>'venue_id')::int, (p_data->>'name'), (p_data->>'description'))
1057
+ RETURNING to_jsonb(sites.*) INTO v_result;
1058
+
1059
+
1060
+ END IF;
1061
+
1062
+
1063
+
1064
+ -- Emit Event
1065
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
1066
+ VALUES (
1067
+ v_commit_id,
1068
+ 'sites',
1069
+ v_op,
1070
+ jsonb_build_object('id', v_result->'id'),
1071
+ v_result,
1072
+ v_old_data, -- NULL for insert
1073
+ p_user_id
1074
+ );
1075
+
1076
+ -- Notify Runtime
1077
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
1078
+
1079
+ -- Remove hidden fields before returning to client
1080
+ RETURN v_result;
1081
+ END;
1082
+ $$;
1083
+
1084
+
1085
+ CREATE OR REPLACE FUNCTION dzql_v2.delete_sites(p_user_id int, p_pk jsonb)
1086
+ RETURNS jsonb
1087
+ LANGUAGE plpgsql
1088
+ SECURITY DEFINER
1089
+ SET search_path = dzql_v2, public
1090
+ AS $$
1091
+ DECLARE
1092
+ v_old_data jsonb;
1093
+ v_commit_id bigint;
1094
+ BEGIN
1095
+ v_commit_id := nextval('dzql_v2.commit_seq');
1096
+
1097
+ -- Fetch old data FIRST for permission check
1098
+ SELECT to_jsonb(sites.*) INTO v_old_data FROM sites WHERE id = (p_pk->>'id')::int;
1099
+
1100
+ IF v_old_data IS NULL THEN
1101
+ RAISE EXCEPTION 'not_found';
1102
+ END IF;
1103
+
1104
+ -- Permission Check (Delete)
1105
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT org_id FROM venues WHERE id = (v_old_data->>'venue_id')::int) AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
1106
+ RAISE EXCEPTION 'permission_denied';
1107
+ END IF;
1108
+
1109
+ -- Graph Rules (Pre-delete cascades)
1110
+
1111
+
1112
+ -- Perform Delete
1113
+ DELETE FROM sites WHERE id = (p_pk->>'id')::int;
1114
+
1115
+ -- Emit Event (always 'delete' operation for client-side removal)
1116
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
1117
+ VALUES (
1118
+ v_commit_id,
1119
+ 'sites',
1120
+ 'delete',
1121
+ jsonb_build_object('id', v_old_data->'id'),
1122
+ v_old_data, -- Include full data for subscription resolution
1123
+ v_old_data,
1124
+ p_user_id
1125
+ );
1126
+
1127
+ -- Notify Runtime
1128
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
1129
+
1130
+ -- Remove hidden fields before returning to client
1131
+ RETURN v_old_data;
1132
+ END;
1133
+ $$;
1134
+
1135
+
1136
+ CREATE OR REPLACE FUNCTION dzql_v2.get_sites(p_user_id int, p_pk jsonb)
1137
+ RETURNS jsonb
1138
+ LANGUAGE plpgsql
1139
+ SECURITY DEFINER
1140
+ SET search_path = dzql_v2, public
1141
+ AS $$
1142
+ DECLARE
1143
+ v_result jsonb;
1144
+ BEGIN
1145
+ SELECT to_jsonb(sites.*) INTO v_result
1146
+ FROM sites
1147
+ WHERE id = (p_pk->>'id')::int
1148
+ AND (TRUE);
1149
+
1150
+ IF v_result IS NULL THEN
1151
+ RETURN NULL;
1152
+ END IF;
1153
+
1154
+
1155
+ RETURN v_result;
1156
+ END;
1157
+ $$;
1158
+
1159
+
1160
+ CREATE OR REPLACE FUNCTION dzql_v2.search_sites(p_user_id int, p_query jsonb)
1161
+ RETURNS jsonb
1162
+ LANGUAGE plpgsql
1163
+ SECURITY DEFINER
1164
+ SET search_path = dzql_v2, public
1165
+ AS $$
1166
+ DECLARE
1167
+ v_results jsonb;
1168
+ v_filters jsonb;
1169
+ v_sort_field text;
1170
+ v_sort_order text;
1171
+ v_where_clause text := '';
1172
+ v_field text;
1173
+ v_filter jsonb;
1174
+ v_operator text;
1175
+ v_value jsonb;
1176
+ BEGIN
1177
+ -- Extract query parameters
1178
+ v_filters := COALESCE(p_query->'filters', '{}'::jsonb);
1179
+ v_sort_field := COALESCE(p_query->>'sort_field', 'name');
1180
+ v_sort_order := COALESCE(p_query->>'sort_order', 'asc');
1181
+
1182
+ -- Build WHERE clause from filters
1183
+ FOR v_field, v_filter IN SELECT * FROM jsonb_each(v_filters)
1184
+ LOOP
1185
+ -- Handle simple value (exact match)
1186
+ IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
1187
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_filter #>> '{}');
1188
+ ELSE
1189
+ -- Handle operator-based filters
1190
+ FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
1191
+ LOOP
1192
+ CASE v_operator
1193
+ WHEN 'eq' THEN
1194
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_value #>> '{}');
1195
+ WHEN 'ne' THEN
1196
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != %L', v_field, v_value #>> '{}');
1197
+ WHEN 'gt' THEN
1198
+ v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
1199
+ WHEN 'gte' THEN
1200
+ v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
1201
+ WHEN 'lt' THEN
1202
+ v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
1203
+ WHEN 'lte' THEN
1204
+ v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
1205
+ WHEN 'in' THEN
1206
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = ANY(%L)', v_field,
1207
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
1208
+ WHEN 'not_in' THEN
1209
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != ALL(%L)', v_field,
1210
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
1211
+ WHEN 'like' THEN
1212
+ v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
1213
+ WHEN 'ilike' THEN
1214
+ v_where_clause := v_where_clause || format(' AND %I ILIKE %L', v_field, v_value #>> '{}');
1215
+ WHEN 'is_null' THEN
1216
+ IF (v_value::text = 'true') THEN
1217
+ v_where_clause := v_where_clause || format(' AND %I IS NULL', v_field);
1218
+ END IF;
1219
+ WHEN 'not_null' THEN
1220
+ IF (v_value::text = 'true') THEN
1221
+ v_where_clause := v_where_clause || format(' AND %I IS NOT NULL', v_field);
1222
+ END IF;
1223
+ ELSE
1224
+ -- Unknown operator, skip
1225
+ END CASE;
1226
+ END LOOP;
1227
+ END IF;
1228
+ END LOOP;
1229
+
1230
+ -- Execute dynamic query (sort inside subquery for correct LIMIT behavior)
1231
+ EXECUTE format('
1232
+ SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
1233
+ FROM (
1234
+ SELECT * FROM sites
1235
+ WHERE (TRUE) %s
1236
+ ORDER BY %I %s
1237
+ LIMIT %L OFFSET %L
1238
+ ) t
1239
+ ', v_where_clause, v_sort_field, v_sort_order,
1240
+ COALESCE((p_query->>'limit')::int, 10),
1241
+ COALESCE((p_query->>'offset')::int, 0))
1242
+ INTO v_results;
1243
+
1244
+ RETURN v_results;
1245
+ END;
1246
+ $$;
1247
+
1248
+
1249
+ CREATE TABLE IF NOT EXISTS products (
1250
+ id serial PRIMARY KEY,
1251
+ org_id int NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
1252
+ name text UNIQUE NOT NULL,
1253
+ description text,
1254
+ price decimal(10, 2) NOT NULL DEFAULT 0.00,
1255
+ created_by int REFERENCES users(id),
1256
+ created_at timestamptz,
1257
+ deleted_at timestamptz
1258
+ );
1259
+
1260
+
1261
+ CREATE OR REPLACE FUNCTION dzql_v2.save_products(p_user_id int, p_data jsonb)
1262
+ RETURNS jsonb
1263
+ LANGUAGE plpgsql
1264
+ SECURITY DEFINER
1265
+ SET search_path = dzql_v2, public
1266
+ AS $$
1267
+ DECLARE
1268
+ v_result jsonb;
1269
+ v_old_data jsonb;
1270
+ v_commit_id bigint;
1271
+ v_op text;
1272
+
1273
+ BEGIN
1274
+ v_commit_id := nextval('dzql_v2.commit_seq');
1275
+
1276
+
1277
+ -- Determine Operation & Check Permissions (supports composite PK)
1278
+ IF ((p_data->>'id') IS NOT NULL) AND EXISTS(SELECT 1 FROM products WHERE id = (p_data->>'id')::int) THEN
1279
+ v_op := 'update';
1280
+
1281
+ -- Fetch old data for update rules/events
1282
+ SELECT to_jsonb(products.*) INTO v_old_data FROM products WHERE id = (p_data->>'id')::int;
1283
+
1284
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (p_data->>'org_id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
1285
+ RAISE EXCEPTION 'permission_denied';
1286
+ END IF;
1287
+
1288
+ -- Perform Partial Update
1289
+ UPDATE products SET
1290
+ org_id = CASE WHEN (p_data ? 'org_id') THEN (p_data->>'org_id')::int ELSE org_id END,
1291
+ name = CASE WHEN (p_data ? 'name') THEN (p_data->>'name') ELSE name END,
1292
+ description = CASE WHEN (p_data ? 'description') THEN (p_data->>'description') ELSE description END,
1293
+ price = CASE WHEN (p_data ? 'price') THEN (p_data->>'price')::numeric ELSE price END,
1294
+ created_by = CASE WHEN (p_data ? 'created_by') THEN (p_data->>'created_by')::int ELSE created_by END,
1295
+ created_at = CASE WHEN (p_data ? 'created_at') THEN (p_data->>'created_at')::timestamptz ELSE created_at END,
1296
+ deleted_at = CASE WHEN (p_data ? 'deleted_at') THEN (p_data->>'deleted_at')::timestamptz ELSE deleted_at END
1297
+ WHERE id = (p_data->>'id')::int
1298
+ RETURNING to_jsonb(products.*) INTO v_result;
1299
+
1300
+
1301
+
1302
+ ELSE
1303
+ v_op := 'insert';
1304
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (p_data->>'org_id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
1305
+ RAISE EXCEPTION 'permission_denied';
1306
+ END IF;
1307
+
1308
+ -- Perform Insert
1309
+ INSERT INTO products (org_id, name, description, price, created_by, created_at, deleted_at)
1310
+ VALUES ((p_data->>'org_id')::int, (p_data->>'name'), (p_data->>'description'), (p_data->>'price')::numeric, COALESCE((p_data->>'created_by')::int, p_user_id), COALESCE((p_data->>'created_at')::timestamptz, now()), (p_data->>'deleted_at')::timestamptz)
1311
+ RETURNING to_jsonb(products.*) INTO v_result;
1312
+
1313
+
1314
+ END IF;
1315
+
1316
+
1317
+
1318
+ -- Emit Event
1319
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
1320
+ VALUES (
1321
+ v_commit_id,
1322
+ 'products',
1323
+ v_op,
1324
+ jsonb_build_object('id', v_result->'id'),
1325
+ v_result,
1326
+ v_old_data, -- NULL for insert
1327
+ p_user_id
1328
+ );
1329
+
1330
+ -- Notify Runtime
1331
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
1332
+
1333
+ -- Remove hidden fields before returning to client
1334
+ RETURN v_result;
1335
+ END;
1336
+ $$;
1337
+
1338
+
1339
+ CREATE OR REPLACE FUNCTION dzql_v2.delete_products(p_user_id int, p_pk jsonb)
1340
+ RETURNS jsonb
1341
+ LANGUAGE plpgsql
1342
+ SECURITY DEFINER
1343
+ SET search_path = dzql_v2, public
1344
+ AS $$
1345
+ DECLARE
1346
+ v_old_data jsonb;
1347
+ v_commit_id bigint;
1348
+ BEGIN
1349
+ v_commit_id := nextval('dzql_v2.commit_seq');
1350
+
1351
+ -- Fetch old data FIRST for permission check
1352
+ SELECT to_jsonb(products.*) INTO v_old_data FROM products WHERE id = (p_pk->>'id')::int;
1353
+
1354
+ IF v_old_data IS NULL THEN
1355
+ RAISE EXCEPTION 'not_found';
1356
+ END IF;
1357
+
1358
+ -- Permission Check (Delete)
1359
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (v_old_data->>'org_id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
1360
+ RAISE EXCEPTION 'permission_denied';
1361
+ END IF;
1362
+
1363
+ -- Graph Rules (Pre-delete cascades)
1364
+
1365
+
1366
+ -- Perform Soft Delete
1367
+ UPDATE products SET deleted_at = now() WHERE id = (p_pk->>'id')::int;
1368
+
1369
+ -- Emit Event (always 'delete' operation for client-side removal)
1370
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
1371
+ VALUES (
1372
+ v_commit_id,
1373
+ 'products',
1374
+ 'delete',
1375
+ jsonb_build_object('id', v_old_data->'id'),
1376
+ v_old_data, -- Include full data for subscription resolution
1377
+ v_old_data,
1378
+ p_user_id
1379
+ );
1380
+
1381
+ -- Notify Runtime
1382
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
1383
+
1384
+ -- Remove hidden fields before returning to client
1385
+ RETURN v_old_data;
1386
+ END;
1387
+ $$;
1388
+
1389
+
1390
+ CREATE OR REPLACE FUNCTION dzql_v2.get_products(p_user_id int, p_pk jsonb)
1391
+ RETURNS jsonb
1392
+ LANGUAGE plpgsql
1393
+ SECURITY DEFINER
1394
+ SET search_path = dzql_v2, public
1395
+ AS $$
1396
+ DECLARE
1397
+ v_result jsonb;
1398
+ BEGIN
1399
+ SELECT to_jsonb(products.*) INTO v_result
1400
+ FROM products
1401
+ WHERE id = (p_pk->>'id')::int
1402
+ AND (TRUE);
1403
+
1404
+ IF v_result IS NULL THEN
1405
+ RETURN NULL;
1406
+ END IF;
1407
+
1408
+
1409
+ RETURN v_result;
1410
+ END;
1411
+ $$;
1412
+
1413
+
1414
+ CREATE OR REPLACE FUNCTION dzql_v2.search_products(p_user_id int, p_query jsonb)
1415
+ RETURNS jsonb
1416
+ LANGUAGE plpgsql
1417
+ SECURITY DEFINER
1418
+ SET search_path = dzql_v2, public
1419
+ AS $$
1420
+ DECLARE
1421
+ v_results jsonb;
1422
+ v_filters jsonb;
1423
+ v_sort_field text;
1424
+ v_sort_order text;
1425
+ v_where_clause text := '';
1426
+ v_field text;
1427
+ v_filter jsonb;
1428
+ v_operator text;
1429
+ v_value jsonb;
1430
+ BEGIN
1431
+ -- Extract query parameters
1432
+ v_filters := COALESCE(p_query->'filters', '{}'::jsonb);
1433
+ v_sort_field := COALESCE(p_query->>'sort_field', 'name');
1434
+ v_sort_order := COALESCE(p_query->>'sort_order', 'asc');
1435
+
1436
+ -- Build WHERE clause from filters
1437
+ FOR v_field, v_filter IN SELECT * FROM jsonb_each(v_filters)
1438
+ LOOP
1439
+ -- Handle simple value (exact match)
1440
+ IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
1441
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_filter #>> '{}');
1442
+ ELSE
1443
+ -- Handle operator-based filters
1444
+ FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
1445
+ LOOP
1446
+ CASE v_operator
1447
+ WHEN 'eq' THEN
1448
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_value #>> '{}');
1449
+ WHEN 'ne' THEN
1450
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != %L', v_field, v_value #>> '{}');
1451
+ WHEN 'gt' THEN
1452
+ v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
1453
+ WHEN 'gte' THEN
1454
+ v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
1455
+ WHEN 'lt' THEN
1456
+ v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
1457
+ WHEN 'lte' THEN
1458
+ v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
1459
+ WHEN 'in' THEN
1460
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = ANY(%L)', v_field,
1461
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
1462
+ WHEN 'not_in' THEN
1463
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != ALL(%L)', v_field,
1464
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
1465
+ WHEN 'like' THEN
1466
+ v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
1467
+ WHEN 'ilike' THEN
1468
+ v_where_clause := v_where_clause || format(' AND %I ILIKE %L', v_field, v_value #>> '{}');
1469
+ WHEN 'is_null' THEN
1470
+ IF (v_value::text = 'true') THEN
1471
+ v_where_clause := v_where_clause || format(' AND %I IS NULL', v_field);
1472
+ END IF;
1473
+ WHEN 'not_null' THEN
1474
+ IF (v_value::text = 'true') THEN
1475
+ v_where_clause := v_where_clause || format(' AND %I IS NOT NULL', v_field);
1476
+ END IF;
1477
+ ELSE
1478
+ -- Unknown operator, skip
1479
+ END CASE;
1480
+ END LOOP;
1481
+ END IF;
1482
+ END LOOP;
1483
+
1484
+ -- Execute dynamic query (sort inside subquery for correct LIMIT behavior)
1485
+ EXECUTE format('
1486
+ SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
1487
+ FROM (
1488
+ SELECT * FROM products
1489
+ WHERE (TRUE) AND deleted_at IS NULL %s
1490
+ ORDER BY %I %s
1491
+ LIMIT %L OFFSET %L
1492
+ ) t
1493
+ ', v_where_clause, v_sort_field, v_sort_order,
1494
+ COALESCE((p_query->>'limit')::int, 10),
1495
+ COALESCE((p_query->>'offset')::int, 0))
1496
+ INTO v_results;
1497
+
1498
+ RETURN v_results;
1499
+ END;
1500
+ $$;
1501
+
1502
+
1503
+ CREATE TABLE IF NOT EXISTS packages (
1504
+ id serial PRIMARY KEY,
1505
+ owner_org_id int NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
1506
+ sponsor_org_id int REFERENCES organisations(id) ON DELETE SET NULL,
1507
+ name text NOT NULL,
1508
+ price decimal(10, 2) NOT NULL DEFAULT 0.00,
1509
+ status text NOT NULL DEFAULT 'draft'
1510
+ );
1511
+
1512
+
1513
+ CREATE OR REPLACE FUNCTION dzql_v2.save_packages(p_user_id int, p_data jsonb)
1514
+ RETURNS jsonb
1515
+ LANGUAGE plpgsql
1516
+ SECURITY DEFINER
1517
+ SET search_path = dzql_v2, public
1518
+ AS $$
1519
+ DECLARE
1520
+ v_result jsonb;
1521
+ v_old_data jsonb;
1522
+ v_commit_id bigint;
1523
+ v_op text;
1524
+
1525
+ BEGIN
1526
+ v_commit_id := nextval('dzql_v2.commit_seq');
1527
+
1528
+
1529
+ -- Determine Operation & Check Permissions (supports composite PK)
1530
+ IF ((p_data->>'id') IS NOT NULL) AND EXISTS(SELECT 1 FROM packages WHERE id = (p_data->>'id')::int) THEN
1531
+ v_op := 'update';
1532
+
1533
+ -- Fetch old data for update rules/events
1534
+ SELECT to_jsonb(packages.*) INTO v_old_data FROM packages WHERE id = (p_data->>'id')::int;
1535
+
1536
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (p_data->>'owner_org_id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
1537
+ RAISE EXCEPTION 'permission_denied';
1538
+ END IF;
1539
+
1540
+ -- Perform Partial Update
1541
+ UPDATE packages SET
1542
+ owner_org_id = CASE WHEN (p_data ? 'owner_org_id') THEN (p_data->>'owner_org_id')::int ELSE owner_org_id END,
1543
+ sponsor_org_id = CASE WHEN (p_data ? 'sponsor_org_id') THEN (p_data->>'sponsor_org_id')::int ELSE sponsor_org_id END,
1544
+ name = CASE WHEN (p_data ? 'name') THEN (p_data->>'name') ELSE name END,
1545
+ price = CASE WHEN (p_data ? 'price') THEN (p_data->>'price')::numeric ELSE price END,
1546
+ status = CASE WHEN (p_data ? 'status') THEN (p_data->>'status') ELSE status END
1547
+ WHERE id = (p_data->>'id')::int
1548
+ RETURNING to_jsonb(packages.*) INTO v_result;
1549
+
1550
+
1551
+
1552
+ ELSE
1553
+ v_op := 'insert';
1554
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (p_data->>'owner_org_id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
1555
+ RAISE EXCEPTION 'permission_denied';
1556
+ END IF;
1557
+
1558
+ -- Perform Insert
1559
+ INSERT INTO packages (owner_org_id, sponsor_org_id, name, price, status)
1560
+ VALUES ((p_data->>'owner_org_id')::int, (p_data->>'sponsor_org_id')::int, (p_data->>'name'), (p_data->>'price')::numeric, (p_data->>'status'))
1561
+ RETURNING to_jsonb(packages.*) INTO v_result;
1562
+
1563
+
1564
+ END IF;
1565
+
1566
+
1567
+
1568
+ -- Emit Event
1569
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
1570
+ VALUES (
1571
+ v_commit_id,
1572
+ 'packages',
1573
+ v_op,
1574
+ jsonb_build_object('id', v_result->'id'),
1575
+ v_result,
1576
+ v_old_data, -- NULL for insert
1577
+ p_user_id
1578
+ );
1579
+
1580
+ -- Notify Runtime
1581
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
1582
+
1583
+ -- Remove hidden fields before returning to client
1584
+ RETURN v_result;
1585
+ END;
1586
+ $$;
1587
+
1588
+
1589
+ CREATE OR REPLACE FUNCTION dzql_v2.delete_packages(p_user_id int, p_pk jsonb)
1590
+ RETURNS jsonb
1591
+ LANGUAGE plpgsql
1592
+ SECURITY DEFINER
1593
+ SET search_path = dzql_v2, public
1594
+ AS $$
1595
+ DECLARE
1596
+ v_old_data jsonb;
1597
+ v_commit_id bigint;
1598
+ BEGIN
1599
+ v_commit_id := nextval('dzql_v2.commit_seq');
1600
+
1601
+ -- Fetch old data FIRST for permission check
1602
+ SELECT to_jsonb(packages.*) INTO v_old_data FROM packages WHERE id = (p_pk->>'id')::int;
1603
+
1604
+ IF v_old_data IS NULL THEN
1605
+ RAISE EXCEPTION 'not_found';
1606
+ END IF;
1607
+
1608
+ -- Permission Check (Delete)
1609
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (v_old_data->>'owner_org_id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
1610
+ RAISE EXCEPTION 'permission_denied';
1611
+ END IF;
1612
+
1613
+ -- Graph Rules (Pre-delete cascades)
1614
+
1615
+
1616
+ -- Perform Delete
1617
+ DELETE FROM packages WHERE id = (p_pk->>'id')::int;
1618
+
1619
+ -- Emit Event (always 'delete' operation for client-side removal)
1620
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
1621
+ VALUES (
1622
+ v_commit_id,
1623
+ 'packages',
1624
+ 'delete',
1625
+ jsonb_build_object('id', v_old_data->'id'),
1626
+ v_old_data, -- Include full data for subscription resolution
1627
+ v_old_data,
1628
+ p_user_id
1629
+ );
1630
+
1631
+ -- Notify Runtime
1632
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
1633
+
1634
+ -- Remove hidden fields before returning to client
1635
+ RETURN v_old_data;
1636
+ END;
1637
+ $$;
1638
+
1639
+
1640
+ CREATE OR REPLACE FUNCTION dzql_v2.get_packages(p_user_id int, p_pk jsonb)
1641
+ RETURNS jsonb
1642
+ LANGUAGE plpgsql
1643
+ SECURITY DEFINER
1644
+ SET search_path = dzql_v2, public
1645
+ AS $$
1646
+ DECLARE
1647
+ v_result jsonb;
1648
+ BEGIN
1649
+ SELECT to_jsonb(packages.*) INTO v_result
1650
+ FROM packages
1651
+ WHERE id = (p_pk->>'id')::int
1652
+ AND (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = packages.owner_org_id AND acts_for.active = true AND acts_for.user_id = p_user_id) OR EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = packages.sponsor_org_id AND acts_for.active = true AND acts_for.user_id = p_user_id));
1653
+
1654
+ IF v_result IS NULL THEN
1655
+ RETURN NULL;
1656
+ END IF;
1657
+
1658
+
1659
+ RETURN v_result;
1660
+ END;
1661
+ $$;
1662
+
1663
+
1664
+ CREATE OR REPLACE FUNCTION dzql_v2.search_packages(p_user_id int, p_query jsonb)
1665
+ RETURNS jsonb
1666
+ LANGUAGE plpgsql
1667
+ SECURITY DEFINER
1668
+ SET search_path = dzql_v2, public
1669
+ AS $$
1670
+ DECLARE
1671
+ v_results jsonb;
1672
+ v_filters jsonb;
1673
+ v_sort_field text;
1674
+ v_sort_order text;
1675
+ v_where_clause text := '';
1676
+ v_field text;
1677
+ v_filter jsonb;
1678
+ v_operator text;
1679
+ v_value jsonb;
1680
+ BEGIN
1681
+ -- Extract query parameters
1682
+ v_filters := COALESCE(p_query->'filters', '{}'::jsonb);
1683
+ v_sort_field := COALESCE(p_query->>'sort_field', 'name');
1684
+ v_sort_order := COALESCE(p_query->>'sort_order', 'asc');
1685
+
1686
+ -- Build WHERE clause from filters
1687
+ FOR v_field, v_filter IN SELECT * FROM jsonb_each(v_filters)
1688
+ LOOP
1689
+ -- Handle simple value (exact match)
1690
+ IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
1691
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_filter #>> '{}');
1692
+ ELSE
1693
+ -- Handle operator-based filters
1694
+ FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
1695
+ LOOP
1696
+ CASE v_operator
1697
+ WHEN 'eq' THEN
1698
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_value #>> '{}');
1699
+ WHEN 'ne' THEN
1700
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != %L', v_field, v_value #>> '{}');
1701
+ WHEN 'gt' THEN
1702
+ v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
1703
+ WHEN 'gte' THEN
1704
+ v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
1705
+ WHEN 'lt' THEN
1706
+ v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
1707
+ WHEN 'lte' THEN
1708
+ v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
1709
+ WHEN 'in' THEN
1710
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = ANY(%L)', v_field,
1711
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
1712
+ WHEN 'not_in' THEN
1713
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != ALL(%L)', v_field,
1714
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
1715
+ WHEN 'like' THEN
1716
+ v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
1717
+ WHEN 'ilike' THEN
1718
+ v_where_clause := v_where_clause || format(' AND %I ILIKE %L', v_field, v_value #>> '{}');
1719
+ WHEN 'is_null' THEN
1720
+ IF (v_value::text = 'true') THEN
1721
+ v_where_clause := v_where_clause || format(' AND %I IS NULL', v_field);
1722
+ END IF;
1723
+ WHEN 'not_null' THEN
1724
+ IF (v_value::text = 'true') THEN
1725
+ v_where_clause := v_where_clause || format(' AND %I IS NOT NULL', v_field);
1726
+ END IF;
1727
+ ELSE
1728
+ -- Unknown operator, skip
1729
+ END CASE;
1730
+ END LOOP;
1731
+ END IF;
1732
+ END LOOP;
1733
+
1734
+ -- Execute dynamic query (sort inside subquery for correct LIMIT behavior)
1735
+ EXECUTE format('
1736
+ SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
1737
+ FROM (
1738
+ SELECT * FROM packages
1739
+ WHERE (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = packages.owner_org_id AND acts_for.active = true AND acts_for.user_id = p_user_id) OR EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = packages.sponsor_org_id AND acts_for.active = true AND acts_for.user_id = p_user_id)) %s
1740
+ ORDER BY %I %s
1741
+ LIMIT %L OFFSET %L
1742
+ ) t
1743
+ ', v_where_clause, v_sort_field, v_sort_order,
1744
+ COALESCE((p_query->>'limit')::int, 10),
1745
+ COALESCE((p_query->>'offset')::int, 0))
1746
+ INTO v_results;
1747
+
1748
+ RETURN v_results;
1749
+ END;
1750
+ $$;
1751
+
1752
+
1753
+ CREATE TABLE IF NOT EXISTS allocations (
1754
+ id serial PRIMARY KEY,
1755
+ package_id int NOT NULL REFERENCES packages(id),
1756
+ site_id int NOT NULL REFERENCES sites(id),
1757
+ from_date date NOT NULL,
1758
+ to_date date NOT NULL
1759
+ );
1760
+
1761
+
1762
+ CREATE OR REPLACE FUNCTION dzql_v2.save_allocations(p_user_id int, p_data jsonb)
1763
+ RETURNS jsonb
1764
+ LANGUAGE plpgsql
1765
+ SECURITY DEFINER
1766
+ SET search_path = dzql_v2, public
1767
+ AS $$
1768
+ DECLARE
1769
+ v_result jsonb;
1770
+ v_old_data jsonb;
1771
+ v_commit_id bigint;
1772
+ v_op text;
1773
+
1774
+ BEGIN
1775
+ v_commit_id := nextval('dzql_v2.commit_seq');
1776
+
1777
+
1778
+ -- Determine Operation & Check Permissions (supports composite PK)
1779
+ IF ((p_data->>'id') IS NOT NULL) AND EXISTS(SELECT 1 FROM allocations WHERE id = (p_data->>'id')::int) THEN
1780
+ v_op := 'update';
1781
+
1782
+ -- Fetch old data for update rules/events
1783
+ SELECT to_jsonb(allocations.*) INTO v_old_data FROM allocations WHERE id = (p_data->>'id')::int;
1784
+
1785
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT org_id FROM venues WHERE id = (SELECT venue_id FROM sites WHERE id = (p_data->>'site_id')::int)) AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
1786
+ RAISE EXCEPTION 'permission_denied';
1787
+ END IF;
1788
+
1789
+ -- Perform Partial Update
1790
+ UPDATE allocations SET
1791
+ package_id = CASE WHEN (p_data ? 'package_id') THEN (p_data->>'package_id')::int ELSE package_id END,
1792
+ site_id = CASE WHEN (p_data ? 'site_id') THEN (p_data->>'site_id')::int ELSE site_id END,
1793
+ from_date = CASE WHEN (p_data ? 'from_date') THEN (p_data->>'from_date')::date ELSE from_date END,
1794
+ to_date = CASE WHEN (p_data ? 'to_date') THEN (p_data->>'to_date')::date ELSE to_date END
1795
+ WHERE id = (p_data->>'id')::int
1796
+ RETURNING to_jsonb(allocations.*) INTO v_result;
1797
+
1798
+
1799
+
1800
+ ELSE
1801
+ v_op := 'insert';
1802
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT org_id FROM venues WHERE id = (SELECT venue_id FROM sites WHERE id = (p_data->>'site_id')::int)) AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
1803
+ RAISE EXCEPTION 'permission_denied';
1804
+ END IF;
1805
+
1806
+ -- Perform Insert
1807
+ INSERT INTO allocations (package_id, site_id, from_date, to_date)
1808
+ VALUES ((p_data->>'package_id')::int, (p_data->>'site_id')::int, (p_data->>'from_date')::date, (p_data->>'to_date')::date)
1809
+ RETURNING to_jsonb(allocations.*) INTO v_result;
1810
+
1811
+
1812
+ END IF;
1813
+
1814
+
1815
+
1816
+ -- Emit Event
1817
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
1818
+ VALUES (
1819
+ v_commit_id,
1820
+ 'allocations',
1821
+ v_op,
1822
+ jsonb_build_object('id', v_result->'id'),
1823
+ v_result,
1824
+ v_old_data, -- NULL for insert
1825
+ p_user_id
1826
+ );
1827
+
1828
+ -- Notify Runtime
1829
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
1830
+
1831
+ -- Remove hidden fields before returning to client
1832
+ RETURN v_result;
1833
+ END;
1834
+ $$;
1835
+
1836
+
1837
+ CREATE OR REPLACE FUNCTION dzql_v2.delete_allocations(p_user_id int, p_pk jsonb)
1838
+ RETURNS jsonb
1839
+ LANGUAGE plpgsql
1840
+ SECURITY DEFINER
1841
+ SET search_path = dzql_v2, public
1842
+ AS $$
1843
+ DECLARE
1844
+ v_old_data jsonb;
1845
+ v_commit_id bigint;
1846
+ BEGIN
1847
+ v_commit_id := nextval('dzql_v2.commit_seq');
1848
+
1849
+ -- Fetch old data FIRST for permission check
1850
+ SELECT to_jsonb(allocations.*) INTO v_old_data FROM allocations WHERE id = (p_pk->>'id')::int;
1851
+
1852
+ IF v_old_data IS NULL THEN
1853
+ RAISE EXCEPTION 'not_found';
1854
+ END IF;
1855
+
1856
+ -- Permission Check (Delete)
1857
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT org_id FROM venues WHERE id = (SELECT venue_id FROM sites WHERE id = (v_old_data->>'site_id')::int)) AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
1858
+ RAISE EXCEPTION 'permission_denied';
1859
+ END IF;
1860
+
1861
+ -- Graph Rules (Pre-delete cascades)
1862
+
1863
+
1864
+ -- Perform Delete
1865
+ DELETE FROM allocations WHERE id = (p_pk->>'id')::int;
1866
+
1867
+ -- Emit Event (always 'delete' operation for client-side removal)
1868
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
1869
+ VALUES (
1870
+ v_commit_id,
1871
+ 'allocations',
1872
+ 'delete',
1873
+ jsonb_build_object('id', v_old_data->'id'),
1874
+ v_old_data, -- Include full data for subscription resolution
1875
+ v_old_data,
1876
+ p_user_id
1877
+ );
1878
+
1879
+ -- Notify Runtime
1880
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
1881
+
1882
+ -- Remove hidden fields before returning to client
1883
+ RETURN v_old_data;
1884
+ END;
1885
+ $$;
1886
+
1887
+
1888
+ CREATE OR REPLACE FUNCTION dzql_v2.get_allocations(p_user_id int, p_pk jsonb)
1889
+ RETURNS jsonb
1890
+ LANGUAGE plpgsql
1891
+ SECURITY DEFINER
1892
+ SET search_path = dzql_v2, public
1893
+ AS $$
1894
+ DECLARE
1895
+ v_result jsonb;
1896
+ BEGIN
1897
+ SELECT to_jsonb(allocations.*) INTO v_result
1898
+ FROM allocations
1899
+ WHERE id = (p_pk->>'id')::int
1900
+ AND (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT org_id FROM venues WHERE id = (SELECT venue_id FROM sites WHERE id = allocations.site_id)) AND acts_for.active = true AND acts_for.user_id = p_user_id) OR EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT owner_org_id FROM packages WHERE id = allocations.package_id) AND acts_for.active = true AND acts_for.user_id = p_user_id) OR EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT sponsor_org_id FROM packages WHERE id = allocations.package_id) AND acts_for.active = true AND acts_for.user_id = p_user_id) OR EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT contractor_org_id FROM contractor_rights WHERE contractor_rights.package_id = allocations.package_id AND contractor_rights.active = true) AND acts_for.active = true AND acts_for.user_id = p_user_id));
1901
+
1902
+ IF v_result IS NULL THEN
1903
+ RETURN NULL;
1904
+ END IF;
1905
+
1906
+
1907
+ RETURN v_result;
1908
+ END;
1909
+ $$;
1910
+
1911
+
1912
+ CREATE OR REPLACE FUNCTION dzql_v2.search_allocations(p_user_id int, p_query jsonb)
1913
+ RETURNS jsonb
1914
+ LANGUAGE plpgsql
1915
+ SECURITY DEFINER
1916
+ SET search_path = dzql_v2, public
1917
+ AS $$
1918
+ DECLARE
1919
+ v_results jsonb;
1920
+ v_filters jsonb;
1921
+ v_sort_field text;
1922
+ v_sort_order text;
1923
+ v_where_clause text := '';
1924
+ v_field text;
1925
+ v_filter jsonb;
1926
+ v_operator text;
1927
+ v_value jsonb;
1928
+ BEGIN
1929
+ -- Extract query parameters
1930
+ v_filters := COALESCE(p_query->'filters', '{}'::jsonb);
1931
+ v_sort_field := COALESCE(p_query->>'sort_field', 'id');
1932
+ v_sort_order := COALESCE(p_query->>'sort_order', 'asc');
1933
+
1934
+ -- Build WHERE clause from filters
1935
+ FOR v_field, v_filter IN SELECT * FROM jsonb_each(v_filters)
1936
+ LOOP
1937
+ -- Handle simple value (exact match)
1938
+ IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
1939
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_filter #>> '{}');
1940
+ ELSE
1941
+ -- Handle operator-based filters
1942
+ FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
1943
+ LOOP
1944
+ CASE v_operator
1945
+ WHEN 'eq' THEN
1946
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_value #>> '{}');
1947
+ WHEN 'ne' THEN
1948
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != %L', v_field, v_value #>> '{}');
1949
+ WHEN 'gt' THEN
1950
+ v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
1951
+ WHEN 'gte' THEN
1952
+ v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
1953
+ WHEN 'lt' THEN
1954
+ v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
1955
+ WHEN 'lte' THEN
1956
+ v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
1957
+ WHEN 'in' THEN
1958
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = ANY(%L)', v_field,
1959
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
1960
+ WHEN 'not_in' THEN
1961
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != ALL(%L)', v_field,
1962
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
1963
+ WHEN 'like' THEN
1964
+ v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
1965
+ WHEN 'ilike' THEN
1966
+ v_where_clause := v_where_clause || format(' AND %I ILIKE %L', v_field, v_value #>> '{}');
1967
+ WHEN 'is_null' THEN
1968
+ IF (v_value::text = 'true') THEN
1969
+ v_where_clause := v_where_clause || format(' AND %I IS NULL', v_field);
1970
+ END IF;
1971
+ WHEN 'not_null' THEN
1972
+ IF (v_value::text = 'true') THEN
1973
+ v_where_clause := v_where_clause || format(' AND %I IS NOT NULL', v_field);
1974
+ END IF;
1975
+ ELSE
1976
+ -- Unknown operator, skip
1977
+ END CASE;
1978
+ END LOOP;
1979
+ END IF;
1980
+ END LOOP;
1981
+
1982
+ -- Execute dynamic query (sort inside subquery for correct LIMIT behavior)
1983
+ EXECUTE format('
1984
+ SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
1985
+ FROM (
1986
+ SELECT * FROM allocations
1987
+ WHERE (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT org_id FROM venues WHERE id = (SELECT venue_id FROM sites WHERE id = allocations.site_id)) AND acts_for.active = true AND acts_for.user_id = p_user_id) OR EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT owner_org_id FROM packages WHERE id = allocations.package_id) AND acts_for.active = true AND acts_for.user_id = p_user_id) OR EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT sponsor_org_id FROM packages WHERE id = allocations.package_id) AND acts_for.active = true AND acts_for.user_id = p_user_id) OR EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT contractor_org_id FROM contractor_rights WHERE contractor_rights.package_id = allocations.package_id AND contractor_rights.active = true) AND acts_for.active = true AND acts_for.user_id = p_user_id)) %s
1988
+ ORDER BY %I %s
1989
+ LIMIT %L OFFSET %L
1990
+ ) t
1991
+ ', v_where_clause, v_sort_field, v_sort_order,
1992
+ COALESCE((p_query->>'limit')::int, 10),
1993
+ COALESCE((p_query->>'offset')::int, 0))
1994
+ INTO v_results;
1995
+
1996
+ RETURN v_results;
1997
+ END;
1998
+ $$;
1999
+
2000
+
2001
+ CREATE TABLE IF NOT EXISTS contractor_rights (
2002
+ contractor_org_id int NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
2003
+ sponsor_org_id int NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
2004
+ package_id int NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
2005
+ valid_from date NOT NULL DEFAULT current_date,
2006
+ valid_to date
2007
+ );
2008
+
2009
+
2010
+ CREATE OR REPLACE FUNCTION dzql_v2.save_contractor_rights(p_user_id int, p_data jsonb)
2011
+ RETURNS jsonb
2012
+ LANGUAGE plpgsql
2013
+ SECURITY DEFINER
2014
+ SET search_path = dzql_v2, public
2015
+ AS $$
2016
+ DECLARE
2017
+ v_result jsonb;
2018
+ v_old_data jsonb;
2019
+ v_commit_id bigint;
2020
+ v_op text;
2021
+
2022
+ BEGIN
2023
+ v_commit_id := nextval('dzql_v2.commit_seq');
2024
+
2025
+
2026
+ -- Determine Operation & Check Permissions (supports composite PK)
2027
+ IF ((p_data->>'contractor_org_id') IS NOT NULL AND (p_data->>'package_id') IS NOT NULL AND (p_data->>'valid_from') IS NOT NULL) AND EXISTS(SELECT 1 FROM contractor_rights WHERE contractor_org_id = (p_data->>'contractor_org_id')::int AND package_id = (p_data->>'package_id')::int AND valid_from = (p_data->>'valid_from')::date) THEN
2028
+ v_op := 'update';
2029
+
2030
+ -- Fetch old data for update rules/events
2031
+ SELECT to_jsonb(contractor_rights.*) INTO v_old_data FROM contractor_rights WHERE contractor_org_id = (p_data->>'contractor_org_id')::int AND package_id = (p_data->>'package_id')::int AND valid_from = (p_data->>'valid_from')::date;
2032
+
2033
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (p_data->>'sponsor_org_id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
2034
+ RAISE EXCEPTION 'permission_denied';
2035
+ END IF;
2036
+
2037
+ -- Perform Partial Update
2038
+ UPDATE contractor_rights SET
2039
+ sponsor_org_id = CASE WHEN (p_data ? 'sponsor_org_id') THEN (p_data->>'sponsor_org_id')::int ELSE sponsor_org_id END,
2040
+ valid_to = CASE WHEN (p_data ? 'valid_to') THEN (p_data->>'valid_to')::date ELSE valid_to END
2041
+ WHERE contractor_org_id = (p_data->>'contractor_org_id')::int AND package_id = (p_data->>'package_id')::int AND valid_from = (p_data->>'valid_from')::date
2042
+ RETURNING to_jsonb(contractor_rights.*) INTO v_result;
2043
+
2044
+
2045
+
2046
+ ELSE
2047
+ v_op := 'insert';
2048
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (p_data->>'sponsor_org_id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
2049
+ RAISE EXCEPTION 'permission_denied';
2050
+ END IF;
2051
+
2052
+ -- Perform Insert
2053
+ INSERT INTO contractor_rights (contractor_org_id, sponsor_org_id, package_id, valid_from, valid_to)
2054
+ VALUES ((p_data->>'contractor_org_id')::int, (p_data->>'sponsor_org_id')::int, (p_data->>'package_id')::int, (p_data->>'valid_from')::date, (p_data->>'valid_to')::date)
2055
+ RETURNING to_jsonb(contractor_rights.*) INTO v_result;
2056
+
2057
+
2058
+ END IF;
2059
+
2060
+
2061
+
2062
+ -- Emit Event
2063
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
2064
+ VALUES (
2065
+ v_commit_id,
2066
+ 'contractor_rights',
2067
+ v_op,
2068
+ jsonb_build_object('contractor_org_id', v_result->'contractor_org_id', 'package_id', v_result->'package_id', 'valid_from', v_result->'valid_from'),
2069
+ v_result,
2070
+ v_old_data, -- NULL for insert
2071
+ p_user_id
2072
+ );
2073
+
2074
+ -- Notify Runtime
2075
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
2076
+
2077
+ -- Remove hidden fields before returning to client
2078
+ RETURN v_result;
2079
+ END;
2080
+ $$;
2081
+
2082
+
2083
+ CREATE OR REPLACE FUNCTION dzql_v2.delete_contractor_rights(p_user_id int, p_pk jsonb)
2084
+ RETURNS jsonb
2085
+ LANGUAGE plpgsql
2086
+ SECURITY DEFINER
2087
+ SET search_path = dzql_v2, public
2088
+ AS $$
2089
+ DECLARE
2090
+ v_old_data jsonb;
2091
+ v_commit_id bigint;
2092
+ BEGIN
2093
+ v_commit_id := nextval('dzql_v2.commit_seq');
2094
+
2095
+ -- Fetch old data FIRST for permission check
2096
+ SELECT to_jsonb(contractor_rights.*) INTO v_old_data FROM contractor_rights WHERE contractor_org_id = (p_pk->>'contractor_org_id')::int AND package_id = (p_pk->>'package_id')::int AND valid_from = (p_pk->>'valid_from')::date;
2097
+
2098
+ IF v_old_data IS NULL THEN
2099
+ RAISE EXCEPTION 'not_found';
2100
+ END IF;
2101
+
2102
+ -- Permission Check (Delete)
2103
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (v_old_data->>'sponsor_org_id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
2104
+ RAISE EXCEPTION 'permission_denied';
2105
+ END IF;
2106
+
2107
+ -- Graph Rules (Pre-delete cascades)
2108
+
2109
+
2110
+ -- Perform Delete
2111
+ DELETE FROM contractor_rights WHERE contractor_org_id = (p_pk->>'contractor_org_id')::int AND package_id = (p_pk->>'package_id')::int AND valid_from = (p_pk->>'valid_from')::date;
2112
+
2113
+ -- Emit Event (always 'delete' operation for client-side removal)
2114
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
2115
+ VALUES (
2116
+ v_commit_id,
2117
+ 'contractor_rights',
2118
+ 'delete',
2119
+ jsonb_build_object('contractor_org_id', v_old_data->'contractor_org_id', 'package_id', v_old_data->'package_id', 'valid_from', v_old_data->'valid_from'),
2120
+ v_old_data, -- Include full data for subscription resolution
2121
+ v_old_data,
2122
+ p_user_id
2123
+ );
2124
+
2125
+ -- Notify Runtime
2126
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
2127
+
2128
+ -- Remove hidden fields before returning to client
2129
+ RETURN v_old_data;
2130
+ END;
2131
+ $$;
2132
+
2133
+
2134
+ CREATE OR REPLACE FUNCTION dzql_v2.get_contractor_rights(p_user_id int, p_pk jsonb)
2135
+ RETURNS jsonb
2136
+ LANGUAGE plpgsql
2137
+ SECURITY DEFINER
2138
+ SET search_path = dzql_v2, public
2139
+ AS $$
2140
+ DECLARE
2141
+ v_result jsonb;
2142
+ BEGIN
2143
+ SELECT to_jsonb(contractor_rights.*) INTO v_result
2144
+ FROM contractor_rights
2145
+ WHERE contractor_org_id = (p_pk->>'contractor_org_id')::int AND package_id = (p_pk->>'package_id')::int AND valid_from = (p_pk->>'valid_from')::date
2146
+ AND (TRUE);
2147
+
2148
+ IF v_result IS NULL THEN
2149
+ RETURN NULL;
2150
+ END IF;
2151
+
2152
+
2153
+ RETURN v_result;
2154
+ END;
2155
+ $$;
2156
+
2157
+
2158
+ CREATE OR REPLACE FUNCTION dzql_v2.search_contractor_rights(p_user_id int, p_query jsonb)
2159
+ RETURNS jsonb
2160
+ LANGUAGE plpgsql
2161
+ SECURITY DEFINER
2162
+ SET search_path = dzql_v2, public
2163
+ AS $$
2164
+ DECLARE
2165
+ v_results jsonb;
2166
+ v_filters jsonb;
2167
+ v_sort_field text;
2168
+ v_sort_order text;
2169
+ v_where_clause text := '';
2170
+ v_field text;
2171
+ v_filter jsonb;
2172
+ v_operator text;
2173
+ v_value jsonb;
2174
+ BEGIN
2175
+ -- Extract query parameters
2176
+ v_filters := COALESCE(p_query->'filters', '{}'::jsonb);
2177
+ v_sort_field := COALESCE(p_query->>'sort_field', 'contractor_org_id');
2178
+ v_sort_order := COALESCE(p_query->>'sort_order', 'asc');
2179
+
2180
+ -- Build WHERE clause from filters
2181
+ FOR v_field, v_filter IN SELECT * FROM jsonb_each(v_filters)
2182
+ LOOP
2183
+ -- Handle simple value (exact match)
2184
+ IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
2185
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_filter #>> '{}');
2186
+ ELSE
2187
+ -- Handle operator-based filters
2188
+ FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
2189
+ LOOP
2190
+ CASE v_operator
2191
+ WHEN 'eq' THEN
2192
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_value #>> '{}');
2193
+ WHEN 'ne' THEN
2194
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != %L', v_field, v_value #>> '{}');
2195
+ WHEN 'gt' THEN
2196
+ v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
2197
+ WHEN 'gte' THEN
2198
+ v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
2199
+ WHEN 'lt' THEN
2200
+ v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
2201
+ WHEN 'lte' THEN
2202
+ v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
2203
+ WHEN 'in' THEN
2204
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = ANY(%L)', v_field,
2205
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
2206
+ WHEN 'not_in' THEN
2207
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != ALL(%L)', v_field,
2208
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
2209
+ WHEN 'like' THEN
2210
+ v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
2211
+ WHEN 'ilike' THEN
2212
+ v_where_clause := v_where_clause || format(' AND %I ILIKE %L', v_field, v_value #>> '{}');
2213
+ WHEN 'is_null' THEN
2214
+ IF (v_value::text = 'true') THEN
2215
+ v_where_clause := v_where_clause || format(' AND %I IS NULL', v_field);
2216
+ END IF;
2217
+ WHEN 'not_null' THEN
2218
+ IF (v_value::text = 'true') THEN
2219
+ v_where_clause := v_where_clause || format(' AND %I IS NOT NULL', v_field);
2220
+ END IF;
2221
+ ELSE
2222
+ -- Unknown operator, skip
2223
+ END CASE;
2224
+ END LOOP;
2225
+ END IF;
2226
+ END LOOP;
2227
+
2228
+ -- Execute dynamic query (sort inside subquery for correct LIMIT behavior)
2229
+ EXECUTE format('
2230
+ SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
2231
+ FROM (
2232
+ SELECT * FROM contractor_rights
2233
+ WHERE (TRUE) %s
2234
+ ORDER BY %I %s
2235
+ LIMIT %L OFFSET %L
2236
+ ) t
2237
+ ', v_where_clause, v_sort_field, v_sort_order,
2238
+ COALESCE((p_query->>'limit')::int, 10),
2239
+ COALESCE((p_query->>'offset')::int, 0))
2240
+ INTO v_results;
2241
+
2242
+ RETURN v_results;
2243
+ END;
2244
+ $$;
2245
+
2246
+
2247
+ CREATE TABLE IF NOT EXISTS brands (
2248
+ id serial PRIMARY KEY,
2249
+ org_id int NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
2250
+ name text NOT NULL,
2251
+ description text
2252
+ );
2253
+
2254
+
2255
+ CREATE OR REPLACE FUNCTION dzql_v2.save_brands(p_user_id int, p_data jsonb)
2256
+ RETURNS jsonb
2257
+ LANGUAGE plpgsql
2258
+ SECURITY DEFINER
2259
+ SET search_path = dzql_v2, public
2260
+ AS $$
2261
+ DECLARE
2262
+ v_result jsonb;
2263
+ v_old_data jsonb;
2264
+ v_commit_id bigint;
2265
+ v_op text;
2266
+ v_tag_ids INT[];
2267
+ BEGIN
2268
+ v_commit_id := nextval('dzql_v2.commit_seq');
2269
+
2270
+ -- M2M: Extract tags IDs
2271
+ IF p_data ? 'tag_ids' THEN
2272
+ v_tag_ids := ARRAY(SELECT jsonb_array_elements_text(p_data->'tag_ids')::int);
2273
+ p_data := p_data - 'tag_ids';
2274
+ END IF;
2275
+
2276
+ -- Determine Operation & Check Permissions (supports composite PK)
2277
+ IF ((p_data->>'id') IS NOT NULL) AND EXISTS(SELECT 1 FROM brands WHERE id = (p_data->>'id')::int) THEN
2278
+ v_op := 'update';
2279
+
2280
+ -- Fetch old data for update rules/events
2281
+ SELECT to_jsonb(brands.*) INTO v_old_data FROM brands WHERE id = (p_data->>'id')::int;
2282
+
2283
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (p_data->>'org_id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
2284
+ RAISE EXCEPTION 'permission_denied';
2285
+ END IF;
2286
+
2287
+ -- Perform Partial Update
2288
+ UPDATE brands SET
2289
+ org_id = CASE WHEN (p_data ? 'org_id') THEN (p_data->>'org_id')::int ELSE org_id END,
2290
+ name = CASE WHEN (p_data ? 'name') THEN (p_data->>'name') ELSE name END,
2291
+ description = CASE WHEN (p_data ? 'description') THEN (p_data->>'description') ELSE description END
2292
+ WHERE id = (p_data->>'id')::int
2293
+ RETURNING to_jsonb(brands.*) INTO v_result;
2294
+
2295
+
2296
+
2297
+ ELSE
2298
+ v_op := 'insert';
2299
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (p_data->>'org_id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
2300
+ RAISE EXCEPTION 'permission_denied';
2301
+ END IF;
2302
+
2303
+ -- Perform Insert
2304
+ INSERT INTO brands (org_id, name, description)
2305
+ VALUES ((p_data->>'org_id')::int, (p_data->>'name'), (p_data->>'description'))
2306
+ RETURNING to_jsonb(brands.*) INTO v_result;
2307
+
2308
+
2309
+ END IF;
2310
+
2311
+ -- M2M Sync: tags (junction: brand_tags)
2312
+ IF v_tag_ids IS NOT NULL THEN
2313
+ -- Delete relationships not in new list
2314
+ DELETE FROM brand_tags
2315
+ WHERE brand_id = (v_result->>'id')::int
2316
+ AND (tag_id <> ALL(v_tag_ids) OR v_tag_ids = '{}');
2317
+
2318
+ -- Insert new relationships (idempotent)
2319
+ IF array_length(v_tag_ids, 1) > 0 THEN
2320
+ INSERT INTO brand_tags (brand_id, tag_id)
2321
+ SELECT (v_result->>'id')::int, unnest(v_tag_ids)
2322
+ ON CONFLICT (brand_id, tag_id) DO NOTHING;
2323
+ END IF;
2324
+ END IF;
2325
+
2326
+ -- M2M: Add tag_ids to output
2327
+ v_result := v_result || jsonb_build_object('tag_ids',
2328
+ (SELECT COALESCE(jsonb_agg(tag_id ORDER BY tag_id), '[]'::jsonb)
2329
+ FROM brand_tags WHERE brand_id = (v_result->>'id')::int));
2330
+
2331
+ -- Emit Event
2332
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
2333
+ VALUES (
2334
+ v_commit_id,
2335
+ 'brands',
2336
+ v_op,
2337
+ jsonb_build_object('id', v_result->'id'),
2338
+ v_result,
2339
+ v_old_data, -- NULL for insert
2340
+ p_user_id
2341
+ );
2342
+
2343
+ -- Notify Runtime
2344
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
2345
+
2346
+ -- Remove hidden fields before returning to client
2347
+ RETURN v_result;
2348
+ END;
2349
+ $$;
2350
+
2351
+
2352
+ CREATE OR REPLACE FUNCTION dzql_v2.delete_brands(p_user_id int, p_pk jsonb)
2353
+ RETURNS jsonb
2354
+ LANGUAGE plpgsql
2355
+ SECURITY DEFINER
2356
+ SET search_path = dzql_v2, public
2357
+ AS $$
2358
+ DECLARE
2359
+ v_old_data jsonb;
2360
+ v_commit_id bigint;
2361
+ BEGIN
2362
+ v_commit_id := nextval('dzql_v2.commit_seq');
2363
+
2364
+ -- Fetch old data FIRST for permission check
2365
+ SELECT to_jsonb(brands.*) INTO v_old_data FROM brands WHERE id = (p_pk->>'id')::int;
2366
+
2367
+ IF v_old_data IS NULL THEN
2368
+ RAISE EXCEPTION 'not_found';
2369
+ END IF;
2370
+
2371
+ -- Permission Check (Delete)
2372
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (v_old_data->>'org_id')::int AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
2373
+ RAISE EXCEPTION 'permission_denied';
2374
+ END IF;
2375
+
2376
+ -- Graph Rules (Pre-delete cascades)
2377
+
2378
+
2379
+ -- Perform Delete
2380
+ DELETE FROM brands WHERE id = (p_pk->>'id')::int;
2381
+
2382
+ -- Emit Event (always 'delete' operation for client-side removal)
2383
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
2384
+ VALUES (
2385
+ v_commit_id,
2386
+ 'brands',
2387
+ 'delete',
2388
+ jsonb_build_object('id', v_old_data->'id'),
2389
+ v_old_data, -- Include full data for subscription resolution
2390
+ v_old_data,
2391
+ p_user_id
2392
+ );
2393
+
2394
+ -- Notify Runtime
2395
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
2396
+
2397
+ -- Remove hidden fields before returning to client
2398
+ RETURN v_old_data;
2399
+ END;
2400
+ $$;
2401
+
2402
+
2403
+ CREATE OR REPLACE FUNCTION dzql_v2.get_brands(p_user_id int, p_pk jsonb)
2404
+ RETURNS jsonb
2405
+ LANGUAGE plpgsql
2406
+ SECURITY DEFINER
2407
+ SET search_path = dzql_v2, public
2408
+ AS $$
2409
+ DECLARE
2410
+ v_result jsonb;
2411
+ BEGIN
2412
+ SELECT to_jsonb(brands.*) INTO v_result
2413
+ FROM brands
2414
+ WHERE id = (p_pk->>'id')::int
2415
+ AND (TRUE);
2416
+
2417
+ IF v_result IS NULL THEN
2418
+ RETURN NULL;
2419
+ END IF;
2420
+
2421
+ -- M2M: Add tag_ids to result
2422
+ v_result := v_result || jsonb_build_object('tag_ids',
2423
+ (SELECT COALESCE(jsonb_agg(tag_id ORDER BY tag_id), '[]'::jsonb)
2424
+ FROM brand_tags WHERE brand_id = (v_result->>'id')::int));
2425
+
2426
+ RETURN v_result;
2427
+ END;
2428
+ $$;
2429
+
2430
+
2431
+ CREATE OR REPLACE FUNCTION dzql_v2.search_brands(p_user_id int, p_query jsonb)
2432
+ RETURNS jsonb
2433
+ LANGUAGE plpgsql
2434
+ SECURITY DEFINER
2435
+ SET search_path = dzql_v2, public
2436
+ AS $$
2437
+ DECLARE
2438
+ v_results jsonb;
2439
+ v_filters jsonb;
2440
+ v_sort_field text;
2441
+ v_sort_order text;
2442
+ v_where_clause text := '';
2443
+ v_field text;
2444
+ v_filter jsonb;
2445
+ v_operator text;
2446
+ v_value jsonb;
2447
+ BEGIN
2448
+ -- Extract query parameters
2449
+ v_filters := COALESCE(p_query->'filters', '{}'::jsonb);
2450
+ v_sort_field := COALESCE(p_query->>'sort_field', 'name');
2451
+ v_sort_order := COALESCE(p_query->>'sort_order', 'asc');
2452
+
2453
+ -- Build WHERE clause from filters
2454
+ FOR v_field, v_filter IN SELECT * FROM jsonb_each(v_filters)
2455
+ LOOP
2456
+ -- Handle simple value (exact match)
2457
+ IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
2458
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_filter #>> '{}');
2459
+ ELSE
2460
+ -- Handle operator-based filters
2461
+ FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
2462
+ LOOP
2463
+ CASE v_operator
2464
+ WHEN 'eq' THEN
2465
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_value #>> '{}');
2466
+ WHEN 'ne' THEN
2467
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != %L', v_field, v_value #>> '{}');
2468
+ WHEN 'gt' THEN
2469
+ v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
2470
+ WHEN 'gte' THEN
2471
+ v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
2472
+ WHEN 'lt' THEN
2473
+ v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
2474
+ WHEN 'lte' THEN
2475
+ v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
2476
+ WHEN 'in' THEN
2477
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = ANY(%L)', v_field,
2478
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
2479
+ WHEN 'not_in' THEN
2480
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != ALL(%L)', v_field,
2481
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
2482
+ WHEN 'like' THEN
2483
+ v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
2484
+ WHEN 'ilike' THEN
2485
+ v_where_clause := v_where_clause || format(' AND %I ILIKE %L', v_field, v_value #>> '{}');
2486
+ WHEN 'is_null' THEN
2487
+ IF (v_value::text = 'true') THEN
2488
+ v_where_clause := v_where_clause || format(' AND %I IS NULL', v_field);
2489
+ END IF;
2490
+ WHEN 'not_null' THEN
2491
+ IF (v_value::text = 'true') THEN
2492
+ v_where_clause := v_where_clause || format(' AND %I IS NOT NULL', v_field);
2493
+ END IF;
2494
+ ELSE
2495
+ -- Unknown operator, skip
2496
+ END CASE;
2497
+ END LOOP;
2498
+ END IF;
2499
+ END LOOP;
2500
+
2501
+ -- Execute dynamic query (sort inside subquery for correct LIMIT behavior)
2502
+ EXECUTE format('
2503
+ SELECT COALESCE(jsonb_agg(to_jsonb(t.*) || jsonb_build_object(''tag_ids'', m2m_tag_ids.tag_ids)), ''[]''::jsonb)
2504
+ FROM (
2505
+ SELECT * FROM brands
2506
+ WHERE (TRUE) %s
2507
+ ORDER BY %I %s
2508
+ LIMIT %L OFFSET %L
2509
+ ) t
2510
+ LEFT JOIN LATERAL (
2511
+ SELECT COALESCE(jsonb_agg(tag_id ORDER BY tag_id), ''[]''::jsonb) as tag_ids
2512
+ FROM brand_tags
2513
+ WHERE brand_id = t.id
2514
+ ) m2m_tag_ids ON true
2515
+ ', v_where_clause, v_sort_field, v_sort_order,
2516
+ COALESCE((p_query->>'limit')::int, 10),
2517
+ COALESCE((p_query->>'offset')::int, 0))
2518
+ INTO v_results;
2519
+
2520
+ RETURN v_results;
2521
+ END;
2522
+ $$;
2523
+
2524
+
2525
+ CREATE TABLE IF NOT EXISTS artwork (
2526
+ id serial PRIMARY KEY,
2527
+ brand_id int NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
2528
+ url text NOT NULL,
2529
+ ratio decimal(10, 4) NOT NULL
2530
+ );
2531
+
2532
+
2533
+ CREATE OR REPLACE FUNCTION dzql_v2.save_artwork(p_user_id int, p_data jsonb)
2534
+ RETURNS jsonb
2535
+ LANGUAGE plpgsql
2536
+ SECURITY DEFINER
2537
+ SET search_path = dzql_v2, public
2538
+ AS $$
2539
+ DECLARE
2540
+ v_result jsonb;
2541
+ v_old_data jsonb;
2542
+ v_commit_id bigint;
2543
+ v_op text;
2544
+
2545
+ BEGIN
2546
+ v_commit_id := nextval('dzql_v2.commit_seq');
2547
+
2548
+
2549
+ -- Determine Operation & Check Permissions (supports composite PK)
2550
+ IF ((p_data->>'id') IS NOT NULL) AND EXISTS(SELECT 1 FROM artwork WHERE id = (p_data->>'id')::int) THEN
2551
+ v_op := 'update';
2552
+
2553
+ -- Fetch old data for update rules/events
2554
+ SELECT to_jsonb(artwork.*) INTO v_old_data FROM artwork WHERE id = (p_data->>'id')::int;
2555
+
2556
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT org_id FROM brands WHERE id = (p_data->>'brand_id')::int) AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
2557
+ RAISE EXCEPTION 'permission_denied';
2558
+ END IF;
2559
+
2560
+ -- Perform Partial Update
2561
+ UPDATE artwork SET
2562
+ brand_id = CASE WHEN (p_data ? 'brand_id') THEN (p_data->>'brand_id')::int ELSE brand_id END,
2563
+ url = CASE WHEN (p_data ? 'url') THEN (p_data->>'url') ELSE url END,
2564
+ ratio = CASE WHEN (p_data ? 'ratio') THEN (p_data->>'ratio')::numeric ELSE ratio END
2565
+ WHERE id = (p_data->>'id')::int
2566
+ RETURNING to_jsonb(artwork.*) INTO v_result;
2567
+
2568
+
2569
+
2570
+ ELSE
2571
+ v_op := 'insert';
2572
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT org_id FROM brands WHERE id = (p_data->>'brand_id')::int) AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
2573
+ RAISE EXCEPTION 'permission_denied';
2574
+ END IF;
2575
+
2576
+ -- Perform Insert
2577
+ INSERT INTO artwork (brand_id, url, ratio)
2578
+ VALUES ((p_data->>'brand_id')::int, (p_data->>'url'), (p_data->>'ratio')::numeric)
2579
+ RETURNING to_jsonb(artwork.*) INTO v_result;
2580
+
2581
+
2582
+ END IF;
2583
+
2584
+
2585
+
2586
+ -- Emit Event
2587
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
2588
+ VALUES (
2589
+ v_commit_id,
2590
+ 'artwork',
2591
+ v_op,
2592
+ jsonb_build_object('id', v_result->'id'),
2593
+ v_result,
2594
+ v_old_data, -- NULL for insert
2595
+ p_user_id
2596
+ );
2597
+
2598
+ -- Notify Runtime
2599
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
2600
+
2601
+ -- Remove hidden fields before returning to client
2602
+ RETURN v_result;
2603
+ END;
2604
+ $$;
2605
+
2606
+
2607
+ CREATE OR REPLACE FUNCTION dzql_v2.delete_artwork(p_user_id int, p_pk jsonb)
2608
+ RETURNS jsonb
2609
+ LANGUAGE plpgsql
2610
+ SECURITY DEFINER
2611
+ SET search_path = dzql_v2, public
2612
+ AS $$
2613
+ DECLARE
2614
+ v_old_data jsonb;
2615
+ v_commit_id bigint;
2616
+ BEGIN
2617
+ v_commit_id := nextval('dzql_v2.commit_seq');
2618
+
2619
+ -- Fetch old data FIRST for permission check
2620
+ SELECT to_jsonb(artwork.*) INTO v_old_data FROM artwork WHERE id = (p_pk->>'id')::int;
2621
+
2622
+ IF v_old_data IS NULL THEN
2623
+ RAISE EXCEPTION 'not_found';
2624
+ END IF;
2625
+
2626
+ -- Permission Check (Delete)
2627
+ IF NOT (EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = (SELECT org_id FROM brands WHERE id = (v_old_data->>'brand_id')::int) AND acts_for.active = true AND acts_for.user_id = p_user_id)) THEN
2628
+ RAISE EXCEPTION 'permission_denied';
2629
+ END IF;
2630
+
2631
+ -- Graph Rules (Pre-delete cascades)
2632
+
2633
+
2634
+ -- Perform Delete
2635
+ DELETE FROM artwork WHERE id = (p_pk->>'id')::int;
2636
+
2637
+ -- Emit Event (always 'delete' operation for client-side removal)
2638
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
2639
+ VALUES (
2640
+ v_commit_id,
2641
+ 'artwork',
2642
+ 'delete',
2643
+ jsonb_build_object('id', v_old_data->'id'),
2644
+ v_old_data, -- Include full data for subscription resolution
2645
+ v_old_data,
2646
+ p_user_id
2647
+ );
2648
+
2649
+ -- Notify Runtime
2650
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
2651
+
2652
+ -- Remove hidden fields before returning to client
2653
+ RETURN v_old_data;
2654
+ END;
2655
+ $$;
2656
+
2657
+
2658
+ CREATE OR REPLACE FUNCTION dzql_v2.get_artwork(p_user_id int, p_pk jsonb)
2659
+ RETURNS jsonb
2660
+ LANGUAGE plpgsql
2661
+ SECURITY DEFINER
2662
+ SET search_path = dzql_v2, public
2663
+ AS $$
2664
+ DECLARE
2665
+ v_result jsonb;
2666
+ BEGIN
2667
+ SELECT to_jsonb(artwork.*) INTO v_result
2668
+ FROM artwork
2669
+ WHERE id = (p_pk->>'id')::int
2670
+ AND (TRUE);
2671
+
2672
+ IF v_result IS NULL THEN
2673
+ RETURN NULL;
2674
+ END IF;
2675
+
2676
+
2677
+ RETURN v_result;
2678
+ END;
2679
+ $$;
2680
+
2681
+
2682
+ CREATE OR REPLACE FUNCTION dzql_v2.search_artwork(p_user_id int, p_query jsonb)
2683
+ RETURNS jsonb
2684
+ LANGUAGE plpgsql
2685
+ SECURITY DEFINER
2686
+ SET search_path = dzql_v2, public
2687
+ AS $$
2688
+ DECLARE
2689
+ v_results jsonb;
2690
+ v_filters jsonb;
2691
+ v_sort_field text;
2692
+ v_sort_order text;
2693
+ v_where_clause text := '';
2694
+ v_field text;
2695
+ v_filter jsonb;
2696
+ v_operator text;
2697
+ v_value jsonb;
2698
+ BEGIN
2699
+ -- Extract query parameters
2700
+ v_filters := COALESCE(p_query->'filters', '{}'::jsonb);
2701
+ v_sort_field := COALESCE(p_query->>'sort_field', 'url');
2702
+ v_sort_order := COALESCE(p_query->>'sort_order', 'asc');
2703
+
2704
+ -- Build WHERE clause from filters
2705
+ FOR v_field, v_filter IN SELECT * FROM jsonb_each(v_filters)
2706
+ LOOP
2707
+ -- Handle simple value (exact match)
2708
+ IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
2709
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_filter #>> '{}');
2710
+ ELSE
2711
+ -- Handle operator-based filters
2712
+ FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
2713
+ LOOP
2714
+ CASE v_operator
2715
+ WHEN 'eq' THEN
2716
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_value #>> '{}');
2717
+ WHEN 'ne' THEN
2718
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != %L', v_field, v_value #>> '{}');
2719
+ WHEN 'gt' THEN
2720
+ v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
2721
+ WHEN 'gte' THEN
2722
+ v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
2723
+ WHEN 'lt' THEN
2724
+ v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
2725
+ WHEN 'lte' THEN
2726
+ v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
2727
+ WHEN 'in' THEN
2728
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = ANY(%L)', v_field,
2729
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
2730
+ WHEN 'not_in' THEN
2731
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != ALL(%L)', v_field,
2732
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
2733
+ WHEN 'like' THEN
2734
+ v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
2735
+ WHEN 'ilike' THEN
2736
+ v_where_clause := v_where_clause || format(' AND %I ILIKE %L', v_field, v_value #>> '{}');
2737
+ WHEN 'is_null' THEN
2738
+ IF (v_value::text = 'true') THEN
2739
+ v_where_clause := v_where_clause || format(' AND %I IS NULL', v_field);
2740
+ END IF;
2741
+ WHEN 'not_null' THEN
2742
+ IF (v_value::text = 'true') THEN
2743
+ v_where_clause := v_where_clause || format(' AND %I IS NOT NULL', v_field);
2744
+ END IF;
2745
+ ELSE
2746
+ -- Unknown operator, skip
2747
+ END CASE;
2748
+ END LOOP;
2749
+ END IF;
2750
+ END LOOP;
2751
+
2752
+ -- Execute dynamic query (sort inside subquery for correct LIMIT behavior)
2753
+ EXECUTE format('
2754
+ SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
2755
+ FROM (
2756
+ SELECT * FROM artwork
2757
+ WHERE (TRUE) %s
2758
+ ORDER BY %I %s
2759
+ LIMIT %L OFFSET %L
2760
+ ) t
2761
+ ', v_where_clause, v_sort_field, v_sort_order,
2762
+ COALESCE((p_query->>'limit')::int, 10),
2763
+ COALESCE((p_query->>'offset')::int, 0))
2764
+ INTO v_results;
2765
+
2766
+ RETURN v_results;
2767
+ END;
2768
+ $$;
2769
+
2770
+
2771
+ CREATE TABLE IF NOT EXISTS tags (
2772
+ id serial PRIMARY KEY,
2773
+ name text NOT NULL UNIQUE,
2774
+ color text,
2775
+ description text
2776
+ );
2777
+
2778
+
2779
+ CREATE OR REPLACE FUNCTION dzql_v2.save_tags(p_user_id int, p_data jsonb)
2780
+ RETURNS jsonb
2781
+ LANGUAGE plpgsql
2782
+ SECURITY DEFINER
2783
+ SET search_path = dzql_v2, public
2784
+ AS $$
2785
+ DECLARE
2786
+ v_result jsonb;
2787
+ v_old_data jsonb;
2788
+ v_commit_id bigint;
2789
+ v_op text;
2790
+
2791
+ BEGIN
2792
+ v_commit_id := nextval('dzql_v2.commit_seq');
2793
+
2794
+
2795
+ -- Determine Operation & Check Permissions (supports composite PK)
2796
+ IF ((p_data->>'id') IS NOT NULL) AND EXISTS(SELECT 1 FROM tags WHERE id = (p_data->>'id')::int) THEN
2797
+ v_op := 'update';
2798
+
2799
+ -- Fetch old data for update rules/events
2800
+ SELECT to_jsonb(tags.*) INTO v_old_data FROM tags WHERE id = (p_data->>'id')::int;
2801
+
2802
+ IF NOT (TRUE) THEN
2803
+ RAISE EXCEPTION 'permission_denied';
2804
+ END IF;
2805
+
2806
+ -- Perform Partial Update
2807
+ UPDATE tags SET
2808
+ name = CASE WHEN (p_data ? 'name') THEN (p_data->>'name') ELSE name END,
2809
+ color = CASE WHEN (p_data ? 'color') THEN (p_data->>'color') ELSE color END,
2810
+ description = CASE WHEN (p_data ? 'description') THEN (p_data->>'description') ELSE description END
2811
+ WHERE id = (p_data->>'id')::int
2812
+ RETURNING to_jsonb(tags.*) INTO v_result;
2813
+
2814
+
2815
+
2816
+ ELSE
2817
+ v_op := 'insert';
2818
+ IF NOT (TRUE) THEN
2819
+ RAISE EXCEPTION 'permission_denied';
2820
+ END IF;
2821
+
2822
+ -- Perform Insert
2823
+ INSERT INTO tags (name, color, description)
2824
+ VALUES ((p_data->>'name'), (p_data->>'color'), (p_data->>'description'))
2825
+ RETURNING to_jsonb(tags.*) INTO v_result;
2826
+
2827
+
2828
+ END IF;
2829
+
2830
+
2831
+
2832
+ -- Emit Event
2833
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
2834
+ VALUES (
2835
+ v_commit_id,
2836
+ 'tags',
2837
+ v_op,
2838
+ jsonb_build_object('id', v_result->'id'),
2839
+ v_result,
2840
+ v_old_data, -- NULL for insert
2841
+ p_user_id
2842
+ );
2843
+
2844
+ -- Notify Runtime
2845
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
2846
+
2847
+ -- Remove hidden fields before returning to client
2848
+ RETURN v_result;
2849
+ END;
2850
+ $$;
2851
+
2852
+
2853
+ CREATE OR REPLACE FUNCTION dzql_v2.delete_tags(p_user_id int, p_pk jsonb)
2854
+ RETURNS jsonb
2855
+ LANGUAGE plpgsql
2856
+ SECURITY DEFINER
2857
+ SET search_path = dzql_v2, public
2858
+ AS $$
2859
+ DECLARE
2860
+ v_old_data jsonb;
2861
+ v_commit_id bigint;
2862
+ BEGIN
2863
+ v_commit_id := nextval('dzql_v2.commit_seq');
2864
+
2865
+ -- Fetch old data FIRST for permission check
2866
+ SELECT to_jsonb(tags.*) INTO v_old_data FROM tags WHERE id = (p_pk->>'id')::int;
2867
+
2868
+ IF v_old_data IS NULL THEN
2869
+ RAISE EXCEPTION 'not_found';
2870
+ END IF;
2871
+
2872
+ -- Permission Check (Delete)
2873
+ IF NOT (TRUE) THEN
2874
+ RAISE EXCEPTION 'permission_denied';
2875
+ END IF;
2876
+
2877
+ -- Graph Rules (Pre-delete cascades)
2878
+
2879
+
2880
+ -- Perform Delete
2881
+ DELETE FROM tags WHERE id = (p_pk->>'id')::int;
2882
+
2883
+ -- Emit Event (always 'delete' operation for client-side removal)
2884
+ INSERT INTO dzql_v2.events (commit_id, table_name, op, pk, data, old_data, user_id)
2885
+ VALUES (
2886
+ v_commit_id,
2887
+ 'tags',
2888
+ 'delete',
2889
+ jsonb_build_object('id', v_old_data->'id'),
2890
+ v_old_data, -- Include full data for subscription resolution
2891
+ v_old_data,
2892
+ p_user_id
2893
+ );
2894
+
2895
+ -- Notify Runtime
2896
+ PERFORM pg_notify('dzql_v2', json_build_object('commit_id', v_commit_id)::text);
2897
+
2898
+ -- Remove hidden fields before returning to client
2899
+ RETURN v_old_data;
2900
+ END;
2901
+ $$;
2902
+
2903
+
2904
+ CREATE OR REPLACE FUNCTION dzql_v2.get_tags(p_user_id int, p_pk jsonb)
2905
+ RETURNS jsonb
2906
+ LANGUAGE plpgsql
2907
+ SECURITY DEFINER
2908
+ SET search_path = dzql_v2, public
2909
+ AS $$
2910
+ DECLARE
2911
+ v_result jsonb;
2912
+ BEGIN
2913
+ SELECT to_jsonb(tags.*) INTO v_result
2914
+ FROM tags
2915
+ WHERE id = (p_pk->>'id')::int
2916
+ AND (TRUE);
2917
+
2918
+ IF v_result IS NULL THEN
2919
+ RETURN NULL;
2920
+ END IF;
2921
+
2922
+
2923
+ RETURN v_result;
2924
+ END;
2925
+ $$;
2926
+
2927
+
2928
+ CREATE OR REPLACE FUNCTION dzql_v2.search_tags(p_user_id int, p_query jsonb)
2929
+ RETURNS jsonb
2930
+ LANGUAGE plpgsql
2931
+ SECURITY DEFINER
2932
+ SET search_path = dzql_v2, public
2933
+ AS $$
2934
+ DECLARE
2935
+ v_results jsonb;
2936
+ v_filters jsonb;
2937
+ v_sort_field text;
2938
+ v_sort_order text;
2939
+ v_where_clause text := '';
2940
+ v_field text;
2941
+ v_filter jsonb;
2942
+ v_operator text;
2943
+ v_value jsonb;
2944
+ BEGIN
2945
+ -- Extract query parameters
2946
+ v_filters := COALESCE(p_query->'filters', '{}'::jsonb);
2947
+ v_sort_field := COALESCE(p_query->>'sort_field', 'name');
2948
+ v_sort_order := COALESCE(p_query->>'sort_order', 'asc');
2949
+
2950
+ -- Build WHERE clause from filters
2951
+ FOR v_field, v_filter IN SELECT * FROM jsonb_each(v_filters)
2952
+ LOOP
2953
+ -- Handle simple value (exact match)
2954
+ IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
2955
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_filter #>> '{}');
2956
+ ELSE
2957
+ -- Handle operator-based filters
2958
+ FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
2959
+ LOOP
2960
+ CASE v_operator
2961
+ WHEN 'eq' THEN
2962
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = %L', v_field, v_value #>> '{}');
2963
+ WHEN 'ne' THEN
2964
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != %L', v_field, v_value #>> '{}');
2965
+ WHEN 'gt' THEN
2966
+ v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
2967
+ WHEN 'gte' THEN
2968
+ v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
2969
+ WHEN 'lt' THEN
2970
+ v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
2971
+ WHEN 'lte' THEN
2972
+ v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
2973
+ WHEN 'in' THEN
2974
+ v_where_clause := v_where_clause || format(' AND %I::TEXT = ANY(%L)', v_field,
2975
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
2976
+ WHEN 'not_in' THEN
2977
+ v_where_clause := v_where_clause || format(' AND %I::TEXT != ALL(%L)', v_field,
2978
+ (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
2979
+ WHEN 'like' THEN
2980
+ v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
2981
+ WHEN 'ilike' THEN
2982
+ v_where_clause := v_where_clause || format(' AND %I ILIKE %L', v_field, v_value #>> '{}');
2983
+ WHEN 'is_null' THEN
2984
+ IF (v_value::text = 'true') THEN
2985
+ v_where_clause := v_where_clause || format(' AND %I IS NULL', v_field);
2986
+ END IF;
2987
+ WHEN 'not_null' THEN
2988
+ IF (v_value::text = 'true') THEN
2989
+ v_where_clause := v_where_clause || format(' AND %I IS NOT NULL', v_field);
2990
+ END IF;
2991
+ ELSE
2992
+ -- Unknown operator, skip
2993
+ END CASE;
2994
+ END LOOP;
2995
+ END IF;
2996
+ END LOOP;
2997
+
2998
+ -- Execute dynamic query (sort inside subquery for correct LIMIT behavior)
2999
+ EXECUTE format('
3000
+ SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), ''[]''::jsonb)
3001
+ FROM (
3002
+ SELECT * FROM tags
3003
+ WHERE (TRUE) %s
3004
+ ORDER BY %I %s
3005
+ LIMIT %L OFFSET %L
3006
+ ) t
3007
+ ', v_where_clause, v_sort_field, v_sort_order,
3008
+ COALESCE((p_query->>'limit')::int, 10),
3009
+ COALESCE((p_query->>'offset')::int, 0))
3010
+ INTO v_results;
3011
+
3012
+ RETURN v_results;
3013
+ END;
3014
+ $$;
3015
+
3016
+
3017
+ CREATE TABLE IF NOT EXISTS brand_tags (
3018
+ brand_id int NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
3019
+ tag_id int NOT NULL REFERENCES tags(id) ON DELETE CASCADE
3020
+ );