Skip to content
Merged
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
2 changes: 2 additions & 0 deletions backend/src/baserow/contrib/database/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ def ready(self):
SingleSelectIsAnyOfViewFilterType,
SingleSelectIsNoneOfViewFilterType,
SingleSelectNotEqualViewFilterType,
StartsWithViewFilterType,
UserIsNotViewFilterType,
UserIsViewFilterType,
)
Expand All @@ -414,6 +415,7 @@ def ready(self):
view_filter_type_registry.register(ContainsNotViewFilterType())
view_filter_type_registry.register(ContainsWordViewFilterType())
view_filter_type_registry.register(DoesntContainWordViewFilterType())
view_filter_type_registry.register(StartsWithViewFilterType())
view_filter_type_registry.register(LengthIsLowerThanViewFilterType())
view_filter_type_registry.register(HigherThanViewFilterType())
view_filter_type_registry.register(HigherThanOrEqualViewFilterType())
Expand Down
12 changes: 12 additions & 0 deletions backend/src/baserow/contrib/database/fields/field_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,18 @@ def contains_filter(
return Q(**{f"{field_name}__icontains": value})


def starts_with_filter(
field_name, value, model_field, _, validate=True
) -> OptionallyAnnotatedQ:
value = value.strip()
# If an empty value has been provided we do not want to filter at all.
if value == "":
return Q()
if validate:
model_field.get_prep_value(value)
return Q(**{f"{field_name}__istartswith": value})


def contains_word_filter(field_name, value, model_field, _) -> OptionallyAnnotatedQ:
value = value.strip()
# If an empty value has been provided we do not want to filter at all.
Expand Down
32 changes: 32 additions & 0 deletions backend/src/baserow/contrib/database/fields/field_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@
contains_word_filter,
filename_contains_filter,
parse_ids_from_csv_string,
starts_with_filter,
)
from .field_helpers import prepare_files_for_export
from .field_sortings import OptionallyAnnotatedOrderBy
Expand Down Expand Up @@ -378,6 +379,9 @@ def get_alter_column_prepare_new_value(self, connection, from_field, to_field):
def contains_query(self, *args):
return contains_filter(*args)

def starts_with_query(self, *args):
return starts_with_filter(*args)

def contains_word_query(self, *args):
return contains_word_filter(*args)

Expand Down Expand Up @@ -474,6 +478,9 @@ def random_value(self, instance, fake, cache):
def contains_query(self, *args):
return contains_filter(*args)

def starts_with_query(self, *args):
return starts_with_filter(*args)

def contains_word_query(self, *args):
return contains_word_filter(*args)

Expand Down Expand Up @@ -544,6 +551,9 @@ def random_value(self, instance, fake, cache):
def contains_query(self, *args):
return contains_filter(*args)

def starts_with_query(self, *args):
return starts_with_filter(*args)

def contains_word_query(self, *args):
return contains_word_filter(*args)

Expand Down Expand Up @@ -810,6 +820,9 @@ def force_same_type_alter_column(self, from_field, to_field):
def contains_query(self, *args):
return contains_filter(*args)

def starts_with_query(self, *args):
return starts_with_filter(*args)

def get_export_serialized_value(self, row, field_name, cache, files_zip, storage):
value = self.get_internal_value_from_db(row, field_name)
return value if value is None else str(value)
Expand Down Expand Up @@ -4776,6 +4789,13 @@ def contains_query(self, field_name, value, model_field, field):
return Q()
return Q(**{f"{field_name}__value__icontains": value})

def starts_with_query(self, field_name, value, model_field, field):
value = value.strip()
# If an empty value has been provided we do not want to filter at all.
if value == "":
return Q()
return Q(**{f"{field_name}__value__istartswith": value})

def contains_word_query(self, field_name, value, model_field, field):
value = value.strip()
# If an empty value has been provided we do not want to filter at all.
Expand Down Expand Up @@ -5700,6 +5720,15 @@ def contains_query(self, field_name, value, model_field, field: FormulaField):
) = self.get_field_instance_and_type_from_formula_field(field)
return field_type.contains_query(field_name, value, model_field, field_instance)

def starts_with_query(self, field_name, value, model_field, field: FormulaField):
(
field_instance,
field_type,
) = self.get_field_instance_and_type_from_formula_field(field)
return field_type.starts_with_query(
field_name, value, model_field, field_instance
)

def contains_word_query(self, field_name, value, model_field, field):
(
field_instance,
Expand Down Expand Up @@ -7523,6 +7552,9 @@ def prepare_value_for_db(self, instance: Field, value):
def contains_query(self, *args):
return contains_filter(*args)

def starts_with_query(self, *args):
return starts_with_filter(*args)

def update_rows_with_field_sequence(
self, field: Field, view: Optional["View"] = None
):
Expand Down
19 changes: 19 additions & 0 deletions backend/src/baserow/contrib/database/fields/registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,25 @@ def contains_query(self, field_name, value, model_field, field):

return Q()

def starts_with_query(self, field_name, value, model_field, field):
"""
Returns a Q or AnnotatedQ filter which performs a starts with filter over the
provided field for this specific type of field.

:param field_name: The name of the field.
:type field_name: str
:param value: The value to check if this field starts with or not.
:type value: str
:param model_field: The field's actual django field model instance.
:type model_field: models.Field
:param field: The related field's instance.
:type field: Field
:return: A Q or AnnotatedQ filter for the given value.
:rtype: OptionallyAnnotatedQ
"""

return Q()

def contains_word_query(self, field_name, value, model_field, field):
"""
Returns a Q or AnnotatedQ filter which performs a regex filter over the
Expand Down
36 changes: 36 additions & 0 deletions backend/src/baserow/contrib/database/views/view_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,42 @@ class ContainsNotViewFilterType(NotViewFilterTypeMixin, ContainsViewFilterType):
type = "contains_not"


class StartsWithViewFilterType(ViewFilterType):
"""
The starts with filter checks if the field value starts with the provided filter
value.
"""

type = "starts_with"
# Field types that resolve to one of these via `get_compatible_filter_field_type`
# also become compatible, so they must implement `starts_with_query`. Otherwise
# the base implementation returns an empty `Q()` and the filter matches every row.
compatible_field_types = [
TextFieldType.type,
LongTextFieldType.type,
URLFieldType.type,
EmailFieldType.type,
PhoneNumberFieldType.type,
NumberFieldType.type,
AutonumberFieldType.type,
SingleSelectFieldType.type,
FormulaFieldType.compatible_with_formula_types(
BaserowFormulaTextType.type,
BaserowFormulaCharType.type,
BaserowFormulaURLType.type,
BaserowFormulaNumberType.type,
BaserowFormulaSingleSelectType.type,
),
]

def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ:
try:
field_type = field_type_registry.get_by_model(field)
return field_type.starts_with_query(field_name, value, model_field, field)
except Exception:
return self.default_filter_on_exception()


class LengthIsLowerThanViewFilterType(ViewFilterType):
"""
The length is lower than filter checks if the fields character
Expand Down
10 changes: 10 additions & 0 deletions backend/src/baserow/core/formula/field.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import logging
from copy import deepcopy
from typing import Any, Dict, List, Optional, Union

from django.db import connection, models
Expand Down Expand Up @@ -451,6 +452,15 @@ def get_prep_value(
if not isinstance(value, (dict, list)):
return value

# Work on a copy: the minification below mutates `value` in place, but
# `value` is the model instance's in-memory attribute. Mutating it as a
# side effect of preparing the DB value is unsafe when `get_prep_value`
# runs more than once for a single save (e.g. django-silk compiles each
# query twice for logging) — the second run would re-minify the
# already-minified value and blank the formula. Copying keeps the
# in-memory value untouched (full form) so re-runs are idempotent.
value = deepcopy(value)

# Iterate over the properties bound to this field.
# Each property represents a path to a formula field
# we need to convert from full to minified.
Expand Down
158 changes: 158 additions & 0 deletions backend/tests/baserow/contrib/database/view/test_view_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,164 @@ def test_contains_word_filter_type(data_fixture):
assert len(ids) == 0


@pytest.mark.django_db
def test_starts_with_filter_type(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
grid_view = data_fixture.create_grid_view(table=table)
text_field = data_fixture.create_text_field(table=table)
long_text_field = data_fixture.create_long_text_field(table=table)
url_field = data_fixture.create_url_field(table=table)
email_field = data_fixture.create_email_field(table=table)
phone_number_field = data_fixture.create_phone_number_field(table=table)
number_field = data_fixture.create_number_field(table=table)
autonumber_field = data_fixture.create_autonumber_field(table=table)
single_select_field = data_fixture.create_single_select_field(table=table)
option_a = data_fixture.create_select_option(
field=single_select_field, value="Active", color="blue"
)
option_b = data_fixture.create_select_option(
field=single_select_field, value="Done", color="red"
)
formula_field = data_fixture.create_formula_field(
table=table,
name="formula",
formula=f"field('{text_field.name}')",
formula_type="text",
)
number_formula_field = data_fixture.create_formula_field(
table=table,
name="number formula",
formula=f"field('{number_field.name}')",
)

handler = ViewHandler()
model = table.get_model()

# The autonumber field is read only and is assigned sequentially, so the
# first created row gets 1 and the second gets 2.
row, row_2 = (
RowHandler()
.create_rows(
user,
table,
rows_values=[
{
f"field_{text_field.id}": "hello world",
f"field_{long_text_field.id}": "hello long world",
f"field_{url_field.id}": "https://www.example.com",
f"field_{email_field.id}": "john.doe@example.com",
f"field_{phone_number_field.id}": "+1-541-754-3010",
f"field_{number_field.id}": 12345,
f"field_{single_select_field.id}": option_a.id,
},
{
f"field_{text_field.id}": "doesnt match",
f"field_{long_text_field.id}": "another value",
f"field_{url_field.id}": "http://baserow.io",
f"field_{email_field.id}": "jane.doe@baserow.io",
f"field_{phone_number_field.id}": "+44-20-7946-0000",
f"field_{number_field.id}": 90000,
f"field_{single_select_field.id}": option_b.id,
},
],
model=model,
)
.created_rows
)

view_filter = data_fixture.create_view_filter(
view=grid_view, field=text_field, type="starts_with", value="hello"
)
# Only the row starting with the value is kept.
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row.id in ids

# The match is case insensitive.
view_filter.value = "HELLO"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row.id in ids

# A value that is contained but not at the start does not match.
view_filter.value = "world"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 0

# An empty value does not filter at all.
view_filter.value = ""
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 2

view_filter.field = long_text_field
view_filter.value = "hello"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row.id in ids

view_filter.field = url_field
view_filter.value = "https://"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row.id in ids

view_filter.field = email_field
view_filter.value = "john"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row.id in ids

view_filter.field = phone_number_field
view_filter.value = "+1"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row.id in ids

view_filter.field = number_field
view_filter.value = "123"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row.id in ids

view_filter.field = autonumber_field
view_filter.value = "1"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row.id in ids

view_filter.field = single_select_field
view_filter.value = "act"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row.id in ids

view_filter.field = formula_field
view_filter.value = "hello"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row.id in ids

# A non-text formula delegates to the underlying field type's query.
view_filter.field = number_formula_field
view_filter.value = "123"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row.id in ids


@pytest.mark.django_db
def test_doesnt_contain_word_filter_type(data_fixture):
user = data_fixture.create_user()
Expand Down
Loading
Loading