dzql 0.6.37 → 0.7.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/docs/README.md +1 -1
- package/docs/for_ai.md +39 -9
- package/package.json +2 -2
- package/src/cli/codegen/notification.ts +3 -3
- package/src/cli/codegen/sql/core.ts +21 -5
- package/src/cli/codegen/sql/delete.ts +5 -7
- package/src/cli/codegen/sql/get.ts +9 -11
- package/src/cli/codegen/sql/index.ts +7 -4
- package/src/cli/codegen/sql/lookup.ts +4 -5
- package/src/cli/codegen/sql/save.ts +58 -39
- package/src/cli/codegen/sql/search.ts +44 -10
- package/src/cli/codegen/sql/utils.ts +16 -1
- package/src/cli/codegen/subscribable_sql.ts +116 -124
- package/src/cli/compile.ts +263 -0
- package/src/cli/compiler/path-validator.ts +115 -83
- package/src/cli/compiler/paths.ts +299 -379
- package/src/cli/compiler/permissions.ts +26 -26
- package/src/cli/index.ts +17 -219
- package/src/client/ws.ts +20 -10
- package/src/runtime/auth.ts +33 -2
- package/src/runtime/index.ts +47 -6
- package/src/runtime/manifest_loader.ts +10 -0
- package/src/runtime/projection.ts +19 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ColumnInfo } from "./types.js";
|
|
1
|
+
import type { ColumnInfo, EntityIR } from "./types.js";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Generate a jsonb_build_object expression that excludes hidden fields.
|
|
@@ -17,6 +17,21 @@ export function buildVisibleJsonb(alias: string, columns: ColumnInfo[], hidden:
|
|
|
17
17
|
return `jsonb_build_object(${pairs})`;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Project a RELATED entity (FK/M2M expansion target) through its own visible
|
|
22
|
+
* columns, so a related object never leaks the target's hidden fields (e.g. a
|
|
23
|
+
* post's `author` must not expose users.password_hash). Falls back to
|
|
24
|
+
* to_jsonb(alias.*) only when the target entity is unknown to the generator.
|
|
25
|
+
* @param alias - Table alias the related row is selected under (e.g. 't', 'target')
|
|
26
|
+
* @param targetEntity - IR of the related entity, if available
|
|
27
|
+
*/
|
|
28
|
+
export function buildVisibleJsonbForEntity(alias: string, targetEntity?: EntityIR): string {
|
|
29
|
+
if (!targetEntity) {
|
|
30
|
+
return `to_jsonb(${alias}.*)`;
|
|
31
|
+
}
|
|
32
|
+
return buildVisibleJsonb(alias, targetEntity.columns, targetEntity.hidden || []);
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
/**
|
|
21
36
|
* Get the SQL cast for a column type
|
|
22
37
|
*/
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { SubscribableIR, IncludeIR, EntityIR } from "../../shared/ir.js";
|
|
12
|
-
import { parsePath } from "../compiler/paths.js";
|
|
12
|
+
import { parsePath, PathCompileError } from "../compiler/paths.js";
|
|
13
|
+
import { compileRuleList } from "../compiler/permissions.js";
|
|
13
14
|
|
|
14
15
|
export function generateSubscribableSQL(name: string, sub: SubscribableIR, entities: Record<string, EntityIR> = {}): string {
|
|
15
16
|
// Validate: if root.key is set, it must exist in params or start with '@'
|
|
@@ -28,6 +29,7 @@ export function generateSubscribableSQL(name: string, sub: SubscribableIR, entit
|
|
|
28
29
|
sections.push(generateHeader(name, sub));
|
|
29
30
|
sections.push(generateCanSubscribeFunction(name, sub));
|
|
30
31
|
sections.push(generateGetFunction(name, sub, entities));
|
|
32
|
+
sections.push(generateCanReceiveFunction(name, sub, entities));
|
|
31
33
|
sections.push(generateAffectedKeysFunction(name, sub));
|
|
32
34
|
|
|
33
35
|
return sections.join('\n\n');
|
|
@@ -43,13 +45,11 @@ function generateHeader(name: string, sub: SubscribableIR): string {
|
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
function generateCanSubscribeFunction(name: string, sub: SubscribableIR): string {
|
|
46
|
-
const
|
|
48
|
+
const rules = sub.canSubscribe || [];
|
|
47
49
|
const rootKey = sub.root.key;
|
|
48
50
|
const isList = !rootKey; // No key means it's a list subscribable
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
if (subscribePaths.length === 0) {
|
|
52
|
-
return `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_subscribe(
|
|
52
|
+
const header = `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_subscribe(
|
|
53
53
|
p_user_id INT,
|
|
54
54
|
p_params JSONB
|
|
55
55
|
) RETURNS BOOLEAN
|
|
@@ -57,45 +57,40 @@ LANGUAGE plpgsql
|
|
|
57
57
|
STABLE
|
|
58
58
|
SECURITY DEFINER
|
|
59
59
|
SET search_path = dzql_v2, public
|
|
60
|
-
AS
|
|
60
|
+
AS $$`;
|
|
61
|
+
|
|
62
|
+
// No rules: nobody may subscribe. Use TRUE for public, AUTHENTICATED for any signed-in user.
|
|
63
|
+
if (rules.length === 0) {
|
|
64
|
+
return `${header}
|
|
61
65
|
BEGIN
|
|
62
|
-
RETURN
|
|
66
|
+
RETURN FALSE; -- canSubscribe is empty: nobody may subscribe
|
|
63
67
|
END;
|
|
64
68
|
$$;`;
|
|
65
69
|
}
|
|
66
70
|
|
|
67
|
-
//
|
|
68
|
-
// (
|
|
71
|
+
// A list subscribable has no root row for a path to start from, so only
|
|
72
|
+
// literal rules (TRUE, FALSE, AUTHENTICATED) can be enforced. Anything else is
|
|
73
|
+
// a build error rather than a silent fallback.
|
|
69
74
|
if (isList) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
for (const rule of rules) {
|
|
76
|
+
if (!parsePath(rule).literal) {
|
|
77
|
+
throw new PathCompileError(
|
|
78
|
+
`${name}.canSubscribe: a list subscribable has no root row to follow a path from; use TRUE, FALSE or AUTHENTICATED`,
|
|
79
|
+
rule
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return `${header}
|
|
79
84
|
BEGIN
|
|
80
|
-
|
|
81
|
-
-- Row-level access is filtered in the get function
|
|
82
|
-
RETURN p_user_id IS NOT NULL;
|
|
85
|
+
RETURN (${compileRuleList(name, rules)});
|
|
83
86
|
END;
|
|
84
87
|
$$;`;
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
// Single-entity subscribable:
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
compileSubscribePermission(sub.root.entity, path, rootKey)
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
// If all checks compile to FALSE (unsupported paths), fall back to authenticated users
|
|
95
|
-
const allFalse = compiledChecks.every(c => c === 'FALSE');
|
|
96
|
-
const checks = allFalse
|
|
97
|
-
? 'p_user_id IS NOT NULL -- Fallback: multi-level paths not yet supported'
|
|
98
|
-
: compiledChecks.join(' OR\n ');
|
|
90
|
+
// Single-entity subscribable: rules are checked against the root row (v_root).
|
|
91
|
+
// A rule's reference to the key param means the root's id.
|
|
92
|
+
const fieldMap: Record<string, string> = rootKey.startsWith('@') ? {} : { [rootKey]: 'id' };
|
|
93
|
+
const checks = compileRuleList(name, rules, { kind: 'row', name: 'v_root', fieldMap });
|
|
99
94
|
|
|
100
95
|
// Extract params
|
|
101
96
|
const paramNames = Object.keys(sub.params);
|
|
@@ -109,15 +104,7 @@ $$;`;
|
|
|
109
104
|
? `p_${rootKey.slice(1)}` // @user_id -> p_user_id
|
|
110
105
|
: `v_${rootKey}`; // venue_id -> v_venue_id
|
|
111
106
|
|
|
112
|
-
return
|
|
113
|
-
p_user_id INT,
|
|
114
|
-
p_params JSONB
|
|
115
|
-
) RETURNS BOOLEAN
|
|
116
|
-
LANGUAGE plpgsql
|
|
117
|
-
STABLE
|
|
118
|
-
SECURITY DEFINER
|
|
119
|
-
SET search_path = dzql_v2, public
|
|
120
|
-
AS $$
|
|
107
|
+
return `${header}
|
|
121
108
|
DECLARE
|
|
122
109
|
${paramDecls}
|
|
123
110
|
v_root RECORD;
|
|
@@ -143,94 +130,91 @@ $$;`;
|
|
|
143
130
|
}
|
|
144
131
|
|
|
145
132
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
133
|
+
* <name>_can_receive(p_user_id, p_params, p_table, p_op, p_data): may this
|
|
134
|
+
* subscriber be pushed this change? The runtime calls it before every push.
|
|
135
|
+
* Returns 'row' (send the change), 'pk' (send only its key: a delete), or
|
|
136
|
+
* NULL (send nothing).
|
|
149
137
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
138
|
+
* - Nobody who fails can_subscribe receives anything.
|
|
139
|
+
* - A single-document subscription: canSubscribe is the whole gate.
|
|
140
|
+
* - A list: canSubscribe can only be a literal, so rows are filtered here, the
|
|
141
|
+
* same way the list query filters them. A root row needs the root's view
|
|
142
|
+
* rules; an included row must appear in one of the subscriber's documents.
|
|
143
|
+
* An included row that was deleted is in no document any more, so its
|
|
144
|
+
* delete goes to every subscriber as its key alone, which is all a client
|
|
145
|
+
* uses to remove it.
|
|
154
146
|
*/
|
|
155
|
-
function
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (parsed.hops.length === 1 && parsed.hops[0].type === 'field') {
|
|
176
|
-
const field = parsed.hops[0].field!;
|
|
177
|
-
const rootField = resolveField(field);
|
|
178
|
-
return `EXISTS (SELECT 1 FROM acts_for WHERE acts_for.${field} = v_root.${rootField} AND acts_for.user_id = p_user_id AND acts_for.active)`;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Traversal path - build EXISTS with nested subqueries for unlimited hops
|
|
182
|
-
// Start with the field from v_root
|
|
183
|
-
const startField = parsed.startField!;
|
|
184
|
-
const rootField = resolveField(startField);
|
|
185
|
-
|
|
186
|
-
// Build value expression starting from v_root
|
|
187
|
-
let valueExpr = `v_root.${rootField}`;
|
|
188
|
-
|
|
189
|
-
// Process intermediate hops (all but first field and last final)
|
|
190
|
-
for (let i = 1; i < parsed.hops.length - 1; i++) {
|
|
191
|
-
const hop = parsed.hops[i];
|
|
192
|
-
if (hop.type === 'table' && hop.table && hop.outputField) {
|
|
193
|
-
valueExpr = `(SELECT ${hop.outputField} FROM ${hop.table} WHERE id = ${valueExpr})`;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Final hop - build EXISTS check
|
|
198
|
-
const finalHop = parsed.hops[parsed.hops.length - 1];
|
|
199
|
-
if (finalHop.type !== 'final' || !finalHop.table) {
|
|
200
|
-
return 'FALSE';
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const conditions: string[] = [];
|
|
147
|
+
function generateCanReceiveFunction(name: string, sub: SubscribableIR, entities: Record<string, EntityIR>): string {
|
|
148
|
+
const isList = !sub.root.key;
|
|
149
|
+
const header = `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_receive(
|
|
150
|
+
p_user_id INT,
|
|
151
|
+
p_params JSONB,
|
|
152
|
+
p_table TEXT,
|
|
153
|
+
p_op TEXT,
|
|
154
|
+
p_data JSONB
|
|
155
|
+
) RETURNS TEXT
|
|
156
|
+
LANGUAGE plpgsql
|
|
157
|
+
STABLE
|
|
158
|
+
SECURITY DEFINER
|
|
159
|
+
SET search_path = dzql_v2, public
|
|
160
|
+
AS $$
|
|
161
|
+
BEGIN
|
|
162
|
+
IF NOT dzql_v2.${name}_can_subscribe(p_user_id, p_params) THEN
|
|
163
|
+
RETURN NULL;
|
|
164
|
+
END IF;
|
|
165
|
+
`;
|
|
166
|
+
const kind = `CASE WHEN p_op = 'delete' THEN 'pk' ELSE 'row' END`;
|
|
204
167
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
168
|
+
if (!isList) {
|
|
169
|
+
return `${header}
|
|
170
|
+
RETURN ${kind};
|
|
171
|
+
END;
|
|
172
|
+
$$;`;
|
|
210
173
|
}
|
|
211
174
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
conditions.push(`${finalHop.table}.user_id = p_user_id`);
|
|
215
|
-
} else if (finalHop.outputField) {
|
|
216
|
-
conditions.push(`${finalHop.table}.${finalHop.outputField} = p_user_id`);
|
|
217
|
-
}
|
|
175
|
+
const root = sub.root.entity;
|
|
176
|
+
const viewRules = compileRuleList(root, entities[root]?.permissions?.view, { kind: 'jsonb', name: 'p_data' });
|
|
218
177
|
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
} else {
|
|
227
|
-
const value = cond.value!.match(/^\d+$/) ? cond.value : `'${cond.value}'`;
|
|
228
|
-
conditions.push(`${finalHop.table}.${cond.field} = ${value}`);
|
|
229
|
-
}
|
|
178
|
+
// Where each included table sits in a list document: root columns || { rel: ... }
|
|
179
|
+
const pathsByTable: Record<string, string[]> = {};
|
|
180
|
+
const collect = (incl: Record<string, IncludeIR>, prefix: string) => {
|
|
181
|
+
for (const [rel, cfg] of Object.entries(incl || {})) {
|
|
182
|
+
const path = `${prefix}."${rel}"`;
|
|
183
|
+
(pathsByTable[cfg.entity] ??= []).push(path);
|
|
184
|
+
if (cfg.includes) collect(cfg.includes, path);
|
|
230
185
|
}
|
|
231
|
-
}
|
|
186
|
+
};
|
|
187
|
+
collect(sub.includes, '$');
|
|
188
|
+
delete pathsByTable[root];
|
|
189
|
+
|
|
190
|
+
const includeCases = Object.entries(pathsByTable).map(([table, paths]) => {
|
|
191
|
+
const shown = paths
|
|
192
|
+
.map(path => `jsonb_path_exists(d, '${path} ? (@.id == $id)', jsonb_build_object('id', p_data->'id'))`)
|
|
193
|
+
.join(' OR ');
|
|
194
|
+
return ` IF p_table = '${table}' THEN
|
|
195
|
+
IF p_op = 'delete' THEN
|
|
196
|
+
RETURN 'pk';
|
|
197
|
+
END IF;
|
|
198
|
+
IF EXISTS (SELECT 1 FROM dzql_v2.get_${name}(p_params, p_user_id) d WHERE ${shown}) THEN
|
|
199
|
+
RETURN 'row';
|
|
200
|
+
END IF;
|
|
201
|
+
RETURN NULL;
|
|
202
|
+
END IF;
|
|
203
|
+
`;
|
|
204
|
+
}).join('\n');
|
|
205
|
+
|
|
206
|
+
return `${header}
|
|
207
|
+
IF p_table = '${root}' THEN
|
|
208
|
+
IF ${viewRules} THEN
|
|
209
|
+
RETURN ${kind};
|
|
210
|
+
END IF;
|
|
211
|
+
RETURN NULL;
|
|
212
|
+
END IF;
|
|
232
213
|
|
|
233
|
-
|
|
214
|
+
${includeCases}
|
|
215
|
+
RETURN NULL;
|
|
216
|
+
END;
|
|
217
|
+
$$;`;
|
|
234
218
|
}
|
|
235
219
|
|
|
236
220
|
/** Column info from EntityIR */
|
|
@@ -307,6 +291,13 @@ function generateGetFunction(name: string, sub: SubscribableIR, entities: Record
|
|
|
307
291
|
}
|
|
308
292
|
}
|
|
309
293
|
}
|
|
294
|
+
if (isList) {
|
|
295
|
+
// canSubscribe on a list can only be a literal, so it can't tell rows apart.
|
|
296
|
+
// Return just the rows the subscriber may view: the root's view rules,
|
|
297
|
+
// checked against each root row ([] shows nobody any row).
|
|
298
|
+
const viewRules = entities[sub.root.entity]?.permissions?.view;
|
|
299
|
+
whereConditions.push(`(${compileRuleList(sub.root.entity, viewRules, { kind: 'row', name: 'root' })})`);
|
|
300
|
+
}
|
|
310
301
|
const whereClause = whereConditions.length > 0
|
|
311
302
|
? `WHERE ${whereConditions.join(' AND ')}`
|
|
312
303
|
: '';
|
|
@@ -334,9 +325,10 @@ ${paramExtracts}
|
|
|
334
325
|
RAISE EXCEPTION 'permission_denied';
|
|
335
326
|
END IF;
|
|
336
327
|
|
|
337
|
-
-- Return stream of documents
|
|
328
|
+
-- Return stream of documents (root projected to visible columns; ::jsonb so
|
|
329
|
+
-- the || merge works even when the projection is row_to_json)
|
|
338
330
|
RETURN QUERY
|
|
339
|
-
SELECT
|
|
331
|
+
SELECT (${rootSelectExpr})::jsonb || jsonb_build_object(${relationSelects ? relationSelects.slice(1) : ''})
|
|
340
332
|
FROM ${sub.root.entity} root
|
|
341
333
|
${whereClause};
|
|
342
334
|
END;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain -> SQL compilation pipeline.
|
|
3
|
+
*
|
|
4
|
+
* The CLI writes these strings to migration files; tests load the same strings
|
|
5
|
+
* into Postgres, so they exercise exactly the SQL the compiler produces.
|
|
6
|
+
*/
|
|
7
|
+
import type { DomainConfig } from "../shared/ir.js";
|
|
8
|
+
import { analyzeDomain } from "./compiler/analyzer.js";
|
|
9
|
+
import { generateIR } from "./compiler/ir.js";
|
|
10
|
+
import { validateDomainPaths } from "./compiler/path-validator.js";
|
|
11
|
+
import { generateCoreSQL, generateAuthSQL, generateEntitySQL, generateSchemaSQL } from "./codegen/sql.js";
|
|
12
|
+
import { generateSubscribableSQL, generateComputeAffectedKeysFunction } from "./codegen/subscribable_sql.js";
|
|
13
|
+
|
|
14
|
+
export interface CompiledDomain {
|
|
15
|
+
ir: any;
|
|
16
|
+
coreSQL: string;
|
|
17
|
+
/** Tables, CRUD functions, auth functions and custom functions */
|
|
18
|
+
schemaSQL: string;
|
|
19
|
+
/** Subscribable functions plus compute_affected_keys */
|
|
20
|
+
subscribableSQL: string;
|
|
21
|
+
customFunctionCount: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class DomainValidationError extends Error {
|
|
25
|
+
constructor(public errors: string[]) {
|
|
26
|
+
super(`Domain validation failed:\n${errors.map(e => ` - ${e}`).join('\n')}`);
|
|
27
|
+
this.name = 'DomainValidationError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function compileDomain(domain: DomainConfig): CompiledDomain {
|
|
32
|
+
const errors = analyzeDomain(domain);
|
|
33
|
+
if (errors.length > 0) {
|
|
34
|
+
throw new DomainValidationError(errors);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ir = generateIR(domain);
|
|
38
|
+
|
|
39
|
+
// Every permission, notification and canSubscribe path must compile against
|
|
40
|
+
// this domain. A path that can't is a build error, never FALSE or a fallback.
|
|
41
|
+
const pathErrors = validateDomainPaths(ir);
|
|
42
|
+
if (pathErrors.length > 0) {
|
|
43
|
+
throw new DomainValidationError(pathErrors);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const coreSQL = generateCoreSQL();
|
|
47
|
+
|
|
48
|
+
// Topologically sort entities by FK dependencies
|
|
49
|
+
// Entities must be created before entities that reference them
|
|
50
|
+
// Also detects circular FKs that need deferred constraint creation
|
|
51
|
+
const { sorted: sortedEntityNames, circularFKs } = topologicalSortEntities(ir.entities);
|
|
52
|
+
|
|
53
|
+
// Build set of circular FK columns for stripping during schema generation
|
|
54
|
+
const circularFKSet = new Set(circularFKs.map(fk => `${fk.table}.${fk.column}`));
|
|
55
|
+
|
|
56
|
+
const entitySQL: string[] = [];
|
|
57
|
+
for (const name of sortedEntityNames) {
|
|
58
|
+
const entityIR = ir.entities[name];
|
|
59
|
+
// Pass circular FK info so schema generation can strip those constraints
|
|
60
|
+
entitySQL.push(generateSchemaSQL(name, entityIR, circularFKSet));
|
|
61
|
+
// Skip CRUD generation for unmanaged entities (e.g., junction tables)
|
|
62
|
+
if (entityIR.managed !== false) {
|
|
63
|
+
entitySQL.push(generateEntitySQL(name, entityIR, ir.entities));
|
|
64
|
+
} else {
|
|
65
|
+
console.log(`[Compiler] Skipping CRUD for unmanaged entity: ${name}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Add deferred FK constraints at the end (for circular dependencies)
|
|
70
|
+
if (circularFKs.length > 0) {
|
|
71
|
+
entitySQL.push(generateDeferredFKConstraints(circularFKs));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Generate subscribable SQL functions
|
|
75
|
+
const subscribableSQL: string[] = [];
|
|
76
|
+
const subscribableNames = Object.keys(ir.subscribables);
|
|
77
|
+
for (const [name, subIR] of Object.entries(ir.subscribables)) {
|
|
78
|
+
console.log(`[Compiler] Generating SQL for subscribable: ${name}`);
|
|
79
|
+
subscribableSQL.push(generateSubscribableSQL(name, subIR as any, ir.entities));
|
|
80
|
+
}
|
|
81
|
+
// Generate central compute_affected_keys function that aggregates all subscribables
|
|
82
|
+
subscribableSQL.push(generateComputeAffectedKeysFunction(subscribableNames));
|
|
83
|
+
|
|
84
|
+
// Collect custom functions SQL
|
|
85
|
+
const customFunctionSQL: string[] = [];
|
|
86
|
+
for (const fn of ir.customFunctions) {
|
|
87
|
+
console.log(`[Compiler] Adding custom function: ${fn.name}`);
|
|
88
|
+
customFunctionSQL.push(fn.sql);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Combine entity SQL with auth functions and custom functions
|
|
92
|
+
// Auth functions must come after schema (they depend on users table)
|
|
93
|
+
// Columns a client may set at registration: those declared in
|
|
94
|
+
// auth.registerParams (beyond the built-in email/password/options), else
|
|
95
|
+
// just the users label field. Everything else is rejected at sign-up.
|
|
96
|
+
const usersEntity = ir.entities.users;
|
|
97
|
+
const declaredRegisterable = Object.keys(ir.auth?.registerParams || {})
|
|
98
|
+
.filter((k) => !["email", "password", "options"].includes(k));
|
|
99
|
+
const registerableColumns = declaredRegisterable.length > 0
|
|
100
|
+
? declaredRegisterable
|
|
101
|
+
: (usersEntity && usersEntity.labelField && usersEntity.labelField !== "id"
|
|
102
|
+
? [usersEntity.labelField]
|
|
103
|
+
: []);
|
|
104
|
+
const authSQL = generateAuthSQL(registerableColumns);
|
|
105
|
+
let schemaSQL = entitySQL.join('\n');
|
|
106
|
+
schemaSQL += '\n\n-- Auth Functions\n' + authSQL;
|
|
107
|
+
if (customFunctionSQL.length > 0) {
|
|
108
|
+
schemaSQL += '\n\n-- Custom Functions\n' + customFunctionSQL.join('\n\n');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
ir,
|
|
113
|
+
coreSQL,
|
|
114
|
+
schemaSQL,
|
|
115
|
+
subscribableSQL: subscribableSQL.join('\n\n'),
|
|
116
|
+
customFunctionCount: customFunctionSQL.length,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** FK info for dependency analysis */
|
|
121
|
+
interface FKInfo {
|
|
122
|
+
table: string;
|
|
123
|
+
column: string;
|
|
124
|
+
referencedTable: string;
|
|
125
|
+
fullConstraint: string; // e.g., "int NOT NULL REFERENCES users(id)"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Result of topological sort with cycle detection */
|
|
129
|
+
interface SortResult {
|
|
130
|
+
sorted: string[];
|
|
131
|
+
circularFKs: FKInfo[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Topologically sort entities based on FK dependencies.
|
|
136
|
+
* Entities that are referenced by others come first.
|
|
137
|
+
* Uses Kahn's algorithm for topological sorting.
|
|
138
|
+
* Returns both sorted order and any circular FK constraints that need deferred creation.
|
|
139
|
+
*/
|
|
140
|
+
function topologicalSortEntities(entities: Record<string, any>): SortResult {
|
|
141
|
+
const entityNames = Object.keys(entities);
|
|
142
|
+
|
|
143
|
+
// Build dependency graph: entity -> entities it depends on (references)
|
|
144
|
+
const dependencies: Record<string, Set<string>> = {};
|
|
145
|
+
const dependents: Record<string, Set<string>> = {};
|
|
146
|
+
|
|
147
|
+
// Track all FK info for cycle detection
|
|
148
|
+
const allFKs: FKInfo[] = [];
|
|
149
|
+
|
|
150
|
+
for (const name of entityNames) {
|
|
151
|
+
dependencies[name] = new Set();
|
|
152
|
+
dependents[name] = new Set();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Parse REFERENCES from column types
|
|
156
|
+
for (const name of entityNames) {
|
|
157
|
+
const entity = entities[name];
|
|
158
|
+
for (const col of entity.columns || []) {
|
|
159
|
+
const match = col.type?.match(/REFERENCES\s+(\w+)/i);
|
|
160
|
+
if (match) {
|
|
161
|
+
const referencedEntity = match[1];
|
|
162
|
+
// Only track dependencies to entities we're managing
|
|
163
|
+
if (entityNames.includes(referencedEntity)) {
|
|
164
|
+
dependencies[name].add(referencedEntity);
|
|
165
|
+
dependents[referencedEntity].add(name);
|
|
166
|
+
allFKs.push({
|
|
167
|
+
table: name,
|
|
168
|
+
column: col.name,
|
|
169
|
+
referencedTable: referencedEntity,
|
|
170
|
+
fullConstraint: col.type
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Kahn's algorithm
|
|
178
|
+
const result: string[] = [];
|
|
179
|
+
const noIncoming: string[] = [];
|
|
180
|
+
const remainingDeps: Record<string, Set<string>> = {};
|
|
181
|
+
|
|
182
|
+
// Copy dependencies for mutation
|
|
183
|
+
for (const name of entityNames) {
|
|
184
|
+
remainingDeps[name] = new Set(dependencies[name]);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Find entities with no dependencies (no incoming edges)
|
|
188
|
+
for (const name of entityNames) {
|
|
189
|
+
if (remainingDeps[name].size === 0) {
|
|
190
|
+
noIncoming.push(name);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
while (noIncoming.length > 0) {
|
|
195
|
+
const node = noIncoming.shift()!;
|
|
196
|
+
result.push(node);
|
|
197
|
+
|
|
198
|
+
// Remove this node from the graph
|
|
199
|
+
for (const dependent of dependents[node]) {
|
|
200
|
+
remainingDeps[dependent].delete(node);
|
|
201
|
+
if (remainingDeps[dependent].size === 0) {
|
|
202
|
+
noIncoming.push(dependent);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Detect circular FKs
|
|
208
|
+
const circularFKs: FKInfo[] = [];
|
|
209
|
+
if (result.length !== entityNames.length) {
|
|
210
|
+
const cycleEntities = new Set(entityNames.filter(n => !result.includes(n)));
|
|
211
|
+
console.warn(`[Compiler] Warning: Circular FK dependencies detected among: ${[...cycleEntities].join(', ')}`);
|
|
212
|
+
|
|
213
|
+
// Find FKs that are part of the cycle - these need deferred creation
|
|
214
|
+
for (const fk of allFKs) {
|
|
215
|
+
if (cycleEntities.has(fk.table) && cycleEntities.has(fk.referencedTable)) {
|
|
216
|
+
circularFKs.push(fk);
|
|
217
|
+
console.log(`[Compiler] Deferring circular FK: ${fk.table}.${fk.column} -> ${fk.referencedTable}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Add remaining entities (cycle members) to result
|
|
222
|
+
result.push(...cycleEntities);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { sorted: result, circularFKs };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Generate ALTER TABLE statements for deferred circular FK constraints.
|
|
230
|
+
*/
|
|
231
|
+
function generateDeferredFKConstraints(circularFKs: FKInfo[]): string {
|
|
232
|
+
if (circularFKs.length === 0) return '';
|
|
233
|
+
|
|
234
|
+
const statements: string[] = [
|
|
235
|
+
'\n-- === DEFERRED FK CONSTRAINTS (for circular dependencies) ===\n'
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
for (const fk of circularFKs) {
|
|
239
|
+
// Extract the REFERENCES part from the full constraint
|
|
240
|
+
const refMatch = fk.fullConstraint.match(/REFERENCES\s+(\w+)(\([^)]+\))?(\s+ON\s+.+)?/i);
|
|
241
|
+
if (refMatch) {
|
|
242
|
+
const targetTable = refMatch[1];
|
|
243
|
+
const targetCol = refMatch[2] || '(id)';
|
|
244
|
+
const cascadeClause = refMatch[3] || '';
|
|
245
|
+
const constraintName = `fk_${fk.table}_${fk.column}`;
|
|
246
|
+
|
|
247
|
+
statements.push(`ALTER TABLE ${fk.table} ADD CONSTRAINT ${constraintName} FOREIGN KEY (${fk.column}) REFERENCES ${targetTable}${targetCol}${cascadeClause};`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return statements.join('\n');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Remove REFERENCES clause from a column type for deferred FK creation.
|
|
256
|
+
* Preserves NOT NULL and other modifiers.
|
|
257
|
+
*/
|
|
258
|
+
function stripReferences(columnType: string): string {
|
|
259
|
+
// Remove REFERENCES clause and any ON DELETE/UPDATE clauses
|
|
260
|
+
return columnType
|
|
261
|
+
.replace(/\s*REFERENCES\s+\w+(\([^)]+\))?(\s+ON\s+(DELETE|UPDATE)\s+(CASCADE|SET NULL|SET DEFAULT|RESTRICT|NO ACTION))*\s*/gi, ' ')
|
|
262
|
+
.trim();
|
|
263
|
+
}
|