Skip to content

Commit ae144e0

Browse files
JonasKsbr-followtiangolo
authored
šŸ› Fix auto detecting and setting nullable, allowing overrides in field (#423)
Co-authored-by: Benjamin Rapaport <[email protected]> Co-authored-by: SebastiĆ”n RamĆ­rez <[email protected]>
1 parent 85f5e7f commit ae144e0

File tree

2 files changed

+130
-4
lines changed

2 files changed

+130
-4
lines changed

ā€Žsqlmodel/main.pyā€Ž

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -423,11 +423,13 @@ def get_column_from_field(field: ModelField) -> Column: # type: ignore
423423
index = getattr(field.field_info, "index", Undefined)
424424
if index is Undefined:
425425
index = False
426+
nullable = not primary_key and _is_field_noneable(field)
427+
# Override derived nullability if the nullable property is set explicitly
428+
# on the field
426429
if hasattr(field.field_info, "nullable"):
427430
field_nullable = getattr(field.field_info, "nullable")
428431
if field_nullable != Undefined:
429432
nullable = field_nullable
430-
nullable = not primary_key and _is_field_nullable(field)
431433
args = []
432434
foreign_key = getattr(field.field_info, "foreign_key", None)
433435
unique = getattr(field.field_info, "unique", False)
@@ -644,11 +646,10 @@ def __tablename__(cls) -> str:
644646
return cls.__name__.lower()
645647

646648

647-
def _is_field_nullable(field: ModelField) -> bool:
649+
def _is_field_noneable(field: ModelField) -> bool:
648650
if not field.required:
649651
# Taken from [Pydantic](https://github.com/samuelcolvin/pydantic/blob/v1.8.2/pydantic/fields.py#L946-L947)
650-
is_optional = field.allow_none and (
652+
return field.allow_none and (
651653
field.shape != SHAPE_SINGLETON or not field.sub_fields
652654
)
653-
return is_optional and field.default is None and field.default_factory is None
654655
return False

ā€Žtests/test_nullable.pyā€Ž

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
from typing import Optional
2+
3+
import pytest
4+
from sqlalchemy.exc import IntegrityError
5+
from sqlmodel import Field, Session, SQLModel, create_engine
6+
7+
8+
def test_nullable_fields(clear_sqlmodel, caplog):
9+
class Hero(SQLModel, table=True):
10+
primary_key: Optional[int] = Field(
11+
default=None,
12+
primary_key=True,
13+
)
14+
required_value: str
15+
optional_default_ellipsis: Optional[str] = Field(default=...)
16+
optional_default_none: Optional[str] = Field(default=None)
17+
optional_non_nullable: Optional[str] = Field(
18+
nullable=False,
19+
)
20+
optional_nullable: Optional[str] = Field(
21+
nullable=True,
22+
)
23+
optional_default_ellipses_non_nullable: Optional[str] = Field(
24+
default=...,
25+
nullable=False,
26+
)
27+
optional_default_ellipses_nullable: Optional[str] = Field(
28+
default=...,
29+
nullable=True,
30+
)
31+
optional_default_none_non_nullable: Optional[str] = Field(
32+
default=None,
33+
nullable=False,
34+
)
35+
optional_default_none_nullable: Optional[str] = Field(
36+
default=None,
37+
nullable=True,
38+
)
39+
default_ellipses_non_nullable: str = Field(default=..., nullable=False)
40+
optional_default_str: Optional[str] = "default"
41+
optional_default_str_non_nullable: Optional[str] = Field(
42+
default="default", nullable=False
43+
)
44+
optional_default_str_nullable: Optional[str] = Field(
45+
default="default", nullable=True
46+
)
47+
str_default_str: str = "default"
48+
str_default_str_non_nullable: str = Field(default="default", nullable=False)
49+
str_default_str_nullable: str = Field(default="default", nullable=True)
50+
str_default_ellipsis_non_nullable: str = Field(default=..., nullable=False)
51+
str_default_ellipsis_nullable: str = Field(default=..., nullable=True)
52+
53+
engine = create_engine("sqlite://", echo=True)
54+
SQLModel.metadata.create_all(engine)
55+
56+
create_table_log = [
57+
message for message in caplog.messages if "CREATE TABLE hero" in message
58+
][0]
59+
assert "primary_key INTEGER NOT NULL," in create_table_log
60+
assert "required_value VARCHAR NOT NULL," in create_table_log
61+
assert "optional_default_ellipsis VARCHAR NOT NULL," in create_table_log
62+
assert "optional_default_none VARCHAR," in create_table_log
63+
assert "optional_non_nullable VARCHAR NOT NULL," in create_table_log
64+
assert "optional_nullable VARCHAR," in create_table_log
65+
assert (
66+
"optional_default_ellipses_non_nullable VARCHAR NOT NULL," in create_table_log
67+
)
68+
assert "optional_default_ellipses_nullable VARCHAR," in create_table_log
69+
assert "optional_default_none_non_nullable VARCHAR NOT NULL," in create_table_log
70+
assert "optional_default_none_nullable VARCHAR," in create_table_log
71+
assert "default_ellipses_non_nullable VARCHAR NOT NULL," in create_table_log
72+
assert "optional_default_str VARCHAR," in create_table_log
73+
assert "optional_default_str_non_nullable VARCHAR NOT NULL," in create_table_log
74+
assert "optional_default_str_nullable VARCHAR," in create_table_log
75+
assert "str_default_str VARCHAR NOT NULL," in create_table_log
76+
assert "str_default_str_non_nullable VARCHAR NOT NULL," in create_table_log
77+
assert "str_default_str_nullable VARCHAR," in create_table_log
78+
assert "str_default_ellipsis_non_nullable VARCHAR NOT NULL," in create_table_log
79+
assert "str_default_ellipsis_nullable VARCHAR," in create_table_log
80+
81+
82+
# Test for regression in https://github.com/tiangolo/sqlmodel/issues/420
83+
def test_non_nullable_optional_field_with_no_default_set(clear_sqlmodel, caplog):
84+
class Hero(SQLModel, table=True):
85+
primary_key: Optional[int] = Field(
86+
default=None,
87+
primary_key=True,
88+
)
89+
90+
optional_non_nullable_no_default: Optional[str] = Field(nullable=False)
91+
92+
engine = create_engine("sqlite://", echo=True)
93+
SQLModel.metadata.create_all(engine)
94+
95+
create_table_log = [
96+
message for message in caplog.messages if "CREATE TABLE hero" in message
97+
][0]
98+
assert "primary_key INTEGER NOT NULL," in create_table_log
99+
assert "optional_non_nullable_no_default VARCHAR NOT NULL," in create_table_log
100+
101+
# We can create a hero with `None` set for the optional non-nullable field
102+
hero = Hero(primary_key=123, optional_non_nullable_no_default=None)
103+
# But we cannot commit it.
104+
with Session(engine) as session:
105+
session.add(hero)
106+
with pytest.raises(IntegrityError):
107+
session.commit()
108+
109+
110+
def test_nullable_primary_key(clear_sqlmodel, caplog):
111+
# Probably the weirdest corner case, it shouldn't happen anywhere, but let's test it
112+
class Hero(SQLModel, table=True):
113+
nullable_integer_primary_key: Optional[int] = Field(
114+
default=None,
115+
primary_key=True,
116+
nullable=True,
117+
)
118+
119+
engine = create_engine("sqlite://", echo=True)
120+
SQLModel.metadata.create_all(engine)
121+
122+
create_table_log = [
123+
message for message in caplog.messages if "CREATE TABLE hero" in message
124+
][0]
125+
assert "nullable_integer_primary_key INTEGER," in create_table_log

0 commit comments

Comments
Ā (0)