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