dzql 0.1.2 → 0.1.4
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/README.md +21 -6
- package/package.json +4 -4
- package/src/compiler/cli/index.js +174 -0
- package/src/compiler/codegen/graph-rules-codegen.js +259 -0
- package/src/compiler/codegen/notification-codegen.js +232 -0
- package/src/compiler/codegen/operation-codegen.js +555 -0
- package/src/compiler/codegen/permission-codegen.js +310 -0
- package/src/compiler/compiler.js +228 -0
- package/src/compiler/index.js +11 -0
- package/src/compiler/parser/entity-parser.js +299 -0
- package/src/compiler/parser/path-parser.js +290 -0
- package/src/database/migrations/002_functions.sql +39 -2
- package/src/database/migrations/003_operations.sql +10 -16
- package/src/database/migrations/004_search.sql +7 -0
- package/src/database/migrations/005_entities.sql +112 -0
- package/GETTING_STARTED.md +0 -1104
- package/REFERENCE.md +0 -960
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operation Code Generator
|
|
3
|
+
* Generates PostgreSQL functions for CRUD operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class OperationCodegen {
|
|
7
|
+
constructor(entity) {
|
|
8
|
+
this.entity = entity;
|
|
9
|
+
this.tableName = entity.tableName;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate all operation functions
|
|
14
|
+
* @returns {string} SQL for all operations
|
|
15
|
+
*/
|
|
16
|
+
generateAll() {
|
|
17
|
+
return [
|
|
18
|
+
this.generateGetFunction(),
|
|
19
|
+
this.generateSaveFunction(),
|
|
20
|
+
this.generateDeleteFunction(),
|
|
21
|
+
this.generateLookupFunction(),
|
|
22
|
+
this.generateSearchFunction()
|
|
23
|
+
].join('\n\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generate GET function
|
|
28
|
+
*/
|
|
29
|
+
generateGetFunction() {
|
|
30
|
+
const fkExpansions = this._generateFKExpansions();
|
|
31
|
+
const filterSensitiveFields = this._generateSensitiveFieldFilter();
|
|
32
|
+
|
|
33
|
+
return `-- GET operation for ${this.tableName}
|
|
34
|
+
CREATE OR REPLACE FUNCTION get_${this.tableName}(
|
|
35
|
+
p_user_id INT,
|
|
36
|
+
p_id INT,
|
|
37
|
+
p_on_date TIMESTAMPTZ DEFAULT NULL
|
|
38
|
+
) RETURNS JSONB AS $$
|
|
39
|
+
DECLARE
|
|
40
|
+
v_result JSONB;
|
|
41
|
+
v_record ${this.tableName}%ROWTYPE;
|
|
42
|
+
BEGIN
|
|
43
|
+
-- Fetch the record
|
|
44
|
+
SELECT * INTO v_record
|
|
45
|
+
FROM ${this.tableName}
|
|
46
|
+
WHERE id = p_id${this._generateTemporalFilter()};
|
|
47
|
+
|
|
48
|
+
IF NOT FOUND THEN
|
|
49
|
+
RAISE EXCEPTION 'Record not found: % with id=%', '${this.tableName}', p_id;
|
|
50
|
+
END IF;
|
|
51
|
+
|
|
52
|
+
-- Convert to JSONB
|
|
53
|
+
v_result := to_jsonb(v_record);
|
|
54
|
+
|
|
55
|
+
-- Check view permission
|
|
56
|
+
IF NOT can_view_${this.tableName}(p_user_id, v_result) THEN
|
|
57
|
+
RAISE EXCEPTION 'Permission denied: view on ${this.tableName}';
|
|
58
|
+
END IF;
|
|
59
|
+
|
|
60
|
+
${fkExpansions}
|
|
61
|
+
${filterSensitiveFields}
|
|
62
|
+
|
|
63
|
+
RETURN v_result;
|
|
64
|
+
END;
|
|
65
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Generate SAVE function
|
|
70
|
+
*/
|
|
71
|
+
generateSaveFunction() {
|
|
72
|
+
const graphRulesCall = this._generateGraphRulesCall();
|
|
73
|
+
const notificationSQL = this._generateNotificationSQL();
|
|
74
|
+
const filterSensitiveFields = this._generateSensitiveFieldFilter('v_output');
|
|
75
|
+
|
|
76
|
+
return `-- SAVE operation for ${this.tableName}
|
|
77
|
+
CREATE OR REPLACE FUNCTION save_${this.tableName}(
|
|
78
|
+
p_user_id INT,
|
|
79
|
+
p_data JSONB
|
|
80
|
+
) RETURNS JSONB AS $$
|
|
81
|
+
DECLARE
|
|
82
|
+
v_result ${this.tableName}%ROWTYPE;
|
|
83
|
+
v_existing ${this.tableName}%ROWTYPE;
|
|
84
|
+
v_output JSONB;
|
|
85
|
+
v_is_insert BOOLEAN := false;
|
|
86
|
+
v_notify_users INT[];
|
|
87
|
+
BEGIN
|
|
88
|
+
-- Determine if this is insert or update
|
|
89
|
+
IF p_data->>'id' IS NULL THEN
|
|
90
|
+
v_is_insert := true;
|
|
91
|
+
ELSE
|
|
92
|
+
-- Try to fetch existing record
|
|
93
|
+
SELECT * INTO v_existing
|
|
94
|
+
FROM ${this.tableName}
|
|
95
|
+
WHERE id = (p_data->>'id')::int;
|
|
96
|
+
|
|
97
|
+
v_is_insert := NOT FOUND;
|
|
98
|
+
END IF;
|
|
99
|
+
|
|
100
|
+
-- Check permissions
|
|
101
|
+
IF v_is_insert THEN
|
|
102
|
+
IF NOT can_create_${this.tableName}(p_user_id, p_data) THEN
|
|
103
|
+
RAISE EXCEPTION 'Permission denied: create on ${this.tableName}';
|
|
104
|
+
END IF;
|
|
105
|
+
ELSE
|
|
106
|
+
IF NOT can_update_${this.tableName}(p_user_id, to_jsonb(v_existing)) THEN
|
|
107
|
+
RAISE EXCEPTION 'Permission denied: update on ${this.tableName}';
|
|
108
|
+
END IF;
|
|
109
|
+
END IF;
|
|
110
|
+
|
|
111
|
+
-- Perform UPSERT
|
|
112
|
+
IF v_is_insert THEN
|
|
113
|
+
-- Dynamic INSERT from JSONB
|
|
114
|
+
EXECUTE (
|
|
115
|
+
SELECT format(
|
|
116
|
+
'INSERT INTO ${this.tableName} (%s) VALUES (%s) RETURNING *',
|
|
117
|
+
string_agg(quote_ident(key), ', '),
|
|
118
|
+
string_agg(quote_nullable(value), ', ')
|
|
119
|
+
)
|
|
120
|
+
FROM jsonb_each_text(p_data) kv(key, value)
|
|
121
|
+
) INTO v_result;
|
|
122
|
+
ELSE
|
|
123
|
+
-- Dynamic UPDATE from JSONB
|
|
124
|
+
EXECUTE (
|
|
125
|
+
SELECT format(
|
|
126
|
+
'UPDATE ${this.tableName} SET %s WHERE id = %L RETURNING *',
|
|
127
|
+
string_agg(quote_ident(key) || ' = ' || quote_nullable(value), ', '),
|
|
128
|
+
(p_data->>'id')::int
|
|
129
|
+
)
|
|
130
|
+
FROM jsonb_each_text(p_data) kv(key, value)
|
|
131
|
+
WHERE key != 'id'
|
|
132
|
+
) INTO v_result;
|
|
133
|
+
END IF;
|
|
134
|
+
|
|
135
|
+
${graphRulesCall}
|
|
136
|
+
${notificationSQL}
|
|
137
|
+
|
|
138
|
+
-- Prepare output (removing sensitive fields)
|
|
139
|
+
v_output := to_jsonb(v_result);
|
|
140
|
+
${filterSensitiveFields}
|
|
141
|
+
|
|
142
|
+
RETURN v_output;
|
|
143
|
+
END;
|
|
144
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Generate DELETE function
|
|
149
|
+
*/
|
|
150
|
+
generateDeleteFunction() {
|
|
151
|
+
const graphRulesCall = this._generateGraphRulesCall('delete');
|
|
152
|
+
const notificationSQL = this._generateNotificationSQL('delete');
|
|
153
|
+
const filterSensitiveFields = this._generateSensitiveFieldFilter('v_output');
|
|
154
|
+
|
|
155
|
+
const deleteSQL = this.entity.softDelete
|
|
156
|
+
? `UPDATE ${this.tableName} SET deleted_at = NOW() WHERE id = p_id RETURNING * INTO v_result;`
|
|
157
|
+
: `DELETE FROM ${this.tableName} WHERE id = p_id RETURNING * INTO v_result;`;
|
|
158
|
+
|
|
159
|
+
return `-- DELETE operation for ${this.tableName}
|
|
160
|
+
CREATE OR REPLACE FUNCTION delete_${this.tableName}(
|
|
161
|
+
p_user_id INT,
|
|
162
|
+
p_id INT
|
|
163
|
+
) RETURNS JSONB AS $$
|
|
164
|
+
DECLARE
|
|
165
|
+
v_result ${this.tableName}%ROWTYPE;
|
|
166
|
+
v_output JSONB;
|
|
167
|
+
v_notify_users INT[];
|
|
168
|
+
BEGIN
|
|
169
|
+
-- Fetch record first
|
|
170
|
+
SELECT * INTO v_result
|
|
171
|
+
FROM ${this.tableName}
|
|
172
|
+
WHERE id = p_id;
|
|
173
|
+
|
|
174
|
+
IF NOT FOUND THEN
|
|
175
|
+
RAISE EXCEPTION 'Record not found: % with id=%', '${this.tableName}', p_id;
|
|
176
|
+
END IF;
|
|
177
|
+
|
|
178
|
+
-- Check delete permission
|
|
179
|
+
IF NOT can_delete_${this.tableName}(p_user_id, to_jsonb(v_result)) THEN
|
|
180
|
+
RAISE EXCEPTION 'Permission denied: delete on ${this.tableName}';
|
|
181
|
+
END IF;
|
|
182
|
+
|
|
183
|
+
${graphRulesCall}
|
|
184
|
+
|
|
185
|
+
-- Perform delete
|
|
186
|
+
${deleteSQL}
|
|
187
|
+
|
|
188
|
+
${notificationSQL}
|
|
189
|
+
|
|
190
|
+
-- Prepare output (removing sensitive fields)
|
|
191
|
+
v_output := to_jsonb(v_result);
|
|
192
|
+
${filterSensitiveFields}
|
|
193
|
+
|
|
194
|
+
RETURN v_output;
|
|
195
|
+
END;
|
|
196
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Generate LOOKUP function
|
|
201
|
+
*/
|
|
202
|
+
generateLookupFunction() {
|
|
203
|
+
return `-- LOOKUP operation for ${this.tableName}
|
|
204
|
+
CREATE OR REPLACE FUNCTION lookup_${this.tableName}(
|
|
205
|
+
p_user_id INT,
|
|
206
|
+
p_filter TEXT DEFAULT NULL,
|
|
207
|
+
p_limit INT DEFAULT 50
|
|
208
|
+
) RETURNS JSONB AS $$
|
|
209
|
+
DECLARE
|
|
210
|
+
v_result JSONB;
|
|
211
|
+
BEGIN
|
|
212
|
+
SELECT COALESCE(jsonb_agg(
|
|
213
|
+
jsonb_build_object(
|
|
214
|
+
'value', id,
|
|
215
|
+
'label', ${this.entity.labelField}
|
|
216
|
+
) ORDER BY ${this.entity.labelField}
|
|
217
|
+
), '[]'::jsonb) INTO v_result
|
|
218
|
+
FROM ${this.tableName}
|
|
219
|
+
WHERE (p_filter IS NULL OR ${this.entity.labelField} ILIKE '%' || p_filter || '%')${this._generateTemporalFilter()}
|
|
220
|
+
LIMIT p_limit;
|
|
221
|
+
|
|
222
|
+
RETURN v_result;
|
|
223
|
+
END;
|
|
224
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Generate SEARCH function
|
|
229
|
+
*/
|
|
230
|
+
generateSearchFunction() {
|
|
231
|
+
const searchFields = this.entity.searchableFields || [this.entity.labelField];
|
|
232
|
+
const searchConditions = searchFields.map(field =>
|
|
233
|
+
`${field} ILIKE '%' || p_search || '%'`
|
|
234
|
+
).join(' OR ');
|
|
235
|
+
const filterSensitiveFieldsArray = this._generateSensitiveFieldFilterArray();
|
|
236
|
+
|
|
237
|
+
return `-- SEARCH operation for ${this.tableName}
|
|
238
|
+
CREATE OR REPLACE FUNCTION search_${this.tableName}(
|
|
239
|
+
p_user_id INT,
|
|
240
|
+
p_filters JSONB DEFAULT '{}',
|
|
241
|
+
p_search TEXT DEFAULT NULL,
|
|
242
|
+
p_sort JSONB DEFAULT NULL,
|
|
243
|
+
p_page INT DEFAULT 1,
|
|
244
|
+
p_limit INT DEFAULT 25
|
|
245
|
+
) RETURNS JSONB AS $$
|
|
246
|
+
DECLARE
|
|
247
|
+
v_data JSONB;
|
|
248
|
+
v_total INT;
|
|
249
|
+
v_offset INT;
|
|
250
|
+
v_sort_field TEXT;
|
|
251
|
+
v_sort_order TEXT;
|
|
252
|
+
v_where_clause TEXT := 'TRUE';
|
|
253
|
+
v_field TEXT;
|
|
254
|
+
v_filter JSONB;
|
|
255
|
+
v_operator TEXT;
|
|
256
|
+
v_value JSONB;
|
|
257
|
+
BEGIN
|
|
258
|
+
v_offset := (p_page - 1) * p_limit;
|
|
259
|
+
|
|
260
|
+
-- Extract sort parameters
|
|
261
|
+
v_sort_field := COALESCE(p_sort->>'field', '${this.entity.labelField}');
|
|
262
|
+
v_sort_order := COALESCE(p_sort->>'order', 'asc');
|
|
263
|
+
|
|
264
|
+
-- Build WHERE clause from filters
|
|
265
|
+
FOR v_field, v_filter IN SELECT * FROM jsonb_each(p_filters)
|
|
266
|
+
LOOP
|
|
267
|
+
-- Handle simple value (exact match)
|
|
268
|
+
IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
|
|
269
|
+
v_where_clause := v_where_clause || format(' AND %I = %L', v_field, v_filter #>> '{}');
|
|
270
|
+
ELSE
|
|
271
|
+
-- Handle operator-based filters
|
|
272
|
+
FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
|
|
273
|
+
LOOP
|
|
274
|
+
CASE v_operator
|
|
275
|
+
WHEN 'eq' THEN
|
|
276
|
+
v_where_clause := v_where_clause || format(' AND %I = %L', v_field, v_value #>> '{}');
|
|
277
|
+
WHEN 'ne' THEN
|
|
278
|
+
v_where_clause := v_where_clause || format(' AND %I != %L', v_field, v_value #>> '{}');
|
|
279
|
+
WHEN 'gt' THEN
|
|
280
|
+
v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
|
|
281
|
+
WHEN 'gte' THEN
|
|
282
|
+
v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
|
|
283
|
+
WHEN 'lt' THEN
|
|
284
|
+
v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
|
|
285
|
+
WHEN 'lte' THEN
|
|
286
|
+
v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
|
|
287
|
+
WHEN 'in' THEN
|
|
288
|
+
v_where_clause := v_where_clause || format(' AND %I = ANY(%L::TEXT[])', v_field,
|
|
289
|
+
(SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
|
|
290
|
+
WHEN 'ilike' THEN
|
|
291
|
+
v_where_clause := v_where_clause || format(' AND %I ILIKE %L', v_field, v_value #>> '{}');
|
|
292
|
+
WHEN 'like' THEN
|
|
293
|
+
v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
|
|
294
|
+
ELSE
|
|
295
|
+
-- Unknown operator, skip
|
|
296
|
+
END CASE;
|
|
297
|
+
END LOOP;
|
|
298
|
+
END IF;
|
|
299
|
+
END LOOP;
|
|
300
|
+
|
|
301
|
+
-- Add search condition
|
|
302
|
+
IF p_search IS NOT NULL THEN
|
|
303
|
+
v_where_clause := v_where_clause || ' AND (${searchConditions})';
|
|
304
|
+
END IF;
|
|
305
|
+
|
|
306
|
+
-- Add temporal filter
|
|
307
|
+
v_where_clause := v_where_clause || '${this._generateTemporalFilter().replace(/\n/g, ' ')}';
|
|
308
|
+
|
|
309
|
+
-- Get total count
|
|
310
|
+
EXECUTE format('SELECT COUNT(*) FROM ${this.tableName} WHERE %s', v_where_clause) INTO v_total;
|
|
311
|
+
|
|
312
|
+
-- Get data
|
|
313
|
+
EXECUTE format('
|
|
314
|
+
SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY %I %s), ''[]''::jsonb)
|
|
315
|
+
FROM ${this.tableName} t
|
|
316
|
+
WHERE %s
|
|
317
|
+
LIMIT %L OFFSET %L
|
|
318
|
+
', v_sort_field, v_sort_order, v_where_clause, p_limit, v_offset) INTO v_data;
|
|
319
|
+
|
|
320
|
+
${filterSensitiveFieldsArray}
|
|
321
|
+
|
|
322
|
+
RETURN jsonb_build_object(
|
|
323
|
+
'data', v_data,
|
|
324
|
+
'total', v_total,
|
|
325
|
+
'page', p_page,
|
|
326
|
+
'limit', p_limit
|
|
327
|
+
);
|
|
328
|
+
END;
|
|
329
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Generate FK expansions for GET
|
|
334
|
+
* @private
|
|
335
|
+
*/
|
|
336
|
+
_generateFKExpansions() {
|
|
337
|
+
if (!this.entity.fkIncludes || Object.keys(this.entity.fkIncludes).length === 0) {
|
|
338
|
+
return '';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const expansions = [];
|
|
342
|
+
|
|
343
|
+
for (const [key, targetTable] of Object.entries(this.entity.fkIncludes)) {
|
|
344
|
+
if (key === targetTable) {
|
|
345
|
+
// Reverse FK: child array
|
|
346
|
+
expansions.push(`
|
|
347
|
+
-- Expand ${key} (child array)
|
|
348
|
+
v_result := v_result || jsonb_build_object(
|
|
349
|
+
'${key}',
|
|
350
|
+
(SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), '[]'::jsonb)
|
|
351
|
+
FROM ${targetTable} t
|
|
352
|
+
WHERE t.${this._singularize(this.tableName)}_id = v_record.id)
|
|
353
|
+
);`);
|
|
354
|
+
} else {
|
|
355
|
+
// Direct FK: single object
|
|
356
|
+
const fkField = key.endsWith('_id') ? key : key + '_id';
|
|
357
|
+
expansions.push(`
|
|
358
|
+
-- Expand ${key} (foreign key)
|
|
359
|
+
IF v_record.${fkField} IS NOT NULL THEN
|
|
360
|
+
v_result := v_result || jsonb_build_object(
|
|
361
|
+
'${key}',
|
|
362
|
+
(SELECT to_jsonb(t.*) FROM ${targetTable} t WHERE t.id = v_record.${fkField})
|
|
363
|
+
);
|
|
364
|
+
END IF;`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return expansions.join('');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Generate temporal filter
|
|
373
|
+
* @private
|
|
374
|
+
*/
|
|
375
|
+
_generateTemporalFilter() {
|
|
376
|
+
if (!this.entity.temporalFields || Object.keys(this.entity.temporalFields).length === 0) {
|
|
377
|
+
return '';
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const validFrom = this.entity.temporalFields.valid_from || 'valid_from';
|
|
381
|
+
const validTo = this.entity.temporalFields.valid_to || 'valid_to';
|
|
382
|
+
|
|
383
|
+
return `
|
|
384
|
+
AND ${validFrom} <= COALESCE(p_on_date, NOW())
|
|
385
|
+
AND (${validTo} > COALESCE(p_on_date, NOW()) OR ${validTo} IS NULL)`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Generate graph rules call
|
|
390
|
+
* @private
|
|
391
|
+
*/
|
|
392
|
+
_generateGraphRulesCall(operation = null) {
|
|
393
|
+
if (!this.entity.graphRules || Object.keys(this.entity.graphRules).length === 0) {
|
|
394
|
+
return '';
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// For DELETE operation
|
|
398
|
+
if (operation === 'delete') {
|
|
399
|
+
if (this.entity.graphRules.on_delete) {
|
|
400
|
+
return `
|
|
401
|
+
-- Execute graph rules: on_delete
|
|
402
|
+
PERFORM _graph_${this.tableName}_on_delete(p_user_id, to_jsonb(v_result));`;
|
|
403
|
+
}
|
|
404
|
+
return '';
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// For SAVE operation (create/update)
|
|
408
|
+
const calls = [];
|
|
409
|
+
|
|
410
|
+
if (this.entity.graphRules.on_create) {
|
|
411
|
+
calls.push(`
|
|
412
|
+
-- Execute graph rules: on_create (if insert)
|
|
413
|
+
IF v_is_insert THEN
|
|
414
|
+
PERFORM _graph_${this.tableName}_on_create(p_user_id, to_jsonb(v_result));
|
|
415
|
+
END IF;`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (this.entity.graphRules.on_update) {
|
|
419
|
+
calls.push(`
|
|
420
|
+
-- Execute graph rules: on_update (if update)
|
|
421
|
+
IF NOT v_is_insert THEN
|
|
422
|
+
PERFORM _graph_${this.tableName}_on_update(p_user_id, to_jsonb(v_existing), to_jsonb(v_result));
|
|
423
|
+
END IF;`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return calls.join('');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Generate notification SQL
|
|
431
|
+
* @private
|
|
432
|
+
*/
|
|
433
|
+
_generateNotificationSQL(operation = 'save') {
|
|
434
|
+
const hasNotificationPaths = this.entity.notificationPaths && Object.keys(this.entity.notificationPaths).length > 0;
|
|
435
|
+
|
|
436
|
+
if (operation === 'save') {
|
|
437
|
+
return `
|
|
438
|
+
-- Resolve notification recipients
|
|
439
|
+
${hasNotificationPaths ? `v_notify_users := _resolve_notification_paths_${this.tableName}(p_user_id, to_jsonb(v_result));` : 'v_notify_users := ARRAY[]::INT[];'}
|
|
440
|
+
|
|
441
|
+
-- Create event for real-time notifications
|
|
442
|
+
INSERT INTO dzql.events (
|
|
443
|
+
table_name,
|
|
444
|
+
op,
|
|
445
|
+
pk,
|
|
446
|
+
before,
|
|
447
|
+
after,
|
|
448
|
+
user_id,
|
|
449
|
+
notify_users
|
|
450
|
+
) VALUES (
|
|
451
|
+
'${this.tableName}',
|
|
452
|
+
CASE WHEN v_is_insert THEN 'insert' ELSE 'update' END,
|
|
453
|
+
jsonb_build_object('id', v_result.id),
|
|
454
|
+
CASE WHEN NOT v_is_insert THEN to_jsonb(v_existing) ELSE NULL END,
|
|
455
|
+
to_jsonb(v_result),
|
|
456
|
+
p_user_id,
|
|
457
|
+
v_notify_users
|
|
458
|
+
);`;
|
|
459
|
+
} else if (operation === 'delete') {
|
|
460
|
+
return `
|
|
461
|
+
-- Resolve notification recipients
|
|
462
|
+
${hasNotificationPaths ? `v_notify_users := _resolve_notification_paths_${this.tableName}(p_user_id, to_jsonb(v_result));` : 'v_notify_users := ARRAY[]::INT[];'}
|
|
463
|
+
|
|
464
|
+
-- Create event for real-time notifications
|
|
465
|
+
INSERT INTO dzql.events (
|
|
466
|
+
table_name,
|
|
467
|
+
op,
|
|
468
|
+
pk,
|
|
469
|
+
before,
|
|
470
|
+
after,
|
|
471
|
+
user_id,
|
|
472
|
+
notify_users
|
|
473
|
+
) VALUES (
|
|
474
|
+
'${this.tableName}',
|
|
475
|
+
'delete',
|
|
476
|
+
jsonb_build_object('id', v_result.id),
|
|
477
|
+
to_jsonb(v_result),
|
|
478
|
+
NULL,
|
|
479
|
+
p_user_id,
|
|
480
|
+
v_notify_users
|
|
481
|
+
);`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return '';
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Generate INSERT columns
|
|
489
|
+
* @private
|
|
490
|
+
*/
|
|
491
|
+
_generateInsertColumns() {
|
|
492
|
+
// This is a simplified version - in reality, would introspect table schema
|
|
493
|
+
return "SELECT string_agg(quote_ident(key), ', ') FROM jsonb_object_keys(p_data) key";
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Generate INSERT values
|
|
498
|
+
* @private
|
|
499
|
+
*/
|
|
500
|
+
_generateInsertValues() {
|
|
501
|
+
return "SELECT string_agg(quote_literal(value), ', ') FROM jsonb_each_text(p_data) kv(key, value)";
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Generate UPDATE SET clause
|
|
506
|
+
* @private
|
|
507
|
+
*/
|
|
508
|
+
_generateUpdateSet() {
|
|
509
|
+
return `SELECT string_agg(quote_ident(key) || ' = ' || quote_literal(value), ', ')
|
|
510
|
+
FROM jsonb_each_text(p_data) kv(key, value)
|
|
511
|
+
WHERE key != 'id'`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Simple singularization (remove trailing 's')
|
|
516
|
+
* @private
|
|
517
|
+
*/
|
|
518
|
+
_singularize(word) {
|
|
519
|
+
return word.endsWith('s') ? word.slice(0, -1) : word;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Generate SQL to filter out sensitive fields from a JSONB variable
|
|
524
|
+
* @param {string} varName - Name of the JSONB variable to filter (default: v_result)
|
|
525
|
+
* @private
|
|
526
|
+
*/
|
|
527
|
+
_generateSensitiveFieldFilter(varName = 'v_result') {
|
|
528
|
+
return `
|
|
529
|
+
-- Remove sensitive fields (password_hash, etc.) from result
|
|
530
|
+
${varName} := ${varName} - 'password_hash' - 'password' - 'secret' - 'token';`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Generate SQL to filter out sensitive fields from array of JSONB objects
|
|
535
|
+
* @private
|
|
536
|
+
*/
|
|
537
|
+
_generateSensitiveFieldFilterArray() {
|
|
538
|
+
return `
|
|
539
|
+
-- Remove sensitive fields from each record in the array
|
|
540
|
+
v_data := (
|
|
541
|
+
SELECT jsonb_agg(elem - 'password_hash' - 'password' - 'secret' - 'token')
|
|
542
|
+
FROM jsonb_array_elements(v_data) elem
|
|
543
|
+
);`;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Generate all operation functions for an entity
|
|
549
|
+
* @param {Object} entity - Entity configuration
|
|
550
|
+
* @returns {string} SQL for all operations
|
|
551
|
+
*/
|
|
552
|
+
export function generateOperations(entity) {
|
|
553
|
+
const codegen = new OperationCodegen(entity);
|
|
554
|
+
return codegen.generateAll();
|
|
555
|
+
}
|