Skip to content

validateRule(toPrisma) misses Prisma shape and literal gaps: Json path arrays, case-insensitive Json, literal types, ordered list/enum ops #30

Open

Description

@agreenspan

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'] with caseInsensitive.
  • 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'])
  • Ordered operators on a list or an enum. tags greaterThan 3, tags lessThan 'X', role greaterThan 3.
  • notContains on 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions