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
Support discriminated union #2336
Support discriminated union #2336
Conversation
Codecov Report
@@ Coverage Diff @@
## master #2336 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 25 25
Lines 5109 5179 +70
Branches 1050 1065 +15
=========================================
+ Hits 5109 5179 +70
Continue to review full report at Codecov.
|
78248a1
to
876b2ab
Compare
fc200f6
to
74d4355
Compare
OpenAPI 3.x actually doesn't support the |
@PrettyWood That's great news! |
I noticed this does not work if the types I am unioning over are forward references. Ie. this works:
but if I move the
(Just on initialisation of the class; no need to actually do anything with it.) |
4efcaa2
to
0fb6fd1
Compare
Thanks @amagee. I had to rewrite a bit the logic to support properly |
I gave this branch a try. Definitely a step in the right direction. In the script below I demonstrate a common situation I encounter where there is more than one discriminating field. I am wondering if you think this is something worth supporting in a discriminated union- would it even be compatible with OpenAPI? from typing import Literal, Union
from pydantic import BaseModel, Field
class DomainA(BaseModel):
domain: Literal["A"]
class DomainB(BaseModel):
domain: Literal["B"]
class FooDomainA(DomainA):
identifier: Literal["foo"]
class BarDomainA(DomainA):
identifier: Literal["bar"]
class FooDomainB(DomainB):
identifier: Literal["foo"]
class BarDomainB(DomainB):
identifier: Literal["bar"]
class MyClass(BaseModel):
__root__: Union[FooDomainA, BarDomainA, FooDomainB, BarDomainB] = Field(
..., discriminator="domain"
)
for domain in ("A", "B"):
for identifier in ("foo", "bar"):
print(domain, identifier)
try:
print(MyClass.parse_obj({"domain": domain, "identifier": identifier}))
except ValueError as e:
print(e)
print() Output:
|
@vdwees Thanks a lot for trying it out! |
@PrettyWood I looked into it a bit more, and based on my reading of the openapi schema, I think the discriminator is only a single field. So as much as I would like the discriminator kwarg to support a sequence in addition to a single value, I guess it wouldn't make sense to support that here 😢 But I was thinking- even if it is only a single value, it might still be reasonable/possible to support similar functionality if the discriminator was nestable, e.g something like below: class FooDomainA(BaseModel):
domain: Literal["A"]
identifier: Literal["foo"]
class BarDomainA(BaseModel):
domain: Literal["A"]
identifier: Literal["bar"]
class FooDomainB(BaseModel):
domain: Literal["B"]
identifier: Literal["foo"]
class BarDomainB(BaseModel):
domain: Literal["B"]
identifier: Literal["bar"]
class DomainA(BaseModel):
__root__: Union[FooDomainA, BarDomainA] = Field(..., discriminator="identifier")
class DomainB(BaseModel):
__root__: Union[FooDomainB, BarDomainB] = Field(..., discriminator="identifier")
class PolymorphClass(BaseModel):
__root__: Union[DomainA, DomainB] = Field(..., discriminator="domain") |
98b268a
to
3398459
Compare
Just to say thank you so much for working on this. Giving this a proper review is weighing heavy on my conscience, but I can't devote the time before v1.8. |
@samuelcolvin No worries! I want to improve it further to support nested discriminated unions. As I said in the 1.8 discussion, better wait for v1.9 for the PRs on union! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is looking good.
I've put in some cleanup suggestions to reduce some duplication, and simplify calling update_forward_refs
(tested locally).
I think an assumption has been made that will cause some headaches though.
It's possible to have duplicated discriminator values.
class Spaniel(BaseModel):
pet_type: Literal['dog']
breed: Literal['spaniel']
class JackRussel(BaseModel):
pet_type: Literal['dog']
breed: Literal['Jack Russel']
class Cat(BaseModel):
pet_type: Literal['cat']
class Model(BaseModel):
pet: Union[Spaniel, JackRussel, Cat] = Field(discriminator='pet_type')
I don't think this is an edge case either, we have something to this effect running in production.
In this case, I think the code should be defensive:
- Don't generate the
mapping
in the schema (or exclude mappings that are duplicates) - have
sub_field_mappings
be defined asDict[str, List[ModelField]]
, so it can be used to narrow down validation
While looking into nested discriminators (as a true fix to the above), You end up with a schema akin to this: definitions:
Pet:
anyOf:
- $ref: '#/definitions/Cat'
- $ref: '#/definitions/Dog'
discriminator:
propertyName: pet_type
mapping:
cat: '#/definitions/Cat'
dog: '#/definitions/Dog'
Cat:
properties:
pet_type:
type: string
const: cat
Dog:
anyOf:
- $ref: '#/definitions/Spaniel'
- $ref: '#/definitions/JackRussel'
discriminator:
propertyName: breed
mapping:
spaniel: '#/definitions/Spaniel'
jack-russel: '#/definitions/JackRussel'
Spaniel:
properties:
pet_type:
type: string
const: dog
breed:
type: string
const: spaniel
s:
type: string
JackRussel:
properties:
pet_type:
type: string
const: dog
breed:
type: string
const: jack-russel
j:
type: string Note how we have To express this, we need to define Dog as: class Dog(BaseModel):
__root__ = Union[Spaniel, JackRussel] = Field(..., discriminator='breed')
class Model(BaseModel):
pet: Union[Cat, Dog] = Field(..., discriminator='pet_type')
number: int However this isn't supported by the current code, raising a KeyError: An example of using in Redoc: https://redocly.github.io/redoc/?url=https://gist.githubusercontent.com/cybojenix/5258ebe39cd100e99be76e5175f9b41f/raw/6b1708c71cad50ef00fda6bbf1d35ea439d6e2fa/nested-discriminator.yml This actually breaks the interface when selecting |
Thanks a lot @cybojenix for the review 🙏 It was still a work in progress but glad to have some constructive feedback on the changes we can do. I'll have a look at it probably next week! |
6096efa
to
fa2009c
Compare
Thanks @vdwees and @cybojenix for the examples. from typing import Literal, Union
from pydantic import BaseModel, Field
class FooDomainAA(BaseModel):
domain: Literal["A"]
identifier: Literal["foo"]
type: Literal["a"]
class FooDomainAB(BaseModel):
domain: Literal["A"]
identifier: Literal["foo"]
type: Literal["b"]
class BarDomainA(BaseModel):
domain: Literal["A"]
identifier: Literal["bar"]
class FooDomainB(BaseModel):
domain: Literal["B"]
identifier: Literal["foo"]
class BarDomainB(BaseModel):
domain: Literal["B"]
identifier: Literal["bar"]
class FooDomainA(BaseModel):
__root__: Union[FooDomainAA, FooDomainAB] = Field(..., discriminator="type")
class DomainA(BaseModel):
__root__: Union[FooDomainA, BarDomainA] = Field(..., discriminator="identifier")
class DomainB(BaseModel):
__root__: Union[FooDomainB, BarDomainB] = Field(..., discriminator="identifier")
class PolymorphClass(BaseModel):
__root__: Union[DomainA, DomainB] = Field(..., discriminator="domain")
assert PolymorphClass.schema() == {
"title": "PolymorphClass",
"discriminator": {
"propertyName": "domain",
"mapping": {
"A": "#/definitions/DomainA",
"B": "#/definitions/DomainB"
}
},
"anyOf": [
{
"$ref": "#/definitions/DomainA"
},
{
"$ref": "#/definitions/DomainB"
}
],
"definitions": {
"FooDomainAA": {
"title": "FooDomainAA",
"type": "object",
"properties": {
"domain": {
"title": "Domain",
"enum": [
"A"
],
"type": "string"
},
"identifier": {
"title": "Identifier",
"enum": [
"foo"
],
"type": "string"
},
"type": {
"title": "Type",
"enum": [
"a"
],
"type": "string"
}
},
"required": [
"domain",
"identifier",
"type"
]
},
"FooDomainAB": {
"title": "FooDomainAB",
"type": "object",
"properties": {
"domain": {
"title": "Domain",
"enum": [
"A"
],
"type": "string"
},
"identifier": {
"title": "Identifier",
"enum": [
"foo"
],
"type": "string"
},
"type": {
"title": "Type",
"enum": [
"b"
],
"type": "string"
}
},
"required": [
"domain",
"identifier",
"type"
]
},
"FooDomainA": {
"title": "FooDomainA",
"discriminator": {
"propertyName": "type",
"mapping": {
"a": "#/definitions/FooDomainAA",
"b": "#/definitions/FooDomainAB"
}
},
"anyOf": [
{
"$ref": "#/definitions/FooDomainAA"
},
{
"$ref": "#/definitions/FooDomainAB"
}
]
},
"BarDomainA": {
"title": "BarDomainA",
"type": "object",
"properties": {
"domain": {
"title": "Domain",
"enum": [
"A"
],
"type": "string"
},
"identifier": {
"title": "Identifier",
"enum": [
"bar"
],
"type": "string"
}
},
"required": [
"domain",
"identifier"
]
},
"DomainA": {
"title": "DomainA",
"discriminator": {
"propertyName": "identifier",
"mapping": {
"foo": "#/definitions/FooDomainA",
"bar": "#/definitions/BarDomainA"
}
},
"anyOf": [
{
"$ref": "#/definitions/FooDomainA"
},
{
"$ref": "#/definitions/BarDomainA"
}
]
},
"FooDomainB": {
"title": "FooDomainB",
"type": "object",
"properties": {
"domain": {
"title": "Domain",
"enum": [
"B"
],
"type": "string"
},
"identifier": {
"title": "Identifier",
"enum": [
"foo"
],
"type": "string"
}
},
"required": [
"domain",
"identifier"
]
},
"BarDomainB": {
"title": "BarDomainB",
"type": "object",
"properties": {
"domain": {
"title": "Domain",
"enum": [
"B"
],
"type": "string"
},
"identifier": {
"title": "Identifier",
"enum": [
"bar"
],
"type": "string"
}
},
"required": [
"domain",
"identifier"
]
},
"DomainB": {
"title": "DomainB",
"discriminator": {
"propertyName": "identifier",
"mapping": {
"foo": "#/definitions/FooDomainB",
"bar": "#/definitions/BarDomainB"
}
},
"anyOf": [
{
"$ref": "#/definitions/FooDomainB"
},
{
"$ref": "#/definitions/BarDomainB"
}
]
}
}
} It looks good to me but maybe I'm mistaken. |
fa2009c
to
3b33560
Compare
Any estimation date for the next release of pydantic with this feature included? |
@ryukinix Probably before the end of the year. I'll come back on this PR asap |
@samuelcolvin I took most of your remarks into consideration. I also added |
Please review |
🎉🙏 |
Thanks for all your hard work folks! |
Change Summary
Add discriminated union support and support open api spec about it
Related issue number
closes #619
closes #3113
Checklist
changes/<pull request or issue id>-<github username>.md
file added describing change(see changes/README.md for details)