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

anyOf/oneOf with Discriminated Unions + Null doesn't work #4380

Open
4 tasks done
guilhermedelyra opened this issue Nov 13, 2024 · 7 comments
Open
4 tasks done

anyOf/oneOf with Discriminated Unions + Null doesn't work #4380

guilhermedelyra opened this issue Nov 13, 2024 · 7 comments

Comments

@guilhermedelyra
Copy link

guilhermedelyra commented Nov 13, 2024

Prerequisites

What theme are you using?

core

Version

5.x

Current Behavior

When using a JSON Schema that combines a discriminated union with null using anyOf, the form does not render the discriminated union options correctly. Instead, it only allows selecting the null option, and the expected fields for the other options are not displayed.

Expected Behavior

The form should correctly render the options from the discriminated union alongside the null option, allowing users to select any of the available types or null.

Steps To Reproduce

  1. Use the following JSON Schema in the react-jsonschema-form playground or your environment:
Failing Json Schema

playground

{
  "$defs": {
    "LogitechMouse": {
      "additionalProperties": false,
      "properties": {
        "name": {
          "default": "Logitech Mouse",
          "title": "Name",
          "type": "string"
        },
        "price": {
          "default": 10.0,
          "title": "Price",
          "type": "number"
        },
        "type": {
          "const": "peripheral",
          "default": "peripheral",
          "enum": [
            "peripheral"
          ],
          "title": "Type",
          "type": "string"
        },
        "kind": {
          "const": "mouse",
          "default": "mouse",
          "enum": [
            "mouse"
          ],
          "title": "Kind",
          "type": "string"
        },
        "max_dpi": {
          "default": 600,
          "title": "Max Dpi",
          "type": "integer"
        },
        "brand": {
          "const": "Logitech",
          "default": "Logitech",
          "enum": [
            "Logitech"
          ],
          "title": "Brand",
          "type": "string"
        }
      },
      "title": "LogitechMouse",
      "type": "object"
    },
    "RazerMouse": {
      "additionalProperties": false,
      "properties": {
        "name": {
          "default": "Razer Mouse",
          "title": "Name",
          "type": "string"
        },
        "price": {
          "default": 20.0,
          "title": "Price",
          "type": "number"
        },
        "type": {
          "const": "peripheral",
          "default": "peripheral",
          "enum": [
            "peripheral"
          ],
          "title": "Type",
          "type": "string"
        },
        "kind": {
          "const": "mouse",
          "default": "mouse",
          "enum": [
            "mouse"
          ],
          "title": "Kind",
          "type": "string"
        },
        "max_dpi": {
          "default": 1200,
          "title": "Max Dpi",
          "type": "integer"
        },
        "brand": {
          "const": "Razer",
          "default": "Razer",
          "enum": [
            "Razer"
          ],
          "title": "Brand",
          "type": "string"
        }
      },
      "title": "RazerMouse",
      "type": "object"
    }
  },
  "properties": {
    "components": {
      "anyOf": [
        {
          "discriminator": {
            "mapping": {
              "Logitech": "#/$defs/LogitechMouse",
              "Razer": "#/$defs/RazerMouse"
            },
            "propertyName": "brand"
          },
          "oneOf": [
            {
              "$ref": "#/$defs/LogitechMouse"
            },
            {
              "$ref": "#/$defs/RazerMouse"
            }
          ],
          "title": "AvailableMouses"
        },
        {
          "type": "null"
        }
      ],
      "title": "Components"
    }
  },
  "required": [
    "components"
  ],
  "title": "Computer",
  "type": "object"
}
  1. Render the form (https://rjsf-team.github.io/react-jsonschema-form/)
  2. Attempt to select options other than null for the components field.
  3. Observe that only the null option is selectable, and the discriminated union options are not available.

To prove that the discriminated union is not the problem, here's the same json without combining it with the null option:

Working Json Schema

playground

{
  "$defs": {
    "LogitechMouse": {
      "additionalProperties": false,
      "properties": {
        "name": {
          "default": "Logitech Mouse",
          "title": "Name",
          "type": "string"
        },
        "price": {
          "default": 10.0,
          "title": "Price",
          "type": "number"
        },
        "type": {
          "const": "peripheral",
          "default": "peripheral",
          "enum": [
            "peripheral"
          ],
          "title": "Type",
          "type": "string"
        },
        "kind": {
          "const": "mouse",
          "default": "mouse",
          "enum": [
            "mouse"
          ],
          "title": "Kind",
          "type": "string"
        },
        "max_dpi": {
          "default": 600,
          "title": "Max Dpi",
          "type": "integer"
        },
        "brand": {
          "const": "Logitech",
          "default": "Logitech",
          "enum": [
            "Logitech"
          ],
          "title": "Brand",
          "type": "string"
        }
      },
      "title": "LogitechMouse",
      "type": "object"
    },
    "RazerMouse": {
      "additionalProperties": false,
      "properties": {
        "name": {
          "default": "Razer Mouse",
          "title": "Name",
          "type": "string"
        },
        "price": {
          "default": 20.0,
          "title": "Price",
          "type": "number"
        },
        "type": {
          "const": "peripheral",
          "default": "peripheral",
          "enum": [
            "peripheral"
          ],
          "title": "Type",
          "type": "string"
        },
        "kind": {
          "const": "mouse",
          "default": "mouse",
          "enum": [
            "mouse"
          ],
          "title": "Kind",
          "type": "string"
        },
        "max_dpi": {
          "default": 1200,
          "title": "Max Dpi",
          "type": "integer"
        },
        "brand": {
          "const": "Razer",
          "default": "Razer",
          "enum": [
            "Razer"
          ],
          "title": "Brand",
          "type": "string"
        }
      },
      "title": "RazerMouse",
      "type": "object"
    }
  },
  "properties": {
    "components": {
      "discriminator": {
        "mapping": {
          "Logitech": "#/$defs/LogitechMouse",
          "Razer": "#/$defs/RazerMouse"
        },
        "propertyName": "brand"
      },
      "oneOf": [
        {
          "$ref": "#/$defs/LogitechMouse"
        },
        {
          "$ref": "#/$defs/RazerMouse"
        }
      ],
      "title": "AvailableMouses"
    }
  },
  "required": [
    "components"
  ],
  "title": "Computer",
  "type": "object"
}

Environment

-- using the live-playground (same behavior happens running locally) --

OS: Ubuntu 22.04.2 LTS on Windows 10 x86_64
Node: v20.5.0
npm: 9.8.0

Anything else?

Off-topic: I'm using Pydantic (version 2.9.2) to generate those Json-Schemas; here's the code:

Pydantic Code
from pydantic import BaseModel, Field, ConfigDict
from typing import Annotated, Literal, Union

class BaseProduct(BaseModel):
    name: str
    price: float

class BaseHardware(BaseProduct):
    type: Literal["hardware"] = "hardware"

class BasePeripheral(BaseProduct):
    type: Literal["peripheral"] = "peripheral"

class BaseMouseProduct(BasePeripheral):
    kind: Literal["mouse"] = "mouse"
    max_dpi: int

class LogitechMouse(BaseMouseProduct):
    model_config = ConfigDict(extra="forbid")

    brand: Literal["Logitech"] = "Logitech"
    name: str = Field("Logitech Mouse")
    max_dpi: int = Field(600)
    price: float = Field(10.0)

class RazerMouse(BaseMouseProduct):
    model_config = ConfigDict(extra="forbid")

    brand: Literal["Razer"] = "Razer"
    name: str = Field("Razer Mouse")
    max_dpi: int = Field(1200)
    price: float = Field(20.0)

AvailableMouses = Annotated[
    Union[LogitechMouse, RazerMouse],
    Field(title="AvailableMouses", discriminator="brand")
]

class Computer(BaseModel):
    components: Union[AvailableMouses, None]


if __name__ == "__main__":
    print(Computer.schema_json(indent=2))
@guilhermedelyra guilhermedelyra added bug needs triage Initial label given, to be assigned correct labels and assigned labels Nov 13, 2024
@guilhermedelyra
Copy link
Author

Another point i've noticed is:

  • the order of the mappings influence on the fields being selected (nothing wrong with that, just evidenciating this behavior);

Example

  • Valid:
"properties": {
    "components": {
      "discriminator": {
        "mapping": {
          "Logitech": "#/$defs/LogitechMouse",
          "Razer": "#/$defs/RazerMouse"
        },
        "propertyName": "brand"
      },
      "oneOf": [
        {
          "$ref": "#/$defs/LogitechMouse"
        },
        {
          "$ref": "#/$defs/RazerMouse"
        }
      ],
      "title": "AvailableMouses"
    }
  }
  • Invalid (results in buggy interface):
"properties": {
    "components": {
      "discriminator": {
        "mapping": {
          "Logitech": "#/$defs/LogitechMouse",
          "Razer": "#/$defs/RazerMouse" <-------
        },
        "propertyName": "brand"
      },
      "oneOf": [
        {
          "$ref": "#/$defs/RazerMouse"  <--------
        },
        {
          "$ref": "#/$defs/LogitechMouse"
        }
      ],
      "title": "AvailableMouses"
    }
  }

@heath-freenome
Copy link
Member

@guilhermedelyra Good job in finding a bug. Honestly I'm not sure how to fix it at this point. Maybe you have the time to debug through it all and provide a fix?

@heath-freenome heath-freenome added help wanted and removed needs triage Initial label given, to be assigned correct labels and assigned labels Nov 22, 2024
@guilhermedelyra
Copy link
Author

@guilhermedelyra Good job in finding a bug. Honestly I'm not sure how to fix it at this point. Maybe you have the time to debug through it all and provide a fix?

oh, this was a journey haha

but from what i understand, either rjsf or ajv-8 dont accept the 'mapping' part (at least that's what i got from the Console)

so, for my use-case, i had to alter the way pydantic were generating the json-schema to use the 'dependencies' strategy instead.

< not very proud of this, feels very hacky; but it works >

from pydantic import BaseModel, GetJsonSchemaHandler
from pydantic.json_schema import JsonSchemaValue
from pydantic_core import CoreSchema


class BaseJsonSchema(BaseModel):
    @classmethod
    def __get_pydantic_json_schema__(  # noqa: C901
        cls,
        core_schema: CoreSchema,
        handler: GetJsonSchemaHandler,
        /,
    ) -> JsonSchemaValue:
        json_schema = handler(core_schema)

        def remove_discriminator_keys_from_definition(
            schema: dict,
            discriminator_keys: list[str],
        ) -> None:
            if isinstance(schema, dict):
                if "$ref" in schema:
                    actual_def = handler.resolve_ref_schema(schema)
                    for key in discriminator_keys:
                        actual_def["properties"].pop(key, None)

                else:
                    leaf_discriminator_key = schema["required"][0]
                    for ref in schema["dependencies"][leaf_discriminator_key]["oneOf"]:
                        remove_discriminator_keys_from_definition(
                            ref,
                            discriminator_keys,
                        )

        def process_schema(
            schema: dict,
            previous_discriminator_key: str | None = None,
            discriminator_value: str | None = None,
        ) -> None:
            if isinstance(schema, dict):
                if "discriminator" in schema:
                    discriminator = schema["discriminator"]
                    property_name = discriminator["propertyName"]
                    mapping = discriminator["mapping"]

                    # Create the base schema
                    base_schema = {
                        "title": f"{property_name.capitalize()}",
                        "type": "object",
                        "properties": {
                            property_name: {
                                "type": "string",
                                "enum": list(mapping.keys()),
                                "title": property_name.capitalize(),
                            },
                        },
                        "required": [property_name],
                        "dependencies": {property_name: {"oneOf": []}},
                    }

                    if previous_discriminator_key:
                        base_schema["properties"][previous_discriminator_key] = {  # type: ignore[index]
                            "type": "string",
                            "enum": [discriminator_value],
                        }

                    for key, sub_schema in mapping.items():
                        if isinstance(sub_schema, dict):
                            process_schema(sub_schema, property_name, key)
                            remove_discriminator_keys_from_definition(
                                sub_schema,
                                [previous_discriminator_key, property_name],  # type: ignore[list-item]
                            )

                        elif isinstance(sub_schema, str):
                            sub_schema = {"$ref": sub_schema}  # noqa: PLW2901

                        base_schema["dependencies"][property_name]["oneOf"].append(  # type: ignore[index]
                            sub_schema,
                        )
                    schema.clear()
                    schema.update(base_schema)
                for value in schema.values():
                    process_schema(value)
            elif isinstance(schema, list):
                for item in schema:
                    process_schema(item)

        process_schema(json_schema)
        return json_schema

this way i went from this:

old:

{
  "$defs": {
    "LogitechMouse": {
      "additionalProperties": false,
      "properties": {
        "name": {
          "default": "Logitech Mouse",
          "title": "Name",
          "type": "string"
        },
        "price": {
          "default": 10.0,
          "title": "Price",
          "type": "number"
        },
        "type": {
          "const": "peripheral",
          "default": "peripheral",
          "enum": [
            "peripheral"
          ],
          "title": "Type",
          "type": "string"
        },
        "kind": {
          "const": "mouse",
          "default": "mouse",
          "enum": [
            "mouse"
          ],
          "title": "Kind",
          "type": "string"
        },
        "max_dpi": {
          "default": 600,
          "title": "Max Dpi",
          "type": "integer"
        },
        "brand": {
          "const": "Logitech",
          "default": "Logitech",
          "enum": [
            "Logitech"
          ],
          "title": "Brand",
          "type": "string"
        }
      },
      "title": "LogitechMouse",
      "type": "object"
    },
    "RazerMouse": {
      "additionalProperties": false,
      "properties": {
        "name": {
          "default": "Razer Mouse",
          "title": "Name",
          "type": "string"
        },
        "price": {
          "default": 20.0,
          "title": "Price",
          "type": "number"
        },
        "type": {
          "const": "peripheral",
          "default": "peripheral",
          "enum": [
            "peripheral"
          ],
          "title": "Type",
          "type": "string"
        },
        "kind": {
          "const": "mouse",
          "default": "mouse",
          "enum": [
            "mouse"
          ],
          "title": "Kind",
          "type": "string"
        },
        "max_dpi": {
          "default": 1200,
          "title": "Max Dpi",
          "type": "integer"
        },
        "brand": {
          "const": "Razer",
          "default": "Razer",
          "enum": [
            "Razer"
          ],
          "title": "Brand",
          "type": "string"
        }
      },
      "title": "RazerMouse",
      "type": "object"
    }
  },
  "properties": {
    "components": {
      "anyOf": [
        {
          "discriminator": {
            "mapping": {
              "Logitech": "#/$defs/LogitechMouse",
              "Razer": "#/$defs/RazerMouse"
            },
            "propertyName": "brand"
          },
          "oneOf": [
            {
              "$ref": "#/$defs/LogitechMouse"
            },
            {
              "$ref": "#/$defs/RazerMouse"
            }
          ],
          "title": "AvailableMouses"
        },
        {
          "type": "null"
        }
      ],
      "title": "Components"
    }
  },
  "required": [
    "components"
  ],
  "title": "Computer",
  "type": "object"
}

to this:

new

{
  "$defs": {
    "LogitechMouse": {
      "additionalProperties": false,
      "properties": {
        "name": {
          "default": "Logitech Mouse",
          "title": "Name",
          "type": "string"
        },
        "price": {
          "default": 10.0,
          "title": "Price",
          "type": "number"
        },
        "type": {
          "const": "peripheral",
          "default": "peripheral",
          "enum": [
            "peripheral"
          ],
          "title": "Type",
          "type": "string"
        },
        "kind": {
          "const": "mouse",
          "default": "mouse",
          "enum": [
            "mouse"
          ],
          "title": "Kind",
          "type": "string"
        },
        "max_dpi": {
          "default": 600,
          "title": "Max Dpi",
          "type": "integer"
        },
        "brand": {
          "const": "Logitech",
          "default": "Logitech",
          "enum": [
            "Logitech"
          ],
          "title": "Brand",
          "type": "string"
        }
      },
      "title": "LogitechMouse",
      "type": "object"
    },
    "RazerMouse": {
      "additionalProperties": false,
      "properties": {
        "name": {
          "default": "Razer Mouse",
          "title": "Name",
          "type": "string"
        },
        "price": {
          "default": 20.0,
          "title": "Price",
          "type": "number"
        },
        "type": {
          "const": "peripheral",
          "default": "peripheral",
          "enum": [
            "peripheral"
          ],
          "title": "Type",
          "type": "string"
        },
        "kind": {
          "const": "mouse",
          "default": "mouse",
          "enum": [
            "mouse"
          ],
          "title": "Kind",
          "type": "string"
        },
        "max_dpi": {
          "default": 1200,
          "title": "Max Dpi",
          "type": "integer"
        },
        "brand": {
          "const": "Razer",
          "default": "Razer",
          "enum": [
            "Razer"
          ],
          "title": "Brand",
          "type": "string"
        }
      },
      "title": "RazerMouse",
      "type": "object"
    }
  },
  "properties": {
    "components": {
      "anyOf": [
        {
          "dependencies": {
            "brand": {
              "oneOf": [
                {
                  "$ref": "#/$defs/LogitechMouse"
                },
                {
                  "$ref": "#/$defs/RazerMouse"
                }
              ]
            }
          },
          "properties": {
            "brand": {
              "enum": [
                "Logitech",
                "Razer"
              ],
              "title": "Brand",
              "type": "string"
            }
          },
          "required": [
            "brand"
          ],
          "title": "Brand",
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "title": "Components"
    }
  },
  "required": [
    "components"
  ],
  "title": "Computer",
  "type": "object"
}

@markus-96
Copy link

hopefully this helps, I encountered the same bug with a much simpler example:

{
  "properties": {
    "username": {
      "type": "string",
      "maxLength": 20,
      "title": "Username"
    },
    "name": {
      "anyOf": [
        {
          "type": "string",
          "maxLength": 50
        },
        {
          "type": "null"
        }
      ],
      "title": "Name"
    }
  },
  "additionalProperties": false,
  "type": "object",
  "required": [
    "username"
  ],
  "title": "User"
}

for the field "name", the null option is always selected and I can not switch to the string option.

@Alan-Cha
Copy link

Alan-Cha commented Dec 2, 2024

This seems related to

#4367
#4375

Also see my comment #4375 (comment)

@mike-capyh
Copy link

We've run into (I believe) the same issue.

It appears to be a regression between releases 5.21.2 and 5.22.0.

(based on partial bisection via eg yarn up '@rjsf/*@5.22.0')

@nickgros
Copy link
Contributor

The regression is resolved if we revert the default value of formData in calculateIndexScore to {} (link to file). This was changed in #3392

However, this change yields different behavior for #4314 in that an excess property is erroneously preserved when switching between options. So this change alone may cause a different regression.

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

No branches or pull requests

6 participants