Skip to content

Commit

Permalink
Support pipe syntax to declare optional fields (elastic#1937)
Browse files Browse the repository at this point in the history
* Support pipe syntax to declare optional fields

Fixes elastic#1928

* type ignores for pipe syntax in 3.8/3.9
  • Loading branch information
miguelgrinberg authored Nov 6, 2024
1 parent 47a7a06 commit 5511abe
Show file tree
Hide file tree
Showing 4 changed files with 81 additions and 3 deletions.
8 changes: 5 additions & 3 deletions docs/persistence.rst
Original file line number Diff line number Diff line change
Expand Up @@ -147,16 +147,18 @@ following table:
- ``Date(format="yyyy-MM-dd", required=True)``

To type a field as optional, the standard ``Optional`` modifier from the Python
``typing`` package can be used. The ``List`` modifier can be added to a field
to convert it to an array, similar to using the ``multi=True`` argument on the
field object.
``typing`` package can be used. When using Python 3.10 or newer, "pipe" syntax
can also be used, by adding ``| None`` to a type. The ``List`` modifier can be
added to a field to convert it to an array, similar to using the ``multi=True``
argument on the field object.

.. code:: python
from typing import Optional, List
class MyDoc(Document):
pub_date: Optional[datetime] # same as pub_date = Date()
middle_name: str | None # same as middle_name = Text()
authors: List[str] # same as authors = Text(multi=True, required=True)
comments: Optional[List[str]] # same as comments = Text(multi=True)
Expand Down
14 changes: 14 additions & 0 deletions elasticsearch_dsl/document_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,15 @@
Tuple,
TypeVar,
Union,
get_args,
overload,
)

try:
from types import UnionType # type: ignore[attr-defined]
except ImportError:
UnionType = None

from typing_extensions import dataclass_transform

from .exceptions import ValidationException
Expand Down Expand Up @@ -203,6 +209,14 @@ def __init__(self, name: str, bases: Tuple[type, ...], attrs: Dict[str, Any]):
if skip or type_ == ClassVar:
# skip ClassVar attributes
continue
if type(type_) is UnionType:
# a union given with the pipe syntax
args = get_args(type_)
if len(args) == 2 and args[1] is type(None):
required = False
type_ = type_.__args__[0]
else:
raise TypeError("Unsupported union")
field = None
field_args: List[Any] = []
field_kwargs: Dict[str, Any] = {}
Expand Down
31 changes: 31 additions & 0 deletions tests/_async/test_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import codecs
import ipaddress
import pickle
import sys
from datetime import datetime
from hashlib import md5
from typing import Any, ClassVar, Dict, List, Optional
Expand Down Expand Up @@ -791,6 +792,36 @@ class TypedDoc(AsyncDocument):
}


@pytest.mark.skipif(sys.version_info < (3, 10), reason="requires Python 3.10")
def test_doc_with_pipe_type_hints() -> None:
with pytest.raises(TypeError):

class BadlyTypedDoc(AsyncDocument):
s: str
f: str | int | None # type: ignore[syntax]

class TypedDoc(AsyncDocument):
s: str
f1: str | None # type: ignore[syntax]
f2: M[int | None] # type: ignore[syntax]
f3: M[datetime | None] # type: ignore[syntax]

props = TypedDoc._doc_type.mapping.to_dict()["properties"]
assert props == {
"s": {"type": "text"},
"f1": {"type": "text"},
"f2": {"type": "integer"},
"f3": {"type": "date"},
}

doc = TypedDoc()
with raises(ValidationException) as exc_info:
doc.full_clean()
assert set(exc_info.value.args[0].keys()) == {"s"}
doc.s = "s"
doc.full_clean()


def test_instrumented_field() -> None:
class Child(InnerDoc):
st: M[str]
Expand Down
31 changes: 31 additions & 0 deletions tests/_sync/test_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import codecs
import ipaddress
import pickle
import sys
from datetime import datetime
from hashlib import md5
from typing import Any, ClassVar, Dict, List, Optional
Expand Down Expand Up @@ -791,6 +792,36 @@ class TypedDoc(Document):
}


@pytest.mark.skipif(sys.version_info < (3, 10), reason="requires Python 3.10")
def test_doc_with_pipe_type_hints() -> None:
with pytest.raises(TypeError):

class BadlyTypedDoc(Document):
s: str
f: str | int | None # type: ignore[syntax]

class TypedDoc(Document):
s: str
f1: str | None # type: ignore[syntax]
f2: M[int | None] # type: ignore[syntax]
f3: M[datetime | None] # type: ignore[syntax]

props = TypedDoc._doc_type.mapping.to_dict()["properties"]
assert props == {
"s": {"type": "text"},
"f1": {"type": "text"},
"f2": {"type": "integer"},
"f3": {"type": "date"},
}

doc = TypedDoc()
with raises(ValidationException) as exc_info:
doc.full_clean()
assert set(exc_info.value.args[0].keys()) == {"s"}
doc.s = "s"
doc.full_clean()


def test_instrumented_field() -> None:
class Child(InnerDoc):
st: M[str]
Expand Down

0 comments on commit 5511abe

Please sign in to comment.