dzql 0.1.3 → 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.
@@ -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
+ }