diff --git a/lib/vocabularies/discriminator/index.ts b/lib/vocabularies/discriminator/index.ts index 19ae6049f3..b2c716227b 100644 --- a/lib/vocabularies/discriminator/index.ts +++ b/lib/vocabularies/discriminator/index.ts @@ -1,6 +1,6 @@ import type {CodeKeywordDefinition, AnySchemaObject, KeywordErrorDefinition} from "../../types" import type {KeywordCxt} from "../../compile/validate" -import {_, getProperty, Name} from "../../compile/codegen" +import {_, or, getProperty, Name} from "../../compile/codegen" import {DiscrError, DiscrErrorObj} from "../discriminator/types" import {resolveRef, SchemaEnv} from "../../compile" import MissingRefError from "../../compile/ref_error" @@ -34,8 +34,9 @@ const def: CodeKeywordDefinition = { if (!oneOf) throw new Error("discriminator: requires oneOf keyword") const valid = gen.let("valid", false) const tag = gen.const("tag", _`${data}${getProperty(tagName)}`) + const tagTypes = ["string", "number", "boolean"] gen.if( - _`typeof ${tag} == "string"`, + or(...tagTypes.map((t) => _`typeof ${tag} == ${t}`), _`${tag} == null`), () => validateMapping(), () => cxt.error(false, {discrError: DiscrError.Tag, tag, tagName}) ) @@ -44,9 +45,9 @@ const def: CodeKeywordDefinition = { function validateMapping(): void { const mapping = getMapping() gen.if(false) - for (const tagValue in mapping) { + for (const tagValue of mapping.keys()) { gen.elseIf(_`${tag} === ${tagValue}`) - gen.assign(valid, applyTagSchema(mapping[tagValue])) + gen.assign(valid, applyTagSchema(mapping.get(tagValue))) } gen.else() cxt.error(false, {discrError: DiscrError.Mapping, tag, tagName}) @@ -60,8 +61,10 @@ const def: CodeKeywordDefinition = { return _valid } - function getMapping(): {[T in string]?: number} { - const oneOfMapping: {[T in string]?: number} = {} + type OneOfMapping = Map + + function getMapping(): OneOfMapping { + const oneOfMapping: OneOfMapping = new Map() const topRequired = hasRequired(parentSchema) let tagRequired = true for (let i = 0; i < oneOf.length; i++) { @@ -89,7 +92,7 @@ const def: CodeKeywordDefinition = { } function addMappings(sch: AnySchemaObject, i: number): void { - if (sch.const) { + if (sch.const !== undefined) { addMapping(sch.const, i) } else if (sch.enum) { for (const tagValue of sch.enum) { @@ -100,11 +103,16 @@ const def: CodeKeywordDefinition = { } } - function addMapping(tagValue: unknown, i: number): void { - if (typeof tagValue != "string" || tagValue in oneOfMapping) { - throw new Error(`discriminator: "${tagName}" values must be unique strings`) + function addMapping(tagValue: any, i: number): void { + if ( + !(["string", "number", "boolean"].includes(typeof tagValue) || tagValue === null) || + oneOfMapping.has(tagValue) + ) { + throw new Error( + `discriminator: "${tagName}" values must be unique strings, numbers, booleans or nulls` + ) } - oneOfMapping[tagValue] = i + oneOfMapping.set(tagValue, i) } } }, diff --git a/spec/discriminator.spec.ts b/spec/discriminator.spec.ts index 74ba33ef0c..34850e5720 100644 --- a/spec/discriminator.spec.ts +++ b/spec/discriminator.spec.ts @@ -23,7 +23,7 @@ describe("discriminator keyword", function () { } describe("validation", () => { - const schema1 = { + const stringSchema1 = { type: "object", discriminator: {propertyName: "foo"}, oneOf: [ @@ -44,7 +44,7 @@ describe("discriminator keyword", function () { ], } - const schema2 = { + const stringSchema2 = { type: "object", discriminator: {propertyName: "foo"}, required: ["foo"], @@ -66,18 +66,151 @@ describe("discriminator keyword", function () { ], } - const schemas = [schema1, schema2] + const numberSchema = { + type: "object", + discriminator: {propertyName: "foo"}, + required: ["foo"], + oneOf: [ + { + properties: { + foo: {const: 1}, + a: {type: "string"}, + }, + required: ["a"], + }, + { + properties: { + foo: {enum: [2, 3]}, + b: {type: "string"}, + }, + required: ["b"], + }, + ], + } - it("should validate data", () => { - assertValid(schemas, {foo: "x", a: "a"}) - assertValid(schemas, {foo: "y", b: "b"}) - assertValid(schemas, {foo: "z", b: "b"}) - assertInvalid(schemas, {}) - assertInvalid(schemas, {foo: 1}) - assertInvalid(schemas, {foo: "bar"}) - assertInvalid(schemas, {foo: "x", b: "b"}) - assertInvalid(schemas, {foo: "y", a: "a"}) - assertInvalid(schemas, {foo: "z", a: "a"}) + const boolSchema = { + type: "object", + discriminator: {propertyName: "foo"}, + required: ["foo"], + oneOf: [ + { + properties: { + foo: {const: true}, + a: {type: "string"}, + }, + required: ["a"], + }, + { + properties: { + foo: {const: false}, + b: {type: "string"}, + }, + required: ["b"], + }, + ], + } + + const mixedSchema = { + type: "object", + discriminator: {propertyName: "foo"}, + required: ["foo"], + oneOf: [ + { + properties: { + foo: {const: "x"}, + a: {type: "string"}, + }, + required: ["a"], + }, + { + properties: { + foo: {enum: [1, 2]}, + b: {type: "string"}, + }, + required: ["b"], + }, + { + properties: { + foo: {const: true}, + c: {type: "string"}, + }, + required: ["c"], + }, + { + properties: { + foo: {const: null}, + d: {type: "number"}, + }, + required: ["d"], + }, + ], + } + + const stringSchemas = [stringSchema1, stringSchema2] + const numberSchemas = [numberSchema] + const boolSchemas = [boolSchema] + const mixedSchemas = [mixedSchema] + + it("should validate data for string discriminator", () => { + assertValid(stringSchemas, {foo: "x", a: "a"}) + assertValid(stringSchemas, {foo: "y", b: "b"}) + assertValid(stringSchemas, {foo: "z", b: "b"}) + + assertInvalid(stringSchemas, {}) + assertInvalid(stringSchemas, {foo: 1}) + assertInvalid(stringSchemas, {foo: "bar"}) + assertInvalid(stringSchemas, {foo: "x", b: "b"}) + assertInvalid(stringSchemas, {foo: "y", a: "a"}) + assertInvalid(stringSchemas, {foo: "z", a: "a"}) + }) + + it("should validate data for number discriminator", () => { + assertValid(numberSchemas, {foo: 1, a: "a"}) + assertValid(numberSchemas, {foo: 2, b: "b"}) + assertValid(numberSchemas, {foo: 3, b: "b"}) + + assertInvalid(stringSchemas, {}) + assertInvalid(stringSchemas, {foo: "1"}) + assertInvalid(stringSchemas, {foo: "bar"}) + assertInvalid(numberSchemas, {foo: 1, b: "b"}) + assertInvalid(numberSchemas, {foo: 2, a: "a"}) + assertInvalid(numberSchemas, {foo: 3, a: "a"}) + }) + + it("should validate data for boolean discriminator", () => { + assertValid(boolSchemas, {foo: true, a: "a"}) + assertValid(boolSchemas, {foo: false, b: "b"}) + + assertInvalid(boolSchemas, {}) + assertInvalid(boolSchemas, {foo: "1"}) + assertInvalid(boolSchemas, {foo: true, b: "b"}) + assertInvalid(boolSchemas, {foo: false, a: "a"}) + }) + + it("should validate data for mixed type discriminator", () => { + assertValid(mixedSchemas, {foo: "x", a: "a"}) + assertValid(mixedSchemas, {foo: 1, b: "b"}) + assertValid(mixedSchemas, {foo: 2, b: "b"}) + assertValid(mixedSchemas, {foo: true, c: "c"}) + assertValid(mixedSchemas, {foo: null, d: 123}) + + assertInvalid(mixedSchemas, {}) + assertInvalid(mixedSchemas, {foo: "x"}) + assertInvalid(mixedSchemas, {foo: "x", b: "b"}) + assertInvalid(mixedSchemas, {foo: "x", c: "c"}) + assertInvalid(mixedSchemas, {foo: 1}) + assertInvalid(mixedSchemas, {foo: 1, a: "a"}) + assertInvalid(mixedSchemas, {foo: 1, c: "c"}) + assertInvalid(mixedSchemas, {foo: 2}) + assertInvalid(mixedSchemas, {foo: 2, a: "a"}) + assertInvalid(mixedSchemas, {foo: 2, c: "c"}) + assertInvalid(mixedSchemas, {foo: true}) + assertInvalid(mixedSchemas, {foo: true, a: "a"}) + assertInvalid(mixedSchemas, {foo: true, b: "b"}) + assertInvalid(mixedSchemas, {foo: null}) + assertInvalid(mixedSchemas, {foo: null, a: "a"}) + assertInvalid(mixedSchemas, {foo: null, b: "b"}) + assertInvalid(mixedSchemas, {foo: null, c: "c"}) }) }) @@ -334,15 +467,15 @@ describe("discriminator keyword", function () { ) }) - it("tag value should be string", () => { + it("tag value should be string, number, boolean or null", () => { invalidSchema( { type: "object", discriminator: {propertyName: "foo"}, required: ["foo"], - oneOf: [{properties: {foo: {const: 1}}}], + oneOf: [{properties: {foo: {const: {baz: "bar"}}}}], }, - /discriminator: "foo" values must be unique strings/ + /discriminator: "foo" values must be unique strings, numbers, booleans or nulls/ ) }) @@ -354,7 +487,27 @@ describe("discriminator keyword", function () { required: ["foo"], oneOf: [{properties: {foo: {const: "a"}}}, {properties: {foo: {const: "a"}}}], }, - /discriminator: "foo" values must be unique strings/ + /discriminator: "foo" values must be unique strings, numbers, booleans or nulls/ + ) + + invalidSchema( + { + type: "object", + discriminator: {propertyName: "foo"}, + required: ["foo"], + oneOf: [{properties: {foo: {const: 1}}}, {properties: {foo: {const: 1}}}], + }, + /discriminator: "foo" values must be unique/ + ) + + invalidSchema( + { + type: "object", + discriminator: {propertyName: "foo"}, + required: ["foo"], + oneOf: [{properties: {foo: {const: true}}}, {properties: {foo: {const: true}}}], + }, + /discriminator: "foo" values must be unique/ ) }) @@ -375,7 +528,11 @@ describe("discriminator keyword", function () { function assertValid(schemas: SchemaObject[], data: unknown): void { schemas.forEach((schema) => - ajvs.forEach((ajv) => assert.strictEqual(ajv.validate(schema, data), true)) + ajvs.forEach((ajv) => { + const validate = ajv.compile(schema) + const valid = validate(data) + assert.strictEqual(valid, true) + }) ) }