@objectstack/formula 9.10.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.
@@ -0,0 +1,110 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { matchesFilterCondition as m } from './matches-filter';
6
+
7
+ const rec = {
8
+ id: 'r1', owner_id: 'u1', org: 'org1', amount: 1000, stage: 'won',
9
+ region: null as string | null, name: 'Acme Beta', created_by: 'u1',
10
+ };
11
+
12
+ describe('matchesFilterCondition — basics', () => {
13
+ it('null/empty filter matches everything', () => {
14
+ expect(m(rec, null)).toBe(true);
15
+ expect(m(rec, {})).toBe(true);
16
+ });
17
+ it('implicit equality', () => {
18
+ expect(m(rec, { owner_id: 'u1' })).toBe(true);
19
+ expect(m(rec, { owner_id: 'u2' })).toBe(false);
20
+ });
21
+ it('{ field: null } → IS NULL', () => {
22
+ expect(m(rec, { region: null })).toBe(true);
23
+ expect(m(rec, { stage: null })).toBe(false);
24
+ });
25
+ it('multiple keys are AND-ed', () => {
26
+ expect(m(rec, { owner_id: 'u1', stage: 'won' })).toBe(true);
27
+ expect(m(rec, { owner_id: 'u1', stage: 'lost' })).toBe(false);
28
+ });
29
+ });
30
+
31
+ describe('matchesFilterCondition — operators', () => {
32
+ it('$eq / $ne', () => {
33
+ expect(m(rec, { stage: { $eq: 'won' } })).toBe(true);
34
+ expect(m(rec, { stage: { $ne: 'lost' } })).toBe(true);
35
+ expect(m(rec, { stage: { $ne: 'won' } })).toBe(false);
36
+ });
37
+ it('$gt/$gte/$lt/$lte', () => {
38
+ expect(m(rec, { amount: { $gte: 1000 } })).toBe(true);
39
+ expect(m(rec, { amount: { $gt: 1000 } })).toBe(false);
40
+ expect(m(rec, { amount: { $lt: 2000 } })).toBe(true);
41
+ expect(m(rec, { amount: { $lte: 999 } })).toBe(false);
42
+ });
43
+ it('$in / $nin', () => {
44
+ expect(m(rec, { stage: { $in: ['won', 'open'] } })).toBe(true);
45
+ expect(m(rec, { stage: { $in: ['lost'] } })).toBe(false);
46
+ expect(m(rec, { stage: { $nin: ['lost'] } })).toBe(true);
47
+ expect(m(rec, { stage: { $nin: ['won'] } })).toBe(false);
48
+ });
49
+ it('$between', () => {
50
+ expect(m(rec, { amount: { $between: [500, 1500] } })).toBe(true);
51
+ expect(m(rec, { amount: { $between: [1100, 1500] } })).toBe(false);
52
+ });
53
+ it('string ops', () => {
54
+ expect(m(rec, { name: { $contains: 'Beta' } })).toBe(true);
55
+ expect(m(rec, { name: { $startsWith: 'Acme' } })).toBe(true);
56
+ expect(m(rec, { name: { $endsWith: 'Beta' } })).toBe(true);
57
+ expect(m(rec, { name: { $notContains: 'Zeta' } })).toBe(true);
58
+ expect(m(rec, { name: { $startsWith: 'Zzz' } })).toBe(false);
59
+ });
60
+ it('$null / $exists', () => {
61
+ expect(m(rec, { region: { $null: true } })).toBe(true);
62
+ expect(m(rec, { stage: { $null: false } })).toBe(true);
63
+ expect(m(rec, { stage: { $exists: true } })).toBe(true);
64
+ expect(m(rec, { missing: { $exists: false } })).toBe(true);
65
+ });
66
+ it('$field reference (field-to-field)', () => {
67
+ expect(m(rec, { created_by: { $eq: { $field: 'owner_id' } } })).toBe(true);
68
+ expect(m(rec, { created_by: { $eq: { $field: 'org' } } })).toBe(false);
69
+ });
70
+ });
71
+
72
+ describe('matchesFilterCondition — combinators', () => {
73
+ it('$and', () => {
74
+ expect(m(rec, { $and: [{ stage: 'won' }, { amount: { $gte: 500 } }] })).toBe(true);
75
+ expect(m(rec, { $and: [{ stage: 'won' }, { amount: { $gte: 5000 } }] })).toBe(false);
76
+ });
77
+ it('$or', () => {
78
+ expect(m(rec, { $or: [{ stage: 'lost' }, { amount: { $gte: 500 } }] })).toBe(true);
79
+ expect(m(rec, { $or: [{ stage: 'lost' }, { amount: { $gte: 5000 } }] })).toBe(false);
80
+ expect(m(rec, { $or: [] })).toBe(false); // empty OR matches nothing
81
+ });
82
+ it('$not', () => {
83
+ expect(m(rec, { $not: { stage: 'lost' } })).toBe(true);
84
+ expect(m(rec, { $not: { stage: 'won' } })).toBe(false);
85
+ });
86
+ it('nested compound (the compiled compound-condition shape)', () => {
87
+ const f = { $and: [{ org: 'org1' }, { $or: [{ stage: 'won' }, { amount: { $gt: 9999 } }] }] };
88
+ expect(m(rec, f)).toBe(true);
89
+ expect(m({ ...rec, org: 'org2' }, f)).toBe(false);
90
+ });
91
+ });
92
+
93
+ describe('matchesFilterCondition — FAIL CLOSED', () => {
94
+ it('unknown operator → false', () => {
95
+ expect(m(rec, { amount: { $regex: '.*' } as never })).toBe(false);
96
+ });
97
+ it('unknown top-level operator → false', () => {
98
+ expect(m(rec, { $weird: [] } as never)).toBe(false);
99
+ });
100
+ it('nested relation object (non-$ key) → false', () => {
101
+ expect(m(rec, { account: { region: 'EMEA' } } as never)).toBe(false);
102
+ });
103
+ it('bare array value → false', () => {
104
+ expect(m(rec, { stage: ['won'] } as never)).toBe(false);
105
+ });
106
+ it('malformed (array/scalar) filter → false', () => {
107
+ expect(m(rec, [] as never)).toBe(false);
108
+ expect(m(rec, 'nope' as never)).toBe(false);
109
+ });
110
+ });
@@ -0,0 +1,115 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * matchesFilterCondition — evaluate a Mongo-style {@link FilterCondition} against
5
+ * ONE in-memory record (ADR-0058 D4/D6).
6
+ *
7
+ * This is the third backend for the canonical filter shape, completing the
8
+ * round-trip: `compileCelToFilter` lowers CEL → FilterCondition; the engine runs
9
+ * it as a `where`; `read-scope-sql` lowers it to SQL; and THIS evaluates it
10
+ * against a single record for write-side validation — the RLS `check` clause
11
+ * (post-image of an insert/update), where there is no query to push down to.
12
+ *
13
+ * Security posture: **fail closed.** Anything it cannot evaluate — a malformed
14
+ * node, an unknown operator, a nested relation object a flat record can't
15
+ * satisfy — returns `false` (the write is denied), never `true`. The operator
16
+ * vocabulary mirrors `read-scope-sql.ts` so the in-memory and SQL backends agree.
17
+ */
18
+
19
+ import type { FilterCondition } from '@objectstack/spec/data';
20
+
21
+ /** True iff `record` satisfies `filter`. A null/empty filter matches everything. */
22
+ export function matchesFilterCondition(record: Record<string, unknown>, filter: FilterCondition | null | undefined): boolean {
23
+ if (filter == null) return true;
24
+ if (typeof filter !== 'object' || Array.isArray(filter)) return false;
25
+ return evalNode(record, filter as Record<string, unknown>);
26
+ }
27
+
28
+ function evalNode(record: Record<string, unknown>, node: Record<string, unknown>): boolean {
29
+ // A node is the AND of all its entries.
30
+ for (const [key, val] of Object.entries(node)) {
31
+ if (key === '$and') {
32
+ if (!Array.isArray(val) || !val.every((c) => evalNode(record, c as Record<string, unknown>))) return false;
33
+ } else if (key === '$or') {
34
+ if (!Array.isArray(val) || val.length === 0 || !val.some((c) => evalNode(record, c as Record<string, unknown>))) return false;
35
+ } else if (key === '$not') {
36
+ if (val == null || typeof val !== 'object') return false;
37
+ if (evalNode(record, val as Record<string, unknown>)) return false;
38
+ } else if (key.startsWith('$')) {
39
+ return false; // unknown top-level operator → fail closed
40
+ } else {
41
+ if (!evalField(record, key, val)) return false;
42
+ }
43
+ }
44
+ return true;
45
+ }
46
+
47
+ function evalField(record: Record<string, unknown>, field: string, spec: unknown): boolean {
48
+ const actual = getPath(record, field);
49
+ // `{ field: null }` → IS NULL.
50
+ if (spec === null) return actual == null;
51
+ // Scalar / Date → implicit equality.
52
+ if (typeof spec !== 'object' || spec instanceof Date) return looseEq(actual, spec);
53
+ // A bare array value is not a valid field spec (must be `{ $in: [...] }`).
54
+ if (Array.isArray(spec)) return false;
55
+
56
+ const ops = spec as Record<string, unknown>;
57
+ const keys = Object.keys(ops);
58
+ // Must be all-operators; a non-`$` key means a nested relation a flat record
59
+ // cannot satisfy → fail closed.
60
+ if (keys.length === 0 || keys.some((k) => !k.startsWith('$'))) return false;
61
+ for (const op of keys) {
62
+ if (!evalOp(actual, op, ops[op], record)) return false;
63
+ }
64
+ return true;
65
+ }
66
+
67
+ function evalOp(actual: unknown, op: string, raw: unknown, record: Record<string, unknown>): boolean {
68
+ const v = resolveValue(raw, record);
69
+ switch (op) {
70
+ case '$eq': return v === null ? actual == null : looseEq(actual, v);
71
+ case '$ne': return v === null ? actual != null : !looseEq(actual, v);
72
+ case '$gt': return actual != null && v != null && (actual as never) > (v as never);
73
+ case '$gte': return actual != null && v != null && (actual as never) >= (v as never);
74
+ case '$lt': return actual != null && v != null && (actual as never) < (v as never);
75
+ case '$lte': return actual != null && v != null && (actual as never) <= (v as never);
76
+ case '$in': return Array.isArray(v) && v.some((x) => looseEq(actual, x));
77
+ case '$nin': return Array.isArray(v) && !v.some((x) => looseEq(actual, x));
78
+ case '$between':
79
+ return Array.isArray(v) && v.length === 2 && actual != null
80
+ && (actual as never) >= (v[0] as never) && (actual as never) <= (v[1] as never);
81
+ case '$contains': return typeof actual === 'string' && typeof v === 'string' && actual.includes(v);
82
+ case '$notContains': return !(typeof actual === 'string' && typeof v === 'string' && actual.includes(v));
83
+ case '$startsWith': return typeof actual === 'string' && typeof v === 'string' && actual.startsWith(v);
84
+ case '$endsWith': return typeof actual === 'string' && typeof v === 'string' && actual.endsWith(v);
85
+ case '$null': return v === true ? actual == null : actual != null;
86
+ case '$exists': return v === true ? actual !== undefined : actual === undefined;
87
+ default: return false; // unknown operator → fail closed
88
+ }
89
+ }
90
+
91
+ /** Resolve a `{ $field: 'path' }` reference against the record; else passthrough. */
92
+ function resolveValue(raw: unknown, record: Record<string, unknown>): unknown {
93
+ if (raw && typeof raw === 'object' && !Array.isArray(raw) && '$field' in (raw as Record<string, unknown>)) {
94
+ return getPath(record, String((raw as Record<string, unknown>).$field));
95
+ }
96
+ return raw;
97
+ }
98
+
99
+ function getPath(record: Record<string, unknown>, path: string): unknown {
100
+ if (!path.includes('.')) return record[path];
101
+ let cur: unknown = record;
102
+ for (const seg of path.split('.')) {
103
+ if (cur == null || typeof cur !== 'object') return undefined;
104
+ cur = (cur as Record<string, unknown>)[seg];
105
+ }
106
+ return cur;
107
+ }
108
+
109
+ /** Equality that treats Dates by time-value; otherwise strict. */
110
+ function looseEq(a: unknown, b: unknown): boolean {
111
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
112
+ if (a instanceof Date && (typeof b === 'string' || typeof b === 'number')) return a.getTime() === new Date(b).getTime();
113
+ if (b instanceof Date && (typeof a === 'string' || typeof a === 'number')) return new Date(a).getTime() === b.getTime();
114
+ return a === b;
115
+ }