From d8e9b807d9286ef9828b96be985e2a90eb67f124 Mon Sep 17 00:00:00 2001 From: Mohankumar Ramachandran Date: Mon, 3 Nov 2025 12:07:28 +0530 Subject: [PATCH 1/2] feat: implement discriminator optimization for JSON schema validation --- src/parser/jsonParser.ts | 133 ++++++- src/test/parser.test.ts | 737 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 869 insertions(+), 1 deletion(-) diff --git a/src/parser/jsonParser.ts b/src/parser/jsonParser.ts index 14fd3876..1e9cd05b 100644 --- a/src/parser/jsonParser.ts +++ b/src/parser/jsonParser.ts @@ -485,9 +485,140 @@ function validate(n: ASTNode | undefined, schema: JSONSchema, validationResult: const testAlternatives = (alternatives: JSONSchemaRef[], maxOneMatch: boolean) => { const matches = []; + // Try to detect and apply discriminator optimization dynamically + let alternativesToTest = alternatives; + + // Attempt to find a common discriminator property across alternatives + const tryDiscriminatorOptimization = () => { + if (alternatives.length < 2) { + return; // No point optimizing with less than 2 alternatives + } + + if (node.type === 'object') { + // Early exit: if node has no properties, can't apply optimization + if (!node.properties || node.properties.length === 0) { + return; + } + + // Find properties that have const values in ALL alternatives + const propertyConstMap = new Map>(); + + for (let i = 0; i < alternatives.length; i++) { + const altSchema = asSchema(alternatives[i]); + if (!altSchema.properties) { + // If any alternative has no properties, we can't use object property discrimination + return; + } + for (const propName in altSchema.properties) { + const propSchema = asSchema(altSchema.properties[propName]); + if (propSchema.const !== undefined) { + if (!propertyConstMap.has(propName)) { + propertyConstMap.set(propName, new Map()); + } + const constMap = propertyConstMap.get(propName)!; + if (!constMap.has(propSchema.const)) { + constMap.set(propSchema.const, []); + } + constMap.get(propSchema.const)!.push(i); + } + } + } + + // Early exit: if no const properties found, can't apply optimization + if (propertyConstMap.size === 0) { + return; + } + + // Find a property where ALL alternatives have a const value (one per alternative) + for (const [propName, constMap] of Array.from(propertyConstMap.entries())) { + const coveredAlternatives = new Set(); + constMap.forEach((indices) => { + indices.forEach(idx => coveredAlternatives.add(idx)); + }); + // This property is a valid discriminator ONLY if ALL alternatives have a const value for it + if (coveredAlternatives.size === alternatives.length) { + // Extract the discriminator value from the current node + const prop = node.properties.find(p => p.keyNode.value === propName); + if (prop?.valueNode?.type === 'string') { + const discriminatorValue = prop.valueNode.value; + const matchingIndices = constMap.get(discriminatorValue); + if (matchingIndices && matchingIndices.length > 0) { + alternativesToTest = matchingIndices.map(idx => alternatives[idx]); + return; + } + } + // If this property covers all alternatives but we couldn't match, + break; + } + } + } else if (node.type === 'array') { + // Early exit: if node has no items, can't apply optimization + if (!node.items || node.items.length === 0) { + return; + } + + // Find array item indices that have const values in ALL alternatives + const indexConstMap = new Map>(); + + for (let i = 0; i < alternatives.length; i++) { + const altSchema = asSchema(alternatives[i]); + const itemSchemas = altSchema.prefixItems || (Array.isArray(altSchema.items) ? altSchema.items : undefined); + + if (!itemSchemas) { + // If any alternative has no item schemas, we can't use array index discrimination + return; + } + + itemSchemas.forEach((itemSchemaRef, itemIndex) => { + const itemSchema = asSchema(itemSchemaRef); + if (itemSchema.const !== undefined) { + if (!indexConstMap.has(itemIndex)) { + indexConstMap.set(itemIndex, new Map()); + } + const constMap = indexConstMap.get(itemIndex)!; + if (!constMap.has(itemSchema.const)) { + constMap.set(itemSchema.const, []); + } + constMap.get(itemSchema.const)!.push(i); + } + }); + } + + // Early exit: if no const items found, can't apply optimization + if (indexConstMap.size === 0) { + return; + } + + // Find an index where ALL alternatives have a const value (one per alternative) + for (const [itemIndex, constMap] of Array.from(indexConstMap.entries())) { + const coveredAlternatives = new Set(); + constMap.forEach((indices) => { + indices.forEach(idx => coveredAlternatives.add(idx)); + }); + // This index is a valid discriminator ONLY if ALL alternatives have a const value at this index + if (coveredAlternatives.size === alternatives.length) { + // Extract the discriminator value from the current node + const item = node.items[itemIndex]; + if (item?.type === 'string') { + const discriminatorValue = item.value; + const matchingIndices = constMap.get(discriminatorValue); + if (matchingIndices && matchingIndices.length > 0) { + alternativesToTest = matchingIndices.map(idx => alternatives[idx]); + return; + } + } + // If this index covers all alternatives but we couldn't match, + break; + } + } + } + }; + + tryDiscriminatorOptimization(); + // remember the best match that is used for error messages let bestMatch: { schema: JSONSchema; validationResult: ValidationResult; matchingSchemas: ISchemaCollector; } | undefined = undefined; - for (const subSchemaRef of alternatives) { + for (const subSchemaRef of alternativesToTest) { const subSchema = asSchema(subSchemaRef); const subValidationResult = new ValidationResult(); const subMatchingSchemas = matchingSchemas.newSub(); diff --git a/src/test/parser.test.ts b/src/test/parser.test.ts index ca1a92fb..2724c42a 100644 --- a/src/test/parser.test.ts +++ b/src/test/parser.test.ts @@ -2666,5 +2666,742 @@ suite('JSON Parser', () => { }); + test('discriminator optimization: object with const property - oneOf', function () { + // Test basic discriminator optimization with const values + const schema: JSONSchema = { + oneOf: [ + { + type: 'object', + properties: { + type: { const: 'cat' }, + meow: { type: 'string' } + } + }, + { + type: 'object', + properties: { + type: { const: 'dog' }, + bark: { type: 'string' } + } + }, + { + type: 'object', + properties: { + type: { const: 'bird' }, + chirp: { type: 'string' } + } + } + ] + }; + + // Valid: matches cat schema + { + const { textDoc, jsonDoc } = toDocument('{"type": "cat", "meow": "meow"}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 0); + } + + // Valid: matches dog schema + { + const { textDoc, jsonDoc } = toDocument('{"type": "dog", "bark": "woof"}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 0); + } + + // Invalid: type matches cat but property type is wrong + { + const { textDoc, jsonDoc } = toDocument('{"type": "cat", "meow": 123}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 1); + assert.strictEqual(semanticErrors![0].message, 'Incorrect type. Expected "string".'); + } + + // Invalid: unknown discriminator value + { + const { textDoc, jsonDoc } = toDocument('{"type": "fish", "swim": "yes"}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 1); + } + }); + + test('discriminator optimization: object with const property - anyOf', function () { + const schema: JSONSchema = { + anyOf: [ + { + type: 'object', + properties: { + kind: { const: 'circle' }, + radius: { type: 'number' } + } + }, + { + type: 'object', + properties: { + kind: { const: 'square' }, + side: { type: 'number' } + } + } + ] + }; + + // Valid: circle + { + const { textDoc, jsonDoc } = toDocument('{"kind": "circle", "radius": 5}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 0); + } + + // Valid: square + { + const { textDoc, jsonDoc } = toDocument('{"kind": "square", "side": 10}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 0); + } + + // Invalid: kind matches but property type wrong + { + const { textDoc, jsonDoc } = toDocument('{"kind": "circle", "radius": "not a number"}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 1); + } + }); + + test('discriminator optimization: no optimization when not all alternatives have const', function () { + // Schema where not all alternatives have const discriminator + const schema: JSONSchema = { + oneOf: [ + { + type: 'object', + properties: { + type: { const: 'a' }, + value: { type: 'string' } + } + }, + { + type: 'object', + properties: { + type: { type: 'string' }, // Not const, just type + value: { type: 'number' } + } + } + ] + }; + + // Should still work but without optimization + { + const { textDoc, jsonDoc } = toDocument('{"type": "a", "value": "test"}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 0); + } + + { + const { textDoc, jsonDoc } = toDocument('{"type": "b", "value": 42}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 0); + } + }); + + test('discriminator optimization: empty object', function () { + const schema: JSONSchema = { + oneOf: [ + { + type: 'object', + properties: { + type: { const: 'a' } + } + }, + { + type: 'object', + properties: { + type: { const: 'b' } + } + } + ] + }; + + // Empty object should not crash + { + const { textDoc, jsonDoc } = toDocument('{}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + // Should have errors for not matching any oneOf + assert.ok(semanticErrors!.length > 0); + } + }); + + test('discriminator optimization: no properties in alternative', function () { + const schema: JSONSchema = { + oneOf: [ + { + type: 'object', + properties: { + type: { const: 'a' } + } + }, + { + type: 'object' + // No properties defined + } + ] + }; + + // Should not crash, optimization should not apply + // Since one alternative has no properties, optimization won't be used + // This matches multiple schemas (both alternatives), so oneOf fails + { + const { textDoc, jsonDoc } = toDocument('{"type": "a"}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 1); + assert.ok(semanticErrors![0].message.includes('multiple schemas')); + } + }); + + test('discriminator optimization: non-string discriminator value', function () { + const schema: JSONSchema = { + oneOf: [ + { + type: 'object', + properties: { + type: { const: 'text' }, + value: { type: 'string' } + } + }, + { + type: 'object', + properties: { + type: { const: 'number' }, + value: { type: 'number' } + } + } + ] + }; + + // Discriminator value is a number, not string - optimization should not apply + { + const { textDoc, jsonDoc } = toDocument('{"type": 123, "value": "test"}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.ok(semanticErrors!.length > 0); + } + }); + + test('discriminator optimization: array with const items - prefixItems', function () { + const schema: JSONSchema = { + oneOf: [ + { + type: 'array', + prefixItems: [ + { const: 'cat' }, + { type: 'string' } + ], + items: false // No additional items allowed + }, + { + type: 'array', + prefixItems: [ + { const: 'dog' }, + { type: 'number' } + ], + items: false // No additional items allowed + }, + { + type: 'array', + prefixItems: [ + { const: 'bird' }, + { type: 'boolean' } + ], + items: false // No additional items allowed + } + ] + }; + + // Valid: cat array + { + const { textDoc, jsonDoc } = toDocument('["cat", "meow"]'); + const semanticErrors = validate2(jsonDoc, textDoc, schema, SchemaDraft.v2020_12); + assert.strictEqual(semanticErrors!.length, 0); + } + + // Valid: dog array + { + const { textDoc, jsonDoc } = toDocument('["dog", 42]'); + const semanticErrors = validate2(jsonDoc, textDoc, schema, SchemaDraft.v2020_12); + assert.strictEqual(semanticErrors!.length, 0); + } + + // Invalid: type matches cat but second element wrong type + { + const { textDoc, jsonDoc } = toDocument('["cat", 123]'); + const semanticErrors = validate2(jsonDoc, textDoc, schema, SchemaDraft.v2020_12); + // With optimization, only the "cat" schema is tested + // The second item should be a string, but is a number + assert.strictEqual(semanticErrors!.length, 1); + assert.ok(semanticErrors![0].message.includes('string')); + } + + // Invalid: unknown discriminator + { + const { textDoc, jsonDoc } = toDocument('["fish", "swim"]'); + const semanticErrors = validate2(jsonDoc, textDoc, schema, SchemaDraft.v2020_12); + assert.ok(semanticErrors!.length > 0); + } + }); + + test('discriminator optimization: array with const items - items array', function () { + const schema: JSONSchema = { + oneOf: [ + { + type: 'array', + items: [ + { const: 'type1' }, + { type: 'string' } + ] + }, + { + type: 'array', + items: [ + { const: 'type2' }, + { type: 'number' } + ] + } + ] + }; + + // Valid: type1 + { + const { textDoc, jsonDoc } = toDocument('["type1", "value"]'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 0); + } + + // Valid: type2 + { + const { textDoc, jsonDoc } = toDocument('["type2", 42]'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 0); + } + }); + + test('discriminator optimization: empty array', function () { + const schema: JSONSchema = { + oneOf: [ + { + type: 'array', + prefixItems: [ + { const: 'a' } + ] + }, + { + type: 'array', + prefixItems: [ + { const: 'b' } + ] + } + ] + }; + + // Empty array should not crash + { + const { textDoc, jsonDoc } = toDocument('[]'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.ok(semanticErrors!.length > 0); + } + }); + + test('discriminator optimization: array without item schemas', function () { + const schema: JSONSchema = { + oneOf: [ + { + type: 'array', + prefixItems: [ + { const: 'a' } + ] + }, + { + type: 'array' + // No items or prefixItems + } + ] + }; + + // Should not crash + // Since one alternative has no item schemas, optimization won't be used + // Both alternatives match, so oneOf fails + { + const { textDoc, jsonDoc } = toDocument('["a"]'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 1); + assert.ok(semanticErrors![0].message.includes('multiple schemas')); + } + }); + + test('discriminator optimization: array with non-string discriminator', function () { + const schema: JSONSchema = { + oneOf: [ + { + type: 'array', + prefixItems: [ + { const: 'type1' }, + { type: 'string' } + ] + }, + { + type: 'array', + prefixItems: [ + { const: 'type2' }, + { type: 'string' } + ] + } + ] + }; + + // First element is number, not string - optimization should not apply + { + const { textDoc, jsonDoc } = toDocument('[123, "value"]'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.ok(semanticErrors!.length > 0); + } + }); + + test('discriminator optimization: single alternative', function () { + // With only one alternative, no optimization should be attempted + const schema: JSONSchema = { + oneOf: [ + { + type: 'object', + properties: { + type: { const: 'only' }, + value: { type: 'string' } + } + } + ] + }; + + { + const { textDoc, jsonDoc } = toDocument('{"type": "only", "value": "test"}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 0); + } + + { + const { textDoc, jsonDoc } = toDocument('{"type": "other", "value": "test"}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.ok(semanticErrors!.length > 0); + } + }); + + test('discriminator optimization: multiple const properties but not all alternatives covered', function () { + const schema: JSONSchema = { + oneOf: [ + { + type: 'object', + properties: { + type: { const: 'a' }, + subtype: { const: 'x' }, + value: { type: 'string' } + } + }, + { + type: 'object', + properties: { + type: { const: 'b' }, + // No subtype const + value: { type: 'number' } + } + } + ] + }; + + // Only 'type' covers all alternatives, should use that + { + const { textDoc, jsonDoc } = toDocument('{"type": "a", "subtype": "x", "value": "test"}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 0); + } + + { + const { textDoc, jsonDoc } = toDocument('{"type": "b", "value": 42}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 0); + } + }); + + test('discriminator optimization: discriminator matches but validation fails', function () { + const schema: JSONSchema = { + oneOf: [ + { + type: 'object', + properties: { + type: { const: 'person' }, + name: { type: 'string', minLength: 5 }, + age: { type: 'number', minimum: 18 } + }, + required: ['name', 'age'] + }, + { + type: 'object', + properties: { + type: { const: 'company' }, + name: { type: 'string' }, + employees: { type: 'number' } + } + } + ] + }; + + // Discriminator matches 'person' but validation fails (name too short) + { + const { textDoc, jsonDoc } = toDocument('{"type": "person", "name": "Joe", "age": 25}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 1); + assert.ok(semanticErrors![0].message.includes('minimum length')); + } + + // Discriminator matches 'person' but validation fails (age too low) + { + const { textDoc, jsonDoc } = toDocument('{"type": "person", "name": "Alice", "age": 15}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 1); + assert.ok(semanticErrors![0].message.includes('minimum')); + } + + // Discriminator matches 'person' but required property missing + { + const { textDoc, jsonDoc } = toDocument('{"type": "person", "name": "Alice"}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 1); + assert.ok(semanticErrors![0].message.includes('Missing property')); + } + + // Valid person + { + const { textDoc, jsonDoc } = toDocument('{"type": "person", "name": "Alice", "age": 25}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 0); + } + + // Valid company + { + const { textDoc, jsonDoc } = toDocument('{"type": "company", "name": "ACME", "employees": 100}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 0); + } + }); + + test('discriminator optimization: complex nested schema', function () { + const schema: JSONSchema = { + type: 'object', + properties: { + data: { + oneOf: [ + { + type: 'object', + properties: { + kind: { const: 'text' }, + content: { type: 'string' } + } + }, + { + type: 'object', + properties: { + kind: { const: 'number' }, + content: { type: 'number' } + } + } + ] + } + } + }; + + // Valid nested with discrimination + { + const { textDoc, jsonDoc } = toDocument('{"data": {"kind": "text", "content": "hello"}}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 0); + } + + // Invalid nested - discriminator matches but type wrong + { + const { textDoc, jsonDoc } = toDocument('{"data": {"kind": "text", "content": 123}}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + assert.strictEqual(semanticErrors!.length, 1); + } + }); + + test('discriminator optimization: array index discriminator at different positions', function () { + // Test discriminator at index 1 instead of 0 + const schema: JSONSchema = { + oneOf: [ + { + type: 'array', + prefixItems: [ + { type: 'number' }, + { const: 'typeA' }, + { type: 'string' } + ], + items: false // No additional items allowed + }, + { + type: 'array', + prefixItems: [ + { type: 'number' }, + { const: 'typeB' }, + { type: 'boolean' } + ], + items: false // No additional items allowed + } + ] + }; + + // Valid: typeA + { + const { textDoc, jsonDoc } = toDocument('[42, "typeA", "hello"]'); + const semanticErrors = validate2(jsonDoc, textDoc, schema, SchemaDraft.v2020_12); + assert.strictEqual(semanticErrors!.length, 0); + } + + // Valid: typeB + { + const { textDoc, jsonDoc } = toDocument('[42, "typeB", true]'); + const semanticErrors = validate2(jsonDoc, textDoc, schema, SchemaDraft.v2020_12); + assert.strictEqual(semanticErrors!.length, 0); + } + + // Invalid: discriminator matches typeA but third element wrong type + { + const { textDoc, jsonDoc } = toDocument('[42, "typeA", true]'); + const semanticErrors = validate2(jsonDoc, textDoc, schema, SchemaDraft.v2020_12); + // With optimization, typeA schema is correctly selected + // The third element should be a string but is boolean + assert.strictEqual(semanticErrors!.length, 1); + assert.ok(semanticErrors![0].message.includes('string')); + } + }); + + test('discriminator optimization: discriminator value present but no matching alternative', function () { + const schema: JSONSchema = { + oneOf: [ + { + type: 'object', + properties: { + type: { const: 'a' }, + value: { type: 'string' } + } + }, + { + type: 'object', + properties: { + type: { const: 'b' }, + value: { type: 'number' } + } + } + ] + }; + + // Discriminator value 'c' doesn't match any alternative + { + const { textDoc, jsonDoc } = toDocument('{"type": "c", "value": "test"}'); + const semanticErrors = validate2(jsonDoc, textDoc, schema); + // Should fall back to testing all alternatives + assert.ok(semanticErrors!.length > 0); + } + }); + + test('self-referencing schema with anyOf and deep nesting (exploding complexity test)', async function () { + // Schema with discriminator and self-references + const schema: JSONSchema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "title": "literal", + "type": "string", + "const": "literal" + }, + "value": { + "type": "string" + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "title": "group", + "type": "string", + "const": "group" + }, + "children": { + "minItems": 2, + "type": "array", + "items": { + "$ref": "#" + } + } + }, + "required": ["type", "children"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "title": "sequence", + "type": "string", + "const": "sequence" + }, + "children": { + "minItems": 2, + "type": "array", + "items": { + "$ref": "#" + } + } + }, + "required": ["type", "children"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "title": "wrapper", + "type": "string", + "const": "wrapper" + }, + "children": { + "type": "array", + "items": [ + { + "$ref": "#" + } + ], + "minItems": 1, + "maxItems": 1 + } + }, + "required": ["type", "children"], + "additionalProperties": false + } + ] + }; + + // Create a deeply nested JSON (100 levels of "wrapper" operations) + let deepJson: any = { "type": "literal", "value": "test" }; + for (let i = 0; i < 100; i++) { + deepJson = { "type": "wrapper", "children": [deepJson] }; + } + const jsonString = JSON.stringify(deepJson); + + const { textDoc, jsonDoc } = toDocument(jsonString); + + const ls = getLanguageService({}); + ls.configure({ schemas: [{ fileMatch: ["*.json"], uri: "http://myschemastore/explode", schema }] }); + + let res = await ls.doValidation(textDoc, jsonDoc, undefined, schema); + assert.strictEqual(res.length, 0); + }); }); From 07dc2becfb0736375779b799d5586db46889bde4 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Mon, 1 Dec 2025 20:06:13 +0100 Subject: [PATCH 2/2] polish and extract common patterns --- src/parser/jsonParser.ts | 201 ++++++++++++++------------------------- 1 file changed, 71 insertions(+), 130 deletions(-) diff --git a/src/parser/jsonParser.ts b/src/parser/jsonParser.ts index 1e9cd05b..d5a3b452 100644 --- a/src/parser/jsonParser.ts +++ b/src/parser/jsonParser.ts @@ -485,136 +485,7 @@ function validate(n: ASTNode | undefined, schema: JSONSchema, validationResult: const testAlternatives = (alternatives: JSONSchemaRef[], maxOneMatch: boolean) => { const matches = []; - // Try to detect and apply discriminator optimization dynamically - let alternativesToTest = alternatives; - - // Attempt to find a common discriminator property across alternatives - const tryDiscriminatorOptimization = () => { - if (alternatives.length < 2) { - return; // No point optimizing with less than 2 alternatives - } - - if (node.type === 'object') { - // Early exit: if node has no properties, can't apply optimization - if (!node.properties || node.properties.length === 0) { - return; - } - - // Find properties that have const values in ALL alternatives - const propertyConstMap = new Map>(); - - for (let i = 0; i < alternatives.length; i++) { - const altSchema = asSchema(alternatives[i]); - if (!altSchema.properties) { - // If any alternative has no properties, we can't use object property discrimination - return; - } - for (const propName in altSchema.properties) { - const propSchema = asSchema(altSchema.properties[propName]); - if (propSchema.const !== undefined) { - if (!propertyConstMap.has(propName)) { - propertyConstMap.set(propName, new Map()); - } - const constMap = propertyConstMap.get(propName)!; - if (!constMap.has(propSchema.const)) { - constMap.set(propSchema.const, []); - } - constMap.get(propSchema.const)!.push(i); - } - } - } - - // Early exit: if no const properties found, can't apply optimization - if (propertyConstMap.size === 0) { - return; - } - - // Find a property where ALL alternatives have a const value (one per alternative) - for (const [propName, constMap] of Array.from(propertyConstMap.entries())) { - const coveredAlternatives = new Set(); - constMap.forEach((indices) => { - indices.forEach(idx => coveredAlternatives.add(idx)); - }); - // This property is a valid discriminator ONLY if ALL alternatives have a const value for it - if (coveredAlternatives.size === alternatives.length) { - // Extract the discriminator value from the current node - const prop = node.properties.find(p => p.keyNode.value === propName); - if (prop?.valueNode?.type === 'string') { - const discriminatorValue = prop.valueNode.value; - const matchingIndices = constMap.get(discriminatorValue); - if (matchingIndices && matchingIndices.length > 0) { - alternativesToTest = matchingIndices.map(idx => alternatives[idx]); - return; - } - } - // If this property covers all alternatives but we couldn't match, - break; - } - } - } else if (node.type === 'array') { - // Early exit: if node has no items, can't apply optimization - if (!node.items || node.items.length === 0) { - return; - } - - // Find array item indices that have const values in ALL alternatives - const indexConstMap = new Map>(); - - for (let i = 0; i < alternatives.length; i++) { - const altSchema = asSchema(alternatives[i]); - const itemSchemas = altSchema.prefixItems || (Array.isArray(altSchema.items) ? altSchema.items : undefined); - - if (!itemSchemas) { - // If any alternative has no item schemas, we can't use array index discrimination - return; - } - - itemSchemas.forEach((itemSchemaRef, itemIndex) => { - const itemSchema = asSchema(itemSchemaRef); - if (itemSchema.const !== undefined) { - if (!indexConstMap.has(itemIndex)) { - indexConstMap.set(itemIndex, new Map()); - } - const constMap = indexConstMap.get(itemIndex)!; - if (!constMap.has(itemSchema.const)) { - constMap.set(itemSchema.const, []); - } - constMap.get(itemSchema.const)!.push(i); - } - }); - } - - // Early exit: if no const items found, can't apply optimization - if (indexConstMap.size === 0) { - return; - } - - // Find an index where ALL alternatives have a const value (one per alternative) - for (const [itemIndex, constMap] of Array.from(indexConstMap.entries())) { - const coveredAlternatives = new Set(); - constMap.forEach((indices) => { - indices.forEach(idx => coveredAlternatives.add(idx)); - }); - // This index is a valid discriminator ONLY if ALL alternatives have a const value at this index - if (coveredAlternatives.size === alternatives.length) { - // Extract the discriminator value from the current node - const item = node.items[itemIndex]; - if (item?.type === 'string') { - const discriminatorValue = item.value; - const matchingIndices = constMap.get(discriminatorValue); - if (matchingIndices && matchingIndices.length > 0) { - alternativesToTest = matchingIndices.map(idx => alternatives[idx]); - return; - } - } - // If this index covers all alternatives but we couldn't match, - break; - } - } - } - }; - - tryDiscriminatorOptimization(); + const alternativesToTest = _tryDiscriminatorOptimization(alternatives) ?? alternatives; // remember the best match that is used for error messages let bestMatch: { schema: JSONSchema; validationResult: ValidationResult; matchingSchemas: ISchemaCollector; } | undefined = undefined; @@ -751,7 +622,77 @@ function validate(n: ASTNode | undefined, schema: JSONSchema, validationResult: } } + function _tryDiscriminatorOptimization(alternatives: JSONSchemaRef[]): JSONSchemaRef[] | undefined { + if (alternatives.length < 2) { + return undefined; + } + + const buildConstMap = (getSchemas: (alt: JSONSchema, idx: number) => [string | number, JSONSchema][] | undefined) => { + const constMap = new Map>(); + + for (let i = 0; i < alternatives.length; i++) { + const schemas = getSchemas(asSchema(alternatives[i]), i); + if (!schemas) { + return undefined; // Early exit if any alternative can't be processed + } + + schemas.forEach(([key, schema]) => { + if (schema.const !== undefined) { + if (!constMap.has(key)) { + constMap.set(key, new Map()); + } + const valueMap = constMap.get(key)!; + if (!valueMap.has(schema.const)) { + valueMap.set(schema.const, []); + } + valueMap.get(schema.const)!.push(i); + } + }); + } + return constMap; + }; + + const findDiscriminator = (constMap: Map>, getValue: (key: string | number) => any) => { + for (const [key, valueMap] of constMap) { + const coveredAlts = new Set(); + valueMap.forEach(indices => indices.forEach(idx => coveredAlts.add(idx))); + + if (coveredAlts.size === alternatives.length) { + const discriminatorValue = getValue(key); + const matchingIndices = valueMap.get(discriminatorValue); + if (matchingIndices?.length) { + return matchingIndices.map(idx => alternatives[idx]); + } + break; // Found valid discriminator but no match + } + } + return undefined; + }; + if (node.type === 'object' && node.properties?.length) { + const constMap = buildConstMap((schema) => + schema.properties ? Object.entries(schema.properties).map(([k, v]) => [k, asSchema(v)]) : undefined + ); + if (constMap) { + return findDiscriminator(constMap, (propName) => { + const prop = node.properties.find(p => p.keyNode.value === propName); + return prop?.valueNode?.type === 'string' ? prop.valueNode.value : undefined; + }); + } + } else if (node.type === 'array' && node.items?.length) { + const constMap = buildConstMap((schema) => { + const itemSchemas = schema.prefixItems || (Array.isArray(schema.items) ? schema.items : undefined); + return itemSchemas ? itemSchemas.map((item, idx) => [idx, asSchema(item)]) : undefined; + }); + if (constMap) { + return findDiscriminator(constMap, (itemIndex) => { + const item = node.items[itemIndex as number]; + return item?.type === 'string' ? item.value : undefined; + }); + } + } + return undefined; + } function _validateNumberNode(node: NumberASTNode): void { const val = node.value;