diff --git a/README.md b/README.md index 1215f53dc..df205f44e 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,8 @@ Supports JSON Schema draft-06/07/2019-09 (draft-04 is supported in [version 6](h Ajv version 7 has these new features: +- NEW: support of JSON Type Definition [RFC8927](https://datatracker.ietf.org/doc/rfc8927/) (from [v7.1.0](https://github.com/ajv-validator/ajv-keywords/releases/tag/v7.1.0)), including generation of [serializers](./docs/api.md#jtd-serialize) and [parsers](./docs/api.md#jtd-parse) from JTD schemas that are more efficient than native JSON serialization/parsing, combining JSON string parsing and validation in one function. - support of JSON Schema draft-2019-09 features: [`unevaluatedProperties`](./docs/json-schema.md#unevaluatedproperties) and [`unevaluatedItems`](./docs/json-schema.md#unevaluateditems), [dynamic recursive references](./docs/validation.md#extending-recursive-schemas) and other [additional keywords](./docs/json-schema.md#json-schema-draft-2019-09). -- NEW: support of JSON Type Definition [RFC8927](https://datatracker.ietf.org/doc/rfc8927/) (from [v7.1.0](https://github.com/ajv-validator/ajv-keywords/releases/tag/v7.1.0)) - to reduce the mistakes in JSON schemas and unexpected validation results, [strict mode](./docs/strict-mode.md) is added - it prohibits ignored or ambiguous JSON Schema elements. - to make code injection from untrusted schemas impossible, [code generation](./docs/codegen.md) is fully re-written to be safe and to allow code optimization (compiled schema code size is reduced by more than 10%). - to simplify Ajv extensions, the new keyword API that is used by pre-defined keywords is available to user-defined keywords - it is much easier to define any keywords now, especially with subschemas. [ajv-keywords](https://github.com/ajv-validator/ajv-keywords) package was updated to use the new API (in [v4.0.0](https://github.com/ajv-validator/ajv-keywords/releases/tag/v4.0.0)) diff --git a/benchmark/jtd.js b/benchmark/jtd.js new file mode 100644 index 000000000..88c2a5ae2 --- /dev/null +++ b/benchmark/jtd.js @@ -0,0 +1,108 @@ +/* eslint-disable no-empty */ +/* eslint-disable no-console */ +const Ajv = require("ajv/dist/jtd").default +const Benchmark = require("benchmark") +const jtdValidationTests = require("../spec/json-typedef-spec/tests/validation.json") + +const ajv = new Ajv() +const suite = new Benchmark.Suite() +const tests = [] + +for (const testName in jtdValidationTests) { + const {schema, instance, errors} = jtdValidationTests[testName] + const valid = errors.length === 0 + if (!valid) continue + tests.push({ + serialize: ajv.compileSerializer(schema), + parse: ajv.compileParser(schema), + data: instance, + json: JSON.stringify(instance), + }) +} + +// suite.add("JTD test suite: compiled JTD serializers", () => { +// for (const test of tests) { +// test.serialize(test.data) +// } +// }) + +// suite.add("JTD test suite: JSON.stringify", () => { +// for (const test of tests) { +// JSON.stringify(test.data) +// } +// }) + +const testSchema = { + definitions: { + obj: { + properties: { + foo: {type: "string"}, + bar: {type: "int8"}, + }, + }, + }, + properties: { + a: {ref: "obj"}, + }, + optionalProperties: { + b: {ref: "obj"}, + }, +} + +const testData = { + a: { + foo: "foo1", + bar: 1, + }, + b: { + foo: "foo2", + bar: 2, + }, +} + +// const serializer = ajv.compileSerializer(testSchema) + +// suite.add("test data: compiled JTD serializer", () => serializer(testData)) +// suite.add("test data: JSON.stringify", () => JSON.stringify(testData)) + +suite.add("JTD test suite: compiled JTD parsers", () => { + for (const test of tests) { + test.parse(test.json) + } +}) + +suite.add("JTD test suite: JSON.parse", () => { + for (const test of tests) { + JSON.parse(test.json) + } +}) + +const validTestData = JSON.stringify(testData) + +const invalidTestData = JSON.stringify({ + a: { + foo: "foo1", + bar: "1", + }, + b: { + foo: "foo2", + bar: 2, + }, +}) + +const parse = ajv.compileParser(testSchema) + +suite.add("valid test data: compiled JTD parser", () => parse(validTestData)) +suite.add("valid test data: JSON.parse", () => JSON.parse(validTestData)) +suite.add("invalid test data: compiled JTD parser", () => parse(invalidTestData)) +suite.add("invalid test data: JSON.parse", () => JSON.parse(invalidTestData)) + +console.log() + +suite + .on("cycle", (event) => console.log(String(event.target))) + .on("complete", function () { + // eslint-disable-next-line no-invalid-this + console.log('The fastest is "' + this.filter("fastest").map("name") + '"') + }) + .run({async: true}) diff --git a/benchmark/package.json b/benchmark/package.json new file mode 100644 index 000000000..2fd10970a --- /dev/null +++ b/benchmark/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "devDependencies": { + "benchmark": "^2.1.4" + } +} diff --git a/docs/api.md b/docs/api.md index c6692972c..862941ddc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -50,6 +50,69 @@ if (validate(data)) { See more advanced example in [the test](../spec/types/json-schema.spec.ts). +#### ajv.compileSerializer(schema: object): (data: any) =\> string (NEW) + +Generate serializing function based on the [JTD schema](./json-type-definition.md) (caches the schema) - only in JTD instance of Ajv (see example below). + +Serializers compiled from JTD schemas can be more than 10 times faster than using `JSON.stringify`, because they do not traverse all the data, only the properties that are defined in the schema. + +Properties not defined in the schema will not be included in serialized JSON, unless the schema has `additionalProperties: true` flag. It can also be beneficial from the application security point of view, as it prevents leaking accidentally/temporarily added additional properties to the API responses. + +If you use JTD with typescript, the type for the schema can be derived from the data type, and generated serializer would only accept correct data type in this case: + +```typescript +import Ajv, {JTDSchemaType} from "ajv/dist/jtd" +const ajv = new Ajv() + +interface MyData = { + foo: number + bar?: string +} + +const mySchema: JTDSchemaType = { + properties: { + foo: {type: "int32"} // any JTD number type would be accepted here + }, + optionalProperties: { + bar: {type: "string"} + } +} + +const serializeMyData = ajv.compileSerializer(mySchema) + +// serializeMyData has type (x: MyData) => string +// it prevents you from accidentally passing the wrong type +``` + +**Please note**: Compiled serializers do NOT validate passed data, it is assumed that the data is valid according to the schema. In the future there may be an option added that would make serializers also validate the data. + +#### ajv.compileParser(schema: object): (json: string) =\> any (NEW) + +Generate parsing function based on the [JTD schema](./json-type-definition.md) (caches the schema) - only in JTD instance of Ajv (see example below). + +Parsers compiled from JTD schemas have comparable performance to `JSON.parse`* in case JSON string is valid according to the schema (and they do not just parse JSON - they ensure that parsed JSON is valid according to the schema as they parse), but they can be many times faster in case the string is invalid - for example, if schema expects an object, and JSON string is array the parser would fail on the first character. + +Parsing will fail if there are properties not defined in the schema, unless the schema has `additionalProperties: true` flag. + +If you use JTD with typescript, the type for the schema can be derived from the data type, and generated parser will return correct data type (see definitions example in the [serialize](#jtd-serialize) section): + +```typescript +const parseMyData = ajv.compileParser(mySchema) + +// parseMyData has type (s: string) => MyData | undefined +// it returns correct data type in case parsing is successful and undefined if not + +const validData = parseMyData('{"foo":1}') // {foo: 1} - success + +const invalidData = parseMyData('{"x":1}') // undefined - failure +console.log(parseMyData.position) // 4 +console.log(parseMyData.message) // property x not allowed +``` + +**Please note**: generated parsers is a NEW Ajv functionality (as of March 2021), there can be some edge cases that are not handled correctly - please report any issues/submit fixes. + +* As long as empty schema `{}` is not used - there is a possibility to improve performance in this case. Also, the performance of parsing `discriminator` schemas depends on the position of discriminator tag in the schema - the best parsing performance will be achieved if the tag is the first property - this is how compiled JTD serializers generate JSON in case of discriminator schemas. + #### ajv.compileAsync(schema: object, meta?: boolean): Promise\ Asynchronous version of `compile` method that loads missing remote schemas using asynchronous function in `options.loadSchema`. This function returns a Promise that resolves to a validation function. An optional callback passed to `compileAsync` will be called with 2 parameters: error (or null) and validating function. The returned promise will reject (and the callback will be called with an error) when: diff --git a/lib/compile/codegen/index.ts b/lib/compile/codegen/index.ts index 0957ca9a7..e33de346b 100644 --- a/lib/compile/codegen/index.ts +++ b/lib/compile/codegen/index.ts @@ -21,6 +21,7 @@ export const operators = { NOT: new _Code("!"), OR: new _Code("||"), AND: new _Code("&&"), + ADD: new _Code("+"), } abstract class Node { @@ -62,11 +63,7 @@ class Def extends Node { } class Assign extends Node { - constructor( - private readonly lhs: Code, - private rhs: SafeExpr, - private readonly sideEffects?: boolean - ) { + constructor(readonly lhs: Code, public rhs: SafeExpr, private readonly sideEffects?: boolean) { super() } @@ -86,6 +83,16 @@ class Assign extends Node { } } +class AssignOp extends Assign { + constructor(lhs: Code, private readonly op: Code, rhs: SafeExpr, sideEffects?: boolean) { + super(lhs, rhs, sideEffects) + } + + render({_n}: CGOptions): string { + return `${this.lhs} ${this.op}= ${this.rhs};` + _n + } +} + class Label extends Node { readonly names: UsedNames = {} constructor(readonly label: Name) { @@ -508,6 +515,11 @@ export class CodeGen { return this._leafNode(new Assign(lhs, rhs, sideEffects)) } + // `+=` code + add(lhs: Code, rhs: SafeExpr): CodeGen { + return this._leafNode(new AssignOp(lhs, operators.ADD, rhs)) + } + // appends passed SafeExpr to code or executes Block code(c: Block | SafeExpr): CodeGen { if (typeof c == "function") c() diff --git a/lib/compile/index.ts b/lib/compile/index.ts index d3d138ee0..44e081d1a 100644 --- a/lib/compile/index.ts +++ b/lib/compile/index.ts @@ -79,6 +79,10 @@ export class SchemaEnv implements SchemaEnvArgs { readonly dynamicAnchors: {[Ref in string]?: true} = {} validate?: AnyValidateFunction validateName?: ValueScopeName + serialize?: (data: unknown) => string + serializeName?: ValueScopeName + parse?: (data: string) => unknown + parseName?: ValueScopeName constructor(env: SchemaEnvArgs) { let schema: AnySchemaObject | undefined @@ -216,7 +220,7 @@ function inlineOrCompile(this: Ajv, sch: SchemaEnv): AnySchema | SchemaEnv { } // Index of schema compilation in the currently compiled list -function getCompilingSchema(this: Ajv, schEnv: SchemaEnv): SchemaEnv | void { +export function getCompilingSchema(this: Ajv, schEnv: SchemaEnv): SchemaEnv | void { for (const sch of this._compilations) { if (sameSchemaEnv(sch, schEnv)) return sch } diff --git a/lib/compile/jtd/parse.ts b/lib/compile/jtd/parse.ts new file mode 100644 index 000000000..f2f2fa0b9 --- /dev/null +++ b/lib/compile/jtd/parse.ts @@ -0,0 +1,431 @@ +import type Ajv from "../../core" +import type {SchemaObject} from "../../types" +import {jtdForms, JTDForm, SchemaObjectMap} from "./types" +import {SchemaEnv, getCompilingSchema} from ".." +import {_, str, and, nil, not, CodeGen, Code, Name, SafeExpr} from "../codegen" +import {MissingRefError} from "../error_classes" +import N from "../names" +import {isOwnProperty, hasPropFunc} from "../../vocabularies/code" +import {hasRef} from "../../vocabularies/jtd/ref" +import {intRange, IntType} from "../../vocabularies/jtd/type" +import {parseJson, parseJsonNumber, parseJsonString} from "../../runtime/parseJson" +import {func} from "../util" +import validTimestamp from "../timestamp" + +type GenParse = (cxt: ParseCxt) => void + +const genParse: {[F in JTDForm]: GenParse} = { + elements: parseElements, + values: parseValues, + discriminator: parseDiscriminator, + properties: parseProperties, + optionalProperties: parseProperties, + enum: parseEnum, + type: parseType, + ref: parseRef, +} + +interface ParseCxt { + readonly gen: CodeGen + readonly self: Ajv // current Ajv instance + readonly schemaEnv: SchemaEnv + readonly definitions: SchemaObjectMap + schema: SchemaObject + data: Code + parseName: Name + char: Name +} + +export default function compileParser( + this: Ajv, + sch: SchemaEnv, + definitions: SchemaObjectMap +): SchemaEnv { + const _sch = getCompilingSchema.call(this, sch) + if (_sch) return _sch + const {es5, lines} = this.opts.code + const {ownProperties} = this.opts + const gen = new CodeGen(this.scope, {es5, lines, ownProperties}) + const parseName = gen.scopeName("parse") + const cxt: ParseCxt = { + self: this, + gen, + schema: sch.schema as SchemaObject, + schemaEnv: sch, + definitions, + data: N.data, + parseName, + char: gen.name("c"), + } + + let sourceCode: string | undefined + try { + this._compilations.add(sch) + sch.parseName = parseName + parserFunction(cxt) + gen.optimize(this.opts.code.optimize) + const parseFuncCode = gen.toString() + sourceCode = `${gen.scopeRefs(N.scope)}return ${parseFuncCode}` + const makeParse = new Function(`${N.scope}`, sourceCode) + const parse: (json: string) => unknown = makeParse(this.scope.get()) + this.scope.value(parseName, {ref: parse}) + sch.parse = parse + } catch (e) { + if (sourceCode) this.logger.error("Error compiling parser, function code:", sourceCode) + delete sch.parse + delete sch.parseName + throw e + } finally { + this._compilations.delete(sch) + } + return sch +} + +const undef = _`undefined` + +function parserFunction(cxt: ParseCxt): void { + const {gen, parseName, char} = cxt + gen.func(parseName, _`${N.json}, ${N.jsonPos}, ${N.jsonPart}`, false, () => { + gen.let(N.data) + gen.let(char) + gen.assign(_`${parseName}.message`, undef) + gen.assign(_`${parseName}.position`, undef) + gen.assign(N.jsonPos, _`${N.jsonPos} || 0`) + gen.const(N.jsonLen, _`${N.json}.length`) + parseCode(cxt) + skipWhitespace(cxt) + gen.if(N.jsonPart, () => { + gen.assign(_`${parseName}.position`, N.jsonPos) + gen.return(N.data) + }) + gen.if(_`${N.jsonPos} === ${N.jsonLen}`, () => gen.return(N.data)) + jsonSyntaxError(cxt) + }) +} + +function parseCode(cxt: ParseCxt): void { + let form: JTDForm | undefined + for (const key of jtdForms) { + if (key in cxt.schema) { + form = key + break + } + } + if (form) parseNullable(cxt, genParse[form]) + else parseEmpty(cxt) +} + +const parseBoolean = parseBooleanToken(true, parseBooleanToken(false, jsonSyntaxError)) + +// function parseEmptyCode(cxt: ParseCxt): void { +// const {gen, data, char: c} = cxt +// skipWhitespace(cxt) +// gen.assign(c, _`${N.json}[${N.jsonPos}]`) +// gen.if(_`${c} === "t" || ${c} === "f"`) +// parseBoolean(cxt) +// gen.elseIf(_`${c} === "n"`) +// tryParseToken(cxt, "null", jsonSyntaxError, () => gen.assign(data, null)) +// gen.elseIf(_`${c} === '"'`) +// parseString(cxt) +// gen.elseIf(_`${c} === "["`) +// parseElements({...cxt, schema: {elements: {}}}) +// gen.elseIf(_`${c} === "{"`) +// parseValues({...cxt, schema: {values: {}}}) +// gen.else() +// parseNumber(cxt) +// gen.endIf() +// skipWhitespace(cxt) +// } + +function parseNullable(cxt: ParseCxt, parseForm: GenParse): void { + const {gen, schema, data} = cxt + if (!schema.nullable) return parseForm(cxt) + tryParseToken(cxt, "null", parseForm, () => gen.assign(data, null)) +} + +function parseElements(cxt: ParseCxt): void { + const {gen, schema, data} = cxt + parseToken(cxt, "[") + const ix = gen.let("i", 0) + gen.assign(data, _`[]`) + parseItems(cxt, "]", () => { + const el = gen.let("el") + parseCode({...cxt, schema: schema.elements, data: el}) + gen.assign(_`${data}[${ix}++]`, el) + }) +} + +function parseValues(cxt: ParseCxt): void { + const {gen, schema, data} = cxt + parseToken(cxt, "{") + gen.assign(data, _`{}`) + parseItems(cxt, "}", () => parseKeyValue(cxt, schema.values)) +} + +function parseItems(cxt: ParseCxt, endToken: string, block: () => void): void { + tryParseItems(cxt, endToken, block) + parseToken(cxt, endToken) +} + +function tryParseItems(cxt: ParseCxt, endToken: string, block: () => void): void { + const {gen} = cxt + gen.for(_`;${N.jsonPos}<${N.jsonLen} && ${jsonSlice(1)}!==${endToken};`, () => { + block() + tryParseToken(cxt, ",", () => gen.break()) + }) +} + +function parseKeyValue(cxt: ParseCxt, schema: SchemaObject): void { + const {gen} = cxt + const key = gen.let("key") + parseString({...cxt, data: key}) + checkDuplicateProperty(cxt, key) + parseToken(cxt, ":") + parsePropertyValue(cxt, key, schema) +} + +function parseDiscriminator(cxt: ParseCxt): void { + const {gen, data, schema} = cxt + const {discriminator, mapping} = schema + parseToken(cxt, "{") + gen.assign(data, _`{}`) + const startPos = gen.const("pos", N.jsonPos) + const value = gen.let("value") + const tag = gen.let("tag") + tryParseItems(cxt, "}", () => { + const key = gen.let("key") + parseString({...cxt, data: key}) + parseToken(cxt, ":") + gen.if( + _`${key} === ${discriminator}`, + () => { + parseString({...cxt, data: tag}) + gen.assign(_`${data}[${key}]`, tag) + gen.break() + }, + () => parseEmpty({...cxt, data: value}) // can be discarded/skipped + ) + }) + gen.assign(N.jsonPos, startPos) + gen.if(_`${tag} === undefined`) + parsingError(cxt, str`discriminator tag not found`) + for (const tagValue in mapping) { + gen.elseIf(_`${tag} === ${tagValue}`) + parseSchemaProperties({...cxt, schema: mapping[tagValue]}, discriminator) + } + gen.else() + parsingError(cxt, str`discriminator value not in schema`) + gen.endIf() +} + +function parseProperties(cxt: ParseCxt): void { + const {gen, data} = cxt + parseToken(cxt, "{") + gen.assign(data, _`{}`) + parseSchemaProperties(cxt) +} + +function parseSchemaProperties(cxt: ParseCxt, discriminator?: string): void { + const {gen, schema, data} = cxt + const {properties, optionalProperties, additionalProperties} = schema + parseItems(cxt, "}", () => { + const key = gen.let("key") + parseString({...cxt, data: key}) + if (discriminator) { + gen.if(_`${key} !== ${discriminator}`, () => checkDuplicateProperty(cxt, key)) + } else { + checkDuplicateProperty(cxt, key) + } + parseToken(cxt, ":") + gen.if(false) + parseDefinedProperty(cxt, key, properties) + parseDefinedProperty(cxt, key, optionalProperties) + if (discriminator) { + gen.elseIf(_`${key} === ${discriminator}`) + const tag = gen.let("tag") + parseString({...cxt, data: tag}) // can be discarded, it is already assigned + } + gen.else() + if (additionalProperties) { + parseEmpty({...cxt, data: _`${data}[${key}]`}) + } else { + parsingError(cxt, str`property ${key} not allowed`) + } + gen.endIf() + }) + if (properties) { + const hasProp = hasPropFunc(gen) + const allProps: Code = and( + ...Object.keys(properties).map((p): Code => _`${hasProp}.call(${data}, ${p})`) + ) + gen.if(not(allProps), () => parsingError(cxt, str`missing required properties`)) + } +} + +function parseDefinedProperty(cxt: ParseCxt, key: Name, schemas: SchemaObjectMap = {}): void { + const {gen} = cxt + for (const prop in schemas) { + gen.elseIf(_`${key} === ${prop}`) + parsePropertyValue(cxt, key, schemas[prop] as SchemaObject) + } +} + +function checkDuplicateProperty({gen, data}: ParseCxt, key: Name): void { + gen.if(isOwnProperty(gen, data, key), () => + gen.throw(_`new Error("JSON: duplicate property " + ${key})`) + ) +} + +function parsePropertyValue(cxt: ParseCxt, key: Name, schema: SchemaObject): void { + parseCode({...cxt, schema, data: _`${cxt.data}[${key}]`}) +} + +function parseType(cxt: ParseCxt): void { + const {gen, schema, data} = cxt + switch (schema.type) { + case "boolean": + parseBoolean(cxt) + break + case "string": + parseString(cxt) + break + case "timestamp": { + // TODO parse timestamp? + parseString(cxt) + const vts = func(gen, validTimestamp) + gen.if(_`!${vts}(${data})`, () => parsingError(cxt, str`invalid timestamp`)) + break + } + case "float32": + case "float64": + parseNumber(cxt) + break + default: { + const [min, max, maxDigits] = intRange[schema.type as IntType] + parseNumber(cxt, maxDigits) + gen.if(_`${data} < ${min} || ${data} > ${max}`, () => + parsingError(cxt, str`integer out of range`) + ) + } + } +} + +function parseString(cxt: ParseCxt): void { + parseToken(cxt, '"') + parseWith(cxt, parseJsonString) +} + +function parseEnum(cxt: ParseCxt): void { + const {gen, data, schema} = cxt + const enumSch = schema.enum + parseToken(cxt, '"') + // TODO loopEnum + gen.if(false) + for (const value of enumSch) { + const valueStr = JSON.stringify(value).slice(1) // remove starting quote + gen.elseIf(_`${jsonSlice(valueStr.length)} === ${valueStr}`) + gen.assign(data, str`${value}`) + gen.add(N.jsonPos, valueStr.length) + } + gen.else() + jsonSyntaxError(cxt) + gen.endIf() +} + +function parseNumber(cxt: ParseCxt, maxDigits?: number): void { + const {gen} = cxt + skipWhitespace(cxt) + gen.if( + _`"-0123456789".indexOf(${jsonSlice(1)}) < 0`, + () => jsonSyntaxError(cxt), + () => parseWith(cxt, parseJsonNumber, maxDigits) + ) +} + +function parseBooleanToken(bool: boolean, fail: GenParse): GenParse { + return (cxt) => { + const {gen, data} = cxt + tryParseToken( + cxt, + `${bool}`, + () => fail(cxt), + () => gen.assign(data, bool) + ) + } +} + +function parseRef(cxt: ParseCxt): void { + const {gen, self, definitions, schema, schemaEnv} = cxt + const {ref} = schema + const refSchema = definitions[ref] + if (!refSchema) throw new MissingRefError("", ref, `No definition ${ref}`) + if (!hasRef(refSchema)) return parseCode({...cxt, schema: refSchema}) + const {root} = schemaEnv + const sch = compileParser.call(self, new SchemaEnv({schema: refSchema, root}), definitions) + partialParse(cxt, getParser(gen, sch), true) +} + +function getParser(gen: CodeGen, sch: SchemaEnv): Code { + return sch.parse + ? gen.scopeValue("parse", {ref: sch.parse}) + : _`${gen.scopeValue("wrapper", {ref: sch})}.parse` +} + +function parseEmpty(cxt: ParseCxt): void { + parseWith(cxt, parseJson) +} + +function parseWith(cxt: ParseCxt, parseFunc: {code: Code}, args?: SafeExpr): void { + const f = cxt.gen.scopeValue("func", { + ref: parseFunc, + code: parseFunc.code, + }) + partialParse(cxt, f, args) +} + +function partialParse(cxt: ParseCxt, parseFunc: Name, args?: SafeExpr): void { + const {gen, data} = cxt + gen.assign(data, _`${parseFunc}(${N.json}, ${N.jsonPos}${args ? _`, ${args}` : nil})`) + gen.assign(N.jsonPos, _`${parseFunc}.position`) + gen.if(_`${data} === undefined`, () => parsingError(cxt, _`${parseFunc}.message`)) +} + +function parseToken(cxt: ParseCxt, tok: string): void { + tryParseToken(cxt, tok, jsonSyntaxError) +} + +function tryParseToken(cxt: ParseCxt, tok: string, fail: GenParse, success?: GenParse): void { + const {gen} = cxt + const n = tok.length + skipWhitespace(cxt) + gen.if( + _`${jsonSlice(n)} === ${tok}`, + () => { + gen.add(N.jsonPos, n) + success?.(cxt) + }, + () => fail(cxt) + ) +} + +function skipWhitespace({gen, char: c}: ParseCxt): void { + gen.code( + _`while((${c}=${N.json}[${N.jsonPos}],${c}===" "||${c}==="\\n"||${c}==="\\r"||${c}==="\\t"))${N.jsonPos}++;` + ) +} + +function jsonSlice(len: number | Name): Code { + return len === 1 + ? _`${N.json}[${N.jsonPos}]` + : _`${N.json}.slice(${N.jsonPos}, ${N.jsonPos}+${len})` +} + +function jsonSyntaxError(cxt: ParseCxt): void { + parsingError(cxt, _`"unexpected token " + ${N.json}[${N.jsonPos}]`) +} + +function parsingError({gen, parseName}: ParseCxt, msg: Code): void { + gen.assign(_`${parseName}.message`, msg) + gen.assign(_`${parseName}.position`, N.jsonPos) + gen.return(undef) +} diff --git a/lib/compile/jtd/serialize.ts b/lib/compile/jtd/serialize.ts new file mode 100644 index 000000000..63b253dd4 --- /dev/null +++ b/lib/compile/jtd/serialize.ts @@ -0,0 +1,266 @@ +import type Ajv from "../../core" +import type {SchemaObject} from "../../types" +import {jtdForms, JTDForm, SchemaObjectMap} from "./types" +import {SchemaEnv, getCompilingSchema} from ".." +import {_, str, and, getProperty, CodeGen, Code, Name} from "../codegen" +import {MissingRefError} from "../error_classes" +import N from "../names" +import {isOwnProperty} from "../../vocabularies/code" +import {hasRef} from "../../vocabularies/jtd/ref" +import quote from "../../runtime/quote" + +const genSerialize: {[F in JTDForm]: (cxt: SerializeCxt) => void} = { + elements: serializeElements, + values: serializeValues, + discriminator: serializeDiscriminator, + properties: serializeProperties, + optionalProperties: serializeProperties, + enum: serializeString, + type: serializeType, + ref: serializeRef, +} + +interface SerializeCxt { + readonly gen: CodeGen + readonly self: Ajv // current Ajv instance + readonly schemaEnv: SchemaEnv + readonly definitions: SchemaObjectMap + schema: SchemaObject + data: Code +} + +export default function compileSerializer( + this: Ajv, + sch: SchemaEnv, + definitions: SchemaObjectMap +): SchemaEnv { + const _sch = getCompilingSchema.call(this, sch) + if (_sch) return _sch + const {es5, lines} = this.opts.code + const {ownProperties} = this.opts + const gen = new CodeGen(this.scope, {es5, lines, ownProperties}) + const serializeName = gen.scopeName("serialize") + const cxt: SerializeCxt = { + self: this, + gen, + schema: sch.schema as SchemaObject, + schemaEnv: sch, + definitions, + data: N.data, + } + + let sourceCode: string | undefined + try { + this._compilations.add(sch) + sch.serializeName = serializeName + gen.func(serializeName, N.data, false, () => { + gen.let(N.json, str``) + serializeCode(cxt) + gen.return(N.json) + }) + gen.optimize(this.opts.code.optimize) + const serializeFuncCode = gen.toString() + sourceCode = `${gen.scopeRefs(N.scope)}return ${serializeFuncCode}` + const makeSerialize = new Function(`${N.scope}`, sourceCode) + const serialize: (data: unknown) => string = makeSerialize(this.scope.get()) + this.scope.value(serializeName, {ref: serialize}) + sch.serialize = serialize + } catch (e) { + if (sourceCode) this.logger.error("Error compiling serializer, function code:", sourceCode) + delete sch.serialize + delete sch.serializeName + throw e + } finally { + this._compilations.delete(sch) + } + return sch +} + +function serializeCode(cxt: SerializeCxt): void { + let form: JTDForm | undefined + for (const key of jtdForms) { + if (key in cxt.schema) { + form = key + break + } + } + serializeNullable(cxt, form ? genSerialize[form] : serializeEmpty) +} + +function serializeNullable(cxt: SerializeCxt, serializeForm: (_cxt: SerializeCxt) => void): void { + const {gen, schema, data} = cxt + if (!schema.nullable) return serializeForm(cxt) + gen.if( + _`${data} === undefined || ${data} === null`, + () => gen.add(N.json, _`"null"`), + () => serializeForm(cxt) + ) +} + +function serializeElements(cxt: SerializeCxt): void { + const {gen, schema, data} = cxt + gen.add(N.json, str`[`) + const first = gen.let("first", true) + gen.forOf("el", data, (el) => { + addComma(cxt, first) + serializeCode({...cxt, schema: schema.elements, data: el}) + }) + gen.add(N.json, str`]`) +} + +function serializeValues(cxt: SerializeCxt): void { + const {gen, schema, data} = cxt + gen.add(N.json, str`{`) + const first = gen.let("first", true) + gen.forIn("key", data, (key) => serializeKeyValue(cxt, key, schema.values, first)) + gen.add(N.json, str`}`) +} + +function serializeKeyValue(cxt: SerializeCxt, key: Name, schema: SchemaObject, first: Name): void { + const {gen, data} = cxt + addComma(cxt, first) + serializeString({...cxt, data: key}) + gen.add(N.json, str`:`) + const value = gen.const("value", _`${data}${getProperty(key)}`) + serializeCode({...cxt, schema, data: value}) +} + +function serializeDiscriminator(cxt: SerializeCxt): void { + const {gen, schema, data} = cxt + const {discriminator} = schema + gen.add(N.json, str`{${JSON.stringify(discriminator)}:`) + const tag = gen.const("tag", _`${data}${getProperty(discriminator)}`) + serializeString({...cxt, data: tag}) + gen.if(false) + for (const tagValue in schema.mapping) { + gen.elseIf(_`${tag} === ${tagValue}`) + const sch = schema.mapping[tagValue] + serializeSchemaProperties({...cxt, schema: sch}, discriminator) + } + gen.endIf() + gen.add(N.json, str`}`) +} + +function serializeProperties(cxt: SerializeCxt): void { + const {gen} = cxt + gen.add(N.json, str`{`) + serializeSchemaProperties(cxt) + gen.add(N.json, str`}`) +} + +function serializeSchemaProperties(cxt: SerializeCxt, discriminator?: string): void { + const {gen, schema, data} = cxt + const {properties, optionalProperties} = schema + const props = keys(properties) + const optProps = keys(optionalProperties) + const allProps = allProperties(props.concat(optProps)) + let first = !discriminator + for (const key of props) { + serializeProperty(key, properties[key], keyValue(key)) + } + for (const key of optProps) { + const value = keyValue(key) + gen.if(and(_`${value} !== undefined`, isOwnProperty(gen, data, key)), () => + serializeProperty(key, optionalProperties[key], value) + ) + } + if (schema.additionalProperties) { + gen.forIn("key", data, (key) => + gen.if(isAdditional(key, allProps), () => + serializeKeyValue(cxt, key, {}, gen.let("first", first)) + ) + ) + } + + function keys(ps?: SchemaObjectMap): string[] { + return ps ? Object.keys(ps) : [] + } + + function allProperties(ps: string[]): string[] { + if (discriminator) ps.push(discriminator) + if (new Set(ps).size !== ps.length) { + throw new Error("JTD: properties/optionalProperties/disciminator overlap") + } + return ps + } + + function keyValue(key: string): Name { + return gen.const("value", _`${data}${getProperty(key)}`) + } + + function serializeProperty(key: string, propSchema: SchemaObject, value: Name): void { + if (first) first = false + else gen.add(N.json, str`,`) + gen.add(N.json, str`${JSON.stringify(key)}:`) + serializeCode({...cxt, schema: propSchema, data: value}) + } + + function isAdditional(key: Name, ps: string[]): Code | true { + return ps.length ? and(...ps.map((p) => _`${key} !== ${p}`)) : true + } +} + +function serializeType(cxt: SerializeCxt): void { + const {gen, schema, data} = cxt + switch (schema.type) { + case "boolean": + gen.add(N.json, _`${data} ? "true" : "false"`) + break + case "string": + serializeString(cxt) + break + case "timestamp": + gen.if( + _`${data} instanceof Date`, + () => gen.add(N.json, _`${data}.toISOString()`), + () => serializeString(cxt) + ) + break + default: + serializeNumber(cxt) + } +} + +function serializeString({gen, data}: SerializeCxt): void { + gen.add(N.json, _`${quoteFunc(gen)}(${data})`) +} + +function serializeNumber({gen, data}: SerializeCxt): void { + gen.add(N.json, _`"" + ${data}`) +} + +function serializeRef(cxt: SerializeCxt): void { + const {gen, self, data, definitions, schema, schemaEnv} = cxt + const {ref} = schema + const refSchema = definitions[ref] + if (!refSchema) throw new MissingRefError("", ref, `No definition ${ref}`) + if (!hasRef(refSchema)) return serializeCode({...cxt, schema: refSchema}) + const {root} = schemaEnv + const sch = compileSerializer.call(self, new SchemaEnv({schema: refSchema, root}), definitions) + gen.add(N.json, _`${getSerialize(gen, sch)}(${data})`) +} + +function getSerialize(gen: CodeGen, sch: SchemaEnv): Code { + return sch.serialize + ? gen.scopeValue("serialize", {ref: sch.serialize}) + : _`${gen.scopeValue("wrapper", {ref: sch})}.serialize` +} + +function serializeEmpty({gen, data}: SerializeCxt): void { + gen.add(N.json, _`JSON.stringify(${data})`) +} + +function addComma({gen}: SerializeCxt, first: Name): void { + gen.if( + first, + () => gen.assign(first, false), + () => gen.add(N.json, str`,`) + ) +} + +function quoteFunc(gen: CodeGen): Name { + return gen.scopeValue("func", { + ref: quote, + code: _`require("ajv/dist/runtime/quote").default`, + }) +} diff --git a/lib/compile/jtd/types.ts b/lib/compile/jtd/types.ts new file mode 100644 index 000000000..7f3619576 --- /dev/null +++ b/lib/compile/jtd/types.ts @@ -0,0 +1,16 @@ +import type {SchemaObject} from "../../types" + +export type SchemaObjectMap = {[Ref in string]?: SchemaObject} + +export const jtdForms = [ + "elements", + "values", + "discriminator", + "properties", + "optionalProperties", + "enum", + "type", + "ref", +] as const + +export type JTDForm = typeof jtdForms[number] diff --git a/lib/compile/names.ts b/lib/compile/names.ts index 325c80a2d..b7f18ca45 100644 --- a/lib/compile/names.ts +++ b/lib/compile/names.ts @@ -17,6 +17,11 @@ const names = { // "globals" self: new Name("self"), scope: new Name("scope"), + // JTD serialize/parse name for JSON string and position + json: new Name("json"), + jsonPos: new Name("jsonPos"), + jsonLen: new Name("jsonLen"), + jsonPart: new Name("jsonPart"), } export default names diff --git a/lib/compile/timestamp.ts b/lib/compile/timestamp.ts index 6543ba56e..0a16954a4 100644 --- a/lib/compile/timestamp.ts +++ b/lib/compile/timestamp.ts @@ -1,3 +1,5 @@ +import {_} from "./codegen" + const DATE_TIME = /^(\d\d\d\d)-(\d\d)-(\d\d)(?:t|\s)(\d\d):(\d\d):(\d\d)(?:\.\d+)?(?:z|([+-]\d\d)(?::?(\d\d))?)$/i const DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] @@ -25,3 +27,5 @@ export default function validTimestamp(str: string): boolean { (hr - tzH === 23 && min - tzM === 59 && sec === 60)) ) } + +validTimestamp.code = _`require("ajv/dist/compile/timestamp").default` diff --git a/lib/compile/util.ts b/lib/compile/util.ts index 329842093..e6a40468a 100644 --- a/lib/compile/util.ts +++ b/lib/compile/util.ts @@ -167,3 +167,10 @@ export function evaluatedPropsToName(gen: CodeGen, ps?: EvaluatedProperties): Na export function setEvaluated(gen: CodeGen, props: Name, ps: {[K in string]?: true}): void { Object.keys(ps).forEach((p) => gen.assign(_`${props}${getProperty(p)}`, true)) } + +export function func(gen: CodeGen, f: {code: Code}): Name { + return gen.scopeValue("func", { + ref: f, + code: f.code, + }) +} diff --git a/lib/core.ts b/lib/core.ts index 0bae07b53..00f4f2e4b 100644 --- a/lib/core.ts +++ b/lib/core.ts @@ -65,6 +65,8 @@ import * as $dataRefSchema from "./refs/data.json" const META_IGNORE_OPTIONS: (keyof Options)[] = ["removeAdditional", "useDefaults", "coerceTypes"] const EXT_SCOPE_NAMES = new Set([ "validate", + "serialize", + "parse", "wrapper", "root", "schema", @@ -624,7 +626,7 @@ export default class Ajv { } } - private _addSchema( + _addSchema( schema: AnySchema, meta?: boolean, validateSchema = this.opts.validateSchema, diff --git a/lib/jtd.ts b/lib/jtd.ts index 356b0bd8a..6a7439d46 100644 --- a/lib/jtd.ts +++ b/lib/jtd.ts @@ -17,6 +17,7 @@ export { AsyncValidateFunction, ErrorObject, ErrorNoParams, + JTDParser, } from "./types" export {Plugin, Options, CodeOptions, InstanceOptions, Logger, ErrorsTextOptions} from "./core" @@ -26,11 +27,15 @@ export {KeywordCxt} // export {DefinedError} from "./vocabularies/errors" export {_, str, stringify, nil, Name, Code, CodeGen, CodeGenOptions} from "./compile/codegen" -import type {AnySchemaObject} from "./types" -export {JTDSchemaType} from "./types/jtd-schema" +import type {AnySchemaObject, SchemaObject, JTDParser} from "./types" +import type {JTDSchemaType} from "./types/jtd-schema" +export {JTDSchemaType} import AjvCore, {CurrentOptions} from "./core" import jtdVocabulary from "./vocabularies/jtd" import jtdMetaSchema from "./refs/jtd-schema" +import compileSerializer from "./compile/jtd/serialize" +import compileParser from "./compile/jtd/parse" +import {SchemaEnv} from "./compile" // const META_SUPPORT_DATA = ["/properties"] @@ -88,4 +93,28 @@ export default class Ajv extends AjvCore { return (this.opts.defaultMeta = super.defaultMeta() || (this.getSchema(META_SCHEMA_ID) ? META_SCHEMA_ID : undefined)) } + + compileSerializer(schema: SchemaObject | JTDSchemaType): (data: T) => string { + const sch = this._addSchema(schema) + return sch.serialize || this._compileSerializer(sch) + } + + compileParser(schema: SchemaObject | JTDSchemaType): JTDParser { + const sch = this._addSchema(schema) + return (sch.parse || this._compileParser(sch)) as JTDParser + } + + private _compileSerializer(sch: SchemaEnv): (data: T) => string { + compileSerializer.call(this, sch, (sch.schema as AnySchemaObject).definitions || {}) + /* istanbul ignore if */ + if (!sch.serialize) throw new Error("ajv implementation error") + return sch.serialize + } + + private _compileParser(sch: SchemaEnv): JTDParser { + compileParser.call(this, sch, (sch.schema as AnySchemaObject).definitions || {}) + /* istanbul ignore if */ + if (!sch.parse) throw new Error("ajv implementation error") + return sch.parse + } } diff --git a/lib/runtime/parseJson.ts b/lib/runtime/parseJson.ts new file mode 100644 index 000000000..2539bbc73 --- /dev/null +++ b/lib/runtime/parseJson.ts @@ -0,0 +1,167 @@ +import {_} from "../compile/codegen" + +const rxParseJson = /position\s(\d+)$/ + +export function parseJson(s: string, pos: number): unknown { + let endPos: number | undefined + parseJson.message = undefined + let matches: RegExpExecArray | null + if (pos) s = s.slice(pos) + try { + parseJson.position = pos + s.length + return JSON.parse(s) + } catch (e) { + matches = rxParseJson.exec(e.message) + if (!matches) { + parseJson.message = "unexpected end" + return undefined + } + endPos = +matches[1] + s = s.slice(0, endPos) + parseJson.position = pos + endPos + try { + return JSON.parse(s) + } catch (e1) { + parseJson.message = `unexpected token ${s[endPos]}` + return undefined + } + } +} + +parseJson.message = undefined as string | undefined +parseJson.position = 0 as number +parseJson.code = _`require("ajv/dist/runtime/parseJson").parseJson` + +export function parseJsonNumber(s: string, pos: number, maxDigits?: number): number | undefined { + let numStr = "" + let c: string + parseJsonNumber.message = undefined + if (s[pos] === "-") { + numStr += "-" + pos++ + } + if (s[pos] === "0") { + numStr += "0" + pos++ + } else { + if (!parseDigits(maxDigits)) { + errorMessage() + return undefined + } + } + if (maxDigits) { + parseJsonNumber.position = pos + return +numStr + } + if (s[pos] === ".") { + numStr += "." + pos++ + if (!parseDigits()) { + errorMessage() + return undefined + } + } + if (((c = s[pos]), c === "e" || c === "E")) { + numStr += "e" + pos++ + if (((c = s[pos]), c === "+" || c === "-")) { + numStr += c + pos++ + } + if (!parseDigits()) { + errorMessage() + return undefined + } + } + parseJsonNumber.position = pos + return +numStr + + function parseDigits(maxLen?: number): boolean { + let digit = false + while (((c = s[pos]), c >= "0" && c <= "9" && (maxLen === undefined || maxLen-- > 0))) { + digit = true + numStr += c + pos++ + } + return digit + } + + function errorMessage(): void { + parseJson.message = pos < s.length ? `unexpected token ${s[pos]}` : "unexpected end" + } +} + +parseJsonNumber.message = undefined as string | undefined +parseJsonNumber.position = 0 as number +parseJsonNumber.code = _`require("ajv/dist/runtime/parseJson").parseJsonNumber` + +const escapedChars: {[X in string]?: string} = { + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: "\t", + '"': '"', + "/": "/", + "\\": "\\", +} + +const A_CODE: number = "a".charCodeAt(0) + +export function parseJsonString(s: string, pos: number): string | undefined { + let str = "" + let c: string | undefined + parseJsonString.message = undefined + // eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition + while (true) { + c = s[pos] + pos++ + if (c === '"') break + if (c === "\\") { + c = s[pos] + if (c in escapedChars) { + str += escapedChars[c] + } else if (c === "u") { + let count = 4 + let code = 0 + while (count--) { + code <<= 4 + c = s[pos].toLowerCase() + if (c >= "a" && c <= "f") { + c += c.charCodeAt(0) - A_CODE + 10 + } else if (c >= "0" && c <= "9") { + code += +c + } else if (c === undefined) { + errorMessage("unexpected end") + return undefined + } else { + errorMessage(`unexpected token ${s[pos]}`) + return undefined + } + pos++ + } + str += String.fromCharCode(code) + } else { + errorMessage(`unexpected token ${s[pos]}`) + return undefined + } + pos++ + } else if (c === undefined) { + errorMessage("unexpected end") + return undefined + } else { + str += c + } + } + parseJsonString.position = pos + return str + + function errorMessage(msg: string): void { + parseJsonString.position = pos + parseJsonString.message = msg + } +} + +parseJsonString.message = undefined as string | undefined +parseJsonString.position = 0 as number +parseJsonString.code = _`require("ajv/dist/runtime/parseJson").parseJsonString` diff --git a/lib/runtime/quote.ts b/lib/runtime/quote.ts new file mode 100644 index 000000000..c3309a0c4 --- /dev/null +++ b/lib/runtime/quote.ts @@ -0,0 +1,28 @@ +// eslint-disable-next-line no-control-regex, no-misleading-character-class +const rxEscapable = /[\\"\u0000-\u001f\u007f-\u009f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g + +const escaped: {[K in string]?: string} = { + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", + '"': '\\"', + "\\": "\\\\", +} + +export default function quote(s: string): string { + rxEscapable.lastIndex = 0 + return ( + '"' + + (rxEscapable.test(s) + ? s.replace(rxEscapable, (a) => { + const c = escaped[a] + return typeof c === "string" + ? c + : "\\u" + ("0000" + a.charCodeAt(0).toString(16)).slice(-4) + }) + : s) + + '"' + ) +} diff --git a/lib/types/index.ts b/lib/types/index.ts index 5fc9b6d2a..79ffa12df 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -53,6 +53,12 @@ export interface ValidateFunction { source?: SourceCode } +export interface JTDParser { + (json: string): T | undefined + message?: string + position?: number +} + export type EvaluatedProperties = {[K in string]?: true} | true export type EvaluatedItems = number | true diff --git a/lib/vocabularies/applicator/additionalProperties.ts b/lib/vocabularies/applicator/additionalProperties.ts index 381495a5a..b8bdb76a4 100644 --- a/lib/vocabularies/applicator/additionalProperties.ts +++ b/lib/vocabularies/applicator/additionalProperties.ts @@ -5,7 +5,7 @@ import type { KeywordErrorDefinition, AnySchema, } from "../../types" -import {allSchemaProperties, usePattern} from "../code" +import {allSchemaProperties, usePattern, isOwnProperty} from "../code" import {_, nil, or, not, Code, Name} from "../../compile/codegen" import N from "../../compile/names" import {SubschemaArgs, Type} from "../../compile/subschema" @@ -52,13 +52,8 @@ const def: CodeKeywordDefinition & AddedKeywordDefinition = { let definedProp: Code if (props.length > 8) { // TODO maybe an option instead of hard-coded 8? - const hasProp = gen.scopeValue("func", { - // eslint-disable-next-line @typescript-eslint/unbound-method - ref: Object.prototype.hasOwnProperty, - code: _`Object.prototype.hasOwnProperty`, - }) const propsSchema = schemaRefOrVal(it, parentSchema.properties, "properties") - definedProp = _`${hasProp}.call(${propsSchema}, ${key})` + definedProp = isOwnProperty(gen, propsSchema as Code, key) } else if (props.length) { definedProp = or(...props.map((p) => _`${key} === ${p}`)) } else { diff --git a/lib/vocabularies/applicator/dependencies.ts b/lib/vocabularies/applicator/dependencies.ts index d3084f479..6d6436edd 100644 --- a/lib/vocabularies/applicator/dependencies.ts +++ b/lib/vocabularies/applicator/dependencies.ts @@ -72,7 +72,7 @@ export function validatePropertyDeps( for (const prop in propertyDeps) { const deps = propertyDeps[prop] as string[] if (deps.length === 0) continue - const hasProperty = propertyInData(data, prop, it.opts.ownProperties) + const hasProperty = propertyInData(gen, data, prop, it.opts.ownProperties) cxt.setParams({ property: prop, depsCount: deps.length, @@ -98,7 +98,7 @@ export function validateSchemaDeps(cxt: KeywordCxt, schemaDeps: SchemaMap = cxt. for (const prop in schemaDeps) { if (alwaysValidSchema(it, schemaDeps[prop] as AnySchema)) continue gen.if( - propertyInData(data, prop, it.opts.ownProperties), + propertyInData(gen, data, prop, it.opts.ownProperties), () => { const schCxt = cxt.subschema({keyword, schemaProp: prop}, valid) cxt.mergeValidEvaluated(schCxt, valid) diff --git a/lib/vocabularies/applicator/properties.ts b/lib/vocabularies/applicator/properties.ts index 5ab96acd0..b469346d8 100644 --- a/lib/vocabularies/applicator/properties.ts +++ b/lib/vocabularies/applicator/properties.ts @@ -25,7 +25,7 @@ const def: CodeKeywordDefinition = { if (hasDefault(prop)) { applyPropertySchema(prop) } else { - gen.if(propertyInData(data, prop, it.opts.ownProperties)) + gen.if(propertyInData(gen, data, prop, it.opts.ownProperties)) applyPropertySchema(prop) if (!it.allErrors) gen.else().var(valid, true) gen.endIf() diff --git a/lib/vocabularies/code.ts b/lib/vocabularies/code.ts index b02cd3e7b..f3be79b43 100644 --- a/lib/vocabularies/code.ts +++ b/lib/vocabularies/code.ts @@ -8,20 +8,21 @@ import N from "../compile/names" export function checkReportMissingProp(cxt: KeywordCxt, prop: string): void { const {gen, data, it} = cxt - gen.if(noPropertyInData(data, prop, it.opts.ownProperties), () => { + gen.if(noPropertyInData(gen, data, prop, it.opts.ownProperties), () => { cxt.setParams({missingProperty: _`${prop}`}, true) cxt.error() }) } export function checkMissingProp( - {data, it: {opts}}: KeywordCxt, + {gen, data, it: {opts}}: KeywordCxt, properties: string[], missing: Name ): Code { return or( ...properties.map( - (prop) => _`${noPropertyInData(data, prop, opts.ownProperties)} && (${missing} = ${prop})` + (prop) => + _`${noPropertyInData(gen, data, prop, opts.ownProperties)} && (${missing} = ${prop})` ) ) } @@ -31,22 +32,36 @@ export function reportMissingProp(cxt: KeywordCxt, missing: Name): void { cxt.error() } -function isOwnProperty(data: Name, property: Name | string): Code { - return _`Object.prototype.hasOwnProperty.call(${data}, ${property})` +export function hasPropFunc(gen: CodeGen): Name { + return gen.scopeValue("func", { + // eslint-disable-next-line @typescript-eslint/unbound-method + ref: Object.prototype.hasOwnProperty, + code: _`Object.prototype.hasOwnProperty`, + }) +} + +export function isOwnProperty(gen: CodeGen, data: Name, property: Name | string): Code { + return _`${hasPropFunc(gen)}.call(${data}, ${property})` } -export function propertyInData(data: Name, property: Name | string, ownProperties?: boolean): Code { +export function propertyInData( + gen: CodeGen, + data: Name, + property: Name | string, + ownProperties?: boolean +): Code { const cond = _`${data}${getProperty(property)} !== undefined` - return ownProperties ? _`${cond} && ${isOwnProperty(data, property)}` : cond + return ownProperties ? _`${cond} && ${isOwnProperty(gen, data, property)}` : cond } export function noPropertyInData( + gen: CodeGen, data: Name, property: Name | string, ownProperties?: boolean ): Code { const cond = _`${data}${getProperty(property)} === undefined` - return ownProperties ? _`${cond} || !${isOwnProperty(data, property)}` : cond + return ownProperties ? _`${cond} || !${isOwnProperty(gen, data, property)}` : cond } export function allSchemaProperties(schemaMap?: SchemaMap): string[] { diff --git a/lib/vocabularies/jtd/properties.ts b/lib/vocabularies/jtd/properties.ts index 5d881d6ac..1ef172b5d 100644 --- a/lib/vocabularies/jtd/properties.ts +++ b/lib/vocabularies/jtd/properties.ts @@ -1,6 +1,6 @@ import type {CodeKeywordDefinition} from "../../types" import type KeywordCxt from "../../compile/context" -import {propertyInData, allSchemaProperties} from "../code" +import {propertyInData, allSchemaProperties, isOwnProperty} from "../code" import {alwaysValidSchema, schemaRefOrVal} from "../../compile/util" import {_, and, Code, Name} from "../../compile/codegen" import {checkMetadata} from "./metadata" @@ -63,7 +63,7 @@ export function validateProperties(cxt: KeywordCxt): void { const _valid = gen.var("valid") for (const prop of props) { gen.if( - propertyInData(data, prop, it.opts.ownProperties), + propertyInData(gen, data, prop, it.opts.ownProperties), () => applyPropertySchema(prop, keyword, _valid), missingProperty ) @@ -116,12 +116,7 @@ export function validateProperties(cxt: KeywordCxt): void { if (props.length > 8) { // TODO maybe an option instead of hard-coded 8? const propsSchema = schemaRefOrVal(it, parentSchema[keyword], keyword) - const hasProp = gen.scopeValue("func", { - // eslint-disable-next-line @typescript-eslint/unbound-method - ref: Object.prototype.hasOwnProperty, - code: _`Object.prototype.hasOwnProperty`, - }) - additional = _`!${hasProp}.call(${propsSchema}, ${key})` + additional = isOwnProperty(gen, propsSchema as Code, key) } else if (props.length) { additional = and(...props.map((p) => _`${key} !== ${p}`)) } else { diff --git a/lib/vocabularies/jtd/ref.ts b/lib/vocabularies/jtd/ref.ts index 5bec32d18..4aed9a9a9 100644 --- a/lib/vocabularies/jtd/ref.ts +++ b/lib/vocabularies/jtd/ref.ts @@ -57,15 +57,15 @@ const def: CodeKeywordDefinition = { valid ) } - - function hasRef(schema: AnySchemaObject): boolean { - for (const key in schema) { - let sch: AnySchemaObject - if (key === "ref" || (typeof (sch = schema[key]) == "object" && hasRef(sch))) return true - } - return false - } }, } +export function hasRef(schema: AnySchemaObject): boolean { + for (const key in schema) { + let sch: AnySchemaObject + if (key === "ref" || (typeof (sch = schema[key]) == "object" && hasRef(sch))) return true + } + return false +} + export default def diff --git a/lib/vocabularies/jtd/type.ts b/lib/vocabularies/jtd/type.ts index 86cecffd0..3e7429c86 100644 --- a/lib/vocabularies/jtd/type.ts +++ b/lib/vocabularies/jtd/type.ts @@ -2,17 +2,18 @@ import type {CodeKeywordDefinition} from "../../types" import type KeywordCxt from "../../compile/context" import {_, or, Code} from "../../compile/codegen" import validTimestamp from "../../compile/timestamp" +import {func} from "../../compile/util" import {checkMetadata} from "./metadata" -type IntType = "int8" | "uint8" | "int16" | "uint16" | "int32" | "uint32" +export type IntType = "int8" | "uint8" | "int16" | "uint16" | "int32" | "uint32" -const intRange: {[T in IntType]: [number, number]} = { - int8: [-128, 127], - uint8: [0, 255], - int16: [-32768, 32767], - uint16: [0, 65535], - int32: [-2147483648, 2147483647], - uint32: [0, 4294967295], +export const intRange: {[T in IntType]: [number, number, number]} = { + int8: [-128, 127, 3], + uint8: [0, 255, 3], + int16: [-32768, 32767, 5], + uint16: [0, 65535, 5], + int32: [-2147483648, 2147483647, 10], + uint32: [0, 4294967295, 10], } const def: CodeKeywordDefinition = { @@ -28,10 +29,7 @@ const def: CodeKeywordDefinition = { cond = _`typeof ${data} == ${schema}` break case "timestamp": { - const vts = gen.scopeValue("func", { - ref: validTimestamp, - code: _`require("ajv/dist/compile/timestamp").default`, - }) + const vts = func(gen, validTimestamp) cond = _`${data} instanceof Date || (typeof ${data} == "string" && ${vts}(${data}))` break } diff --git a/lib/vocabularies/validation/required.ts b/lib/vocabularies/validation/required.ts index d803b0578..1bfb2a475 100644 --- a/lib/vocabularies/validation/required.ts +++ b/lib/vocabularies/validation/required.ts @@ -60,7 +60,7 @@ const def: CodeKeywordDefinition = { function loopAllRequired(): void { gen.forOf("prop", schemaCode as Code, (prop) => { cxt.setParams({missingProperty: prop}) - gen.if(noPropertyInData(data, prop, opts.ownProperties), () => cxt.error()) + gen.if(noPropertyInData(gen, data, prop, opts.ownProperties), () => cxt.error()) }) } @@ -70,7 +70,7 @@ const def: CodeKeywordDefinition = { missing, schemaCode as Code, () => { - gen.assign(valid, propertyInData(data, missing, opts.ownProperties)) + gen.assign(valid, propertyInData(gen, data, missing, opts.ownProperties)) gen.if(not(valid), () => { cxt.error() gen.break() diff --git a/package.json b/package.json index 8ecc53dd8..e8071d67f 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "test-all": "npm run test-cov && if-node-version 12 npm run test-browser", "test": "npm link && npm link ajv && npm run json-tests && npm run eslint && npm run test-cov", "test-ci": "AJV_FULL_TEST=true npm test", - "prepublish": "npm run build" + "prepublish": "npm run build", + "benchmark": "npm i && npm run build && npm link && cd ./benchmark && npm link ajv && npm i && node ./jtd" }, "nyc": { "exclude": [ diff --git a/spec/jtd-schema.spec.ts b/spec/jtd-schema.spec.ts index 3bc386f18..cdd768296 100644 --- a/spec/jtd-schema.spec.ts +++ b/spec/jtd-schema.spec.ts @@ -1,5 +1,5 @@ import type AjvJTD from "../dist/jtd" -import type {SchemaObject} from "../dist/jtd" +import type {SchemaObject, JTDParser} from "../dist/jtd" import _AjvJTD from "./ajv_jtd" import getAjvInstances from "./ajv_instances" import {withStandalone} from "./ajv_standalone" @@ -48,14 +48,14 @@ describe("JSON Type Definition", () => { allErrors: true, inlineRefs: false, code: {es5: true, lines: true, optimize: false}, - }) + }) as AjvJTD[] ajvs.forEach((ajv) => (ajv.opts.code.source = true)) }) for (const testName in jtdValidationTests) { const {schema, instance, errors} = jtdValidationTests[testName] as TestCase const valid = errors.length === 0 - describeOnly(testName, () => + describe(testName, () => it(`should be ${valid ? "valid" : "invalid"}`, () => withStandalone(ajvs).forEach((ajv) => { // console.log(ajv.compile(schema).toString()) @@ -93,6 +93,61 @@ describe("JSON Type Definition", () => { ) } }) + + describe("serialize", () => { + const ajv = new _AjvJTD() + + for (const testName in jtdValidationTests) { + const {schema, instance, errors} = jtdValidationTests[testName] as TestCase + const valid = errors.length === 0 + if (!valid) continue + describe(testName, () => + it(`should serialize data`, () => { + const serialize = ajv.compileSerializer(schema) + // console.log(serialize.toString()) + assert.deepStrictEqual(JSON.parse(serialize(instance)), instance) + }) + ) + } + }) + + describe("parse", () => { + const ajv = new _AjvJTD() + + for (const testName in jtdValidationTests) { + const {schema, instance, errors} = jtdValidationTests[testName] as TestCase + const valid = errors.length === 0 + describeOnly(testName, () => { + if (valid) { + it(`should parse valid JSON string`, () => { + const parse = ajv.compileParser(schema) + // console.log(schema, instance, `"${JSON.stringify(instance)}"`, parse.toString()) + shouldParse(parse, JSON.stringify(instance), instance) + shouldParse(parse, ` ${JSON.stringify(instance, null, 2)} `, instance) + }) + } else { + it(`should return undefined on invalid JSON string`, () => { + const parse = ajv.compileParser(schema) + // console.log(parse.toString()) + shouldFail(parse, JSON.stringify(instance)) + shouldFail(parse, ` ${JSON.stringify(instance, null, 2)} `) + }) + } + }) + } + + function shouldParse(parse: JTDParser, str: string, res: unknown): void { + assert.deepStrictEqual(parse(str), res) + assert.strictEqual(parse.message, undefined) + assert.strictEqual(parse.position, undefined) + } + + function shouldFail(parse: JTDParser, str: string): void { + assert.strictEqual(parse(str), undefined) + assert.strictEqual(typeof parse.message, "string") + assert.strictEqual(typeof parse.position, "number") + } + }) }) function describeOnly(name: string, func: () => void) {