Skip to content

Commit

Permalink
if a column is nullable, make it optional by default in `create_pydan…
Browse files Browse the repository at this point in the history
…tic_model`
  • Loading branch information
dantownsend committed Jan 14, 2025
1 parent 965c5b3 commit 15c9b36
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 23 deletions.
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: bool = ...
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: bool = ...,
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 ...:
# 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

0 comments on commit 15c9b36

Please sign in to comment.