From 410d5e65475fc0e4bd858ae3c52d47734d33f747 Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Tue, 22 Sep 2026 14:37:19 -0600 Subject: [PATCH 1/2] perf(schema_engine): cut per-node bookkeeping from compiled validation Validation spent most of its time on bookkeeping around each schema node rather than on assertions. No change to what is accepted or rejected. - Keep the reference-cycle guard on a stack compared by identity instead of a Set that hashed the mutable EvaluationPath via objectid per node. - Pass the CompiledNode down instead of re-finding it by NodeId, which has no hash method, for every child and $ref. - Resolve static $ref targets and properties children once per compiled graph into a JumpTable indexed by node; subschema/select share it. - Record a shared sentinel issue under branches read only for validity (anyOf/oneOf alternatives, not, if, contains) instead of rendering a path per failure; counts and max_issues are unchanged. - Check keyword presence before keyword_applies. - Runtime: cache the subschema view per descriptor under graph_lock, and decode Union{Absent, Nothing, X} directly when it has one real variant. --- src/runtime.jl | 34 ++++ src/schema_engine/compiled.jl | 96 ++++++++++ src/schema_engine/compiled_validation.jl | 218 +++++++++++++++-------- test/runtime.jl | 45 +++++ test/schema_engine/compiled.jl | 74 ++++++++ 5 files changed, 394 insertions(+), 73 deletions(-) diff --git a/src/runtime.jl b/src/runtime.jl index fc78547..f41d6bf 100644 --- a/src/runtime.jl +++ b/src/runtime.jl @@ -66,6 +66,7 @@ struct Spec default_server::String server::Base.RefValue{String} graphs::Dict{Symbol,Any} + subschemas::Dict{Tuple{Symbol,String,String},Any} graph_lock::ReentrantLock # Generated data is required and name-mapped so an omitted keyword or a # declaration reorder cannot silently substitute an empty or adjacent @@ -88,6 +89,7 @@ struct Spec default_server = normalized_server, server = Ref(normalized_server), graphs = Dict{Symbol,Any}(), + subschemas = Dict{Tuple{Symbol,String,String},Any}(), graph_lock = ReentrantLock(), ) ordered = map(field -> getproperty(values, field), fieldnames(Spec)) @@ -221,6 +223,17 @@ end function _schema_at(spec::Spec, descriptor, direction::Symbol = :neutral) descriptor === nothing && return nothing + # Every validation of a generated model asks for the same few views, and building one + # parses the resource URI and walks its pointer; keep each view once it exists. + key = (direction, String(descriptor.resource), String(descriptor.pointer)) + cached = lock(() -> get(spec.subschemas, key, nothing), spec.graph_lock) + cached === nothing || return cached + schema = _build_schema_at(spec, descriptor, direction) + lock(() -> (spec.subschemas[key] = schema), spec.graph_lock) + return schema +end + +function _build_schema_at(spec::Spec, descriptor, direction::Symbol) graph = _schema_graph(spec, direction) graph === nothing && throw(ArgumentError( "generated schema metadata has a descriptor but no schema roots; regenerate the module", @@ -639,6 +652,27 @@ function _decode_union( if value === nothing && Nothing in variants return nothing end + # `Union{Absent, Nothing, X}` is how every optional field is typed. With one variant to + # try, the ordering and collection below reduce to decoding it, so skip them. + candidate = nothing + candidates = 0 + for variant in variants + variant in (Absent, Nothing) && continue + candidates += 1 + candidate = variant + end + if candidates == 1 + try + return _decode(candidate, value, validate) + catch error + error isa DecodeError || rethrow() + throw( + DecodeError( + "value does not match any variant of $T: " * error.message, + ), + ) + end + end if !oneof preferred = Any[ variant for variant in variants diff --git a/src/schema_engine/compiled.jl b/src/schema_engine/compiled.jl index 80ad054..131b181 100644 --- a/src/schema_engine/compiled.jl +++ b/src/schema_engine/compiled.jl @@ -106,6 +106,64 @@ function _register_dialect_aliases!(compiler::Compiler, aliases::AbstractDict) return compiler end +""" +Evaluation shortcuts resolved once per compiled graph, indexed by `CompiledNode.index`. + +`refs[i]` is node `i`'s static `\$ref` target (the reference table's `NodeId` and the node it +compiles to); `properties[i]` is node `i`'s `properties` children in schema order. Either is +`nothing` when the node has none, or when some part could not be resolved at compile time, in +which case evaluation takes the general path exactly as before. They replace a `NodeId`- or +string-tuple-keyed lookup per `\$ref` and per property, which dominated validation time. +""" +struct JumpTable + refs::Vector{Union{Nothing,Tuple{Resources.NodeId,CompiledNode}}} + properties::Vector{Union{Nothing,Vector{Tuple{String,CompiledNode}}}} +end + +function JumpTable( + evaluation_nodes::AbstractDict, + transitions::AbstractDict, + references::AbstractDict, + registry, +) + count = maximum((node.index for node in values(evaluation_nodes)); init = 0) + refs = Vector{Union{Nothing,Tuple{Resources.NodeId,CompiledNode}}}(nothing, count) + properties = Vector{Union{Nothing,Vector{Tuple{String,CompiledNode}}}}(nothing, count) + for node in values(evaluation_nodes) + value = node.value + value isa AbstractDict || continue + if get(value, "\$ref", nothing) isa AbstractString + target = get(references, (node.id, "\$ref"), nothing) + if target !== nothing + compiled = get(evaluation_nodes, target, nothing) + if compiled === nothing + compiled = get( + evaluation_nodes, + Resources.canonical(registry, target), + nothing, + ) + end + compiled === nothing || (refs[node.index] = (target, compiled)) + end + end + declared = get(value, "properties", nothing) + declared isa AbstractDict || continue + children = Tuple{String,CompiledNode}[] + complete = true + for (name, subschema) in declared + (subschema isa AbstractDict || subschema isa Bool) || continue + child = get(transitions, (node.index, ("properties", String(name))), nothing) + if child === nothing + complete = false + break + end + push!(children, (String(name), child)) + end + complete && (properties[node.index] = children) + end + return JumpTable(refs, properties) +end + """A non-mutating, dialect-aware JSON Schema resource graph.""" struct CompiledSchema{R<:Resources.AbstractRetriever} data::Union{Resources.FrozenObject,Bool} @@ -122,6 +180,42 @@ struct CompiledSchema{R<:Resources.AbstractRetriever} reference_failures::ReferenceFailures regexes::Dict{String,Regex} retriever::R + jumps::JumpTable +end + +function CompiledSchema( + data, + dialect, + registry, + root, + dialects, + dialect_aliases, + evaluation_nodes, + transitions, + uses_annotations, + recursive_anchors, + references, + reference_failures, + regexes, + retriever, +) + return CompiledSchema( + data, + dialect, + registry, + root, + dialects, + dialect_aliases, + evaluation_nodes, + transitions, + uses_annotations, + recursive_anchors, + references, + reference_failures, + regexes, + retriever, + JumpTable(evaluation_nodes, transitions, references, registry), + ) end """A compiled graph with multiple JSON Schema roots embedded in JSON resources.""" @@ -1524,6 +1618,7 @@ function select(schemas::CompiledSchemas, requested::Resources.NodeId) getfield(template, :reference_failures), getfield(template, :regexes), template.retriever, + getfield(template, :jumps), ) end @@ -1567,6 +1662,7 @@ function subschema(template::CompiledSchema, requested::Resources.NodeId) getfield(template, :reference_failures), getfield(template, :regexes), template.retriever, + getfield(template, :jumps), ) end diff --git a/src/schema_engine/compiled_validation.jl b/src/schema_engine/compiled_validation.jl index ba1e098..1acba2f 100644 --- a/src/schema_engine/compiled_validation.jl +++ b/src/schema_engine/compiled_validation.jl @@ -38,7 +38,11 @@ end mutable struct EvaluationContext schema::CompiledSchema - active::Set{Tuple{Int,EvaluationPath,Tuple{Vararg{Resources.ResourceId}}}} + # `(compiled node index, instance path, dynamic scope)` for every node being evaluated, + # outermost first, for reference-cycle detection. A stack scanned by identity rather than + # a `Set`: entries are pushed and popped in call order, the stack is only as deep as the + # evaluation, and a `Set` had to hash the mutable path by `objectid` on every node visited. + active::Vector{Tuple{Int,EvaluationPath,Vector{Resources.ResourceId}}} regexes::Dict{String,Regex} evaluations::Int depth::Int @@ -48,6 +52,9 @@ mutable struct EvaluationContext collect_all::Bool annotations::Bool tracks_cycles::Bool + # > 0 while evaluating a branch whose issues are discarded (`anyOf`/`oneOf` alternatives, + # `not`, an `if` condition, `contains` candidates): only its validity is read. + speculative::Int end function EvaluationContext( @@ -63,7 +70,7 @@ function EvaluationContext( max_depth > 0 || throw(ArgumentError("max_depth must be positive")) return EvaluationContext( schema, - Set{Tuple{Int,EvaluationPath,Tuple{Vararg{Resources.ResourceId}}}}(), + Tuple{Int,EvaluationPath,Vector{Resources.ResourceId}}[], copy(getfield(schema, :regexes)), 0, 0, @@ -73,6 +80,7 @@ function EvaluationContext( collect_all, getfield(schema, :uses_annotations), !isempty(getfield(schema, :references)), + 0, ) end @@ -107,6 +115,42 @@ function _regex(context::EvaluationContext, pattern::AbstractString) end end +struct _LazyIssue + x::Any + path::EvaluationPath + keyword::String + value::Any +end + +# Stands in for every issue raised under a speculative branch. Counted like a real one, so +# `max_issues` behaves the same, but never built: building one renders the instance path. +const _SPECULATIVE_ISSUE = SingleIssue(nothing, "", "speculative", nothing) + +function _invalidate!( + result::EvaluationResult, + context::EvaluationContext, + lazy::_LazyIssue, +) + !context.collect_all && !result.valid && return result + if context.speculative > 0 + return _invalidate!(result, context, _SPECULATIVE_ISSUE) + end + return _invalidate!( + result, + context, + _issue(lazy.x, lazy.path, lazy.keyword, lazy.value), + ) +end + +function _speculative_result(context, node, tokens, x, path, dynamic_scope) + context.speculative += 1 + try + return _child_result(context, node, tokens, x, path, dynamic_scope) + finally + context.speculative -= 1 + end +end + function _invalidate!( result::EvaluationResult, context::EvaluationContext, @@ -216,8 +260,7 @@ function _canonical(schema::CompiledSchema, node::Resources.NodeId) return _compiled_node(schema, node).id end -function _compiled_child(schema::CompiledSchema, node::Resources.NodeId, tokens) - parent = _compiled_node(schema, node) +function _compiled_child(schema::CompiledSchema, parent::CompiledNode, tokens) key = (parent.index, tokens) return get( () -> throw( @@ -282,16 +325,16 @@ function _simple_assertions!( ) expected = get(schema, "type", nothing) if expected !== nothing && !_type_valid(x, expected) - _invalidate!(result, context, _issue(x, path, "type", expected)) + _invalidate!(result, context, _LazyIssue(x, path, "type", expected)) end enum = get(schema, "enum", nothing) if enum isa AbstractVector && !any(value -> _isequal(x, value), enum) - _invalidate!(result, context, _issue(x, path, "enum", enum)) + _invalidate!(result, context, _LazyIssue(x, path, "enum", enum)) end - if keyword_applies(schema_dialect, "const") && - haskey(schema, "const") && + if haskey(schema, "const") && + keyword_applies(schema_dialect, "const") && !_isequal(x, schema["const"]) - _invalidate!(result, context, _issue(x, path, "const", schema["const"])) + _invalidate!(result, context, _LazyIssue(x, path, "const", schema["const"])) end if x isa Real && !(x isa Bool) multiple = get(schema, "multipleOf", nothing) @@ -299,16 +342,16 @@ function _simple_assertions!( _invalidate!( result, context, - _issue(x, path, "multipleOf", multiple), + _LazyIssue(x, path, "multipleOf", multiple), ) end maximum = get(schema, "maximum", nothing) if maximum isa Real && x > maximum - _invalidate!(result, context, _issue(x, path, "maximum", maximum)) + _invalidate!(result, context, _LazyIssue(x, path, "maximum", maximum)) end minimum = get(schema, "minimum", nothing) if minimum isa Real && x < minimum - _invalidate!(result, context, _issue(x, path, "minimum", minimum)) + _invalidate!(result, context, _LazyIssue(x, path, "minimum", minimum)) end exclusive_maximum = get(schema, "exclusiveMaximum", nothing) if schema_dialect.name != :draft4 && @@ -318,7 +361,7 @@ function _simple_assertions!( _invalidate!( result, context, - _issue(x, path, "exclusiveMaximum", exclusive_maximum), + _LazyIssue(x, path, "exclusiveMaximum", exclusive_maximum), ) elseif schema_dialect.name == :draft4 && exclusive_maximum === true && @@ -327,7 +370,7 @@ function _simple_assertions!( _invalidate!( result, context, - _issue(x, path, "exclusiveMaximum", exclusive_maximum), + _LazyIssue(x, path, "exclusiveMaximum", exclusive_maximum), ) end exclusive_minimum = get(schema, "exclusiveMinimum", nothing) @@ -338,7 +381,7 @@ function _simple_assertions!( _invalidate!( result, context, - _issue(x, path, "exclusiveMinimum", exclusive_minimum), + _LazyIssue(x, path, "exclusiveMinimum", exclusive_minimum), ) elseif schema_dialect.name == :draft4 && exclusive_minimum === true && @@ -347,7 +390,7 @@ function _simple_assertions!( _invalidate!( result, context, - _issue(x, path, "exclusiveMinimum", exclusive_minimum), + _LazyIssue(x, path, "exclusiveMinimum", exclusive_minimum), ) end end @@ -356,34 +399,34 @@ function _simple_assertions!( maximum isa Real && isinteger(maximum) && length(x) > maximum && - _invalidate!(result, context, _issue(x, path, "maxLength", maximum)) + _invalidate!(result, context, _LazyIssue(x, path, "maxLength", maximum)) minimum = get(schema, "minLength", nothing) minimum isa Real && isinteger(minimum) && length(x) < minimum && - _invalidate!(result, context, _issue(x, path, "minLength", minimum)) + _invalidate!(result, context, _LazyIssue(x, path, "minLength", minimum)) pattern = get(schema, "pattern", nothing) pattern isa AbstractString && !occursin(_regex(context, pattern), x) && - _invalidate!(result, context, _issue(x, path, "pattern", pattern)) + _invalidate!(result, context, _LazyIssue(x, path, "pattern", pattern)) elseif x isa AbstractVector maximum = get(schema, "maxItems", nothing) maximum isa Real && isinteger(maximum) && length(x) > maximum && - _invalidate!(result, context, _issue(x, path, "maxItems", maximum)) + _invalidate!(result, context, _LazyIssue(x, path, "maxItems", maximum)) minimum = get(schema, "minItems", nothing) minimum isa Real && isinteger(minimum) && length(x) < minimum && - _invalidate!(result, context, _issue(x, path, "minItems", minimum)) + _invalidate!(result, context, _LazyIssue(x, path, "minItems", minimum)) if get(schema, "uniqueItems", false) === true for left in eachindex(x), right in firstindex(x):(left-1) if _isequal(x[left], x[right]) _invalidate!( result, context, - _issue(x, path, "uniqueItems", true), + _LazyIssue(x, path, "uniqueItems", true), ) break end @@ -397,7 +440,7 @@ function _simple_assertions!( _invalidate!( result, context, - _issue(x, path, "maxProperties", maximum), + _LazyIssue(x, path, "maxProperties", maximum), ) minimum = get(schema, "minProperties", nothing) minimum isa Real && @@ -406,19 +449,19 @@ function _simple_assertions!( _invalidate!( result, context, - _issue(x, path, "minProperties", minimum), + _LazyIssue(x, path, "minProperties", minimum), ) required = get(schema, "required", nothing) if required isa AbstractVector all(name -> haskey(x, name), required) || _invalidate!( result, context, - _issue(x, path, "required", required), + _LazyIssue(x, path, "required", required), ) end dependent = get(schema, "dependentRequired", nothing) - keyword_applies(schema_dialect, "dependentRequired") && - dependent isa AbstractDict && + dependent isa AbstractDict && + keyword_applies(schema_dialect, "dependentRequired") && _dependent_required!( result, context, @@ -428,8 +471,8 @@ function _simple_assertions!( "dependentRequired", ) dependencies = get(schema, "dependencies", nothing) - keyword_applies(schema_dialect, "dependencies") && - dependencies isa AbstractDict && + dependencies isa AbstractDict && + keyword_applies(schema_dialect, "dependencies") && _dependent_required!( result, context, @@ -451,7 +494,7 @@ function _dependent_required!(result, context, x, dependencies, path, keyword) all(name -> haskey(x, name), required) || _invalidate!( result, context, - _issue(x, path, keyword, dependencies), + _LazyIssue(x, path, keyword, dependencies), ) end return result @@ -459,14 +502,14 @@ end function _reference_target( context::EvaluationContext, - node::Resources.NodeId, + node::CompiledNode, keyword::String, reference_text::AbstractString, dynamic_scope::Vector{Resources.ResourceId}; dynamic::Bool = false, recursive::Bool = false, ) - canonical_node = _canonical(context.schema, node) + canonical_node = node.id reference = dynamic ? Resources.Reference(canonical_node.resource, reference_text) : nothing @@ -516,7 +559,7 @@ end function _follow_reference( context::EvaluationContext, - node::Resources.NodeId, + node::CompiledNode, keyword::String, reference_text::AbstractString, x, @@ -525,33 +568,36 @@ function _follow_reference( dynamic::Bool = false, recursive::Bool = false, ) - target = _reference_target( - context, - node, - keyword, - reference_text, - dynamic_scope; - dynamic, - recursive, - ) + jump = nothing + if keyword == "\$ref" && !dynamic && !recursive + jump = getfield(context.schema, :jumps).refs[node.index] + end + if jump === nothing + target = _reference_target( + context, + node, + keyword, + reference_text, + dynamic_scope; + dynamic, + recursive, + ) + compiled = _compiled_node(context.schema, target) + else + target, compiled = jump + end next_scope = dynamic_scope if isempty(dynamic_scope) || last(dynamic_scope) != target.resource next_scope = copy(dynamic_scope) push!(next_scope, target.resource) end - return _evaluate_compiled_node( - context, - _compiled_node(context.schema, target), - x, - path, - next_scope, - ) + return _evaluate_compiled_node(context, compiled, x, path, next_scope) end function _references!( result::EvaluationResult, context::EvaluationContext, - node::Resources.NodeId, + node::CompiledNode, x, schema::AbstractDict, schema_dialect::Dialect, @@ -645,7 +691,7 @@ function _combinators!( schemas isa AbstractVector || continue valid = EvaluationResult[] for index in eachindex(schemas) - child = _child_result( + child = _speculative_result( context, node, (keyword, string(index - 1)), @@ -661,22 +707,22 @@ function _combinators!( _absorb!(result, context, child) end else - _invalidate!(result, context, _issue(x, path, keyword, schemas)) + _invalidate!(result, context, _LazyIssue(x, path, keyword, schemas)) _stopped(context, result) && return result end end negated = get(schema, "not", nothing) if negated isa AbstractDict || negated isa Bool - child = _child_result(context, node, ("not",), x, path, dynamic_scope) + child = _speculative_result(context, node, ("not",), x, path, dynamic_scope) if child.valid - _invalidate!(result, context, _issue(x, path, "not", negated)) + _invalidate!(result, context, _LazyIssue(x, path, "not", negated)) _stopped(context, result) && return result end end condition = get(schema, "if", nothing) - if keyword_applies(schema_dialect, "if") && - (condition isa AbstractDict || condition isa Bool) - child = _child_result(context, node, ("if",), x, path, dynamic_scope) + if (condition isa AbstractDict || condition isa Bool) && + keyword_applies(schema_dialect, "if") + child = _speculative_result(context, node, ("if",), x, path, dynamic_scope) child.valid && _absorb!(result, context, child) branch = child.valid ? "then" : "else" selected = get(schema, branch, nothing) @@ -704,7 +750,23 @@ function _object_applicators!( tracks_coverage = additional isa AbstractDict || additional isa Bool covered = tracks_coverage ? Set{String}() : nothing properties = get(schema, "properties", nothing) - if properties isa AbstractDict + children = getfield(context.schema, :jumps).properties[node.index] + if children !== nothing + for (name, compiled) in children + haskey(x, name) || continue + child = _evaluate_compiled_node( + context, + compiled, + x[name], + _property_path(path, name), + dynamic_scope, + ) + _absorb!(result, context, child; annotations = false) + _stopped(context, result) && return result + tracks_coverage && push!(covered::Set{String}, name) + context.annotations && _mark_property!(result, name) + end + elseif properties isa AbstractDict for (name, subschema) in properties haskey(x, name) || continue (subschema isa AbstractDict || subschema isa Bool) || continue @@ -761,8 +823,8 @@ function _object_applicators!( end end names = get(schema, "propertyNames", nothing) - if keyword_applies(schema_dialect, "propertyNames") && - (names isa AbstractDict || names isa Bool) + if (names isa AbstractDict || names isa Bool) && + keyword_applies(schema_dialect, "propertyNames") for name in keys(x) child = _child_result( context, @@ -777,9 +839,9 @@ function _object_applicators!( end end for keyword in ("dependencies", "dependentSchemas") - keyword_applies(schema_dialect, keyword) || continue dependencies = get(schema, keyword, nothing) dependencies isa AbstractDict || continue + keyword_applies(schema_dialect, keyword) || continue for (name, subschema) in dependencies haskey(x, name) || continue (subschema isa AbstractDict || subschema isa Bool) || continue @@ -911,11 +973,11 @@ function _array_applicators!( end end contains = get(schema, "contains", nothing) - if keyword_applies(schema_dialect, "contains") && - (contains isa AbstractDict || contains isa Bool) + if (contains isa AbstractDict || contains isa Bool) && + keyword_applies(schema_dialect, "contains") matches = BitSet() for index in eachindex(x) - child = _child_result( + child = _speculative_result( context, node, ("contains",), @@ -932,7 +994,7 @@ function _array_applicators!( keyword_applies(schema_dialect, "maxContains") ? get(schema, "maxContains", typemax(Int)) : typemax(Int) if !(minimum <= length(matches) <= maximum) - _invalidate!(result, context, _issue(x, path, "contains", contains)) + _invalidate!(result, context, _LazyIssue(x, path, "contains", contains)) else if context.annotations && !isempty(matches) result.items === nothing && (result.items = BitSet()) @@ -1018,7 +1080,7 @@ _unevaluated!(result, context, node, x, schema, path, dynamic_scope) = result function _evaluate_schema( context::EvaluationContext, - node::Resources.NodeId, + node::CompiledNode, x, schema::Bool, schema_dialect::Dialect, @@ -1026,13 +1088,13 @@ function _evaluate_schema( dynamic_scope, ) result = EvaluationResult() - schema || _invalidate!(result, context, _issue(x, path, "schema", false)) + schema || _invalidate!(result, context, _LazyIssue(x, path, "schema", false)) return result end function _evaluate_schema( context::EvaluationContext, - node::Resources.NodeId, + node::CompiledNode, x, schema::AbstractDict, schema_dialect::Dialect, @@ -1093,6 +1155,16 @@ function _evaluate_schema( return result end +const _NO_SCOPE = Resources.ResourceId[] + +function _is_active(context::EvaluationContext, active) + for entry in context.active + entry[1] == active[1] && entry[2] === active[2] && entry[3] == active[3] && + return true + end + return false +end + function _evaluate_compiled_node( context::EvaluationContext, compiled::CompiledNode, @@ -1129,7 +1201,7 @@ function _evaluate_compiled_node( try return _evaluate_schema( context, - node, + compiled, x, compiled.value, compiled.dialect, @@ -1142,9 +1214,9 @@ function _evaluate_compiled_node( end scoped_key = compiled.dialect.dynamic_refs || compiled.dialect.recursive_refs ? - Tuple(scoped) : () + scoped : _NO_SCOPE active = (compiled.index, path, scoped_key) - if active in context.active + if _is_active(context, active) context.depth -= 1 throw( EvaluationError( @@ -1158,7 +1230,7 @@ function _evaluate_compiled_node( try return _evaluate_schema( context, - node, + compiled, x, compiled.value, compiled.dialect, @@ -1166,7 +1238,7 @@ function _evaluate_compiled_node( scoped, ) finally - delete!(context.active, active) + pop!(context.active) context.depth -= 1 end end diff --git a/test/runtime.jl b/test/runtime.jl index 58c6c7b..e900116 100644 --- a/test/runtime.jl +++ b/test/runtime.jl @@ -636,3 +636,48 @@ @test occursin("ff00", shown) end end + +@testset "runtime validation shortcuts" begin + Runtime = OpenAPI.Runtime + # An optional field's union decodes its one real variant directly, with the same error. + optional = Union{Nothing,Runtime.Absent,Int64} + @test Runtime._decode_union(optional, 3) == 3 + @test Runtime._decode_union(optional, nothing) === nothing + error = try + Runtime._decode_union(optional, "three") + nothing + catch caught + caught + end + @test error isa Runtime.DecodeError + @test startswith(error.message, "value does not match any variant of") + + # The subschema view for a descriptor is built once per spec and direction. + resource = "https://example.test/cached-schema" + spec = Runtime.Spec(; + security_schemes = Dict{String,NamedTuple}(), + resources = Any[( + id = resource, + retrieval = resource, + media_type = "application/schema+json", + json = JSON.json( + OpenAPI.obj( + "\$defs" => OpenAPI.obj("n" => OpenAPI.obj("type" => "integer")), + ), + ), + )], + roots = Any[( + resource = resource, + pointer = "", + dialect = SchemaEngine.DRAFT202012, + )], + dialects = Any[], + directional_required = Any[], + default_server = "", + ) + descriptor = (resource = resource, pointer = "/\$defs/n") + view = Runtime._schema_at(spec, descriptor) + @test Runtime._schema_at(spec, descriptor) === view + @test Runtime._schema_valid(spec, descriptor, 1) + @test !Runtime._schema_valid(spec, descriptor, "one") +end diff --git a/test/schema_engine/compiled.jl b/test/schema_engine/compiled.jl index 81354b6..6e03770 100644 --- a/test/schema_engine/compiled.jl +++ b/test/schema_engine/compiled.jl @@ -277,6 +277,80 @@ end ) end +@testset "Compiled evaluation shortcuts" begin + # `$ref` targets and `properties` children resolved at compile time must report the same + # results, paths, and order as resolving them per evaluation. + schema = SchemaEngine.CompiledSchema( + OpenAPI.obj( + "\$schema" => SchemaEngine.DRAFT202012.uri, + "\$defs" => OpenAPI.obj( + "limits" => OpenAPI.obj( + "type" => "object", + "properties" => OpenAPI.obj( + "min" => OpenAPI.obj("type" => "number"), + "max" => OpenAPI.obj("type" => "number"), + ), + "required" => Any["min", "max"], + ), + ), + "type" => "object", + "properties" => OpenAPI.obj( + "name" => OpenAPI.obj("type" => "string"), + "limits" => OpenAPI.obj("\$ref" => "#/\$defs/limits"), + "rating" => OpenAPI.obj( + "anyOf" => Any[ + OpenAPI.obj("type" => "number", "minimum" => 0), + OpenAPI.obj("type" => "null"), + ], + ), + ), + ), + ) + @test isvalid( + schema, + Dict( + "name" => "a", + "limits" => Dict("min" => 0, "max" => 1), + "rating" => nothing, + ), + ) + + issue = SchemaEngine.validate( + schema, + Dict("limits" => Dict("min" => "low", "max" => 1)), + ) + @test issue.path == "/limits/min" + @test issue.reason == "type" + + # Every alternative failing reports the combinator itself, not its alternatives. + issues = SchemaEngine.validate(schema, Dict("rating" => -1); fail_fast = false) + @test length(issues) == 1 + @test only(issues).path == "/rating" + @test only(issues).reason == "anyOf" + + # Collected issues follow the schema's declared property order. + issues = SchemaEngine.validate( + schema, + Dict("rating" => "high", "limits" => Dict("min" => 0), "name" => 1); + fail_fast = false, + ) + @test [issue.path for issue in issues] == ["/name", "/limits", "/rating"] + + # Branches read only for validity still decide the result. + conditional = SchemaEngine.CompiledSchema( + OpenAPI.obj( + "\$schema" => SchemaEngine.DRAFT202012.uri, + "if" => OpenAPI.obj("type" => "string"), + "then" => OpenAPI.obj("minLength" => 2), + "not" => OpenAPI.obj("const" => "no"), + ), + ) + @test isvalid(conditional, 5) + @test isvalid(conditional, "ok") + @test SchemaEngine.validate(conditional, "a").reason == "minLength" + @test SchemaEngine.validate(conditional, "no").reason == "not" +end + @testset "Nested resource canonicalization" begin child = Dict( "\$id" => "sub/", From 57a0168fd5bd7ffec613176cc3dac74d78266df2 Mon Sep 17 00:00:00 2001 From: Jose Daniel Lara Date: Fri, 25 Sep 2026 13:47:32 -0600 Subject: [PATCH 2/2] fix(schema_engine): keep instance path in max_issues error under speculative branches The speculative issue sentinel carried an empty path, so hitting max_issues inside an anyOf/oneOf/not/if/contains branch reported the instance root. Keep the unrendered EvaluationPath in the sentinel and render it only at the two throw sites. --- src/schema_engine/compiled_validation.jl | 20 ++++++++++++++------ test/schema_engine/compiled.jl | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/schema_engine/compiled_validation.jl b/src/schema_engine/compiled_validation.jl index 1acba2f..95a548c 100644 --- a/src/schema_engine/compiled_validation.jl +++ b/src/schema_engine/compiled_validation.jl @@ -122,9 +122,16 @@ struct _LazyIssue value::Any end -# Stands in for every issue raised under a speculative branch. Counted like a real one, so -# `max_issues` behaves the same, but never built: building one renders the instance path. -const _SPECULATIVE_ISSUE = SingleIssue(nothing, "", "speculative", nothing) +# Stands in for an issue raised under a speculative branch. Counted like a real one, so +# `max_issues` behaves the same, but the instance path is kept unrendered in `val` and only +# rendered if the issue limit is reached and the path has to appear in the error. +_speculative_issue(path::EvaluationPath) = SingleIssue(nothing, "", "speculative", path) + +function _issue_path(issue::SingleIssue) + issue.reason == "speculative" && issue.val isa EvaluationPath && + return _path_string(issue.val::EvaluationPath) + return issue.path +end function _invalidate!( result::EvaluationResult, @@ -133,7 +140,7 @@ function _invalidate!( ) !context.collect_all && !result.valid && return result if context.speculative > 0 - return _invalidate!(result, context, _SPECULATIVE_ISSUE) + return _invalidate!(result, context, _speculative_issue(lazy.path)) end return _invalidate!( result, @@ -162,7 +169,7 @@ function _invalidate!( issue_count < context.max_issues || throw( EvaluationError( context.schema.root, - issue.path, + _issue_path(issue), "the issue limit was reached", ), ) @@ -219,7 +226,7 @@ function _absorb!( issue_count + length(child_issues) <= context.max_issues || throw( EvaluationError( context.schema.root, - isempty(child_issues) ? "" : first(child_issues).path, + isempty(child_issues) ? "" : _issue_path(first(child_issues)), "the issue limit was reached", ), ) @@ -1155,6 +1162,7 @@ function _evaluate_schema( return result end +# Shared sentinel; compared only. Never push!/append! to it. const _NO_SCOPE = Resources.ResourceId[] function _is_active(context::EvaluationContext, active) diff --git a/test/schema_engine/compiled.jl b/test/schema_engine/compiled.jl index 6e03770..c3011f8 100644 --- a/test/schema_engine/compiled.jl +++ b/test/schema_engine/compiled.jl @@ -260,6 +260,25 @@ end @test isempty( SchemaEngine.validate(speculative, 1; fail_fast = false, max_issues = 1), ) + # The issue limit error names the real location even under a speculative branch. + for branch in ( + Dict("allOf" => Any[false, false, false]), + Dict("minimum" => 10, "maximum" => 0, "multipleOf" => 7), + ) + limited = SchemaEngine.CompiledSchema( + Dict( + "\$schema" => SchemaEngine.DRAFT202012.uri, + "properties" => Dict("p" => Dict("anyOf" => Any[branch])), + ), + ) + err = try + SchemaEngine.validate(limited, Dict("p" => 1); fail_fast = false, max_issues = 2) + catch caught + caught + end + @test err isa SchemaEngine.EvaluationError + @test occursin("instance \"/p\"", sprint(showerror, err)) + end short_circuit = SchemaEngine.CompiledSchema( Dict(