1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-10 23:50:12 +00:00

Resolve "Add support to filter lookup of multiple select fields"

This commit is contained in:
Davide Silvestri 2025-01-15 17:14:41 +00:00
parent 2eef18b347
commit 8e36dbe32f
28 changed files with 1369 additions and 592 deletions

View file

@ -23,6 +23,21 @@ FILTER_TYPE_OR = "OR"
tracer = trace.get_tracer(__name__)
def parse_ids_from_csv_string(value: str) -> list[int]:
"""
Parses the provided value and returns a list of integers that represent ids. If a
token is not a digit, it is ignored.
:param value: The value that has been provided by the user.
:return: A list of integers that represent ids.
"""
try:
return [int(v) for v in value.split(",") if v.strip().isdigit()]
except ValueError:
return []
class AnnotatedQ:
"""
A simple wrapper class combining a params for a Queryset.annotate call with a

View file

@ -185,6 +185,7 @@ from .field_filters import (
contains_filter,
contains_word_filter,
filename_contains_filter,
parse_ids_from_csv_string,
)
from .field_sortings import OptionallyAnnotatedOrderBy
from .fields import BaserowExpressionField, BaserowLastModifiedField
@ -777,13 +778,16 @@ class NumberFieldType(FieldType):
"number_separator": field.number_separator,
}
def prepare_filter_value(self, field, model_field, value):
def parse_filter_value(self, field, model_field, value):
"""
Verify if it's a valid and finite decimal value, but the filter value doesn't
need to respect the number_decimal_places, because they can change while the
filter_value remains the same.
"""
if value == "":
return None
try:
value = Decimal(value)
if not value.is_finite():
@ -968,8 +972,10 @@ class BooleanFieldType(FieldType):
) -> BooleanField:
return BooleanField()
def prepare_filter_value(self, field, model_field, value):
if value in BASEROW_BOOLEAN_FIELD_TRUE_VALUES:
def parse_filter_value(self, field, model_field, value):
if value == "":
return None
elif value in BASEROW_BOOLEAN_FIELD_TRUE_VALUES:
return True
elif value in BASEROW_BOOLEAN_FIELD_FALSE_VALUES:
return False
@ -3871,6 +3877,20 @@ class SelectOptionBaseFieldType(FieldType):
) -> Expression | F:
return F(f"{field_name}__value")
def parse_filter_value(self, field, model_field, value) -> List[int]:
"""
Parses the provided comma separated string value to extract option ids from it.
If the result does not contain any valid option id, a ValueError is raised.
"""
if value == "":
return None
option_ids = parse_ids_from_csv_string(value)
if not option_ids:
raise ValueError("The provided value does not contain a valid option id.")
return option_ids
class SingleSelectFieldType(CollationSortMixin, SelectOptionBaseFieldType):
type = "single_select"
@ -5291,12 +5311,12 @@ class FormulaFieldType(FormulaFieldTypeArrayFilterSupport, ReadOnlyFieldType):
return FormulaHandler.get_dependencies_field_names(serialized_field["formula"])
def prepare_filter_value(self, field, model_field, value):
def parse_filter_value(self, field, model_field, value):
(
field_instance,
field_type,
) = self.get_field_instance_and_type_from_formula_field(field)
return field_type.prepare_filter_value(field_instance, model_field, value)
return field_type.parse_filter_value(field_instance, model_field, value)
class CountFieldType(FormulaFieldType):

View file

@ -1,9 +1,11 @@
import re
from typing import TYPE_CHECKING, Any, Dict, Type
from typing import TYPE_CHECKING, Any, Dict, List, Type
from django.contrib.postgres.fields import JSONField
from django.db import models
from django.db.models import BooleanField, F, Q, Value
from django.db.models import BooleanField, F
from django.db.models import Field as DjangoField
from django.db.models import Q, Value
from django.db.models.expressions import RawSQL
from loguru import logger
@ -16,9 +18,7 @@ from baserow.contrib.database.formula.expression_generator.django_expressions im
ComparisonOperator,
JSONArrayAllAreExpr,
JSONArrayCompareNumericValueExpr,
JSONArrayContainsValueExpr,
JSONArrayContainsValueLengthLowerThanExpr,
JSONArrayContainsValueSimilarToExpr,
)
if TYPE_CHECKING:
@ -40,7 +40,7 @@ class HasValueEmptyFilterSupport:
return ""
def get_in_array_empty_query(
self, field_name: str, model_field: models.Field, field: "Field"
self, field_name: str, model_field: DjangoField, field: "Field"
) -> OptionallyAnnotatedQ:
"""
Specifies a Q expression to filter empty values contained in an array.
@ -59,7 +59,7 @@ class HasValueEmptyFilterSupport:
class HasValueEqualFilterSupport:
def get_in_array_is_query(
self, field_name: str, value: str, model_field: models.Field, field: "Field"
self, field_name: str, value: str, model_field: DjangoField, field: "Field"
) -> OptionallyAnnotatedQ:
"""
Specifies a Q expression to filter exact values contained in an array.
@ -76,7 +76,7 @@ class HasValueEqualFilterSupport:
class HasValueContainsFilterSupport:
def get_in_array_contains_query(
self, field_name: str, value: str, model_field: models.Field, field: "Field"
self, field_name: str, value: str, model_field: DjangoField, field: "Field"
) -> OptionallyAnnotatedQ:
"""
Specifies a Q expression to filter values in an array that contain a
@ -89,49 +89,30 @@ class HasValueContainsFilterSupport:
:return: A Q or AnnotatedQ filter given value.
"""
annotation_query = JSONArrayContainsValueExpr(
F(field_name), Value(f"%{value}%"), output_field=BooleanField()
)
hashed_value = hash(value)
return AnnotatedQ(
annotation={
f"{field_name}_has_value_contains_{hashed_value}": annotation_query
},
q={f"{field_name}_has_value_contains_{hashed_value}": True},
)
return get_jsonb_contains_filter_expr(model_field, value)
class HasValueContainsWordFilterSupport:
def get_in_array_contains_word_query(
self, field_name: str, value: str, model_field: models.Field, field: "Field"
self, field_name: str, value: str, model_field: DjangoField, field: "Field"
) -> OptionallyAnnotatedQ:
"""
Specifies a Q expression to filter values in an array that contain a
specific word.
Specifies a Q expression to filter values in an array that contain a specific
word.
:param field_name: The name of the field.
:param value: The value to check if it is contained in array.
:param model_field: The field's actual django field model instance.
:param field: The related field's instance.
:param model_field: Django model field instance.
:param field: The related Baserow field's instance containing field's metadata.
:return: A Q or AnnotatedQ filter given value.
"""
value = re.escape(value.upper())
annotation_query = JSONArrayContainsValueSimilarToExpr(
F(field_name), Value(f"%\\m{value}\\M%"), output_field=BooleanField()
)
hashed_value = hash(value)
return AnnotatedQ(
annotation={
f"{field_name}_has_value_contains_word_{hashed_value}": annotation_query
},
q={f"{field_name}_has_value_contains_word_{hashed_value}": True},
)
return get_jsonb_contains_word_filter_expr(model_field, value)
class HasValueLengthIsLowerThanFilterSupport:
def get_in_array_length_is_lower_than_query(
self, field_name: str, value: str, model_field: models.Field, field: "Field"
self, field_name: str, value: str, model_field: DjangoField, field: "Field"
) -> OptionallyAnnotatedQ:
"""
Specifies a Q expression to filter values in an array that has lower
@ -139,8 +120,8 @@ class HasValueLengthIsLowerThanFilterSupport:
:param field_name: The name of the field.
:param value: The value representing the length to use for the check.
:param model_field: The field's actual django field model instance.
:param field: The related field's instance.
:param model_field: Django model field instance.
:param field: The related Baserow field's instance containing field's metadata.
:return: A Q or AnnotatedQ filter given value.
"""
@ -162,23 +143,23 @@ class HasValueLengthIsLowerThanFilterSupport:
class HasAllValuesEqualFilterSupport:
def get_has_all_values_equal_query(
self, field_name: str, value: str, model_field: models.Field, field: "Field"
self, field_name: str, value: str, model_field: DjangoField, field: "Field"
) -> "OptionallyAnnotatedQ":
"""
Creates a query expression to filter rows where all values of an array in
the specified field are equal to a specific value
Creates a query expression to filter rows where all values of an array in
the specified field are equal to a specific value
:param field_name: The name of the field
:param value: The value that should be present in all array elements
in the field
:param model_field: Field's schema model instance.
:param field: Field's instance.
:return: A Q or AnnotatedQ filter given value.
:param field_name: The name of the field
:param value: The value that should be present in all array elements
in the field
:param model_field: Django model field instance.
:param field: The related Baserow field's instance containing field's metadata.
:return: A Q or AnnotatedQ filter given value.
"""
try:
return get_array_json_filter_expression(
JSONArrayAllAreExpr, field_name, value
JSONArrayAllAreExpr, field_name, Value(value)
)
except Exception as err:
@ -194,14 +175,14 @@ class HasNumericValueComparableToFilterSupport:
self,
field_name: str,
value: str,
model_field: models.Field,
model_field: DjangoField,
field: "Field",
comparison_op: ComparisonOperator,
) -> OptionallyAnnotatedQ:
return get_array_json_filter_expression(
JSONArrayCompareNumericValueExpr,
field_name,
value,
Value(value),
comparison_op=comparison_op,
)
@ -209,7 +190,7 @@ class HasNumericValueComparableToFilterSupport:
def get_array_json_filter_expression(
json_expression: Type[BaserowFilterExpression],
field_name: str,
value: str,
value: Value,
**extra: Dict[str, Any],
) -> AnnotatedQ:
"""
@ -220,13 +201,13 @@ def get_array_json_filter_expression(
:param json_expression: BaserowFilterExpression to use for filtering.
:param field_name: the name of the field
:param value: filter the filter value.
:param value: Value expression containing the filter value with the proper type.
:param extra: extra arguments for the json_expression.
:return: the annotated query for the filter.
"""
annotation_query = json_expression(
F(field_name), Value(value), output_field=BooleanField(), **extra
F(field_name), value, output_field=BooleanField(), **extra
)
expr_name = json_expression.__name__.lower()
hashed_value = hash(value)
@ -235,3 +216,153 @@ def get_array_json_filter_expression(
annotation={annotation_name: annotation_query},
q={annotation_name: True},
)
def get_jsonb_contains_filter_expr(
model_field: DjangoField, value: str, query_path: str = "$[*].value"
) -> OptionallyAnnotatedQ:
"""
Returns an AnnotatedQ that will filter rows where the JSON field contains the
specified value or an empty filter if the value provided is an emtpy string. The
value is matched using a case-insensitive LIKE query. Providing a query_path allows
for filtering on a specific path in the JSON field.
:param model_field: The django model field to filter on. The field is used to get
the model for the subquery and the field name.
:param value: The value to use to create the contains filter, case-insensitive.
:param query_path: The path in the JSON field to filter on. Defaults to
"$[*].value".
:return: An AnnotatedQ that will filter rows where the JSON field contains the
"""
# If an empty value has been provided we do not want to filter at all.
if value == "":
return Q()
field_name = model_field.name
raw_sql = f"""
EXISTS(
SELECT 1
FROM jsonb_path_query("{field_name}", %s) elem
WHERE UPPER(elem::text) LIKE UPPER(%s)
)
""" # nosec B608 {field_name}
expr = RawSQL(raw_sql, (query_path, f"%{value}%")) # nosec B611
annotation_name = f"{field_name}_contains_{hash(value)}"
return AnnotatedQ(
annotation={annotation_name: expr},
q=Q(**{annotation_name: True}),
)
def get_jsonb_contains_word_filter_expr(
model_field: DjangoField, value: str, query_path: str = "$[*].value"
) -> OptionallyAnnotatedQ:
"""
Returns an AnnotatedQ that will filter rows where the JSON field contains the
specified word or an empty filter if the value provided is an emtpy string. The
value is matched using a case-insensitive LIKE query. Providing a query_path allows
for filtering on a specific path in the JSON field.
:param model_field: The django model field to filter on. The field is used to get
the model for the subquery and the field name.
:param value: The value to use to create the contains word filter, case-insensitive.
:param query_path: The path in the JSON field to filter on. Defaults to
"$[*].value".
:return: An AnnotatedQ that will filter rows where the JSON field contains the
specified word.
"""
# If an empty value has been provided we do not want to filter at all.
if value == "":
return Q()
field_name = model_field.name
re_value = re.escape(value.upper())
raw_sql = f"""
EXISTS(
SELECT 1
FROM jsonb_path_query("{field_name}", %s) elem
WHERE UPPER(elem::text) ~ %s
)
""" # nosec B608 {field_name}
expr = RawSQL(raw_sql, (query_path, rf"\m{re_value}\M")) # nosec B611
annotation_name = f"{field_name}_contains_word_{hash(value)}"
return AnnotatedQ(
annotation={annotation_name: expr},
q=Q(**{annotation_name: True}),
)
def get_jsonb_has_any_in_value_filter_expr(
model_field: DjangoField,
value: List[int],
query_path: str = "$[*].id",
) -> OptionallyAnnotatedQ:
"""
Returns an AnnotatedQ that will filter rows where the JSON field contains any of the
IDs provided in value. Providing a query_path allows for
filtering on a specific path in the JSON field.
:param model_field: The django model field to filter on. The field is used to get
the model for the subquery and the field name.
:param value: A list of IDs to filter on. The list cannot be empty.
:param query_path: The path in the JSON field to filter on. Defaults to "$[*].id".
:return: An AnnotatedQ that will filter rows where the JSON field contains any of
the select option IDs provided in the value.
"""
sql_ids = "||".join([f"(@ == {v})" for v in value])
field_name = model_field.name
raw_sql = f"""
EXISTS(
SELECT 1
FROM jsonb_path_exists("{field_name}", %s) elem
WHERE elem = true
)
""" # nosec B608 {field_name}
expr = RawSQL(raw_sql, (f"{query_path} ? ({sql_ids})",)) # nosec B611
annotation_name = f"{field_name}_has_any_of_{hash(sql_ids)}"
return AnnotatedQ(
annotation={annotation_name: expr},
q=Q(**{annotation_name: True}),
)
def get_jsonb_has_exact_value_filter_expr(
model_field: DjangoField, value: List[int]
) -> OptionallyAnnotatedQ:
"""
Returns an AnnotatedQ that filters rows where the JSON field exactly matches the
provided IDs. The JSON field must be an array of objects, each containing a 'value'
key, which is an array of objects with 'id' keys. For example:
[{"value": [{"id": 1}, {"id": 2}]}, {"value": [{"id": 3}]}, ...]
:param model_field: The Django model field to filter on.
:param value: A list of IDs to match. The list cannot be empty.
:return: An AnnotatedQ that filters rows with the exact IDs in the JSON field.
"""
field_name = model_field.name
sql_ids = sorted(set(value))
raw_sql = f"""
EXISTS(
SELECT 1
FROM jsonb_array_elements("{field_name}") top_obj
WHERE (
SELECT array_agg((inner_el->>'id')::int ORDER BY (inner_el->>'id')::int)
FROM jsonb_array_elements(top_obj->'value') inner_el
) = %s::int[]
)
""" # nosec B608 {field_name}
expr = RawSQL(raw_sql, (sql_ids,)) # nosec B611
annotation_name = f"{field_name}_has_any_of_{hash(tuple(sql_ids))}"
return AnnotatedQ(
annotation={annotation_name: expr},
q=Q(**{annotation_name: True}),
)

View file

@ -1,7 +1,6 @@
import typing
from django.db import models
from django.db.models import Q
from baserow.contrib.database.fields.field_filters import OptionallyAnnotatedQ
from baserow.contrib.database.formula.expression_generator.django_expressions import (
@ -43,7 +42,7 @@ class FormulaFieldTypeArrayFilterSupport(
value: str,
model_field: models.Field,
field: "FormulaField",
) -> Q | OptionallyAnnotatedQ:
) -> OptionallyAnnotatedQ:
(
field_instance,
field_type,

View file

@ -0,0 +1,58 @@
from typing import TYPE_CHECKING, List
from django.db.models import Field
from baserow.contrib.database.fields.field_filters import OptionallyAnnotatedQ
from .base import (
HasValueContainsFilterSupport,
HasValueContainsWordFilterSupport,
HasValueEmptyFilterSupport,
HasValueEqualFilterSupport,
get_jsonb_contains_filter_expr,
get_jsonb_contains_word_filter_expr,
get_jsonb_has_any_in_value_filter_expr,
get_jsonb_has_exact_value_filter_expr,
)
if TYPE_CHECKING:
from baserow.contrib.database.fields.models import Field as BaserowField
class MultipleSelectFormulaTypeFilterSupport(
HasValueEmptyFilterSupport,
HasValueEqualFilterSupport,
HasValueContainsFilterSupport,
HasValueContainsWordFilterSupport,
):
def get_in_array_empty_query(
self, field_name, model_field, field: "BaserowField"
) -> OptionallyAnnotatedQ:
# Use get_jsonb_has_any_in_value_filter_expr with size() to check if the array
# is empty.
return get_jsonb_has_any_in_value_filter_expr(
model_field, [0], query_path="$[*].value.size()"
)
def get_in_array_is_query(
self,
field_name: str,
value: List[int],
model_field: Field,
field: "BaserowField",
) -> OptionallyAnnotatedQ:
return get_jsonb_has_exact_value_filter_expr(model_field, value)
def get_in_array_contains_query(
self, field_name: str, value: str, model_field: Field, field: "BaserowField"
) -> OptionallyAnnotatedQ:
return get_jsonb_contains_filter_expr(
model_field, value, query_path="$[*].value.value"
)
def get_in_array_contains_word_query(
self, field_name: str, value: str, model_field: Field, field: "BaserowField"
) -> OptionallyAnnotatedQ:
return get_jsonb_contains_word_filter_expr(
model_field, value, query_path="$[*].value.value"
)

View file

@ -1,17 +1,10 @@
from functools import reduce
from typing import TYPE_CHECKING, Any, List
from typing import TYPE_CHECKING, List
from django.db import models
from django.db.models import BooleanField, F, Q, Value
from baserow.contrib.database.fields.field_filters import (
AnnotatedQ,
OptionallyAnnotatedQ,
)
from baserow.contrib.database.formula.expression_generator.django_expressions import (
JSONArrayContainsSelectOptionValueExpr,
JSONArrayContainsSelectOptionValueSimilarToExpr,
JSONArrayEqualSelectOptionIdExpr,
from baserow.contrib.database.fields.field_filters import OptionallyAnnotatedQ
from baserow.contrib.database.fields.filter_support.multiple_select import (
get_jsonb_has_any_in_value_filter_expr,
)
from .base import (
@ -19,6 +12,8 @@ from .base import (
HasValueContainsWordFilterSupport,
HasValueEmptyFilterSupport,
HasValueEqualFilterSupport,
get_jsonb_contains_filter_expr,
get_jsonb_contains_word_filter_expr,
)
if TYPE_CHECKING:
@ -31,65 +26,30 @@ class SingleSelectFormulaTypeFilterSupport(
HasValueContainsFilterSupport,
HasValueContainsWordFilterSupport,
):
def get_in_array_empty_value(self, field: "Field") -> Any:
def get_in_array_empty_value(self, field: "Field"):
return None
def get_in_array_is_query(
self,
field_name: str,
value: str | List[str],
value: List[int],
model_field: models.Field,
field: "Field",
) -> OptionallyAnnotatedQ:
if not value:
return Q()
elif isinstance(value, str):
try:
# If the value is a single value it must be a valid ID.
int(value)
except ValueError:
return Q()
value = [value]
annotations, q = {}, []
for v in value:
hashed_value = hash(v)
annotation_key = f"{field_name}_has_value_{hashed_value}"
annotation_query = JSONArrayEqualSelectOptionIdExpr(
F(field_name), Value(f"{v}"), output_field=BooleanField()
)
annotations[annotation_key] = annotation_query
q.append(Q(**{annotation_key: True}))
return AnnotatedQ(
annotation=annotations,
q=reduce(lambda a, b: a | b, q),
return get_jsonb_has_any_in_value_filter_expr(
model_field, value, query_path="$[*].value.id"
)
def get_in_array_contains_query(
self, field_name: str, value: str, model_field: models.Field, field: "Field"
) -> OptionallyAnnotatedQ:
annotation_query = JSONArrayContainsSelectOptionValueExpr(
F(field_name), Value(f"%{value}%"), output_field=BooleanField()
)
hashed_value = hash(value)
return AnnotatedQ(
annotation={
f"{field_name}_has_value_contains_{hashed_value}": annotation_query
},
q={f"{field_name}_has_value_contains_{hashed_value}": True},
return get_jsonb_contains_filter_expr(
model_field, value, query_path="$[*].value.value"
)
def get_in_array_contains_word_query(
self, field_name: str, value: str, model_field: models.Field, field: "Field"
) -> OptionallyAnnotatedQ:
annotation_query = JSONArrayContainsSelectOptionValueSimilarToExpr(
F(field_name), Value(f"{value}"), output_field=BooleanField()
)
hashed_value = hash(value)
return AnnotatedQ(
annotation={
f"{field_name}_has_value_contains_word_{hashed_value}": annotation_query
},
q={f"{field_name}_has_value_contains_word_{hashed_value}": True},
return get_jsonb_contains_word_filter_expr(
model_field, value, query_path="$[*].value.value"
)

View file

@ -19,8 +19,8 @@ from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.fields import JSONField as PostgresJSONField
from django.core.exceptions import ValidationError
from django.core.files.storage import Storage
from django.db import models as django_models
from django.db.models import (
Aggregate,
BooleanField,
CharField,
Count,
@ -28,6 +28,9 @@ from django.db.models import (
Expression,
ExpressionWrapper,
F,
)
from django.db.models import Field as DjangoField
from django.db.models import (
IntegerField,
JSONField,
Model,
@ -368,7 +371,7 @@ class FieldType(
def empty_query(
self,
field_name: str,
model_field: django_models.Field,
model_field: DjangoField,
field: Field,
) -> Q:
"""
@ -1826,8 +1829,8 @@ class FieldType(
return value1 == value2
def prepare_filter_value(
self, field: "Field", model_field: django_models.Field, value: Any
def parse_filter_value(
self, field: "Field", model_field: DjangoField, value: str
) -> Any:
"""
Prepare a non-empty value string to be used in a view filter, verifying if it is
@ -1840,11 +1843,14 @@ class FieldType(
:param field: The field instance that the value belongs to.
:param model_field: The model field that the value must be prepared for.
:param value: The value that must be prepared for filtering.
:return: The prepared value.
:return: The prepared value or None if the value is an empty string.
:raises ValueError: If the value is not compatible for the given field and
model_field.
"""
if value == "":
return None
try:
return model_field.get_prep_value(value)
except ValidationError as e:
@ -1852,7 +1858,7 @@ class FieldType(
def get_formula_reference_to_model_field(
self,
model_field: django_models.Field,
model_field: DjangoField,
db_column: str,
already_in_subquery: bool,
) -> Expression:
@ -2207,9 +2213,7 @@ class FieldAggregationType(Instance):
for t in self.compatible_field_types
)
def _get_raw_aggregation(
self, model_field: django_models.Field, field: Field
) -> django_models.Aggregate:
def _get_raw_aggregation(self, model_field: DjangoField, field: Field) -> Aggregate:
"""
Returns the raw aggregation that should be used for the field aggregation
type.
@ -2222,7 +2226,7 @@ class FieldAggregationType(Instance):
return self.raw_type().get_aggregation(field.db_column, model_field, field)
def _get_aggregation_dict(
self, queryset: QuerySet, model_field: django_models.Field, field: Field
self, queryset: QuerySet, model_field: DjangoField, field: Field
) -> dict:
"""
Returns a dictinary defining the aggregation for the queryset.aggregate

View file

@ -192,34 +192,6 @@ class FileNameContainsExpr(BaserowFilterExpression):
# fmt: on
class JSONArrayContainsValueExpr(BaserowFilterExpression):
# fmt: off
template = (
f"""
EXISTS(
SELECT 1
FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field
WHERE UPPER(filtered_field ->> 'value') LIKE UPPER(%(value)s::text)
)
""" # nosec B608
)
# fmt: on
class JSONArrayContainsValueSimilarToExpr(BaserowFilterExpression):
# fmt: off
template = (
f"""
EXISTS(
SELECT 1
FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field
WHERE UPPER(filtered_field ->> 'value') SIMILAR TO %(value)s
)
""" # nosec B608 %(value)s
)
# fmt: on
class JSONArrayContainsValueLengthLowerThanExpr(BaserowFilterExpression):
# fmt: off
template = (
@ -242,48 +214,6 @@ class JSONArrayAllAreExpr(BaserowFilterExpression):
SELECT upper(filtered_field ->> 'value')
FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field
) AND JSONB_ARRAY_LENGTH(%(field_name)s) > 0
""" # nosec B608 %(value)s
)
# fmt: on
class JSONArrayEqualSelectOptionIdExpr(BaserowFilterExpression):
# fmt: off
template = (
f"""
EXISTS(
SELECT 1
FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field
WHERE (filtered_field -> 'value' ->> 'id') LIKE (%(value)s)
)
""" # nosec B608
)
# fmt: on
class JSONArrayContainsSelectOptionValueExpr(BaserowFilterExpression):
# fmt: off
template = (
f"""
EXISTS(
SELECT 1
FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field
WHERE UPPER(filtered_field -> 'value' ->> 'value') LIKE UPPER(%(value)s)
)
""" # nosec B608
)
# fmt: on
class JSONArrayContainsSelectOptionValueSimilarToExpr(BaserowFilterExpression):
# fmt: off
template = (
r"""
EXISTS(
SELECT 1
FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field
WHERE filtered_field -> 'value' ->> 'value' ~* ('\y' || %(value)s || '\y')
)
""" # nosec B608 %(value)s
)
# fmt: on

View file

@ -497,7 +497,7 @@ class BaserowFormulaType(abc.ABC):
field_instance.id = field.id
return field_type.is_searchable(field_instance)
def prepare_filter_value(self, field, model_field, value):
def parse_filter_value(self, field, model_field, value):
"""
Use the Baserow field type method where possible to prepare the filter value.
"""
@ -506,7 +506,7 @@ class BaserowFormulaType(abc.ABC):
field_instance,
field_type,
) = self.get_baserow_field_instance_and_type()
return field_type.prepare_filter_value(field_instance, model_field, value)
return field_type.parse_filter_value(field_instance, model_field, value)
class BaserowFormulaInvalidType(BaserowFormulaType):

View file

@ -4,19 +4,9 @@ from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import Any, List, Optional, Set, Type, Union
from django.contrib.postgres.expressions import ArraySubquery
from django.contrib.postgres.fields import ArrayField, JSONField
from django.db import models
from django.db.models import (
Expression,
F,
Func,
OuterRef,
Q,
QuerySet,
TextField,
Value,
)
from django.db.models import Expression, F, Func, Q, QuerySet, TextField, Value
from django.db.models.functions import Cast, Concat
from dateutil import parser
@ -28,10 +18,7 @@ from baserow.contrib.database.fields.expressions import (
extract_jsonb_list_values_to_array,
json_extract_path,
)
from baserow.contrib.database.fields.field_filters import (
AnnotatedQ,
OptionallyAnnotatedQ,
)
from baserow.contrib.database.fields.field_filters import OptionallyAnnotatedQ
from baserow.contrib.database.fields.field_sortings import OptionallyAnnotatedOrderBy
from baserow.contrib.database.fields.filter_support.base import (
HasAllValuesEqualFilterSupport,
@ -42,6 +29,12 @@ from baserow.contrib.database.fields.filter_support.base import (
HasValueEqualFilterSupport,
HasValueLengthIsLowerThanFilterSupport,
get_array_json_filter_expression,
get_jsonb_contains_filter_expr,
get_jsonb_contains_word_filter_expr,
)
from baserow.contrib.database.fields.filter_support.multiple_select import (
MultipleSelectFormulaTypeFilterSupport,
get_jsonb_has_any_in_value_filter_expr,
)
from baserow.contrib.database.fields.filter_support.single_select import (
SingleSelectFormulaTypeFilterSupport,
@ -63,7 +56,6 @@ from baserow.contrib.database.formula.ast.tree import (
from baserow.contrib.database.formula.expression_generator.django_expressions import (
ComparisonOperator,
JSONArrayCompareNumericValueExpr,
JSONArrayContainsValueExpr,
)
from baserow.contrib.database.formula.registries import formula_function_registry
from baserow.contrib.database.formula.types.exceptions import UnknownFormulaType
@ -82,14 +74,21 @@ from baserow.core.utils import list_to_comma_separated_string
class BaserowJSONBObjectBaseType(BaserowFormulaValidType, ABC):
def prepare_filter_value(self, field, model_field, value):
def parse_filter_value(self, field, model_field, value):
"""
Since the subclasses don't have a baserow_field_type or data might be stored
differently due to the JSONB nature, return the value as is here and let the
filter method handle it.
"""
return value
from baserow.contrib.database.fields.registries import field_type_registry
if self.baserow_field_type is None:
return value if value != "" else None
return field_type_registry.get(self.baserow_field_type).parse_filter_value(
field, model_field, value
)
class BaserowFormulaBaseTextType(BaserowFormulaTypeHasEmptyBaserowExpression):
@ -482,7 +481,7 @@ class BaserowFormulaNumberType(
return get_array_json_filter_expression(
JSONArrayCompareNumericValueExpr,
field_name,
value,
Value(value),
comparison_op=ComparisonOperator.EQUAL,
)
@ -527,10 +526,10 @@ class BaserowFormulaBooleanType(
return expr
def get_in_array_is_query(
self, field_name: str, value: str, model_field: models.Field, field: "Field"
self, field_name: str, value: bool, model_field: models.Field, field: "Field"
) -> OptionallyAnnotatedQ:
return get_array_json_filter_expression(
JSONArrayContainsValueExpr, field_name, value
return get_jsonb_has_any_in_value_filter_expr(
model_field, [str(value).lower()], query_path="$[*].value"
)
def get_order_by_in_array_expr(self, field, field_name, order_direction):
@ -543,13 +542,6 @@ class BaserowFormulaBooleanType(
),
)
def get_has_all_values_equal_query(
self, field_name: str, value: str, model_field: models.Field, field: "Field"
) -> "OptionallyAnnotatedQ":
return super().get_has_all_values_equal_query(
field_name, value, model_field, field
)
def _calculate_addition_interval_type(
arg1: BaserowExpression[BaserowFormulaValidType],
@ -1352,8 +1344,8 @@ class BaserowFormulaArrayType(
)
}
def prepare_filter_value(self, field, model_field, value):
return self.sub_type.prepare_filter_value(field, model_field, value)
def parse_filter_value(self, field, model_field, value):
return self.sub_type.parse_filter_value(field, model_field, value)
class BaserowFormulaSingleSelectType(
@ -1508,7 +1500,9 @@ class BaserowFormulaSingleSelectType(
}
class BaserowFormulaMultipleSelectType(BaserowJSONBObjectBaseType):
class BaserowFormulaMultipleSelectType(
MultipleSelectFormulaTypeFilterSupport, BaserowJSONBObjectBaseType
):
type = "multiple_select"
baserow_field_type = "multiple_select"
can_order_by = False
@ -1621,74 +1615,10 @@ class BaserowFormulaMultipleSelectType(BaserowJSONBObjectBaseType):
return formula_function_registry.get("multiple_select_count")(arg)
def contains_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()
model = model_field.model
subq = (
model.objects.filter(id=OuterRef("id"))
.annotate(
res=Func(
Func(
field_name,
function="jsonb_array_elements",
output_field=JSONField(),
),
Value("value"),
function="jsonb_extract_path",
output_field=TextField(),
)
)
.values("res")
)
annotation_name = f"{field_name}_contains"
return AnnotatedQ(
annotation={
annotation_name: Func(
ArraySubquery(subq, output_field=ArrayField(TextField())),
Value(","),
function="array_to_string",
output_field=TextField(),
)
},
q=Q(**{f"{annotation_name}__icontains": value}),
)
return get_jsonb_contains_filter_expr(model_field, 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.
if value == "":
return Q()
model = model_field.model
subq = (
model.objects.filter(id=OuterRef("id"))
.annotate(
res=Func(
Func(
field_name,
function="jsonb_array_elements",
output_field=JSONField(),
),
Value("value"),
function="jsonb_extract_path",
output_field=TextField(),
)
)
.values("res")
)
annotation_name = f"{field_name}_contains"
return AnnotatedQ(
annotation={
annotation_name: Func(
ArraySubquery(subq, output_field=ArrayField(TextField())),
Value(","),
function="array_to_string",
output_field=TextField(),
)
},
q=Q(**{f"{annotation_name}__iregex": rf"\m{re.escape(value)}\M"}),
)
return get_jsonb_contains_word_filter_expr(model_field, value)
BASEROW_FORMULA_TYPES = [

View file

@ -5,6 +5,7 @@ from django.db.models import Q
from baserow.contrib.database.fields.field_filters import OptionallyAnnotatedQ
from baserow.contrib.database.fields.field_types import FormulaFieldType
from baserow.contrib.database.fields.filter_support.base import (
HasAllValuesEqualFilterSupport,
HasNumericValueComparableToFilterSupport,
HasValueContainsFilterSupport,
HasValueContainsWordFilterSupport,
@ -23,6 +24,7 @@ from baserow.contrib.database.formula.expression_generator.django_expressions im
from baserow.contrib.database.formula.types.formula_types import (
BaserowFormulaBooleanType,
BaserowFormulaCharType,
BaserowFormulaMultipleSelectType,
BaserowFormulaSingleSelectType,
BaserowFormulaURLType,
)
@ -45,6 +47,7 @@ class HasEmptyValueViewFilterType(ViewFilterType):
FormulaFieldType.array_of(BaserowFormulaURLType.type),
FormulaFieldType.array_of(BaserowFormulaSingleSelectType.type),
FormulaFieldType.array_of(BaserowFormulaNumberType.type),
FormulaFieldType.array_of(BaserowFormulaMultipleSelectType.type),
),
]
@ -68,15 +71,17 @@ class ComparisonHasValueFilter(ViewFilterType, ABC):
def get_filter(
self, field_name, value: str, model_field, field
) -> OptionallyAnnotatedQ:
if value == "":
return Q()
field_type = field_type_registry.get_by_model(field)
try:
filter_value = field_type.prepare_filter_value(field, model_field, value)
filter_value = field_type.parse_filter_value(
field, model_field, value.strip()
)
except ValueError: # invalid filter value for the field
return self.default_filter_on_exception()
if filter_value is None:
return Q()
return self.get_filter_expression(field_name, filter_value, model_field, field)
@abstractmethod
@ -110,6 +115,7 @@ class HasValueEqualViewFilterType(ComparisonHasValueFilter):
FormulaFieldType.array_of(BaserowFormulaBooleanType.type),
FormulaFieldType.array_of(BaserowFormulaSingleSelectType.type),
FormulaFieldType.array_of(BaserowFormulaNumberType.type),
FormulaFieldType.array_of(BaserowFormulaMultipleSelectType.type),
),
]
@ -140,13 +146,11 @@ class HasValueContainsViewFilterType(ViewFilterType):
FormulaFieldType.array_of(BaserowFormulaURLType.type),
FormulaFieldType.array_of(BaserowFormulaSingleSelectType.type),
FormulaFieldType.array_of(BaserowFormulaNumberType.type),
FormulaFieldType.array_of(BaserowFormulaMultipleSelectType.type),
),
]
def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ:
if value == "":
return Q()
field_type: HasValueContainsFilterSupport = field_type_registry.get_by_model(
field
)
@ -174,18 +178,16 @@ class HasValueContainsWordViewFilterType(ViewFilterType):
FormulaFieldType.array_of(BaserowFormulaCharType.type),
FormulaFieldType.array_of(BaserowFormulaURLType.type),
FormulaFieldType.array_of(BaserowFormulaSingleSelectType.type),
FormulaFieldType.array_of(BaserowFormulaMultipleSelectType.type),
),
]
def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ:
if value == "":
return Q()
field_type: HasValueContainsWordFilterSupport = (
field_type_registry.get_by_model(field)
)
return field_type.get_in_array_contains_word_query(
field_name, value, model_field, field
field_name, value.strip(), model_field, field
)
@ -244,7 +246,7 @@ class HasAllValuesEqualViewFilterType(ComparisonHasValueFilter):
def get_filter_expression(
self, field_name, value, model_field, field
) -> OptionallyAnnotatedQ:
field_type: HasAllValuesEqualViewFilterType = field_type_registry.get_by_model(
field_type: HasAllValuesEqualFilterSupport = field_type_registry.get_by_model(
field
)
return field_type.get_has_all_values_equal_query(
@ -252,6 +254,7 @@ class HasAllValuesEqualViewFilterType(ComparisonHasValueFilter):
)
# TODO: remove in future versions since it's the same as parent class now.
class HasAnySelectOptionEqualViewFilterType(HasValueEqualViewFilterType):
"""
This filter can be used to verify if any of the select options in an array
@ -265,21 +268,11 @@ class HasAnySelectOptionEqualViewFilterType(HasValueEqualViewFilterType):
),
]
def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ:
if value == "":
return Q()
return super().get_filter(field_name, value.split(","), model_field, field)
# TODO: remove in future versions since it's the same as parent class now.
class HasNoneSelectOptionEqualViewFilterType(
NotViewFilterTypeMixin, HasAnySelectOptionEqualViewFilterType
):
"""
This filter can be used to verify if none of the select options in an array are
equal to the option IDs provided
"""
type = "has_none_select_option_equal"

View file

@ -931,35 +931,30 @@ class ViewFilterType(Instance):
return {}
def get_export_serialized_value(self, value, id_mapping: Dict) -> str:
def get_export_serialized_value(self, value: str | None, id_mapping: dict) -> str:
"""
This method is called before the filter value is exported. Here it can
optionally be modified.
:param value: The original value.
:type value: str
:param id_mapping: Cache for mapping object ids.
:return: The updated value.
:rtype: str
"""
if value is None:
return ""
return value
def set_import_serialized_value(self, value, id_mapping) -> str:
def set_import_serialized_value(self, value: str | None, id_mapping: dict) -> str:
"""
This method is called before a field is imported. It can optionally be
modified. If the value for example points to a field or select option id, it
can be replaced with the correct value by doing a lookup in the id_mapping.
:param value: The original exported value.
:type value: str
:param id_mapping: The map of exported ids to newly created ids that must be
updated when a new instance has been created.
:type id_mapping: dict
:return: The new value that will be imported.
:rtype: str
"""
if value is None:

View file

@ -3,17 +3,14 @@ import zoneinfo
from collections import defaultdict
from datetime import date, datetime, timedelta
from enum import Enum
from functools import reduce
from types import MappingProxyType
from typing import Any, Dict, NamedTuple, Optional, Tuple, Union
from django.contrib.postgres.expressions import ArraySubquery
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.db.models import DateField, DateTimeField, IntegerField, OuterRef, Q, Value
from django.db.models import DateField, DateTimeField, IntegerField, Q, Value
from django.db.models.expressions import F, Func
from django.db.models.fields.json import JSONField
from django.db.models.functions import Cast, Extract, Length, Mod, TruncDate
from django.db.models.functions import Extract, Length, Mod, TruncDate
from dateutil import parser
from dateutil.relativedelta import MO, relativedelta
@ -24,6 +21,7 @@ from baserow.contrib.database.fields.field_filters import (
FilterBuilder,
OptionallyAnnotatedQ,
filename_contains_filter,
parse_ids_from_csv_string,
)
from baserow.contrib.database.fields.field_types import (
AutonumberFieldType,
@ -50,7 +48,10 @@ from baserow.contrib.database.fields.field_types import (
URLFieldType,
UUIDFieldType,
)
from baserow.contrib.database.fields.models import Field
from baserow.contrib.database.fields.filter_support.base import (
get_jsonb_has_any_in_value_filter_expr,
)
from baserow.contrib.database.fields.models import Field, LinkRowField
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.formula import (
BaserowFormulaBooleanType,
@ -115,20 +116,19 @@ class EqualViewFilterType(ViewFilterType):
]
def get_filter(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()
# Check if the model_field accepts the value.
field_type = field_type_registry.get_by_model(field)
try:
value = field_type.prepare_filter_value(field, model_field, value)
return Q(**{field_name: value})
except Exception:
value = field_type.parse_filter_value(field, model_field, value.strip())
except ValueError:
return self.default_filter_on_exception()
# If an empty value has been provided we do not want to filter at all.
if value is None:
return Q()
return Q(**{field_name: value})
class NotEqualViewFilterType(NotViewFilterTypeMixin, EqualViewFilterType):
type = "not_equal"
@ -379,18 +379,17 @@ class NumericComparisonViewFilterType(ViewFilterType):
]
def get_filter(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()
field_type = field_type_registry.get_by_model(field)
try:
filter_value = field_type.prepare_filter_value(field, model_field, value)
filter_value = field_type.parse_filter_value(
field, model_field, value.strip()
)
except ValueError:
return self.default_filter_on_exception()
if filter_value is None:
return Q()
return Q(**{f"{field_name}__{self.operator}": filter_value})
@ -1073,11 +1072,15 @@ class SingleSelectEqualViewFilterType(ViewFilterType):
),
]
def _get_filter(field_name, value, model_field, field):
@staticmethod
def _get_filter(field_name, value: int, model_field, field):
return Q(**{f"{field_name}_id": value})
def _get_formula_filter(field_name, value, model_field, field):
return Q(**{f"{field_name}__id": value})
@staticmethod
def _get_formula_filter(field_name, value: int, model_field, field):
return get_jsonb_has_any_in_value_filter_expr(
model_field, [value], query_path="$.id"
)
filter_functions = MappingProxyType(
{
@ -1132,9 +1135,8 @@ class SingleSelectIsAnyOfViewFilterType(ViewFilterType):
return Q(**{f"{field_name}_id__in": option_ids})
def _get_formula_filter(field_name, option_ids, model_field, field):
return reduce(
lambda x, y: x | y,
[Q(**{f"{field_name}__id": str(option_id)}) for option_id in option_ids],
return get_jsonb_has_any_in_value_filter_expr(
model_field, option_ids, query_path="$.id"
)
filter_functions = MappingProxyType(
@ -1144,31 +1146,21 @@ class SingleSelectIsAnyOfViewFilterType(ViewFilterType):
}
)
def parse_option_ids(self, value):
try:
return [int(v) for v in value.split(",") if v.isdigit()]
# non-strings will raise AttributeError, so we have a type check here too
except (
ValueError,
AttributeError,
):
return []
def get_filter(self, field_name, value: str, model_field, field):
value = value.strip()
if not value:
return Q()
if not (option_ids := self.parse_option_ids(value)):
if not (option_ids := parse_ids_from_csv_string(value)):
return self.default_filter_on_exception()
field_type = field_type_registry.get_by_model(field)
filter_function = self.filter_functions[field_type.type]
return filter_function(field_name, option_ids, model_field, field)
def set_import_serialized_value(self, value: str, id_mapping: dict) -> str:
def set_import_serialized_value(self, value: str | None, id_mapping: dict) -> str:
# Parses the old option ids and remaps them to the new option ids.
old_options_ids = self.parse_option_ids(value)
old_options_ids = parse_ids_from_csv_string(value or "")
select_option_map = id_mapping["database_field_select_options"]
new_values = []
for old_id in old_options_ids:
@ -1206,16 +1198,18 @@ class BooleanViewFilterType(ViewFilterType):
]
def get_filter(self, field_name, value, model_field, field):
if value == "": # consider emtpy value as False
value = "false"
if value == "": # consider an empty string as False
return Q(**{field_name: False})
field_type = field_type_registry.get_by_model(field)
try:
value = field_type.prepare_filter_value(field, model_field, value)
return Q(**{field_name: value})
filter_value = BooleanFieldType().parse_filter_value(
field, model_field, value
)
except ValueError:
return self.default_filter_on_exception()
return Q(**{field_name: filter_value})
class ManyToManyHasBaseViewFilter(ViewFilterType):
"""
@ -1263,18 +1257,20 @@ class LinkRowHasViewFilterType(ManyToManyHasBaseViewFilter):
pass
if related_row_id:
field = view_filter.field.specific
# TODO: use field.get_related_primary_field() and
# model_field.remote_field.model here instead
table = field.link_row_table
primary_field = table.field_set.get(primary=True)
model = table.get_model(
field_ids=[], fields=[primary_field], add_dependencies=False
)
related_table_id = LinkRowField.objects.filter(
id=view_filter.field_id
).values("link_row_table_id")[:1]
try:
primary_field = Field.objects.select_related("table").get(
primary=True, table_id=related_table_id
)
model = primary_field.table.get_model(
field_ids=[], fields=[primary_field], add_dependencies=False
)
name = str(model.objects.get(pk=related_row_id))
except model.DoesNotExist:
except (Field.DoesNotExist, model.DoesNotExist):
pass
return {"display_name": name}
@ -1365,49 +1361,21 @@ class MultipleSelectHasViewFilterType(ManyToManyHasBaseViewFilter):
),
]
@staticmethod
def _get_filter(field_name, option_ids, model_field, field):
try:
remote_field = model_field.remote_field
remote_model = remote_field.model
return Q(
id__in=remote_model.objects.filter(id__in=option_ids).values(
f"{remote_field.related_name}__id"
)
remote_field = model_field.remote_field
remote_model = remote_field.model
return Q(
id__in=remote_model.objects.filter(id__in=option_ids).values(
f"{remote_field.related_name}__id"
)
except ValueError:
return Q()
)
@staticmethod
def _get_formula_filter(field_name, option_ids, model_field, field):
model = model_field.model
subq = (
model.objects.filter(id=OuterRef("id"))
.annotate(
res=Cast(
Func(
Func(field_name, function="jsonb_array_elements"),
Value("id"),
function="jsonb_extract_path",
),
output_field=IntegerField(),
)
)
.values("res")
return get_jsonb_has_any_in_value_filter_expr(
model_field, option_ids, query_path="$[*].id"
)
annotation_name = f"{field_name}_has"
return AnnotatedQ(
annotation={
annotation_name: ArraySubquery(
subq, output_field=ArrayField(IntegerField())
)
},
q=Q(**{f"{annotation_name}__overlap": option_ids}),
)
def parse_option_ids(self, value):
try:
return [int(v) for v in value.split(",") if v.isdigit()]
except ValueError:
return []
filter_functions = MappingProxyType(
{
@ -1421,16 +1389,16 @@ class MultipleSelectHasViewFilterType(ManyToManyHasBaseViewFilter):
if not value:
return Q()
if not (option_ids := self.parse_option_ids(value)):
if not (option_ids := parse_ids_from_csv_string(value)):
return self.default_filter_on_exception()
field_type = field_type_registry.get_by_model(field)
filter_function = self.filter_functions[field_type.type]
return filter_function(field_name, option_ids, model_field, field)
def set_import_serialized_value(self, value, id_mapping):
def set_import_serialized_value(self, value: str | None, id_mapping: dict) -> str:
# Parses the old option ids and remaps them to the new option ids.
old_options_ids = self.parse_option_ids(value)
old_options_ids = parse_ids_from_csv_string(value or "")
select_option_map = id_mapping["database_field_select_options"]
new_values = []

View file

@ -3193,7 +3193,7 @@ def test_multiple_select_contains_not_filter_type(field_name, data_fixture):
)
def test_multiple_select_contains_word_filter_type(field_name, data_fixture):
test_setup = setup_view_for_multiple_select_field(
data_fixture, ["A", "AA", "B", None]
data_fixture, ["A", "aa", "B", None]
)
handler = ViewHandler()
grid_view = test_setup.grid_view

View file

@ -123,6 +123,19 @@ def single_select_field_value_factory(data_fixture, target_field, value=None):
)
def multiple_select_field_factory(data_fixture, table, user):
return data_fixture.create_multiple_select_field(
name="target", user=user, table=table
)
def multiple_select_field_value_factory(data_fixture, target_field, value=None):
if value is None:
return []
option = data_fixture.create_select_option(field=target_field, value=value)
return [option.id]
def duration_field_factory(
data_fixture, table, user, duration_format: str = "d h mm", name: str | None = None
):

View file

@ -7,6 +7,8 @@ from tests.baserow.contrib.database.utils import (
boolean_field_factory,
email_field_factory,
long_text_field_factory,
multiple_select_field_factory,
multiple_select_field_value_factory,
phone_number_field_factory,
setup_linked_table_and_lookup,
single_select_field_factory,
@ -2355,3 +2357,224 @@ def test_has_none_select_option_equal_filter_single_select_field(data_fixture):
]
assert len(ids) == 1
assert row_2.id in ids
def setup_multiple_select_rows(data_fixture):
test_setup = setup_linked_table_and_lookup(
data_fixture, multiple_select_field_factory
)
user = data_fixture.create_user()
row_A_value = multiple_select_field_value_factory(
data_fixture, test_setup.target_field, "Aa C"
)
row_B_value = multiple_select_field_value_factory(
data_fixture, test_setup.target_field, "B"
)
row_empty_value = multiple_select_field_value_factory(
data_fixture, test_setup.target_field
)
(
other_row_A,
other_row_B,
other_row_empty,
) = test_setup.row_handler.force_create_rows(
user,
test_setup.other_table,
[
{f"field_{test_setup.target_field.id}": row_A_value},
{f"field_{test_setup.target_field.id}": row_B_value},
{f"field_{test_setup.target_field.id}": row_empty_value},
],
)
row_1 = test_setup.row_handler.create_row(
user=test_setup.user,
table=test_setup.table,
values={
f"field_{test_setup.link_row_field.id}": [
other_row_A.id,
other_row_empty.id,
]
},
)
row_2 = test_setup.row_handler.create_row(
user=test_setup.user,
table=test_setup.table,
values={f"field_{test_setup.link_row_field.id}": []},
)
row_3 = test_setup.row_handler.create_row(
user=test_setup.user,
table=test_setup.table,
values={f"field_{test_setup.link_row_field.id}": [other_row_B.id]},
)
return test_setup, [row_1, row_2, row_3], [*row_A_value, *row_B_value]
@pytest.mark.django_db
@pytest.mark.field_multiple_select
def test_has_or_has_not_empty_value_filter_multiple_select_field_types(
data_fixture,
):
test_setup, [row_1, row_2, row_3], _ = setup_multiple_select_rows(data_fixture)
view_filter = data_fixture.create_view_filter(
view=test_setup.grid_view,
field=test_setup.lookup_field,
type="has_empty_value",
value="",
)
ids = [
r.id
for r in test_setup.view_handler.apply_filters(
test_setup.grid_view, test_setup.model.objects.all()
).all()
]
assert len(ids) == 1
assert row_1.id in ids
view_filter.type = "has_not_empty_value"
view_filter.save()
ids = [
r.id
for r in test_setup.view_handler.apply_filters(
test_setup.grid_view, test_setup.model.objects.all()
).all()
]
assert ids == [row_2.id, row_3.id]
@pytest.mark.django_db
@pytest.mark.field_multiple_select
def test_has_or_doesnt_have_value_contains_filter_multiple_select_field_types(
data_fixture,
):
test_setup, [row_1, row_2, row_3], _ = setup_multiple_select_rows(data_fixture)
view_filter = data_fixture.create_view_filter(
view=test_setup.grid_view,
field=test_setup.lookup_field,
type="has_value_contains",
value="A",
)
ids = [
r.id
for r in test_setup.view_handler.apply_filters(
test_setup.grid_view, test_setup.model.objects.all()
).all()
]
assert len(ids) == 1
assert row_1.id in ids
view_filter.type = "has_not_value_contains"
view_filter.save()
ids = [
r.id
for r in test_setup.view_handler.apply_filters(
test_setup.grid_view, test_setup.model.objects.all()
).all()
]
assert ids == [row_2.id, row_3.id]
@pytest.mark.django_db
@pytest.mark.field_multiple_select
def test_has_or_doesnt_have_value_contains_word_filter_multiple_select_field_types(
data_fixture,
):
test_setup, [row_1, row_2, row_3], _ = setup_multiple_select_rows(data_fixture)
view_filter = data_fixture.create_view_filter(
view=test_setup.grid_view,
field=test_setup.lookup_field,
type="has_value_contains_word",
value="A",
)
ids = [
r.id
for r in test_setup.view_handler.apply_filters(
test_setup.grid_view, test_setup.model.objects.all()
).all()
]
assert len(ids) == 0
view_filter.value = "Aa"
view_filter.save()
ids = [
r.id
for r in test_setup.view_handler.apply_filters(
test_setup.grid_view, test_setup.model.objects.all()
).all()
]
assert row_1.id in ids
view_filter.type = "has_not_value_contains_word"
view_filter.save()
ids = [
r.id
for r in test_setup.view_handler.apply_filters(
test_setup.grid_view, test_setup.model.objects.all()
).all()
]
assert ids == [row_2.id, row_3.id]
@pytest.mark.django_db
@pytest.mark.field_multiple_select
def test_has_or_doesnt_have_value_equal_filter_multiple_select_field_types(
data_fixture,
):
test_setup, [row_1, row_2, row_3], options = setup_multiple_select_rows(
data_fixture
)
# row_1 links to other_row_A (options[0]) and other_row_empty ([])
# row_2 links to other_row_empty ([])
# row_3 links to other_row_B (options[1])
view_filter = data_fixture.create_view_filter(
view=test_setup.grid_view,
field=test_setup.lookup_field,
type="has_value_equal",
value="A",
)
ids = [
r.id
for r in test_setup.view_handler.apply_filters(
test_setup.grid_view, test_setup.model.objects.all()
).all()
]
assert len(ids) == 0
view_filter.value = str(options[0])
view_filter.save()
ids = [
r.id
for r in test_setup.view_handler.apply_filters(
test_setup.grid_view, test_setup.model.objects.all()
).all()
]
assert ids == [row_1.id]
view_filter.value = ",".join([str(oid) for oid in options])
view_filter.save()
ids = [
r.id
for r in test_setup.view_handler.apply_filters(
test_setup.grid_view, test_setup.model.objects.all()
).all()
]
assert ids == [] # no row has all options
view_filter.type = "has_not_value_equal"
view_filter.save()
ids = [
r.id
for r in test_setup.view_handler.apply_filters(
test_setup.grid_view, test_setup.model.objects.all()
).all()
]
assert ids == [row_1.id, row_2.id, row_3.id]

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Add support to filter lookup of multiple select fields.",
"issue_number": 3277,
"bullet_points": [],
"created_at": "2024-12-12"
}

View file

@ -608,8 +608,8 @@ class TimelineViewType(ViewType):
Verify that the start and the end date fields have compatible settings. This
means that both fields must have the same timezone (if set) and include time
settings. If include_time is False (date only fields), the timezone setting is
ignored. Please keep this in sync with the frontend implementation.
#TODO: add reference to frontend implementation
ignored. Please keep this in sync with the dateFieldsAreCompatible function in
web-frontend/modules/baserow_premium/utils/timeline.js.
:param start_date_field: The start date field instance.
:return: True if the date settings are valid, otherwise False.

View file

@ -8,6 +8,7 @@ import {
numericHasValueComparableToFilterFunction,
ComparisonOperator,
} from '@baserow/modules/database/utils/fieldFilters'
import _ from 'lodash'
export const hasEmptyValueFilterMixin = {
getHasEmptyValueFilterFunction(field) {
@ -276,9 +277,9 @@ export const hasSelectOptionIdEqualMixin = Object.assign(
const filterValues = String(filterValue ?? '')
.trim()
.split(',')
return filterValues.reduce((acc, fltValue) => {
return acc || hasValueEqualFilter(cellValue, String(fltValue))
}, false)
return filterValues.some((fltValue) =>
hasValueEqualFilter(cellValue, String(fltValue))
)
}
},
}
@ -311,3 +312,87 @@ export const hasSelectOptionValueContainsWordFilterMixin = Object.assign(
},
}
)
export const hasNestedSelectOptionValueContainsFilterMixin = Object.assign(
{},
hasValueContainsFilterMixin,
{
getHasValueContainsFilterFunction(field) {
return (cellValue, filterValue) => {
if (!Array.isArray(cellValue) || cellValue.length === 0) {
return false
}
return cellValue.some((v) =>
genericHasValueContainsFilter(v?.value || [], filterValue)
)
}
},
}
)
export const hasNestedSelectOptionValueContainsWordFilterMixin = Object.assign(
{},
hasValueContainsWordFilterMixin,
{
getHasValueContainsWordFilterFunction(field) {
return (cellValue, filterValue) => {
if (!Array.isArray(cellValue) || cellValue.length === 0) {
return false
}
return cellValue.some((v) =>
genericHasValueContainsWordFilter(v?.value || [], filterValue)
)
}
},
}
)
export const hasMultipleSelectAnyOptionIdEqualMixin = Object.assign(
{},
hasValueEqualFilterMixin,
{
getHasValueEqualFilterFunction(field) {
return (cellValue, filterValue) => {
if (!Array.isArray(cellValue)) {
return false
}
const rowValueIds = new Set(
cellValue.flatMap((v) => (v?.value || []).map((i) => i.id))
)
const filterValues = (filterValue || '')
.trim()
.split(',')
.map(Number.parseInt)
return filterValues.some((fltValue) => rowValueIds.has(fltValue))
}
},
}
)
export const hasMultipleSelectOptionIdEqualMixin = Object.assign(
{},
hasValueEqualFilterMixin,
{
getHasValueEqualFilterFunction(field) {
return (cellValue, filterValue) => {
if (!Array.isArray(cellValue)) {
return false
}
const filterValues = (filterValue || '')
.trim()
.split(',')
.map((oid) => Number.parseInt(oid))
// create an array with the sets containing the ids per linked row
const rowValueIdSets = cellValue.map(
(v) => new Set(v?.value.map((i) => i.id))
)
// Compare if any of the linked row values match exactly the filter values
return rowValueIdSets.some((rowValueIdSet) =>
_.isEqual(rowValueIdSet, new Set(filterValues))
)
}
},
}
)

View file

@ -6,8 +6,25 @@ import viewFilterTypeText from '@baserow/modules/database/components/view/ViewFi
import ViewFilterTypeMultipleSelectOptions from '@baserow/modules/database/components/view/ViewFilterTypeMultipleSelectOptions'
import { BaserowFormulaNumberType } from '@baserow/modules/database/formula/formulaTypes'
import { ComparisonOperator } from '@baserow/modules/database//utils/fieldFilters'
import { mix } from '@baserow/modules/core/mixins'
export class HasEmptyValueViewFilterType extends ViewFilterType {
const HasEmptyValueViewFilterTypeMixin = {
getCompatibleFieldTypes() {
return [
FormulaFieldType.compatibleWithFormulaTypes('array(text)'),
FormulaFieldType.compatibleWithFormulaTypes('array(char)'),
FormulaFieldType.compatibleWithFormulaTypes('array(url)'),
FormulaFieldType.compatibleWithFormulaTypes('array(number)'),
FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'),
FormulaFieldType.compatibleWithFormulaTypes('array(multiple_select)'),
]
},
}
export class HasEmptyValueViewFilterType extends mix(
HasEmptyValueViewFilterTypeMixin,
ViewFilterType
) {
static getType() {
return 'has_empty_value'
}
@ -17,22 +34,15 @@ export class HasEmptyValueViewFilterType extends ViewFilterType {
return i18n.t('viewFilter.hasEmptyValue')
}
getCompatibleFieldTypes() {
return [
FormulaFieldType.compatibleWithFormulaTypes('array(text)'),
FormulaFieldType.compatibleWithFormulaTypes('array(char)'),
FormulaFieldType.compatibleWithFormulaTypes('array(url)'),
FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'),
FormulaFieldType.compatibleWithFormulaTypes('array(number)'),
]
}
matches(cellValue, filterValue, field, fieldType) {
return fieldType.getHasEmptyValueFilterFunction(field)(cellValue)
}
}
export class HasNotEmptyValueViewFilterType extends ViewFilterType {
export class HasNotEmptyValueViewFilterType extends mix(
HasEmptyValueViewFilterTypeMixin,
ViewFilterType
) {
static getType() {
return 'has_not_empty_value'
}
@ -42,22 +52,41 @@ export class HasNotEmptyValueViewFilterType extends ViewFilterType {
return i18n.t('viewFilter.hasNotEmptyValue')
}
getCompatibleFieldTypes() {
return [
FormulaFieldType.compatibleWithFormulaTypes('array(text)'),
FormulaFieldType.compatibleWithFormulaTypes('array(char)'),
FormulaFieldType.compatibleWithFormulaTypes('array(url)'),
FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'),
FormulaFieldType.compatibleWithFormulaTypes('array(number)'),
]
}
matches(cellValue, filterValue, field, fieldType) {
return !fieldType.getHasEmptyValueFilterFunction(field)(cellValue)
}
}
export class HasValueEqualViewFilterType extends ViewFilterType {
const HasValueEqualViewFilterTypeMixin = {
prepareValue(value, field) {
const fieldType = this.app.$registry.get('field', field.type)
return fieldType.formatFilterValue(field, value)
},
getInputComponent(field) {
const fieldType = this.app.$registry.get('field', field.type)
return fieldType.getFilterInputComponent(field, this) || viewFilterTypeText
},
getCompatibleFieldTypes() {
return [
FormulaFieldType.compatibleWithFormulaTypes(
FormulaFieldType.arrayOf('text'),
FormulaFieldType.arrayOf('char'),
FormulaFieldType.arrayOf('url'),
FormulaFieldType.arrayOf('boolean'),
FormulaFieldType.arrayOf('number'),
FormulaFieldType.arrayOf('single_select'),
FormulaFieldType.arrayOf('multiple_select')
),
]
},
}
export class HasValueEqualViewFilterType extends mix(
HasValueEqualViewFilterTypeMixin,
ViewFilterType
) {
static getType() {
return 'has_value_equal'
}
@ -68,30 +97,18 @@ export class HasValueEqualViewFilterType extends ViewFilterType {
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
return fieldType.hasValueEqualFilter(cellValue, filterValue, field)
}
getInputComponent(field) {
const fieldType = this.app.$registry.get('field', field.type)
return fieldType.getFilterInputComponent(field, this) || viewFilterTypeText
}
getCompatibleFieldTypes() {
return [
FormulaFieldType.compatibleWithFormulaTypes(
FormulaFieldType.arrayOf('text'),
FormulaFieldType.arrayOf('char'),
FormulaFieldType.arrayOf('url'),
FormulaFieldType.arrayOf('boolean'),
FormulaFieldType.arrayOf('single_select'),
FormulaFieldType.arrayOf('number')
),
]
filterValue = fieldType.parseFilterValue(field, filterValue)
return (
filterValue === '' ||
fieldType.hasValueEqualFilter(cellValue, filterValue, field)
)
}
}
export class HasNotValueEqualViewFilterType extends ViewFilterType {
export class HasNotValueEqualViewFilterType extends mix(
HasValueEqualViewFilterTypeMixin,
ViewFilterType
) {
static getType() {
return 'has_not_value_equal'
}
@ -102,14 +119,18 @@ export class HasNotValueEqualViewFilterType extends ViewFilterType {
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
return fieldType.hasNotValueEqualFilter(cellValue, filterValue, field)
filterValue = fieldType.parseFilterValue(field, filterValue)
return (
filterValue === '' ||
fieldType.hasNotValueEqualFilter(cellValue, filterValue, field)
)
}
}
const HasValueContainsViewFilterTypeMixin = {
getInputComponent(field) {
const fieldType = this.app.$registry.get('field', field.type)
return fieldType.getFilterInputComponent(field, this) || viewFilterTypeText
}
return ViewFilterTypeText
},
getCompatibleFieldTypes() {
return [
@ -117,15 +138,18 @@ export class HasNotValueEqualViewFilterType extends ViewFilterType {
FormulaFieldType.arrayOf('text'),
FormulaFieldType.arrayOf('char'),
FormulaFieldType.arrayOf('url'),
FormulaFieldType.arrayOf('boolean'),
FormulaFieldType.arrayOf('number'),
FormulaFieldType.arrayOf('single_select'),
FormulaFieldType.arrayOf('number')
FormulaFieldType.arrayOf('multiple_select')
),
]
}
},
}
export class HasValueContainsViewFilterType extends ViewFilterType {
export class HasValueContainsViewFilterType extends mix(
HasValueContainsViewFilterTypeMixin,
ViewFilterType
) {
static getType() {
return 'has_value_contains'
}
@ -135,28 +159,18 @@ export class HasValueContainsViewFilterType extends ViewFilterType {
return i18n.t('viewFilter.hasValueContains')
}
getInputComponent(field) {
return ViewFilterTypeText
}
getCompatibleFieldTypes() {
return [
FormulaFieldType.compatibleWithFormulaTypes(
FormulaFieldType.arrayOf('char'),
FormulaFieldType.arrayOf('text'),
FormulaFieldType.arrayOf('url'),
FormulaFieldType.arrayOf('single_select'),
FormulaFieldType.arrayOf('number')
),
]
}
matches(cellValue, filterValue, field, fieldType) {
return fieldType.hasValueContainsFilter(cellValue, filterValue, field)
return (
filterValue.trim() === '' ||
fieldType.hasValueContainsFilter(cellValue, filterValue, field)
)
}
}
export class HasNotValueContainsViewFilterType extends ViewFilterType {
export class HasNotValueContainsViewFilterType extends mix(
HasValueContainsViewFilterTypeMixin,
ViewFilterType
) {
static getType() {
return 'has_not_value_contains'
}
@ -166,28 +180,36 @@ export class HasNotValueContainsViewFilterType extends ViewFilterType {
return i18n.t('viewFilter.hasNotValueContains')
}
matches(cellValue, filterValue, field, fieldType) {
return (
filterValue.trim() === '' ||
fieldType.hasNotValueContainsFilter(cellValue, filterValue, field)
)
}
}
const HasValueContainsWordViewFilterTypeMixin = {
getInputComponent(field) {
return ViewFilterTypeText
}
},
getCompatibleFieldTypes() {
return [
FormulaFieldType.compatibleWithFormulaTypes(
FormulaFieldType.arrayOf('char'),
FormulaFieldType.arrayOf('text'),
FormulaFieldType.arrayOf('char'),
FormulaFieldType.arrayOf('url'),
FormulaFieldType.arrayOf('single_select'),
FormulaFieldType.arrayOf('number')
FormulaFieldType.arrayOf('multiple_select')
),
]
}
matches(cellValue, filterValue, field, fieldType) {
return fieldType.hasNotValueContainsFilter(cellValue, filterValue, field)
}
},
}
export class HasValueContainsWordViewFilterType extends ViewFilterType {
export class HasValueContainsWordViewFilterType extends mix(
HasValueContainsWordViewFilterTypeMixin,
ViewFilterType
) {
static getType() {
return 'has_value_contains_word'
}
@ -197,25 +219,18 @@ export class HasValueContainsWordViewFilterType extends ViewFilterType {
return i18n.t('viewFilter.hasValueContainsWord')
}
getInputComponent(field) {
return ViewFilterTypeText
}
getCompatibleFieldTypes() {
return [
FormulaFieldType.compatibleWithFormulaTypes('array(text)'),
FormulaFieldType.compatibleWithFormulaTypes('array(char)'),
FormulaFieldType.compatibleWithFormulaTypes('array(url)'),
FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'),
]
}
matches(cellValue, filterValue, field, fieldType) {
return fieldType.hasValueContainsWordFilter(cellValue, filterValue, field)
return (
filterValue.trim() === '' ||
fieldType.hasValueContainsWordFilter(cellValue, filterValue, field)
)
}
}
export class HasNotValueContainsWordViewFilterType extends ViewFilterType {
export class HasNotValueContainsWordViewFilterType extends mix(
HasValueContainsWordViewFilterTypeMixin,
ViewFilterType
) {
static getType() {
return 'has_not_value_contains_word'
}
@ -225,24 +240,10 @@ export class HasNotValueContainsWordViewFilterType extends ViewFilterType {
return i18n.t('viewFilter.hasNotValueContainsWord')
}
getInputComponent(field) {
return ViewFilterTypeText
}
getCompatibleFieldTypes() {
return [
FormulaFieldType.compatibleWithFormulaTypes('array(text)'),
FormulaFieldType.compatibleWithFormulaTypes('array(char)'),
FormulaFieldType.compatibleWithFormulaTypes('array(url)'),
FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'),
]
}
matches(cellValue, filterValue, field, fieldType) {
return fieldType.hasNotValueContainsWordFilter(
cellValue,
filterValue,
field
return (
filterValue.trim() === '' ||
fieldType.hasNotValueContainsWordFilter(cellValue, filterValue, field)
)
}
}
@ -297,7 +298,7 @@ export class HasAllValuesEqualViewFilterType extends ViewFilterType {
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
filterValue = fieldType.parseFilterValue(field, filterValue)
return fieldType.hasAllValuesEqualFilter(cellValue, filterValue, field)
}
}
@ -371,7 +372,7 @@ export class HasValueHigherThanViewFilterType extends ViewFilterType {
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
filterValue = fieldType.parseFilterValue(field, filterValue)
return (
filterValue === '' ||
fieldType.hasValueComparableToFilter(
@ -395,7 +396,7 @@ export class HasNotValueHigherThanViewFilterType extends HasValueHigherThanViewF
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
filterValue = fieldType.parseFilterValue(field, filterValue)
return (
filterValue === '' ||
!fieldType.hasValueComparableToFilter(
@ -431,7 +432,7 @@ export class HasValueHigherThanOrEqualViewFilterType extends ViewFilterType {
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
filterValue = fieldType.parseFilterValue(field, filterValue)
return (
filterValue === '' ||
fieldType.hasValueComparableToFilter(
@ -455,7 +456,7 @@ export class HasNotValueHigherThanOrEqualViewFilterType extends HasValueHigherTh
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
filterValue = fieldType.parseFilterValue(field, filterValue)
return (
filterValue === '' ||
!fieldType.hasValueComparableToFilter(
@ -491,7 +492,7 @@ export class HasValueLowerThanViewFilterType extends ViewFilterType {
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
filterValue = fieldType.parseFilterValue(field, filterValue)
return (
filterValue === '' ||
fieldType.hasValueComparableToFilter(
@ -515,7 +516,7 @@ export class HasNotValueLowerThanViewFilterType extends HasValueLowerThanViewFil
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
filterValue = fieldType.parseFilterValue(field, filterValue)
return (
filterValue === '' ||
!fieldType.hasValueComparableToFilter(
@ -551,7 +552,7 @@ export class HasValueLowerThanOrEqualViewFilterType extends ViewFilterType {
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
filterValue = fieldType.parseFilterValue(field, filterValue)
return (
filterValue === '' ||
fieldType.hasValueComparableToFilter(
@ -575,7 +576,7 @@ export class HasNotValueLowerThanOrEqualViewFilterType extends HasValueLowerThan
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
filterValue = fieldType.parseFilterValue(field, filterValue)
return (
filterValue === '' ||
!fieldType.hasValueComparableToFilter(

View file

@ -717,10 +717,18 @@ export class FieldType extends Registerable {
* Return a valid filter value for the field type. This is used to parse the
* filter value from the frontend to the backend.
*/
prepareFilterValue(field, filterValue) {
parseFilterValue(field, filterValue) {
return filterValue
}
/**
* Given a field value, format it as a string to be used in a filter value
* and sent to the backend.
*/
formatFilterValue(field, value) {
return String(value ?? '')
}
/**
* Is called for each field in the row when another field value in the row has
* changed. Optionally, a different value can be returned here for that field. This
@ -888,6 +896,19 @@ class SelectOptionBaseFieldType extends FieldType {
getFormViewFieldOptionsComponent() {
return FormViewFieldOptionsAllowedSelectOptions
}
formatFilterValue(field, value) {
// Filter out any invalid option IDs before sending to the backend.
// This prevents confusion where invalid IDs might be interpreted as no option selected,
// but the backend will reject them.
const validOptionIds = field.select_options.map((option) =>
String(option.id)
)
return value
.split(',')
.filter((id) => validOptionIds.includes(String(id)))
.join(',')
}
}
export class TextFieldType extends FieldType {
@ -1652,7 +1673,7 @@ export class NumberFieldType extends FieldType {
return new BigNumber(value)
}
prepareFilterValue(field, value) {
parseFilterValue(field, value) {
const res = parseNumberValue(field, String(value ?? ''), false)
return res === null || res.isNaN() ? '' : res.toString()
}
@ -1928,7 +1949,7 @@ export class BooleanFieldType extends FieldType {
return this.getHasValueEqualFilterFunction(field, true)
}
prepareFilterValue(field, value) {
parseFilterValue(field, value) {
return this.parseInputValue(field, String(value ?? ''))
}
}
@ -3804,8 +3825,12 @@ export class FormulaFieldType extends mix(
return i18n.t('fieldType.formula')
}
prepareFilterValue(field, value) {
return this.getFormulaType(field)?.prepareFilterValue(field, value)
parseFilterValue(field, value) {
return this.getFormulaType(field)?.parseFilterValue(field, value)
}
formatFilterValue(field, value) {
return this.getFormulaType(field)?.formatFilterValue(field, value)
}
getFormulaType(field) {

View file

@ -59,6 +59,9 @@ import {
hasSelectOptionValueContainsWordFilterMixin,
baserowFormulaArrayTypeFilterMixin,
hasNumericValueComparableToFilterMixin,
hasNestedSelectOptionValueContainsFilterMixin,
hasNestedSelectOptionValueContainsWordFilterMixin,
hasMultipleSelectOptionIdEqualMixin,
} from '@baserow/modules/database/arrayFilterMixins'
import _ from 'lodash'
import ViewFilterTypeBoolean from '@baserow/modules/database/components/view/ViewFilterTypeBoolean.vue'
@ -66,8 +69,8 @@ import {
genericHasAllValuesEqualFilter,
genericHasValueContainsFilter,
} from '@baserow/modules/database/utils/fieldFilters'
import ViewFilterTypeSelectOptions from '@baserow/modules/database/components/view/ViewFilterTypeSelectOptions.vue'
import ViewFilterTypeDuration from '@baserow/modules/database/components/view/ViewFilterTypeDuration.vue'
import ViewFilterTypeMultipleSelectOptions from '@baserow/modules/database/components/view/ViewFilterTypeMultipleSelectOptions.vue'
export class BaserowFormulaTypeDefinition extends Registerable {
getIconClass() {
@ -92,10 +95,16 @@ export class BaserowFormulaTypeDefinition extends Registerable {
return null
}
prepareFilterValue(field, value) {
parseFilterValue(field, value) {
return this.app.$registry
.get('field', this.getFieldType())
.prepareFilterValue(field, value)
.parseFilterValue(field, value)
}
formatFilterValue(field, value) {
return this.app.$registry
.get('field', this.getFieldType())
.formatFilterValue(field, value)
}
getFunctionalGridViewFieldComponent() {
@ -616,8 +625,20 @@ export class BaserowFormulaArrayType extends mix(
return RowCardFieldArray
}
prepareFilterValue(field, value) {
return this.getSubType(field)?.prepareFilterValue(field, value)
parseFilterValue(field, value) {
const subType = this.getSubType(field)
if (subType == null) {
return value
}
return subType.parseFilterValue(field, value)
}
formatFilterValue(field, value) {
const subType = this.getSubType(field)
if (subType == null) {
return value
}
return subType.formatFilterValue(field, value)
}
getSubType(field) {
@ -899,7 +920,7 @@ export class BaserowFormulaSingleSelectType extends mix(
}
getFilterInputComponent(field, filterType) {
return ViewFilterTypeSelectOptions
return ViewFilterTypeMultipleSelectOptions
}
getSortOrder() {
@ -923,7 +944,13 @@ export class BaserowFormulaSingleSelectType extends mix(
}
}
export class BaserowFormulaMultipleSelectType extends BaserowFormulaTypeDefinition {
export class BaserowFormulaMultipleSelectType extends mix(
hasEmptyValueFilterMixin,
hasNestedSelectOptionValueContainsFilterMixin,
hasNestedSelectOptionValueContainsWordFilterMixin,
hasMultipleSelectOptionIdEqualMixin,
BaserowFormulaTypeDefinition
) {
static getType() {
return 'multiple_select'
}
@ -936,6 +963,10 @@ export class BaserowFormulaMultipleSelectType extends BaserowFormulaTypeDefiniti
return 'baserow-icon-multiple-select'
}
getFilterInputComponent(field, filterType) {
return ViewFilterTypeMultipleSelectOptions
}
getRowEditFieldComponent(field) {
return RowEditFieldMultipleSelectReadOnly
}

View file

@ -62,7 +62,11 @@ export function genericHasEmptyValueFilter(cellValue, filterValue) {
for (let i = 0; i < cellValue.length; i++) {
const value = cellValue[i].value
if (value === '' || value === null) {
if (
value === '' ||
value === null ||
(Array.isArray(value) && value.length === 0)
) {
return true
}
}

View file

@ -226,7 +226,7 @@ export const TreeGroupNode = class {
}
const filterType = this.filterType
for (const filter of this.filters) {
const filterValue = filter.value
const filterValue = String(filter.value ?? '')
const field = fields.find((f) => f.id === filter.field)
const fieldType = $registry.get('field', field.type)
const viewFilterType = $registry.get('viewFilter', filter.type)

View file

@ -95,7 +95,7 @@ export class ViewFilterType extends Registerable {
* example be used to convert the value to a number.
*/
prepareValue(value, field) {
return value
return String(value ?? '')
}
/**

View file

@ -602,7 +602,9 @@ describe('Text-based array view filters', () => {
field,
fieldType
)
expect(result).toBe(!testValues.expected)
expect(result).toBe(
testValues.filterValue === '' || !testValues.expected
)
}
)
}
@ -1056,11 +1058,6 @@ describe('Number-based array view filters', () => {
filterValue: '',
expected: { has: true, hasNot: true },
},
{
cellValue: [],
filterValue: null,
expected: { has: false, hasNot: true },
},
{
cellValue: [{ value: null }],
filterValue: '',
@ -1071,12 +1068,6 @@ describe('Number-based array view filters', () => {
filterValue: '',
expected: { has: true, hasNot: true },
},
{
cellValue: [{ value: null }],
filterValue: null,
expected: { has: true, hasNot: false },
},
{
cellValue: [{ value: 123.0 }, { value: null }],
filterValue: '123',
@ -1122,16 +1113,6 @@ describe('Number-based array view filters', () => {
notHigherEqual: true,
},
},
{
cellValue: [],
filterValue: null,
expected: {
higher: true,
higherEqual: true,
notHigher: true,
notHigherEqual: true,
},
},
{
cellValue: [{ value: null }],
filterValue: '',
@ -1346,3 +1327,405 @@ describe('Number-based array view filters', () => {
}
)
})
describe('Multiple select-based array view filters', () => {
let testApp = null
beforeAll(() => {
testApp = new TestApp()
})
afterEach(() => {
testApp.afterEach()
})
const hasMultipleSelectOptionsEqualCases = [
{
cellValue: [],
filterValue: '1',
expected: false,
},
{
cellValue: [
{
value: [
{ id: 2, value: 'B' },
{ id: 3, value: 'C' },
],
},
{ value: [{ id: 1, value: 'A' }] },
],
filterValue: '1',
expected: true,
},
{
cellValue: [
{
value: [
{ id: 1, value: 'A' },
{ id: 3, value: 'C' },
],
},
],
filterValue: '2',
expected: false,
},
{
cellValue: [{ value: [{ id: 4, value: 'Aa' }] }],
filterValue: '1',
expected: false,
},
{
cellValue: [{ value: [{ id: 4, value: 'Aa' }] }],
filterValue: '',
expected: true,
},
{
cellValue: [
{
value: [
{ id: 2, value: 'B' },
{ id: 3, value: 'C' },
],
},
{ value: [{ id: 1, value: 'A' }] },
],
filterValue: '2,3',
expected: true,
},
{
cellValue: [
{
value: [
{ id: 2, value: 'B' },
{ id: 3, value: 'C' },
],
},
{ value: [{ id: 1, value: 'A' }] },
],
filterValue: '2',
expected: false,
},
]
const hasMultipleSelectOptionEqualSupportedFields = [
{
TestFieldType: FormulaFieldType,
formula_type: 'array',
array_formula_type: 'multiple_select',
},
]
describe.each(hasMultipleSelectOptionEqualSupportedFields)(
'HasValueEqualViewFilterType %j',
(field) => {
test.each(hasMultipleSelectOptionsEqualCases)(
'filter matches values %j',
(testValues) => {
const fieldType = new field.TestFieldType({
app: testApp._app,
})
const result = new HasValueEqualViewFilterType({
app: testApp._app,
}).matches(
testValues.cellValue,
testValues.filterValue,
field,
fieldType
)
expect(result).toBe(testValues.expected)
}
)
}
)
describe.each(hasMultipleSelectOptionEqualSupportedFields)(
'HasNotValueEqualViewFilterType %j',
(field) => {
test.each(hasMultipleSelectOptionsEqualCases)(
'filter not matches values %j',
(testValues) => {
const fieldType = new field.TestFieldType({
app: testApp._app,
})
const result = new HasNotValueEqualViewFilterType({
app: testApp._app,
}).matches(
testValues.cellValue,
testValues.filterValue,
field,
fieldType
)
expect(result).toBe(
testValues.filterValue === '' || !testValues.expected
)
}
)
}
)
const hasMultipleSelectOptionContainsCases = [
{
cellValue: [],
filterValue: 'A',
expected: false,
},
{
cellValue: [
{ value: [{ id: 2, value: 'B' }] },
{
value: [
{ id: 1, value: 'A' },
{ id: 2, value: 'B' },
],
},
],
filterValue: 'A',
expected: true,
},
{
cellValue: [
{
value: [
{ id: 1, value: 'A' },
{ id: 2, value: 'C' },
],
},
],
filterValue: 'B',
expected: false,
},
{
cellValue: [{ value: [{ id: 3, value: 'Aa' }] }],
filterValue: 'a',
expected: true,
},
{
cellValue: [{ value: [{ id: 3, value: 'a' }] }],
filterValue: '',
expected: true,
},
]
const hasMultipleSelectOptionContainsSupportedFields = [
{
TestFieldType: FormulaFieldType,
formula_type: 'array',
array_formula_type: 'multiple_select',
},
]
describe.each(hasMultipleSelectOptionContainsSupportedFields)(
'HasValueContainsViewFilterType %j',
(field) => {
test.each(hasMultipleSelectOptionContainsCases)(
'filter matches values %j',
(testValues) => {
const fieldType = new field.TestFieldType({
app: testApp._app,
})
const result = new HasValueContainsViewFilterType({
app: testApp._app,
}).matches(
testValues.cellValue,
testValues.filterValue,
field,
fieldType
)
expect(result).toBe(testValues.expected)
}
)
}
)
describe.each(hasMultipleSelectOptionContainsSupportedFields)(
'HasNotValueContainsViewFilterType %j',
(field) => {
test.each(hasMultipleSelectOptionContainsCases)(
'filter not matches values %j',
(testValues) => {
const fieldType = new field.TestFieldType({
app: testApp._app,
})
const result = new HasNotValueContainsViewFilterType({
app: testApp._app,
}).matches(
testValues.cellValue,
testValues.filterValue,
field,
fieldType
)
expect(result).toBe(
testValues.filterValue === '' || !testValues.expected
)
}
)
}
)
const hasMultipleSelectOptionContainsWordCases = [
{
cellValue: [],
filterValue: 'A',
expected: false,
},
{
cellValue: [
{
value: [
{ id: 2, value: 'B' },
{ id: 3, value: 'C' },
],
},
{ value: [{ id: 1, value: 'Aa' }] },
{ value: [] },
],
filterValue: 'Aa',
expected: true,
},
{
cellValue: [{ value: [{ id: 1, value: 'A' }] }],
filterValue: 'B',
expected: false,
},
{
cellValue: [{ value: [{ id: 3, value: 'Aa' }] }],
filterValue: 'a',
expected: false,
},
{
cellValue: [{ value: [{ id: 1, value: 'A' }] }],
filterValue: '',
expected: true,
},
]
const hasMultipleSelectOptionsContainsWordSupportedFields = [
{
TestFieldType: FormulaFieldType,
formula_type: 'array',
array_formula_type: 'multiple_select',
},
]
describe.each(hasMultipleSelectOptionsContainsWordSupportedFields)(
'HasValueContainsWordViewFilterType %j',
(field) => {
test.each(hasMultipleSelectOptionContainsWordCases)(
'filter matches values %j',
(testValues) => {
const fieldType = new field.TestFieldType({
app: testApp._app,
})
const result = new HasValueContainsWordViewFilterType({
app: testApp._app,
}).matches(
testValues.cellValue,
testValues.filterValue,
field,
fieldType
)
expect(result).toBe(testValues.expected)
}
)
}
)
describe.each(hasMultipleSelectOptionsContainsWordSupportedFields)(
'HasNotValueContainsWordViewFilterType %j',
(field) => {
test.each(hasMultipleSelectOptionContainsWordCases)(
'filter not matches values %j',
(testValues) => {
const fieldType = new field.TestFieldType({
app: testApp._app,
})
const result = new HasNotValueContainsWordViewFilterType({
app: testApp._app,
}).matches(
testValues.cellValue,
testValues.filterValue,
field,
fieldType
)
expect(result).toBe(
testValues.filterValue === '' || !testValues.expected
)
}
)
}
)
const hasEmptySelectOptionsCases = [
{
cellValue: [],
expected: false,
},
{
cellValue: [{ value: [{ id: 1, value: 'a' }] }, { value: [] }],
expected: true,
},
{
cellValue: [{ value: [] }],
expected: true,
},
{
cellValue: [{ value: [{ id: 2, value: 'b' }] }],
expected: false,
},
]
const hasEmptySelectOptionSupportedFields = [
{
TestFieldType: FormulaFieldType,
formula_type: 'array',
array_formula_type: 'multiple_select',
},
]
describe.each(hasEmptySelectOptionSupportedFields)(
'HasEmptyValueViewFilterType %j',
(field) => {
test.each(hasEmptySelectOptionsCases)(
'filter not matches values %j',
(testValues) => {
const fieldType = new field.TestFieldType({
app: testApp._app,
})
const result = new HasEmptyValueViewFilterType({
app: testApp._app,
}).matches(
testValues.cellValue,
testValues.filterValue,
field,
fieldType
)
expect(result).toBe(testValues.expected)
}
)
}
)
describe.each(hasEmptySelectOptionSupportedFields)(
'HasNotEmptyValueViewFilterType %j',
(field) => {
test.each(hasEmptySelectOptionsCases)(
'filter not matches values %j',
(testValues) => {
const fieldType = new field.TestFieldType({
app: testApp._app,
})
const result = new HasNotEmptyValueViewFilterType({
app: testApp._app,
}).matches(
testValues.cellValue,
testValues.filterValue,
field,
fieldType
)
expect(result).toBe(!testValues.expected)
}
)
}
)
})

View file

@ -76,7 +76,7 @@ const view = {
{
field: 2,
type: 'equal',
value: 2,
value: '2',
preload_values: {},
_: { hover: false, loading: false },
id: 11,
@ -139,7 +139,7 @@ describe('ViewFilterForm match snapshots', () => {
// We want to bypass some setTimeout
jest.useFakeTimers()
// Mock server filter update call
mockServer.updateViewFilter(11, 5)
mockServer.updateViewFilter(11, '5')
// Add rating one filter
const viewClone = JSON.parse(JSON.stringify(view))
@ -147,7 +147,7 @@ describe('ViewFilterForm match snapshots', () => {
{
field: 2,
type: 'equal',
value: 2,
value: '2',
preload_values: {},
_: { hover: false, loading: false },
id: 11,

View file

@ -50,6 +50,7 @@ import {
FormulaFieldType,
NumberFieldType,
SingleSelectFieldType,
MultipleSelectFieldType,
} from '@baserow/modules/database/fieldTypes'
const dateBeforeCases = [
@ -787,7 +788,7 @@ const multipleSelectValuesHas = [
{ id: 155, value: 'A', color: 'green' },
{ id: 154, value: 'B', color: 'green' },
],
filterValue: 154,
filterValue: '154',
expected: true,
},
{
@ -795,7 +796,7 @@ const multipleSelectValuesHas = [
{ id: 155, value: 'A', color: 'green' },
{ id: 154, value: 'B', color: 'green' },
],
filterValue: 200,
filterValue: '200,201',
expected: false,
},
{
@ -814,7 +815,7 @@ const multipleSelectValuesHasNot = [
{ id: 155, value: 'A', color: 'green' },
{ id: 154, value: 'B', color: 'green' },
],
filterValue: 154,
filterValue: '154,155',
expected: false,
},
{
@ -822,7 +823,7 @@ const multipleSelectValuesHasNot = [
{ id: 155, value: 'A', color: 'green' },
{ id: 154, value: 'B', color: 'green' },
],
filterValue: 200,
filterValue: '200',
expected: true,
},
{
@ -2163,9 +2164,10 @@ describe('All Tests', () => {
})
test.each(multipleSelectValuesHas)('MultipleSelect Has', (values) => {
const fieldType = new MultipleSelectFieldType()
const result = new MultipleSelectHasFilterType({
app: testApp._app,
}).matches(values.rowValue, values.filterValue, {})
}).matches(values.rowValue, values.filterValue, {}, fieldType)
expect(result).toBe(values.expected)
})