dzql 0.5.33 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.env.sample +28 -0
  2. package/compose.yml +28 -0
  3. package/dist/client/index.ts +1 -0
  4. package/dist/client/stores/useMyProfileStore.ts +114 -0
  5. package/dist/client/stores/useOrgDashboardStore.ts +131 -0
  6. package/dist/client/stores/useVenueDetailStore.ts +117 -0
  7. package/dist/client/ws.ts +716 -0
  8. package/dist/db/migrations/000_core.sql +92 -0
  9. package/dist/db/migrations/20251229T212912022Z_schema.sql +3020 -0
  10. package/dist/db/migrations/20251229T212912022Z_subscribables.sql +371 -0
  11. package/dist/runtime/manifest.json +1562 -0
  12. package/docs/README.md +293 -36
  13. package/docs/feature-requests/applyPatch-bug-report.md +85 -0
  14. package/docs/feature-requests/connection-ready-profile.md +57 -0
  15. package/docs/feature-requests/hidden-bug-report.md +111 -0
  16. package/docs/feature-requests/hidden-fields-subscribables.md +34 -0
  17. package/docs/feature-requests/subscribable-param-key-bug.md +38 -0
  18. package/docs/feature-requests/todo.md +146 -0
  19. package/docs/for_ai.md +641 -0
  20. package/docs/project-setup.md +432 -0
  21. package/examples/blog.ts +50 -0
  22. package/examples/invalid.ts +18 -0
  23. package/examples/venues.js +485 -0
  24. package/package.json +23 -60
  25. package/src/cli/codegen/client.ts +99 -0
  26. package/src/cli/codegen/manifest.ts +95 -0
  27. package/src/cli/codegen/pinia.ts +174 -0
  28. package/src/cli/codegen/realtime.ts +58 -0
  29. package/src/cli/codegen/sql.ts +698 -0
  30. package/src/cli/codegen/subscribable_sql.ts +547 -0
  31. package/src/cli/codegen/subscribable_store.ts +184 -0
  32. package/src/cli/codegen/types.ts +142 -0
  33. package/src/cli/compiler/analyzer.ts +52 -0
  34. package/src/cli/compiler/graph_rules.ts +251 -0
  35. package/src/cli/compiler/ir.ts +233 -0
  36. package/src/cli/compiler/loader.ts +132 -0
  37. package/src/cli/compiler/permissions.ts +227 -0
  38. package/src/cli/index.ts +164 -0
  39. package/src/client/index.ts +1 -0
  40. package/src/client/ws.ts +286 -0
  41. package/src/create/.env.example +8 -0
  42. package/src/create/README.md +101 -0
  43. package/src/create/compose.yml +14 -0
  44. package/src/create/domain.ts +153 -0
  45. package/src/create/package.json +24 -0
  46. package/src/create/server.ts +18 -0
  47. package/src/create/setup.sh +11 -0
  48. package/src/create/tsconfig.json +15 -0
  49. package/src/runtime/auth.ts +39 -0
  50. package/src/runtime/db.ts +33 -0
  51. package/src/runtime/errors.ts +51 -0
  52. package/src/runtime/index.ts +98 -0
  53. package/src/runtime/js_functions.ts +63 -0
  54. package/src/runtime/manifest_loader.ts +29 -0
  55. package/src/runtime/namespace.ts +483 -0
  56. package/src/runtime/server.ts +87 -0
  57. package/src/runtime/ws.ts +197 -0
  58. package/src/shared/ir.ts +197 -0
  59. package/tests/client.test.ts +38 -0
  60. package/tests/codegen.test.ts +71 -0
  61. package/tests/compiler.test.ts +45 -0
  62. package/tests/graph_rules.test.ts +173 -0
  63. package/tests/integration/db.test.ts +174 -0
  64. package/tests/integration/e2e.test.ts +65 -0
  65. package/tests/integration/features.test.ts +922 -0
  66. package/tests/integration/full_stack.test.ts +262 -0
  67. package/tests/integration/setup.ts +45 -0
  68. package/tests/ir.test.ts +32 -0
  69. package/tests/namespace.test.ts +395 -0
  70. package/tests/permissions.test.ts +55 -0
  71. package/tests/pinia.test.ts +48 -0
  72. package/tests/realtime.test.ts +22 -0
  73. package/tests/runtime.test.ts +80 -0
  74. package/tests/subscribable_gen.test.ts +72 -0
  75. package/tests/subscribable_reactivity.test.ts +258 -0
  76. package/tests/venues_gen.test.ts +25 -0
  77. package/tsconfig.json +20 -0
  78. package/tsconfig.tsbuildinfo +1 -0
  79. package/README.md +0 -90
  80. package/bin/cli.js +0 -727
  81. package/docs/compiler/ADVANCED_FILTERS.md +0 -183
  82. package/docs/compiler/CODING_STANDARDS.md +0 -415
  83. package/docs/compiler/COMPARISON.md +0 -673
  84. package/docs/compiler/QUICKSTART.md +0 -326
  85. package/docs/compiler/README.md +0 -134
  86. package/docs/examples/README.md +0 -38
  87. package/docs/examples/blog.sql +0 -160
  88. package/docs/examples/venue-detail-simple.sql +0 -8
  89. package/docs/examples/venue-detail-subscribable.sql +0 -45
  90. package/docs/for-ai/claude-guide.md +0 -1210
  91. package/docs/getting-started/quickstart.md +0 -125
  92. package/docs/getting-started/subscriptions-quick-start.md +0 -203
  93. package/docs/getting-started/tutorial.md +0 -1104
  94. package/docs/guides/atomic-updates.md +0 -299
  95. package/docs/guides/client-stores.md +0 -730
  96. package/docs/guides/composite-primary-keys.md +0 -158
  97. package/docs/guides/custom-functions.md +0 -362
  98. package/docs/guides/drop-semantics.md +0 -554
  99. package/docs/guides/field-defaults.md +0 -240
  100. package/docs/guides/interpreter-vs-compiler.md +0 -237
  101. package/docs/guides/many-to-many.md +0 -929
  102. package/docs/guides/subscriptions.md +0 -537
  103. package/docs/reference/api.md +0 -1373
  104. package/docs/reference/client.md +0 -224
  105. package/src/client/stores/index.js +0 -8
  106. package/src/client/stores/useAppStore.js +0 -285
  107. package/src/client/stores/useWsStore.js +0 -289
  108. package/src/client/ws.js +0 -762
  109. package/src/compiler/cli/compile-example.js +0 -33
  110. package/src/compiler/cli/compile-subscribable.js +0 -43
  111. package/src/compiler/cli/debug-compile.js +0 -44
  112. package/src/compiler/cli/debug-parse.js +0 -26
  113. package/src/compiler/cli/debug-path-parser.js +0 -18
  114. package/src/compiler/cli/debug-subscribable-parser.js +0 -21
  115. package/src/compiler/cli/index.js +0 -174
  116. package/src/compiler/codegen/auth-codegen.js +0 -153
  117. package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
  118. package/src/compiler/codegen/graph-rules-codegen.js +0 -450
  119. package/src/compiler/codegen/notification-codegen.js +0 -232
  120. package/src/compiler/codegen/operation-codegen.js +0 -1382
  121. package/src/compiler/codegen/permission-codegen.js +0 -318
  122. package/src/compiler/codegen/subscribable-codegen.js +0 -827
  123. package/src/compiler/compiler.js +0 -371
  124. package/src/compiler/index.js +0 -11
  125. package/src/compiler/parser/entity-parser.js +0 -440
  126. package/src/compiler/parser/path-parser.js +0 -290
  127. package/src/compiler/parser/subscribable-parser.js +0 -244
  128. package/src/database/dzql-core.sql +0 -161
  129. package/src/database/migrations/001_schema.sql +0 -60
  130. package/src/database/migrations/002_functions.sql +0 -890
  131. package/src/database/migrations/003_operations.sql +0 -1135
  132. package/src/database/migrations/004_search.sql +0 -581
  133. package/src/database/migrations/005_entities.sql +0 -730
  134. package/src/database/migrations/006_auth.sql +0 -94
  135. package/src/database/migrations/007_events.sql +0 -133
  136. package/src/database/migrations/008_hello.sql +0 -18
  137. package/src/database/migrations/008a_meta.sql +0 -172
  138. package/src/database/migrations/009_subscriptions.sql +0 -240
  139. package/src/database/migrations/010_atomic_updates.sql +0 -157
  140. package/src/database/migrations/010_fix_m2m_events.sql +0 -94
  141. package/src/index.js +0 -40
  142. package/src/server/api.js +0 -9
  143. package/src/server/db.js +0 -442
  144. package/src/server/index.js +0 -317
  145. package/src/server/logger.js +0 -259
  146. package/src/server/mcp.js +0 -594
  147. package/src/server/meta-route.js +0 -251
  148. package/src/server/namespace.js +0 -292
  149. package/src/server/subscriptions.js +0 -351
  150. package/src/server/ws.js +0 -573
