Skip to content

Commit 9e11aa0

Browse files
authored
Merge pull request #1191 from python-openapi/feature/schema-validator-per-resolver-clarification
SchemaValidator per-resolver cache clarification
2 parents 08086ed + 72abf0e commit 9e11aa0

3 files changed

Lines changed: 214 additions & 4 deletions

File tree

openapi_core/validation/schemas/validators.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,15 @@ def validate(self, value: Any) -> None:
4848
raise InvalidSchemaValue(value, schema_type, schema_errors=errors)
4949

5050
# Cache the recursive "does this schema benefit from a ValidationState?"
51-
# check, keyed on the SchemaPath. SchemaPath is hashed by content, so
52-
# two SchemaPaths pointing at the same spec location share a cache
53-
# slot regardless of identity -- safe across GC, bounded by the number
54-
# of distinct schema shapes in the spec rather than by input volume.
51+
# check, keyed on the SchemaPath. Under jsonschema-path 0.5 (pathable
52+
# 0.6) SchemaPath is an AccessorPath whose identity is
53+
# (parts, accessor), and SchemaAccessor in turn hashes/compares on
54+
# id(node) and id(path_resolver). The key is therefore effectively
55+
# per-resolver: two SchemaPaths share a cache slot only when they
56+
# address the same location *within the same loaded spec*, never
57+
# across distinct specs that merely share a JSON-pointer path.
58+
# Entries are bounded by the number of distinct schema shapes per
59+
# spec and become collectable once the owning resolver is GC'd.
5560
_needs_state_cache: dict[SchemaPath, bool] = {}
5661

5762
@classmethod

tests/integration/unmarshalling/test_unmarshallers.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2174,3 +2174,66 @@ def test_subschema_null(self, spec, unmarshallers_factory):
21742174
result = unmarshaller.unmarshal(value)
21752175

21762176
assert result is None
2177+
2178+
2179+
class TestCrossSpecUnmarshalling:
2180+
"""Unmarshalling two separately-loaded specs in the same process must
2181+
produce independent, correct results even when the specs share
2182+
JSON-pointer paths.
2183+
"""
2184+
2185+
@pytest.fixture
2186+
def root(self):
2187+
return SchemaPath.from_dict({})
2188+
2189+
def test_composed_and_plain_specs_unmarshal_independently(self, root):
2190+
# Composed: oneOf selects the object branch, whose date format
2191+
# must be applied -> created_at becomes a date object.
2192+
composed = SchemaPath.from_dict(
2193+
{
2194+
"oneOf": [
2195+
{
2196+
"type": "object",
2197+
"properties": {
2198+
"created_at": {
2199+
"type": "string",
2200+
"format": "date",
2201+
}
2202+
},
2203+
},
2204+
{"type": "integer"},
2205+
]
2206+
}
2207+
)
2208+
# Plain: same JSON-pointer shape but no composition and no
2209+
# format -> created_at stays a string.
2210+
plain = SchemaPath.from_dict(
2211+
{
2212+
"type": "object",
2213+
"properties": {"created_at": {"type": "string"}},
2214+
}
2215+
)
2216+
2217+
value = {"created_at": "2020-01-02"}
2218+
2219+
composed_result = oas31_schema_unmarshallers_factory.create(
2220+
root, composed
2221+
).unmarshal(value)
2222+
plain_result = oas31_schema_unmarshallers_factory.create(
2223+
root, plain
2224+
).unmarshal(value)
2225+
2226+
assert composed_result == {"created_at": date(2020, 1, 2)}
2227+
assert plain_result == {"created_at": "2020-01-02"}
2228+
2229+
# Order independence: re-run in reverse to ensure neither spec's
2230+
# cached answer poisoned the other.
2231+
plain_again = oas31_schema_unmarshallers_factory.create(
2232+
root, plain
2233+
).unmarshal(value)
2234+
composed_again = oas31_schema_unmarshallers_factory.create(
2235+
root, composed
2236+
).unmarshal(value)
2237+
2238+
assert plain_again == {"created_at": "2020-01-02"}
2239+
assert composed_again == {"created_at": date(2020, 1, 2)}

tests/unit/validation/test_schema_validators.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
oas30_write_schema_validators_factory,
66
)
77
from openapi_core.validation.schemas.exceptions import InvalidSchemaValue
8+
from openapi_core.validation.schemas.validators import SchemaValidator
89

910

1011
class TestSchemaValidate:
@@ -356,3 +357,144 @@ def test_enforce_properties_required_applies_to_nested_composed_schemas(
356357
schema,
357358
enforce_properties_required=True,
358359
).validate({"name": "openapi-core", "meta": {}})
360+
361+
362+
class TestSchemaValidateState:
363+
SCHEMA_DICT = {
364+
"type": "object",
365+
"properties": {
366+
"x": {"oneOf": [{"type": "string"}, {"type": "integer"}]}
367+
},
368+
}
369+
VALUE = {"x": "hi"}
370+
371+
@pytest.fixture(autouse=True)
372+
def clear_cache(self):
373+
# Keep this class's cache observations isolated from other tests.
374+
SchemaValidator._needs_state_cache.clear()
375+
yield
376+
SchemaValidator._needs_state_cache.clear()
377+
378+
@pytest.fixture
379+
def cache(self):
380+
return SchemaValidator._needs_state_cache
381+
382+
@pytest.fixture
383+
def validator_and_prop_factory(self):
384+
# Build a validator over a freshly loaded spec and return it
385+
# alongside the SchemaPath the cache keys on for property "x".
386+
root = SchemaPath.from_dict({})
387+
388+
def _build(schema_dict):
389+
spec = SchemaPath.from_dict(schema_dict)
390+
validator = oas30_write_schema_validators_factory.create(
391+
root, spec
392+
)
393+
prop = spec / "properties" / "x"
394+
return validator, prop
395+
396+
return _build
397+
398+
def test_cold_pass_populates_cache(
399+
self, cache, validator_and_prop_factory
400+
):
401+
validator, prop = validator_and_prop_factory(self.SCHEMA_DICT)
402+
assert prop not in cache
403+
404+
validator.validate_state(self.VALUE)
405+
406+
# oneOf under "x" -> a ValidationState is worthwhile.
407+
assert cache[prop] is True
408+
409+
def test_warm_pass_reads_cached_answer(
410+
self, cache, validator_and_prop_factory
411+
):
412+
validator, prop = validator_and_prop_factory(self.SCHEMA_DICT)
413+
validator.validate_state(self.VALUE) # prime
414+
# Poison the entry: a genuine cache hit returns this value
415+
# unchanged, whereas a recompute would overwrite it back to True.
416+
cache[prop] = False
417+
418+
validator.validate_state(self.VALUE)
419+
420+
assert cache[prop] is False
421+
422+
def test_distinct_spec_does_not_collide(
423+
self, cache, validator_and_prop_factory
424+
):
425+
# Two separately loaded specs with identical contents have
426+
# distinct identity, so their equally-pathed property schemas
427+
# occupy separate cache slots instead of colliding.
428+
validator_a, prop_a = validator_and_prop_factory(self.SCHEMA_DICT)
429+
validator_b, prop_b = validator_and_prop_factory(self.SCHEMA_DICT)
430+
431+
validator_a.validate_state(self.VALUE)
432+
assert prop_a in cache
433+
assert prop_b not in cache
434+
435+
validator_b.validate_state(self.VALUE)
436+
assert cache[prop_a] is True
437+
assert cache[prop_b] is True
438+
439+
440+
class TestSchemaValidateStateRefDedup:
441+
# A single composed schema reached through two different $ref aliases.
442+
SCHEMA_DICT = {
443+
"type": "object",
444+
"properties": {
445+
"a": {"$ref": "#/$defs/Composed"},
446+
"b": {"$ref": "#/$defs/Composed"},
447+
},
448+
"$defs": {
449+
"Composed": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
450+
},
451+
}
452+
VALUE = {"a": "hi", "b": 1}
453+
454+
@pytest.fixture(autouse=True)
455+
def clear_cache(self):
456+
SchemaValidator._needs_state_cache.clear()
457+
yield
458+
SchemaValidator._needs_state_cache.clear()
459+
460+
@pytest.fixture
461+
def cache(self):
462+
return SchemaValidator._needs_state_cache
463+
464+
@pytest.fixture
465+
def validator_and_props_factory(self):
466+
root = SchemaPath.from_dict({})
467+
468+
def _build(schema_dict):
469+
spec = SchemaPath.from_dict(schema_dict)
470+
validator = oas30_write_schema_validators_factory.create(
471+
root, spec
472+
)
473+
prop_a = spec / "properties" / "a"
474+
prop_b = spec / "properties" / "b"
475+
canonical = spec / "$defs" / "Composed"
476+
return validator, prop_a, prop_b, canonical
477+
478+
return _build
479+
480+
@pytest.mark.xfail(
481+
strict=True,
482+
reason=(
483+
"The cache keys on the navigation path, so each $ref "
484+
"alias gets its own slot. Once the cache keys on canonical "
485+
"the aliases collapse to a single entry."
486+
),
487+
)
488+
def test_aliases_to_same_node_share_one_cache_slot(
489+
self, cache, validator_and_props_factory
490+
):
491+
validator, prop_a, prop_b, canonical = validator_and_props_factory(
492+
self.SCHEMA_DICT
493+
)
494+
495+
validator.validate_state(self.VALUE)
496+
497+
assert len(cache) == 1
498+
assert prop_a not in cache
499+
assert prop_b not in cache
500+
assert cache[canonical] is True

0 commit comments

Comments
 (0)