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.
@@ -1,17 +1,46 @@
1
1
  /**
2
2
  * Path Expression Parser and Compiler
3
3
  *
4
- * Shared module for parsing permission and notification path expressions.
5
- * Provides unified path parsing with different output modes:
6
- * - EXISTS: For permission checks (returns boolean)
7
- * - SELECT: For notification resolution (returns user_id array)
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?: { field: string; value: string }; // e.g., org_id=$
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
- * Parse a path expression into structured hops
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
- // Literal TRUE/FALSE
40
- if (path === 'TRUE' || path === 'true') {
41
- return { hops: [], isValid: true };
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
- if (path.match(/^@[a-zA-Z0-9_]+$/) && !path.includes('->')) {
49
- return {
50
- hops: [{ type: 'field', field: path.slice(1) }],
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(/^@([a-zA-Z0-9_]+)\s*==\s*@user_id$/);
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
- return parseTraversalPath(path);
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 (edge case)
72
- const tableFirstMatch = path.match(/^([a-zA-Z0-9_]+)\[([^\]]+)\]/);
73
- if (tableFirstMatch) {
74
- return parseTableFirstPath(path);
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
- * Parse a traversal path with -> operators
86
- */
87
- function parseTraversalPath(path: string): ParsedPath {
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 firstHop = rawHops[0];
142
+ const hops: PathHop[] = [];
96
143
  let tableFirst = false;
144
+ let startField: string | undefined;
97
145
 
98
- // Check if first hop is table-first pattern
99
- if (!firstHop.startsWith('@')) {
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 parsed = parseTableFirstHop(firstHop);
102
- if (!parsed) {
103
- return { hops: [], isValid: false, error: `Cannot parse table-first hop: ${firstHop}` };
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
- hops.push(parsed);
106
- } else {
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(parsed);
160
+ hops.push(first);
120
161
  }
121
162
 
122
- // Process final hop
123
- const finalHop = rawHops[rawHops.length - 1];
124
- const parsed = parseFinalHop(finalHop);
125
- if (!parsed) {
126
- return { hops: [], isValid: false, error: `Cannot parse final hop: ${finalHop}` };
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
- * Parse table-first pattern: contractor_rights[package_id=@package_id]{active}.contractor_org_id
140
- */
141
- function parseTableFirstHop(hop: string): PathHop | null {
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
- // Parse filter: field=@value or field=$
148
- const filterMatch = filterExpr.match(/([a-zA-Z0-9_]+)=(@?[a-zA-Z0-9_$]+)/);
149
- if (!filterMatch) return null;
150
-
151
- const filter = {
152
- field: filterMatch[1],
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 conditions = condExpr ? parseConditions(condExpr) : [];
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: 'table',
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
- * Parse table-first path (without traversal)
169
- */
170
- function parseTableFirstPath(path: string): ParsedPath {
171
- const parsed = parseTableFirstHop(path);
172
- if (!parsed) {
173
- return { hops: [], isValid: false, error: `Cannot parse table-first path: ${path}` };
174
- }
175
- return {
176
- hops: [parsed],
177
- tableFirst: true,
178
- isValid: true
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 null;
221
+ parts.push(cur.trim());
222
+ return parts;
205
223
  }
206
224
 
207
- /**
208
- * Parse final hop: table[filter=$]{conditions}.user_id
209
- */
210
- function parseFinalHop(hop: string): PathHop | null {
211
- // Full pattern: acts_for[org_id=$]{active,role=admin}.user_id
212
- const fullMatch = hop.match(/^([a-zA-Z0-9_]+)(?:\[([^\]]+)\])?(?:\{([^}]+)\})?\.([a-zA-Z0-9_]+)$/);
213
- if (!fullMatch) return null;
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(condExpr: string): ConditionPart[] {
243
- const inner = condExpr.replace(/[{}]/g, '');
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 (part.match(/^[a-zA-Z0-9_]+$/)) {
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
- // Special case: NULL
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
- conditions.push({ type: 'equality', field, value: 'NULL' });
263
- } else {
264
- // Remove quotes if present
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
- continue;
254
+ return { type: 'equality', field: eq[1], value } as ConditionPart;
271
255
  }
272
256
 
273
- // Temporal: {valid_from<=now()} - treat as raw expression
274
- conditions.push({ type: 'temporal', field: part });
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
- return conditions;
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
- // Simple field reference
303
- if (parsed.hops.length === 1 && parsed.hops[0].type === 'field') {
304
- const field = parsed.hops[0].field!;
305
- return `${fieldAccess(field)} = ${userVar}`;
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
- * Compile a parsed path to SQL for notification resolution (SELECT user_ids)
333
- */
334
- export function compilePathToSelect(
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
- // Process intermediate hops
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
- // Final hop - build SELECT for user_ids
376
- const finalHop = parsed.hops[parsed.hops.length - 1];
377
- return buildSelectUsers(finalHop, valueExpr);
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
- * Build a table lookup subquery for table-first patterns
382
- */
383
- function buildTableLookup(hop: PathHop, dataVar: string, isJsonb: boolean): string {
384
- const { table, filter, conditions, outputField } = hop;
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
- if (!filter || !table || !outputField) {
387
- return 'NULL';
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
- // Build filter value
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 `${table}.${cond.field} = true`;
327
+ return `${alias}.${cond.field} = true`;
419
328
  case 'equality':
420
- if (cond.value === 'NULL') {
421
- return `${table}.${cond.field} IS NULL`;
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
- // Raw expression
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
- * Build EXISTS check for permission verification
348
+ * Compile a parsed path to SQL for permission checks (EXISTS)
436
349
  */
437
- function buildExistsCheck(hop: PathHop, valueExpr: string, userVar: string): string {
438
- const { table, filter, conditions, outputField } = hop;
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 (!table) return 'FALSE';
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 condClauses: string[] = [];
361
+ const source = toSource(dataVar);
362
+ const field = fieldAccessor(source);
443
363
 
444
- // Join condition
445
- if (filter) {
446
- if (filter.value === '$') {
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
- // Additional conditions
456
- for (const cond of conditions || []) {
457
- condClauses.push(buildConditionClause(table, cond));
458
- }
369
+ const alias = aliaser(source);
370
+ const { steps, start } = chainSteps(parsed, field);
459
371
 
460
- // User check
461
- if (outputField === 'user_id') {
462
- condClauses.push(`${table}.user_id = ${userVar}`);
463
- } else if (outputField) {
464
- condClauses.push(`${table}.${outputField} = ${userVar}`);
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 `EXISTS (SELECT 1 FROM ${table} WHERE ${condClauses.join(' AND ')})`;
385
+ return build(0, start);
468
386
  }
469
387
 
470
388
  /**
471
- * Build SELECT query for notification user resolution
389
+ * Compile a parsed path to SQL for notification resolution (SELECT user_ids)
472
390
  */
473
- function buildSelectUsers(hop: PathHop, valueExpr: string): string {
474
- const { table, filter, conditions, outputField } = hop;
391
+ export function compilePathToSelect(
392
+ parsed: ParsedPath,
393
+ dataVar: string | RowSource = 'p_data'
394
+ ): string {
395
+ requireValid(parsed);
475
396
 
476
- if (!table || !outputField) {
477
- return `SELECT NULL::int WHERE FALSE`;
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 condClauses: string[] = [];
401
+ const source = toSource(dataVar);
402
+ const field = fieldAccessor(source);
481
403
 
482
- // Join condition
483
- if (filter) {
484
- if (filter.value === '$') {
485
- condClauses.push(`${table}.${filter.field} = ${valueExpr}`);
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
- // Additional conditions
494
- for (const cond of conditions || []) {
495
- condClauses.push(buildConditionClause(table, cond));
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 ${table}.${outputField} FROM ${table} WHERE ${condClauses.join(' AND ')}`;
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
  }