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.
- package/.env.sample +28 -0
- package/compose.yml +28 -0
- package/dist/client/index.ts +1 -0
- package/dist/client/stores/useMyProfileStore.ts +114 -0
- package/dist/client/stores/useOrgDashboardStore.ts +131 -0
- package/dist/client/stores/useVenueDetailStore.ts +117 -0
- package/dist/client/ws.ts +716 -0
- package/dist/db/migrations/000_core.sql +92 -0
- package/dist/db/migrations/20251229T212912022Z_schema.sql +3020 -0
- package/dist/db/migrations/20251229T212912022Z_subscribables.sql +371 -0
- package/dist/runtime/manifest.json +1562 -0
- package/docs/README.md +293 -36
- package/docs/feature-requests/applyPatch-bug-report.md +85 -0
- package/docs/feature-requests/connection-ready-profile.md +57 -0
- package/docs/feature-requests/hidden-bug-report.md +111 -0
- package/docs/feature-requests/hidden-fields-subscribables.md +34 -0
- package/docs/feature-requests/subscribable-param-key-bug.md +38 -0
- package/docs/feature-requests/todo.md +146 -0
- package/docs/for_ai.md +641 -0
- package/docs/project-setup.md +432 -0
- package/examples/blog.ts +50 -0
- package/examples/invalid.ts +18 -0
- package/examples/venues.js +485 -0
- package/package.json +23 -60
- package/src/cli/codegen/client.ts +99 -0
- package/src/cli/codegen/manifest.ts +95 -0
- package/src/cli/codegen/pinia.ts +174 -0
- package/src/cli/codegen/realtime.ts +58 -0
- package/src/cli/codegen/sql.ts +698 -0
- package/src/cli/codegen/subscribable_sql.ts +547 -0
- package/src/cli/codegen/subscribable_store.ts +184 -0
- package/src/cli/codegen/types.ts +142 -0
- package/src/cli/compiler/analyzer.ts +52 -0
- package/src/cli/compiler/graph_rules.ts +251 -0
- package/src/cli/compiler/ir.ts +233 -0
- package/src/cli/compiler/loader.ts +132 -0
- package/src/cli/compiler/permissions.ts +227 -0
- package/src/cli/index.ts +164 -0
- package/src/client/index.ts +1 -0
- package/src/client/ws.ts +286 -0
- package/src/create/.env.example +8 -0
- package/src/create/README.md +101 -0
- package/src/create/compose.yml +14 -0
- package/src/create/domain.ts +153 -0
- package/src/create/package.json +24 -0
- package/src/create/server.ts +18 -0
- package/src/create/setup.sh +11 -0
- package/src/create/tsconfig.json +15 -0
- package/src/runtime/auth.ts +39 -0
- package/src/runtime/db.ts +33 -0
- package/src/runtime/errors.ts +51 -0
- package/src/runtime/index.ts +98 -0
- package/src/runtime/js_functions.ts +63 -0
- package/src/runtime/manifest_loader.ts +29 -0
- package/src/runtime/namespace.ts +483 -0
- package/src/runtime/server.ts +87 -0
- package/src/runtime/ws.ts +197 -0
- package/src/shared/ir.ts +197 -0
- package/tests/client.test.ts +38 -0
- package/tests/codegen.test.ts +71 -0
- package/tests/compiler.test.ts +45 -0
- package/tests/graph_rules.test.ts +173 -0
- package/tests/integration/db.test.ts +174 -0
- package/tests/integration/e2e.test.ts +65 -0
- package/tests/integration/features.test.ts +922 -0
- package/tests/integration/full_stack.test.ts +262 -0
- package/tests/integration/setup.ts +45 -0
- package/tests/ir.test.ts +32 -0
- package/tests/namespace.test.ts +395 -0
- package/tests/permissions.test.ts +55 -0
- package/tests/pinia.test.ts +48 -0
- package/tests/realtime.test.ts +22 -0
- package/tests/runtime.test.ts +80 -0
- package/tests/subscribable_gen.test.ts +72 -0
- package/tests/subscribable_reactivity.test.ts +258 -0
- package/tests/venues_gen.test.ts +25 -0
- package/tsconfig.json +20 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/README.md +0 -90
- package/bin/cli.js +0 -727
- package/docs/compiler/ADVANCED_FILTERS.md +0 -183
- package/docs/compiler/CODING_STANDARDS.md +0 -415
- package/docs/compiler/COMPARISON.md +0 -673
- package/docs/compiler/QUICKSTART.md +0 -326
- package/docs/compiler/README.md +0 -134
- package/docs/examples/README.md +0 -38
- package/docs/examples/blog.sql +0 -160
- package/docs/examples/venue-detail-simple.sql +0 -8
- package/docs/examples/venue-detail-subscribable.sql +0 -45
- package/docs/for-ai/claude-guide.md +0 -1210
- package/docs/getting-started/quickstart.md +0 -125
- package/docs/getting-started/subscriptions-quick-start.md +0 -203
- package/docs/getting-started/tutorial.md +0 -1104
- package/docs/guides/atomic-updates.md +0 -299
- package/docs/guides/client-stores.md +0 -730
- package/docs/guides/composite-primary-keys.md +0 -158
- package/docs/guides/custom-functions.md +0 -362
- package/docs/guides/drop-semantics.md +0 -554
- package/docs/guides/field-defaults.md +0 -240
- package/docs/guides/interpreter-vs-compiler.md +0 -237
- package/docs/guides/many-to-many.md +0 -929
- package/docs/guides/subscriptions.md +0 -537
- package/docs/reference/api.md +0 -1373
- package/docs/reference/client.md +0 -224
- package/src/client/stores/index.js +0 -8
- package/src/client/stores/useAppStore.js +0 -285
- package/src/client/stores/useWsStore.js +0 -289
- package/src/client/ws.js +0 -762
- package/src/compiler/cli/compile-example.js +0 -33
- package/src/compiler/cli/compile-subscribable.js +0 -43
- package/src/compiler/cli/debug-compile.js +0 -44
- package/src/compiler/cli/debug-parse.js +0 -26
- package/src/compiler/cli/debug-path-parser.js +0 -18
- package/src/compiler/cli/debug-subscribable-parser.js +0 -21
- package/src/compiler/cli/index.js +0 -174
- package/src/compiler/codegen/auth-codegen.js +0 -153
- package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
- package/src/compiler/codegen/graph-rules-codegen.js +0 -450
- package/src/compiler/codegen/notification-codegen.js +0 -232
- package/src/compiler/codegen/operation-codegen.js +0 -1382
- package/src/compiler/codegen/permission-codegen.js +0 -318
- package/src/compiler/codegen/subscribable-codegen.js +0 -827
- package/src/compiler/compiler.js +0 -371
- package/src/compiler/index.js +0 -11
- package/src/compiler/parser/entity-parser.js +0 -440
- package/src/compiler/parser/path-parser.js +0 -290
- package/src/compiler/parser/subscribable-parser.js +0 -244
- package/src/database/dzql-core.sql +0 -161
- package/src/database/migrations/001_schema.sql +0 -60
- package/src/database/migrations/002_functions.sql +0 -890
- package/src/database/migrations/003_operations.sql +0 -1135
- package/src/database/migrations/004_search.sql +0 -581
- package/src/database/migrations/005_entities.sql +0 -730
- package/src/database/migrations/006_auth.sql +0 -94
- package/src/database/migrations/007_events.sql +0 -133
- package/src/database/migrations/008_hello.sql +0 -18
- package/src/database/migrations/008a_meta.sql +0 -172
- package/src/database/migrations/009_subscriptions.sql +0 -240
- package/src/database/migrations/010_atomic_updates.sql +0 -157
- package/src/database/migrations/010_fix_m2m_events.sql +0 -94
- package/src/index.js +0 -40
- package/src/server/api.js +0 -9
- package/src/server/db.js +0 -442
- package/src/server/index.js +0 -317
- package/src/server/logger.js +0 -259
- package/src/server/mcp.js +0 -594
- package/src/server/meta-route.js +0 -251
- package/src/server/namespace.js +0 -292
- package/src/server/subscriptions.js +0 -351
- package/src/server/ws.js +0 -573
|
@@ -1,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
|
-
}
|