From 244d6050bd923544acfade353cc7e7f82b0e9703 Mon Sep 17 00:00:00 2001 From: Cezary Statkiewicz <cezary@baserow.io> Date: Thu, 2 Jan 2025 09:19:28 +0000 Subject: [PATCH] #3112 number lookup field filters --- backend/src/baserow/contrib/database/apps.py | 16 + .../contrib/database/fields/field_types.py | 35 +- .../database/fields/filter_support/base.py | 93 +-- .../database/fields/filter_support/formula.py | 44 +- .../fields/filter_support/single_select.py | 11 +- .../contrib/database/fields/registries.py | 24 + .../django_expressions.py | 99 +++- .../database/formula/types/filter_support.py | 91 +++ .../database/formula/types/formula_type.py | 11 + .../database/formula/types/formula_types.py | 127 ++-- .../database/views/array_view_filters.py | 263 ++++++-- .../contrib/database/views/view_filters.py | 46 +- .../field/test_number_lookup_field_filters.py | 561 ++++++++++++++++++ .../tests/baserow/contrib/database/utils.py | 9 +- .../database/view/test_view_array_filters.py | 33 +- .../3112_number_lookup_field_filters.json | 7 + .../modules/database/arrayFilterMixins.js | 119 +++- .../modules/database/arrayViewFilters.js | 314 ++++++++-- .../components/view/ViewFilterTypeNumber.vue | 6 + web-frontend/modules/database/fieldTypes.js | 106 ++-- .../modules/database/formula/formulaTypes.js | 35 +- web-frontend/modules/database/locales/en.json | 10 +- .../modules/database/mixins/numberField.js | 12 +- web-frontend/modules/database/plugin.js | 42 ++ .../modules/database/utils/fieldFilters.js | 69 ++- web-frontend/modules/database/utils/number.js | 33 +- .../database/arrayViewFiltersMatch.spec.js | 350 ++++++++++- 27 files changed, 2175 insertions(+), 391 deletions(-) create mode 100644 backend/src/baserow/contrib/database/formula/types/filter_support.py create mode 100644 backend/tests/baserow/contrib/database/field/test_number_lookup_field_filters.py create mode 100644 changelog/entries/unreleased/feature/3112_number_lookup_field_filters.json diff --git a/backend/src/baserow/contrib/database/apps.py b/backend/src/baserow/contrib/database/apps.py index c2329b112..29ce8c019 100755 --- a/backend/src/baserow/contrib/database/apps.py +++ b/backend/src/baserow/contrib/database/apps.py @@ -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, diff --git a/backend/src/baserow/contrib/database/fields/field_types.py b/backend/src/baserow/contrib/database/fields/field_types.py index 4e3487618..42f31ee4d 100755 --- a/backend/src/baserow/contrib/database/fields/field_types.py +++ b/backend/src/baserow/contrib/database/fields/field_types.py @@ -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" diff --git a/backend/src/baserow/contrib/database/fields/filter_support/base.py b/backend/src/baserow/contrib/database/fields/filter_support/base.py index 60807d307..8b00e94bd 100644 --- a/backend/src/baserow/contrib/database/fields/filter_support/base.py +++ b/backend/src/baserow/contrib/database/fields/filter_support/base.py @@ -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}, ) diff --git a/backend/src/baserow/contrib/database/fields/filter_support/formula.py b/backend/src/baserow/contrib/database/fields/filter_support/formula.py index 5fe6deb9d..b29598545 100644 --- a/backend/src/baserow/contrib/database/fields/filter_support/formula.py +++ b/backend/src/baserow/contrib/database/fields/filter_support/formula.py @@ -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 + ) diff --git a/backend/src/baserow/contrib/database/fields/filter_support/single_select.py b/backend/src/baserow/contrib/database/fields/filter_support/single_select.py index 0a158fd80..f08bc7759 100644 --- a/backend/src/baserow/contrib/database/fields/filter_support/single_select.py +++ b/backend/src/baserow/contrib/database/fields/filter_support/single_select.py @@ -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, diff --git a/backend/src/baserow/contrib/database/fields/registries.py b/backend/src/baserow/contrib/database/fields/registries.py index 52c00a0fa..25d9d744d 100644 --- a/backend/src/baserow/contrib/database/fields/registries.py +++ b/backend/src/baserow/contrib/database/fields/registries.py @@ -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 diff --git a/backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py b/backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py index 0e79f257c..0b4ba4cb5 100644 --- a/backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py +++ b/backend/src/baserow/contrib/database/formula/expression_generator/django_expressions.py @@ -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 diff --git a/backend/src/baserow/contrib/database/formula/types/filter_support.py b/backend/src/baserow/contrib/database/formula/types/filter_support.py new file mode 100644 index 000000000..815a36d30 --- /dev/null +++ b/backend/src/baserow/contrib/database/formula/types/filter_support.py @@ -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 + ) diff --git a/backend/src/baserow/contrib/database/formula/types/formula_type.py b/backend/src/baserow/contrib/database/formula/types/formula_type.py index 722fdde9c..6e6d2b0e7 100644 --- a/backend/src/baserow/contrib/database/formula/types/formula_type.py +++ b/backend/src/baserow/contrib/database/formula/types/formula_type.py @@ -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 diff --git a/backend/src/baserow/contrib/database/formula/types/formula_types.py b/backend/src/baserow/contrib/database/formula/types/formula_types.py index 31b6a3326..f0d136b8f 100644 --- a/backend/src/baserow/contrib/database/formula/types/formula_types.py +++ b/backend/src/baserow/contrib/database/formula/types/formula_types.py @@ -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, diff --git a/backend/src/baserow/contrib/database/views/array_view_filters.py b/backend/src/baserow/contrib/database/views/array_view_filters.py index b61548d0a..e6a47beab 100644 --- a/backend/src/baserow/contrib/database/views/array_view_filters.py +++ b/backend/src/baserow/contrib/database/views/array_view_filters.py @@ -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" diff --git a/backend/src/baserow/contrib/database/views/view_filters.py b/backend/src/baserow/contrib/database/views/view_filters.py index bb4058dbb..711dc31b6 100644 --- a/backend/src/baserow/contrib/database/views/view_filters.py +++ b/backend/src/baserow/contrib/database/views/view_filters.py @@ -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): diff --git a/backend/tests/baserow/contrib/database/field/test_number_lookup_field_filters.py b/backend/tests/baserow/contrib/database/field/test_number_lookup_field_filters.py new file mode 100644 index 000000000..6ecacffd0 --- /dev/null +++ b/backend/tests/baserow/contrib/database/field/test_number_lookup_field_filters.py @@ -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 + ) diff --git a/backend/tests/baserow/contrib/database/utils.py b/backend/tests/baserow/contrib/database/utils.py index 9c7155bc1..94cd60626 100644 --- a/backend/tests/baserow/contrib/database/utils.py +++ b/backend/tests/baserow/contrib/database/utils.py @@ -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() diff --git a/backend/tests/baserow/contrib/database/view/test_view_array_filters.py b/backend/tests/baserow/contrib/database/view/test_view_array_filters.py index 958b993bb..e89fd0a73 100644 --- a/backend/tests/baserow/contrib/database/view/test_view_array_filters.py +++ b/backend/tests/baserow/contrib/database/view/test_view_array_filters.py @@ -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, + ], ), ], ) diff --git a/changelog/entries/unreleased/feature/3112_number_lookup_field_filters.json b/changelog/entries/unreleased/feature/3112_number_lookup_field_filters.json new file mode 100644 index 000000000..53aa6058e --- /dev/null +++ b/changelog/entries/unreleased/feature/3112_number_lookup_field_filters.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "message": "Number lookup field filters", + "issue_number": 3112, + "bullet_points": [], + "created_at": "2024-12-13" +} \ No newline at end of file diff --git a/web-frontend/modules/database/arrayFilterMixins.js b/web-frontend/modules/database/arrayFilterMixins.js index 2f331db40..b83be66f4 100644 --- a/web-frontend/modules/database/arrayFilterMixins.js +++ b/web-frontend/modules/database/arrayFilterMixins.js @@ -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( diff --git a/web-frontend/modules/database/arrayViewFilters.js b/web-frontend/modules/database/arrayViewFilters.js index 444df7214..a0c34f213 100644 --- a/web-frontend/modules/database/arrayViewFilters.js +++ b/web-frontend/modules/database/arrayViewFilters.js @@ -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 + ) + ) + } +} diff --git a/web-frontend/modules/database/components/view/ViewFilterTypeNumber.vue b/web-frontend/modules/database/components/view/ViewFilterTypeNumber.vue index 7a5a57421..25985940c 100644 --- a/web-frontend/modules/database/components/view/ViewFilterTypeNumber.vue +++ b/web-frontend/modules/database/components/view/ViewFilterTypeNumber.vue @@ -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() { diff --git a/web-frontend/modules/database/fieldTypes.js b/web-frontend/modules/database/fieldTypes.js index 983e26ddf..4ee50bdcf 100644 --- a/web-frontend/modules/database/fieldTypes.js +++ b/web-frontend/modules/database/fieldTypes.js @@ -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) } } diff --git a/web-frontend/modules/database/formula/formulaTypes.js b/web-frontend/modules/database/formula/formulaTypes.js index 4f4f6338d..853db626b 100644 --- a/web-frontend/modules/database/formula/formulaTypes.js +++ b/web-frontend/modules/database/formula/formulaTypes.js @@ -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() diff --git a/web-frontend/modules/database/locales/en.json b/web-frontend/modules/database/locales/en.json index 39fa46773..7f1186bcb 100644 --- a/web-frontend/modules/database/locales/en.json +++ b/web-frontend/modules/database/locales/en.json @@ -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", diff --git a/web-frontend/modules/database/mixins/numberField.js b/web-frontend/modules/database/mixins/numberField.js index 6186b25b5..c2d0b8cf4 100644 --- a/web-frontend/modules/database/mixins/numberField.js +++ b/web-frontend/modules/database/mixins/numberField.js @@ -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) diff --git a/web-frontend/modules/database/plugin.js b/web-frontend/modules/database/plugin.js index e3a164ccc..9ab044ab7 100644 --- a/web-frontend/modules/database/plugin.js +++ b/web-frontend/modules/database/plugin.js @@ -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', diff --git a/web-frontend/modules/database/utils/fieldFilters.js b/web-frontend/modules/database/utils/fieldFilters.js index 7ddb24eb1..81256b280 100644 --- a/web-frontend/modules/database/utils/fieldFilters.js +++ b/web-frontend/modules/database/utils/fieldFilters.js @@ -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) + ) + } +} diff --git a/web-frontend/modules/database/utils/number.js b/web-frontend/modules/database/utils/number.js index cf8be9e6e..30990218b 100644 --- a/web-frontend/modules/database/utils/number.js +++ b/web-frontend/modules/database/utils/number.js @@ -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 } diff --git a/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js b/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js index 9f1aa61cf..c0d5cc14a 100644 --- a/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js +++ b/web-frontend/test/unit/database/arrayViewFiltersMatch.spec.js @@ -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) + } + ) +})