1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-06 22:08:52 +00:00

number lookup field filters

This commit is contained in:
Cezary Statkiewicz 2025-01-02 09:19:28 +00:00 committed by Davide Silvestri
parent aa20310a0e
commit 244d6050bd
27 changed files with 2175 additions and 391 deletions

View file

@ -453,10 +453,18 @@ class DatabaseConfig(AppConfig):
HasNotValueContainsViewFilterType,
HasNotValueContainsWordViewFilterType,
HasNotValueEqualViewFilterType,
HasNotValueHigherOrEqualTHanFilterType,
HasNotValueHigherThanFilterType,
HasNotValueLowerOrEqualTHanFilterType,
HasNotValueLowerThanFilterType,
HasValueContainsViewFilterType,
HasValueContainsWordViewFilterType,
HasValueEqualViewFilterType,
HasValueHigherOrEqualThanFilter,
HasValueLengthIsLowerThanViewFilterType,
HasValueLowerOrEqualThanFilter,
HasValueLowerThanFilter,
hasValueComparableToFilter,
)
view_filter_type_registry.register(HasValueEqualViewFilterType())
@ -471,6 +479,14 @@ class DatabaseConfig(AppConfig):
view_filter_type_registry.register(HasNotEmptyValueViewFilterType())
view_filter_type_registry.register(HasAnySelectOptionEqualViewFilterType())
view_filter_type_registry.register(HasNoneSelectOptionEqualViewFilterType())
view_filter_type_registry.register(HasValueLowerThanFilter())
view_filter_type_registry.register(HasValueLowerOrEqualThanFilter())
view_filter_type_registry.register(hasValueComparableToFilter())
view_filter_type_registry.register(HasValueHigherOrEqualThanFilter())
view_filter_type_registry.register(HasNotValueHigherOrEqualTHanFilterType())
view_filter_type_registry.register(HasNotValueHigherThanFilterType())
view_filter_type_registry.register(HasNotValueLowerOrEqualTHanFilterType())
view_filter_type_registry.register(HasNotValueLowerThanFilterType())
from .views.view_aggregations import (
AverageViewAggregationType,

View file

@ -94,7 +94,7 @@ from baserow.contrib.database.api.views.errors import (
from baserow.contrib.database.db.functions import RandomUUID
from baserow.contrib.database.export_serialized import DatabaseExportSerializedStructure
from baserow.contrib.database.fields.filter_support.formula import (
FormulaArrayFilterSupport,
FormulaFieldTypeArrayFilterSupport,
)
from baserow.contrib.database.formula import (
BASEROW_FORMULA_TYPE_ALLOWED_FIELDS,
@ -144,6 +144,7 @@ from baserow.core.user_files.handler import UserFileHandler
from baserow.core.utils import list_to_comma_separated_string
from .constants import (
BASEROW_BOOLEAN_FIELD_FALSE_VALUES,
BASEROW_BOOLEAN_FIELD_TRUE_VALUES,
UPSERT_OPTION_DICT_KEY,
DeleteFieldStrategyEnum,
@ -783,6 +784,21 @@ class NumberFieldType(FieldType):
"number_separator": field.number_separator,
}
def prepare_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.
"""
try:
value = Decimal(value)
if not value.is_finite():
raise ValueError
except (InvalidOperation, ValueError, TypeError):
raise ValueError(f"Invalid value for number field: {value}")
return value
class RatingFieldType(FieldType):
type = "rating"
@ -959,6 +975,14 @@ class BooleanFieldType(FieldType):
) -> BooleanField:
return BooleanField()
def prepare_filter_value(self, field, model_field, value):
if value in BASEROW_BOOLEAN_FIELD_TRUE_VALUES:
return True
elif value in BASEROW_BOOLEAN_FIELD_FALSE_VALUES:
return False
else:
raise ValueError(f"Invalid value for boolean field: {value}")
class DateFieldType(FieldType):
type = "date"
@ -4674,7 +4698,7 @@ class PhoneNumberFieldType(CollationSortMixin, CharFieldMatchingRegexFieldType):
return collate_expression(Value(value))
class FormulaFieldType(FormulaArrayFilterSupport, ReadOnlyFieldType):
class FormulaFieldType(FormulaFieldTypeArrayFilterSupport, ReadOnlyFieldType):
type = "formula"
model_class = FormulaField
_db_column_fields = []
@ -5238,6 +5262,13 @@ class FormulaFieldType(FormulaArrayFilterSupport, ReadOnlyFieldType):
return FormulaHandler.get_dependencies_field_names(serialized_field["formula"])
def prepare_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)
class CountFieldType(FormulaFieldType):
type = "count"

View file

@ -1,5 +1,5 @@
import re
import typing
from typing import TYPE_CHECKING, Any, Dict, Type
from django.contrib.postgres.fields import JSONField
from django.db import models
@ -13,17 +13,32 @@ from baserow.contrib.database.fields.field_filters import (
)
from baserow.contrib.database.formula.expression_generator.django_expressions import (
BaserowFilterExpression,
ComparisonOperator,
JSONArrayAllAreExpr,
JSONArrayCompareNumericValueExpr,
JSONArrayContainsValueExpr,
JSONArrayContainsValueLengthLowerThanExpr,
JSONArrayContainsValueSimilarToExpr,
)
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
from baserow.contrib.database.fields.models import Field
class HasValueEmptyFilterSupport:
def get_in_array_empty_value(self, field: "Field") -> Any:
"""
Returns the value to use for filtering empty values in an array. An empty string
is used by default and works for test-like fields, but for number fields or
other types, this method should be overridden returning None or the most
appropriate value.
:param field: The related field's instance.
:return: The value to use for filtering empty values in an array.
"""
return ""
def get_in_array_empty_query(
self, field_name: str, model_field: models.Field, field: "Field"
) -> OptionallyAnnotatedQ:
@ -36,10 +51,13 @@ class HasValueEmptyFilterSupport:
:return: A Q or AnnotatedQ filter given value.
"""
return Q(**{f"{field_name}__contains": Value([{"value": ""}], JSONField())})
empty_value = self.get_in_array_empty_value(field)
return Q(
**{f"{field_name}__contains": Value([{"value": empty_value}], JSONField())}
)
class HasValueFilterSupport:
class HasValueEqualFilterSupport:
def get_in_array_is_query(
self, field_name: str, value: str, model_field: models.Field, field: "Field"
) -> OptionallyAnnotatedQ:
@ -53,9 +71,6 @@ class HasValueFilterSupport:
:return: A Q or AnnotatedQ filter given value.
"""
if not value:
return Q()
return Q(**{f"{field_name}__contains": Value([{"value": value}], JSONField())})
@ -74,8 +89,6 @@ class HasValueContainsFilterSupport:
:return: A Q or AnnotatedQ filter given value.
"""
if not value:
return Q()
annotation_query = JSONArrayContainsValueExpr(
F(field_name), Value(f"%{value}%"), output_field=BooleanField()
)
@ -103,9 +116,6 @@ class HasValueContainsWordFilterSupport:
:return: A Q or AnnotatedQ filter given value.
"""
value = value.strip()
if not value:
return Q()
value = re.escape(value.upper())
annotation_query = JSONArrayContainsValueSimilarToExpr(
F(field_name), Value(f"%\\m{value}\\M%"), output_field=BooleanField()
@ -134,9 +144,6 @@ class HasValueLengthIsLowerThanFilterSupport:
:return: A Q or AnnotatedQ filter given value.
"""
value = value.strip()
if not value:
return Q()
try:
converted_value = int(value)
except (TypeError, ValueError):
@ -182,29 +189,49 @@ class HasAllValuesEqualFilterSupport:
return self.default_filter_on_exception()
def get_array_json_filter_expression(
json_expression: typing.Type[BaserowFilterExpression], field_name: str, value: str
) -> OptionallyAnnotatedQ:
"""
helper to generate annotated query to get filtered json-based array.
`json_expression` should be a filter expression class.
class HasNumericValueComparableToFilterSupport:
def get_has_numeric_value_comparable_to_filter_query(
self,
field_name: str,
value: str,
model_field: models.Field,
field: "Field",
comparison_op: ComparisonOperator,
) -> OptionallyAnnotatedQ:
return get_array_json_filter_expression(
JSONArrayCompareNumericValueExpr,
field_name,
value,
comparison_op=comparison_op,
)
:param json_expression: BaserowFilterExpression to use
:param field_name: a name of a field
:param value: filter value
:param model_field:
:param field:
:return:
def get_array_json_filter_expression(
json_expression: Type[BaserowFilterExpression],
field_name: str,
value: str,
**extra: Dict[str, Any],
) -> AnnotatedQ:
"""
Helper function to generate an AnnotatedQ for the given field and filtering
expression. This function ensure a consistent way to name the annotations so they
don't clash when combined with similar filters for different fields or values.
:param json_expression: BaserowFilterExpression to use for filtering.
:param field_name: the name of the field
:param value: filter the filter value.
: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()
F(field_name), Value(value), output_field=BooleanField(), **extra
)
lookup_name = (json_expression.__name__).lower()
expr_name = json_expression.__name__.lower()
hashed_value = hash(value)
annotation_name = f"{field_name}_{expr_name}_{hashed_value}"
return AnnotatedQ(
annotation={
f"{field_name}_array_expr_{lookup_name}_{hashed_value}": annotation_query
},
q={f"{field_name}_array_expr_{lookup_name}_{hashed_value}": True},
annotation={annotation_name: annotation_query},
q={annotation_name: True},
)

View file

@ -4,28 +4,39 @@ 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 (
ComparisonOperator,
)
from .base import (
HasAllValuesEqualFilterSupport,
HasNumericValueComparableToFilterSupport,
HasValueContainsFilterSupport,
HasValueContainsWordFilterSupport,
HasValueEmptyFilterSupport,
HasValueFilterSupport,
HasValueEqualFilterSupport,
HasValueLengthIsLowerThanFilterSupport,
)
if typing.TYPE_CHECKING:
from baserow.contrib.database.fields.models import FormulaField
from baserow.contrib.database.fields.models import Field, FormulaField
class FormulaArrayFilterSupport(
class FormulaFieldTypeArrayFilterSupport(
HasAllValuesEqualFilterSupport,
HasValueFilterSupport,
HasValueEqualFilterSupport,
HasValueEmptyFilterSupport,
HasValueContainsFilterSupport,
HasValueContainsWordFilterSupport,
HasValueLengthIsLowerThanFilterSupport,
HasNumericValueComparableToFilterSupport,
):
"""
A mixin that acts as a proxy between the formula field and the specific array
formula function to call. Every method needs to be implemented here and forwarded
to the right array formula subtype method.
"""
def get_in_array_is_query(
self,
field_name: str,
@ -42,6 +53,14 @@ class FormulaArrayFilterSupport(
field_name, value, model_field, field_instance
)
def get_in_array_empty_value(self, field: "Field") -> typing.Any:
(
field_instance,
field_type,
) = self.get_field_instance_and_type_from_formula_field(field)
return field_type.get_in_array_empty_value(field_instance)
def get_in_array_empty_query(self, field_name, model_field, field: "FormulaField"):
(
field_instance,
@ -99,3 +118,20 @@ class FormulaArrayFilterSupport(
return field_type.get_has_all_values_equal_query(
field_name, value, model_field, field_instance
)
def get_has_numeric_value_comparable_to_filter_query(
self,
field_name: str,
value: str,
model_field: models.Field,
field: "Field",
comparison_op: ComparisonOperator,
) -> OptionallyAnnotatedQ:
(
field_instance,
field_type,
) = self.get_field_instance_and_type_from_formula_field(field)
return field_type.get_has_numeric_value_comparable_to_filter_query(
field_name, value, model_field, field_instance, comparison_op
)

View file

@ -1,7 +1,6 @@
from functools import reduce
from typing import TYPE_CHECKING, List
from typing import TYPE_CHECKING, Any, List
from django.contrib.postgres.fields import JSONField
from django.db import models
from django.db.models import BooleanField, F, Q, Value
@ -19,7 +18,7 @@ from .base import (
HasValueContainsFilterSupport,
HasValueContainsWordFilterSupport,
HasValueEmptyFilterSupport,
HasValueFilterSupport,
HasValueEqualFilterSupport,
)
if TYPE_CHECKING:
@ -28,12 +27,12 @@ if TYPE_CHECKING:
class SingleSelectFormulaTypeFilterSupport(
HasValueEmptyFilterSupport,
HasValueFilterSupport,
HasValueEqualFilterSupport,
HasValueContainsFilterSupport,
HasValueContainsWordFilterSupport,
):
def get_in_array_empty_query(self, field_name, model_field, field: "Field"):
return Q(**{f"{field_name}__contains": Value([{"value": None}], JSONField())})
def get_in_array_empty_value(self, field: "Field") -> Any:
return None
def get_in_array_is_query(
self,

View file

@ -1825,6 +1825,30 @@ class FieldType(
return value1 == value2
def prepare_filter_value(
self, field: "Field", model_field: django_models.Field, value: Any
) -> Any:
"""
Prepare a non-empty value string to be used in a view filter, verifying if it is
compatible with the given field and model_field. This method must be called
before the value is passed to the filter method that requires it. This is useful
to ensure that comparisons are done correctly and that the value is correctly
prepared for the database, like converting a string to a date object or a
number.
: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.
:raises ValueError: If the value is not compatible for the given field and
model_field.
"""
try:
return model_field.get_prep_value(value)
except ValidationError as e:
raise ValueError(str(e))
class ReadOnlyFieldType(FieldType):
read_only = True

View file

@ -1,4 +1,5 @@
import typing
from enum import Enum
from django.contrib.postgres.aggregates.mixins import OrderableAggMixin
from django.db import NotSupportedError
@ -148,15 +149,33 @@ class BaserowFilterExpression(Expression):
return c
def as_sql(self, compiler, connection, template=None):
sql_value, params_value = compiler.compile(self.value)
template = template or self.template
data = {
def get_template_data(self, sql_value) -> dict:
return {
"field_name": f'"{self.field_name.field.column}"',
"value": sql_value,
}
return template % data, params_value
def render_template_as_sql(
self, filter_value: str, template: str | None = None
) -> str:
"""
Renders the template with the given sql_value and returns the result. If a
custom template is provided, it will be used instead of the default one.
:param filter_value: The value that will be used in the template.
:param template: The custom template to use. If not provided, the default one
will be used.
:return: The rendered template with data that will be used as SQL.
"""
template = template or self.template
data = self.get_template_data(filter_value)
return template % data
def as_sql(self, compiler, connection, template=None):
sql_value, sql_params = compiler.compile(self.value)
sql_query = self.render_template_as_sql(sql_value, template)
return sql_query, sql_params
class FileNameContainsExpr(BaserowFilterExpression):
@ -164,7 +183,7 @@ class FileNameContainsExpr(BaserowFilterExpression):
template = (
f"""
EXISTS(
SELECT attached_files ->> 'visible_name'
SELECT 1
FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as attached_files
WHERE UPPER(attached_files ->> 'visible_name') LIKE UPPER(%(value)s)
)
@ -178,7 +197,7 @@ class JSONArrayContainsValueExpr(BaserowFilterExpression):
template = (
f"""
EXISTS(
SELECT filtered_field ->> 'value'
SELECT 1
FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field
WHERE UPPER(filtered_field ->> 'value') LIKE UPPER(%(value)s::text)
)
@ -192,7 +211,7 @@ class JSONArrayContainsValueSimilarToExpr(BaserowFilterExpression):
template = (
f"""
EXISTS(
SELECT filtered_field ->> 'value'
SELECT 1
FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field
WHERE UPPER(filtered_field ->> 'value') SIMILAR TO %(value)s
)
@ -206,7 +225,7 @@ class JSONArrayContainsValueLengthLowerThanExpr(BaserowFilterExpression):
template = (
f"""
EXISTS(
SELECT filtered_field ->> 'value'
SELECT 1
FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field
WHERE LENGTH(filtered_field ->> 'value') < %(value)s
)
@ -219,7 +238,7 @@ class JSONArrayAllAreExpr(BaserowFilterExpression):
# fmt: off
template = (
f"""
upper(%(value)s::text) =ALL(
upper(%(value)s::text) = ALL(
SELECT upper(filtered_field ->> 'value')
FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field
) AND JSONB_ARRAY_LENGTH(%(field_name)s) > 0
@ -233,7 +252,7 @@ class JSONArrayEqualSelectOptionIdExpr(BaserowFilterExpression):
template = (
f"""
EXISTS(
SELECT filtered_field -> 'value' ->> 'id'
SELECT 1
FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field
WHERE (filtered_field -> 'value' ->> 'id') LIKE (%(value)s)
)
@ -247,7 +266,7 @@ class JSONArrayContainsSelectOptionValueExpr(BaserowFilterExpression):
template = (
f"""
EXISTS(
SELECT filtered_field -> 'value' ->> 'value'
SELECT 1
FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field
WHERE UPPER(filtered_field -> 'value' ->> 'value') LIKE UPPER(%(value)s)
)
@ -261,10 +280,62 @@ class JSONArrayContainsSelectOptionValueSimilarToExpr(BaserowFilterExpression):
template = (
r"""
EXISTS(
SELECT filtered_field -> 'value' ->> 'value'
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
class ComparisonOperator(Enum):
"""
An enumeration of the comparison operators that can be used to compare a number
field value.
"""
EQUAL = "="
LOWER_THAN = "<"
LOWER_THAN_OR_EQUAL = "<="
HIGHER_THAN = ">"
HIGHER_THAN_OR_EQUAL = ">="
class JSONArrayCompareNumericValueExpr(BaserowFilterExpression):
"""
Base class for expressions that compare a numeric value in a JSON array.
Together with the field_name and value, a comparison operator must be provided to be
used in the template.
"""
def __init__(
self,
field_name: F,
value: Value,
comparison_op: ComparisonOperator,
output_field: Field,
):
super().__init__(field_name, value, output_field)
if not isinstance(comparison_op, ComparisonOperator):
raise ValueError(
f"comparison_op must be a ComparisonOperator, not {type(comparison_op)}"
)
self.comparison_op = comparison_op
# fmt: off
template = (
f"""
EXISTS(
SELECT 1
FROM JSONB_ARRAY_ELEMENTS(%(field_name)s) as filtered_field
WHERE (filtered_field ->> 'value')::numeric %(comparison_op)s %(value)s::numeric
)
""" # nosec B608 %(value)s %(comparison_op)s
)
# fmt: on
def get_template_data(self, sql_value) -> dict:
data = super().get_template_data(sql_value)
data["comparison_op"] = self.comparison_op.value
return data

View file

@ -0,0 +1,91 @@
import typing
from django.db import models
from baserow.contrib.database.fields.filter_support.base import (
HasAllValuesEqualFilterSupport,
HasNumericValueComparableToFilterSupport,
HasValueContainsFilterSupport,
HasValueContainsWordFilterSupport,
HasValueEmptyFilterSupport,
HasValueEqualFilterSupport,
HasValueLengthIsLowerThanFilterSupport,
)
from baserow.contrib.database.formula.expression_generator.django_expressions import (
ComparisonOperator,
)
if typing.TYPE_CHECKING:
from baserow.contrib.database.fields.field_filters import OptionallyAnnotatedQ
from baserow.contrib.database.fields.models import Field
class BaserowFormulaArrayFilterSupportMixin(
HasAllValuesEqualFilterSupport,
HasValueEmptyFilterSupport,
HasValueEqualFilterSupport,
HasValueContainsFilterSupport,
HasValueContainsWordFilterSupport,
HasValueLengthIsLowerThanFilterSupport,
HasNumericValueComparableToFilterSupport,
):
"""
This mixin proxies all the array formula filters methods to the formula subtype.
"""
def get_in_array_empty_value(self, field):
field_instance, _ = self.sub_type.get_baserow_field_instance_and_type()
return self.sub_type.get_in_array_empty_value(field_instance)
def get_in_array_empty_query(self, field_name, model_field, field):
field_instance, _ = self.sub_type.get_baserow_field_instance_and_type()
return self.sub_type.get_in_array_empty_query(
field_name, model_field, field_instance
)
def get_in_array_is_query(self, field_name, value, model_field, field):
field_instance, _ = self.sub_type.get_baserow_field_instance_and_type()
return self.sub_type.get_in_array_is_query(
field_name, value, model_field, field_instance
)
def get_in_array_contains_query(self, field_name, value, model_field, field):
field_instance, _ = self.sub_type.get_baserow_field_instance_and_type()
return self.sub_type.get_in_array_contains_query(
field_name, value, model_field, field_instance
)
def get_in_array_contains_word_query(self, field_name, value, model_field, field):
field_instance, _ = self.sub_type.get_baserow_field_instance_and_type()
return self.sub_type.get_in_array_contains_word_query(
field_name, value, model_field, field_instance
)
def get_in_array_length_is_lower_than_query(
self, field_name, value, model_field, field
):
field_instance, _ = self.sub_type.get_baserow_field_instance_and_type()
return self.sub_type.get_in_array_length_is_lower_than_query(
field_name, value, model_field, field_instance
)
def get_has_all_values_equal_query(
self, field_name: str, value: str, model_field: models.Field, field: "Field"
) -> "OptionallyAnnotatedQ":
field_instance, _ = self.sub_type.get_baserow_field_instance_and_type()
return self.sub_type.get_has_all_values_equal_query(
field_name, value, model_field, field_instance
)
def get_has_numeric_value_comparable_to_filter_query(
self,
field_name: str,
value: str,
model_field: models.Field,
field: "Field",
comparison_op: ComparisonOperator,
) -> "OptionallyAnnotatedQ":
field_instance, _ = self.sub_type.get_baserow_field_instance_and_type()
return self.sub_type.get_has_numeric_value_comparable_to_filter_query(
field_name, value, model_field, field_instance, comparison_op
)

View file

@ -497,6 +497,17 @@ 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):
"""
Use the Baserow field type method where possible to prepare the filter value.
"""
(
field_instance,
field_type,
) = self.get_baserow_field_instance_and_type()
return field_type.prepare_filter_value(field_instance, model_field, value)
class BaserowFormulaInvalidType(BaserowFormulaType):
is_valid = False

View file

@ -6,7 +6,6 @@ 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.core.exceptions import ValidationError
from django.db import models
from django.db.models import (
Expression,
@ -21,7 +20,6 @@ from django.db.models import (
from django.db.models.functions import Cast, Concat
from dateutil import parser
from loguru import logger
from rest_framework import serializers
from rest_framework.fields import Field
@ -37,16 +35,14 @@ from baserow.contrib.database.fields.field_filters import (
from baserow.contrib.database.fields.field_sortings import OptionallyAnnotatedOrderBy
from baserow.contrib.database.fields.filter_support.base import (
HasAllValuesEqualFilterSupport,
HasNumericValueComparableToFilterSupport,
HasValueContainsFilterSupport,
HasValueContainsWordFilterSupport,
HasValueEmptyFilterSupport,
HasValueFilterSupport,
HasValueEqualFilterSupport,
HasValueLengthIsLowerThanFilterSupport,
get_array_json_filter_expression,
)
from baserow.contrib.database.fields.filter_support.exceptions import (
FilterNotSupportedException,
)
from baserow.contrib.database.fields.filter_support.single_select import (
SingleSelectFormulaTypeFilterSupport,
)
@ -65,10 +61,15 @@ from baserow.contrib.database.formula.ast.tree import (
BaserowStringLiteral,
)
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
from baserow.contrib.database.formula.types.filter_support import (
BaserowFormulaArrayFilterSupportMixin,
)
from baserow.contrib.database.formula.types.formula_type import (
BaserowFormulaInvalidType,
BaserowFormulaType,
@ -81,7 +82,14 @@ from baserow.core.utils import list_to_comma_separated_string
class BaserowJSONBObjectBaseType(BaserowFormulaValidType, ABC):
pass
def prepare_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
class BaserowFormulaBaseTextType(BaserowFormulaTypeHasEmptyBaserowExpression):
@ -130,7 +138,7 @@ class BaserowFormulaBaseTextType(BaserowFormulaTypeHasEmptyBaserowExpression):
class BaserowFormulaTextType(
HasValueEmptyFilterSupport,
HasValueFilterSupport,
HasValueEqualFilterSupport,
HasValueContainsFilterSupport,
HasValueContainsWordFilterSupport,
HasValueLengthIsLowerThanFilterSupport,
@ -328,7 +336,12 @@ class BaserowFormulaButtonType(BaserowFormulaLinkType):
class BaserowFormulaNumberType(
BaserowFormulaTypeHasEmptyBaserowExpression, BaserowFormulaValidType
HasValueEmptyFilterSupport,
HasValueEqualFilterSupport,
HasValueContainsFilterSupport,
HasNumericValueComparableToFilterSupport,
BaserowFormulaTypeHasEmptyBaserowExpression,
BaserowFormulaValidType,
):
type = "number"
baserow_field_type = "number"
@ -460,13 +473,26 @@ class BaserowFormulaNumberType(
),
)
def get_in_array_empty_value(self, field: "Field") -> Any:
return None
def get_in_array_is_query(
self, field_name: str, value: str, model_field: models.Field, field: "Field"
) -> OptionallyAnnotatedQ:
return get_array_json_filter_expression(
JSONArrayCompareNumericValueExpr,
field_name,
value,
comparison_op=ComparisonOperator.EQUAL,
)
def __str__(self) -> str:
return f"number({self.number_decimal_places})"
class BaserowFormulaBooleanType(
HasAllValuesEqualFilterSupport,
HasValueFilterSupport,
HasValueEqualFilterSupport,
BaserowFormulaTypeHasEmptyBaserowExpression,
BaserowFormulaValidType,
):
@ -500,22 +526,9 @@ class BaserowFormulaBooleanType(
):
return expr
def _get_prep_value(self, value: str):
from baserow.contrib.database.fields.registries import field_type_registry
baserow_field_type = field_type_registry.get(self.baserow_field_type)
# boolean field type doesn't expect instance value
field_instance = baserow_field_type.get_model_field(None)
try:
# get_prep_value can return None
return field_instance.get_prep_value(value) or False
except ValidationError:
return False
def get_in_array_is_query(
self, field_name: str, value: str, model_field: models.Field, field: "Field"
) -> OptionallyAnnotatedQ:
value = self._get_prep_value(value)
return get_array_json_filter_expression(
JSONArrayContainsValueExpr, field_name, value
)
@ -533,7 +546,6 @@ class BaserowFormulaBooleanType(
def get_has_all_values_equal_query(
self, field_name: str, value: str, model_field: models.Field, field: "Field"
) -> "OptionallyAnnotatedQ":
value = self._get_prep_value(value)
return super().get_has_all_values_equal_query(
field_name, value, model_field, field
)
@ -1062,12 +1074,7 @@ class BaserowFormulaSingleFileType(BaserowJSONBObjectBaseType):
class BaserowFormulaArrayType(
HasAllValuesEqualFilterSupport,
HasValueEmptyFilterSupport,
HasValueFilterSupport,
HasValueContainsFilterSupport,
HasValueContainsWordFilterSupport,
HasValueLengthIsLowerThanFilterSupport,
BaserowFormulaArrayFilterSupportMixin,
BaserowFormulaValidType,
):
type = "array"
@ -1235,57 +1242,6 @@ class BaserowFormulaArrayType(
def contains_query(self, field_name, value, model_field, field):
return Q()
def get_in_array_is_query(self, field_name, value, model_field, field):
if not isinstance(self.sub_type, HasValueFilterSupport):
raise FilterNotSupportedException()
return self.sub_type.get_in_array_is_query(
field_name, value, model_field, field
)
def get_in_array_empty_query(self, field_name, model_field, field):
if not isinstance(self.sub_type, HasValueEmptyFilterSupport):
raise FilterNotSupportedException()
return self.sub_type.get_in_array_empty_query(field_name, model_field, field)
def get_in_array_contains_query(self, field_name, value, model_field, field):
if not isinstance(self.sub_type, HasValueContainsFilterSupport):
raise FilterNotSupportedException()
return self.sub_type.get_in_array_contains_query(
field_name, value, model_field, field
)
def get_in_array_contains_word_query(self, field_name, value, model_field, field):
if not isinstance(self.sub_type, HasValueContainsWordFilterSupport):
raise FilterNotSupportedException()
return self.sub_type.get_in_array_contains_word_query(
field_name, value, model_field, field
)
def get_in_array_length_is_lower_than_query(
self, field_name, value, model_field, field
):
if not isinstance(self.sub_type, HasValueLengthIsLowerThanFilterSupport):
raise FilterNotSupportedException()
return self.sub_type.get_in_array_length_is_lower_than_query(
field_name, value, model_field, field
)
def get_has_all_values_equal_query(
self, field_name: str, value: str, model_field: models.Field, field: "Field"
) -> "OptionallyAnnotatedQ":
if not isinstance(self.sub_type, HasAllValuesEqualFilterSupport):
logger.warning(
f"field {field} is not from {HasAllValuesEqualFilterSupport} hierarchy"
)
raise FilterNotSupportedException()
return self.sub_type.get_has_all_values_equal_query(
field_name, value, model_field, field
)
def get_alter_column_prepare_old_value(self, connection, from_field, to_field):
return "p_in = '';"
@ -1362,10 +1318,8 @@ class BaserowFormulaArrayType(
return None
def check_if_compatible_with(self, compatible_formula_types: List[str]):
return (
self.type in compatible_formula_types
or str(self) in compatible_formula_types
)
self_as_str = self.formula_array_type_as_str(self.sub_type.type)
return self_as_str in compatible_formula_types
def __str__(self) -> str:
return self.formula_array_type_as_str(self.sub_type)
@ -1398,6 +1352,9 @@ class BaserowFormulaArrayType(
)
}
def prepare_filter_value(self, field, model_field, value):
return self.sub_type.prepare_filter_value(field, model_field, value)
class BaserowFormulaSingleSelectType(
SingleSelectFormulaTypeFilterSupport,

View file

@ -1,18 +1,25 @@
from abc import ABC, abstractmethod
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,
HasValueEmptyFilterSupport,
HasValueFilterSupport,
HasValueEqualFilterSupport,
HasValueLengthIsLowerThanFilterSupport,
)
from baserow.contrib.database.fields.filter_support.exceptions import (
FilterNotSupportedException,
)
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.formula import BaserowFormulaTextType
from baserow.contrib.database.formula import (
BaserowFormulaNumberType,
BaserowFormulaTextType,
)
from baserow.contrib.database.formula.expression_generator.django_expressions import (
ComparisonOperator,
)
from baserow.contrib.database.formula.types.formula_types import (
BaserowFormulaBooleanType,
BaserowFormulaCharType,
@ -37,18 +44,13 @@ class HasEmptyValueViewFilterType(ViewFilterType):
FormulaFieldType.array_of(BaserowFormulaCharType.type),
FormulaFieldType.array_of(BaserowFormulaURLType.type),
FormulaFieldType.array_of(BaserowFormulaSingleSelectType.type),
FormulaFieldType.array_of(BaserowFormulaNumberType.type),
),
]
def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ:
field_type = field_type_registry.get_by_model(field)
try:
if not isinstance(field_type, HasValueEmptyFilterSupport):
raise FilterNotSupportedException(field_type)
return field_type.get_in_array_empty_query(field_name, model_field, field)
except FilterNotSupportedException:
return self.default_filter_on_exception()
field_type: HasValueEmptyFilterSupport = field_type_registry.get_by_model(field)
return field_type.get_in_array_empty_query(field_name, model_field, field)
class HasNotEmptyValueViewFilterType(
@ -57,7 +59,43 @@ class HasNotEmptyValueViewFilterType(
type = "has_not_empty_value"
class HasValueEqualViewFilterType(ViewFilterType):
class ComparisonHasValueFilter(ViewFilterType, ABC):
"""
A filter that can be used to compare the values in an array with a specific value
using a comparison operator.
"""
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)
except ValueError: # invalid filter value for the field
return self.default_filter_on_exception()
return self.get_filter_expression(field_name, filter_value, model_field, field)
@abstractmethod
def get_filter_expression(
self, field_name, value, model_field, field
) -> OptionallyAnnotatedQ:
"""
Return the filter expression to use for the required comparison.
:param field_name: The name of the field to filter on.
:param value: A non-empty string value to compare against.
:param model_field: The model field instance.
:param field: The field instance.
:return: The filter expression to use.
:raises ValidationError: If the value cannot be parsed to the proper type.
"""
class HasValueEqualViewFilterType(ComparisonHasValueFilter):
"""
The filter can be used to check for "is" condition for
items in an array.
@ -71,19 +109,15 @@ class HasValueEqualViewFilterType(ViewFilterType):
FormulaFieldType.array_of(BaserowFormulaURLType.type),
FormulaFieldType.array_of(BaserowFormulaBooleanType.type),
FormulaFieldType.array_of(BaserowFormulaSingleSelectType.type),
FormulaFieldType.array_of(BaserowFormulaNumberType.type),
),
]
def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ:
field_type = field_type_registry.get_by_model(field)
try:
if not isinstance(field_type, HasValueFilterSupport):
raise FilterNotSupportedException(field_type)
return field_type.get_in_array_is_query(
field_name, value, model_field, field
)
except FilterNotSupportedException:
return self.default_filter_on_exception()
def get_filter_expression(
self, field_name, value, model_field, field
) -> OptionallyAnnotatedQ:
field_type: HasValueEqualFilterSupport = field_type_registry.get_by_model(field)
return field_type.get_in_array_is_query(field_name, value, model_field, field)
class HasNotValueEqualViewFilterType(
@ -105,20 +139,20 @@ class HasValueContainsViewFilterType(ViewFilterType):
FormulaFieldType.array_of(BaserowFormulaCharType.type),
FormulaFieldType.array_of(BaserowFormulaURLType.type),
FormulaFieldType.array_of(BaserowFormulaSingleSelectType.type),
FormulaFieldType.array_of(BaserowFormulaNumberType.type),
),
]
def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ:
field_type = field_type_registry.get_by_model(field)
try:
if not isinstance(field_type, HasValueContainsFilterSupport):
raise FilterNotSupportedException(field_type)
if value == "":
return Q()
return field_type.get_in_array_contains_query(
field_name, value, model_field, field
)
except FilterNotSupportedException:
return self.default_filter_on_exception()
field_type: HasValueContainsFilterSupport = field_type_registry.get_by_model(
field
)
return field_type.get_in_array_contains_query(
field_name, value, model_field, field
)
class HasNotValueContainsViewFilterType(
@ -144,16 +178,15 @@ class HasValueContainsWordViewFilterType(ViewFilterType):
]
def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ:
field_type = field_type_registry.get_by_model(field)
try:
if not isinstance(field_type, HasValueContainsWordFilterSupport):
raise FilterNotSupportedException(field_type)
if value == "":
return Q()
return field_type.get_in_array_contains_word_query(
field_name, value, model_field, field
)
except FilterNotSupportedException:
return self.default_filter_on_exception()
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
)
class HasNotValueContainsWordViewFilterType(
@ -178,19 +211,25 @@ class HasValueLengthIsLowerThanViewFilterType(ViewFilterType):
]
def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ:
field_type = field_type_registry.get_by_model(field)
try:
if not isinstance(field_type, HasValueLengthIsLowerThanFilterSupport):
raise FilterNotSupportedException(field_type)
value = value.strip()
if value == "":
return Q()
return field_type.get_in_array_length_is_lower_than_query(
field_name, value, model_field, field
)
except FilterNotSupportedException:
try:
# The value is expected to be an integer representing the length to compare
filter_value = int(value)
except (ValueError, TypeError):
return self.default_filter_on_exception()
field_type: HasValueLengthIsLowerThanFilterSupport = (
field_type_registry.get_by_model(field)
)
return field_type.get_in_array_length_is_lower_than_query(
field_name, filter_value, model_field, field
)
class HasAllValuesEqualViewFilterType(ViewFilterType):
class HasAllValuesEqualViewFilterType(ComparisonHasValueFilter):
"""
The filter checks if all values in an array are equal to a specific value.
"""
@ -202,16 +241,15 @@ class HasAllValuesEqualViewFilterType(ViewFilterType):
),
]
def get_filter(self, field_name, value, model_field, field) -> OptionallyAnnotatedQ:
try:
field_type = field_type_registry.get_by_model(field)
if not isinstance(field_type, HasAllValuesEqualFilterSupport):
raise FilterNotSupportedException(field_type)
return field_type.get_has_all_values_equal_query(
field_name, value, model_field, field
)
except FilterNotSupportedException:
return self.default_filter_on_exception()
def get_filter_expression(
self, field_name, value, model_field, field
) -> OptionallyAnnotatedQ:
field_type: HasAllValuesEqualViewFilterType = field_type_registry.get_by_model(
field
)
return field_type.get_has_all_values_equal_query(
field_name, value, model_field, field
)
class HasAnySelectOptionEqualViewFilterType(HasValueEqualViewFilterType):
@ -228,6 +266,9 @@ 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)
@ -240,3 +281,101 @@ class HasNoneSelectOptionEqualViewFilterType(
"""
type = "has_none_select_option_equal"
class hasValueComparableToFilter(ComparisonHasValueFilter):
type = "has_value_higher"
compatible_field_types = [
FormulaFieldType.compatible_with_formula_types(
FormulaFieldType.array_of(BaserowFormulaNumberType.type)
),
]
def get_filter_expression(self, field_name, value, model_field, field):
field_type: HasNumericValueComparableToFilterSupport = (
field_type_registry.get_by_model(field)
)
return field_type.get_has_numeric_value_comparable_to_filter_query(
field_name, value, model_field, field, ComparisonOperator.HIGHER_THAN
)
class HasNotValueHigherThanFilterType(
NotViewFilterTypeMixin, hasValueComparableToFilter
):
type = "has_not_value_higher"
class HasValueHigherOrEqualThanFilter(ComparisonHasValueFilter):
type = "has_value_higher_or_equal"
compatible_field_types = [
FormulaFieldType.compatible_with_formula_types(
FormulaFieldType.array_of(BaserowFormulaNumberType.type)
),
]
def get_filter_expression(self, field_name, value, model_field, field):
field_type: HasNumericValueComparableToFilterSupport = (
field_type_registry.get_by_model(field)
)
return field_type.get_has_numeric_value_comparable_to_filter_query(
field_name,
value,
model_field,
field,
ComparisonOperator.HIGHER_THAN_OR_EQUAL,
)
class HasNotValueHigherOrEqualTHanFilterType(
NotViewFilterTypeMixin, HasValueHigherOrEqualThanFilter
):
type = "has_not_value_higher_or_equal"
class HasValueLowerThanFilter(ComparisonHasValueFilter):
type = "has_value_lower"
compatible_field_types = [
FormulaFieldType.compatible_with_formula_types(
FormulaFieldType.array_of(BaserowFormulaNumberType.type)
),
]
def get_filter_expression(self, field_name, value, model_field, field):
field_type: HasNumericValueComparableToFilterSupport = (
field_type_registry.get_by_model(field)
)
return field_type.get_has_numeric_value_comparable_to_filter_query(
field_name, value, model_field, field, ComparisonOperator.LOWER_THAN
)
class HasNotValueLowerThanFilterType(NotViewFilterTypeMixin, HasValueLowerThanFilter):
type = "has_not_value_lower"
class HasValueLowerOrEqualThanFilter(ComparisonHasValueFilter):
type = "has_value_lower_or_equal"
compatible_field_types = [
FormulaFieldType.compatible_with_formula_types(
FormulaFieldType.array_of(BaserowFormulaNumberType.type)
),
]
def get_filter_expression(self, field_name, value, model_field, field):
field_type: HasNumericValueComparableToFilterSupport = (
field_type_registry.get_by_model(field)
)
return field_type.get_has_numeric_value_comparable_to_filter_query(
field_name,
value,
model_field,
field,
ComparisonOperator.LOWER_THAN_OR_EQUAL,
)
class HasNotValueLowerOrEqualTHanFilterType(
NotViewFilterTypeMixin, HasValueLowerOrEqualThanFilter
):
type = "has_not_value_lower_or_equal"

View file

@ -2,10 +2,8 @@ import datetime as datetime_module
import zoneinfo
from collections import defaultdict
from datetime import date, datetime, timedelta
from decimal import Decimal
from enum import Enum
from functools import reduce
from math import ceil, floor
from types import MappingProxyType
from typing import Any, Dict, NamedTuple, Optional, Tuple, Union
@ -124,8 +122,9 @@ class EqualViewFilterType(ViewFilterType):
return Q()
# Check if the model_field accepts the value.
field_type = field_type_registry.get_by_model(field)
try:
value = model_field.get_prep_value(value)
value = field_type.prepare_filter_value(field, model_field, value)
return Q(**{field_name: value})
except Exception:
return self.default_filter_on_exception()
@ -379,9 +378,6 @@ class NumericComparisonViewFilterType(ViewFilterType):
),
]
def should_round_value_to_compare(self, value, model_field):
return isinstance(model_field, IntegerField) and value.find(".") != -1
def get_filter(self, field_name, value, model_field, field):
value = value.strip()
@ -389,17 +385,14 @@ class NumericComparisonViewFilterType(ViewFilterType):
if value == "":
return Q()
if self.should_round_value_to_compare(value, model_field):
decimal_value = Decimal(value)
value = self.rounding_func(decimal_value)
# Check if the model_field accepts the value.
field_type = field_type_registry.get_by_model(field)
try:
value = model_field.get_prep_value(value)
return Q(**{f"{field_name}__{self.operator}": value})
except Exception:
filter_value = field_type.prepare_filter_value(field, model_field, value)
except ValueError:
return self.default_filter_on_exception()
return Q(**{f"{field_name}__{self.operator}": filter_value})
class LowerThanViewFilterType(NumericComparisonViewFilterType):
"""
@ -409,7 +402,6 @@ class LowerThanViewFilterType(NumericComparisonViewFilterType):
type = "lower_than"
operator = "lt"
rounding_func = floor
class LowerThanOrEqualViewFilterType(NumericComparisonViewFilterType):
@ -421,7 +413,6 @@ class LowerThanOrEqualViewFilterType(NumericComparisonViewFilterType):
type = "lower_than_or_equal"
operator = "lte"
rounding_func = floor
class HigherThanViewFilterType(NumericComparisonViewFilterType):
@ -432,7 +423,6 @@ class HigherThanViewFilterType(NumericComparisonViewFilterType):
type = "higher_than"
operator = "gt"
rounding_func = ceil
class HigherThanOrEqualViewFilterType(NumericComparisonViewFilterType):
@ -444,7 +434,6 @@ class HigherThanOrEqualViewFilterType(NumericComparisonViewFilterType):
type = "higher_than_or_equal"
operator = "gte"
rounding_func = ceil
class TimezoneAwareDateViewFilterType(ViewFilterType):
@ -1217,24 +1206,15 @@ class BooleanViewFilterType(ViewFilterType):
]
def get_filter(self, field_name, value, model_field, field):
value = value.strip().lower()
value = value in [
"y",
"t",
"o",
"yes",
"true",
"on",
"1",
]
if value == "": # consider emtpy value as False
value = "false"
# Check if the model_field accepts the value.
# noinspection PyBroadException
field_type = field_type_registry.get_by_model(field)
try:
value = model_field.get_prep_value(value)
value = field_type.prepare_filter_value(field, model_field, value)
return Q(**{field_name: value})
except Exception:
return Q()
except ValueError:
return self.default_filter_on_exception()
class ManyToManyHasBaseViewFilter(ViewFilterType):

View file

@ -0,0 +1,561 @@
import typing
from functools import partial
import pytest
from tests.baserow.contrib.database.utils import (
number_field_factory,
setup_linked_table_and_lookup,
text_field_factory,
)
if typing.TYPE_CHECKING:
from baserow.test_utils.fixtures import Fixtures
def number_lookup_filter_proc(
data_fixture: "Fixtures",
filter_type_name: str,
test_value: str,
expected_rows: set[str],
number_decimal_places: int = 5,
):
"""
Common numeric lookup field test procedure. Each test operates on a fixed set of
data, where each table row contains a lookup field with a predefined set of linked
rows.
"""
t = setup_linked_table_and_lookup(
data_fixture,
target_field_factory=partial(
number_field_factory,
number_decimal_places=number_decimal_places,
number_negative=True,
),
helper_fields_table=[partial(text_field_factory, name="row name")],
)
row_name_field = t.model.get_field_object_by_user_field_name("row name")["field"]
link_row_name = t.link_row_field.db_column
target_name = t.target_field.db_column
lookup_name = t.lookup_field.db_column
row_name = row_name_field.db_column
row_values = [
"1000.0001", # 0
"1000",
"999.999",
"123.45",
"100",
"50", # 5
"1.000001", # will be rounded to 1.00000
"1",
"0.00007",
"0.00004",
"0.00001", # 10
"0.0000",
"-0.1", # mind that it will be rounded to 0.10000
"-2",
None, # 14
"999.99999999",
]
dict_rows = [{target_name: rval} for rval in row_values]
linked_rows = t.row_handler.create_rows(
user=t.user, table=t.other_table, rows_values=dict_rows
)
# helper to get linked rows by indexes
def get_linked_rows(*indexes) -> list[int]:
return [linked_rows[idx].id for idx in indexes]
rows = [
{row_name: "above 100", link_row_name: get_linked_rows(0, 1, 2, 3)},
{row_name: "exact 100", link_row_name: get_linked_rows(4)},
{row_name: "between 100 and 10", link_row_name: get_linked_rows(4, 5)},
{
row_name: "between 10 and 0",
link_row_name: get_linked_rows(6, 7, 8, 9, 10, 11),
},
{row_name: "zero", link_row_name: get_linked_rows(11)},
{row_name: "below zero", link_row_name: get_linked_rows(12, 13)},
{row_name: "nineninenine", link_row_name: get_linked_rows(2)},
{row_name: "onetwothree", link_row_name: get_linked_rows(3)},
# no refs is a tricky one - we don't have is_empty for numeric field
{row_name: "no refs", link_row_name: []},
{row_name: "refs with empty", link_row_name: get_linked_rows(14)},
{row_name: "100 with empty", link_row_name: get_linked_rows(4, 14)},
{row_name: "ninieninenine rounded", link_row_name: get_linked_rows(15)},
]
t.row_handler.create_rows(user=t.user, table=t.table, rows_values=rows)
clean_query = t.view_handler.get_queryset(t.grid_view)
t.view_handler.create_filter(
t.user,
t.grid_view,
field=t.lookup_field,
type_name=filter_type_name,
value=test_value,
)
q = t.view_handler.get_queryset(t.grid_view)
print(f"filter {filter_type_name} with value: {(test_value,)}")
print(f"expected: {expected_rows}")
print(f"filtered: {[getattr(item, row_name) for item in q]}")
for item in q:
print(f" {item.id} -> {getattr(item, row_name)}: {getattr(item, lookup_name)}")
print()
for item in clean_query:
print(f" {item.id} -> {getattr(item, row_name)}: {getattr(item, lookup_name)}")
assert len(q) == len(expected_rows)
assert set([getattr(r, row_name) for r in q]) == set(expected_rows)
ALL_ROW_NAMES = [
"above 100",
"exact 100",
"between 100 and 10",
"between 10 and 0",
"zero",
"below zero",
"nineninenine",
"onetwothree",
"no refs",
"refs with empty",
"100 with empty",
"ninieninenine rounded",
]
@pytest.mark.parametrize(
"filter_type_name,expected_rows",
[
("has_empty_value", ["refs with empty", "100 with empty"]),
(
"has_not_empty_value",
[
"above 100",
"exact 100",
"between 100 and 10",
"between 10 and 0",
"zero",
"below zero",
"nineninenine",
"ninieninenine rounded",
"onetwothree",
"no refs", # this is due to inversion of has_empty_value
# "refs with empty", "100 with empty"
],
),
],
)
@pytest.mark.django_db
def test_number_lookup_field_has_empty_value_filter(
data_fixture, filter_type_name, expected_rows
):
return number_lookup_filter_proc(data_fixture, filter_type_name, "", expected_rows)
@pytest.mark.parametrize(
"filter_type_name,test_value,expected_rows",
[
("has_value_equal", "", ALL_ROW_NAMES),
("has_value_equal", "invalid", []),
# too large, the same as invalid
(
"has_value_equal",
"1" + ("0" * 40),
[],
),
(
"has_value_equal",
"100",
["exact 100", "between 100 and 10", "100 with empty"],
),
# no rounding to 0.00001
("has_value_equal", "100.00000000001", []),
("has_not_value_equal", "", ALL_ROW_NAMES),
("has_not_value_equal", "invalid", ALL_ROW_NAMES),
(
"has_not_value_equal",
"1" + ("0" * 40),
ALL_ROW_NAMES,
),
("has_not_value_equal", "999", ALL_ROW_NAMES),
("has_not_value_equal", "1.00000001", ALL_ROW_NAMES),
(
"has_not_value_equal",
"0.0",
[o for o in ALL_ROW_NAMES if o not in {"zero", "between 10 and 0"}],
),
],
)
@pytest.mark.django_db
def test_number_lookup_field_has_value_equal_filter(
data_fixture, filter_type_name, test_value, expected_rows
):
return number_lookup_filter_proc(
data_fixture, filter_type_name, test_value, expected_rows
)
@pytest.mark.parametrize(
"filter_type_name,test_value,expected_rows",
[
("has_value_contains", "", ALL_ROW_NAMES),
("has_value_contains", "invalid", []),
(
"has_value_contains",
"1" + ("0" * 40),
[],
),
(
"has_value_contains",
"100",
# includes '0.10000'
[
"above 100",
"exact 100",
"between 100 and 10",
"below zero",
"100 with empty",
"ninieninenine rounded",
],
),
("has_value_contains", "999", ["nineninenine", "above 100"]),
("has_value_contains", "99.999", ["nineninenine", "above 100"]),
("has_not_value_contains", "", ALL_ROW_NAMES),
("has_not_value_contains", "invalid", ALL_ROW_NAMES),
(
"has_not_value_contains",
"1" + ("0" * 40),
ALL_ROW_NAMES,
),
(
"has_not_value_contains",
"999",
[o for o in ALL_ROW_NAMES if o not in {"nineninenine", "above 100"}],
),
("has_not_value_contains", "1.00000001", ALL_ROW_NAMES),
(
"has_not_value_contains",
"001",
# between 10 and 0 contains 1.00001
# above 100 contains 1000.0001
[o for o in ALL_ROW_NAMES if o not in {"between 10 and 0", "above 100"}],
),
],
)
@pytest.mark.django_db
def test_number_lookup_field_has_value_contains_filter(
data_fixture, filter_type_name, test_value, expected_rows
):
return number_lookup_filter_proc(
data_fixture, filter_type_name, test_value, expected_rows
)
@pytest.mark.parametrize(
"filter_type_name,test_value,expected_rows",
[
("has_value_higher", "", ALL_ROW_NAMES),
("has_value_higher", "invalid", []),
(
"has_value_higher",
"1" + ("0" * 40),
[],
),
(
"has_value_higher",
"-1" + ("0" * 40),
[o for o in ALL_ROW_NAMES if o not in {"no refs", "refs with empty"}],
),
(
"has_value_higher",
"-0.0000001",
[
"above 100",
"exact 100",
"between 100 and 10",
"between 10 and 0",
"zero",
"nineninenine",
"onetwothree",
"100 with empty",
"ninieninenine rounded",
],
),
(
"has_value_higher",
"0.00000001",
[
"above 100",
"exact 100",
"between 100 and 10",
"between 10 and 0",
"nineninenine",
"onetwothree",
"100 with empty",
"ninieninenine rounded",
],
),
(
"has_value_higher",
"999.998999", # not rounded
["above 100", "nineninenine", "ninieninenine rounded"],
),
("has_value_higher", "999.999", ["above 100", "ninieninenine rounded"]),
],
)
@pytest.mark.django_db
def test_number_lookup_field_has_value_higher_than_filter(
data_fixture, filter_type_name, test_value, expected_rows
):
return number_lookup_filter_proc(
data_fixture, filter_type_name, test_value, expected_rows
)
@pytest.mark.parametrize(
"filter_type_name,test_value,expected_rows",
[
("has_value_higher_or_equal", "", ALL_ROW_NAMES),
("has_value_higher_or_equal", "invalid", []),
(
"has_value_higher_or_equal",
"1" + ("0" * 40),
[],
),
(
"has_value_higher_or_equal",
"-1" + ("0" * 40),
[o for o in ALL_ROW_NAMES if o not in {"no refs", "refs with empty"}],
),
(
"has_value_higher_or_equal",
"-0.0",
[
"above 100",
"exact 100",
"between 100 and 10",
"between 10 and 0",
"zero",
"nineninenine",
"onetwothree",
"100 with empty",
"ninieninenine rounded",
],
),
(
"has_value_higher_or_equal",
"999.999",
["above 100", "nineninenine", "ninieninenine rounded"],
),
("has_not_value_higher_or_equal", "", ALL_ROW_NAMES),
(
"has_not_value_higher_or_equal",
"invalid",
ALL_ROW_NAMES,
), # reversed has_value_higher_or_equal
(
"has_not_value_higher_or_equal",
"1" + ("0" * 40),
ALL_ROW_NAMES, # reversed has_value_higher_or_equal
),
(
"has_not_value_higher_or_equal",
"-1" + ("0" * 40),
[o for o in ALL_ROW_NAMES if o in {"no refs", "refs with empty"}],
),
(
"has_not_value_higher_or_equal",
"-0.0",
["below zero", "no refs", "refs with empty"],
),
(
"has_not_value_higher_or_equal",
"999.999",
[
o
for o in ALL_ROW_NAMES
if o not in {"above 100", "nineninenine", "ninieninenine rounded"}
],
),
],
)
@pytest.mark.django_db
def test_number_lookup_field_has_value_higher_equal_than_filter(
data_fixture, filter_type_name, test_value, expected_rows
):
return number_lookup_filter_proc(
data_fixture, filter_type_name, test_value, expected_rows
)
@pytest.mark.parametrize(
"filter_type_name,test_value,expected_rows",
[
("has_value_lower_or_equal", "", ALL_ROW_NAMES),
("has_value_lower_or_equal", "invalid", []),
(
"has_value_lower_or_equal",
"1" + ("0" * 40),
[o for o in ALL_ROW_NAMES if o not in {"no refs", "refs with empty"}],
),
(
"has_value_lower_or_equal",
"-1" + ("0" * 40),
[],
),
(
"has_value_lower_or_equal",
"-0.0",
["between 10 and 0", "zero", "below zero"],
),
(
"has_value_lower_or_equal",
"999.999",
[
"above 100",
"exact 100",
"between 100 and 10",
"between 10 and 0",
"zero",
"below zero",
"nineninenine",
"onetwothree",
"100 with empty",
],
),
("has_not_value_lower_or_equal", "", ALL_ROW_NAMES),
(
"has_not_value_lower_or_equal",
"invalid",
ALL_ROW_NAMES,
), # reversed has_value_lower_or_equal
(
"has_not_value_lower_or_equal",
"1" + ("0" * 40),
["no refs", "refs with empty"], # reversed has_value_lower_or_equal
),
(
"has_not_value_lower_or_equal",
"-1" + ("0" * 40),
ALL_ROW_NAMES,
),
(
"has_not_value_lower_or_equal",
"-0.0",
[
"above 100",
"exact 100",
"between 100 and 10",
"nineninenine",
"onetwothree",
"no refs",
"refs with empty",
"100 with empty",
"ninieninenine rounded",
],
),
(
"has_not_value_lower_or_equal",
"999.999",
["no refs", "refs with empty", "ninieninenine rounded"],
),
],
)
@pytest.mark.django_db
def test_number_lookup_field_has_value_lower_equal_than_filter(
data_fixture, filter_type_name, test_value, expected_rows
):
return number_lookup_filter_proc(
data_fixture, filter_type_name, test_value, expected_rows
)
@pytest.mark.parametrize(
"filter_type_name,test_value,expected_rows",
[
("has_value_lower", "", ALL_ROW_NAMES),
("has_value_lower", "invalid", []),
(
"has_value_lower",
"1" + ("0" * 40),
[o for o in ALL_ROW_NAMES if o not in {"no refs", "refs with empty"}],
),
(
"has_value_lower",
"-1" + ("0" * 40),
[],
),
(
"has_value_lower",
"-0.0",
["below zero"],
),
(
"has_value_lower",
"999.999",
[
"above 100",
"exact 100",
"between 100 and 10",
"between 10 and 0",
"zero",
"below zero",
"onetwothree",
"100 with empty",
],
),
("has_not_value_lower", "", ALL_ROW_NAMES),
(
"has_not_value_lower",
"invalid",
ALL_ROW_NAMES,
), # reversed has_value_lower
(
"has_not_value_lower",
"1" + ("0" * 40),
["no refs", "refs with empty"], # reversed has_value_lower
),
(
"has_not_value_lower",
"-1" + ("0" * 40),
ALL_ROW_NAMES,
),
(
"has_not_value_lower",
"-0.0",
[
"above 100",
"exact 100",
"between 100 and 10",
"between 10 and 0",
"zero",
"nineninenine",
"onetwothree",
"no refs",
"refs with empty",
"100 with empty",
"ninieninenine rounded",
],
),
(
"has_not_value_lower",
"999.999",
["nineninenine", "no refs", "refs with empty", "ninieninenine rounded"],
),
],
)
@pytest.mark.django_db
def test_number_lookup_field_has_value_lower_than_filter(
data_fixture, filter_type_name, test_value, expected_rows
):
return number_lookup_filter_proc(
data_fixture, filter_type_name, test_value, expected_rows
)

View file

@ -142,13 +142,18 @@ def text_field_value_factory(data_fixture, target_field, value=None):
def setup_linked_table_and_lookup(
data_fixture, target_field_factory
data_fixture,
target_field_factory,
helper_fields_other_table: Iterable[Callable] = frozenset(),
helper_fields_table: Iterable[Callable] = frozenset(),
) -> LookupFieldSetup:
user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user)
table = data_fixture.create_database_table(user=user, database=database)
other_table = data_fixture.create_database_table(user=user, database=database)
target_field = target_field_factory(data_fixture, other_table, user)
for helper_field_factory in helper_fields_other_table:
helper_field_factory(data_fixture, table=other_table, user=user)
link_row_field = data_fixture.create_link_row_field(
name="link", table=table, link_row_table=other_table
)
@ -160,6 +165,8 @@ def setup_linked_table_and_lookup(
target_field_name=target_field.name,
setup_dependencies=False,
)
for helper_field_factory in helper_fields_table:
helper_field_factory(data_fixture, table=table, user=user)
grid_view = data_fixture.create_grid_view(table=table)
view_handler = ViewHandler()
row_handler = RowHandler()

View file

@ -1629,12 +1629,17 @@ def test_has_value_length_is_lower_than_uuid_field_types(data_fixture):
(
"has_all_values_equal",
"",
[BooleanLookupRow.ALL_FALSE],
[
BooleanLookupRow.ALL_TRUE,
BooleanLookupRow.ALL_FALSE,
BooleanLookupRow.NO_VALUES,
BooleanLookupRow.MIXED,
],
),
(
"has_all_values_equal",
"invalid",
[BooleanLookupRow.ALL_FALSE],
[],
),
],
)
@ -1683,12 +1688,17 @@ def test_has_all_values_equal_filter_boolean_lookup_field_type(
(
"has_value_equal",
"",
[BooleanLookupRow.MIXED, BooleanLookupRow.ALL_FALSE],
[
BooleanLookupRow.MIXED,
BooleanLookupRow.ALL_FALSE,
BooleanLookupRow.NO_VALUES,
BooleanLookupRow.ALL_TRUE,
],
),
(
"has_value_equal",
"invalid",
[BooleanLookupRow.MIXED, BooleanLookupRow.ALL_FALSE],
[],
),
],
)
@ -1737,12 +1747,23 @@ def test_has_value_equal_filter_boolean_lookup_field_type(
(
"has_not_value_equal",
"",
[BooleanLookupRow.ALL_TRUE, BooleanLookupRow.NO_VALUES],
[
BooleanLookupRow.MIXED,
BooleanLookupRow.ALL_FALSE,
BooleanLookupRow.NO_VALUES,
BooleanLookupRow.ALL_TRUE,
],
),
(
"has_not_value_equal",
"invalid",
[BooleanLookupRow.ALL_TRUE, BooleanLookupRow.NO_VALUES],
# inverse of has_value_equal with `invalid` value
[
BooleanLookupRow.ALL_TRUE,
BooleanLookupRow.ALL_FALSE,
BooleanLookupRow.NO_VALUES,
BooleanLookupRow.MIXED,
],
),
],
)

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Number lookup field filters",
"issue_number": 3112,
"bullet_points": [],
"created_at": "2024-12-13"
}

View file

@ -5,6 +5,8 @@ import {
genericHasEmptyValueFilter,
genericHasValueLengthLowerThanFilter,
genericHasAllValuesEqualFilter,
numericHasValueComparableToFilterFunction,
ComparisonOperator,
} from '@baserow/modules/database/utils/fieldFilters'
export const hasEmptyValueFilterMixin = {
@ -24,6 +26,7 @@ export const hasAllValuesEqualFilterMixin = {
this.getHasAllValuesEqualFilterFunction(field)(cellValue, filterValue)
)
},
hasNotAllValuesEqualFilter(cellValue, filterValue, field) {
return (
filterValue === '' ||
@ -91,11 +94,107 @@ export const hasValueLengthIsLowerThanFilterMixin = {
},
}
export const formulaArrayFilterMixin = {
getSubType(field) {
return this.app.$registry.get('formula_type', field.array_formula_type)
export const hasNumericValueComparableToFilterMixin = {
// equal to
getHasValueEqualFilterFunction(field) {
return numericHasValueComparableToFilterFunction(ComparisonOperator.EQUAL)
},
hasValueEqualFilter(cellValue, filterValue, field) {
return (
filterValue === '' ||
this.getHasValueEqualFilterFunction(field)(cellValue, filterValue)
)
},
hasNotValueEqualFilter(cellValue, filterValue, field) {
return (
filterValue === '' ||
!this.getHasValueEqualFilterFunction(field)(cellValue, filterValue)
)
},
/**
* All other comparison operators: higher_than, lower_than, etc.
*/
hasValueComparableToFilter(
cellValue,
filterValue,
field,
comparisonOperator
) {
return numericHasValueComparableToFilterFunction(comparisonOperator)(
cellValue,
filterValue
)
},
}
/*
* Mixin for the FormulaField to handle the array formula filters for number fields.
*/
export const formulaFieldArrayFilterMixin = Object.assign(
{},
hasAllValuesEqualFilterMixin,
hasEmptyValueFilterMixin,
hasValueEqualFilterMixin,
hasValueContainsFilterMixin,
hasValueContainsWordFilterMixin,
hasValueLengthIsLowerThanFilterMixin,
{
getHasAllValuesEqualFilterFunction(field) {
return this.getFormulaType(field)?.getHasAllValuesEqualFilterFunction(
field
)
},
getHasEmptyValueFilterFunction(field) {
return this.getFormulaType(field)?.getHasEmptyValueFilterFunction(field)
},
getHasValueEqualFilterFunction(field) {
return this.getFormulaType(field)?.getHasValueEqualFilterFunction(field)
},
getHasValueContainsFilterFunction(field) {
return this.getFormulaType(field)?.getHasValueContainsFilterFunction(
field
)
},
getHasValueContainsWordFilterFunction(field) {
return this.getFormulaType(field)?.getHasValueContainsWordFilterFunction(
field
)
},
getHasValueLengthIsLowerThanFilterFunction(field) {
return this.getFormulaType(
field
)?.getHasValueLengthIsLowerThanFilterFunction(field)
},
hasValueComparableToFilter(
cellValue,
filterValue,
field,
comparisonOperator
) {
return this.getFormulaType(field)?.hasValueComparableToFilter(
cellValue,
filterValue,
field,
comparisonOperator
)
},
}
)
/*
* Mixin for the BaserowFormulaArrayType to proxy all the array filters to the
* correct sub type.
*/
export const baserowFormulaArrayTypeFilterMixin = {
getHasEmptyValueFilterFunction(field) {
const subType = this.getSubType(field)
return subType.getHasEmptyValueFilterFunction(field)
@ -144,6 +243,20 @@ export const formulaArrayFilterMixin = {
getHasAllValuesEqualFilterFunction(field) {
return this.getSubType(field)?.getHasAllValuesEqualFilterFunction(field)
},
hasValueComparableToFilter(
cellValue,
filterValue,
field,
comparisonOperator
) {
return this.getSubType(field)?.hasValueComparableToFilter(
cellValue,
filterValue,
field,
comparisonOperator
)
},
}
export const hasSelectOptionIdEqualMixin = Object.assign(

View file

@ -4,20 +4,8 @@ import { FormulaFieldType } from '@baserow/modules/database/fieldTypes'
import { ViewFilterType } from '@baserow/modules/database/viewFilters'
import viewFilterTypeText from '@baserow/modules/database/components/view/ViewFilterTypeText.vue'
import ViewFilterTypeMultipleSelectOptions from '@baserow/modules/database/components/view/ViewFilterTypeMultipleSelectOptions'
import _ from 'lodash'
/**
* This function normalizes boolean values that may be used internally in the filter
* to values that can be transferred to the backend.
* @param value
* @returns {string|*}
*/
const normalizeBooleanForFilters = (value) => {
if (!_.isBoolean(value)) {
return value
}
return value ? '1' : '0'
}
import { BaserowFormulaNumberType } from '@baserow/modules/database/formula/formulaTypes'
import { ComparisonOperator } from '@baserow/modules/database//utils/fieldFilters'
export class HasEmptyValueViewFilterType extends ViewFilterType {
static getType() {
@ -35,6 +23,7 @@ export class HasEmptyValueViewFilterType extends ViewFilterType {
FormulaFieldType.compatibleWithFormulaTypes('array(char)'),
FormulaFieldType.compatibleWithFormulaTypes('array(url)'),
FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'),
FormulaFieldType.compatibleWithFormulaTypes('array(number)'),
]
}
@ -59,6 +48,7 @@ export class HasNotEmptyValueViewFilterType extends ViewFilterType {
FormulaFieldType.compatibleWithFormulaTypes('array(char)'),
FormulaFieldType.compatibleWithFormulaTypes('array(url)'),
FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'),
FormulaFieldType.compatibleWithFormulaTypes('array(number)'),
]
}
@ -77,22 +67,11 @@ export class HasValueEqualViewFilterType extends ViewFilterType {
return i18n.t('viewFilter.hasValueEqual')
}
getDefaultValue(field) {
// has_value_equal filter by default sends an empty string. For consistency
// a default value should be in pair with a default value from the input component.
return this.prepareValue('', field)
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.parseInputValue(field, filterValue)
filterValue = fieldType.prepareFilterValue(field, filterValue)
return fieldType.hasValueEqualFilter(cellValue, filterValue, field)
}
prepareValue(value, field) {
const fieldType = this.app.$registry.get('field', field.type)
return normalizeBooleanForFilters(fieldType.parseInputValue(field, value))
}
getInputComponent(field) {
const fieldType = this.app.$registry.get('field', field.type)
return fieldType.getFilterInputComponent(field, this) || viewFilterTypeText
@ -105,7 +84,8 @@ export class HasValueEqualViewFilterType extends ViewFilterType {
FormulaFieldType.arrayOf('char'),
FormulaFieldType.arrayOf('url'),
FormulaFieldType.arrayOf('boolean'),
FormulaFieldType.arrayOf('single_select')
FormulaFieldType.arrayOf('single_select'),
FormulaFieldType.arrayOf('number')
),
]
}
@ -122,21 +102,10 @@ export class HasNotValueEqualViewFilterType extends ViewFilterType {
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.parseInputValue(field, filterValue)
filterValue = fieldType.prepareFilterValue(field, filterValue)
return fieldType.hasNotValueEqualFilter(cellValue, filterValue, field)
}
getDefaultValue(field) {
// has_not_value_equal filter by default sends an empty string. For consistency
// a default value should be in pair with a default value from the input component.
return this.prepareValue('', field)
}
prepareValue(value, field) {
const fieldType = this.app.$registry.get('field', field.type)
return normalizeBooleanForFilters(fieldType.parseInputValue(field, value))
}
getInputComponent(field) {
const fieldType = this.app.$registry.get('field', field.type)
return fieldType.getFilterInputComponent(field, this) || viewFilterTypeText
@ -149,7 +118,8 @@ export class HasNotValueEqualViewFilterType extends ViewFilterType {
FormulaFieldType.arrayOf('char'),
FormulaFieldType.arrayOf('url'),
FormulaFieldType.arrayOf('boolean'),
FormulaFieldType.arrayOf('single_select')
FormulaFieldType.arrayOf('single_select'),
FormulaFieldType.arrayOf('number')
),
]
}
@ -171,10 +141,13 @@ export class HasValueContainsViewFilterType extends ViewFilterType {
getCompatibleFieldTypes() {
return [
FormulaFieldType.compatibleWithFormulaTypes('array(text)'),
FormulaFieldType.compatibleWithFormulaTypes('array(char)'),
FormulaFieldType.compatibleWithFormulaTypes('array(url)'),
FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'),
FormulaFieldType.compatibleWithFormulaTypes(
FormulaFieldType.arrayOf('char'),
FormulaFieldType.arrayOf('text'),
FormulaFieldType.arrayOf('url'),
FormulaFieldType.arrayOf('single_select'),
FormulaFieldType.arrayOf('number')
),
]
}
@ -199,10 +172,13 @@ export class HasNotValueContainsViewFilterType extends ViewFilterType {
getCompatibleFieldTypes() {
return [
FormulaFieldType.compatibleWithFormulaTypes('array(text)'),
FormulaFieldType.compatibleWithFormulaTypes('array(char)'),
FormulaFieldType.compatibleWithFormulaTypes('array(url)'),
FormulaFieldType.compatibleWithFormulaTypes('array(single_select)'),
FormulaFieldType.compatibleWithFormulaTypes(
FormulaFieldType.arrayOf('char'),
FormulaFieldType.arrayOf('text'),
FormulaFieldType.arrayOf('url'),
FormulaFieldType.arrayOf('single_select'),
FormulaFieldType.arrayOf('number')
),
]
}
@ -321,7 +297,7 @@ export class HasAllValuesEqualViewFilterType extends ViewFilterType {
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.parseInputValue(field, filterValue)
filterValue = fieldType.prepareFilterValue(field, filterValue)
return fieldType.hasAllValuesEqualFilter(cellValue, filterValue, field)
}
}
@ -371,3 +347,243 @@ export class HasNoneSelectOptionEqualViewFilterType extends ViewFilterType {
return fieldType.hasNotValueEqualFilter(cellValue, filterValue, field)
}
}
export class HasValueHigherThanViewFilterType extends ViewFilterType {
static getType() {
return 'has_value_higher'
}
getName() {
const { i18n } = this.app
return i18n.t('viewFilter.hasValueHigherThan')
}
getInputComponent(field) {
return ViewFilterTypeNumber
}
getCompatibleFieldTypes() {
return [
FormulaFieldType.compatibleWithFormulaTypes(
FormulaFieldType.arrayOf(BaserowFormulaNumberType.getType())
),
]
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
return (
filterValue === '' ||
fieldType.hasValueComparableToFilter(
cellValue,
filterValue,
field,
ComparisonOperator.HIGHER_THAN
)
)
}
}
export class HasNotValueHigherThanViewFilterType extends HasValueHigherThanViewFilterType {
static getType() {
return 'has_not_value_higher'
}
getName() {
const { i18n } = this.app
return i18n.t('viewFilter.hasNotValueHigherThan')
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
return (
filterValue === '' ||
!fieldType.hasValueComparableToFilter(
cellValue,
filterValue,
field,
ComparisonOperator.HIGHER_THAN
)
)
}
}
export class HasValueHigherThanOrEqualViewFilterType extends ViewFilterType {
static getType() {
return 'has_value_higher_or_equal'
}
getName() {
const { i18n } = this.app
return i18n.t('viewFilter.hasValueHigherThanOrEqual')
}
getInputComponent(field) {
return ViewFilterTypeNumber
}
getCompatibleFieldTypes() {
return [
FormulaFieldType.compatibleWithFormulaTypes(
FormulaFieldType.arrayOf(BaserowFormulaNumberType.getType())
),
]
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
return (
filterValue === '' ||
fieldType.hasValueComparableToFilter(
cellValue,
filterValue,
field,
ComparisonOperator.HIGHER_THAN_OR_EQUAL
)
)
}
}
export class HasNotValueHigherThanOrEqualViewFilterType extends HasValueHigherThanOrEqualViewFilterType {
static getType() {
return 'has_not_value_higher_or_equal'
}
getName() {
const { i18n } = this.app
return i18n.t('viewFilter.hasNotValueHigherThanOrEqual')
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
return (
filterValue === '' ||
!fieldType.hasValueComparableToFilter(
cellValue,
filterValue,
field,
ComparisonOperator.HIGHER_THAN_OR_EQUAL
)
)
}
}
export class HasValueLowerThanViewFilterType extends ViewFilterType {
static getType() {
return 'has_value_lower'
}
getName() {
const { i18n } = this.app
return i18n.t('viewFilter.hasValueLowerThan')
}
getInputComponent(field) {
return ViewFilterTypeNumber
}
getCompatibleFieldTypes() {
return [
FormulaFieldType.compatibleWithFormulaTypes(
FormulaFieldType.arrayOf(BaserowFormulaNumberType.getType())
),
]
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
return (
filterValue === '' ||
fieldType.hasValueComparableToFilter(
cellValue,
filterValue,
field,
ComparisonOperator.LOWER_THAN
)
)
}
}
export class HasNotValueLowerThanViewFilterType extends HasValueLowerThanViewFilterType {
static getType() {
return 'has_not_value_lower'
}
getName() {
const { i18n } = this.app
return i18n.t('viewFilter.hasNotValueLowerThan')
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
return (
filterValue === '' ||
!fieldType.hasValueComparableToFilter(
cellValue,
filterValue,
field,
ComparisonOperator.LOWER_THAN
)
)
}
}
export class HasValueLowerThanOrEqualViewFilterType extends ViewFilterType {
static getType() {
return 'has_value_lower_or_equal'
}
getName() {
const { i18n } = this.app
return i18n.t('viewFilter.hasValueLowerThanOrEqual')
}
getInputComponent(field) {
return ViewFilterTypeNumber
}
getCompatibleFieldTypes() {
return [
FormulaFieldType.compatibleWithFormulaTypes(
FormulaFieldType.arrayOf(BaserowFormulaNumberType.getType())
),
]
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
return (
filterValue === '' ||
fieldType.hasValueComparableToFilter(
cellValue,
filterValue,
field,
ComparisonOperator.LOWER_THAN_OR_EQUAL
)
)
}
}
export class HasNotValueLowerThanOrEqualViewFilterType extends HasValueLowerThanOrEqualViewFilterType {
static getType() {
return 'has_not_value_lower_or_equal'
}
getName() {
const { i18n } = this.app
return i18n.t('viewFilter.hasNotValueLowerThanOrEqual')
}
matches(cellValue, filterValue, field, fieldType) {
filterValue = fieldType.prepareFilterValue(field, filterValue)
return (
filterValue === '' ||
!fieldType.hasValueComparableToFilter(
cellValue,
filterValue,
field,
ComparisonOperator.LOWER_THAN_OR_EQUAL
)
)
}
}

View file

@ -20,6 +20,12 @@ import numberField from '@baserow/modules/database/mixins/numberField'
export default {
name: 'ViewFilterTypeNumber',
mixins: [filterTypeInput, numberField],
data() {
return {
// Avoid rounding decimals to ensure filter values match backend behavior.
roundDecimals: false,
}
},
watch: {
field: {
handler() {

View file

@ -15,14 +15,7 @@ import {
isValidEmail,
isValidURL,
} from '@baserow/modules/core/utils/string'
import {
hasValueContainsFilterMixin,
hasValueEqualFilterMixin,
hasValueContainsWordFilterMixin,
hasValueLengthIsLowerThanFilterMixin,
hasEmptyValueFilterMixin,
hasAllValuesEqualFilterMixin,
} from '@baserow/modules/database/arrayFilterMixins'
import { formulaFieldArrayFilterMixin } from '@baserow/modules/database/arrayFilterMixins'
import {
parseNumberValue,
formatNumberValue,
@ -709,10 +702,25 @@ export class FieldType extends Registerable {
)
}
/**
* Returns optionally input component for a field / filter type combination.
* This is called by FilterType to get the component. FilterType should provide
* a default if FieldType returns null.
*
* @returns {null}
*/
getFilterInputComponent(field, filterType) {
return null
}
/**
* 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) {
return filterValue
}
/**
* 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
@ -1643,6 +1651,11 @@ export class NumberFieldType extends FieldType {
}
return new BigNumber(value)
}
prepareFilterValue(field, value) {
const res = parseNumberValue(field, String(value ?? ''), false)
return res === null || res.isNaN() ? '' : res.toString()
}
}
BigNumber.config({ EXPONENTIAL_AT: NumberFieldType.getMaxNumberLength() })
@ -1914,6 +1927,10 @@ export class BooleanFieldType extends FieldType {
getHasNotValueEqualFilterFunction(field) {
return this.getHasValueEqualFilterFunction(field, true)
}
prepareFilterValue(field, value) {
return this.parseInputValue(field, String(value ?? ''))
}
}
class BaseDateFieldType extends FieldType {
@ -3745,12 +3762,7 @@ export class PhoneNumberFieldType extends FieldType {
}
export class FormulaFieldType extends mix(
hasAllValuesEqualFilterMixin,
hasEmptyValueFilterMixin,
hasValueEqualFilterMixin,
hasValueContainsFilterMixin,
hasValueContainsWordFilterMixin,
hasValueLengthIsLowerThanFilterMixin,
formulaFieldArrayFilterMixin,
FieldType
) {
static getType() {
@ -3792,7 +3804,11 @@ export class FormulaFieldType extends mix(
return i18n.t('fieldType.formula')
}
getFormulaSubtype(field) {
prepareFilterValue(field, value) {
return this.getFormulaType(field)?.prepareFilterValue(field, value)
}
getFormulaType(field) {
return this.app.$registry.get('formula_type', field.formula_type)
}
@ -3813,7 +3829,7 @@ export class FormulaFieldType extends mix(
}
getFilterInputComponent(field, filterType) {
return this.getFormulaSubtype(field)?.getFilterInputComponent(
return this.getFormulaType(field)?.getFilterInputComponent(
field,
filterType
)
@ -3824,15 +3840,15 @@ export class FormulaFieldType extends mix(
}
getCardValueHeight(field) {
return this.getFormulaSubtype(field)?.getCardComponent().height || 0
return this.getFormulaType(field)?.getCardComponent().height || 0
}
getCanSortInView(field) {
return this.getFormulaSubtype(field)?.getCanSortInView(field)
return this.getFormulaType(field)?.getCanSortInView(field)
}
getSort(name, order, field) {
return this.getFormulaSubtype(field)?.getSort(name, order, field)
return this.getFormulaType(field)?.getSort(name, order, field)
}
getEmptyValue(field) {
@ -3840,7 +3856,7 @@ export class FormulaFieldType extends mix(
}
getDocsDataType(field) {
return this.getFormulaSubtype(field)?.getDocsDataType(field)
return this.getFormulaType(field)?.getDocsDataType(field)
}
getDocsDescription(field) {
@ -3852,11 +3868,11 @@ export class FormulaFieldType extends mix(
}
getDocsResponseExample(field) {
return this.getFormulaSubtype(field)?.getDocsResponseExample(field)
return this.getFormulaType(field)?.getDocsResponseExample(field)
}
prepareValueForCopy(field, value) {
return this.getFormulaSubtype(field)?.prepareValueForCopy(field, value)
return this.getFormulaType(field)?.prepareValueForCopy(field, value)
}
getContainsFilterFunction(field) {
@ -3876,11 +3892,11 @@ export class FormulaFieldType extends mix(
}
toHumanReadableString(field, value) {
return this.getFormulaSubtype(field)?.toHumanReadableString(field, value)
return this.getFormulaType(field)?.toHumanReadableString(field, value)
}
getSortIndicator(field) {
return this.getFormulaSubtype(field)?.getSortIndicator(field)
return this.getFormulaType(field)?.getSortIndicator(field)
}
getFormComponent() {
@ -3912,57 +3928,25 @@ export class FormulaFieldType extends mix(
}
canRepresentDate(field) {
return this.getFormulaSubtype(field)?.canRepresentDate(field)
return this.getFormulaType(field)?.canRepresentDate(field)
}
getCanGroupByInView(field) {
return this.getFormulaSubtype(field)?.canGroupByInView(field)
return this.getFormulaType(field)?.canGroupByInView(field)
}
parseInputValue(field, value) {
const underlyingFieldType = this.getFormulaSubtype(field)
const underlyingFieldType = this.getFormulaType(field)
return underlyingFieldType.parseInputValue(field, value)
}
parseFromLinkedRowItemValue(field, value) {
const underlyingFieldType = this.getFormulaSubtype(field)
const underlyingFieldType = this.getFormulaType(field)
return underlyingFieldType.parseFromLinkedRowItemValue(field, value)
}
canRepresentFiles(field) {
return this.getFormulaSubtype(field)?.canRepresentFiles(field)
}
getHasAllValuesEqualFilterFunction(field) {
return this.getFormulaSubtype(field)?.getHasAllValuesEqualFilterFunction(
field
)
}
getHasEmptyValueFilterFunction(field) {
return this.getFormulaSubtype(field)?.getHasEmptyValueFilterFunction(field)
}
getHasValueEqualFilterFunction(field) {
return this.getFormulaSubtype(field)?.getHasValueEqualFilterFunction(field)
}
getHasValueContainsFilterFunction(field) {
return this.getFormulaSubtype(field)?.getHasValueContainsFilterFunction(
field
)
}
getHasValueContainsWordFilterFunction(field) {
return this.getFormulaSubtype(field)?.getHasValueContainsWordFilterFunction(
field
)
}
getHasValueLengthIsLowerThanFilterFunction(field) {
return this.getFormulaSubtype(
field
)?.getHasValueLengthIsLowerThanFilterFunction(field)
return this.getFormulaType(field)?.canRepresentFiles(field)
}
}

View file

@ -57,7 +57,8 @@ import {
hasSelectOptionIdEqualMixin,
hasSelectOptionValueContainsFilterMixin,
hasSelectOptionValueContainsWordFilterMixin,
formulaArrayFilterMixin,
baserowFormulaArrayTypeFilterMixin,
hasNumericValueComparableToFilterMixin,
} from '@baserow/modules/database/arrayFilterMixins'
import _ from 'lodash'
import ViewFilterTypeBoolean from '@baserow/modules/database/components/view/ViewFilterTypeBoolean.vue'
@ -81,18 +82,22 @@ export class BaserowFormulaTypeDefinition extends Registerable {
)
}
/**
* Returns optionally input component for a field / filter type combination
* @returns {null}
*/
getFilterInputComponent(field, filterType) {
return null
return this.app.$registry
.get('field', this.getFieldType())
.getFilterInputComponent(field, filterType)
}
getRowEditArrayFieldComponent() {
return null
}
prepareFilterValue(field, value) {
return this.app.$registry
.get('field', this.getFieldType())
.prepareFilterValue(field, value)
}
getFunctionalGridViewFieldComponent() {
return this.app.$registry
.get('field', this.getFieldType())
@ -305,7 +310,13 @@ export class BaserowFormulaCharType extends mix(
}
}
export class BaserowFormulaNumberType extends BaserowFormulaTypeDefinition {
export class BaserowFormulaNumberType extends mix(
hasEmptyValueFilterMixin,
hasValueContainsFilterMixin,
hasNumericValueComparableToFilterMixin,
BaserowFormulaTypeDefinition
) {
static getType() {
return 'number'
}
@ -582,7 +593,7 @@ export class BaserowFormulaInvalidType extends BaserowFormulaTypeDefinition {
}
export class BaserowFormulaArrayType extends mix(
formulaArrayFilterMixin,
baserowFormulaArrayTypeFilterMixin,
BaserowFormulaTypeDefinition
) {
static getType() {
@ -605,6 +616,14 @@ export class BaserowFormulaArrayType extends mix(
return RowCardFieldArray
}
prepareFilterValue(field, value) {
return this.getSubType(field)?.prepareFilterValue(field, value)
}
getSubType(field) {
return this.app.$registry.get('formula_type', field.array_formula_type)
}
getRowEditFieldComponent(field) {
const arrayOverride =
this.getSubType(field)?.getRowEditArrayFieldComponent()

View file

@ -580,7 +580,15 @@
},
"viewFilter": {
"filter": "Filter | 1 Filter | {count} Filters",
"hasAllValuesEqual": "has all values equal"
"hasAllValuesEqual": "has all values equal",
"hasValueHigherThan": "has value higher than",
"hasValueHigherThanOrEqual": "has value higher than or equal",
"hasValueLowerThan": "has value lower than",
"hasValueLowerThanOrEqual": "has value lower than or equal",
"hasNotValueHigherThan": "doesn't have value higher than",
"hasNotValueHigherThanOrEqual": "doesn't have value higher than or equal",
"hasNotValueLowerThan": "doesn't have value lower than",
"hasNotValueLowerThanOrEqual": "doesn't have value lower than or equal"
},
"viewContext": {
"exportView": "Export view",

View file

@ -16,6 +16,7 @@ export default {
return {
formattedValue: '',
focused: false,
roundDecimals: true,
}
},
methods: {
@ -23,7 +24,7 @@ export default {
this.formattedValue = this.formatNumberValue(field, value)
},
formatNumberValue(field, value) {
return formatNumberValue(field, value)
return formatNumberValue(field, value, true, this.roundDecimals)
},
/*
* This method is similar to formatNumberValue, but it returns the value as a
@ -33,10 +34,15 @@ export default {
*/
formatNumberValueForEdit(field, value) {
const withThousandSeparator = false
return formatNumberValue(field, value, withThousandSeparator)
return formatNumberValue(
field,
value,
withThousandSeparator,
this.roundDecimals
)
},
parseNumberValue(field, value) {
return parseNumberValue(field, value)
return parseNumberValue(field, value, this.roundDecimals)
},
getNumberFormatOptions(field) {
return getNumberFormatOptions(field)

View file

@ -108,6 +108,14 @@ import {
HasAllValuesEqualViewFilterType,
HasAnySelectOptionEqualViewFilterType,
HasNoneSelectOptionEqualViewFilterType,
HasValueLowerThanViewFilterType,
HasValueLowerThanOrEqualViewFilterType,
HasValueHigherThanViewFilterType,
HasValueHigherThanOrEqualViewFilterType,
HasNotValueLowerThanOrEqualViewFilterType,
HasNotValueLowerThanViewFilterType,
HasNotValueHigherThanOrEqualViewFilterType,
HasNotValueHigherThanViewFilterType,
} from '@baserow/modules/database/arrayViewFilters'
import {
CSVImporterType,
@ -581,6 +589,40 @@ export default (context) => {
app.$registry.register('viewFilter', new NotEmptyViewFilterType(context))
app.$registry.register('viewFilter', new UserIsFilterType(context))
app.$registry.register('viewFilter', new UserIsNotFilterType(context))
app.$registry.register(
'viewFilter',
new HasValueHigherThanViewFilterType(context)
)
app.$registry.register(
'viewFilter',
new HasNotValueHigherThanViewFilterType(context)
)
app.$registry.register(
'viewFilter',
new HasValueHigherThanOrEqualViewFilterType(context)
)
app.$registry.register(
'viewFilter',
new HasNotValueHigherThanOrEqualViewFilterType(context)
)
app.$registry.register(
'viewFilter',
new HasValueLowerThanViewFilterType(context)
)
app.$registry.register(
'viewFilter',
new HasNotValueLowerThanViewFilterType(context)
)
app.$registry.register(
'viewFilter',
new HasValueLowerThanOrEqualViewFilterType(context)
)
app.$registry.register(
'viewFilter',
new HasNotValueLowerThanOrEqualViewFilterType(context)
)
app.$registry.register(
'viewOwnershipType',

View file

@ -4,6 +4,7 @@
// list of file names, we don't want the filterValue to accidentally match the end
// of one filename and the start of another.
import _ from 'lodash'
import BigNumber from 'bignumber.js'
export function filenameContainsFilter(
rowValue,
@ -31,8 +32,8 @@ export function genericContainsFilter(
if (humanReadableRowValue == null) {
return false
}
humanReadableRowValue = humanReadableRowValue.toString().toLowerCase().trim()
filterValue = filterValue.toString().toLowerCase().trim()
humanReadableRowValue = String(humanReadableRowValue).toLowerCase().trim()
filterValue = String(filterValue).toLowerCase().trim()
return humanReadableRowValue.includes(filterValue)
}
@ -45,8 +46,8 @@ export function genericContainsWordFilter(
if (humanReadableRowValue == null) {
return false
}
humanReadableRowValue = humanReadableRowValue.toString().toLowerCase().trim()
filterValue = filterValue.toString().toLowerCase().trim()
humanReadableRowValue = String(humanReadableRowValue).toLowerCase().trim()
filterValue = String(filterValue).toLowerCase().trim()
// check using regex to match whole words
// make sure to escape the filterValue as it may contain regex special characters
filterValue = filterValue.replace(/[-[\]{}()*+?.,\\^$|#]/g, '\\$&')
@ -100,10 +101,10 @@ export function genericHasValueContainsFilter(cellValue, filterValue) {
return false
}
filterValue = filterValue.toString().toLowerCase().trim()
filterValue = String(filterValue).toLowerCase().trim()
for (let i = 0; i < cellValue.length; i++) {
const value = cellValue[i].value.toString().toLowerCase().trim()
const value = String(cellValue[i].value).toLowerCase().trim()
if (value.includes(filterValue)) {
return true
@ -118,14 +119,14 @@ export function genericHasValueContainsWordFilter(cellValue, filterValue) {
return false
}
filterValue = filterValue.toString().toLowerCase().trim()
filterValue = String(filterValue).toLowerCase().trim()
filterValue = filterValue.replace(/[-[\]{}()*+?.,\\^$|#]/g, '\\$&')
for (let i = 0; i < cellValue.length; i++) {
if (cellValue[i].value == null) {
continue
}
const value = cellValue[i].value.toString().toLowerCase().trim()
const value = String(cellValue[i].value).toLowerCase().trim()
if (value.match(new RegExp(`\\b${filterValue}\\b`))) {
return true
}
@ -143,7 +144,7 @@ export function genericHasValueLengthLowerThanFilter(cellValue, filterValue) {
if (cellValue[i].value == null) {
continue
}
const valueLength = cellValue[i].value.toString().length
const valueLength = String(cellValue[i].value).length
if (valueLength < filterValue) {
return true
}
@ -151,3 +152,53 @@ export function genericHasValueLengthLowerThanFilter(cellValue, filterValue) {
return false
}
export const ComparisonOperator = {
EQUAL: '=',
HIGHER_THAN: '>',
HIGHER_THAN_OR_EQUAL: '>=',
LOWER_THAN: '<',
LOWER_THAN_OR_EQUAL: '<=',
}
function doNumericArrayComparison(cellValue, filterValue, compareFunc) {
const filterNr = new BigNumber(filterValue)
if (!Array.isArray(cellValue) || filterNr.isNaN()) {
return false
}
return _.some(_.map(cellValue, 'value'), (item) =>
compareFunc(new BigNumber(item), filterNr)
)
}
export function numericHasValueComparableToFilterFunction(comparisonOp) {
return (cellValue, filterValue) => {
let compareFunc
switch (comparisonOp) {
case ComparisonOperator.EQUAL:
compareFunc = (a, b) => a.isEqualTo(b)
break
case ComparisonOperator.HIGHER_THAN:
compareFunc = (a, b) => a.isGreaterThan(b)
break
case ComparisonOperator.HIGHER_THAN_OR_EQUAL:
compareFunc = (a, b) => a.isGreaterThanOrEqualTo(b)
break
case ComparisonOperator.LOWER_THAN:
compareFunc = (a, b) => a.isLessThan(b)
break
case ComparisonOperator.LOWER_THAN_OR_EQUAL:
compareFunc = (a, b) => a.isLessThanOrEqualTo(b)
break
}
if (compareFunc === undefined) {
throw new Error('Invalid comparison operator')
}
return doNumericArrayComparison(
cellValue,
filterValue,
(arrayItemNr, filterNr) => compareFunc(arrayItemNr, filterNr)
)
}
}

View file

@ -12,6 +12,8 @@ const DECIMAL_SEPARATORS = {
PERIOD: '.',
}
const NUMBER_MAX_DECIMAL_PLACES = 10
const DEFAULT_THOUSAND_SEPARATOR = THOUSAND_SEPARATORS.NONE
const DEFAULT_DECIMAL_SEPARATOR = DECIMAL_SEPARATORS.PERIOD
@ -57,7 +59,7 @@ export const getNumberFormatOptions = (field) => {
const numberPrefix = field.number_prefix ?? ''
const numberSuffix = field.number_suffix ?? ''
const decimalPlaces = field.number_decimal_places ?? 0
const decimalPlaces = field.number_decimal_places ?? undefined
const allowNegative = field.number_negative ?? false
return {
@ -79,7 +81,8 @@ export const getNumberFormatOptions = (field) => {
export const formatNumberValue = (
field,
value,
withThousandSeparator = true
withThousandSeparator = true,
roundDecimals = true
) => {
if (value === null || value === undefined || value === '') {
return ''
@ -95,7 +98,9 @@ export const formatNumberValue = (
// Parse the input value if it's a string
let numericValue =
typeof value === 'string' ? parseNumberValue(field, value) : value
typeof value === 'string'
? parseNumberValue(field, value, roundDecimals)
: value
if (numericValue === null) {
return null
@ -116,9 +121,13 @@ export const formatNumberValue = (
locale = 'en-US'
localeThousandsSeparator = DECIMAL_SEPARATORS.COMMA
}
// Format the number, but keep all decimal places if roundDecimals is false.
// For example, filter values are not rounded since the backend doesn't round them.
const formatter = new Intl.NumberFormat(locale, {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
maximumFractionDigits: roundDecimals
? decimalPlaces
: NUMBER_MAX_DECIMAL_PLACES,
useGrouping: true,
})
let formatted = formatter.format(numericValue)
@ -139,18 +148,22 @@ export const formatNumberValue = (
return `${sign}${numberPrefix}${formatted}${numberSuffix}`.trim()
}
export const parseNumberValue = (field, value) => {
export const parseNumberValue = (field, value, roundDecimals = true) => {
const { numberPrefix, numberSuffix, decimalSeparator } =
getNumberFormatOptions(field)
if (value === null || value === undefined || value === '') {
if (value == null || value === '') {
return null
}
const toBigNumber = (val) => {
return new BigNumber(
new BigNumber(val).toFixed(field.number_decimal_places)
)
let rounded = val
if (roundDecimals) {
rounded = new BigNumber(val).decimalPlaces(
field.number_decimal_places ?? 0
)
}
return new BigNumber(rounded)
}
if (typeof value === 'number' || BigNumber.isBigNumber(value)) {
@ -192,5 +205,5 @@ export const parseNumberValue = (field, value) => {
}
const parsedNumber = toBigNumber(result)
return isNaN(parsedNumber) ? null : isNegative ? -parsedNumber : parsedNumber
return parsedNumber.isNaN() ? null : isNegative ? -parsedNumber : parsedNumber
}

View file

@ -10,8 +10,15 @@ import {
HasNotEmptyValueViewFilterType,
HasValueLengthIsLowerThanViewFilterType,
HasAllValuesEqualViewFilterType,
HasValueHigherThanViewFilterType,
HasValueHigherThanOrEqualViewFilterType,
HasNotValueHigherThanViewFilterType,
HasNotValueHigherThanOrEqualViewFilterType,
} from '@baserow/modules/database/arrayViewFilters'
import { FormulaFieldType } from '@baserow/modules/database/fieldTypes'
import {
FormulaFieldType,
LookupFieldType,
} from '@baserow/modules/database/fieldTypes'
import {
EmptyViewFilterType,
NotEmptyViewFilterType,
@ -998,3 +1005,344 @@ describe('Boolean-based array view filters', () => {
}
)
})
describe('Number-based array view filters', () => {
let testApp = null
let fieldType = null
const fieldDefinition = {
type: 'lookup',
formula_type: 'array',
array_formula_type: 'number',
}
beforeAll(() => {
testApp = new TestApp()
fieldType = new LookupFieldType({
app: testApp._app,
})
})
afterEach(() => {
testApp.afterEach()
})
const hasEmptyValueTestCases = [
{
cellValue: [],
filterValue: '',
expected: false,
},
{
cellValue: [{ value: null }],
filterValue: '',
expected: true,
},
{
cellValue: [{ value: '123.0' }, { value: null }],
filterValue: '',
expected: true,
},
{
cellValue: [{ value: '123.0' }, { value: '' }],
filterValue: '',
expected: true,
},
]
const hasValueContainsTestCases = [
{
cellValue: [],
filterValue: '',
expected: { has: true, hasNot: true },
},
{
cellValue: [],
filterValue: null,
expected: { has: false, hasNot: true },
},
{
cellValue: [{ value: null }],
filterValue: '',
expected: { has: true, hasNot: true },
},
{
cellValue: [{ value: '' }],
filterValue: '',
expected: { has: true, hasNot: true },
},
{
cellValue: [{ value: null }],
filterValue: null,
expected: { has: true, hasNot: false },
},
{
cellValue: [{ value: 123.0 }, { value: null }],
filterValue: '123',
expected: { has: true, hasNot: false },
},
{
cellValue: [{ value: '123.0' }, { value: null }],
filterValue: '123',
expected: { has: true, hasNot: false },
},
{
cellValue: [{ value: 123.0 }, { value: '' }],
filterValue: '100',
expected: { has: false, hasNot: true },
},
{
cellValue: [{ value: 1.11101 }],
filterValue: '10',
expected: { has: true, hasNot: false },
},
{
cellValue: [{ value: '1.11101' }],
filterValue: '10',
expected: { has: true, hasNot: false },
},
{
cellValue: [{ value: 10100.001 }],
filterValue: '20',
expected: { has: false, hasNot: true },
},
]
// has value higher/lower are implemented with a code path that will return
// true for empty filterValues. This requires per filter type result check
const hasValueHigherThanOrEqualTestCases = [
{
cellValue: [],
filterValue: '',
expected: {
higher: true,
higherEqual: true,
notHigher: true,
notHigherEqual: true,
},
},
{
cellValue: [],
filterValue: null,
expected: {
higher: true,
higherEqual: true,
notHigher: true,
notHigherEqual: true,
},
},
{
cellValue: [{ value: null }],
filterValue: '',
expected: {
higher: true,
higherEqual: true,
notHigher: true,
notHigherEqual: true,
},
},
{
cellValue: [{ value: '' }],
filterValue: '',
expected: {
higher: true,
higherEqual: true,
notHigher: true,
notHigherEqual: true,
},
},
{
cellValue: [{ value: null }],
filterValue: 10,
expected: {
higher: false,
higherEqual: false,
notHigher: true,
notHigherEqual: true,
},
},
{
cellValue: [{ value: null }],
filterValue: 10,
expected: {
higher: false,
higherEqual: false,
notHigher: true,
notHigherEqual: true,
},
},
{
cellValue: [{ value: 123.0 }, { value: null }],
filterValue: '123',
expected: {
higher: false,
higherEqual: true,
notHigher: true,
notHigherEqual: false,
},
},
{
cellValue: [{ value: '123.0' }, { value: null }],
filterValue: '123',
expected: {
higher: false,
higherEqual: true,
notHigher: true,
notHigherEqual: false,
},
},
{
cellValue: [{ value: 123.0 }],
filterValue: '123.0001',
expected: {
higher: false,
higherEqual: false,
notHigher: true,
notHigherEqual: true,
},
},
{
cellValue: [{ value: '123.0' }],
filterValue: '123.0001',
expected: {
higher: false,
higherEqual: false,
notHigher: true,
notHigherEqual: true,
},
},
{
cellValue: [{ value: 123.0 }, { value: 500 }],
filterValue: '123.0001',
expected: {
higher: true,
higherEqual: true,
notHigher: false,
notHigherEqual: false,
},
},
]
test.each(hasEmptyValueTestCases)(
'hasEmptyValueTestCases %j',
(testValues) => {
const result = new HasEmptyValueViewFilterType({
app: testApp._app,
}).matches(
testValues.cellValue,
testValues.filterValue,
fieldDefinition,
fieldType
)
expect(result).toBe(testValues.expected)
}
)
test.each(hasEmptyValueTestCases)(
'hasNotEmptyValueTestCases %j',
(testValues) => {
const result = new HasNotEmptyValueViewFilterType({
app: testApp._app,
}).matches(
testValues.cellValue,
testValues.filterValue,
fieldDefinition,
fieldType
)
expect(result).toBe(!testValues.expected)
}
)
test.each(hasValueContainsTestCases)(
'hasValueContainsTestCases %j',
(testValues) => {
const result = new HasValueContainsViewFilterType({
app: testApp._app,
}).matches(
testValues.cellValue,
testValues.filterValue,
fieldDefinition,
fieldType
)
expect(result).toBe(testValues.expected.has)
}
)
test.each(hasValueContainsTestCases)(
'hasNotValueContainsTestCases %j',
(testValues) => {
const result = new HasNotValueContainsViewFilterType({
app: testApp._app,
}).matches(
testValues.cellValue,
testValues.filterValue,
fieldDefinition,
fieldType
)
expect(result).toBe(testValues.expected.hasNot)
}
)
test.each(hasValueHigherThanOrEqualTestCases)(
'hasValueHigherTestCases %j',
(testValues) => {
const result = new HasValueHigherThanViewFilterType({
app: testApp._app,
}).matches(
testValues.cellValue,
testValues.filterValue,
fieldDefinition,
fieldType
)
expect(result).toBe(testValues.expected.higher)
}
)
test.each(hasValueHigherThanOrEqualTestCases)(
'hasValueHigherOrEqualTestCases %j',
(testValues) => {
const result = new HasValueHigherThanOrEqualViewFilterType({
app: testApp._app,
}).matches(
testValues.cellValue,
testValues.filterValue,
fieldDefinition,
fieldType
)
expect(result).toBe(testValues.expected.higherEqual)
}
)
test.each(hasValueHigherThanOrEqualTestCases)(
'hasNotValueHigherTestCases %j',
(testValues) => {
const result = new HasNotValueHigherThanViewFilterType({
app: testApp._app,
}).matches(
testValues.cellValue,
testValues.filterValue,
fieldDefinition,
fieldType
)
expect(result).toBe(testValues.expected.notHigher)
}
)
test.each(hasValueHigherThanOrEqualTestCases)(
'hasNotValueHigherOrEqualTestCases %j',
(testValues) => {
const result = new HasNotValueHigherThanOrEqualViewFilterType({
app: testApp._app,
}).matches(
testValues.cellValue,
testValues.filterValue,
fieldDefinition,
fieldType
)
expect(result).toBe(testValues.expected.notHigherEqual)
}
)
})