dzql 0.6.22 → 0.6.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/codegen/sql.ts +27 -2
- package/src/cli/compiler/graph_rules.ts +42 -7
- package/src/cli/index.ts +99 -12
package/package.json
CHANGED
package/src/cli/codegen/sql.ts
CHANGED
|
@@ -179,9 +179,23 @@ $$;
|
|
|
179
179
|
`;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Generate CREATE TABLE SQL for an entity.
|
|
184
|
+
* @param name - Entity/table name
|
|
185
|
+
* @param entityIR - Entity IR definition
|
|
186
|
+
* @param circularFKs - Set of "table.column" strings for circular FK columns to defer
|
|
187
|
+
*/
|
|
188
|
+
export function generateSchemaSQL(name: string, entityIR: EntityIR, circularFKs?: Set<string>): string {
|
|
183
189
|
const columns = entityIR.columns.map((c: ColumnInfo) => {
|
|
184
|
-
|
|
190
|
+
let colType = c.type;
|
|
191
|
+
|
|
192
|
+
// If this column is part of a circular FK, strip the REFERENCES clause
|
|
193
|
+
// The constraint will be added later via ALTER TABLE
|
|
194
|
+
if (circularFKs && circularFKs.has(`${name}.${c.name}`)) {
|
|
195
|
+
colType = stripFKReferences(colType);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return `${c.name} ${colType}`;
|
|
185
199
|
}).join(',\n ');
|
|
186
200
|
|
|
187
201
|
return `
|
|
@@ -191,6 +205,17 @@ CREATE TABLE IF NOT EXISTS ${name} (
|
|
|
191
205
|
`;
|
|
192
206
|
}
|
|
193
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Remove REFERENCES clause from a column type for deferred FK creation.
|
|
210
|
+
* Preserves NOT NULL, DEFAULT, and other modifiers.
|
|
211
|
+
* e.g., "int NOT NULL REFERENCES users(id) ON DELETE CASCADE" -> "int NOT NULL"
|
|
212
|
+
*/
|
|
213
|
+
function stripFKReferences(columnType: string): string {
|
|
214
|
+
return columnType
|
|
215
|
+
.replace(/\s*REFERENCES\s+\w+(\([^)]+\))?(\s+ON\s+(DELETE|UPDATE)\s+(CASCADE|SET NULL|SET DEFAULT|RESTRICT|NO ACTION))*/gi, '')
|
|
216
|
+
.trim();
|
|
217
|
+
}
|
|
218
|
+
|
|
194
219
|
// === SAVE FUNCTION (Upsert) ===
|
|
195
220
|
export function generateSaveFunction(name: string, entityIR: EntityIR): string {
|
|
196
221
|
const cols = entityIR.columns.map((c: ColumnInfo) => c.name);
|
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
import type { GraphRuleIR } from "../../shared/ir.js";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Parses params that may be a string or object into a normalized object.
|
|
5
|
+
* String format: "field1=source1,field2,field3=source3"
|
|
6
|
+
* - "field=source" maps field to @source
|
|
7
|
+
* - "field" alone maps field to @field (or @id for the new record's id)
|
|
8
|
+
*/
|
|
9
|
+
function parseParams(params: Record<string, string> | string | undefined): Record<string, string> {
|
|
10
|
+
if (!params) return {};
|
|
11
|
+
|
|
12
|
+
if (typeof params === 'object') {
|
|
13
|
+
return params;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Parse string format: "org_id=@id,user_id=@user_id" or "organisation_id=sponsor_id,campaign_id"
|
|
17
|
+
const result: Record<string, string> = {};
|
|
18
|
+
for (const part of params.split(',')) {
|
|
19
|
+
const trimmed = part.trim();
|
|
20
|
+
if (!trimmed) continue;
|
|
21
|
+
|
|
22
|
+
if (trimmed.includes('=')) {
|
|
23
|
+
const [field, source] = trimmed.split('=').map(s => s.trim());
|
|
24
|
+
// Ensure source starts with @ for variable references
|
|
25
|
+
result[field] = source.startsWith('@') ? source : `@${source}`;
|
|
26
|
+
} else {
|
|
27
|
+
// Field alone means use @field as source (or @id for 'id')
|
|
28
|
+
result[trimmed] = `@${trimmed}`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
3
34
|
/**
|
|
4
35
|
* Resolves a value reference (like @id, @user_id, @before.field, @after.field)
|
|
5
36
|
* into the appropriate SQL expression based on the trigger context.
|
|
@@ -110,7 +141,7 @@ export function compileGraphRules(entity: string, trigger: string, rules: GraphR
|
|
|
110
141
|
// === REACTOR ===
|
|
111
142
|
if (rule.action === 'reactor') {
|
|
112
143
|
const name = rule.target;
|
|
113
|
-
const params = rule.params
|
|
144
|
+
const params = parseParams(rule.params);
|
|
114
145
|
|
|
115
146
|
// Build JSON object using jsonb_build_object
|
|
116
147
|
const jsonArgs: string[] = [];
|
|
@@ -139,7 +170,7 @@ export function compileGraphRules(entity: string, trigger: string, rules: GraphR
|
|
|
139
170
|
// === CREATE SIDE EFFECT ===
|
|
140
171
|
if (rule.action === 'create') {
|
|
141
172
|
const target = rule.target;
|
|
142
|
-
const data = rule.params
|
|
173
|
+
const data = parseParams(rule.params);
|
|
143
174
|
const cols: string[] = [];
|
|
144
175
|
const vals: string[] = [];
|
|
145
176
|
|
|
@@ -159,8 +190,8 @@ export function compileGraphRules(entity: string, trigger: string, rules: GraphR
|
|
|
159
190
|
// === UPDATE SIDE EFFECT ===
|
|
160
191
|
if (rule.action === 'update') {
|
|
161
192
|
const target = rule.target;
|
|
162
|
-
const data = rule.params
|
|
163
|
-
const match = rule.match
|
|
193
|
+
const data = parseParams(rule.params);
|
|
194
|
+
const match = parseParams(rule.match);
|
|
164
195
|
|
|
165
196
|
const setClauses: string[] = [];
|
|
166
197
|
for (const [key, val] of Object.entries(data)) {
|
|
@@ -186,7 +217,11 @@ export function compileGraphRules(entity: string, trigger: string, rules: GraphR
|
|
|
186
217
|
// === DELETE CASCADE ===
|
|
187
218
|
if (rule.action === 'delete') {
|
|
188
219
|
const target = rule.target;
|
|
189
|
-
|
|
220
|
+
// Try match first, fall back to params
|
|
221
|
+
let match = parseParams(rule.match);
|
|
222
|
+
if (Object.keys(match).length === 0) {
|
|
223
|
+
match = parseParams(rule.params);
|
|
224
|
+
}
|
|
190
225
|
const whereClauses: string[] = [];
|
|
191
226
|
|
|
192
227
|
for (const [key, val] of Object.entries(match)) {
|
|
@@ -204,7 +239,7 @@ export function compileGraphRules(entity: string, trigger: string, rules: GraphR
|
|
|
204
239
|
// === VALIDATE ===
|
|
205
240
|
if (rule.action === 'validate') {
|
|
206
241
|
const functionName = rule.target;
|
|
207
|
-
const params = rule.params
|
|
242
|
+
const params = parseParams(rule.params);
|
|
208
243
|
const errorMessage = rule.error_message || 'Validation failed';
|
|
209
244
|
|
|
210
245
|
const paramList: string[] = [];
|
|
@@ -224,7 +259,7 @@ export function compileGraphRules(entity: string, trigger: string, rules: GraphR
|
|
|
224
259
|
// === EXECUTE ===
|
|
225
260
|
if (rule.action === 'execute') {
|
|
226
261
|
const functionName = rule.target;
|
|
227
|
-
const params = rule.params
|
|
262
|
+
const params = parseParams(rule.params);
|
|
228
263
|
|
|
229
264
|
const paramList: string[] = [];
|
|
230
265
|
for (const [key, val] of Object.entries(params)) {
|
package/src/cli/index.ts
CHANGED
|
@@ -70,12 +70,17 @@ async function main() {
|
|
|
70
70
|
|
|
71
71
|
// Topologically sort entities by FK dependencies
|
|
72
72
|
// Entities must be created before entities that reference them
|
|
73
|
-
|
|
73
|
+
// Also detects circular FKs that need deferred constraint creation
|
|
74
|
+
const { sorted: sortedEntityNames, circularFKs } = topologicalSortEntities(ir.entities);
|
|
75
|
+
|
|
76
|
+
// Build set of circular FK columns for stripping during schema generation
|
|
77
|
+
const circularFKSet = new Set(circularFKs.map(fk => `${fk.table}.${fk.column}`));
|
|
74
78
|
|
|
75
79
|
const entitySQL: string[] = [];
|
|
76
80
|
for (const name of sortedEntityNames) {
|
|
77
81
|
const entityIR = ir.entities[name];
|
|
78
|
-
|
|
82
|
+
// Pass circular FK info so schema generation can strip those constraints
|
|
83
|
+
entitySQL.push(generateSchemaSQL(name, entityIR, circularFKSet));
|
|
79
84
|
// Skip CRUD generation for unmanaged entities (e.g., junction tables)
|
|
80
85
|
if (entityIR.managed !== false) {
|
|
81
86
|
entitySQL.push(generateEntitySQL(name, entityIR));
|
|
@@ -84,6 +89,11 @@ async function main() {
|
|
|
84
89
|
}
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
// Add deferred FK constraints at the end (for circular dependencies)
|
|
93
|
+
if (circularFKs.length > 0) {
|
|
94
|
+
entitySQL.push(generateDeferredFKConstraints(circularFKs));
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
// Generate subscribable SQL functions
|
|
88
98
|
const subscribableSQL: string[] = [];
|
|
89
99
|
const subscribableNames = Object.keys(ir.subscribables);
|
|
@@ -178,18 +188,36 @@ async function main() {
|
|
|
178
188
|
|
|
179
189
|
main();
|
|
180
190
|
|
|
191
|
+
/** FK info for dependency analysis */
|
|
192
|
+
interface FKInfo {
|
|
193
|
+
table: string;
|
|
194
|
+
column: string;
|
|
195
|
+
referencedTable: string;
|
|
196
|
+
fullConstraint: string; // e.g., "int NOT NULL REFERENCES users(id)"
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Result of topological sort with cycle detection */
|
|
200
|
+
interface SortResult {
|
|
201
|
+
sorted: string[];
|
|
202
|
+
circularFKs: FKInfo[];
|
|
203
|
+
}
|
|
204
|
+
|
|
181
205
|
/**
|
|
182
206
|
* Topologically sort entities based on FK dependencies.
|
|
183
207
|
* Entities that are referenced by others come first.
|
|
184
208
|
* Uses Kahn's algorithm for topological sorting.
|
|
209
|
+
* Returns both sorted order and any circular FK constraints that need deferred creation.
|
|
185
210
|
*/
|
|
186
|
-
function topologicalSortEntities(entities: Record<string, any>):
|
|
211
|
+
function topologicalSortEntities(entities: Record<string, any>): SortResult {
|
|
187
212
|
const entityNames = Object.keys(entities);
|
|
188
213
|
|
|
189
214
|
// Build dependency graph: entity -> entities it depends on (references)
|
|
190
215
|
const dependencies: Record<string, Set<string>> = {};
|
|
191
216
|
const dependents: Record<string, Set<string>> = {};
|
|
192
217
|
|
|
218
|
+
// Track all FK info for cycle detection
|
|
219
|
+
const allFKs: FKInfo[] = [];
|
|
220
|
+
|
|
193
221
|
for (const name of entityNames) {
|
|
194
222
|
dependencies[name] = new Set();
|
|
195
223
|
dependents[name] = new Set();
|
|
@@ -206,6 +234,12 @@ function topologicalSortEntities(entities: Record<string, any>): string[] {
|
|
|
206
234
|
if (entityNames.includes(referencedEntity)) {
|
|
207
235
|
dependencies[name].add(referencedEntity);
|
|
208
236
|
dependents[referencedEntity].add(name);
|
|
237
|
+
allFKs.push({
|
|
238
|
+
table: name,
|
|
239
|
+
column: col.name,
|
|
240
|
+
referencedTable: referencedEntity,
|
|
241
|
+
fullConstraint: col.type
|
|
242
|
+
});
|
|
209
243
|
}
|
|
210
244
|
}
|
|
211
245
|
}
|
|
@@ -214,10 +248,16 @@ function topologicalSortEntities(entities: Record<string, any>): string[] {
|
|
|
214
248
|
// Kahn's algorithm
|
|
215
249
|
const result: string[] = [];
|
|
216
250
|
const noIncoming: string[] = [];
|
|
251
|
+
const remainingDeps: Record<string, Set<string>> = {};
|
|
252
|
+
|
|
253
|
+
// Copy dependencies for mutation
|
|
254
|
+
for (const name of entityNames) {
|
|
255
|
+
remainingDeps[name] = new Set(dependencies[name]);
|
|
256
|
+
}
|
|
217
257
|
|
|
218
258
|
// Find entities with no dependencies (no incoming edges)
|
|
219
259
|
for (const name of entityNames) {
|
|
220
|
-
if (
|
|
260
|
+
if (remainingDeps[name].size === 0) {
|
|
221
261
|
noIncoming.push(name);
|
|
222
262
|
}
|
|
223
263
|
}
|
|
@@ -228,20 +268,67 @@ function topologicalSortEntities(entities: Record<string, any>): string[] {
|
|
|
228
268
|
|
|
229
269
|
// Remove this node from the graph
|
|
230
270
|
for (const dependent of dependents[node]) {
|
|
231
|
-
|
|
232
|
-
if (
|
|
271
|
+
remainingDeps[dependent].delete(node);
|
|
272
|
+
if (remainingDeps[dependent].size === 0) {
|
|
233
273
|
noIncoming.push(dependent);
|
|
234
274
|
}
|
|
235
275
|
}
|
|
236
276
|
}
|
|
237
277
|
|
|
238
|
-
//
|
|
278
|
+
// Detect circular FKs
|
|
279
|
+
const circularFKs: FKInfo[] = [];
|
|
239
280
|
if (result.length !== entityNames.length) {
|
|
240
|
-
const
|
|
241
|
-
console.warn(`[Compiler] Warning: Circular FK dependencies detected among: ${
|
|
242
|
-
|
|
243
|
-
|
|
281
|
+
const cycleEntities = new Set(entityNames.filter(n => !result.includes(n)));
|
|
282
|
+
console.warn(`[Compiler] Warning: Circular FK dependencies detected among: ${[...cycleEntities].join(', ')}`);
|
|
283
|
+
|
|
284
|
+
// Find FKs that are part of the cycle - these need deferred creation
|
|
285
|
+
for (const fk of allFKs) {
|
|
286
|
+
if (cycleEntities.has(fk.table) && cycleEntities.has(fk.referencedTable)) {
|
|
287
|
+
circularFKs.push(fk);
|
|
288
|
+
console.log(`[Compiler] Deferring circular FK: ${fk.table}.${fk.column} -> ${fk.referencedTable}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Add remaining entities (cycle members) to result
|
|
293
|
+
result.push(...cycleEntities);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { sorted: result, circularFKs };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Generate ALTER TABLE statements for deferred circular FK constraints.
|
|
301
|
+
*/
|
|
302
|
+
function generateDeferredFKConstraints(circularFKs: FKInfo[]): string {
|
|
303
|
+
if (circularFKs.length === 0) return '';
|
|
304
|
+
|
|
305
|
+
const statements: string[] = [
|
|
306
|
+
'\n-- === DEFERRED FK CONSTRAINTS (for circular dependencies) ===\n'
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
for (const fk of circularFKs) {
|
|
310
|
+
// Extract the REFERENCES part from the full constraint
|
|
311
|
+
const refMatch = fk.fullConstraint.match(/REFERENCES\s+(\w+)(\([^)]+\))?(\s+ON\s+.+)?/i);
|
|
312
|
+
if (refMatch) {
|
|
313
|
+
const targetTable = refMatch[1];
|
|
314
|
+
const targetCol = refMatch[2] || '(id)';
|
|
315
|
+
const cascadeClause = refMatch[3] || '';
|
|
316
|
+
const constraintName = `fk_${fk.table}_${fk.column}`;
|
|
317
|
+
|
|
318
|
+
statements.push(`ALTER TABLE ${fk.table} ADD CONSTRAINT ${constraintName} FOREIGN KEY (${fk.column}) REFERENCES ${targetTable}${targetCol}${cascadeClause};`);
|
|
319
|
+
}
|
|
244
320
|
}
|
|
245
321
|
|
|
246
|
-
return
|
|
322
|
+
return statements.join('\n');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Remove REFERENCES clause from a column type for deferred FK creation.
|
|
327
|
+
* Preserves NOT NULL and other modifiers.
|
|
328
|
+
*/
|
|
329
|
+
function stripReferences(columnType: string): string {
|
|
330
|
+
// Remove REFERENCES clause and any ON DELETE/UPDATE clauses
|
|
331
|
+
return columnType
|
|
332
|
+
.replace(/\s*REFERENCES\s+\w+(\([^)]+\))?(\s+ON\s+(DELETE|UPDATE)\s+(CASCADE|SET NULL|SET DEFAULT|RESTRICT|NO ACTION))*\s*/gi, ' ')
|
|
333
|
+
.trim();
|
|
247
334
|
}
|