Skip to content

Commit

Permalink
Prevent querying range fields with tuples of (start, end)
Browse files Browse the repository at this point in the history
The Django implementation of range fields allows querying them with
tuples of (start, end), presumably because the underlying pg_extras.Range
types aren't really supposed to be exposed to the user.

Considering we're attempting to enforce strict usages of types, we
explicitly prevent non-range types from being used in queries.
  • Loading branch information
jarshwah committed Mar 1, 2024
1 parent 04c956d commit 0491227
Show file tree
Hide file tree
Showing 3 changed files with 137 additions and 0 deletions.
24 changes: 24 additions & 0 deletions docs/xocto/model_fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,30 @@ not require any changes to the database schema. The migration file generated wil
to the new fields, but since the underlying database type is the same, the migration will
be a no-op.

The standard Django query operators are almost the same as for the built-in types.
They accept `xocto.ranges` as arguments, but don't support passing in a tuple of values:

```python

assert SalesPeriod.objects.filter(
period__contains=ranges.FiniteDateRange(
start=datetime.date(2020, 1, 10),
start=datetime.date(2020, 1, 20),
)
).exists()

assert SalesPeriod.objects.filter(
period__overlaps=ranges.FiniteDateRange(
start=datetime.date(2020, 1, 10),
start=datetime.date(2020, 1, 20),
)
).exists()

# ERROR! This will raise a TypeError
SalesPeriod.objects.filter(period__overlaps=(datetime.date(2020, 1, 10), datetime.date(2020, 1, 20)))

```

#### FiniteDateRangeField

Module: `xocto.fields.postgres.ranges.FiniteDateRangeField`\
Expand Down
83 changes: 83 additions & 0 deletions tests/fields/postgres/test_ranges.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,29 @@ def test_query(self):
)
).exists()

def test_query_does_not_allow_tuple_values(self):
with pytest.raises(TypeError):
models.FiniteDateRangeModel.objects.filter(
finite_date_range=(
datetime.date(2024, 1, 10),
datetime.date(2024, 2, 9),
)
)
with pytest.raises(TypeError):
models.FiniteDateRangeModel.objects.filter(
finite_date_range__overlap=(
datetime.date(2024, 1, 1),
datetime.date(2024, 1, 15),
)
)
with pytest.raises(TypeError):
models.FiniteDateRangeModel.objects.filter(
finite_date_range__contains=(
datetime.date(2024, 1, 11),
datetime.date(2024, 1, 15),
)
)

def test_serialization(self):
obj = models.FiniteDateRangeModel.objects.create(
finite_date_range=ranges.FiniteDateRange(
Expand Down Expand Up @@ -124,6 +147,29 @@ def test_query(self):
)
).exists()

def test_query_does_not_allow_tuple_values(self):
with pytest.raises(TypeError):
models.FiniteDateTimeRangeModel.objects.filter(
finite_datetime_range=(
localtime.datetime(2024, 1, 10),
localtime.datetime(2024, 2, 9),
)
)
with pytest.raises(TypeError):
models.FiniteDateTimeRangeModel.objects.filter(
finite_datetime_range__overlap=(
localtime.datetime(2024, 1, 1),
localtime.datetime(2024, 1, 15),
)
)
with pytest.raises(TypeError):
models.FiniteDateTimeRangeModel.objects.filter(
finite_datetime_range__contains=(
localtime.datetime(2024, 1, 11),
localtime.datetime(2024, 1, 15),
)
)

def test_serialization(self):
obj = models.FiniteDateTimeRangeModel.objects.create(
finite_datetime_range=ranges.FiniteDatetimeRange(
Expand Down Expand Up @@ -234,6 +280,43 @@ def test_query(self):
)
).exists()

