mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
205 lines
8.4 KiB
TypeScript
205 lines
8.4 KiB
TypeScript
// The validators the benchmarks compare, each behind the same interface: compile a schema, then check values.
|
|
// Every library is used through its fastest boolean path and keeps the object it returns, as an application would.
|
|
// Run the benchmarks through `npm run bench`, which sets ATA_NO_NATIVE=1: ata-validator's optional native addon only
|
|
// serves its buffer APIs, and the verdicts measured here are JavaScript either way.
|
|
|
|
import { Validator as CfValidator } from "@cfworker/json-schema";
|
|
import { fromJsonSchema } from "@oh-my-pi/omptype";
|
|
import Ajv7Module from "ajv";
|
|
import Ajv2020Module from "ajv/dist/2020.js";
|
|
import draft07MetaSchema from "ajv/dist/refs/json-schema-draft-07.json" with { type: "json" };
|
|
import addFormatsModule from "ajv-formats";
|
|
import { Validator as AtaValidator } from "ata-validator";
|
|
import { Compile } from "typebox/compile";
|
|
import { ResetExternal, ResetFunctions } from "typebox/schema";
|
|
import { Settings } from "typebox/system";
|
|
import { compile, type FormatCheck, type JsonSchema } from "../../src/index.ts";
|
|
import { nativeCases, nativeZod, zodFromJsonSchema } from "./zod.ts";
|
|
|
|
/** How a benchmark compiles a schema. */
|
|
export interface PrepareOptions {
|
|
readonly draft: "2020-12" | "7";
|
|
readonly schemas?: Readonly<Record<string, JsonSchema>>;
|
|
readonly formats?: Readonly<Record<string, FormatCheck>>;
|
|
/** The schema is a JSON-Schema-Test-Suite schema (for libraries that take native schemas otherwise). */
|
|
readonly suite?: boolean;
|
|
}
|
|
|
|
/** A compiled schema: its boolean check and the object the library returned (kept alive by the caller). */
|
|
export interface Prepared {
|
|
readonly check: (value: unknown) => boolean;
|
|
readonly validator: unknown;
|
|
}
|
|
|
|
/** A library under comparison. */
|
|
export interface Candidate {
|
|
readonly name: string;
|
|
readonly prepare: (schema: JsonSchema, options: PrepareOptions) => Prepared;
|
|
}
|
|
|
|
/** Whether this process runs with code generation from strings disabled, as under a strict CSP. */
|
|
export const noEval = process.execArgv.includes("--disallow-code-generation-from-strings");
|
|
|
|
const piSchema: Candidate = {
|
|
name: "pi-schema",
|
|
prepare(schema, options) {
|
|
const validator = compile(schema, options);
|
|
return { check: validator.check, validator };
|
|
},
|
|
};
|
|
|
|
// TypeBox keeps its last build in module-level accumulators; reset them so no build state is retained between schemas.
|
|
function typeboxCompile(schema: JsonSchema, options: PrepareOptions, accelerate: boolean) {
|
|
Settings.Set({ useAcceleration: accelerate });
|
|
try {
|
|
return Compile((options.schemas ?? {}) as never, schema as never);
|
|
} finally {
|
|
ResetFunctions();
|
|
ResetExternal();
|
|
Settings.Set({ useAcceleration: true });
|
|
}
|
|
}
|
|
|
|
const typebox: Candidate = {
|
|
name: noEval ? "typebox (no eval: interpreter)" : "typebox",
|
|
prepare(schema, options) {
|
|
const validator = typeboxCompile(schema, options, true);
|
|
return { check: (v) => validator.Check(v), validator };
|
|
},
|
|
};
|
|
|
|
const typeboxInterpreter: Candidate = {
|
|
name: "typebox interpreter",
|
|
prepare(schema, options) {
|
|
const validator = typeboxCompile(schema, options, false);
|
|
return { check: (v) => validator.Check(v), validator };
|
|
},
|
|
};
|
|
|
|
const cfworker: Candidate = {
|
|
name: "cfworker",
|
|
prepare(schema, options) {
|
|
const validator = new CfValidator(schema as never, options.draft, true);
|
|
for (const [uri, remote] of Object.entries(options.schemas ?? {})) validator.addSchema(remote as never, uri);
|
|
return { check: (v) => validator.validate(v).valid, validator };
|
|
},
|
|
};
|
|
|
|
// omptype compiles with new Function on the first allows() call. Without eval that call throws EvalError once per
|
|
// schema; omptype then keeps validating through its interpreter, so the benchmark absorbs that one throw.
|
|
const omptype: Candidate = {
|
|
name: noEval ? "omptype (no eval: interpreter)" : "omptype",
|
|
prepare(schema) {
|
|
const validator = fromJsonSchema(schema);
|
|
if (noEval) {
|
|
try {
|
|
validator.allows(null);
|
|
} catch (error) {
|
|
if (!(error instanceof EvalError)) throw error;
|
|
}
|
|
}
|
|
return { check: (v) => validator.allows(v), validator };
|
|
},
|
|
};
|
|
|
|
// Ajv's CommonJS modules export the class as module.exports and as `default`; the latter is what its types describe.
|
|
const Ajv2020 = Ajv2020Module.default;
|
|
const Ajv7 = Ajv7Module.default;
|
|
const addFormats = addFormatsModule.default;
|
|
type AjvInstance = InstanceType<typeof Ajv2020>;
|
|
|
|
// One Ajv per draft, as an application keeps it. Ajv2020 also gets the draft-07 meta-schema, so tool schemas that
|
|
// declare draft-07 compile. strict: false so schemas written for other tools compile; logger: false keeps Ajv from
|
|
// printing generated code when compiling fails without eval.
|
|
function newAjv(draft: "2020-12" | "7", formats: boolean): AjvInstance {
|
|
const options = { strict: false, validateFormats: formats, logger: false as const };
|
|
const ajv = draft === "7" ? new Ajv7(options) : new Ajv2020(options);
|
|
if (draft !== "7") ajv.addMetaSchema(draft07MetaSchema);
|
|
if (formats) addFormats(ajv);
|
|
return ajv;
|
|
}
|
|
|
|
const sharedAjv = new Map<string, AjvInstance>();
|
|
|
|
// Ajv compiles with new Function and has no interpreter: without eval, compile() throws EvalError.
|
|
const ajv: Candidate = {
|
|
name: noEval ? "ajv (no eval: fails)" : "ajv",
|
|
prepare(schema, options) {
|
|
let instance: AjvInstance;
|
|
if (options.schemas === undefined) {
|
|
const key = `${options.draft} ${options.formats !== undefined}`;
|
|
instance = sharedAjv.get(key) ?? newAjv(options.draft, options.formats !== undefined);
|
|
sharedAjv.set(key, instance);
|
|
} else {
|
|
// The suite reuses $ids across groups, which one shared instance would reject: a fresh one per schema.
|
|
instance = newAjv(options.draft, options.formats !== undefined);
|
|
for (const [uri, remote] of Object.entries(options.schemas)) {
|
|
// Ajv ships its own meta-schemas; adding them again under the same id throws.
|
|
if (instance.getSchema(uri) === undefined) {
|
|
try {
|
|
instance.addSchema(remote as object, uri);
|
|
} catch {
|
|
// A remote whose $id Ajv already registered under another key.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const validator = instance.compile(schema as object);
|
|
return { check: (v) => validator(v) as boolean, validator };
|
|
},
|
|
};
|
|
|
|
// ata-validator: its defaults fill `default` values into the input and assert `format`; both are turned off to match
|
|
// the other candidates (format checks only in the format run, with its own checks). It reads the draft only from
|
|
// $schema, so a draft 7 schema without one gets it declared.
|
|
function ataValidator(schema: JsonSchema, options: PrepareOptions, engine: "interpreter" | undefined) {
|
|
const declared =
|
|
options.draft === "7" && typeof schema === "object" && !("$schema" in schema)
|
|
? { $schema: "http://json-schema.org/draft-07/schema#", ...schema }
|
|
: schema;
|
|
return new AtaValidator(declared, {
|
|
useDefaults: false,
|
|
assertFormat: options.formats !== undefined,
|
|
schemas: (options.schemas ?? {}) as Record<string, object>,
|
|
logger: false,
|
|
...(engine === undefined ? {} : { engine }),
|
|
});
|
|
}
|
|
|
|
// Generated code, or its interpreted engine where code generation is blocked.
|
|
const ata: Candidate = {
|
|
name: noEval ? "ata (no eval: interpreter)" : "ata",
|
|
prepare(schema, options) {
|
|
const validator = ataValidator(schema, options, undefined);
|
|
return { check: (v) => validator.isValidObject(v), validator };
|
|
},
|
|
};
|
|
|
|
// Its eval-free engine, a tree of closures, chosen explicitly: what it runs on for schemas from outside.
|
|
const ataInterpreter: Candidate = {
|
|
name: "ata interpreter",
|
|
prepare(schema, options) {
|
|
const validator = ataValidator(schema, options, "interpreter");
|
|
return { check: (v) => validator.isValidObject(v), validator };
|
|
},
|
|
};
|
|
|
|
// Zod: native Zod schemas for the speed cases and the corpus, z.fromJSONSchema only for the suite. Its object parser
|
|
// generates code with new Function when it can and parses without it otherwise; safeParse is its boolean path.
|
|
const zod: Candidate = {
|
|
name: noEval ? "zod (no eval: no JIT)" : "zod",
|
|
prepare(schema, options) {
|
|
const validator = options.suite
|
|
? zodFromJsonSchema(schema, options.draft)
|
|
: (nativeCases.get(schema) ?? nativeZod(schema as Record<string, unknown>));
|
|
return { check: (v) => validator.safeParse(v).success, validator };
|
|
},
|
|
};
|
|
|
|
/** Every candidate, ours first. */
|
|
export const candidates: readonly Candidate[] = [piSchema, typebox, ajv, ata, ataInterpreter, zod, cfworker, omptype];
|
|
|
|
/** Candidates by name, including TypeBox's interpreter (what TypeBox runs when eval is unavailable). */
|
|
export const byName: ReadonlyMap<string, Candidate> = new Map(
|
|
[...candidates, typeboxInterpreter].map((c) => [c.name.split(" (")[0], c]),
|
|
);
|