Repository navigation
validateRule(toPrisma) misses Prisma shape and literal gaps: Json path arrays, case-insensitive Json, literal types, ordered list/enum ops #30
Description
validateRule(rule, { target: 'toPrisma', map, model }) and toPrisma disagree on some shapes. In 900 random rules over Json, list and text columns of the rails fixture, run against PGlite + Prisma 7:
| Outcome | Rules |
|---|---|
| Validates and runs | 282 |
| Refused by both | 328 |
| Validates, then toPrisma or Prisma throws | 102 |
| Refused by validateRule, yet compiles and runs | 188 |
PR #26 makes the lens postures safe against the first gap: toLensSelect, toSourceQueries and toPrisma(…, { lens }) refuse a lens condition they can't compile with a LensRefusal, never a plain Error. A bare validateRule result can still be wrong, though. Found in the round-11 review of PR #26; not fixed there by decision.
Categories where it validates and then throws:
- Element conditions over a Json path array.
{ field: 'meta.list', arrayOperator: 'none', condition }has no Prisma form. - Case-insensitive comparisons on a Json path. For example,
meta.a.b equals ['a','b']withcaseInsensitive. - Literal type mismatches. Prisma or the compile rejects these at runtime:
- a number against a String column (
name equals 3) - an object against a list or text column (
tags equals {a:1},org.name notEquals {a:1}) - a boolean against a list (
tags equals true) - a one-element list against a scalar (
name equals ['admin'])
- a number against a String column (
- Ordered operators on a list or an enum.
tags greaterThan 3,tags lessThan 'X',role greaterThan 3. notContainson a Json value.$element refs in a relation's condition.posts none { field: '$', … }reaches Prisma as an unknown argument.
Refused by validateRule, yet compiles (over-strict, 188 rules): not analyzed yet. The probe's VAL-BAD-BUT-COMPILES lines list them.
Probe (copy to test/zz11/vr.test.ts and run N=900 bun test test/zz11/vr.test.ts):
import { afterAll, beforeAll, test } from 'bun:test';
import { type Condition, check, executePrismaPlan, toPrisma, validateRule } from '../../index';
import { map, openRails } from '../rails/harness';
import { mulberry32 } from '../fuzz/mulberry32';
let rails: Awaited<ReturnType<typeof openRails>>;
beforeAll(async () => { rails = await openRails(); });
afterAll(async () => rails.close(), 30000);
test('validateRule(toPrisma) ⇔ toPrisma compiles and executes, on Json / list / text', async () => {
const rnd = mulberry32(Number(process.env.S0 ?? 4242));
const pick = <T,>(a: T[]): T => a[Math.floor(rnd() * a.length)];
const fields = ['meta', 'meta.a.b', 'meta.n', 'meta.list', 'tags', 'name', 'role', 'org.settings', 'org.settings.tier', 'org.name'];
const ops = ['equals', 'notEquals', 'in', 'notIn', 'contains', 'notContains', 'startsWith', 'endsWith', 'exists', 'notExists', 'isEmpty', 'notEmpty', 'greaterThan', 'lessThan', 'matches'];
const vals = () => pick<unknown>(['x', 'X', 3, null, ['a', 'b'], ['a', null], [null], [], ['admin'], ['ADMIN', 'member'], { a: 1 }, true]);
let n = 0; const out: string[] = []; const counts = { okBoth: 0, refusedBoth: 0, valOkThrow: 0, valBadCompiles: 0 };
for (let i = 0; i < Number(process.env.N ?? 3000); i++) {
let rule: Record<string, unknown>;
const f = pick(fields);
if (rnd() < 0.25) rule = { field: pick(['tags', 'meta.list', 'org.settings.nums', 'posts']), arrayOperator: pick(['any', 'all', 'none', 'empty', 'notEmpty']), condition: pick<unknown>([true, { field: 'title', operator: 'exists' }, { field: '$', operator: 'equals', value: 'a' }]) };
else rule = { field: f, operator: pick(ops), ...(rnd() < 0.85 ? { value: vals() } : {}), ...(rnd() < 0.4 ? { caseInsensitive: pick([true, false]) } : {}) };
if ((rule.arrayOperator === 'empty' || rule.arrayOperator === 'notEmpty')) delete rule.condition;
n++;
let vok: boolean; try { vok = validateRule(rule, { target: 'toPrisma', map, model: 'User' }).ok; } catch (e) { out.push(`VALIDATE THREW ${JSON.stringify(rule)} ${(e as Error).message.slice(0, 100)}`); continue; }
let err: string | null = null;
try { const w = await executePrismaPlan(toPrisma(rule as Condition, { map, model: 'User' }), rails.prisma as never); await rails.prisma.user.findMany({ where: w as never, select: { id: true } }); } catch (e) { err = (e as Error).message.split('\n').filter(Boolean).at(-1)!.slice(0, 140); }
if (vok && !err) counts.okBoth++; else if (!vok && err) counts.refusedBoth++;
else if (vok && err) { counts.valOkThrow++; out.push(`VAL-OK-BUT-THROWS ${JSON.stringify(rule)} :: ${err}`); }
else { counts.valBadCompiles++; const v = validateRule(rule, { target: 'toPrisma', map, model: 'User' }); out.push(`VAL-BAD-BUT-COMPILES ${JSON.stringify(rule)} :: ${JSON.stringify(v.errors?.[0]).slice(0, 160)}`); }
}
const uniq = [...new Set(out.map((l) => l.replace(/"value":[^,}]*(\]|)/, '')))];
const cat = new Map<string, string>(); for (const l of out.filter((x) => x.startsWith('VAL-OK'))) { const k = l.split(' :: ')[1].replace(/'[^']*'/g, "'_'").replace(/\[.*?\]/g, '[..]').slice(0, 70); if (!cat.has(k)) cat.set(k, l.slice(0, 260)); }
console.log(n, JSON.stringify(counts), '\n' + [...cat.values()].join('\n'));
}, 300000);
Activity