Skip to content

Commit 7211b36

Browse files
authored
3324 db date overflow handling (baserow#4057)
handle database date value overflow in python for psycopg3
1 parent e325b2b commit 7211b36

File tree

8 files changed

+346
-13
lines changed

8 files changed

+346
-13
lines changed

backend/src/baserow/contrib/database/apps.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.db import ProgrammingError
66
from django.db.models.signals import post_migrate, pre_migrate
77

8+
from baserow.contrib.database.fields.utils.pg_datetime import pg_init
89
from baserow.contrib.database.table.cache import clear_generated_model_cache
910
from baserow.contrib.database.table.operations import RestoreDatabaseTableOperationType
1011
from baserow.core.registries import (
@@ -1140,6 +1141,9 @@ def ready(self):
11401141
get_user_model()._meta._expire_cache = lambda *a, **kw: None
11411142
SelectOption._meta._expire_cache = lambda *a, **kw: None
11421143

1144+
# date/datetime min/max year handling - replace overflowed date with None
1145+
pg_init()
1146+
11431147

11441148
# noinspection PyPep8Naming
11451149
def clear_generated_model_cache_receiver(sender, **kwargs):

backend/src/baserow/contrib/database/fields/field_types.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1386,14 +1386,27 @@ def get_alter_column_prepare_new_value(self, connection, from_field, to_field):
13861386
ELSEIF p_in IS NULL THEN
13871387
p_in = null;
13881388
ELSE
1389-
p_in = GREATEST(
1390-
{sql_function}(p_in::text, 'FM{sql_format}'),
1391-
'0001-01-01'::{sql_type}
1392-
);
1389+
p_in = case when
1390+
{sql_function}(p_in::text, 'FM{sql_format}')
1391+
between '0001-01-01'::{sql_type}
1392+
and '9999-12-31'::{sql_type}
1393+
then
1394+
{sql_function}(p_in::text, 'FM{sql_format}')
1395+
else NULL
1396+
end;
1397+
13931398
END IF;
13941399
exception when others then
13951400
begin
1396-
p_in = GREATEST(p_in::{sql_type}, '0001-01-01'::{sql_type});
1401+
p_in = case when
1402+
p_in::{sql_type}
1403+
between '0001-01-01'::{sql_type}
1404+
and '9999-12-31'::{sql_type}
1405+
then
1406+
p_in::{sql_type}
1407+
else NULL
1408+
end;
1409+
13971410
exception when others then
13981411
p_in = p_default;
13991412
end;
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import typing
2+
3+
from baserow.core.psycopg import is_psycopg3, psycopg
4+
5+
if is_psycopg3:
6+
from django.db.backends.signals import connection_created
7+
8+
from baserow.core.psycopg import (
9+
DataError,
10+
DateBinaryLoader,
11+
DateLoader,
12+
TimestampBinaryLoader,
13+
TimestampLoader,
14+
TimestamptzBinaryLoader,
15+
TimestamptzLoader,
16+
)
17+
18+
class _DateOverflowLoaderMixin:
19+
def load(self, data):
20+
try:
21+
return super().load(data)
22+
except DataError:
23+
return None
24+
25+
class _TimestamptzOverflowLoaderMixin:
26+
timezone = None
27+
28+
def load(self, data):
29+
try:
30+
res = super().load(data)
31+
return res.replace(tzinfo=self.timezone)
32+
except DataError:
33+
return None
34+
35+
class BaserowDateLoader(_DateOverflowLoaderMixin, DateLoader):
36+
pass
37+
38+
class BaserowDateBinaryLoader(_DateOverflowLoaderMixin, DateBinaryLoader):
39+
pass
40+
41+
class BaserowTimestampLoader(_DateOverflowLoaderMixin, TimestampLoader):
42+
pass
43+
44+
class BaserowTimestampBinaryLoader(_DateOverflowLoaderMixin, TimestampBinaryLoader):
45+
pass
46+
47+
def pg_init():
48+
"""
49+
Registers loaders for psycopg3 to handle date overflow.
50+
"""
51+
52+
psycopg.adapters.register_loader("date", BaserowDateLoader)
53+
psycopg.adapters.register_loader("date", BaserowDateBinaryLoader)
54+
55+
psycopg.adapters.register_loader("timestamp", BaserowTimestampLoader)
56+
psycopg.adapters.register_loader("timestamp", BaserowTimestampBinaryLoader)
57+
58+
# psycopg3 and timezones allow per-connection / per-cursor adapting. This is
59+
# done in django/db/backends/postgresql/psycopg_any.py in a hook that
60+
# registries tz aware adapter for each connection/cursor.
61+
# We can re-register our loaders here, but note that this will work on
62+
# per-connection tz setting. Cursors still will use django-provided adapters
63+
def register_context(signal, sender, connection, **kwargs):
64+
register_on_connection(connection)
65+
66+
connection_created.connect(register_context)
67+
68+
def register_on_connection(connection):
69+
"""
70+
Registers timestamptz pg type loaders for a connection.
71+
"""
72+
73+
ctx = connection.connection.adapters
74+
75+
class SpecificTzLoader(_TimestamptzOverflowLoaderMixin, TimestamptzLoader):
76+
timezone = connection.timezone
77+
78+
class SpecificTzBinaryLoader(
79+
_TimestamptzOverflowLoaderMixin, TimestamptzBinaryLoader
80+
):
81+
timezone = connection.timezone
82+
83+
ctx.register_loader("timestamptz", SpecificTzLoader)
84+
ctx.register_loader("timestamptz", SpecificTzBinaryLoader)
85+
86+
else:
87+
from django.db.utils import DataError as DjangoDataError
88+
89+
from psycopg2._psycopg import (
90+
DATE,
91+
DATEARRAY,
92+
DATETIME,
93+
DATETIMEARRAY,
94+
DATETIMETZ,
95+
DATETIMETZARRAY,
96+
DataError,
97+
)
98+
99+
def _make_adapter(
100+
type_adapter,
101+
) -> typing.Callable[[typing.Any, typing.Any], typing.Any]:
102+
def adapter(value, cur):
103+
try:
104+
return type_adapter(value, cur)
105+
except (DataError, DjangoDataError, ValueError):
106+
return
107+
108+
return adapter
109+
110+
def pg_init():
111+
"""
112+
Registers loaders for psycopg2 to handle date overflow.
113+
"""
114+
115+
for type_adapter, typea_adapter in (
116+
(
117+
DATE,
118+
DATEARRAY,
119+
),
120+
(
121+
DATETIME,
122+
DATETIMEARRAY,
123+
),
124+
(
125+
DATETIMETZ,
126+
DATETIMETZARRAY,
127+
),
128+
):
129+
oid = type_adapter.values
130+
array_oid = typea_adapter.values
131+
typename = type_adapter.name
132+
handler = _make_adapter(type_adapter)
133+
array_handler = _make_adapter(typea_adapter)
134+
135+
ptype = psycopg.extensions.new_type(oid, typename, handler)
136+
array_ptype = psycopg.extensions.new_type(
137+
array_oid, typename, array_handler
138+
)
139+
psycopg.extensions.register_type(ptype)
140+
psycopg.extensions.register_type(array_ptype)

backend/src/baserow/core/psycopg.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,20 @@
55
import psycopg # noqa: F401
66
from psycopg import errors, sql # noqa: F401
77

8+
# used for date type mapping
9+
from psycopg.types.datetime import ( # noqa: F401
10+
DataError,
11+
DateBinaryLoader,
12+
DateLoader,
13+
TimestampBinaryLoader,
14+
TimestampLoader,
15+
TimestamptzBinaryLoader,
16+
TimestamptzLoader,
17+
)
18+
819
else:
920
import psycopg2 as psycopg # noqa: F401
21+
from psycopg2 import DataError # noqa: F401
1022
from psycopg2 import errors, sql # noqa: F401
1123

1224

backend/tests/baserow/contrib/database/field/test_date_field_type.py

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88

99
from baserow.contrib.database.fields.field_types import DateFieldType
1010
from baserow.contrib.database.fields.handler import FieldHandler
11-
from baserow.contrib.database.fields.models import DateField
11+
from baserow.contrib.database.fields.models import DateField, TextField
1212
from baserow.contrib.database.fields.registries import field_type_registry
1313
from baserow.contrib.database.fields.utils import DeferredForeignKeyUpdater
1414
from baserow.contrib.database.rows.handler import RowHandler
1515
from baserow.contrib.database.views.handler import ViewHandler
16+
from baserow.core.psycopg import is_psycopg3
1617
from baserow.core.registries import ImportExportConfig
1718

1819

@@ -519,10 +520,8 @@ def test_negative_date_field_value(data_fixture):
519520
assert getattr(results[3], f"field_{datetime_field.id}") is None
520521
assert getattr(results[4], f"field_{date_field.id}") is None
521522
assert getattr(results[4], f"field_{datetime_field.id}") is None
522-
assert getattr(results[5], f"field_{date_field.id}") == date(1, 1, 1)
523-
assert getattr(results[5], f"field_{datetime_field.id}") == (
524-
datetime(1, 1, 1, tzinfo=timezone.utc)
525-
)
523+
assert getattr(results[5], f"field_{date_field.id}") is None
524+
assert getattr(results[5], f"field_{datetime_field.id}") is None
526525
assert getattr(results[6], f"field_{date_field.id}") is None
527526
assert getattr(results[6], f"field_{datetime_field.id}") is None
528527
assert getattr(results[7], f"field_{date_field.id}") == date(2010, 2, 3)
@@ -738,3 +737,106 @@ def test_get_group_by_metadata_in_rows_with_date_field(data_fixture):
738737
]
739738
)
740739
}
740+
741+
742+
@pytest.mark.django_db
743+
def test_date_field_overflow(settings, data_fixture):
744+
user = data_fixture.create_user()
745+
table = data_fixture.create_database_table(user=user)
746+
747+
field_handler = FieldHandler()
748+
row_handler = RowHandler()
749+
750+
date_field = field_handler.create_field(
751+
user=user,
752+
table=table,
753+
type_name="text",
754+
name="Date",
755+
)
756+
invalid_date_value = "19999-01-01"
757+
row = row_handler.create_row(
758+
user=user, table=table, values={date_field.db_column: invalid_date_value}
759+
)
760+
assert getattr(row, date_field.db_column, None) == invalid_date_value
761+
762+
date_field = field_handler.update_field(
763+
user=user, field=date_field, new_type_name="date", date_format="ISO"
764+
)
765+
766+
assert isinstance(
767+
table.get_model().get_field_object(date_field.db_column)["field"], DateField
768+
)
769+
out = row_handler.get_rows(table.get_model(), [row.id])
770+
assert len(out) == 1
771+
assert getattr(out[0], date_field.db_column, None) is None
772+
773+
date_field = field_handler.update_field(
774+
user=user, field=date_field, new_type_name="text", date_format="ISO"
775+
)
776+
777+
table.refresh_from_db()
778+
assert isinstance(
779+
table.get_model().get_field_object(date_field.db_column)["field"], TextField
780+
)
781+
out = row_handler.get_rows(table.get_model(), [row.id])
782+
assert len(out) == 1
783+
assert getattr(out[0], date_field.db_column, None) is None
784+
785+
786+
@pytest.mark.django_db
787+
def test_datetime_field_overflow(on_db_connection, data_fixture):
788+
if is_psycopg3:
789+
from baserow.contrib.database.fields.utils.pg_datetime import (
790+
register_on_connection,
791+
)
792+
793+
# manually register adapters, as signal-based registration will be called
794+
# too late
795+
on_db_connection(register_on_connection)
796+
797+
user = data_fixture.create_user()
798+
table = data_fixture.create_database_table(user=user)
799+
800+
field_handler = FieldHandler()
801+
row_handler = RowHandler()
802+
803+
date_field = field_handler.create_field(
804+
user=user,
805+
table=table,
806+
type_name="text",
807+
name="Date",
808+
)
809+
invalid_date_value = "19999-01-01 01:01"
810+
row = row_handler.create_row(
811+
user=user, table=table, values={date_field.db_column: invalid_date_value}
812+
)
813+
assert getattr(row, date_field.db_column, None) == invalid_date_value
814+
815+
date_field = field_handler.update_field(
816+
user=user,
817+
field=date_field,
818+
new_type_name="date",
819+
date_format="ISO",
820+
date_include_time=True,
821+
date_time_format="24",
822+
)
823+
assert isinstance(
824+
table.get_model().get_field_object(date_field.db_column)["field"], DateField
825+
)
826+
out = row_handler.get_rows(table.get_model(), [row.id])
827+
assert len(out) == 1
828+
829+
assert getattr(out[0], date_field.db_column, None) is None
830+
831+
date_field = field_handler.update_field(
832+
user=user, field=date_field, new_type_name="text", date_format="ISO"
833+
)
834+
835+
table.refresh_from_db()
836+
assert isinstance(
837+
table.get_model().get_field_object(date_field.db_column)["field"], TextField
838+
)
839+
out = row_handler.get_rows(table.get_model(), [row.id])
840+
assert len(out) == 1
841+
842+
assert getattr(out[0], date_field.db_column, None) is None

0 commit comments

Comments
 (0)