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:
parent
2eef18b347
commit
8e36dbe32f
28 changed files with 1369 additions and 592 deletions
backend
src/baserow/contrib/database
tests/baserow/contrib/database
changelog/entries/unreleased/feature
premium/backend/src/baserow_premium/views
web-frontend
modules/database
test/unit/database
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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}),
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
)
|
|
@ -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"
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
):
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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))
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue