@objectstack/formula 9.11.0 → 10.0.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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +76 -0
- package/dist/index.d.mts +78 -2
- package/dist/index.d.ts +78 -2
- package/dist/index.js +369 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +365 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/cel-engine.ts +6 -0
- package/src/cel-to-filter.test.ts +218 -0
- package/src/cel-to-filter.ts +411 -0
- package/src/index.ts +6 -0
- package/src/matches-filter.test.ts +110 -0
- package/src/matches-filter.ts +115 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { compileCelToFilter, isPushdownableCel } from './cel-to-filter';
|
|
6
|
+
|
|
7
|
+
/** current_user context used across the value-resolution cases. */
|
|
8
|
+
const VARS = {
|
|
9
|
+
current_user: {
|
|
10
|
+
id: 'u_me',
|
|
11
|
+
organization_id: 'org_1',
|
|
12
|
+
org_user_ids: ['u_me', 'u_peer'],
|
|
13
|
+
team_member_ids: ['u_me', 'u_report'],
|
|
14
|
+
department: 'sales',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const ok = (src: string, vars = VARS) => {
|
|
19
|
+
const r = compileCelToFilter(src, { variables: vars });
|
|
20
|
+
if (!r.ok) throw new Error(`expected ok for "${src}" but got ${r.reason}: ${r.detail}`);
|
|
21
|
+
return r.filter;
|
|
22
|
+
};
|
|
23
|
+
const fail = (src: string, vars: Record<string, unknown> = VARS) =>
|
|
24
|
+
compileCelToFilter(src, { variables: vars });
|
|
25
|
+
|
|
26
|
+
describe('compileCelToFilter — equality & literals', () => {
|
|
27
|
+
it('field == variable → implicit equality with resolved value', () => {
|
|
28
|
+
expect(ok('owner_id == current_user.id')).toEqual({ owner_id: 'u_me' });
|
|
29
|
+
});
|
|
30
|
+
it('record.field == variable (strips record root)', () => {
|
|
31
|
+
expect(ok('record.organization_id == current_user.organization_id')).toEqual({ organization_id: 'org_1' });
|
|
32
|
+
});
|
|
33
|
+
it('field == string literal', () => {
|
|
34
|
+
expect(ok("record.region == 'EMEA'")).toEqual({ region: 'EMEA' });
|
|
35
|
+
});
|
|
36
|
+
it('field == number literal (bigint coerced to number)', () => {
|
|
37
|
+
expect(ok('record.amount == 1000')).toEqual({ amount: 1000 });
|
|
38
|
+
});
|
|
39
|
+
it('field == boolean literal', () => {
|
|
40
|
+
expect(ok('record.active == true')).toEqual({ active: true });
|
|
41
|
+
});
|
|
42
|
+
it('field != variable → $ne', () => {
|
|
43
|
+
expect(ok('record.owner_id != current_user.id')).toEqual({ owner_id: { $ne: 'u_me' } });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('compileCelToFilter — null & exists', () => {
|
|
48
|
+
it('field == null → $null:true', () => {
|
|
49
|
+
expect(ok('record.target_channels == null')).toEqual({ target_channels: { $null: true } });
|
|
50
|
+
});
|
|
51
|
+
it('field != null → $null:false', () => {
|
|
52
|
+
expect(ok('record.target_channels != null')).toEqual({ target_channels: { $null: false } });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('compileCelToFilter — comparisons (with right-side field flip)', () => {
|
|
57
|
+
it('>', () => expect(ok('record.amount > 1000')).toEqual({ amount: { $gt: 1000 } }));
|
|
58
|
+
it('>=', () => expect(ok('record.rating >= 4')).toEqual({ rating: { $gte: 4 } }));
|
|
59
|
+
it('<', () => expect(ok('record.amount < 500')).toEqual({ amount: { $lt: 500 } }));
|
|
60
|
+
it('<=', () => expect(ok('record.amount <= 500')).toEqual({ amount: { $lte: 500 } }));
|
|
61
|
+
it('flips when the field is on the right (100 > record.amount → amount < 100)', () => {
|
|
62
|
+
expect(ok('100 > record.amount')).toEqual({ amount: { $lt: 100 } });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('compileCelToFilter — membership (in → $in)', () => {
|
|
67
|
+
it('field in current_user.<array> (the RLS membership IN-form)', () => {
|
|
68
|
+
expect(ok('id in current_user.org_user_ids')).toEqual({ id: { $in: ['u_me', 'u_peer'] } });
|
|
69
|
+
});
|
|
70
|
+
it('record.field in <inline list>', () => {
|
|
71
|
+
expect(ok("record.status in ['open','won']")).toEqual({ status: { $in: ['open', 'won'] } });
|
|
72
|
+
});
|
|
73
|
+
it('not in → !(x in y) → $not wrapping $in', () => {
|
|
74
|
+
expect(ok('!(record.status in [\'lost\'])')).toEqual({ $not: { status: { $in: ['lost'] } } });
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('compileCelToFilter — string methods', () => {
|
|
79
|
+
it('startsWith → $startsWith', () => {
|
|
80
|
+
expect(ok("record.name.startsWith('Acme')")).toEqual({ name: { $startsWith: 'Acme' } });
|
|
81
|
+
});
|
|
82
|
+
it('endsWith → $endsWith', () => {
|
|
83
|
+
expect(ok("record.email.endsWith('@corp.com')")).toEqual({ email: { $endsWith: '@corp.com' } });
|
|
84
|
+
});
|
|
85
|
+
it('contains → $contains', () => {
|
|
86
|
+
expect(ok("record.name.contains('beta')")).toEqual({ name: { $contains: 'beta' } });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('compileCelToFilter — logical combinators (the #1887 compound-condition target)', () => {
|
|
91
|
+
it('&& → $and', () => {
|
|
92
|
+
expect(ok("record.stage == 'won' && record.amount >= 500")).toEqual({
|
|
93
|
+
$and: [{ stage: 'won' }, { amount: { $gte: 500 } }],
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
it('|| → $or', () => {
|
|
97
|
+
expect(ok("record.tier == 'gold' || record.tier == 'platinum'")).toEqual({
|
|
98
|
+
$or: [{ tier: 'gold' }, { tier: 'platinum' }],
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
it('! → $not', () => {
|
|
102
|
+
expect(ok('!(record.secret == true)')).toEqual({ $not: { secret: true } });
|
|
103
|
+
});
|
|
104
|
+
it('flattens nested same-operator (a && b && c → one $and)', () => {
|
|
105
|
+
expect(ok("record.a == 1 && record.b == 2 && record.c == 3")).toEqual({
|
|
106
|
+
$and: [{ a: 1 }, { b: 2 }, { c: 3 }],
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
it('nested mixed precedence', () => {
|
|
110
|
+
expect(ok("record.region == 'EMEA' && (record.tier == 'gold' || record.amount > 10000)")).toEqual({
|
|
111
|
+
$and: [{ region: 'EMEA' }, { $or: [{ tier: 'gold' }, { amount: { $gt: 10000 } }] }],
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('compileCelToFilter — field-to-field ($field reference)', () => {
|
|
117
|
+
it('record.a == record.b → $eq $field', () => {
|
|
118
|
+
expect(ok('record.created_by == record.owner_id')).toEqual({
|
|
119
|
+
created_by: { $eq: { $field: 'owner_id' } },
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('compileCelToFilter — allow-all constant fold', () => {
|
|
125
|
+
it('1 == 1 → {} (no restriction)', () => {
|
|
126
|
+
expect(ok('1 == 1')).toEqual({});
|
|
127
|
+
});
|
|
128
|
+
it('true → {}', () => {
|
|
129
|
+
expect(ok('true')).toEqual({});
|
|
130
|
+
});
|
|
131
|
+
it('1 == 2 → fails closed (not allow-all)', () => {
|
|
132
|
+
expect(fail('1 == 2').ok).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('compileCelToFilter — fail-closed on non-pushdownable (ADR-0055 / D5)', () => {
|
|
137
|
+
const unsupported = [
|
|
138
|
+
"record.name.matches('A.*')", // unsupported rcall method
|
|
139
|
+
'record.amount + 1 > 2', // arithmetic
|
|
140
|
+
'size(record.tags) > 0', // function call
|
|
141
|
+
'record.account.region == \'X\'', // cross-object / nested relation traversal
|
|
142
|
+
'account.region == \'X\'', // unknown-root relation traversal
|
|
143
|
+
"record.cond ? record.a : record.b == 1", // ternary
|
|
144
|
+
];
|
|
145
|
+
for (const src of unsupported) {
|
|
146
|
+
it(`refuses: ${src}`, () => {
|
|
147
|
+
const r = fail(src);
|
|
148
|
+
expect(r.ok).toBe(false);
|
|
149
|
+
if (!r.ok) expect(r.reason).toBe('unsupported');
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
it('parse error is reported, not thrown', () => {
|
|
153
|
+
const r = fail('record.a == ');
|
|
154
|
+
expect(r.ok).toBe(false);
|
|
155
|
+
if (!r.ok) expect(r.reason).toBe('parse-error');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('compileCelToFilter — unresolved variable fails closed', () => {
|
|
160
|
+
it('missing current_user.* → unresolved-variable', () => {
|
|
161
|
+
const r = compileCelToFilter('record.owner_id == current_user.id', { variables: { current_user: {} } });
|
|
162
|
+
expect(r.ok).toBe(false);
|
|
163
|
+
if (!r.ok) expect(r.reason).toBe('unresolved-variable');
|
|
164
|
+
});
|
|
165
|
+
it('null variable (e.g. no active org) → unresolved-variable (fail closed)', () => {
|
|
166
|
+
const r = compileCelToFilter('record.organization_id == current_user.organization_id', {
|
|
167
|
+
variables: { current_user: { organization_id: null } },
|
|
168
|
+
});
|
|
169
|
+
expect(r.ok).toBe(false);
|
|
170
|
+
if (!r.ok) expect(r.reason).toBe('unresolved-variable');
|
|
171
|
+
});
|
|
172
|
+
it('empty membership array still compiles to $in:[] (caller decides)', () => {
|
|
173
|
+
expect(ok('id in current_user.org_user_ids', { current_user: { org_user_ids: [] } })).toEqual({
|
|
174
|
+
id: { $in: [] },
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('isPushdownableCel — shape-only gate (no variables)', () => {
|
|
180
|
+
const supported = [
|
|
181
|
+
'owner_id == current_user.id',
|
|
182
|
+
"record.region == 'EMEA'",
|
|
183
|
+
'record.amount > 1000',
|
|
184
|
+
'id in current_user.org_user_ids',
|
|
185
|
+
"record.status in ['a','b']",
|
|
186
|
+
'record.target_channels != null',
|
|
187
|
+
"record.a == 1 && record.b == 2",
|
|
188
|
+
"record.name.startsWith('A')",
|
|
189
|
+
'1 == 1',
|
|
190
|
+
];
|
|
191
|
+
for (const src of supported) {
|
|
192
|
+
it(`accepts: ${src}`, () => expect(isPushdownableCel(src).ok).toBe(true));
|
|
193
|
+
}
|
|
194
|
+
const refused = [
|
|
195
|
+
'record.amount + 1 > 2',
|
|
196
|
+
'size(record.tags) > 0',
|
|
197
|
+
"record.account.region == 'X'",
|
|
198
|
+
];
|
|
199
|
+
for (const src of refused) {
|
|
200
|
+
it(`rejects: ${src}`, () => expect(isPushdownableCel(src).ok).toBe(false));
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('compileCelToFilter — input shapes', () => {
|
|
205
|
+
it('accepts a { dialect, source } expression object', () => {
|
|
206
|
+
expect(ok({ dialect: 'cel', source: "record.region == 'EMEA'" } as unknown as string)).toBeUndefined;
|
|
207
|
+
const r = compileCelToFilter({ source: "record.region == 'EMEA'" }, { variables: VARS });
|
|
208
|
+
expect(r.ok && r.filter).toEqual({ region: 'EMEA' });
|
|
209
|
+
});
|
|
210
|
+
it('custom variableRoots/fieldRoots', () => {
|
|
211
|
+
const r = compileCelToFilter('row.dept == ctx.department', {
|
|
212
|
+
fieldRoots: ['row'],
|
|
213
|
+
variableRoots: ['ctx'],
|
|
214
|
+
variables: { ctx: { department: 'sales' } },
|
|
215
|
+
});
|
|
216
|
+
expect(r.ok && r.filter).toEqual({ dept: 'sales' });
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical CEL → FilterCondition pushdown compiler (ADR-0058 D1/D2/D6).
|
|
5
|
+
*
|
|
6
|
+
* ObjectStack has ONE authoring language (CEL) and ONE good interpreter
|
|
7
|
+
* (`cel-engine.ts`), but historically THREE disconnected "compile-to-filter"
|
|
8
|
+
* front-ends: `plugin-security/rls-compiler.ts`'s 4-form regex, `plugin-sharing`'s
|
|
9
|
+
* `celToFilter`, and the ObjectUI array-AST path. They diverged — which is the
|
|
10
|
+
* root of #1887 (a sharing `condition` that the interpreter understands but no
|
|
11
|
+
* compiler lowers, so it never enforces).
|
|
12
|
+
*
|
|
13
|
+
* This module is the single, canonical lowering. It takes the **same parsed
|
|
14
|
+
* `@marcbachmann/cel-js` AST the interpreter uses** (`env.parse(src).ast`) and
|
|
15
|
+
* lowers the pushdown-able subset to a Mongo-style {@link FilterCondition} — the
|
|
16
|
+
* one shape BOTH backends already consume: the ObjectQL engine `where` (AND-injected
|
|
17
|
+
* by plugin-security) and the analytics SQL backend
|
|
18
|
+
* (`service-analytics/read-scope-sql.ts`). One AST, two backends (D6).
|
|
19
|
+
*
|
|
20
|
+
* ## Supported subset (ADR-0058 D2)
|
|
21
|
+
* `==` `!=` `>` `<` `>=` `<=` · `in` (→ `$in`) · `&&` `||` `!` ·
|
|
22
|
+
* `== null` / `!= null` (→ `$null`) · string methods `startsWith` / `endsWith`
|
|
23
|
+
* / `contains` (→ `$startsWith` / `$endsWith` / `$contains`).
|
|
24
|
+
* `not in` is `!(x in y)`. Negation wraps in `$not`.
|
|
25
|
+
*
|
|
26
|
+
* ## Hard boundaries (ADR-0055 stands)
|
|
27
|
+
* - **No subqueries, no cross-object traversal.** A field path is a SINGLE
|
|
28
|
+
* column (`record.region` → `region`, bare `owner` → `owner`). A multi-segment
|
|
29
|
+
* relation path (`record.account.region`) is an authoring-time compile error,
|
|
30
|
+
* not a silent join.
|
|
31
|
+
* - Arithmetic (`+ - * / %`), function calls (`size(...)`), ternary, maps, and
|
|
32
|
+
* any other non-pushdown shape are a compile error — NEVER silently dropped.
|
|
33
|
+
* A dropped predicate leaves an object unprotected; failing closed is the
|
|
34
|
+
* security-correct outcome (ADR-0049/0056 D4).
|
|
35
|
+
*
|
|
36
|
+
* ## Value resolution
|
|
37
|
+
* A leaf rooted at a `variableRoot` (default `current_user`) is resolved against
|
|
38
|
+
* `opts.variables` to a literal — `current_user.id` → the caller's id,
|
|
39
|
+
* `current_user.org_user_ids` → a pre-resolved membership array for `$in`
|
|
40
|
+
* (honours ADR-0055: the runtime pre-resolves the set; the compiler never emits
|
|
41
|
+
* a subquery). A variable that resolves to `undefined`/`null` yields
|
|
42
|
+
* `unresolved-variable` (the "no active org" fail-closed path).
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { Environment } from '@marcbachmann/cel-js';
|
|
46
|
+
import type { ASTNode } from '@marcbachmann/cel-js';
|
|
47
|
+
import type { FilterCondition } from '@objectstack/spec/data';
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Public contract
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
export type CelFilterFailReason =
|
|
54
|
+
/** CEL did not parse (syntax error). */
|
|
55
|
+
| 'parse-error'
|
|
56
|
+
/** Shape is not pushdown-able (arithmetic, function call, relation traversal, …). */
|
|
57
|
+
| 'unsupported'
|
|
58
|
+
/** A required `variableRoot` reference was undefined/null in `variables`. */
|
|
59
|
+
| 'unresolved-variable';
|
|
60
|
+
|
|
61
|
+
export type CelFilterCompileResult =
|
|
62
|
+
| { ok: true; filter: FilterCondition }
|
|
63
|
+
| { ok: false; reason: CelFilterFailReason; detail: string };
|
|
64
|
+
|
|
65
|
+
export interface CelFilterCompileOptions {
|
|
66
|
+
/** Member-access roots that denote a record FIELD path. Default `['record']`. */
|
|
67
|
+
fieldRoots?: readonly string[];
|
|
68
|
+
/** Roots resolved as VALUES against {@link variables}. Default `['current_user']`. */
|
|
69
|
+
variableRoots?: readonly string[];
|
|
70
|
+
/**
|
|
71
|
+
* Value-resolution context, keyed by variable root. e.g.
|
|
72
|
+
* `{ current_user: { id, organization_id, org_user_ids } }`. A `record.*`
|
|
73
|
+
* (field) reference is NEVER resolved here — only `variableRoot` leaves are.
|
|
74
|
+
*/
|
|
75
|
+
variables?: Record<string, unknown>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Symbol returned for a variable leaf during a shape-only check (never executed). */
|
|
79
|
+
const SHAPE_VALUE = Symbol('cel-filter-shape-placeholder');
|
|
80
|
+
|
|
81
|
+
class CompileError extends Error {
|
|
82
|
+
constructor(public reason: CelFilterFailReason, message: string) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.name = 'CelFilterCompileError';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A roots-permissive env: parsing is purely syntactic (we read `.ast`, never
|
|
89
|
+
// `.check()`/`.evaluate()`), so any identifier or method call parses. Built once.
|
|
90
|
+
let parseEnv: Environment | undefined;
|
|
91
|
+
function getParseEnv(): Environment {
|
|
92
|
+
if (!parseEnv) {
|
|
93
|
+
parseEnv = new Environment({ unlistedVariablesAreDyn: true, enableOptionalTypes: true });
|
|
94
|
+
}
|
|
95
|
+
return parseEnv;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Unwrap a CEL expression input — accepts a raw string or `{ source }`. */
|
|
99
|
+
function toSource(input: string | { source?: string } | null | undefined): string | null {
|
|
100
|
+
if (typeof input === 'string') return input.trim() || null;
|
|
101
|
+
if (input && typeof input === 'object' && typeof input.source === 'string') {
|
|
102
|
+
return input.source.trim() || null;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Entry points
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Compile a CEL predicate into a {@link FilterCondition}, resolving `variableRoot`
|
|
113
|
+
* leaves against `opts.variables`. Returns a discriminated result — never throws
|
|
114
|
+
* for an authoring-level fault; a `false` result with a reason is the caller's
|
|
115
|
+
* cue to fail closed (deny) or surface a compile error.
|
|
116
|
+
*/
|
|
117
|
+
export function compileCelToFilter(
|
|
118
|
+
input: string | { source?: string },
|
|
119
|
+
opts: CelFilterCompileOptions = {},
|
|
120
|
+
): CelFilterCompileResult {
|
|
121
|
+
const source = toSource(input);
|
|
122
|
+
if (!source) return { ok: false, reason: 'parse-error', detail: 'empty expression' };
|
|
123
|
+
let ast: ASTNode;
|
|
124
|
+
try {
|
|
125
|
+
ast = getParseEnv().parse(source).ast;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
return { ok: false, reason: 'parse-error', detail: (err as Error).message?.split('\n')[0] ?? 'parse error' };
|
|
128
|
+
}
|
|
129
|
+
return lowerCelAst(ast, opts, 'value');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Shape-only check: is this CEL predicate pushdown-able at all? Used by the
|
|
134
|
+
* authoring gate (ADR-0056 D4) to REJECT a predicate the runtime could only
|
|
135
|
+
* silently drop. Does not resolve `variables`.
|
|
136
|
+
*/
|
|
137
|
+
export function isPushdownableCel(
|
|
138
|
+
input: string | { source?: string },
|
|
139
|
+
opts: Pick<CelFilterCompileOptions, 'fieldRoots' | 'variableRoots'> = {},
|
|
140
|
+
): { ok: true } | { ok: false; reason: CelFilterFailReason; detail: string } {
|
|
141
|
+
const source = toSource(input);
|
|
142
|
+
if (!source) return { ok: false, reason: 'parse-error', detail: 'empty expression' };
|
|
143
|
+
let ast: ASTNode;
|
|
144
|
+
try {
|
|
145
|
+
ast = getParseEnv().parse(source).ast;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return { ok: false, reason: 'parse-error', detail: (err as Error).message?.split('\n')[0] ?? 'parse error' };
|
|
148
|
+
}
|
|
149
|
+
const res = lowerCelAst(ast, opts, 'shape');
|
|
150
|
+
return res.ok ? { ok: true } : { ok: false, reason: res.reason, detail: res.detail };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Lower a pre-parsed cel-js AST node — the variant that lets the interpreter and
|
|
155
|
+
* the compiler share ONE parse (ADR-0058 D6, "one AST, two backends").
|
|
156
|
+
*/
|
|
157
|
+
export function lowerCelAst(
|
|
158
|
+
ast: ASTNode,
|
|
159
|
+
opts: CelFilterCompileOptions = {},
|
|
160
|
+
mode: 'value' | 'shape' = 'value',
|
|
161
|
+
): CelFilterCompileResult {
|
|
162
|
+
const ctx: Ctx = {
|
|
163
|
+
fieldRoots: new Set(opts.fieldRoots ?? ['record']),
|
|
164
|
+
variableRoots: new Set(opts.variableRoots ?? ['current_user']),
|
|
165
|
+
variables: opts.variables ?? {},
|
|
166
|
+
mode,
|
|
167
|
+
};
|
|
168
|
+
try {
|
|
169
|
+
return { ok: true, filter: lowerCondition(ast, ctx) };
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (err instanceof CompileError) return { ok: false, reason: err.reason, detail: err.message };
|
|
172
|
+
return { ok: false, reason: 'unsupported', detail: (err as Error).message ?? 'compile error' };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Internals
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
interface Ctx {
|
|
181
|
+
fieldRoots: Set<string>;
|
|
182
|
+
variableRoots: Set<string>;
|
|
183
|
+
variables: Record<string, unknown>;
|
|
184
|
+
mode: 'value' | 'shape';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
type Leaf =
|
|
188
|
+
| { kind: 'field'; path: string }
|
|
189
|
+
| { kind: 'literal'; value: unknown }
|
|
190
|
+
| { kind: 'var'; path: string[] };
|
|
191
|
+
|
|
192
|
+
const FLIP: Record<string, string> = { '>': '<', '<': '>', '>=': '<=', '<=': '>=', '==': '==', '!=': '!=' };
|
|
193
|
+
const CMP_OP: Record<string, string> = { '>': '$gt', '>=': '$gte', '<': '$lt', '<=': '$lte' };
|
|
194
|
+
const STRING_METHOD: Record<string, string> = { startsWith: '$startsWith', endsWith: '$endsWith', contains: '$contains' };
|
|
195
|
+
|
|
196
|
+
/** Lower a boolean-valued node into a FilterCondition. Throws CompileError. */
|
|
197
|
+
function lowerCondition(node: ASTNode, ctx: Ctx): FilterCondition {
|
|
198
|
+
const op = node.op;
|
|
199
|
+
const args = node.args as unknown;
|
|
200
|
+
switch (op) {
|
|
201
|
+
case '&&':
|
|
202
|
+
return combine('$and', node, ctx);
|
|
203
|
+
case '||':
|
|
204
|
+
return combine('$or', node, ctx);
|
|
205
|
+
case '!_':
|
|
206
|
+
return { $not: lowerCondition(args as ASTNode, ctx) };
|
|
207
|
+
case '==':
|
|
208
|
+
case '!=':
|
|
209
|
+
case '>':
|
|
210
|
+
case '>=':
|
|
211
|
+
case '<':
|
|
212
|
+
case '<=':
|
|
213
|
+
return lowerComparison(op, (args as [ASTNode, ASTNode])[0], (args as [ASTNode, ASTNode])[1], ctx);
|
|
214
|
+
case 'in':
|
|
215
|
+
return lowerMembership((args as [ASTNode, ASTNode])[0], (args as [ASTNode, ASTNode])[1], ctx);
|
|
216
|
+
case 'rcall':
|
|
217
|
+
return lowerStringMethod(args as [string, ASTNode, ASTNode[]], ctx);
|
|
218
|
+
case 'value': {
|
|
219
|
+
// A bare boolean condition. `true` → no restriction; anything else fails
|
|
220
|
+
// closed (we never let a non-true constant become allow-all).
|
|
221
|
+
const v = coerceLiteral(args);
|
|
222
|
+
if (v === true) return {};
|
|
223
|
+
throw new CompileError('unsupported', `constant non-true predicate (${String(v)})`);
|
|
224
|
+
}
|
|
225
|
+
default:
|
|
226
|
+
throw new CompileError('unsupported', `unsupported operator "${String(op)}"`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function combine(key: '$and' | '$or', node: ASTNode, ctx: Ctx): FilterCondition {
|
|
231
|
+
const [l, r] = node.args as [ASTNode, ASTNode];
|
|
232
|
+
const parts: FilterCondition[] = [];
|
|
233
|
+
for (const child of [lowerCondition(l, ctx), lowerCondition(r, ctx)]) {
|
|
234
|
+
// Flatten same-key nesting so `a && b && c` is one `$and: [a,b,c]`.
|
|
235
|
+
const nested = (child as Record<string, unknown>)[key];
|
|
236
|
+
if (Array.isArray(nested) && Object.keys(child).length === 1) parts.push(...(nested as FilterCondition[]));
|
|
237
|
+
else parts.push(child);
|
|
238
|
+
}
|
|
239
|
+
return { [key]: parts } as FilterCondition;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function lowerComparison(op: string, lNode: ASTNode, rNode: ASTNode, ctx: Ctx): FilterCondition {
|
|
243
|
+
const L = classify(lNode, ctx);
|
|
244
|
+
const R = classify(rNode, ctx);
|
|
245
|
+
const lField = L.kind === 'field';
|
|
246
|
+
const rField = R.kind === 'field';
|
|
247
|
+
|
|
248
|
+
if (lField && rField) {
|
|
249
|
+
// field-to-field comparison → `{ $field: otherPath }` reference.
|
|
250
|
+
return emit((L as { path: string }).path, op, { $field: (R as { path: string }).path }, true);
|
|
251
|
+
}
|
|
252
|
+
if (lField) return emit((L as { path: string }).path, op, resolveValue(R, ctx), false);
|
|
253
|
+
if (rField) return emit((R as { path: string }).path, FLIP[op] ?? op, resolveValue(L, ctx), false);
|
|
254
|
+
|
|
255
|
+
// Neither side is a field: a constant comparison. Fold the always-true case
|
|
256
|
+
// (`1 == 1`, the RLS allow-all) to "no restriction"; refuse the rest (a
|
|
257
|
+
// non-true constant must fail closed, never become allow-all).
|
|
258
|
+
const lv = resolveValue(L, ctx);
|
|
259
|
+
const rv = resolveValue(R, ctx);
|
|
260
|
+
if (ctx.mode === 'shape') return {}; // shape check: structurally fine
|
|
261
|
+
const truth = constFold(op, lv, rv);
|
|
262
|
+
if (truth === true) return {};
|
|
263
|
+
throw new CompileError('unsupported', `constant ${op} predicate that is not always-true`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function lowerMembership(elemNode: ASTNode, containerNode: ASTNode, ctx: Ctx): FilterCondition {
|
|
267
|
+
const elem = classify(elemNode, ctx);
|
|
268
|
+
if (elem.kind !== 'field') {
|
|
269
|
+
throw new CompileError('unsupported', `\`in\` requires a field on the left (got ${elem.kind})`);
|
|
270
|
+
}
|
|
271
|
+
const container = classify(containerNode, ctx);
|
|
272
|
+
const value = resolveValue(container, ctx);
|
|
273
|
+
if (value !== SHAPE_VALUE && !Array.isArray(value)) {
|
|
274
|
+
throw new CompileError('unsupported', `\`in\` requires an array/list on the right`);
|
|
275
|
+
}
|
|
276
|
+
return { [(elem as { path: string }).path]: { $in: value } } as FilterCondition;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function lowerStringMethod(args: [string, ASTNode, ASTNode[]], ctx: Ctx): FilterCondition {
|
|
280
|
+
const [method, receiver, callArgs] = args;
|
|
281
|
+
const mapped = STRING_METHOD[method];
|
|
282
|
+
if (!mapped) throw new CompileError('unsupported', `unsupported method "${method}()"`);
|
|
283
|
+
const recv = classify(receiver, ctx);
|
|
284
|
+
if (recv.kind !== 'field') throw new CompileError('unsupported', `"${method}()" must be called on a field`);
|
|
285
|
+
if (!Array.isArray(callArgs) || callArgs.length !== 1) {
|
|
286
|
+
throw new CompileError('unsupported', `"${method}()" takes exactly one argument`);
|
|
287
|
+
}
|
|
288
|
+
const arg = resolveValue(classify(callArgs[0], ctx), ctx);
|
|
289
|
+
if (arg !== SHAPE_VALUE && typeof arg !== 'string') {
|
|
290
|
+
throw new CompileError('unsupported', `"${method}()" argument must be a string literal`);
|
|
291
|
+
}
|
|
292
|
+
return { [(recv as { path: string }).path]: { [mapped]: arg } } as FilterCondition;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Build `{ field: <op> value }`. `isRef` true → value is a `{ $field }` reference. */
|
|
296
|
+
function emit(field: string, op: string, value: unknown, isRef: boolean): FilterCondition {
|
|
297
|
+
if (op === '==') {
|
|
298
|
+
if (!isRef && value === null) return { [field]: { $null: true } } as FilterCondition;
|
|
299
|
+
if (isRef) return { [field]: { $eq: value } } as FilterCondition;
|
|
300
|
+
return { [field]: value } as FilterCondition; // implicit equality
|
|
301
|
+
}
|
|
302
|
+
if (op === '!=') {
|
|
303
|
+
if (!isRef && value === null) return { [field]: { $null: false } } as FilterCondition;
|
|
304
|
+
return { [field]: { $ne: value } } as FilterCondition;
|
|
305
|
+
}
|
|
306
|
+
const cmp = CMP_OP[op];
|
|
307
|
+
if (cmp) return { [field]: { [cmp]: value } } as FilterCondition;
|
|
308
|
+
throw new CompileError('unsupported', `unsupported comparison "${op}"`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Syntactically classify an operand node. Throws on a non-pushdown shape. */
|
|
312
|
+
function classify(node: ASTNode, ctx: Ctx): Leaf {
|
|
313
|
+
switch (node.op) {
|
|
314
|
+
case 'value':
|
|
315
|
+
return { kind: 'literal', value: coerceLiteral(node.args) };
|
|
316
|
+
case 'list': {
|
|
317
|
+
const items = (node.args as ASTNode[]).map((n) => {
|
|
318
|
+
const leaf = classify(n, ctx);
|
|
319
|
+
if (leaf.kind !== 'literal') {
|
|
320
|
+
throw new CompileError('unsupported', 'list elements must be literals');
|
|
321
|
+
}
|
|
322
|
+
return leaf.value;
|
|
323
|
+
});
|
|
324
|
+
return { kind: 'literal', value: items };
|
|
325
|
+
}
|
|
326
|
+
case 'id': {
|
|
327
|
+
const name = node.args as string;
|
|
328
|
+
if (ctx.variableRoots.has(name)) return { kind: 'var', path: [name] };
|
|
329
|
+
// Bare identifier = a single record field (RLS convention).
|
|
330
|
+
return { kind: 'field', path: name };
|
|
331
|
+
}
|
|
332
|
+
case '.':
|
|
333
|
+
case '.?': {
|
|
334
|
+
const [recv, field] = node.args as [ASTNode, string];
|
|
335
|
+
const chain = memberChain(recv, field);
|
|
336
|
+
if (!chain) throw new CompileError('unsupported', 'unsupported member-access expression');
|
|
337
|
+
const [root, ...rest] = chain;
|
|
338
|
+
if (ctx.variableRoots.has(root)) return { kind: 'var', path: chain };
|
|
339
|
+
if (ctx.fieldRoots.has(root)) {
|
|
340
|
+
if (rest.length !== 1) {
|
|
341
|
+
// `record.account.region` = cross-object traversal (ADR-0055): refuse.
|
|
342
|
+
throw new CompileError('unsupported', `cross-object/nested field path "${chain.join('.')}" is not pushdown-able`);
|
|
343
|
+
}
|
|
344
|
+
return { kind: 'field', path: rest[0] };
|
|
345
|
+
}
|
|
346
|
+
// A `.`-chain rooted at an unknown identifier = relation traversal.
|
|
347
|
+
throw new CompileError('unsupported', `cross-object field path "${chain.join('.')}" is not pushdown-able`);
|
|
348
|
+
}
|
|
349
|
+
default:
|
|
350
|
+
throw new CompileError('unsupported', `unsupported operand "${String(node.op)}"`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Flatten a `.`-member chain into `[root, seg, seg, …]`, or null if not a pure path. */
|
|
355
|
+
function memberChain(recv: ASTNode, field: string): string[] | null {
|
|
356
|
+
if (recv.op === 'id') return [recv.args as string, field];
|
|
357
|
+
if (recv.op === '.' || recv.op === '.?') {
|
|
358
|
+
const [innerRecv, innerField] = recv.args as [ASTNode, string];
|
|
359
|
+
const inner = memberChain(innerRecv, innerField);
|
|
360
|
+
return inner ? [...inner, field] : null;
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Resolve a leaf to its VALUE (literal directly; var via `variables`). */
|
|
366
|
+
function resolveValue(leaf: Leaf, ctx: Ctx): unknown {
|
|
367
|
+
if (leaf.kind === 'literal') return leaf.value;
|
|
368
|
+
if (leaf.kind === 'field') {
|
|
369
|
+
throw new CompileError('unsupported', `expected a value but got field "${leaf.path}"`);
|
|
370
|
+
}
|
|
371
|
+
// var
|
|
372
|
+
if (ctx.mode === 'shape') return SHAPE_VALUE;
|
|
373
|
+
let cur: unknown = ctx.variables;
|
|
374
|
+
for (const seg of leaf.path) {
|
|
375
|
+
if (cur == null || typeof cur !== 'object') {
|
|
376
|
+
throw new CompileError('unresolved-variable', `variable "${leaf.path.join('.')}" is not resolvable`);
|
|
377
|
+
}
|
|
378
|
+
cur = (cur as Record<string, unknown>)[seg];
|
|
379
|
+
}
|
|
380
|
+
if (cur === undefined || cur === null) {
|
|
381
|
+
throw new CompileError('unresolved-variable', `variable "${leaf.path.join('.')}" is ${String(cur)}`);
|
|
382
|
+
}
|
|
383
|
+
return cur;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Coerce a cel-js literal to a plain JS value (cel-js uses BigInt for ints). */
|
|
387
|
+
function coerceLiteral(v: unknown): unknown {
|
|
388
|
+
if (typeof v === 'bigint') return Number(v);
|
|
389
|
+
if (v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v;
|
|
390
|
+
// `v` is already non-null here (the line above returns for null), so a
|
|
391
|
+
// further `v !== null` would be a dead comparison; rely on the early return.
|
|
392
|
+
if (typeof v === 'object' && typeof (v as { valueOf?: unknown }).valueOf === 'function') {
|
|
393
|
+
const prim = (v as { valueOf: () => unknown }).valueOf();
|
|
394
|
+
if (typeof prim === 'bigint') return Number(prim);
|
|
395
|
+
if (typeof prim === 'number' || typeof prim === 'string' || typeof prim === 'boolean') return prim;
|
|
396
|
+
}
|
|
397
|
+
throw new CompileError('unsupported', `unsupported literal type "${typeof v}"`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Compile-time fold of a comparison between two concrete values. */
|
|
401
|
+
function constFold(op: string, l: unknown, r: unknown): boolean | undefined {
|
|
402
|
+
switch (op) {
|
|
403
|
+
case '==': return l === r;
|
|
404
|
+
case '!=': return l !== r;
|
|
405
|
+
case '>': return (l as number) > (r as number);
|
|
406
|
+
case '>=': return (l as number) >= (r as number);
|
|
407
|
+
case '<': return (l as number) < (r as number);
|
|
408
|
+
case '<=': return (l as number) <= (r as number);
|
|
409
|
+
default: return undefined;
|
|
410
|
+
}
|
|
411
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,12 @@ export { templateEngine, TEMPLATE_FORMATTERS, formatValue } from './template-eng
|
|
|
16
16
|
export { registerStdLib, buildScope } from './stdlib';
|
|
17
17
|
export { resolveSeed, resolveSeedRecord } from './seed-eval';
|
|
18
18
|
export { normalizeExpression, normalizeExpressionTree } from './normalize';
|
|
19
|
+
// ADR-0058 — canonical CEL → FilterCondition pushdown compiler (one AST,
|
|
20
|
+
// two backends). Replaces the regex/celToFilter front-ends in plugin-security
|
|
21
|
+
// and plugin-sharing; honours ADR-0055 (no subquery / no cross-object traversal).
|
|
22
|
+
export { compileCelToFilter, isPushdownableCel, lowerCelAst } from './cel-to-filter';
|
|
23
|
+
export type { CelFilterCompileResult, CelFilterCompileOptions, CelFilterFailReason } from './cel-to-filter';
|
|
24
|
+
export { matchesFilterCondition } from './matches-filter';
|
|
19
25
|
// ADR-0032 — shared validator + introspection (one validator for build,
|
|
20
26
|
// registration, and the agent-callable validate_expression tool).
|
|
21
27
|
export { validateExpression, introspectScope, expectedDialect, CEL_STDLIB_FUNCTIONS } from './validate';
|