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

1132 If a column is nullable, make it optional by default in create_pydantic_model #1141

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions docs/src/piccolo/serialization/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -191,23 +191,45 @@ So if we want to disallow extra fields, we can do:
Required fields
~~~~~~~~~~~~~~~

You can specify which fields are required using the ``required``
argument of :class:`Column <piccolo.columns.base.Column>`. For example:
If a column has ``null=True``, then it creates an ``Optional`` field in the
Pydantic model:

.. code-block:: python

class Band(Table):
name = Varchar(required=True)
name = Varchar(null=True)

BandModel = create_pydantic_model(Band)

# This is equivalent to:
from pydantic import BaseModel

class BandModel(BaseModel):
name: Optional[str] = None

If the column has ``null=True``, but we still want the user to provide a value,
then we can pass ``required=True`` to :class:`Column <piccolo.columns.base.Column>`:

.. code-block:: python

class Band(Table):
name = Varchar(null=True, required=True)

BandModel = create_pydantic_model(Band)

# This is equivalent to:
from pydantic import BaseModel

class BandModel(BaseModel):
name: str

# Omitting the field raises an error:
>>> BandModel()
ValidationError - name field required

You can override this behaviour using the ``all_optional`` argument. An example
use case is when you have a model which is used for filtering, then you'll want
all fields to be optional.
If you don't want any of your fields to be required, you can use the
``all_optional`` argument. An example use case is when you have a model which
is used for filtering:

.. code-block:: python

Expand All @@ -217,11 +239,10 @@ all fields to be optional.
BandFilterModel = create_pydantic_model(
Band,
all_optional=True,
model_name='BandFilterModel',
)

# This no longer raises an exception:
>>> BandModel()
>>> BandFilterModel()

Subclassing the model
~~~~~~~~~~~~~~~~~~~~~
Expand Down
4 changes: 2 additions & 2 deletions piccolo/columns/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ class ColumnMeta:
unique: bool = False
index: bool = False
index_method: IndexMethod = IndexMethod.btree
required: bool = False
required: t.Optional[bool] = None
help_text: t.Optional[str] = None
choices: t.Optional[t.Type[Enum]] = None
secret: bool = False
Expand Down Expand Up @@ -459,7 +459,7 @@ def __init__(
unique: bool = False,
index: bool = False,
index_method: IndexMethod = IndexMethod.btree,
required: bool = False,
required: t.Optional[bool] = None,
help_text: t.Optional[str] = None,
choices: t.Optional[t.Type[Enum]] = None,
db_column_name: t.Optional[str] = None,
Expand Down
14 changes: 12 additions & 2 deletions piccolo/utils/pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,10 +243,20 @@ def create_pydantic_model(
for column in piccolo_columns:
column_name = column._meta.name

is_optional = True if all_optional else not column._meta.required
#######################################################################
# Work out if the field should be optional

if all_optional:
is_optional = True
elif column._meta.required is not None:
# The user can force the field to be optional or not, irrespective
# of whether it's nullable in the database.
is_optional = not column._meta.required
else:
is_optional = column._meta.null

#######################################################################
# Work out the column type
# Work out the field type

if isinstance(column, (JSON, JSONB)):
if deserialize_json:
Expand Down
1 change: 1 addition & 0 deletions tests/apps/fixtures/commands/test_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def test_shared(self):
"unique_col": "hello",
"null_col": None,
"not_null_col": "hello",
"double_precision_col": 1.0,
}
],
}
Expand Down
94 changes: 83 additions & 11 deletions tests/utils/test_pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,17 @@ class Director(Table):
pydantic_model = create_pydantic_model(table=Director)

self.assertEqual(
pydantic_model.model_json_schema()["properties"]["email"]["anyOf"][
0
]["format"],
pydantic_model.model_json_schema()["properties"]["email"][
"format"
],
"email",
)

self.assertEqual(
pydantic_model.model_json_schema()["properties"]["email"]["type"],
"string",
)

with self.assertRaises(ValidationError):
pydantic_model(email="not a valid email")

Expand Down Expand Up @@ -121,8 +126,8 @@ class Band(Table):

self.assertEqual(
pydantic_model.model_json_schema()["properties"]["members"][
"anyOf"
][0]["items"]["type"],
"items"
]["type"],
"string",
)

Expand All @@ -132,7 +137,7 @@ def test_multidimensional_array(self):
"""

class Band(Table):
members = Array(Array(Varchar(length=255)), required=True)
members = Array(Array(Varchar(length=255)))

pydantic_model = create_pydantic_model(table=Band)

Expand Down Expand Up @@ -223,8 +228,8 @@ class Concert(Table):

self.assertEqual(
pydantic_model.model_json_schema()["properties"]["start_time"][
"anyOf"
][0]["format"],
"format"
],
"time",
)

Expand Down Expand Up @@ -281,13 +286,80 @@ class Ticket(Table):
self.assertEqual(json, '{"code":"' + str(ticket_.code) + '"}')

self.assertEqual(
pydantic_model.model_json_schema()["properties"]["code"]["anyOf"][
0
]["format"],
pydantic_model.model_json_schema()["properties"]["code"]["format"],
"uuid",
)


class TestRequired(TestCase):
"""
Using the `required` attribute, we can force the field to be required or
not (overriding `column._meta.null`)
"""

def test_required(self):
"""
Make a null column required.
"""

class Director(Table):
name = Varchar(null=True, required=True)

pydantic_model = create_pydantic_model(table=Director)

self.assertEqual(
pydantic_model.model_json_schema()["properties"]["name"]["type"],
"string",
)

with self.assertRaises(pydantic.ValidationError):
pydantic_model(name=None)

def test_not_required(self):
"""
Make a column not required.
"""

class Director(Table):
name = Varchar(null=False, required=False)

pydantic_model = create_pydantic_model(table=Director)

self.assertEqual(
pydantic_model.model_json_schema()["properties"]["name"]["anyOf"],
[
{"maxLength": 255, "type": "string"},
{"type": "null"},
],
)

# Shouldn't raise an error:
pydantic_model(name=None)

def test_all_optional(self):
"""
Makes all columns not required - useful for filters.
"""

class Director(Table):
name = Varchar(null=False)

pydantic_model = create_pydantic_model(
table=Director, all_optional=True
)

self.assertEqual(
pydantic_model.model_json_schema()["properties"]["name"]["anyOf"],
[
{"maxLength": 255, "type": "string"},
{"type": "null"},
],
)

# Shouldn't raise an error:
pydantic_model(name=None)


class TestColumnHelpText(TestCase):
"""
Make sure that columns with `help_text` attribute defined have the
Expand Down
Loading