def test_query_does_not_allow_tuple_values(self):
with pytest.raises(TypeError):
models.HalfFiniteDateTimeRangeModel.objects.filter(
half_finite_datetime_range=(
localtime.datetime(2024, 1, 10),
None,
)
)
with pytest.raises(TypeError):
models.HalfFiniteDateTimeRangeModel.objects.filter(
half_finite_datetime_range__overlap=(
localtime.datetime(2024, 1, 1),
localtime.datetime(2024, 1, 15),
)
)
with pytest.raises(TypeError):
models.HalfFiniteDateTimeRangeModel.objects.filter(
half_finite_datetime_range__overlap=(
localtime.datetime(2024, 1, 1),
None,
)
)
with pytest.raises(TypeError):
models.HalfFiniteDateTimeRangeModel.objects.filter(
half_finite_datetime_range__contains=(
localtime.datetime(2024, 1, 11),
localtime.datetime(2024, 1, 15),
)
)
with pytest.raises(TypeError):
models.HalfFiniteDateTimeRangeModel.objects.filter(
half_finite_datetime_range__contains=(
localtime.datetime(2024, 1, 11),
None,
)
)

def test_serialization(self):
obj = models.HalfFiniteDateTimeRangeModel.objects.create(
half_finite_datetime_range=ranges.HalfFiniteDatetimeRange(
Expand Down
30 changes: 30 additions & 0 deletions xocto/fields/postgres/ranges.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ def get_prep_value(
) -> Optional[pg_ranges.DateRange]:
if value is None:
return None
if not isinstance(value, ranges.FiniteDateRange):
raise TypeError(
"FiniteDateRangeField may only accept FiniteDateRange objects."
)
return pg_ranges.DateRange(lower=value.start, upper=value.end, bounds="[]")

def from_db_value(
Expand Down Expand Up @@ -56,6 +60,10 @@ def value_to_string(self, obj: models.Model) -> Optional[str]:
value: Optional[ranges.FiniteDateRange] = self.value_from_object(obj)
if value is None:
return None
if not isinstance(value, ranges.FiniteDateRange):
raise TypeError(
"FiniteDateRangeField may only accept FiniteDateRange objects."
)
base_field = self.base_field
start = pg_utils.AttributeSetter(base_field.attname, value.start)
end = pg_utils.AttributeSetter(base_field.attname, value.end)
Expand Down Expand Up @@ -92,6 +100,10 @@ def get_prep_value(
) -> Optional[pg_ranges.DateTimeTZRange]:
if value is None:
return None
if not isinstance(value, ranges.FiniteDatetimeRange):
raise TypeError(
"FiniteDateTimeRangeField may only accept FiniteDatetimeRange objects."
)
return pg_ranges.DateTimeTZRange(
lower=value.start, upper=value.end, bounds="[)"
)
Expand Down Expand Up @@ -119,6 +131,10 @@ def value_to_string(self, obj: models.Model) -> Optional[str]:
value: Optional[ranges.FiniteDatetimeRange] = self.value_from_object(obj)
if value is None:
return None
if not isinstance(value, ranges.FiniteDatetimeRange):
raise TypeError(
"FiniteDateTimeRangeField may only accept FiniteDatetimeRange objects."
)
base_field = self.base_field
start = pg_utils.AttributeSetter(base_field.attname, value.start)
end = pg_utils.AttributeSetter(base_field.attname, value.end)
Expand All @@ -143,6 +159,16 @@ def get_prep_value(
) -> Optional[pg_ranges.DateTimeTZRange]:
if value is None:
return None
if (
# HalfFiniteDatetimeRange is a subscripted generic and may not be checked with
# isinstance directly. So, we check for it's parent class and attribute values.
not isinstance(value, ranges.HalfFiniteRange) # type: ignore [redundant-expr]
or not isinstance(value.start, datetime.datetime) # type: ignore [redundant-expr]
or (value.end is not None and not isinstance(value.end, datetime.datetime))
):
raise TypeError(
"HalfFiniteDateTimeRangeField may only accept HalfFiniteDatetimeRange objects."
)
return pg_ranges.DateTimeTZRange(
lower=value.start, upper=value.end, bounds="[)"
)
Expand Down Expand Up @@ -172,6 +198,10 @@ def value_to_string(self, obj: models.Model) -> Optional[str]:
value: Optional[ranges.HalfFiniteDatetimeRange] = self.value_from_object(obj)
if value is None:
return None
if not isinstance(value, ranges.HalfFiniteRange):
raise TypeError(
"HalfFiniteDateTimeRangeField may only accept HalfFiniteDatetimeRange objects."
)
base_field = self.base_field
start = pg_utils.AttributeSetter(base_field.attname, value.start)
end = pg_utils.AttributeSetter(base_field.attname, value.end)
Expand Down

0 comments on commit 0491227

Please sign in to comment.