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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.6.22",
3
+ "version": "0.6.24",
4
4
  "description": "Database-first real-time framework with TypeScript support",
5
5
  "repository": {
6
6
  "type": "git",
@@ -179,9 +179,23 @@ $$;
179
179
  `;
180
180
  }
181
181
 
182
- export function generateSchemaSQL(name: string, entityIR: EntityIR): string {
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
- return `${c.name} ${c.type}`;
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
- const match = rule.match || rule.params || {};
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
- const sortedEntityNames = topologicalSortEntities(ir.entities);
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
- entitySQL.push(generateSchemaSQL(name, entityIR));
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>): string[] {
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 (dependencies[name].size === 0) {
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
- dependencies[dependent].delete(node);
232
- if (dependencies[dependent].size === 0) {
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
- // Check for cycles
278
+ // Detect circular FKs
279
+ const circularFKs: FKInfo[] = [];
239
280
  if (result.length !== entityNames.length) {
240
- const remaining = entityNames.filter(n => !result.includes(n));
241
- console.warn(`[Compiler] Warning: Circular FK dependencies detected among: ${remaining.join(', ')}`);
242
- // Add remaining entities anyway (they may have circular refs)
243
- result.push(...remaining);
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 result;
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
  }