@@ -1,1382 +0,0 @@
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
- this.primaryKey = entity.primaryKey || ['id'];
11
- this.isCompositePK = this.primaryKey.length > 1 || this.primaryKey[0] !== 'id';
12
- }
13
-
14
- /**
15
- * Determine appropriate type cast for a column name
16
- * Only casts to int if column is 'id' or ends with '_id'
17
- * @private
18
- */
19
- _getTypeCast(columnName) {
20
- if (columnName === 'id' || columnName.endsWith('_id')) {
21
- return '::int';
22
- }
23
- return '';
24
- }
25
-
26
- /**
27
- * Generate all operation functions
28
- * @returns {string} SQL for all operations
29
- */
30
- generateAll() {
31
- return [
32
- this.generateGetFunction(),
33
- this.generateSaveFunction(),
34
- this.generateDeleteFunction(),
35
- this.generateLookupFunction(),
36
- this.generateSearchFunction()
37
- ].join('\n\n');
38
- }
39
-
40
- /**
41
- * Generate GET function
42
- */
43
- generateGetFunction() {
44
- const fkExpansions = this._generateFKExpansions();
45
- const m2mExpansionForGet = this._generateM2MExpansionForGet();
46
- const filterSensitiveFields = this._generateSensitiveFieldFilter();
47
-
48
- // For composite PKs, accept JSONB containing all PK fields
49
- // For simple PKs, accept INT for backwards compatibility
50
- if (this.isCompositePK) {
51
- const whereClause = this.primaryKey.map(col => `${col} = (p_pk->>'${col}')${this._getTypeCast(col)}`).join(' AND ');
52
- const pkDescription = this.primaryKey.join(', ');
53
-
54
- return `-- GET operation for ${this.tableName} (composite primary key: ${pkDescription})
55
- CREATE OR REPLACE FUNCTION get_${this.tableName}(
56
- p_user_id INT,
57
- p_pk JSONB,
58
- p_on_date TIMESTAMPTZ DEFAULT NULL
59
- ) RETURNS JSONB AS $$
60
- DECLARE
61
- v_result JSONB;
62
- v_record ${this.tableName}%ROWTYPE;
63
- BEGIN
64
- -- Fetch the record by composite primary key
65
- SELECT * INTO v_record
66
- FROM ${this.tableName}
67
- WHERE ${whereClause}${this._generateTemporalFilter()};
68
-
69
- IF NOT FOUND THEN
70
- RAISE EXCEPTION 'Record not found: % with pk=%', '${this.tableName}', p_pk;
71
- END IF;
72
-
73
- -- Convert to JSONB
74
- v_result := to_jsonb(v_record);
75
-
76
- -- Check view permission
77
- IF NOT can_view_${this.tableName}(p_user_id, v_result) THEN
78
- RAISE EXCEPTION 'Permission denied: view on ${this.tableName}';
79
- END IF;
80
-
81
- ${fkExpansions}
82
- ${m2mExpansionForGet}
83
- ${filterSensitiveFields}
84
-
85
- RETURN v_result;
86
- END;
87
- $$ LANGUAGE plpgsql SECURITY DEFINER;`;
88
- }
89
-
90
- // Simple PK (id column) - original signature for backwards compatibility
91
- return `-- GET operation for ${this.tableName}
92
- CREATE OR REPLACE FUNCTION get_${this.tableName}(
93
- p_user_id INT,
94
- p_id INT,
95
- p_on_date TIMESTAMPTZ DEFAULT NULL
96
- ) RETURNS JSONB AS $$
97
- DECLARE
98
- v_result JSONB;
99
- v_record ${this.tableName}%ROWTYPE;
100
- BEGIN
101
- -- Fetch the record
102
- SELECT * INTO v_record
103
- FROM ${this.tableName}
104
- WHERE id = p_id${this._generateTemporalFilter()};
105
-
106
- IF NOT FOUND THEN
107
- RAISE EXCEPTION 'Record not found: % with id=%', '${this.tableName}', p_id;
108
- END IF;
109
-
110
- -- Convert to JSONB
111
- v_result := to_jsonb(v_record);
112
-
113
- -- Check view permission
114
- IF NOT can_view_${this.tableName}(p_user_id, v_result) THEN
115
- RAISE EXCEPTION 'Permission denied: view on ${this.tableName}';
116
- END IF;
117
-
118
- ${fkExpansions}
119
- ${m2mExpansionForGet}
120
- ${filterSensitiveFields}
121
-
122
- RETURN v_result;
123
- END;
124
- $$ LANGUAGE plpgsql SECURITY DEFINER;`;
125
- }
126
-
127
- /**
128
- * Generate SAVE function
129
- */
130
- generateSaveFunction() {
131
- const graphRulesCall = this._generateGraphRulesCall();
132
- const notificationSQL = this._generateNotificationSQL();
133
- const filterSensitiveFields = this._generateSensitiveFieldFilter('v_output');
134
- const m2mVariables = this._generateM2MVariableDeclarations();
135
- const m2mExtraction = this._generateM2MExtraction();
136
- const m2mSync = this._generateM2MSync();
137
- const m2mExpansion = this._generateM2MExpansion();
138
- const fieldDefaults = this._generateFieldDefaults();
139
-
140
- // For composite PKs, generate a different function signature and logic
141
- if (this.isCompositePK) {
142
- return this._generateCompositePKSaveFunction({
143
- graphRulesCall,
144
- notificationSQL,
145
- filterSensitiveFields,
146
- m2mVariables,
147
- m2mExtraction,
148
- m2mSync,
149
- m2mExpansion,
150
- fieldDefaults
151
- });
152
- }
153
-
154
- // Simple PK (id column) - original implementation for backwards compatibility
155
- return `-- SAVE operation for ${this.tableName}
156
- CREATE OR REPLACE FUNCTION save_${this.tableName}(
157
- p_user_id INT,
158
- p_data JSONB
159
- ) RETURNS JSONB AS $$
160
- DECLARE
161
- v_result ${this.tableName}%ROWTYPE;
162
- v_existing ${this.tableName}%ROWTYPE;
163
- v_output JSONB;
164
- v_before JSONB;
165
- v_is_insert BOOLEAN := false;
166
- v_notify_users INT[];
167
- ${m2mVariables}
168
- BEGIN
169
- ${m2mExtraction}
170
- -- Determine if this is insert or update
171
- IF p_data->>'id' IS NULL THEN
172
- v_is_insert := true;
173
- ELSE
174
- -- Try to fetch existing record
175
- SELECT * INTO v_existing
176
- FROM ${this.tableName}
177
- WHERE id = (p_data->>'id')::int;
178
-
179
- v_is_insert := NOT FOUND;
180
- END IF;
181
-
182
- -- Check permissions
183
- IF v_is_insert THEN
184
- IF NOT can_create_${this.tableName}(p_user_id, p_data) THEN
185
- RAISE EXCEPTION 'Permission denied: create on ${this.tableName}';
186
- END IF;
187
- ELSE
188
- IF NOT can_update_${this.tableName}(p_user_id, to_jsonb(v_existing)) THEN
189
- RAISE EXCEPTION 'Permission denied: update on ${this.tableName}';
190
- END IF;
191
- END IF;
192
-
193
- -- Expand M2M for existing record (for UPDATE events "before" field)
194
- IF NOT v_is_insert THEN
195
- v_before := to_jsonb(v_existing);
196
- ${this._generateM2MExpansionForBefore()}
197
- END IF;
198
- ${fieldDefaults}
199
- -- Perform UPSERT
200
- IF v_is_insert THEN
201
- -- Dynamic INSERT from JSONB
202
- EXECUTE (
203
- SELECT format(
204
- 'INSERT INTO ${this.tableName} (%s) VALUES (%s) RETURNING *',
205
- string_agg(quote_ident(key), ', '),
206
- string_agg(quote_nullable(value), ', ')
207
- )
208
- FROM jsonb_each_text(p_data) kv(key, value)
209
- ) INTO v_result;
210
- ELSE
211
- -- Dynamic UPDATE from JSONB (only if there are fields to update)
212
- IF (SELECT COUNT(*) FROM jsonb_object_keys(p_data) WHERE jsonb_object_keys != 'id') > 0 THEN
213
- EXECUTE (
214
- SELECT format(
215
- 'UPDATE ${this.tableName} SET %s WHERE id = %L RETURNING *',
216
- string_agg(quote_ident(key) || ' = ' || quote_nullable(value), ', '),
217
- (p_data->>'id')::int
218
- )
219
- FROM jsonb_each_text(p_data) kv(key, value)
220
- WHERE key != 'id'
221
- ) INTO v_result;
222
- ELSE
223
- -- No fields to update (only M2M fields were provided), just fetch existing
224
- v_result := v_existing;
225
- END IF;
226
- END IF;
227
-
228
- ${m2mSync}
229
-
230
- -- Prepare output with M2M fields (BEFORE event creation for real-time notifications!)
231
- v_output := to_jsonb(v_result);
232
- ${m2mExpansion}
233
-
234
- ${graphRulesCall}
235
- ${notificationSQL}
236
-
237
- ${filterSensitiveFields}
238
-
239
- RETURN v_output;
240
- END;
241
- $$ LANGUAGE plpgsql SECURITY DEFINER;`;
242
- }
243
-
244
- /**
245
- * Generate SAVE function for composite primary keys
246
- * Uses INSERT ... ON CONFLICT DO UPDATE (UPSERT) to avoid race conditions
247
- * @private
248
- */
249
- _generateCompositePKSaveFunction(helpers) {
250
- const {
251
- graphRulesCall,
252
- notificationSQL,
253
- filterSensitiveFields,
254
- m2mVariables,
255
- m2mExtraction,
256
- m2mSync,
257
- m2mExpansion,
258
- fieldDefaults
259
- } = helpers;
260
-
261
- const pkDescription = this.primaryKey.join(', ');
262
-
263
- // Generate WHERE clause for composite PK lookup (used for permission checks and v_before)
264
- // e.g., "entity_type = (p_data->>'entity_type') AND entity_id = (p_data->>'entity_id')::int"
265
- const whereClause = this.primaryKey.map(col => {
266
- return `${col} = (p_data->>'${col}')${this._getTypeCast(col)}`;
267
- }).join(' AND ');
268
-
269
- // Check if all PK fields are present in p_data
270
- const pkNullCheck = this.primaryKey.map(col => `p_data->>'${col}' IS NULL`).join(' OR ');
271
-
272
- // Build the list of PK field names for exclusion from SET clause
273
- const pkFieldsExclusion = this.primaryKey.map(col => `jsonb_object_keys != '${col}'`).join(' AND ');
274
-
275
- // For M2M sync and expansion, we need to reference the PK fields from v_result
276
- const m2mSyncCompositePK = this._generateM2MSyncCompositePK();
277
- const m2mExpansionCompositePK = this._generateM2MExpansionCompositePK();
278
- const m2mExpansionForBeforeCompositePK = this._generateM2MExpansionForBeforeCompositePK();
279
-
280
- // Generate ON CONFLICT clause for composite PK
281
- // e.g., "(entity_type, entity_id)"
282
- const onConflictPK = `(${this.primaryKey.join(', ')})`;
283
-
284
- return `-- SAVE operation for ${this.tableName} (composite primary key: ${pkDescription})
285
- -- Uses UPSERT (INSERT ... ON CONFLICT DO UPDATE) to prevent race conditions
286
- CREATE OR REPLACE FUNCTION save_${this.tableName}(
287
- p_user_id INT,
288
- p_data JSONB
289
- ) RETURNS JSONB AS $$
290
- DECLARE
291
- v_result ${this.tableName}%ROWTYPE;
292
- v_existing ${this.tableName}%ROWTYPE;
293
- v_output JSONB;
294
- v_before JSONB;
295
- v_is_insert BOOLEAN := false;
296
- v_notify_users INT[];
297
- v_columns TEXT;
298
- v_values TEXT;
299
- v_update_set TEXT;
300
- ${m2mVariables}
301
- BEGIN
302
- ${m2mExtraction}
303
- -- Check if this might be an update (all PK fields present)
304
- -- We still need to check for existing record for permission checks and v_before
305
- IF NOT (${pkNullCheck}) THEN
306
- SELECT * INTO v_existing
307
- FROM ${this.tableName}
308
- WHERE ${whereClause};
309
-
310
- IF FOUND THEN
311
- -- Check update permission on existing record
312
- IF NOT can_update_${this.tableName}(p_user_id, to_jsonb(v_existing)) THEN
313
- RAISE EXCEPTION 'Permission denied: update on ${this.tableName}';
314
- END IF;
315
- -- Store before state for M2M expansion
316
- v_before := to_jsonb(v_existing);
317
- ${m2mExpansionForBeforeCompositePK}
318
- ELSE
319
- -- Record doesn't exist yet, this will be an insert
320
- IF NOT can_create_${this.tableName}(p_user_id, p_data) THEN
321
- RAISE EXCEPTION 'Permission denied: create on ${this.tableName}';
322
- END IF;
323
- END IF;
324
- ELSE
325
- -- Missing PK fields - must be a new insert
326
- IF NOT can_create_${this.tableName}(p_user_id, p_data) THEN
327
- RAISE EXCEPTION 'Permission denied: create on ${this.tableName}';
328
- END IF;
329
- END IF;
330
- ${fieldDefaults}
331
- -- Build column and value lists for UPSERT
332
- SELECT
333
- string_agg(quote_ident(key), ', '),
334
- string_agg(quote_nullable(value), ', ')
335
- INTO v_columns, v_values
336
- FROM jsonb_each_text(p_data) kv(key, value);
337
-
338
- -- Build UPDATE SET clause (excluding PK columns)
339
- SELECT string_agg(quote_ident(key) || ' = EXCLUDED.' || quote_ident(key), ', ')
340
- INTO v_update_set
341
- FROM jsonb_each_text(p_data) kv(key, value)
342
- WHERE ${this.primaryKey.map(col => `key != '${col}'`).join(' AND ')};
343
-
344
- -- Perform atomic UPSERT to avoid race conditions
345
- IF v_update_set IS NOT NULL AND v_update_set != '' THEN
346
- -- Has non-PK fields to update
347
- EXECUTE format(
348
- 'INSERT INTO ${this.tableName} (%s) VALUES (%s) ' ||
349
- 'ON CONFLICT ${onConflictPK} DO UPDATE SET %s ' ||
350
- 'RETURNING *',
351
- v_columns, v_values, v_update_set
352
- ) INTO v_result;
353
- ELSE
354
- -- Only PK fields provided - insert or return existing
355
- EXECUTE format(
356
- 'INSERT INTO ${this.tableName} (%s) VALUES (%s) ' ||
357
- 'ON CONFLICT ${onConflictPK} DO UPDATE SET ${this.primaryKey[0]} = EXCLUDED.${this.primaryKey[0]} ' ||
358
- 'RETURNING *',
359
- v_columns, v_values
360
- ) INTO v_result;
361
- END IF;
362
-
363
- -- Determine if this was an insert by checking if v_existing was found
364
- -- (if v_existing was NOT FOUND, then this was an insert)
365
- v_is_insert := v_existing.${this.primaryKey[0]} IS NULL;
366
-
367
- ${m2mSyncCompositePK}
368
-
369
- -- Prepare output with M2M fields (BEFORE event creation for real-time notifications!)
370
- v_output := to_jsonb(v_result);
371
- ${m2mExpansionCompositePK}
372
-
373
- ${graphRulesCall}
374
- ${notificationSQL}
375
-
376
- ${filterSensitiveFields}
377
-
378
- RETURN v_output;
379
- END;
380
- $$ LANGUAGE plpgsql SECURITY DEFINER;`;
381
- }
382
-
383
- /**
384
- * Generate DELETE function
385
- */
386
- generateDeleteFunction() {
387
- const graphRulesCall = this._generateGraphRulesCall('delete');
388
- const notificationSQL = this._generateNotificationSQL('delete');
389
- const filterSensitiveFields = this._generateSensitiveFieldFilter('v_output');
390
-
391
- // For composite PKs, accept JSONB containing all PK fields
392
- if (this.isCompositePK) {
393
- const whereClause = this.primaryKey.map(col => `${col} = (p_pk->>'${col}')${this._getTypeCast(col)}`).join(' AND ');
394
- const pkDescription = this.primaryKey.join(', ');
395
-
396
- const deleteSQL = this.entity.softDelete
397
- ? `UPDATE ${this.tableName} SET deleted_at = NOW() WHERE ${whereClause} RETURNING * INTO v_result;`
398
- : `DELETE FROM ${this.tableName} WHERE ${whereClause} RETURNING * INTO v_result;`;
399
-
400
- return `-- DELETE operation for ${this.tableName} (composite primary key: ${pkDescription})
401
- CREATE OR REPLACE FUNCTION delete_${this.tableName}(
402
- p_user_id INT,
403
- p_pk JSONB
404
- ) RETURNS JSONB AS $$
405
- DECLARE
406
- v_result ${this.tableName}%ROWTYPE;
407
- v_output JSONB;
408
- v_notify_users INT[];
409
- BEGIN
410
- -- Fetch record first by composite primary key
411
- SELECT * INTO v_result
412
- FROM ${this.tableName}
413
- WHERE ${whereClause};
414
-
415
- IF NOT FOUND THEN
416
- RAISE EXCEPTION 'Record not found: % with pk=%', '${this.tableName}', p_pk;
417
- END IF;
418
-
419
- -- Check delete permission
420
- IF NOT can_delete_${this.tableName}(p_user_id, to_jsonb(v_result)) THEN
421
- RAISE EXCEPTION 'Permission denied: delete on ${this.tableName}';
422
- END IF;
423
-
424
- ${graphRulesCall}
425
-
426
- -- Perform delete
427
- ${deleteSQL}
428
-
429
- ${notificationSQL}
430
-
431
- -- Prepare output (removing sensitive fields)
432
- v_output := to_jsonb(v_result);
433
- ${filterSensitiveFields}
434
-
435
- RETURN v_output;
436
- END;
437
- $$ LANGUAGE plpgsql SECURITY DEFINER;`;
438
- }
439
-
440
- // Simple PK (id column) - original signature for backwards compatibility
441
- const deleteSQL = this.entity.softDelete
442
- ? `UPDATE ${this.tableName} SET deleted_at = NOW() WHERE id = p_id RETURNING * INTO v_result;`
443
- : `DELETE FROM ${this.tableName} WHERE id = p_id RETURNING * INTO v_result;`;
444
-
445
- return `-- DELETE operation for ${this.tableName}
446
- CREATE OR REPLACE FUNCTION delete_${this.tableName}(
447
- p_user_id INT,
448
- p_id INT
449
- ) RETURNS JSONB AS $$
450
- DECLARE
451
- v_result ${this.tableName}%ROWTYPE;
452
- v_output JSONB;
453
- v_notify_users INT[];
454
- BEGIN
455
- -- Fetch record first
456
- SELECT * INTO v_result
457
- FROM ${this.tableName}
458
- WHERE id = p_id;
459
-
460
- IF NOT FOUND THEN
461
- RAISE EXCEPTION 'Record not found: % with id=%', '${this.tableName}', p_id;
462
- END IF;
463
-
464
- -- Check delete permission
465
- IF NOT can_delete_${this.tableName}(p_user_id, to_jsonb(v_result)) THEN
466
- RAISE EXCEPTION 'Permission denied: delete on ${this.tableName}';
467
- END IF;
468
-
469
- ${graphRulesCall}
470
-
471
- -- Perform delete
472
- ${deleteSQL}
473
-
474
- ${notificationSQL}
475
-
476
- -- Prepare output (removing sensitive fields)
477
- v_output := to_jsonb(v_result);
478
- ${filterSensitiveFields}
479
-
480
- RETURN v_output;
481
- END;
482
- $$ LANGUAGE plpgsql SECURITY DEFINER;`;
483
- }
484
-
485
- /**
486
- * Generate LOOKUP function
487
- */
488
- generateLookupFunction() {
489
- return `-- LOOKUP operation for ${this.tableName}
490
- CREATE OR REPLACE FUNCTION lookup_${this.tableName}(
491
- p_user_id INT,
492
- p_filter TEXT DEFAULT NULL,
493
- p_limit INT DEFAULT 50
494
- ) RETURNS JSONB AS $$
495
- DECLARE
496
- v_result JSONB;
497
- BEGIN
498
- SELECT COALESCE(jsonb_agg(
499
- jsonb_build_object(
500
- 'value', id,
501
- 'label', ${this.entity.labelField}
502
- ) ORDER BY ${this.entity.labelField}
503
- ), '[]'::jsonb) INTO v_result
504
- FROM ${this.tableName}
505
- WHERE (p_filter IS NULL OR ${this.entity.labelField} ILIKE '%' || p_filter || '%')${this._generateTemporalFilter()}
506
- LIMIT p_limit;
507
-
508
- RETURN v_result;
509
- END;
510
- $$ LANGUAGE plpgsql SECURITY DEFINER;`;
511
- }
512
-
513
- /**
514
- * Generate SEARCH function
515
- */
516
- generateSearchFunction() {
517
- const searchFields = this.entity.searchableFields || [this.entity.labelField];
518
- const searchConditions = searchFields.map(field =>
519
- `${field} ILIKE '%' || p_search || '%'`
520
- ).join(' OR ');
521
- const filterSensitiveFieldsArray = this._generateSensitiveFieldFilterArray();
522
- const m2mSearchExpansion = this._generateM2MExpansionForSearch();
523
-
524
- return `-- SEARCH operation for ${this.tableName}
525
- CREATE OR REPLACE FUNCTION search_${this.tableName}(
526
- p_user_id INT,
527
- p_filters JSONB DEFAULT '{}',
528
- p_search TEXT DEFAULT NULL,
529
- p_sort JSONB DEFAULT NULL,
530
- p_page INT DEFAULT 1,
531
- p_limit INT DEFAULT 25,
532
- p_on_date TIMESTAMPTZ DEFAULT NULL
533
- ) RETURNS JSONB AS $$
534
- DECLARE
535
- v_data JSONB;
536
- v_total INT;
537
- v_offset INT;
538
- v_sort_field TEXT;
539
- v_sort_order TEXT;
540
- v_where_clause TEXT := 'TRUE';
541
- v_field TEXT;
542
- v_filter JSONB;
543
- v_operator TEXT;
544
- v_value JSONB;
545
- v_on_date TIMESTAMPTZ;
546
- BEGIN
547
- v_offset := (p_page - 1) * p_limit;
548
- v_on_date := COALESCE(p_on_date, NOW());
549
-
550
- -- Extract sort parameters
551
- v_sort_field := COALESCE(p_sort->>'field', '${this.entity.labelField}');
552
- v_sort_order := COALESCE(p_sort->>'order', 'asc');
553
-
554
- -- Build WHERE clause from filters
555
- FOR v_field, v_filter IN SELECT * FROM jsonb_each(p_filters)
556
- LOOP
557
- -- Handle simple value (exact match)
558
- IF jsonb_typeof(v_filter) IN ('string', 'number', 'boolean') THEN
559
- v_where_clause := v_where_clause || format(' AND %I = %L', v_field, v_filter #>> '{}');
560
- ELSE
561
- -- Handle operator-based filters
562
- FOR v_operator, v_value IN SELECT * FROM jsonb_each(v_filter)
563
- LOOP
564
- CASE v_operator
565
- WHEN 'eq' THEN
566
- v_where_clause := v_where_clause || format(' AND %I = %L', v_field, v_value #>> '{}');
567
- WHEN 'ne' THEN
568
- v_where_clause := v_where_clause || format(' AND %I != %L', v_field, v_value #>> '{}');
569
- WHEN 'gt' THEN
570
- v_where_clause := v_where_clause || format(' AND %I > %L', v_field, v_value #>> '{}');
571
- WHEN 'gte' THEN
572
- v_where_clause := v_where_clause || format(' AND %I >= %L', v_field, v_value #>> '{}');
573
- WHEN 'lt' THEN
574
- v_where_clause := v_where_clause || format(' AND %I < %L', v_field, v_value #>> '{}');
575
- WHEN 'lte' THEN
576
- v_where_clause := v_where_clause || format(' AND %I <= %L', v_field, v_value #>> '{}');
577
- WHEN 'in' THEN
578
- v_where_clause := v_where_clause || format(' AND %I::TEXT = ANY(%L)', v_field,
579
- (SELECT array_agg(value #>> '{}') FROM jsonb_array_elements(v_value) AS value));
580
- WHEN 'ilike' THEN
581
- v_where_clause := v_where_clause || format(' AND %I ILIKE %L', v_field, v_value #>> '{}');
582
- WHEN 'like' THEN
583
- v_where_clause := v_where_clause || format(' AND %I LIKE %L', v_field, v_value #>> '{}');
584
- ELSE
585
- -- Unknown operator, skip
586
- END CASE;
587
- END LOOP;
588
- END IF;
589
- END LOOP;
590
-
591
- -- Add search condition
592
- IF p_search IS NOT NULL THEN
593
- v_where_clause := v_where_clause || ' AND (${searchConditions})';
594
- END IF;
595
-
596
- -- Add temporal filter${this._generateTemporalFilterForSearch()}
597
-
598
- -- Get total count
599
- EXECUTE format('SELECT COUNT(*) FROM ${this.tableName} WHERE %s', v_where_clause) INTO v_total;
600
-
601
- -- Get data
602
- EXECUTE format('
603
- SELECT COALESCE(jsonb_agg(${m2mSearchExpansion.selectExpression} ORDER BY %I %s), ''[]''::jsonb)
604
- FROM ${this.tableName} t${m2mSearchExpansion.lateralJoins}
605
- WHERE %s
606
- LIMIT %L OFFSET %L
607
- ', v_sort_field, v_sort_order, v_where_clause, p_limit, v_offset) INTO v_data;
608
-
609
- ${filterSensitiveFieldsArray}
610
-
611
- RETURN jsonb_build_object(
612
- 'data', v_data,
613
- 'total', v_total,
614
- 'page', p_page,
615
- 'limit', p_limit
616
- );
617
- END;
618
- $$ LANGUAGE plpgsql SECURITY DEFINER;`;
619
- }
620
-
621
- /**
622
- * Generate M2M variable declarations
623
- * COMPILE TIME: Loop to generate static variable declarations
624
- * RUNTIME: No loops, just variables
625
- * @private
626
- */
627
- _generateM2MVariableDeclarations() {
628
- const manyToMany = this.entity.manyToMany || {};
629
- if (Object.keys(manyToMany).length === 0) return '';
630
-
631
- const declarations = [];
632
-
633
- // COMPILE TIME LOOP: Generate separate variable for each M2M relationship
634
- for (const [relationKey, config] of Object.entries(manyToMany)) {
635
- const idField = config.id_field;
636
- declarations.push(` v_${idField} INT[]; -- M2M: ${relationKey}`);
637
- }
638
-
639
- return declarations.join('\n');
640
- }
641
-
642
- /**
643
- * Generate M2M extraction logic
644
- * COMPILE TIME: Loop to generate code
645
- * RUNTIME: Separate IF blocks (NO loops!)
646
- * @private
647
- */
648
- _generateM2MExtraction() {
649
- const manyToMany = this.entity.manyToMany || {};
650
- if (Object.keys(manyToMany).length === 0) return '';
651
-
652
- const extractions = [];
653
-
654
- // COMPILE TIME LOOP: Generate separate extraction block for each M2M
655
- for (const [relationKey, config] of Object.entries(manyToMany)) {
656
- const idField = config.id_field;
657
-
658
- // Each M2M gets its own static IF block (no runtime loops!)
659
- extractions.push(`
660
- -- Extract M2M field: ${idField} (${relationKey})
661
- IF p_data ? '${idField}' THEN
662
- v_${idField} := ARRAY(SELECT jsonb_array_elements_text(p_data->'${idField}')::int);
663
- p_data := p_data - '${idField}'; -- Remove from data (not a table column)
664
- END IF;`);
665
- }
666
-
667
- return extractions.join('');
668
- }
669
-
670
- /**
671
- * Generate M2M junction table sync logic
672
- * COMPILE TIME: Loop to generate code
673
- * RUNTIME: Direct SQL execution (NO loops!)
674
- * @private
675
- */
676
- _generateM2MSync() {
677
- const manyToMany = this.entity.manyToMany || {};
678
- if (Object.keys(manyToMany).length === 0) return '';
679
-
680
- const syncs = [];
681
-
682
- // COMPILE TIME LOOP: Generate separate sync block for EACH relationship
683
- for (const [relationKey, config] of Object.entries(manyToMany)) {
684
- const idField = config.id_field;
685
- const junctionTable = config.junction_table;
686
- const localKey = config.local_key;
687
- const foreignKey = config.foreign_key;
688
-
689
- // Static SQL - all names known at compile time!
690
- syncs.push(`
691
- -- ============================================================================
692
- -- M2M Sync: ${relationKey} (junction: ${junctionTable})
693
- -- ============================================================================
694
- IF v_${idField} IS NOT NULL THEN
695
- -- Delete relationships not in new list
696
- DELETE FROM ${junctionTable}
697
- WHERE ${localKey} = v_result.id
698
- AND (${foreignKey} <> ALL(v_${idField}) OR v_${idField} = '{}');
699
-
700
- -- Insert new relationships (idempotent)
701
- IF array_length(v_${idField}, 1) > 0 THEN
702
- INSERT INTO ${junctionTable} (${localKey}, ${foreignKey})
703
- SELECT v_result.id, unnest(v_${idField})
704
- ON CONFLICT (${localKey}, ${foreignKey}) DO NOTHING;
705
- END IF;
706
- END IF;`);
707
- }
708
-
709
- return syncs.join('');
710
- }
711
-
712
- /**
713
- * Generate M2M expansion in output (for SAVE function)
714
- * COMPILE TIME: Loop to generate code
715
- * RUNTIME: Direct SQL queries (NO loops!)
716
- * Expands M2M fields into v_output BEFORE event creation (for real-time notifications)
717
- * @private
718
- */
719
- _generateM2MExpansion() {
720
- const manyToMany = this.entity.manyToMany || {};
721
- if (Object.keys(manyToMany).length === 0) return '';
722
-
723
- const expansions = [];
724
-
725
- // COMPILE TIME LOOP: Generate code for each M2M relationship
726
- for (const [relationKey, config] of Object.entries(manyToMany)) {
727
- const idField = config.id_field;
728
- const junctionTable = config.junction_table;
729
- const localKey = config.local_key;
730
- const foreignKey = config.foreign_key;
731
- const targetEntity = config.target_entity;
732
- const expand = config.expand || false;
733
-
734
- // Always add ID array (static SQL) - use v_result.id since v_output is v_result as jsonb
735
- expansions.push(`
736
- -- Add M2M IDs: ${idField}
737
- v_output := v_output || jsonb_build_object('${idField}',
738
- (SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
739
- FROM ${junctionTable} WHERE ${localKey} = v_result.id)
740
- );`);
741
-
742
- // Conditionally expand full objects (known at compile time!)
743
- if (expand) {
744
- expansions.push(`
745
- -- Expand M2M objects: ${relationKey} (expand=true)
746
- v_output := v_output || jsonb_build_object('${relationKey}',
747
- (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
748
- FROM ${junctionTable} jt
749
- JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
750
- WHERE jt.${localKey} = v_result.id)
751
- );`);
752
- }
753
- }
754
-
755
- return expansions.join('');
756
- }
757
-
758
- /**
759
- * Generate M2M expansion for SEARCH operation
760
- * COMPILE TIME: Loop to generate LATERAL joins
761
- * RUNTIME: Static joins (NO loops!)
762
- * @private
763
- */
764
- _generateM2MExpansionForSearch() {
765
- const manyToMany = this.entity.manyToMany || {};
766
-
767
- if (Object.keys(manyToMany).length === 0) {
768
- return {
769
- lateralJoins: '',
770
- selectExpression: 'to_jsonb(t.*)'
771
- };
772
- }
773
-
774
- const lateralJoins = [];
775
- const mergeExpressions = [];
776
-
777
- // COMPILE TIME LOOP: Generate LATERAL join for each M2M relationship
778
- for (const [relationKey, config] of Object.entries(manyToMany)) {
779
- const idField = config.id_field;
780
- const junctionTable = config.junction_table;
781
- const localKey = config.local_key;
782
- const foreignKey = config.foreign_key;
783
- const targetEntity = config.target_entity;
784
- const expand = config.expand || false;
785
-
786
- // LATERAL join for ID array (static SQL)
787
- lateralJoins.push(`
788
- LEFT JOIN LATERAL (
789
- SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), ''[]''::jsonb) as ${idField}
790
- FROM ${junctionTable}
791
- WHERE ${localKey} = t.id
792
- ) m2m_${idField} ON true`);
793
-
794
- mergeExpressions.push(`jsonb_build_object(''${idField}'', m2m_${idField}.${idField})`);
795
-
796
- // Optionally expand full objects
797
- if (expand) {
798
- lateralJoins.push(`
799
- LEFT JOIN LATERAL (
800
- SELECT COALESCE(jsonb_agg(to_jsonb(target.*) ORDER BY target.id), ''[]''::jsonb) as ${relationKey}
801
- FROM ${junctionTable} jt
802
- JOIN ${targetEntity} target ON target.id = jt.${foreignKey}
803
- WHERE jt.${localKey} = t.id
804
- ) m2m_${relationKey} ON true`);
805
-
806
- mergeExpressions.push(`jsonb_build_object(''${relationKey}'', m2m_${relationKey}.${relationKey})`);
807
- }
808
- }
809
-
810
- // Build the select expression that merges M2M fields
811
- const selectExpression = mergeExpressions.length > 0
812
- ? `to_jsonb(t.*) || ${mergeExpressions.join(' || ')}`
813
- : 'to_jsonb(t.*)';
814
-
815
- return {
816
- lateralJoins: lateralJoins.join(''),
817
- selectExpression
818
- };
819
- }
820
-
821
- /**
822
- * Generate M2M expansion for existing record in SAVE (for "before" field)
823
- * COMPILE TIME: Loop to generate code
824
- * RUNTIME: Direct SQL queries (NO loops!)
825
- * @private
826
- */
827
- _generateM2MExpansionForBefore() {
828
- const manyToMany = this.entity.manyToMany || {};
829
- if (Object.keys(manyToMany).length === 0) return '';
830
-
831
- const expansions = [];
832
-
833
- // COMPILE TIME LOOP: Generate code for each M2M relationship
834
- for (const [relationKey, config] of Object.entries(manyToMany)) {
835
- const idField = config.id_field;
836
- const junctionTable = config.junction_table;
837
- const localKey = config.local_key;
838
- const foreignKey = config.foreign_key;
839
- const targetEntity = config.target_entity;
840
- const expand = config.expand || false;
841
-
842
- // Always add ID array (static SQL)
843
- expansions.push(`
844
- v_before := v_before || jsonb_build_object('${idField}',
845
- (SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
846
- FROM ${junctionTable} WHERE ${localKey} = v_existing.id)
847
- );`);
848
-
849
- // Conditionally expand full objects (known at compile time!)
850
- if (expand) {
851
- expansions.push(`
852
- v_before := v_before || jsonb_build_object('${relationKey}',
853
- (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
854
- FROM ${junctionTable} jt
855
- JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
856
- WHERE jt.${localKey} = v_existing.id)
857
- );`);
858
- }
859
- }
860
-
861
- return expansions.join('');
862
- }
863
-
864
- /**
865
- * Generate M2M expansion for GET operation
866
- * COMPILE TIME: Loop to generate code
867
- * RUNTIME: Direct SQL queries (NO loops!)
868
- * @private
869
- */
870
- _generateM2MExpansionForGet() {
871
- const manyToMany = this.entity.manyToMany || {};
872
- if (Object.keys(manyToMany).length === 0) return '';
873
-
874
- const expansions = [];
875
-
876
- // COMPILE TIME LOOP: Generate code for each M2M relationship
877
- for (const [relationKey, config] of Object.entries(manyToMany)) {
878
- const idField = config.id_field;
879
- const junctionTable = config.junction_table;
880
- const localKey = config.local_key;
881
- const foreignKey = config.foreign_key;
882
- const targetEntity = config.target_entity;
883
- const expand = config.expand || false;
884
-
885
- // Always add ID array (static SQL)
886
- expansions.push(`
887
- -- Add M2M IDs: ${idField}
888
- v_result := v_result || jsonb_build_object('${idField}',
889
- (SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
890
- FROM ${junctionTable} WHERE ${localKey} = v_record.id)
891
- );`);
892
-
893
- // Conditionally expand full objects (known at compile time!)
894
- if (expand) {
895
- expansions.push(`
896
- -- Expand M2M objects: ${relationKey} (expand=true)
897
- v_result := v_result || jsonb_build_object('${relationKey}',
898
- (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
899
- FROM ${junctionTable} jt
900
- JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
901
- WHERE jt.${localKey} = v_record.id)
902
- );`);
903
- }
904
- }
905
-
906
- return expansions.join('');
907
- }
908
-
909
- /**
910
- * Generate M2M sync for composite primary keys
911
- * For tables with composite PKs, M2M relationships are rare but possible
912
- * @private
913
- */
914
- _generateM2MSyncCompositePK() {
915
- const manyToMany = this.entity.manyToMany || {};
916
- if (Object.keys(manyToMany).length === 0) return '';
917
-
918
- // For composite PKs, we need to build a composite key reference
919
- // This is a rare case - most tables with composite PKs are junction tables themselves
920
- const syncs = [];
921
-
922
- for (const [relationKey, config] of Object.entries(manyToMany)) {
923
- const idField = config.id_field;
924
- const junctionTable = config.junction_table;
925
- const localKey = config.local_key;
926
- const foreignKey = config.foreign_key;
927
-
928
- // For composite PK tables with M2M, we'd need a different junction table structure
929
- // This is an edge case - log a warning comment in the generated SQL
930
- syncs.push(`
931
- -- ============================================================================
932
- -- M2M Sync: ${relationKey} (junction: ${junctionTable}) - COMPOSITE PK
933
- -- Note: M2M on composite PK tables requires junction table to reference all PK columns
934
- -- ============================================================================
935
- IF v_${idField} IS NOT NULL THEN
936
- -- Delete relationships not in new list (using first PK column as reference)
937
- DELETE FROM ${junctionTable}
938
- WHERE ${localKey} = v_result.${this.primaryKey[0]}
939
- AND (${foreignKey} <> ALL(v_${idField}) OR v_${idField} = '{}');
940
-
941
- -- Insert new relationships (idempotent)
942
- IF array_length(v_${idField}, 1) > 0 THEN
943
- INSERT INTO ${junctionTable} (${localKey}, ${foreignKey})
944
- SELECT v_result.${this.primaryKey[0]}, unnest(v_${idField})
945
- ON CONFLICT (${localKey}, ${foreignKey}) DO NOTHING;
946
- END IF;
947
- END IF;`);
948
- }
949
-
950
- return syncs.join('');
951
- }
952
-
953
- /**
954
- * Generate M2M expansion for composite primary keys (for SAVE output)
955
- * @private
956
- */
957
- _generateM2MExpansionCompositePK() {
958
- const manyToMany = this.entity.manyToMany || {};
959
- if (Object.keys(manyToMany).length === 0) return '';
960
-
961
- const expansions = [];
962
-
963
- for (const [relationKey, config] of Object.entries(manyToMany)) {
964
- const idField = config.id_field;
965
- const junctionTable = config.junction_table;
966
- const localKey = config.local_key;
967
- const foreignKey = config.foreign_key;
968
- const targetEntity = config.target_entity;
969
- const expand = config.expand || false;
970
-
971
- // Use first PK column for M2M lookups
972
- expansions.push(`
973
- -- Add M2M IDs: ${idField} (composite PK table)
974
- v_output := v_output || jsonb_build_object('${idField}',
975
- (SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
976
- FROM ${junctionTable} WHERE ${localKey} = v_result.${this.primaryKey[0]})
977
- );`);
978
-
979
- if (expand) {
980
- expansions.push(`
981
- -- Expand M2M objects: ${relationKey} (expand=true, composite PK table)
982
- v_output := v_output || jsonb_build_object('${relationKey}',
983
- (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
984
- FROM ${junctionTable} jt
985
- JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
986
- WHERE jt.${localKey} = v_result.${this.primaryKey[0]})
987
- );`);
988
- }
989
- }
990
-
991
- return expansions.join('');
992
- }
993
-
994
- /**
995
- * Generate M2M expansion for "before" record with composite primary keys
996
- * @private
997
- */
998
- _generateM2MExpansionForBeforeCompositePK() {
999
- const manyToMany = this.entity.manyToMany || {};
1000
- if (Object.keys(manyToMany).length === 0) return '';
1001
-
1002
- const expansions = [];
1003
-
1004
- for (const [relationKey, config] of Object.entries(manyToMany)) {
1005
- const idField = config.id_field;
1006
- const junctionTable = config.junction_table;
1007
- const localKey = config.local_key;
1008
- const foreignKey = config.foreign_key;
1009
- const targetEntity = config.target_entity;
1010
- const expand = config.expand || false;
1011
-
1012
- expansions.push(`
1013
- v_before := v_before || jsonb_build_object('${idField}',
1014
- (SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
1015
- FROM ${junctionTable} WHERE ${localKey} = v_existing.${this.primaryKey[0]})
1016
- );`);
1017
-
1018
- if (expand) {
1019
- expansions.push(`
1020
- v_before := v_before || jsonb_build_object('${relationKey}',
1021
- (SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
1022
- FROM ${junctionTable} jt
1023
- JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
1024
- WHERE jt.${localKey} = v_existing.${this.primaryKey[0]})
1025
- );`);
1026
- }
1027
- }
1028
-
1029
- return expansions.join('');
1030
- }
1031
-
1032
- /**
1033
- * Generate FK expansions for GET
1034
- * @private
1035
- */
1036
- _generateFKExpansions() {
1037
- if (!this.entity.fkIncludes || Object.keys(this.entity.fkIncludes).length === 0) {
1038
- return '';
1039
- }
1040
-
1041
- const expansions = [];
1042
-
1043
- for (const [key, targetTable] of Object.entries(this.entity.fkIncludes)) {
1044
- if (key === targetTable) {
1045
- // Reverse FK: child array
1046
- expansions.push(`
1047
- -- Expand ${key} (child array)
1048
- v_result := v_result || jsonb_build_object(
1049
- '${key}',
1050
- (SELECT COALESCE(jsonb_agg(to_jsonb(t.*)), '[]'::jsonb)
1051
- FROM ${targetTable} t
1052
- WHERE t.${this._singularize(this.tableName)}_id = v_record.id)
1053
- );`);
1054
- } else {
1055
- // Direct FK: single object
1056
- // Use JSONB to check field existence (like resolve_direct_fk)
1057
- expansions.push(`
1058
- -- Expand ${key} (foreign key)
1059
- DECLARE
1060
- v_fk_id INT;
1061
- BEGIN
1062
- -- Try field name directly first, then with _id suffix
1063
- IF to_jsonb(v_record) ? '${key}' THEN
1064
- v_fk_id := v_record.${key};
1065
- ELSIF to_jsonb(v_record) ? '${key}_id' THEN
1066
- v_fk_id := v_record.${key}_id;
1067
- END IF;
1068
-
1069
- IF v_fk_id IS NOT NULL THEN
1070
- v_result := v_result || jsonb_build_object(
1071
- '${key}',
1072
- (SELECT to_jsonb(t.*) FROM ${targetTable} t WHERE t.id = v_fk_id)
1073
- );
1074
- END IF;
1075
- END;`);
1076
- }
1077
- }
1078
-
1079
- return expansions.join('');
1080
- }
1081
-
1082
- /**
1083
- * Generate field defaults application
1084
- * COMPILE TIME: Loop to generate code for each default
1085
- * RUNTIME: Direct value assignment (NO loops!)
1086
- * @private
1087
- */
1088
- _generateFieldDefaults() {
1089
- const fieldDefaults = this.entity.fieldDefaults || {};
1090
- if (Object.keys(fieldDefaults).length === 0) return '';
1091
-
1092
- const defaults = [];
1093
-
1094
- // COMPILE TIME LOOP: Generate separate IF block for each field default
1095
- for (const [fieldName, defaultValue] of Object.entries(fieldDefaults)) {
1096
- if (defaultValue.startsWith('@')) {
1097
- // Resolve variable defaults (@user_id, @now, @today)
1098
- const resolved = this._resolveDefaultVariable(defaultValue, fieldName);
1099
- defaults.push(`
1100
- -- Apply field default: ${fieldName} = ${defaultValue}
1101
- IF v_is_insert AND NOT (p_data ? '${fieldName}') THEN
1102
- p_data := p_data || jsonb_build_object('${fieldName}', ${resolved});
1103
- END IF;`);
1104
- } else {
1105
- // Literal default value
1106
- defaults.push(`
1107
- -- Apply field default: ${fieldName} = ${defaultValue}
1108
- IF v_is_insert AND NOT (p_data ? '${fieldName}') THEN
1109
- p_data := p_data || jsonb_build_object('${fieldName}', '${defaultValue}');
1110
- END IF;`);
1111
- }
1112
- }
1113
-
1114
- return defaults.join('');
1115
- }
1116
-
1117
- /**
1118
- * Resolve a variable default (@user_id, @now, @today, @field_name) to SQL expression
1119
- * @private
1120
- */
1121
- _resolveDefaultVariable(variable, fieldName) {
1122
- // Handle built-in variables
1123
- switch (variable) {
1124
- case '@user_id':
1125
- return 'p_user_id';
1126
- case '@now':
1127
- return `to_char(NOW(), 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`;
1128
- case '@today':
1129
- return `to_char(CURRENT_DATE, 'YYYY-MM-DD')`;
1130
- }
1131
-
1132
- // Handle field references: @other_field
1133
- if (variable.startsWith('@')) {
1134
- const referencedField = variable.substring(1);
1135
- // Reference to another field in the data being inserted
1136
- return `(p_data->>'${referencedField}')`;
1137
- }
1138
-
1139
- throw new Error(`Unknown field default variable: ${variable} for field ${fieldName}`);
1140
- }
1141
-
1142
- /**
1143
- * Generate temporal filter for static SQL (GET, LOOKUP)
1144
- * @private
1145
- */
1146
- _generateTemporalFilter() {
1147
- if (!this.entity.temporalFields || Object.keys(this.entity.temporalFields).length === 0) {
1148
- return '';
1149
- }
1150
-
1151
- const validFrom = this.entity.temporalFields.valid_from || 'valid_from';
1152
- const validTo = this.entity.temporalFields.valid_to || 'valid_to';
1153
-
1154
- return `
1155
- AND ${validFrom} <= COALESCE(p_on_date, NOW())
1156
- AND (${validTo} > COALESCE(p_on_date, NOW()) OR ${validTo} IS NULL)`;
1157
- }
1158
-
1159
- /**
1160
- * Generate temporal filter for SEARCH function (dynamic SQL with EXECUTE)
1161
- * Uses format() to properly interpolate the v_on_date variable
1162
- * @private
1163
- */
1164
- _generateTemporalFilterForSearch() {
1165
- if (!this.entity.temporalFields || Object.keys(this.entity.temporalFields).length === 0) {
1166
- return '';
1167
- }
1168
-
1169
- const validFrom = this.entity.temporalFields.valid_from || 'valid_from';
1170
- const validTo = this.entity.temporalFields.valid_to || 'valid_to';
1171
-
1172
- return `
1173
- v_where_clause := v_where_clause || format(' AND ${validFrom} <= %L AND (${validTo} > %L OR ${validTo} IS NULL)', v_on_date, v_on_date);`;
1174
- }
1175
-
1176
- /**
1177
- * Check if a trigger has any rules with actions
1178
- * @private
1179
- */
1180
- _hasGraphRuleActions(trigger) {
1181
- const rules = this.entity.graphRules[trigger];
1182
- if (!rules || typeof rules !== 'object') {
1183
- return false;
1184
- }
1185
-
1186
- // Check if any rule has actions
1187
- for (const ruleConfig of Object.values(rules)) {
1188
- if (ruleConfig && ruleConfig.actions) {
1189
- const actions = Array.isArray(ruleConfig.actions)
1190
- ? ruleConfig.actions
1191
- : [ruleConfig.actions];
1192
- if (actions.length > 0) {
1193
- return true;
1194
- }
1195
- }
1196
- }
1197
-
1198
- return false;
1199
- }
1200
-
1201
- /**
1202
- * Generate graph rules call
1203
- * @private
1204
- */
1205
- _generateGraphRulesCall(operation = null) {
1206
- if (!this.entity.graphRules || Object.keys(this.entity.graphRules).length === 0) {
1207
- return '';
1208
- }
1209
-
1210
- // For DELETE operation
1211
- if (operation === 'delete') {
1212
- if (this._hasGraphRuleActions('on_delete')) {
1213
- return `
1214
- -- Execute graph rules: on_delete
1215
- PERFORM _graph_${this.tableName}_on_delete(p_user_id, to_jsonb(v_result));`;
1216
- }
1217
- return '';
1218
- }
1219
-
1220
- // For SAVE operation (create/update)
1221
- const calls = [];
1222
-
1223
- if (this._hasGraphRuleActions('on_create')) {
1224
- calls.push(`
1225
- -- Execute graph rules: on_create (if insert)
1226
- IF v_is_insert THEN
1227
- PERFORM _graph_${this.tableName}_on_create(p_user_id, to_jsonb(v_result));
1228
- END IF;`);
1229
- }
1230
-
1231
- if (this._hasGraphRuleActions('on_update')) {
1232
- calls.push(`
1233
- -- Execute graph rules: on_update (if update)
1234
- IF NOT v_is_insert THEN
1235
- PERFORM _graph_${this.tableName}_on_update(p_user_id, to_jsonb(v_existing), to_jsonb(v_result));
1236
- END IF;`);
1237
- }
1238
-
1239
- return calls.join('');
1240
- }
1241
-
1242
- /**
1243
- * Generate jsonb_build_object for primary key columns
1244
- * Supports composite primary keys by building an object with all pk columns
1245
- * @param {string} recordVar - The record variable name (e.g., 'v_result', 'v_existing')
1246
- * @private
1247
- */
1248
- _generatePKBuildObject(recordVar = 'v_result') {
1249
- const primaryKey = this.entity.primaryKey || ['id'];
1250
-
1251
- // Build jsonb_build_object with all primary key columns
1252
- // e.g., jsonb_build_object('id', v_result.id)
1253
- // or jsonb_build_object('template_id', v_result.template_id, 'depends_on_template_id', v_result.depends_on_template_id)
1254
- const pairs = primaryKey.map(col => `'${col}', ${recordVar}.${col}`);
1255
- return `jsonb_build_object(${pairs.join(', ')})`;
1256
- }
1257
-
1258
- /**
1259
- * Generate notification SQL
1260
- * @private
1261
- */
1262
- _generateNotificationSQL(operation = 'save') {
1263
- const hasNotificationPaths = this.entity.notificationPaths && Object.keys(this.entity.notificationPaths).length > 0;
1264
- const pkBuildObject = this._generatePKBuildObject('v_result');
1265
-
1266
- if (operation === 'save') {
1267
- return `
1268
- -- Resolve notification recipients (use v_output with M2M fields!)
1269
- ${hasNotificationPaths ? `v_notify_users := _resolve_notification_paths_${this.tableName}(p_user_id, v_output);` : 'v_notify_users := ARRAY[]::INT[];'}
1270
-
1271
- -- Create event for real-time notifications (v_output includes M2M fields!)
1272
- INSERT INTO dzql.events (
1273
- table_name,
1274
- op,
1275
- pk,
1276
- data,
1277
- user_id,
1278
- notify_users
1279
- ) VALUES (
1280
- '${this.tableName}',
1281
- CASE WHEN v_is_insert THEN 'insert' ELSE 'update' END,
1282
- ${pkBuildObject},
1283
- v_output,
1284
- p_user_id,
1285
- v_notify_users
1286
- );`;
1287
- } else if (operation === 'delete') {
1288
- return `
1289
- -- Resolve notification recipients
1290
- ${hasNotificationPaths ? `v_notify_users := _resolve_notification_paths_${this.tableName}(p_user_id, to_jsonb(v_result));` : 'v_notify_users := ARRAY[]::INT[];'}
1291
-
1292
- -- Create event for real-time notifications
1293
- -- Include full record data so _affected_documents can resolve subscription FKs
1294
- INSERT INTO dzql.events (
1295
- table_name,
1296
- op,
1297
- pk,
1298
- data,
1299
- user_id,
1300
- notify_users
1301
- ) VALUES (
1302
- '${this.tableName}',
1303
- 'delete',
1304
- ${pkBuildObject},
1305
- to_jsonb(v_result),
1306
- p_user_id,
1307
- v_notify_users
1308
- );`;
1309
- }
1310
-
1311
- return '';
1312
- }
1313
-
1314
- /**
1315
- * Generate INSERT columns
1316
- * @private
1317
- */
1318
- _generateInsertColumns() {
1319
- // This is a simplified version - in reality, would introspect table schema
1320
- return "SELECT string_agg(quote_ident(key), ', ') FROM jsonb_object_keys(p_data) key";
1321
- }
1322
-
1323
- /**
1324
- * Generate INSERT values
1325
- * @private
1326
- */
1327
- _generateInsertValues() {
1328
- return "SELECT string_agg(quote_literal(value), ', ') FROM jsonb_each_text(p_data) kv(key, value)";
1329
- }
1330
-
1331
- /**
1332
- * Generate UPDATE SET clause
1333
- * @private
1334
- */
1335
- _generateUpdateSet() {
1336
- return `SELECT string_agg(quote_ident(key) || ' = ' || quote_literal(value), ', ')
1337
- FROM jsonb_each_text(p_data) kv(key, value)
1338
- WHERE key != 'id'`;
1339
- }
1340
-
1341
- /**
1342
- * Simple singularization (remove trailing 's')
1343
- * @private
1344
- */
1345
- _singularize(word) {
1346
- return word.endsWith('s') ? word.slice(0, -1) : word;
1347
- }
1348
-
1349
- /**
1350
- * Generate SQL to filter out sensitive fields from a JSONB variable
1351
- * @param {string} varName - Name of the JSONB variable to filter (default: v_result)
1352
- * @private
1353
- */
1354
- _generateSensitiveFieldFilter(varName = 'v_result') {
1355
- return `
1356
- -- Remove sensitive fields (password_hash, etc.) from result
1357
- ${varName} := ${varName} - 'password_hash' - 'password' - 'secret' - 'token';`;
1358
- }
1359
-
1360
- /**
1361
- * Generate SQL to filter out sensitive fields from array of JSONB objects
1362
- * @private
1363
- */
1364
- _generateSensitiveFieldFilterArray() {
1365
- return `
1366
- -- Remove sensitive fields from each record in the array
1367
- v_data := (
1368
- SELECT jsonb_agg(elem - 'password_hash' - 'password' - 'secret' - 'token')
1369
- FROM jsonb_array_elements(v_data) elem
1370
- );`;
1371
- }
1372
- }
1373
-
1374
- /**
1375
- * Generate all operation functions for an entity
1376
- * @param {Object} entity - Entity configuration
1377
- * @returns {string} SQL for all operations
1378
- */
1379
- export function generateOperations(entity) {
1380
- const codegen = new OperationCodegen(entity);
1381
- return codegen.generateAll();
1382
- }