dzql 0.6.37 → 0.8.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/README.md +16 -0
- package/docs/README.md +1 -1
- package/docs/for_ai.md +39 -9
- package/package.json +12 -5
- 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/postgres/auth-jwt-sql.ts +32 -0
- package/src/postgres/auth-jwt.ts +209 -0
- package/src/postgres/auth.ts +50 -0
- package/src/postgres/bootstrap.ts +55 -0
- package/src/postgres/carry.ts +40 -0
- package/src/postgres/codegen.ts +172 -0
- package/src/postgres/index.ts +58 -0
- package/src/postgres/listener.ts +706 -0
- package/src/postgres/migrate.ts +143 -0
- package/src/postgres/pglite.ts +142 -0
- package/src/postgres/registry.ts +253 -0
- package/src/postgres/schema.ts +171 -0
- package/src/postgres/sql.ts +42 -0
- 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
- package/src/sql/001a-delta-meta.sql +204 -0
- package/src/sql/001b-delta-scope.sql +619 -0
- package/src/sql/001c-delta-read.sql +506 -0
- package/src/sql/001d-delta-write.sql +1088 -0
- package/src/sql/001e-delta-ops.sql +142 -0
- package/src/sql/001f-delta-as.sql +41 -0
- package/src/sql/001g-delta-ledger.sql +564 -0
- package/src/sql/auth-jwt.sql +62 -0
|
@@ -1,17 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Path Expression Parser and Compiler
|
|
3
3
|
*
|
|
4
|
-
* Shared
|
|
5
|
-
*
|
|
6
|
-
* - EXISTS:
|
|
7
|
-
* - SELECT:
|
|
4
|
+
* Shared by permission checks, subscribe checks and notification resolution, so a
|
|
5
|
+
* rule means the same thing wherever it is written. Output modes:
|
|
6
|
+
* - EXISTS: for permission checks (returns boolean)
|
|
7
|
+
* - SELECT: for notification resolution (returns user ids)
|
|
8
|
+
*
|
|
9
|
+
* Grammar:
|
|
10
|
+
* rule := TRUE | FALSE | AUTHENTICATED | @field | @field == @user_id | chain
|
|
11
|
+
* chain := start ('->' hop)+
|
|
12
|
+
* start := @field | table '[' filters ']' conds? '.' field (table-first)
|
|
13
|
+
* hop := table ('[' filters ']')? conds? ('.' field)?
|
|
14
|
+
* filters:= field '=' value (',' field '=' value)*
|
|
15
|
+
* conds := '{' cond (',' cond)* '}'
|
|
16
|
+
*
|
|
17
|
+
* Each hop after the start joins on the previous value: on its `$` filter if it
|
|
18
|
+
* has brackets, otherwise on its id. A hop's `.field` is the value passed on;
|
|
19
|
+
* the last hop's `.field` is compared with the user. Every hop compiles to a
|
|
20
|
+
* nested EXISTS, so a hop that matches several rows (occasion_promoters for an
|
|
21
|
+
* occasion) is followed correctly.
|
|
22
|
+
*
|
|
23
|
+
* A path that can't be compiled throws PathCompileError. There is no fallback.
|
|
8
24
|
*/
|
|
9
25
|
|
|
26
|
+
export class PathCompileError extends Error {
|
|
27
|
+
constructor(message: string, public path?: string) {
|
|
28
|
+
super(path !== undefined ? `${message} in path '${path}'` : message);
|
|
29
|
+
this.name = 'PathCompileError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FilterPart {
|
|
34
|
+
field: string;
|
|
35
|
+
value: string; // '$', '@field', or a literal as written
|
|
36
|
+
}
|
|
37
|
+
|
|
10
38
|
export interface PathHop {
|
|
11
39
|
type: 'field' | 'table' | 'final';
|
|
12
40
|
table?: string;
|
|
13
41
|
field?: string;
|
|
14
|
-
filter?:
|
|
42
|
+
filter?: FilterPart; // the join filter ($), or the first filter
|
|
43
|
+
filters?: FilterPart[]; // every filter inside [ ]
|
|
15
44
|
conditions?: ConditionPart[]; // e.g., {active} or {role=admin}
|
|
16
45
|
outputField?: string; // The .field at the end
|
|
17
46
|
}
|
|
@@ -20,495 +49,386 @@ export interface ConditionPart {
|
|
|
20
49
|
type: 'boolean' | 'equality' | 'temporal';
|
|
21
50
|
field: string;
|
|
22
51
|
value?: string;
|
|
52
|
+
op?: string; // temporal comparison operator
|
|
23
53
|
}
|
|
24
54
|
|
|
55
|
+
export type PathLiteral = 'TRUE' | 'FALSE' | 'AUTHENTICATED';
|
|
56
|
+
|
|
25
57
|
export interface ParsedPath {
|
|
26
58
|
hops: PathHop[];
|
|
27
59
|
startField?: string; // For @field patterns
|
|
28
60
|
tableFirst?: boolean; // For table[filter].field patterns
|
|
61
|
+
literal?: PathLiteral;
|
|
62
|
+
path?: string;
|
|
29
63
|
isValid: boolean;
|
|
30
64
|
error?: string;
|
|
31
65
|
}
|
|
32
66
|
|
|
33
67
|
/**
|
|
34
|
-
*
|
|
68
|
+
* Where the row being checked comes from:
|
|
69
|
+
* - jsonb: a JSONB variable such as p_data or v_old_data
|
|
70
|
+
* - row: a table name or alias in scope, or a RECORD variable such as v_root.
|
|
71
|
+
* fieldMap renames fields (a subscribable's key param maps to the root's id).
|
|
35
72
|
*/
|
|
73
|
+
export type RowSource =
|
|
74
|
+
| { kind: 'jsonb'; name: string }
|
|
75
|
+
| { kind: 'row'; name: string; fieldMap?: Record<string, string> };
|
|
76
|
+
|
|
77
|
+
const IDENT = '[a-zA-Z_][a-zA-Z0-9_]*';
|
|
78
|
+
const IDENT_RE = new RegExp(`^${IDENT}$`);
|
|
79
|
+
const HOP_RE = new RegExp(`^(${IDENT})(?:\\[([^\\]]*)\\])?(?:\\{([^}]*)\\})?(?:\\.(${IDENT}))?$`);
|
|
80
|
+
const LITERAL_VALUE_RE = /^(-?\d+(\.\d+)?|'(?:[^']|'')*'|NULL|null|true|false|[a-zA-Z_][a-zA-Z0-9_]*)$/;
|
|
81
|
+
const TEMPORAL_RE = new RegExp(
|
|
82
|
+
`^(${IDENT})\\s*(<=|>=|<>|!=|<|>)\\s*(now\\(\\)|current_date|current_timestamp|NULL|-?\\d+(?:\\.\\d+)?|'(?:[^']|'')*')$`, 'i');
|
|
83
|
+
const INT_TYPE_RE = /^\s*(int|integer|int2|int4|int8|smallint|bigint|serial|bigserial|smallserial)\b/i;
|
|
84
|
+
|
|
85
|
+
/** True if a schema type string is an integer key type. */
|
|
86
|
+
export function isIntegerType(type: string | undefined): boolean {
|
|
87
|
+
return !!type && INT_TYPE_RE.test(type);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const invalid = (path: string, error: string): ParsedPath => ({ hops: [], isValid: false, error, path });
|
|
91
|
+
|
|
92
|
+
/** Parse a path expression into structured hops */
|
|
36
93
|
export function parsePath(path: string): ParsedPath {
|
|
37
|
-
path = path.trim();
|
|
94
|
+
path = (path ?? '').trim();
|
|
38
95
|
|
|
39
|
-
|
|
40
|
-
if (path === '
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (path === 'FALSE' || path === 'false') {
|
|
44
|
-
return { hops: [], isValid: true };
|
|
96
|
+
if (path === 'TRUE' || path === 'true') return { hops: [], isValid: true, literal: 'TRUE', path };
|
|
97
|
+
if (path === 'FALSE' || path === 'false') return { hops: [], isValid: true, literal: 'FALSE', path };
|
|
98
|
+
if (path === 'AUTHENTICATED' || path === 'authenticated') {
|
|
99
|
+
return { hops: [], isValid: true, literal: 'AUTHENTICATED', path };
|
|
45
100
|
}
|
|
46
101
|
|
|
47
102
|
// Simple field reference: @author_id
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
startField: path.slice(1),
|
|
52
|
-
isValid: true
|
|
53
|
-
};
|
|
103
|
+
const fieldMatch = path.match(new RegExp(`^@(${IDENT})$`));
|
|
104
|
+
if (fieldMatch) {
|
|
105
|
+
return { hops: [{ type: 'field', field: fieldMatch[1] }], startField: fieldMatch[1], isValid: true, path };
|
|
54
106
|
}
|
|
55
107
|
|
|
56
108
|
// Explicit comparison: @field == @user_id
|
|
57
|
-
const comparisonMatch = path.match(
|
|
109
|
+
const comparisonMatch = path.match(new RegExp(`^@(${IDENT})\\s*==\\s*@user_id$`));
|
|
58
110
|
if (comparisonMatch) {
|
|
59
|
-
return {
|
|
60
|
-
hops: [{ type: 'field', field: comparisonMatch[1] }],
|
|
61
|
-
startField: comparisonMatch[1],
|
|
62
|
-
isValid: true
|
|
63
|
-
};
|
|
111
|
+
return { hops: [{ type: 'field', field: comparisonMatch[1] }], startField: comparisonMatch[1], isValid: true, path };
|
|
64
112
|
}
|
|
65
113
|
|
|
66
|
-
// Traversal paths (with ->)
|
|
67
114
|
if (path.includes('->')) {
|
|
68
|
-
|
|
115
|
+
try {
|
|
116
|
+
return parseChain(path, path.split('->'));
|
|
117
|
+
} catch (e) {
|
|
118
|
+
if (e instanceof PathCompileError) return invalid(path, e.message);
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
69
121
|
}
|
|
70
122
|
|
|
71
|
-
// Table-first without traversal
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
123
|
+
// Table-first without traversal: rights[package_id=@package_id].user_id
|
|
124
|
+
if (new RegExp(`^${IDENT}\\[`).test(path)) {
|
|
125
|
+
try {
|
|
126
|
+
return parseChain(path, [path]);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
if (e instanceof PathCompileError) return invalid(path, e.message);
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
75
131
|
}
|
|
76
132
|
|
|
77
|
-
return {
|
|
78
|
-
hops: [],
|
|
79
|
-
isValid: false,
|
|
80
|
-
error: `Unrecognized path format: ${path}`
|
|
81
|
-
};
|
|
133
|
+
return invalid(path, `Unrecognized path format: ${path}`);
|
|
82
134
|
}
|
|
83
135
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const rawHops = path.split('->');
|
|
89
|
-
const hops: PathHop[] = [];
|
|
90
|
-
|
|
91
|
-
if (rawHops.length < 2) {
|
|
92
|
-
return { hops: [], isValid: false, error: 'Traversal path must have at least 2 hops' };
|
|
136
|
+
function parseChain(path: string, segments: string[]): ParsedPath {
|
|
137
|
+
const raw = segments.map(s => s.trim());
|
|
138
|
+
if (raw.some(s => s === '')) {
|
|
139
|
+
throw new PathCompileError(`Empty hop`);
|
|
93
140
|
}
|
|
94
141
|
|
|
95
|
-
const
|
|
142
|
+
const hops: PathHop[] = [];
|
|
96
143
|
let tableFirst = false;
|
|
144
|
+
let startField: string | undefined;
|
|
97
145
|
|
|
98
|
-
|
|
99
|
-
|
|
146
|
+
if (raw[0].startsWith('@')) {
|
|
147
|
+
if (raw.length < 2) throw new PathCompileError(`A traversal needs at least one hop after ${raw[0]}`);
|
|
148
|
+
startField = raw[0].slice(1);
|
|
149
|
+
if (!IDENT_RE.test(startField)) throw new PathCompileError(`Cannot parse start '${raw[0]}'`);
|
|
150
|
+
hops.push({ type: 'field', field: startField });
|
|
151
|
+
} else {
|
|
100
152
|
tableFirst = true;
|
|
101
|
-
const
|
|
102
|
-
if (!
|
|
103
|
-
|
|
153
|
+
const first = parseHop(raw[0], raw.length === 1 ? 'final' : 'table');
|
|
154
|
+
if (!first.filters?.length || !first.outputField) {
|
|
155
|
+
throw new PathCompileError(`A table-first hop needs [filters] and a .field: '${raw[0]}'`);
|
|
104
156
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// Field reference: @venue_id
|
|
108
|
-
const field = firstHop.slice(1);
|
|
109
|
-
hops.push({ type: 'field', field });
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Process intermediate hops (all but last)
|
|
113
|
-
for (let i = 1; i < rawHops.length - 1; i++) {
|
|
114
|
-
const hop = rawHops[i];
|
|
115
|
-
const parsed = parseIntermediateHop(hop);
|
|
116
|
-
if (!parsed) {
|
|
117
|
-
return { hops: [], isValid: false, error: `Cannot parse intermediate hop: ${hop}` };
|
|
157
|
+
if (first.filters.some(f => f.value === '$')) {
|
|
158
|
+
throw new PathCompileError(`$ has nothing to join to in the first hop '${raw[0]}'`);
|
|
118
159
|
}
|
|
119
|
-
hops.push(
|
|
160
|
+
hops.push(first);
|
|
120
161
|
}
|
|
121
162
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
163
|
+
for (let i = 1; i < raw.length; i++) {
|
|
164
|
+
const isFinal = i === raw.length - 1;
|
|
165
|
+
const hop = parseHop(raw[i], isFinal ? 'final' : 'table');
|
|
166
|
+
if (hop.filters && hop.filters.length > 0) {
|
|
167
|
+
const joins = hop.filters.filter(f => f.value === '$');
|
|
168
|
+
if (joins.length === 0) {
|
|
169
|
+
throw new PathCompileError(`Hop '${raw[i]}' has [filters] so it must join on $`);
|
|
170
|
+
}
|
|
171
|
+
if (joins.length > 1) {
|
|
172
|
+
throw new PathCompileError(`Hop '${raw[i]}' has more than one $`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
hops.push(hop);
|
|
127
176
|
}
|
|
128
|
-
hops.push(parsed);
|
|
129
177
|
|
|
130
|
-
return {
|
|
131
|
-
hops,
|
|
132
|
-
startField: !tableFirst ? firstHop.slice(1) : undefined,
|
|
133
|
-
tableFirst,
|
|
134
|
-
isValid: true
|
|
135
|
-
};
|
|
178
|
+
return { hops, startField, tableFirst, isValid: true, path };
|
|
136
179
|
}
|
|
137
180
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const match = hop.match(/^([a-zA-Z0-9_]+)\[([^\]]+)\](\{[^}]+\})?\.([a-zA-Z0-9_]+)$/);
|
|
143
|
-
if (!match) return null;
|
|
144
|
-
|
|
145
|
-
const [, table, filterExpr, condExpr, outputField] = match;
|
|
181
|
+
function parseHop(text: string, type: 'table' | 'final'): PathHop {
|
|
182
|
+
const m = text.match(HOP_RE);
|
|
183
|
+
if (!m) throw new PathCompileError(`Cannot parse hop '${text}'`);
|
|
184
|
+
const [, table, filterExpr, condExpr, outputField] = m;
|
|
146
185
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
value: filterMatch[2]
|
|
154
|
-
};
|
|
186
|
+
if (type === 'final' && !outputField) {
|
|
187
|
+
throw new PathCompileError(`The final hop '${text}' needs a .field to compare with the user`);
|
|
188
|
+
}
|
|
189
|
+
if (type === 'table' && !outputField && (filterExpr !== undefined || condExpr !== undefined)) {
|
|
190
|
+
throw new PathCompileError(`Hop '${text}' needs a .field to pass on`);
|
|
191
|
+
}
|
|
155
192
|
|
|
156
|
-
const
|
|
193
|
+
const filters = filterExpr !== undefined ? parseFilters(filterExpr) : undefined;
|
|
194
|
+
const conditions = condExpr !== undefined ? parseConditions(condExpr) : [];
|
|
195
|
+
const filter = filters?.find(f => f.value === '$') ?? filters?.[0];
|
|
157
196
|
|
|
158
197
|
return {
|
|
159
|
-
type
|
|
198
|
+
type,
|
|
160
199
|
table,
|
|
161
200
|
filter,
|
|
201
|
+
filters,
|
|
162
202
|
conditions,
|
|
163
|
-
outputField
|
|
203
|
+
outputField: outputField ?? 'id',
|
|
164
204
|
};
|
|
165
205
|
}
|
|
166
206
|
|
|
167
|
-
/**
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Parse intermediate hop: table.field
|
|
184
|
-
*/
|
|
185
|
-
function parseIntermediateHop(hop: string): PathHop | null {
|
|
186
|
-
const dotMatch = hop.match(/^([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]+)$/);
|
|
187
|
-
if (dotMatch) {
|
|
188
|
-
return {
|
|
189
|
-
type: 'table',
|
|
190
|
-
table: dotMatch[1],
|
|
191
|
-
outputField: dotMatch[2]
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Just table name - use id
|
|
196
|
-
if (hop.match(/^[a-zA-Z0-9_]+$/)) {
|
|
197
|
-
return {
|
|
198
|
-
type: 'table',
|
|
199
|
-
table: hop,
|
|
200
|
-
outputField: 'id'
|
|
201
|
-
};
|
|
207
|
+
/** Split on commas that are not inside single quotes */
|
|
208
|
+
function splitTopLevel(text: string): string[] {
|
|
209
|
+
const parts: string[] = [];
|
|
210
|
+
let cur = '';
|
|
211
|
+
let inQuote = false;
|
|
212
|
+
for (const ch of text) {
|
|
213
|
+
if (ch === "'") inQuote = !inQuote;
|
|
214
|
+
if (ch === ',' && !inQuote) {
|
|
215
|
+
parts.push(cur.trim());
|
|
216
|
+
cur = '';
|
|
217
|
+
} else {
|
|
218
|
+
cur += ch;
|
|
219
|
+
}
|
|
202
220
|
}
|
|
203
|
-
|
|
204
|
-
return
|
|
221
|
+
parts.push(cur.trim());
|
|
222
|
+
return parts;
|
|
205
223
|
}
|
|
206
224
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const [, table, filterExpr, condExpr, outputField] = fullMatch;
|
|
216
|
-
|
|
217
|
-
let filter: { field: string; value: string } | undefined;
|
|
218
|
-
if (filterExpr) {
|
|
219
|
-
const filterMatch = filterExpr.match(/([a-zA-Z0-9_]+)=(\$|@?[a-zA-Z0-9_]+)/);
|
|
220
|
-
if (filterMatch) {
|
|
221
|
-
filter = {
|
|
222
|
-
field: filterMatch[1],
|
|
223
|
-
value: filterMatch[2]
|
|
224
|
-
};
|
|
225
|
+
function parseFilters(expr: string): FilterPart[] {
|
|
226
|
+
return splitTopLevel(expr).map(part => {
|
|
227
|
+
const m = part.match(new RegExp(`^(${IDENT})\\s*=\\s*(.+)$`));
|
|
228
|
+
if (!m) throw new PathCompileError(`Cannot parse filter '${part}'`);
|
|
229
|
+
const value = m[2].trim();
|
|
230
|
+
if (value !== '$' && !new RegExp(`^@${IDENT}$`).test(value) && !LITERAL_VALUE_RE.test(value)) {
|
|
231
|
+
throw new PathCompileError(`Cannot parse filter value '${value}'`);
|
|
225
232
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const conditions = condExpr ? parseConditions(condExpr) : [];
|
|
229
|
-
|
|
230
|
-
return {
|
|
231
|
-
type: 'final',
|
|
232
|
-
table,
|
|
233
|
-
filter,
|
|
234
|
-
conditions,
|
|
235
|
-
outputField
|
|
236
|
-
};
|
|
233
|
+
return { field: m[1], value };
|
|
234
|
+
});
|
|
237
235
|
}
|
|
238
236
|
|
|
239
237
|
/**
|
|
240
238
|
* Parse condition expression: {active} or {active,role=admin} or {valid_from<=now(),valid_to=NULL}
|
|
241
239
|
*/
|
|
242
|
-
function parseConditions(
|
|
243
|
-
|
|
244
|
-
const parts = inner.split(',').map(p => p.trim());
|
|
245
|
-
const conditions: ConditionPart[] = [];
|
|
246
|
-
|
|
247
|
-
for (const part of parts) {
|
|
240
|
+
function parseConditions(expr: string): ConditionPart[] {
|
|
241
|
+
return splitTopLevel(expr).map(part => {
|
|
248
242
|
// Boolean field: {active}
|
|
249
|
-
if (
|
|
250
|
-
conditions.push({ type: 'boolean', field: part });
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Equality: {role=admin} or {role='admin'}
|
|
255
|
-
const eqMatch = part.match(/^([a-zA-Z0-9_]+)=(.+)$/);
|
|
256
|
-
if (eqMatch) {
|
|
257
|
-
const field = eqMatch[1];
|
|
258
|
-
let value = eqMatch[2];
|
|
243
|
+
if (IDENT_RE.test(part)) return { type: 'boolean', field: part } as ConditionPart;
|
|
259
244
|
|
|
260
|
-
|
|
245
|
+
// Equality: {role=admin} or {role='admin'} or {valid_to=NULL}
|
|
246
|
+
const eq = part.match(new RegExp(`^(${IDENT})\\s*=\\s*(.+)$`));
|
|
247
|
+
if (eq && LITERAL_VALUE_RE.test(eq[2].trim())) {
|
|
248
|
+
let value = eq[2].trim();
|
|
261
249
|
if (value.toUpperCase() === 'NULL') {
|
|
262
|
-
|
|
263
|
-
} else {
|
|
264
|
-
|
|
265
|
-
if (value.startsWith("'") && value.endsWith("'")) {
|
|
266
|
-
value = value.slice(1, -1);
|
|
267
|
-
}
|
|
268
|
-
conditions.push({ type: 'equality', field, value });
|
|
250
|
+
value = 'NULL';
|
|
251
|
+
} else if (value.startsWith("'") && value.endsWith("'")) {
|
|
252
|
+
value = value.slice(1, -1).replace(/''/g, "'");
|
|
269
253
|
}
|
|
270
|
-
|
|
254
|
+
return { type: 'equality', field: eq[1], value } as ConditionPart;
|
|
271
255
|
}
|
|
272
256
|
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
257
|
+
// Comparison: {valid_from<=now()}
|
|
258
|
+
const cmp = part.match(TEMPORAL_RE);
|
|
259
|
+
if (cmp) return { type: 'temporal', field: cmp[1], op: cmp[2], value: cmp[3] } as ConditionPart;
|
|
276
260
|
|
|
277
|
-
|
|
261
|
+
throw new PathCompileError(`Cannot parse condition '${part}'`);
|
|
262
|
+
});
|
|
278
263
|
}
|
|
279
264
|
|
|
280
|
-
|
|
281
|
-
* Compile a parsed path to SQL for permission checks (EXISTS)
|
|
282
|
-
*/
|
|
283
|
-
export function compilePathToExists(
|
|
284
|
-
parsed: ParsedPath,
|
|
285
|
-
dataVar: string = 'p_data',
|
|
286
|
-
userVar: string = 'p_user_id'
|
|
287
|
-
): string {
|
|
288
|
-
if (!parsed.isValid) {
|
|
289
|
-
console.warn(`[Compiler] Invalid path: ${parsed.error}`);
|
|
290
|
-
return 'FALSE';
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
if (parsed.hops.length === 0) {
|
|
294
|
-
// TRUE/FALSE literal was parsed
|
|
295
|
-
return 'FALSE';
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const isJsonb = dataVar.startsWith('p_') || dataVar.startsWith('v_');
|
|
299
|
-
const fieldAccess = (field: string) =>
|
|
300
|
-
isJsonb ? `(${dataVar}->>'${field}')::int` : `${dataVar}.${field}`;
|
|
265
|
+
// === SQL generation ===
|
|
301
266
|
|
|
302
|
-
|
|
303
|
-
if (
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
// Build value expression through the hops
|
|
309
|
-
let valueExpr: string;
|
|
310
|
-
|
|
311
|
-
// Handle table-first vs field-first
|
|
312
|
-
if (parsed.tableFirst) {
|
|
313
|
-
const firstHop = parsed.hops[0];
|
|
314
|
-
valueExpr = buildTableLookup(firstHop, dataVar, isJsonb);
|
|
315
|
-
} else {
|
|
316
|
-
valueExpr = fieldAccess(parsed.startField!);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Process intermediate hops
|
|
320
|
-
const startIdx = parsed.tableFirst ? 1 : 1;
|
|
321
|
-
for (let i = startIdx; i < parsed.hops.length - 1; i++) {
|
|
322
|
-
const hop = parsed.hops[i];
|
|
323
|
-
valueExpr = `(SELECT ${hop.outputField} FROM ${hop.table} WHERE id = ${valueExpr})`;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Final hop - build EXISTS check
|
|
327
|
-
const finalHop = parsed.hops[parsed.hops.length - 1];
|
|
328
|
-
return buildExistsCheck(finalHop, valueExpr, userVar);
|
|
267
|
+
function toSource(source: string | RowSource): RowSource {
|
|
268
|
+
if (typeof source !== 'string') return source;
|
|
269
|
+
return source.startsWith('p_') || source.startsWith('v_')
|
|
270
|
+
? { kind: 'jsonb', name: source }
|
|
271
|
+
: { kind: 'row', name: source };
|
|
329
272
|
}
|
|
330
273
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
parsed: ParsedPath,
|
|
336
|
-
dataVar: string = 'p_data'
|
|
337
|
-
): string {
|
|
338
|
-
if (!parsed.isValid) {
|
|
339
|
-
console.warn(`[Notification] Invalid path: ${parsed.error}`);
|
|
340
|
-
return `SELECT NULL::int WHERE FALSE`;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
if (parsed.hops.length === 0) {
|
|
344
|
-
return `SELECT NULL::int WHERE FALSE`;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const isJsonb = dataVar.startsWith('p_') || dataVar.startsWith('v_');
|
|
348
|
-
const fieldAccess = (field: string) =>
|
|
349
|
-
isJsonb ? `(${dataVar}->>'${field}')::int` : `${dataVar}.${field}`;
|
|
350
|
-
|
|
351
|
-
// Simple field reference - direct user_id
|
|
352
|
-
if (parsed.hops.length === 1 && parsed.hops[0].type === 'field') {
|
|
353
|
-
const field = parsed.hops[0].field!;
|
|
354
|
-
return `SELECT ${fieldAccess(field)} WHERE (${dataVar}->>'${field}') IS NOT NULL`;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Build value expression through the hops
|
|
358
|
-
let valueExpr: string;
|
|
359
|
-
|
|
360
|
-
// Handle table-first vs field-first
|
|
361
|
-
if (parsed.tableFirst) {
|
|
362
|
-
const firstHop = parsed.hops[0];
|
|
363
|
-
valueExpr = buildTableLookup(firstHop, dataVar, isJsonb);
|
|
364
|
-
} else {
|
|
365
|
-
valueExpr = fieldAccess(parsed.startField!);
|
|
366
|
-
}
|
|
274
|
+
function fieldAccessor(source: RowSource): (field: string) => string {
|
|
275
|
+
if (source.kind === 'jsonb') return (field) => `(${source.name}->>'${field}')::int`;
|
|
276
|
+
return (field) => `${source.name}.${source.fieldMap?.[field] ?? field}`;
|
|
277
|
+
}
|
|
367
278
|
|
|
368
|
-
|
|
369
|
-
const startIdx = parsed.tableFirst ? 1 : 1;
|
|
370
|
-
for (let i = startIdx; i < parsed.hops.length - 1; i++) {
|
|
371
|
-
const hop = parsed.hops[i];
|
|
372
|
-
valueExpr = `(SELECT ${hop.outputField} FROM ${hop.table} WHERE id = ${valueExpr})`;
|
|
373
|
-
}
|
|
279
|
+
const quote = (s: string) => `'${s.replace(/'/g, "''")}'`;
|
|
374
280
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
281
|
+
/** SQL for a literal as written in a filter or condition */
|
|
282
|
+
function literalSQL(value: string): string {
|
|
283
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return value;
|
|
284
|
+
if (value === 'true' || value === 'false') return value;
|
|
285
|
+
if (value.startsWith("'") && value.endsWith("'")) return quote(value.slice(1, -1).replace(/''/g, "'"));
|
|
286
|
+
return quote(value);
|
|
378
287
|
}
|
|
379
288
|
|
|
380
|
-
/**
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
289
|
+
/** Picks an alias for each table so no hop shadows the row being checked or another hop */
|
|
290
|
+
function aliaser(source: RowSource) {
|
|
291
|
+
const used = new Set<string>(source.kind === 'row' ? [source.name] : []);
|
|
292
|
+
return (table: string): string => {
|
|
293
|
+
if (!used.has(table)) {
|
|
294
|
+
used.add(table);
|
|
295
|
+
return table;
|
|
296
|
+
}
|
|
297
|
+
let n = 1;
|
|
298
|
+
while (used.has(`${table}_${n}`)) n++;
|
|
299
|
+
used.add(`${table}_${n}`);
|
|
300
|
+
return `${table}_${n}`;
|
|
301
|
+
};
|
|
302
|
+
}
|
|
385
303
|
|
|
386
|
-
|
|
387
|
-
|
|
304
|
+
/** WHERE clauses for one hop: its join on prev, its other filters, its conditions */
|
|
305
|
+
function hopClauses(hop: PathHop, alias: string, prev: string | null, field: (f: string) => string): string[] {
|
|
306
|
+
const clauses: string[] = [];
|
|
307
|
+
if (hop.filters && hop.filters.length > 0) {
|
|
308
|
+
for (const f of hop.filters) {
|
|
309
|
+
const col = `${alias}.${f.field}`;
|
|
310
|
+
if (f.value === '$') clauses.push(`${col} = ${prev}`);
|
|
311
|
+
else if (f.value.startsWith('@')) clauses.push(`${col} = ${field(f.value.slice(1))}`);
|
|
312
|
+
else if (f.value.toUpperCase() === 'NULL') clauses.push(`${col} IS NULL`);
|
|
313
|
+
else clauses.push(`${col} = ${literalSQL(f.value)}`);
|
|
314
|
+
}
|
|
315
|
+
} else if (prev !== null) {
|
|
316
|
+
clauses.push(`${alias}.id = ${prev}`);
|
|
388
317
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
let filterValue: string;
|
|
392
|
-
if (filter.value === '$') {
|
|
393
|
-
// $ is not valid for first hop - use the field name
|
|
394
|
-
filterValue = 'NULL';
|
|
395
|
-
} else if (filter.value.startsWith('@')) {
|
|
396
|
-
const refField = filter.value.slice(1);
|
|
397
|
-
filterValue = isJsonb ? `(${dataVar}->>'${refField}')::int` : `${dataVar}.${refField}`;
|
|
398
|
-
} else {
|
|
399
|
-
filterValue = filter.value;
|
|
318
|
+
for (const cond of hop.conditions || []) {
|
|
319
|
+
clauses.push(conditionSQL(alias, cond));
|
|
400
320
|
}
|
|
401
|
-
|
|
402
|
-
// Build conditions
|
|
403
|
-
const condClauses: string[] = [`${table}.${filter.field} = ${filterValue}`];
|
|
404
|
-
|
|
405
|
-
for (const cond of conditions || []) {
|
|
406
|
-
condClauses.push(buildConditionClause(table, cond));
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
return `(SELECT ${outputField} FROM ${table} WHERE ${condClauses.join(' AND ')})`;
|
|
321
|
+
return clauses;
|
|
410
322
|
}
|
|
411
323
|
|
|
412
|
-
|
|
413
|
-
* Build condition clause for a table
|
|
414
|
-
*/
|
|
415
|
-
function buildConditionClause(table: string, cond: ConditionPart): string {
|
|
324
|
+
function conditionSQL(alias: string, cond: ConditionPart): string {
|
|
416
325
|
switch (cond.type) {
|
|
417
326
|
case 'boolean':
|
|
418
|
-
return `${
|
|
327
|
+
return `${alias}.${cond.field} = true`;
|
|
419
328
|
case 'equality':
|
|
420
|
-
if (cond.value === 'NULL') {
|
|
421
|
-
|
|
422
|
-
}
|
|
423
|
-
// Quote string values, leave numbers as-is
|
|
424
|
-
const value = cond.value!.match(/^\d+$/) ? cond.value : `'${cond.value}'`;
|
|
425
|
-
return `${table}.${cond.field} = ${value}`;
|
|
329
|
+
if (cond.value === 'NULL') return `${alias}.${cond.field} IS NULL`;
|
|
330
|
+
return `${alias}.${cond.field} = ${/^-?\d+(\.\d+)?$/.test(cond.value!) ? cond.value : quote(cond.value!)}`;
|
|
426
331
|
case 'temporal':
|
|
427
|
-
|
|
428
|
-
return `${table}.${cond.field}`;
|
|
429
|
-
default:
|
|
430
|
-
return 'TRUE';
|
|
332
|
+
return `${alias}.${cond.field} ${cond.op} ${cond.value}`;
|
|
431
333
|
}
|
|
432
334
|
}
|
|
433
335
|
|
|
336
|
+
function requireValid(parsed: ParsedPath): void {
|
|
337
|
+
if (!parsed.isValid) throw new PathCompileError(parsed.error ?? 'Invalid path', parsed.path);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The hops a chain walks, and the value the first of them joins on */
|
|
341
|
+
function chainSteps(parsed: ParsedPath, field: (f: string) => string): { steps: PathHop[]; start: string | null } {
|
|
342
|
+
return parsed.tableFirst
|
|
343
|
+
? { steps: parsed.hops, start: null }
|
|
344
|
+
: { steps: parsed.hops.slice(1), start: field(parsed.startField!) };
|
|
345
|
+
}
|
|
346
|
+
|
|
434
347
|
/**
|
|
435
|
-
*
|
|
348
|
+
* Compile a parsed path to SQL for permission checks (EXISTS)
|
|
436
349
|
*/
|
|
437
|
-
function
|
|
438
|
-
|
|
350
|
+
export function compilePathToExists(
|
|
351
|
+
parsed: ParsedPath,
|
|
352
|
+
dataVar: string | RowSource = 'p_data',
|
|
353
|
+
userVar: string = 'p_user_id'
|
|
354
|
+
): string {
|
|
355
|
+
requireValid(parsed);
|
|
439
356
|
|
|
440
|
-
if (
|
|
357
|
+
if (parsed.literal === 'TRUE') return 'TRUE';
|
|
358
|
+
if (parsed.literal === 'FALSE') return 'FALSE';
|
|
359
|
+
if (parsed.literal === 'AUTHENTICATED') return `${userVar} IS NOT NULL`;
|
|
441
360
|
|
|
442
|
-
const
|
|
361
|
+
const source = toSource(dataVar);
|
|
362
|
+
const field = fieldAccessor(source);
|
|
443
363
|
|
|
444
|
-
//
|
|
445
|
-
if (
|
|
446
|
-
|
|
447
|
-
condClauses.push(`${table}.${filter.field} = ${valueExpr}`);
|
|
448
|
-
} else {
|
|
449
|
-
condClauses.push(`${table}.${filter.field} = ${filter.value}`);
|
|
450
|
-
}
|
|
451
|
-
} else {
|
|
452
|
-
condClauses.push(`${table}.id = ${valueExpr}`);
|
|
364
|
+
// Simple field reference
|
|
365
|
+
if (parsed.hops.length === 1 && parsed.hops[0].type === 'field') {
|
|
366
|
+
return `${field(parsed.hops[0].field!)} = ${userVar}`;
|
|
453
367
|
}
|
|
454
368
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
condClauses.push(buildConditionClause(table, cond));
|
|
458
|
-
}
|
|
369
|
+
const alias = aliaser(source);
|
|
370
|
+
const { steps, start } = chainSteps(parsed, field);
|
|
459
371
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
372
|
+
const build = (i: number, prev: string | null): string => {
|
|
373
|
+
const hop = steps[i];
|
|
374
|
+
const a = alias(hop.table!);
|
|
375
|
+
const clauses = hopClauses(hop, a, prev, field);
|
|
376
|
+
if (i === steps.length - 1) {
|
|
377
|
+
clauses.push(`${a}.${hop.outputField} = ${userVar}`);
|
|
378
|
+
} else {
|
|
379
|
+
clauses.push(build(i + 1, `${a}.${hop.outputField}`));
|
|
380
|
+
}
|
|
381
|
+
const from = a === hop.table ? hop.table : `${hop.table} ${a}`;
|
|
382
|
+
return `EXISTS (SELECT 1 FROM ${from} WHERE ${clauses.join(' AND ')})`;
|
|
383
|
+
};
|
|
466
384
|
|
|
467
|
-
return
|
|
385
|
+
return build(0, start);
|
|
468
386
|
}
|
|
469
387
|
|
|
470
388
|
/**
|
|
471
|
-
*
|
|
389
|
+
* Compile a parsed path to SQL for notification resolution (SELECT user_ids)
|
|
472
390
|
*/
|
|
473
|
-
function
|
|
474
|
-
|
|
391
|
+
export function compilePathToSelect(
|
|
392
|
+
parsed: ParsedPath,
|
|
393
|
+
dataVar: string | RowSource = 'p_data'
|
|
394
|
+
): string {
|
|
395
|
+
requireValid(parsed);
|
|
475
396
|
|
|
476
|
-
if (
|
|
477
|
-
|
|
397
|
+
if (parsed.literal) {
|
|
398
|
+
throw new PathCompileError(`A notification path must lead to users, not ${parsed.literal}`, parsed.path);
|
|
478
399
|
}
|
|
479
400
|
|
|
480
|
-
const
|
|
401
|
+
const source = toSource(dataVar);
|
|
402
|
+
const field = fieldAccessor(source);
|
|
481
403
|
|
|
482
|
-
//
|
|
483
|
-
if (
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
} else {
|
|
487
|
-
condClauses.push(`${table}.${filter.field} = ${filter.value}`);
|
|
488
|
-
}
|
|
489
|
-
} else {
|
|
490
|
-
condClauses.push(`${table}.id = ${valueExpr}`);
|
|
404
|
+
// Simple field reference - direct user_id
|
|
405
|
+
if (parsed.hops.length === 1 && parsed.hops[0].type === 'field') {
|
|
406
|
+
const value = field(parsed.hops[0].field!);
|
|
407
|
+
return `SELECT ${value} WHERE ${value} IS NOT NULL`;
|
|
491
408
|
}
|
|
492
409
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
410
|
+
const alias = aliaser(source);
|
|
411
|
+
const { steps, start } = chainSteps(parsed, field);
|
|
412
|
+
const froms: string[] = [];
|
|
413
|
+
const clauses: string[] = [];
|
|
414
|
+
let prev = start;
|
|
415
|
+
let last = '';
|
|
416
|
+
|
|
417
|
+
for (const hop of steps) {
|
|
418
|
+
const a = alias(hop.table!);
|
|
419
|
+
froms.push(a === hop.table ? hop.table! : `${hop.table} ${a}`);
|
|
420
|
+
clauses.push(...hopClauses(hop, a, prev, field));
|
|
421
|
+
prev = `${a}.${hop.outputField}`;
|
|
422
|
+
last = prev;
|
|
496
423
|
}
|
|
497
424
|
|
|
498
|
-
return `SELECT ${
|
|
425
|
+
return `SELECT ${last} FROM ${froms.join(', ')} WHERE ${clauses.join(' AND ')}`;
|
|
499
426
|
}
|
|
500
427
|
|
|
501
428
|
/**
|
|
502
|
-
* Validate a path expression and return detailed errors
|
|
429
|
+
* Validate a path expression's syntax and return detailed errors
|
|
503
430
|
*/
|
|
504
431
|
export function validatePath(path: string, entityName?: string): { valid: boolean; error?: string } {
|
|
505
432
|
const parsed = parsePath(path);
|
|
506
|
-
|
|
507
|
-
if (!parsed.isValid) {
|
|
508
|
-
return { valid: false, error: parsed.error };
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// Additional validation could check table/column existence
|
|
512
|
-
// For now just validate syntax
|
|
513
|
-
return { valid: true };
|
|
433
|
+
return parsed.isValid ? { valid: true } : { valid: false, error: parsed.error };
|
|
514
434
|
}
|