mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(schema): run const, enum, type list, bounds and pattern checks inline in object and array loops
This commit is contained in:
parent
0c5b7ee393
commit
20c637b1a5
4 changed files with 221 additions and 41 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Faster checks, still without eval: object and array loops run `const`, small `enum`s, type lists, number bounds, string lengths and patterns inline instead of calling a closure; a closed object looks up keys in declared order first; `anyOf` branches that are bare types (as `nullable` builds) run as one type test. Checks take about 16% less time across the benchmark's speed cases, up to 30% less for tagged unions and 45% less for nullable properties.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed local `$ref`s in schemas whose root `$id` is a relative path with a directory, such as `schemas/tool.json`: the `$id` was applied twice, so `compile` threw and `unresolvedRefs: "reject"` rejected valid tool arguments. Relative `$id`s are now also applied once with `unevaluatedProperties`/`unevaluatedItems`, when explaining errors under nested resources, and on the first coercion of a schema with `$dynamicRef` ([#11697](https://github.com/earendil-works/pi/issues/11697))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Check factories: each returns a closure that captures only its arguments, never the compiler, so a compiled
|
||||
// schema keeps no compile-time state alive. Leaf checks are module constants that object and array loops inline.
|
||||
// schema keeps no compile-time state alive. Leaf checks (a type, a const, an enum, bounds) are registered with a code
|
||||
// and an argument, so object and array loops run them inline instead of calling them.
|
||||
|
||||
import type { Check } from "./types.ts";
|
||||
|
||||
|
|
@ -31,17 +32,48 @@ export const TYPE_CHECKS: ReadonlyMap<string, Check> = new Map([
|
|||
["bytes", IS_BYTES],
|
||||
]);
|
||||
|
||||
/** Leaf checks that object and array loops run inline by code instead of calling them. */
|
||||
export const LEAF_CODES: ReadonlyMap<Check, number> = new Map([
|
||||
[IS_STRING, 1],
|
||||
[IS_NUMBER, 2],
|
||||
[IS_INTEGER, 3],
|
||||
[IS_BOOLEAN, 4],
|
||||
[IS_NULL, 5],
|
||||
[ALWAYS, 6],
|
||||
/**
|
||||
* A leaf check as a code and its argument, which object and array loops run by `leafOk` instead of calling the
|
||||
* check: a call through a list of checks is not inlined, a switch is.
|
||||
*/
|
||||
export type Leaf = readonly [code: number, arg: unknown];
|
||||
|
||||
const leaves = new WeakMap<Check, Leaf>([
|
||||
[IS_STRING, [1, undefined]],
|
||||
[IS_NUMBER, [2, undefined]],
|
||||
[IS_INTEGER, [3, undefined]],
|
||||
[IS_BOOLEAN, [4, undefined]],
|
||||
[IS_NULL, [5, undefined]],
|
||||
[ALWAYS, [6, undefined]],
|
||||
]);
|
||||
|
||||
function leafOk(code: number, x: unknown): boolean {
|
||||
/** The leaf form of `check`, undefined when it is not a leaf. */
|
||||
export function leafOf(check: Check): Leaf | undefined {
|
||||
return leaves.get(check);
|
||||
}
|
||||
|
||||
function leaf(code: number, arg: unknown, check: Check): Check {
|
||||
leaves.set(check, [code, arg]);
|
||||
return check;
|
||||
}
|
||||
|
||||
/** Bounds of a number or a string length leaf. */
|
||||
class Bounds {
|
||||
readonly min: number;
|
||||
readonly max: number;
|
||||
readonly xmin: number;
|
||||
readonly xmax: number;
|
||||
|
||||
constructor(min: number, max: number, xmin: number, xmax: number) {
|
||||
this.min = min;
|
||||
this.max = max;
|
||||
this.xmin = xmin;
|
||||
this.xmax = xmax;
|
||||
}
|
||||
}
|
||||
|
||||
// Codes 1-6 are the bare types and `true`; the others read their argument.
|
||||
function leafOk(code: number, x: unknown, arg: unknown): boolean {
|
||||
switch (code) {
|
||||
case 1:
|
||||
return typeof x === "string";
|
||||
|
|
@ -53,11 +85,32 @@ function leafOk(code: number, x: unknown): boolean {
|
|||
return typeof x === "boolean";
|
||||
case 5:
|
||||
return x === null;
|
||||
case 7:
|
||||
return x === arg;
|
||||
case 8: {
|
||||
const list = arg as readonly unknown[];
|
||||
for (let i = 0; i < list.length; i++) if (list[i] === x) return true;
|
||||
return false;
|
||||
}
|
||||
case 9:
|
||||
return (typeBits(x) & (arg as number)) !== 0;
|
||||
case 10:
|
||||
return Number.isInteger(x) && inBounds(x as number, arg as Bounds);
|
||||
case 11:
|
||||
return typeof x === "number" && inBounds(x, arg as Bounds);
|
||||
case 12:
|
||||
return typeof x === "string" && lengthOk(x, (arg as Bounds).min, (arg as Bounds).max);
|
||||
case 13:
|
||||
return typeof x === "string" && (arg as RegExp).test(x);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function inBounds(x: number, b: Bounds): boolean {
|
||||
return x >= b.min && x <= b.max && x > b.xmin && x < b.xmax;
|
||||
}
|
||||
|
||||
/** Whether a combined object or array check also checks the type: not at all, strictly, or passing other types. */
|
||||
export const GUARD_NONE = 0;
|
||||
export const GUARD_STRICT = 1;
|
||||
|
|
@ -65,9 +118,9 @@ export const GUARD_LOOSE = 2;
|
|||
export type Guard = typeof GUARD_NONE | typeof GUARD_STRICT | typeof GUARD_LOOSE;
|
||||
|
||||
/** Flags of a declared property in an object check's entries: its leaf code and two bits. */
|
||||
export const CODE = 7;
|
||||
export const REQUIRED = 8;
|
||||
export const INHERITED = 16;
|
||||
export const CODE = 15;
|
||||
export const REQUIRED = 16;
|
||||
export const INHERITED = 32;
|
||||
|
||||
/** All of `parts`; two and three parts get closures without a loop. */
|
||||
export function and(parts: readonly Check[]): Check {
|
||||
|
|
@ -109,12 +162,53 @@ export function scopedCheck(scope: string[], uri: string, check: Check): Check {
|
|||
|
||||
/** Any of the named types. */
|
||||
export function typeCheck(types: readonly unknown[]): Check {
|
||||
const checks = types.map((t) => (typeof t === "string" ? TYPE_CHECKS.get(t) : undefined) ?? NEVER);
|
||||
if (checks.length === 1) return checks[0];
|
||||
return (v) => {
|
||||
for (let i = 0; i < checks.length; i++) if (checks[i](v)) return true;
|
||||
return false;
|
||||
};
|
||||
if (types.length === 1) return (typeof types[0] === "string" ? TYPE_CHECKS.get(types[0]) : undefined) ?? NEVER;
|
||||
return maskCheck(typeMask(types));
|
||||
}
|
||||
|
||||
const TYPE_BITS: ReadonlyMap<string, number> = new Map([
|
||||
["string", 1],
|
||||
["number", 2],
|
||||
["integer", 4],
|
||||
["boolean", 8],
|
||||
["null", 16],
|
||||
["array", 32],
|
||||
["object", 64],
|
||||
["bytes", 128],
|
||||
]);
|
||||
|
||||
/** The bits of the named types; unknown names add none. */
|
||||
export function typeMask(types: readonly unknown[]): number {
|
||||
let mask = 0;
|
||||
for (const t of types) if (typeof t === "string") mask |= TYPE_BITS.get(t) ?? 0;
|
||||
return mask;
|
||||
}
|
||||
|
||||
/** The type bits of `v`: an integer has the bits of `number` and `integer`. */
|
||||
function typeBits(v: unknown): number {
|
||||
switch (typeof v) {
|
||||
case "string":
|
||||
return 1;
|
||||
case "number":
|
||||
return Number.isInteger(v) ? 6 : 2;
|
||||
case "boolean":
|
||||
return 8;
|
||||
case "object":
|
||||
return v === null ? 16 : Array.isArray(v) ? 32 : v instanceof Uint8Array ? 128 : 64;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function maskCheck(mask: number): Check {
|
||||
return leaf(9, mask, (v) => (typeBits(v) & mask) !== 0);
|
||||
}
|
||||
|
||||
/** `anyOf` whose bare-type branches have the types of `mask`: those by one type test, the others by their checks. */
|
||||
export function maskedAnyCheck(mask: number, checks: readonly Check[]): Check {
|
||||
if (checks.length === 0) return maskCheck(mask);
|
||||
const rest = checks.length === 1 ? checks[0] : anyCheck(checks);
|
||||
return (v) => (typeBits(v) & mask) !== 0 || rest(v);
|
||||
}
|
||||
|
||||
/** A Uint8Array within the length bounds. */
|
||||
|
|
@ -153,11 +247,13 @@ export function stringCheck(
|
|||
format: ((value: string) => boolean) | undefined,
|
||||
): Check {
|
||||
if (re !== undefined && min === 0 && max === Number.POSITIVE_INFINITY && format === undefined) {
|
||||
return strict ? (v) => typeof v === "string" && re.test(v) : (v) => typeof v !== "string" || re.test(v);
|
||||
return strict
|
||||
? leaf(13, re, (v) => typeof v === "string" && re.test(v))
|
||||
: (v) => typeof v !== "string" || re.test(v);
|
||||
}
|
||||
if (re === undefined && format === undefined) {
|
||||
return strict
|
||||
? (v) => typeof v === "string" && lengthOk(v, min, max)
|
||||
? leaf(12, new Bounds(min, max, 0, 0), (v) => typeof v === "string" && lengthOk(v, min, max))
|
||||
: (v) => typeof v !== "string" || lengthOk(v, min, max);
|
||||
}
|
||||
return (v) => {
|
||||
|
|
@ -175,11 +271,13 @@ export function numberCheck(
|
|||
xmax: number,
|
||||
multipleOf: number | undefined,
|
||||
): Check {
|
||||
return (v) => {
|
||||
const check: Check = (v) => {
|
||||
if (typeof v !== "number") return mode === 0;
|
||||
if (mode === 2 && !Number.isInteger(v)) return false;
|
||||
return v >= min && v <= max && v > xmin && v < xmax && (multipleOf === undefined || isMultiple(v, multipleOf));
|
||||
};
|
||||
if (mode === 0 || multipleOf !== undefined) return check;
|
||||
return leaf(mode === 2 ? 10 : 11, new Bounds(min, max, xmin, xmax), check);
|
||||
}
|
||||
|
||||
/** Whether `v` is a multiple of `d`, exactly for decimals: 0.0075 / 0.0001 is 74.99999999999999 in floating point. */
|
||||
|
|
@ -226,18 +324,24 @@ export function declaredObject(
|
|||
// Guarded: by the check above, or by the caller for GUARD_NONE.
|
||||
const o = v as Record<string, unknown>;
|
||||
let required = 0;
|
||||
let next = 0;
|
||||
for (const k in o) {
|
||||
let i = 0;
|
||||
while (i < n && entries[i] !== k) i += 3;
|
||||
if (i === n) {
|
||||
if (closed) return false;
|
||||
continue;
|
||||
// Keys usually come in the declared order: try the entry after the last match before searching.
|
||||
let i = next;
|
||||
if (entries[i] !== k) {
|
||||
i = 0;
|
||||
while (i < n && entries[i] !== k) i += 3;
|
||||
if (i === n) {
|
||||
if (closed) return false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
next = i + 3;
|
||||
const x = o[k];
|
||||
if (x === undefined) continue;
|
||||
const f = entries[i + 2] as number;
|
||||
const c = f & CODE;
|
||||
if (c !== 0 ? !leafOk(c, x) : !(entries[i + 1] as Check)(x)) return false;
|
||||
if (c !== 0 ? !leafOk(c, x, entries[i + 1]) : !(entries[i + 1] as Check)(x)) return false;
|
||||
if (f & REQUIRED) required++;
|
||||
}
|
||||
return required === requiredCount;
|
||||
|
|
@ -266,7 +370,7 @@ export function openObject(
|
|||
continue;
|
||||
}
|
||||
const c = f & CODE;
|
||||
if (c !== 0 ? !leafOk(c, x) : !(entries[i + 1] as Check)(x)) return false;
|
||||
if (c !== 0 ? !leafOk(c, x, entries[i + 1]) : !(entries[i + 1] as Check)(x)) return false;
|
||||
}
|
||||
for (let i = 0; i < extraRequired.length; i++) if (!Object.hasOwn(o, extraRequired[i])) return false;
|
||||
if (known === undefined) return true;
|
||||
|
|
@ -319,6 +423,8 @@ export function dependentSchemasCheck(entries: readonly (readonly [string, Check
|
|||
};
|
||||
}
|
||||
|
||||
const NO_LEAF: Leaf = [0, undefined];
|
||||
|
||||
/** The common array: guard, length bounds, prefix items and the items after them, in one closure. */
|
||||
export function listCheck(
|
||||
guard: Guard,
|
||||
|
|
@ -328,7 +434,7 @@ export function listCheck(
|
|||
rest: Check | undefined,
|
||||
): Check {
|
||||
const n = prefix.length;
|
||||
const code = rest === undefined ? 0 : (LEAF_CODES.get(rest) ?? 0);
|
||||
const [code, arg] = (rest === undefined ? undefined : leafOf(rest)) ?? NO_LEAF;
|
||||
return (v) => {
|
||||
if (!Array.isArray(v)) return guard === GUARD_LOOSE;
|
||||
const length = v.length;
|
||||
|
|
@ -337,7 +443,7 @@ export function listCheck(
|
|||
for (let i = 0; i < m; i++) if (!prefix[i](v[i])) return false;
|
||||
if (rest === undefined) return true;
|
||||
if (code !== 0) {
|
||||
for (let i = n; i < length; i++) if (!leafOk(code, v[i])) return false;
|
||||
for (let i = n; i < length; i++) if (!leafOk(code, v[i], arg)) return false;
|
||||
return true;
|
||||
}
|
||||
for (let i = n; i < length; i++) if (!rest(v[i])) return false;
|
||||
|
|
@ -376,10 +482,10 @@ export function uniqueCheck(v: unknown): boolean {
|
|||
export function enumCheck(values: readonly unknown[]): Check {
|
||||
const primitives = values.filter((x) => typeof x !== "object" || x === null);
|
||||
if (primitives.length === values.length && primitives.length <= 8) {
|
||||
return (v) => {
|
||||
return leaf(8, primitives, (v) => {
|
||||
for (let i = 0; i < primitives.length; i++) if (primitives[i] === v) return true;
|
||||
return false;
|
||||
};
|
||||
});
|
||||
}
|
||||
const set = new Set(primitives);
|
||||
const objects = values.filter((x) => typeof x === "object" && x !== null);
|
||||
|
|
@ -388,7 +494,7 @@ export function enumCheck(values: readonly unknown[]): Check {
|
|||
|
||||
/** `const`. */
|
||||
export function constCheck(value: unknown): Check {
|
||||
if (typeof value !== "object" || value === null) return (v) => v === value;
|
||||
if (typeof value !== "object" || value === null) return leaf(7, value, (v) => v === value);
|
||||
return (v) => deepEqual(v, value);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,9 +43,10 @@ import {
|
|||
ifCheck,
|
||||
indirect,
|
||||
itemsCheck,
|
||||
LEAF_CODES,
|
||||
leafOf,
|
||||
lengthCheck,
|
||||
listCheck,
|
||||
maskedAnyCheck,
|
||||
NEVER,
|
||||
notCheck,
|
||||
numberCheck,
|
||||
|
|
@ -59,6 +60,7 @@ import {
|
|||
TYPE_CHECKS,
|
||||
taggedCheck,
|
||||
typeCheck,
|
||||
typeMask,
|
||||
uniqueCheck,
|
||||
} from "./checks.ts";
|
||||
import { documentOf, isSchemaObject, Resolver, resolveUri } from "./resolver.ts";
|
||||
|
|
@ -189,7 +191,7 @@ export class Compiler {
|
|||
return (
|
||||
constUnion(branches, exactlyOne) ??
|
||||
this.tagged(branches, base) ??
|
||||
(exactlyOne ? oneCheck(checks()) : anyCheck(checks()))
|
||||
(exactlyOne ? oneCheck(checks()) : anyOf(checks()))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -334,15 +336,15 @@ export class Compiler {
|
|||
) {
|
||||
return undefined;
|
||||
}
|
||||
// One array per object: key, check, flags (leaf code, required, key also names an Object.prototype member).
|
||||
// One array per object: key, check (or a leaf's argument), flags (leaf code, required, key also names an
|
||||
// Object.prototype member).
|
||||
const entries: unknown[] = [];
|
||||
for (const k of keys) {
|
||||
const check = this.check(properties[k], base);
|
||||
const leaf = leafOf(check);
|
||||
const flags =
|
||||
(LEAF_CODES.get(check) ?? 0) |
|
||||
(required.includes(k) ? REQUIRED : 0) |
|
||||
(k in Object.prototype ? INHERITED : 0);
|
||||
entries.push(k, check, flags);
|
||||
(leaf?.[0] ?? 0) | (required.includes(k) ? REQUIRED : 0) | (k in Object.prototype ? INHERITED : 0);
|
||||
entries.push(k, leaf === undefined ? check : leaf[1], flags);
|
||||
}
|
||||
const extraRequired = required.filter((k) => !Object.hasOwn(properties, k));
|
||||
const pats = patternKeys.map((p) => [sharedRegex(p), this.check(patterns[p], base)] as const);
|
||||
|
|
@ -575,6 +577,20 @@ function constUnion(branches: readonly unknown[], exactlyOne: boolean): Check |
|
|||
return enumCheck(values);
|
||||
}
|
||||
|
||||
/** `anyOf`: its bare-type branches (`nullable` gives one) run as one type test. */
|
||||
function anyOf(checks: readonly Check[]): Check {
|
||||
const types: string[] = [];
|
||||
const rest: Check[] = [];
|
||||
for (const check of checks) {
|
||||
const type = BARE_TYPES.get(check);
|
||||
if (type === undefined) rest.push(check);
|
||||
else types.push(type);
|
||||
}
|
||||
return types.length === 0 ? anyCheck(rest) : maskedAnyCheck(typeMask(types), rest);
|
||||
}
|
||||
|
||||
const BARE_TYPES: ReadonlyMap<Check, string> = new Map([...TYPE_CHECKS].map(([type, check]) => [check, type]));
|
||||
|
||||
// The tag of a tagged-union branch: a primitive const on a required property of an object schema.
|
||||
function tagOf(branch: SchemaObject, key: string): unknown {
|
||||
const properties = branch.properties;
|
||||
|
|
|
|||
|
|
@ -292,6 +292,60 @@ describe("unresolved references", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("leaf checks inlined by object and array loops", () => {
|
||||
const leaves: [object, unknown[]][] = [
|
||||
[{ const: "a" }, ["a", "b", 1, null]],
|
||||
[{ const: null }, [null, 0, false, "null"]],
|
||||
[{ enum: ["a", 1, null] }, ["a", 1, null, "b", 2, true]],
|
||||
[{ type: ["string", "null"] }, ["a", null, 1, {}, []]],
|
||||
[{ type: ["integer", "boolean"] }, [1, 1.5, true, "1"]],
|
||||
[{ type: ["number", "array"] }, [1.5, 2, [], {}, new Uint8Array(1)]],
|
||||
[{ type: ["object", "bytes"] }, [{}, new Uint8Array(1), [], null]],
|
||||
[{ anyOf: [{ type: "string" }, { type: "null" }] }, ["a", null, 1]],
|
||||
[{ anyOf: [{ type: "integer" }, { type: "string", minLength: 2 }] }, [1, 1.5, "ab", "a"]],
|
||||
[{ type: "integer", minimum: 1, exclusiveMaximum: 5 }, [1, 4, 5, 0, 2.5, "2"]],
|
||||
[{ type: "number", exclusiveMinimum: 0, maximum: 1 }, [0.5, 1, 0, 1.5, "0.5"]],
|
||||
[{ type: "integer", multipleOf: 2 }, [2, 3, 2.5]],
|
||||
[{ type: "string", minLength: 2, maxLength: 3 }, ["ab", "abc", "a", "abcd", "😀😀", 12]],
|
||||
[{ type: "string", pattern: "^a+$" }, ["aa", "ab", 1]],
|
||||
[{ minimum: 1 }, [1, 0, "x", null]],
|
||||
];
|
||||
|
||||
it("agree with the standalone check as a property, a pattern property and an item", () => {
|
||||
for (const [schema, values] of leaves) {
|
||||
const alone = compile(schema).check;
|
||||
const closed = compile({ type: "object", properties: { p: schema }, additionalProperties: false }).check;
|
||||
const open = compile({ type: "object", properties: { p: schema }, required: ["q"] }).check;
|
||||
const items = compile({ type: "array", items: schema }).check;
|
||||
for (const v of values) {
|
||||
const expected = alone(v);
|
||||
expect([closed({ p: v }), open({ p: v, q: 1 }), items([v])], JSON.stringify({ schema, v })).toEqual([
|
||||
expected,
|
||||
expected,
|
||||
expected,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("find properties in any order", () => {
|
||||
const { check } = compile({
|
||||
type: "object",
|
||||
properties: { a: { const: 1 }, b: { type: "string" }, c: { type: "integer", minimum: 0 } },
|
||||
required: ["a", "c"],
|
||||
additionalProperties: false,
|
||||
});
|
||||
expect(check({ a: 1, b: "x", c: 0 })).toBe(true);
|
||||
expect(check({ c: 0, b: "x", a: 1 })).toBe(true);
|
||||
expect(check({ b: "x", c: 0, a: 1 })).toBe(true);
|
||||
expect(check({ a: 1, c: 0, a2: 1 })).toBe(false);
|
||||
expect(check({ c: -1, a: 1 })).toBe(false);
|
||||
expect(check({ c: 0, b: 1, a: 1 })).toBe(false);
|
||||
expect(check({ c: 0, b: undefined, a: 1 })).toBe(true);
|
||||
expect(check({ c: 0, b: "x" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("string lengths", () => {
|
||||
it("count an unpaired high surrogate as one code point and a pair as one", () => {
|
||||
expect(compile({ type: "string", minLength: 2 }).check("\ud800A")).toBe(true);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue