Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue: subset schemas don't work with unevaluatedProperties across oneOf branches #1585

Open
smikulcik opened this issue Feb 17, 2025 · 2 comments

Comments

@smikulcik
Copy link

I can't figure out how to set up mutual exclusion of subset schemas elegantly.

I thought unevaluatedProperties was the solution so that I can easily reject properties of the superset schema that don't appear in the subset schema, but I can't find the right pattern to set it up right.

Subset and Superset schemas

Let's define subset schema as such:

A schema is a subset schema of another schema if any instance of the first schema also is valid also in the second schema.

Similarly,

A schema is a superset schema of another schema if the other schema is a subset schema of it.

Rationale

I have a real world schema with 2 kinds of values. But the first option happens to be a subset of the second
option. I'd like to make it so that if the subset schema validates, the superset schema will not validate.

{
  "foo": (Either subset schema or superset schema)
}

Attempt 1 - Use not - PASS

oneOf:
- required: ['a']
  properties:
    a: {}
  not:
    required: ['b']
    properties:
      b: {}
- required: ['a', 'b']
  properties:
    a: {}
    b: {}

This one works as expected, where {"a": true, "b": true} only validates against the second of the oneOf options, but I have to nest the meat of the second option inside a not of the first. This is ugly, but effective. When I compose my schemas, I don't want to reference all possible superset schemas in the subset schema.

Attempt 2 - Use anyOf - PASS

anyOf:
- required: ['a']
  properties:
    a: {}
- required: ['a', 'b']
  properties:
    a: {}
    b: {}

This approach gives up on the "typing" quality of the oneOf where consumers of the instance can learn about the "type" of the schema by inspecting which branch validates. Consumers need more sophistication since both validate for {"a": true, "b": true}.

Attempt 3 - Use unevaluatedProperties at the top level - FAIL

unevaluatedProperties: false
oneOf:
- required: ['a']
  properties:
    a: {}
- required: ['a', 'b']
  properties:
    a: {}
    b: {}

For this instance, {"a": true, "b": true}, validation will fail on the oneOf since both branches validate and the collected annotations from the valid oneOf branches make both "a" and "b" evaluated properties.

Attempt 4 - push unevaluatedProperties to child schemas - FAIL

If I push down unevaluatedProperties: false, I get the same issue as we have with additionalProperties: false, schemas can not be extended if a parent "tacks on" some more properties.

oneOf:
- unevaluatedProperties: false
  required: ['a']
  properties:
    a: {}
- unevaluatedProperties: false
  required: ['a', 'b']
  properties:
    a: {}
    b: {}
properties:
  c: {}

If I try this JSON, the second oneOf will fail on unevaluatedProperty: "c"

{"a": true, "b": true, "c": true}

Attempt 5 - Introduce new in-place applicator firstOf: PASS

Let's define a new in place applicator, firstOf

This keyword's value MUST be a non-empty array. Each item of the array MUST be a valid JSON Schema.

An instance validates successfully against this keyword if it validates successfully against at least one schema defined by this keyword's value.

Annotations are only collected from the first valid sub-schema.

Consumers are to ignore subsequent valid subschemas beyond the first valid subschema

unevaluatedProperties: false
firstOf:
- required: ['a']
  properties:
    a: {}
- required: ['a', 'b']
  properties:
    a: {}
    b: {}

Both branches validate this instance, {"a": true, "b": true}, but only annotations from the first branch
are collected. This causes unevaluatedProperties to disregard annotations from the second branch and thereby reject property "b" as "unevaluated".

@gregsdennis
Copy link
Member

It sounds to me like you're trying to model a polymorphic system, which has historically been a problem for JSON Schema. Notably, JSON Schema is a constraint system, not a data modelling system. As such, it has a really hard time representing polymorphic types.

What I've typically seen done is encode a "type" property in your data model. This can then be used along with a const keyword to isolate which subschema should apply.

{
  "type": "object",
  "properties": {
    "a": true,
    "b": true,
    "c": true
  },
  "required": ["type"],
  "oneOf": [
    {
      "properties": {
        "type": { "const": "a" }
      },
      "required": ["a"]
    },
    {
      "properties": {
        "type": { "const": "b" }
      },
      "required": ["b"],
      "not": { "required": ["c"] }
    },
    {
      "properties": {
        "type": { "const": "ab" }
      },
      "required": ["a", "b"]
    },
  ]
}

Note

This operates very similarly to OpenAPI's discriminator concept, where type would be your discriminator. (OpenAPI's keyword actually allows the user to define the name of the property to use as a discriminator, in this case "type", and then there's additional subschema selection processing that happens. The above is functionally equivalent and only uses JSON Schema.)

This would validate

  • {"type": "a", "a": 1}
  • {"type": "a", "a": 1, "c": 3}
  • {"type": "b", "b": 2}
  • {"type": "ab", "a": 1, "b": 2}
  • {"type": "a", "a": 1, "b": 2, "c": 3}

and would fail

  • {"type": "b", "b": 2, "c": 3}

Notice that to restrict c, you only have to include the {not: {required: []}} structure.


In regard to unevaluatedProperties, you can put one at the root to forbid any properties that aren't listed in this schema. So for instance, if you wanted to disallow foo.

@smikulcik
Copy link
Author

Thanks @gregsdennis , Yeah it would be nice if I could add a type property, but this particular problem is a brownfield project. I can't change the JSON instances, but I can change the schema to something that will validate things.

Chatting with @hudlow, firstOf could be expressed as syntax sugar for an if/then chain

with firstOf

firstOf:
- required: ['a']
  properties:
    a: {}
- required: ['a', 'b']
  properties:
    a: {}
    b: {}
- required: ['a', 'b', 'c']
  properties:
    a: {}
    b: {}
    c: {}

with if/then chain

unevaluatedProperties: false
if:
  required: ['a']
  properties:
    a: {}
then:
  if:
    required: ['a', 'b']
    properties:
      a: {}
      b: {}
  then:
    required: ['a', 'b', 'c']
    properties:
      a: {}
      b: {}
      c: {}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants