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