From 8c0d3bc373db9eadf5ed4fff29dddf2215ab2834 Mon Sep 17 00:00:00 2001 From: Tsering Paljor Date: Fri, 19 Dec 2025 15:33:54 +0400 Subject: [PATCH] Add more advanced formulas (#4297) * Add replace() * Add length() * Add contains() * Add reverse() * Add join() * Add split() * Add is_empty() * Add strip() * Add sum() * Add avg() * Add at() * Add changelog * Small fixes * Add tests for ensure_array() * Improve error handling for RuntimeLength * RuntimeContains should return None/null for the default case * Improve array/object/int check for RuntimeIsEmpty * Fix logic to only work on strings for RuntimeStrip * Lint fix * RuntimeSum backend validation shouldn't allow non-numbers. * RuntimeAvg backend validation shouldn't allow non-numbers. * Handle ValueError in literal array string * Lint fix backend * Fix frontend tests * Add more test cases * Refactor backend tests * Remove allowLiteralArray/allow_literal_array * Add new RuntimeToArray function and refactor existing functions that use Any type to avoid using ensurer. Update examples with usage of to_array. * Refactor frontend tests * Add RuntimeToArray in the backend + tests * Refactor backend functions/tests to avoid using ensurer * Improve frontend validation and tests * Fix examples * simplify * Remove local debugging code * Allow > to compare any type * Allow < to compare any type * Allow >= to compare any type * Allow <= to compare any type * lint fix * is_empty should raise an error for unsupported types. * Change arg type to text for strip * split() should return a list with the same arg if the string has no spaces and no delimited is provided. * Update avg() helper to allow a strict param * Fix toArray description * is_empty() should return false by default, and handle supported args correctly. * Refactor at() to bubble up error * Let join() bubble up errors * Allow reverse() to bubble up errors * Allow contains() to bubble up errors * Allow length() to bubble up errors * Update examples for comparison operators to include date examples --- backend/src/baserow/core/apps.py | 24 + .../baserow/core/formula/argument_types.py | 23 + .../baserow/core/formula/parser/exceptions.py | 4 + .../core/formula/runtime_formula_types.py | 162 +++- .../formula/test_runtime_formula_types.py | 711 +++++++++++++++++- .../4318_added_more_advanced_formulas.json | 9 + web-frontend/locales/en.json | 12 + web-frontend/modules/core/plugin.js | 24 + .../core/runtimeFormulaArgumentTypes.js | 16 + .../modules/core/runtimeFormulaTypes.js | 670 ++++++++++++++++- web-frontend/modules/core/utils/number.js | 30 + web-frontend/modules/core/utils/string.js | 10 + .../core/formula/runtimeFormulaTypes.spec.js | 431 +++++++++++ 13 files changed, 2109 insertions(+), 17 deletions(-) create mode 100644 changelog/entries/unreleased/feature/4318_added_more_advanced_formulas.json diff --git a/backend/src/baserow/core/apps.py b/backend/src/baserow/core/apps.py index 66ceb9e842..3eaca57c25 100755 --- a/backend/src/baserow/core/apps.py +++ b/backend/src/baserow/core/apps.py @@ -38,8 +38,11 @@ def ready(self): from baserow.core.formula.runtime_formula_types import ( RuntimeAdd, RuntimeAnd, + RuntimeAt, + RuntimeAvg, RuntimeCapitalize, RuntimeConcat, + RuntimeContains, RuntimeDateTimeFormat, RuntimeDay, RuntimeDivide, @@ -51,8 +54,11 @@ def ready(self): RuntimeGreaterThanOrEqual, RuntimeHour, RuntimeIf, + RuntimeIsEmpty, RuntimeIsEven, RuntimeIsOdd, + RuntimeJoin, + RuntimeLength, RuntimeLessThan, RuntimeLessThanOrEqual, RuntimeLower, @@ -66,8 +72,14 @@ def ready(self): RuntimeRandomBool, RuntimeRandomFloat, RuntimeRandomInt, + RuntimeReplace, + RuntimeReverse, RuntimeRound, RuntimeSecond, + RuntimeSplit, + RuntimeStrip, + RuntimeSum, + RuntimeToArray, RuntimeToday, RuntimeUpper, RuntimeYear, @@ -108,6 +120,18 @@ def ready(self): formula_runtime_function_registry.register(RuntimeIf()) formula_runtime_function_registry.register(RuntimeAnd()) formula_runtime_function_registry.register(RuntimeOr()) + formula_runtime_function_registry.register(RuntimeReplace()) + formula_runtime_function_registry.register(RuntimeLength()) + formula_runtime_function_registry.register(RuntimeContains()) + formula_runtime_function_registry.register(RuntimeReverse()) + formula_runtime_function_registry.register(RuntimeJoin()) + formula_runtime_function_registry.register(RuntimeSplit()) + formula_runtime_function_registry.register(RuntimeIsEmpty()) + formula_runtime_function_registry.register(RuntimeStrip()) + formula_runtime_function_registry.register(RuntimeSum()) + formula_runtime_function_registry.register(RuntimeAvg()) + formula_runtime_function_registry.register(RuntimeAt()) + formula_runtime_function_registry.register(RuntimeToArray()) from baserow.core.permission_manager import ( AllowIfTemplatePermissionManagerType, diff --git a/backend/src/baserow/core/formula/argument_types.py b/backend/src/baserow/core/formula/argument_types.py index f1efd6fb72..f2c42571ae 100644 --- a/backend/src/baserow/core/formula/argument_types.py +++ b/backend/src/baserow/core/formula/argument_types.py @@ -5,6 +5,7 @@ import pytz from baserow.core.formula.validator import ( + ensure_array, ensure_boolean, ensure_datetime, ensure_numeric, @@ -112,3 +113,25 @@ def test(self, value): def parse(self, value): return value + + +class ArrayOfNumbersBaserowRuntimeFormulaArgumentType( + BaserowRuntimeFormulaArgumentType +): + def test(self, value): + try: + value = ensure_array(value) + except ValidationError: + return False + + for item in value: + try: + ensure_numeric(item) + except ValidationError: + return False + + return True + + def parse(self, value): + value = ensure_array(value) + return [ensure_numeric(item) for item in value] diff --git a/backend/src/baserow/core/formula/parser/exceptions.py b/backend/src/baserow/core/formula/parser/exceptions.py index 7974958ae0..624edcfa9e 100644 --- a/backend/src/baserow/core/formula/parser/exceptions.py +++ b/backend/src/baserow/core/formula/parser/exceptions.py @@ -57,3 +57,7 @@ def __init__(self, operatorText): class BaserowFormulaSyntaxError(BaserowFormulaException): pass + + +class BaserowFormulaExecuteError(BaserowFormulaException): + pass diff --git a/backend/src/baserow/core/formula/runtime_formula_types.py b/backend/src/baserow/core/formula/runtime_formula_types.py index 033d7a44c4..85de1b5964 100644 --- a/backend/src/baserow/core/formula/runtime_formula_types.py +++ b/backend/src/baserow/core/formula/runtime_formula_types.py @@ -7,6 +7,7 @@ from baserow.core.formula.argument_types import ( AnyBaserowRuntimeFormulaArgumentType, + ArrayOfNumbersBaserowRuntimeFormulaArgumentType, BooleanBaserowRuntimeFormulaArgumentType, DateTimeBaserowRuntimeFormulaArgumentType, DictBaserowRuntimeFormulaArgumentType, @@ -17,7 +18,7 @@ from baserow.core.formula.registries import RuntimeFormulaFunction from baserow.core.formula.types import FormulaArg, FormulaArgs, FormulaContext from baserow.core.formula.utils.date import convert_date_format_moment_to_python -from baserow.core.formula.validator import ensure_string +from baserow.core.formula.validator import ensure_array, ensure_string class RuntimeConcat(RuntimeFormulaFunction): @@ -418,3 +419,162 @@ class RuntimeOr(RuntimeFormulaFunction): def execute(self, context: FormulaContext, args: FormulaArgs): return args[0] or args[1] + + +class RuntimeReplace(RuntimeFormulaFunction): + type = "replace" + + args = [ + TextBaserowRuntimeFormulaArgumentType(), + TextBaserowRuntimeFormulaArgumentType(), + TextBaserowRuntimeFormulaArgumentType(), + ] + + def execute(self, context: FormulaContext, args: FormulaArgs): + return args[0].replace(args[1], args[2]) + + +class RuntimeLength(RuntimeFormulaFunction): + type = "length" + + args = [ + AnyBaserowRuntimeFormulaArgumentType(), + ] + + def execute(self, context: FormulaContext, args: FormulaArgs): + return len(args[0]) + + +class RuntimeContains(RuntimeFormulaFunction): + type = "contains" + + args = [ + AnyBaserowRuntimeFormulaArgumentType(), + AnyBaserowRuntimeFormulaArgumentType(), + ] + + def execute(self, context: FormulaContext, args: FormulaArgs): + return args[1] in args[0] + + +class RuntimeReverse(RuntimeFormulaFunction): + type = "reverse" + + args = [ + AnyBaserowRuntimeFormulaArgumentType(), + ] + + def execute(self, context: FormulaContext, args: FormulaArgs): + value = args[0] + + if isinstance(value, list): + return list(reversed(value)) + + if isinstance(value, str): + return "".join(list(reversed(value))) + + raise TypeError(f"Cannot reverse {value}") + + +class RuntimeJoin(RuntimeFormulaFunction): + type = "join" + + args = [ + AnyBaserowRuntimeFormulaArgumentType(), + TextBaserowRuntimeFormulaArgumentType(optional=True), + ] + + def execute(self, context: FormulaContext, args: FormulaArgs): + value = args[0] + separator = args[1] if len(args) == 2 else "," + return separator.join(value) + + +class RuntimeSplit(RuntimeFormulaFunction): + type = "split" + + args = [ + TextBaserowRuntimeFormulaArgumentType(), + TextBaserowRuntimeFormulaArgumentType(optional=True), + ] + + def execute(self, context: FormulaContext, args: FormulaArgs): + separator = args[1] if len(args) == 2 else None + return args[0].split(separator) + + +class RuntimeIsEmpty(RuntimeFormulaFunction): + type = "is_empty" + + args = [ + AnyBaserowRuntimeFormulaArgumentType(), + ] + + def execute(self, context: FormulaContext, args: FormulaArgs): + value = args[0] + + if value is None: + return True + + if isinstance(value, (list, str, dict)): + if isinstance(value, str): + value = value.strip() + return len(value) == 0 + + return False + + +class RuntimeStrip(RuntimeFormulaFunction): + type = "strip" + + args = [ + TextBaserowRuntimeFormulaArgumentType(), + ] + + def execute(self, context: FormulaContext, args: FormulaArgs): + return args[0].strip() + + +class RuntimeSum(RuntimeFormulaFunction): + type = "sum" + + args = [ + ArrayOfNumbersBaserowRuntimeFormulaArgumentType(), + ] + + def execute(self, context: FormulaContext, args: FormulaArgs): + return sum(args[0]) + + +class RuntimeAvg(RuntimeFormulaFunction): + type = "avg" + + args = [ + ArrayOfNumbersBaserowRuntimeFormulaArgumentType(), + ] + + def execute(self, context: FormulaContext, args: FormulaArgs): + return sum(args[0]) / len(args[0]) + + +class RuntimeAt(RuntimeFormulaFunction): + type = "at" + + args = [ + AnyBaserowRuntimeFormulaArgumentType(), + NumberBaserowRuntimeFormulaArgumentType(cast_to_int=True), + ] + + def execute(self, context: FormulaContext, args: FormulaArgs): + value = args[0] + index = args[1] + return value[index] + + +class RuntimeToArray(RuntimeFormulaFunction): + type = "to_array" + + args = [TextBaserowRuntimeFormulaArgumentType()] + + def execute(self, context: FormulaContext, args: FormulaArgs): + return ensure_array(args[0]) diff --git a/backend/tests/baserow/core/formula/test_runtime_formula_types.py b/backend/tests/baserow/core/formula/test_runtime_formula_types.py index 020bcd760d..042507aed7 100644 --- a/backend/tests/baserow/core/formula/test_runtime_formula_types.py +++ b/backend/tests/baserow/core/formula/test_runtime_formula_types.py @@ -1,5 +1,5 @@ import uuid -from datetime import datetime +from datetime import date, datetime from unittest.mock import MagicMock import pytest @@ -8,8 +8,11 @@ from baserow.core.formula.runtime_formula_types import ( RuntimeAdd, RuntimeAnd, + RuntimeAt, + RuntimeAvg, RuntimeCapitalize, RuntimeConcat, + RuntimeContains, RuntimeDateTimeFormat, RuntimeDay, RuntimeDivide, @@ -21,8 +24,11 @@ RuntimeGreaterThanOrEqual, RuntimeHour, RuntimeIf, + RuntimeIsEmpty, RuntimeIsEven, RuntimeIsOdd, + RuntimeJoin, + RuntimeLength, RuntimeLessThan, RuntimeLessThanOrEqual, RuntimeLower, @@ -36,8 +42,14 @@ RuntimeRandomBool, RuntimeRandomFloat, RuntimeRandomInt, + RuntimeReplace, + RuntimeReverse, RuntimeRound, RuntimeSecond, + RuntimeSplit, + RuntimeStrip, + RuntimeSum, + RuntimeToArray, RuntimeToday, RuntimeUpper, RuntimeYear, @@ -434,12 +446,48 @@ def test_runtime_not_equal_validate_number_of_args(args, expected): ([3, 2], True), (["apple", "ball"], False), (["ball", "apple"], True), + ([1, "a"], None), + (["a", 1], None), + ( + [ + date(2025, 12, 18), + date(2025, 12, 18), + ], + False, + ), + ( + [ + date(2025, 12, 19), + date(2025, 12, 18), + ], + True, + ), + ( + [ + datetime(year=2025, month=11, day=6, hour=12, minute=30), + datetime(year=2025, month=11, day=6, hour=12, minute=30), + ], + False, + ), + ( + [ + datetime(year=2025, month=11, day=6, hour=12, minute=31), + datetime(year=2025, month=11, day=6, hour=12, minute=30), + ], + True, + ), ], ) def test_runtime_greater_than_execute(args, expected): parsed_args = RuntimeGreaterThan().parse_args(args) - result = RuntimeGreaterThan().execute({}, parsed_args) - assert result == expected + + if expected is None: + with pytest.raises(TypeError) as e: + RuntimeGreaterThan().execute({}, parsed_args) + assert f"'>' not supported between instances of" in str(e) + else: + result = RuntimeGreaterThan().execute({}, parsed_args) + assert result == expected @pytest.mark.parametrize( @@ -485,12 +533,48 @@ def test_runtime_greater_than_validate_number_of_args(args, expected): ([3, 2], False), (["apple", "ball"], True), (["ball", "apple"], False), + ([1, "a"], None), + (["a", 1], None), + ( + [ + date(2025, 12, 18), + date(2025, 12, 18), + ], + False, + ), + ( + [ + date(2025, 12, 18), + date(2025, 12, 19), + ], + True, + ), + ( + [ + datetime(year=2025, month=11, day=6, hour=12, minute=30), + datetime(year=2025, month=11, day=6, hour=12, minute=30), + ], + False, + ), + ( + [ + datetime(year=2025, month=11, day=6, hour=12, minute=30), + datetime(year=2025, month=11, day=6, hour=12, minute=31), + ], + True, + ), ], ) def test_runtime_less_than_execute(args, expected): parsed_args = RuntimeLessThan().parse_args(args) - result = RuntimeLessThan().execute({}, parsed_args) - assert result == expected + + if expected is None: + with pytest.raises(TypeError) as e: + RuntimeLessThan().execute({}, parsed_args) + assert f"'<' not supported between instances of" in str(e) + else: + result = RuntimeLessThan().execute({}, parsed_args) + assert result == expected @pytest.mark.parametrize( @@ -536,12 +620,50 @@ def test_runtime_less_than_validate_number_of_args(args, expected): ([3, 2], True), (["apple", "ball"], False), (["ball", "apple"], True), + ([[], "a"], None), + ([{}, "a"], None), + ([1, "a"], None), + (["a", 1], None), + ( + [ + date(2025, 12, 17), + date(2025, 12, 18), + ], + False, + ), + ( + [ + date(2025, 12, 20), + date(2025, 12, 19), + ], + True, + ), + ( + [ + datetime(year=2025, month=11, day=6, hour=12, minute=29), + datetime(year=2025, month=11, day=6, hour=12, minute=30), + ], + False, + ), + ( + [ + datetime(year=2025, month=11, day=6, hour=12, minute=30), + datetime(year=2025, month=11, day=6, hour=12, minute=30), + ], + True, + ), ], ) def test_runtime_greater_than_or_equal_execute(args, expected): parsed_args = RuntimeGreaterThanOrEqual().parse_args(args) - result = RuntimeGreaterThanOrEqual().execute({}, parsed_args) - assert result == expected + + if expected is None: + with pytest.raises(TypeError) as e: + RuntimeGreaterThanOrEqual().execute({}, parsed_args) + assert f"'>=' not supported between instances of" in str(e) + else: + result = RuntimeGreaterThanOrEqual().execute({}, parsed_args) + assert result == expected @pytest.mark.parametrize( @@ -587,12 +709,50 @@ def test_runtime_greater_than_or_equal_validate_number_of_args(args, expected): ([3, 2], False), (["apple", "ball"], True), (["ball", "apple"], False), + ([[], "a"], None), + ([{}, "a"], None), + ([1, "a"], None), + (["a", 1], None), + ( + [ + date(2025, 12, 18), + date(2025, 12, 17), + ], + False, + ), + ( + [ + date(2025, 12, 19), + date(2025, 12, 20), + ], + True, + ), + ( + [ + datetime(year=2025, month=11, day=6, hour=12, minute=30), + datetime(year=2025, month=11, day=6, hour=12, minute=29), + ], + False, + ), + ( + [ + datetime(year=2025, month=11, day=6, hour=12, minute=29), + datetime(year=2025, month=11, day=6, hour=12, minute=30), + ], + True, + ), ], ) def test_runtime_less_than_or_equal_execute(args, expected): parsed_args = RuntimeLessThanOrEqual().parse_args(args) - result = RuntimeLessThanOrEqual().execute({}, parsed_args) - assert result == expected + + if expected is None: + with pytest.raises(TypeError) as e: + RuntimeLessThanOrEqual().execute({}, parsed_args) + assert f"'<=' not supported between instances of" in str(e) + else: + result = RuntimeLessThanOrEqual().execute({}, parsed_args) + assert result == expected @pytest.mark.parametrize( @@ -1366,7 +1526,7 @@ def test_runtime_get_property_validate_number_of_args(args, expected): "args,expected", [ ([1, 100], AnyInt()), - ([10.24, 100.54], AnyInt()), + ([10, 100], AnyInt()), ], ) def test_runtime_random_int_execute(args, expected): @@ -1606,7 +1766,6 @@ def test_runtime_and_validate_number_of_args(args, expected): assert result is expected -## @pytest.mark.parametrize( "args,expected", [ @@ -1666,3 +1825,533 @@ def test_runtime_or_validate_type_of_args(args, expected): def test_runtime_or_validate_number_of_args(args, expected): result = RuntimeOr().validate_number_of_args(args) assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["Hello, world!", "l", "-"], "He--o, wor-d!"), + ([1112111, 2, 3], "1113111"), + ], +) +def test_runtime_replace_execute(args, expected): + parsed_args = RuntimeReplace().parse_args(args) + result = RuntimeReplace().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + # Valid types for all args + (["foo", "bar", "baz"], None), + ([100, 200, 300], None), + ], +) +def test_runtime_replace_validate_type_of_args(args, expected): + result = RuntimeReplace().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], False), + (["foo", "bar", "baz"], True), + (["foo", "bar", "baz", "x"], False), + ], +) +def test_runtime_replace_validate_number_of_args(args, expected): + result = RuntimeReplace().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["Hello, world!"], 13), + ([{"a": "b", "c": "d"}], 2), + ([["a", "b", "c", "d"]], 4), + ([3], None), + (["0"], 1), + ], +) +def test_runtime_length_execute(args, expected): + parsed_args = RuntimeLength().parse_args(args) + + if expected is None: + with pytest.raises(TypeError) as e: + RuntimeLength().execute({}, parsed_args) + assert "has no len" in str(e) + else: + result = RuntimeLength().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["foo"], None), + (['{"foo": "bar"}'], None), + (['["foo", "bar"]'], None), + ], +) +def test_runtime_length_validate_type_of_args(args, expected): + result = RuntimeLength().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_length_validate_number_of_args(args, expected): + result = RuntimeLength().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["Hello, world!", "ll"], True), + (["Hello, world!", "goodbye"], False), + (['"foo bar"', "foo"], True), + ([["foo", "bar"], "foo"], True), + ([{"foo": "bar"}, "foo"], True), + ([1, 2], None), + ], +) +def test_runtime_contains_execute(args, expected): + parsed_args = RuntimeContains().parse_args(args) + + if expected is None: + with pytest.raises(TypeError) as e: + RuntimeContains().execute({}, parsed_args) + assert "is not iterable" in str(e) + else: + result = RuntimeContains().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["foo"], None), + (['{"foo": "bar"}'], None), + (['["foo", "bar"]'], None), + ], +) +def test_runtime_contains_validate_type_of_args(args, expected): + result = RuntimeContains().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_contains_validate_number_of_args(args, expected): + result = RuntimeContains().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["Hello, world!"], "!dlrow ,olleH"), + ([["Hello", "world!"]], ["world!", "Hello"]), + (["😀💙🚀"], "🚀💙😀"), + (["Hello, world!"], "!dlrow ,olleH"), + ([1], None), + ], +) +def test_runtime_reverse_execute(args, expected): + parsed_args = RuntimeReverse().parse_args(args) + + if expected is None: + with pytest.raises(TypeError) as e: + RuntimeReverse().execute({}, parsed_args) + assert f"Cannot reverse {parsed_args[0]}" in str(e) + else: + result = RuntimeReverse().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["foo"], None), + (["😀💙🚀"], None), + (['["foo", "bar"]'], None), + ], +) +def test_runtime_reverse_validate_type_of_args(args, expected): + result = RuntimeReverse().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_reverse_validate_number_of_args(args, expected): + result = RuntimeReverse().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([["foo", "bar"]], "foo,bar"), + ([["foo", "bar"], "*"], "foo*bar"), + (["foo", "*"], "f*o*o"), + ([1], None), + ], +) +def test_runtime_join_execute(args, expected): + parsed_args = RuntimeJoin().parse_args(args) + + if expected is None: + with pytest.raises(TypeError) as e: + RuntimeJoin().execute({}, parsed_args) + assert "can only join an iterable" in str(e) + else: + result = RuntimeJoin().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (['["foo", "bar"]'], None), + (['["foo", "bar"]', "baz"], None), + ], +) +def test_runtime_join_validate_type_of_args(args, expected): + result = RuntimeJoin().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_join_validate_number_of_args(args, expected): + result = RuntimeJoin().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["foobar", "b"], ["foo", "ar"]), + (["foobar"], ["foobar"]), + ], +) +def test_runtime_split_execute(args, expected): + parsed_args = RuntimeSplit().parse_args(args) + result = RuntimeSplit().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (['["foo", "bar"]'], None), + (['["foo", "bar"]', "baz"], None), + ], +) +def test_runtime_split_validate_type_of_args(args, expected): + result = RuntimeSplit().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_split_validate_number_of_args(args, expected): + result = RuntimeSplit().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([0], False), + ([0.0], False), + ([0.1], False), + ([1], False), + (["0"], False), + ([""], True), + ([None], True), + ([date.today()], False), + ([[]], True), + ([{}], True), + (["[]"], False), + (["{}"], False), + ([" "], True), + (["foo"], False), + ([["foo"]], False), + ([{"foo": "bar"}], False), + ], +) +def test_runtime_is_empty_execute(args, expected): + parsed_args = RuntimeIsEmpty().parse_args(args) + result = RuntimeIsEmpty().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([""], None), + (["foo"], None), + ([[]], None), + ([{}], None), + ], +) +def test_runtime_is_empty_validate_type_of_args(args, expected): + result = RuntimeIsEmpty().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_is_empty_validate_number_of_args(args, expected): + result = RuntimeIsEmpty().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([""], ""), + ([" "], ""), + ([" foo "], "foo"), + (["foo"], "foo"), + ], +) +def test_runtime_strip_execute(args, expected): + parsed_args = RuntimeStrip().parse_args(args) + result = RuntimeStrip().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([""], None), + (["foo"], None), + ], +) +def test_runtime_strip_validate_type_of_args(args, expected): + result = RuntimeStrip().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_strip_validate_number_of_args(args, expected): + result = RuntimeStrip().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([[1, 2, 3]], 6.0), + ], +) +def test_runtime_sum_execute(args, expected): + parsed_args = RuntimeSum().parse_args(args) + result = RuntimeSum().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([""], None), + (['["1", "foo"]'], '["1", "foo"]'), + ([["1", 2]], None), + ], +) +def test_runtime_sum_validate_type_of_args(args, expected): + result = RuntimeSum().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_sum_validate_number_of_args(args, expected): + result = RuntimeSum().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([[1, 2, 3, 4]], 2.5), + ], +) +def test_runtime_avg_execute(args, expected): + parsed_args = RuntimeAvg().parse_args(args) + result = RuntimeAvg().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([""], None), + (['["1", "foo"]'], '["1", "foo"]'), + ([["1", 2]], None), + ], +) +def test_runtime_avg_validate_type_of_args(args, expected): + result = RuntimeAvg().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_avg_validate_number_of_args(args, expected): + result = RuntimeAvg().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([["foo", "bar"], 1], "bar"), + ([["foo", "bar"], 2], None), + (["foobar", 3], "b"), + ([3, 1], None), + ], +) +def test_runtime_at_execute(args, expected): + parsed_args = RuntimeAt().parse_args(args) + + if expected is None: + with pytest.raises((IndexError, TypeError)) as e: + RuntimeAt().execute({}, parsed_args) + else: + result = RuntimeAt().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["[]", "2"], None), + ([[], 2], None), + ], +) +def test_runtime_at_validate_type_of_args(args, expected): + result = RuntimeAt().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], False), + (["foo", "bar"], True), + (["foo", "bar", "baz"], False), + ], +) +def test_runtime_at_validate_number_of_args(args, expected): + result = RuntimeAt().validate_number_of_args(args) + assert result is expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["foo,bar"], ["foo", "bar"]), + ([["foo", "bar"]], ["foo", "bar"]), + (['["foo", "bar"]'], ['["foo"', '"bar"]']), + ([123], ["123"]), + ], +) +def test_runtime_to_array_execute(args, expected): + parsed_args = RuntimeToArray().parse_args(args) + result = RuntimeToArray().execute({}, parsed_args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + (["[]"], None), + ([[]], None), + (["foo,bar"], None), + ], +) +def test_runtime_to_array_validate_type_of_args(args, expected): + result = RuntimeToArray().validate_type_of_args(args) + assert result == expected + + +@pytest.mark.parametrize( + "args,expected", + [ + ([], False), + (["foo"], True), + (["foo", "bar"], False), + ], +) +def test_runtime_to_array_validate_number_of_args(args, expected): + result = RuntimeToArray().validate_number_of_args(args) + assert result is expected diff --git a/changelog/entries/unreleased/feature/4318_added_more_advanced_formulas.json b/changelog/entries/unreleased/feature/4318_added_more_advanced_formulas.json new file mode 100644 index 0000000000..09e9504406 --- /dev/null +++ b/changelog/entries/unreleased/feature/4318_added_more_advanced_formulas.json @@ -0,0 +1,9 @@ +{ + "type": "feature", + "message": "Added more advanced formulas.", + "issue_origin": "github", + "issue_number": 4318, + "domain": "core", + "bullet_points": [], + "created_at": "2025-11-21" +} \ No newline at end of file diff --git a/web-frontend/locales/en.json b/web-frontend/locales/en.json index 0093df6774..77e5c1b697 100644 --- a/web-frontend/locales/en.json +++ b/web-frontend/locales/en.json @@ -606,6 +606,7 @@ "absDescription": "Returns the absolute value for the argument number provided.", "ceilDescription": "Returns the smallest integer that is greater than or equal the argument number provided.", "floorDescription": "Returns the largest integer that is less than or equal the argument number provided.", + "toArrayDescription": "Converts a comma-delimited string into an array.", "encodeUriDescription": "Returns a encoded URI string from the argument provided.", "encodeUriComponentDescription": "Returns a encoded URI string component from the argument provided.", "getFileVisibleNameDescription": "Returns the visible file name from a single file returned from the index function.", @@ -676,6 +677,17 @@ "ifDescription": "If the first argument is true, returns the second argument, otherwise returns the third argument.", "andDescription": "Returns true if all arguments are true, otherwise returns false.", "orDescription": "Returns true if any argument is true, otherwise returns false.", + "replaceDescription": "Given the first argument, replaces all occurrences of the second argument with the third argument.", + "lengthDescription": "Returns the length of the argument: the number of items in an array, the number of characters in a string, or the number of keys in an object.", + "containsDescription": "Returns true if the first argument contains the second argument.", + "reverseDescription": "Reverses the argument. If the argument is a string, the string is reversed. If it is a list, the list order is reversed.", + "joinDescription": "Joins the first argument using the optional second argument as a separator. If the second argument is not provided, uses ',' as the separator.", + "splitDescription": "Splits the first argument using the optional second argument as the separator. If the second argument is not provided, uses ' ' as the separator.", + "isEmptyDescription": "Returns true if the argument is empty, otherwise returns false", + "stripDescription": "Strips any leading and trailing whitespace from the argument", + "sumDescription": "Sums the numbers inside the argument.", + "avgDescription": "Averages the numbers inside the argument.", + "atDescription": "Returns the item in the first argument at the index specified by the second argument.", "formulaTypeFormula": "Function | Functions", "formulaTypeOperator": "Operator | Operators", "formulaTypeData": "Data", diff --git a/web-frontend/modules/core/plugin.js b/web-frontend/modules/core/plugin.js index 2d42e28890..00972c59a1 100644 --- a/web-frontend/modules/core/plugin.js +++ b/web-frontend/modules/core/plugin.js @@ -129,6 +129,18 @@ import { RuntimeIf, RuntimeAnd, RuntimeOr, + RuntimeReplace, + RuntimeLength, + RuntimeContains, + RuntimeReverse, + RuntimeJoin, + RuntimeSplit, + RuntimeIsEmpty, + RuntimeStrip, + RuntimeSum, + RuntimeAvg, + RuntimeAt, + RuntimeToArray, } from '@baserow/modules/core/runtimeFormulaTypes' import priorityBus from '@baserow/modules/core/plugins/priorityBus' @@ -311,6 +323,18 @@ export default (context, inject) => { registry.register('runtimeFormulaFunction', new RuntimeIf(context)) registry.register('runtimeFormulaFunction', new RuntimeAnd(context)) registry.register('runtimeFormulaFunction', new RuntimeOr(context)) + registry.register('runtimeFormulaFunction', new RuntimeReplace(context)) + registry.register('runtimeFormulaFunction', new RuntimeLength(context)) + registry.register('runtimeFormulaFunction', new RuntimeContains(context)) + registry.register('runtimeFormulaFunction', new RuntimeReverse(context)) + registry.register('runtimeFormulaFunction', new RuntimeJoin(context)) + registry.register('runtimeFormulaFunction', new RuntimeSplit(context)) + registry.register('runtimeFormulaFunction', new RuntimeIsEmpty(context)) + registry.register('runtimeFormulaFunction', new RuntimeStrip(context)) + registry.register('runtimeFormulaFunction', new RuntimeSum(context)) + registry.register('runtimeFormulaFunction', new RuntimeAvg(context)) + registry.register('runtimeFormulaFunction', new RuntimeAt(context)) + registry.register('runtimeFormulaFunction', new RuntimeToArray(context)) registry.register('roles', new AdminRoleType(context)) registry.register('roles', new MemberRoleType(context)) diff --git a/web-frontend/modules/core/runtimeFormulaArgumentTypes.js b/web-frontend/modules/core/runtimeFormulaArgumentTypes.js index a8b9039b4d..9e1b91a0af 100644 --- a/web-frontend/modules/core/runtimeFormulaArgumentTypes.js +++ b/web-frontend/modules/core/runtimeFormulaArgumentTypes.js @@ -4,6 +4,7 @@ import { ensureDateTime, ensureObject, ensureBoolean, + ensureArray, } from '@baserow/modules/core/utils/validator' import moment from '@baserow/modules/core/moment' @@ -113,6 +114,21 @@ export class ObjectBaserowRuntimeFormulaArgumentType extends BaserowRuntimeFormu } } +export class ArrayBaserowRuntimeFormulaArgumentType extends BaserowRuntimeFormulaArgumentType { + test(value) { + try { + ensureArray(value) + return true + } catch (e) { + return false + } + } + + parse(value) { + return ensureArray(value) + } +} + export class BooleanBaserowRuntimeFormulaArgumentType extends BaserowRuntimeFormulaArgumentType { test(value) { try { diff --git a/web-frontend/modules/core/runtimeFormulaTypes.js b/web-frontend/modules/core/runtimeFormulaTypes.js index 5b3f31a544..1f2bbd40f7 100644 --- a/web-frontend/modules/core/runtimeFormulaTypes.js +++ b/web-frontend/modules/core/runtimeFormulaTypes.js @@ -7,13 +7,19 @@ import { BooleanBaserowRuntimeFormulaArgumentType, TimezoneBaserowRuntimeFormulaArgumentType, AnyBaserowRuntimeFormulaArgumentType, + ArrayBaserowRuntimeFormulaArgumentType, } from '@baserow/modules/core/runtimeFormulaArgumentTypes' import { InvalidFormulaArgumentType, InvalidNumberOfArguments, } from '@baserow/modules/core/formula/parser/errors' import { Node, VueNodeViewRenderer } from '@tiptap/vue-2' -import { ensureString } from '@baserow/modules/core/utils/validator' +import { reverseString } from '@baserow/modules/core/utils/string' +import { avg, sum } from '@baserow/modules/core/utils/number' +import { + ensureString, + ensureArray, +} from '@baserow/modules/core/utils/validator' import GetFormulaComponent from '@baserow/modules/core/components/formula/GetFormulaComponent' import { mergeAttributes } from '@tiptap/core' import { FORMULA_CATEGORY, FORMULA_TYPE } from '@baserow/modules/core/enums' @@ -578,6 +584,10 @@ export class RuntimeEqual extends RuntimeFormulaFunction { formula: '"foo" = "foo"', result: 'true', }, + { + formula: 'now() = now()', + result: 'false', + }, ] } } @@ -629,6 +639,10 @@ export class RuntimeNotEqual extends RuntimeFormulaFunction { formula: '"foo" != "bar"', result: 'true', }, + { + formula: 'now() != now()', + result: 'true', + }, ] } } @@ -658,7 +672,18 @@ export class RuntimeGreaterThan extends RuntimeFormulaFunction { } execute(context, [a, b]) { - return a > b + const typeA = typeof a + const typeB = typeof b + + if (typeA === 'number' && typeB === 'number') { + return a > b + } + + if (typeA === 'string' && typeB === 'string') { + return a > b + } + + return null } getDescription() { @@ -680,6 +705,10 @@ export class RuntimeGreaterThan extends RuntimeFormulaFunction { formula: '"Ambarella" > "fig"', result: 'false', }, + { + formula: 'now() > now()', + result: 'false', + }, ] } } @@ -709,7 +738,18 @@ export class RuntimeLessThan extends RuntimeFormulaFunction { } execute(context, [a, b]) { - return a < b + const typeA = typeof a + const typeB = typeof b + + if (typeA === 'number' && typeB === 'number') { + return a < b + } + + if (typeA === 'string' && typeB === 'string') { + return a < b + } + + return null } getDescription() { @@ -731,6 +771,10 @@ export class RuntimeLessThan extends RuntimeFormulaFunction { formula: '"Ambarella" < "fig"', result: 'true', }, + { + formula: 'now() < now()', + result: 'true', + }, ] } } @@ -760,7 +804,18 @@ export class RuntimeGreaterThanOrEqual extends RuntimeFormulaFunction { } execute(context, [a, b]) { - return a >= b + const typeA = typeof a + const typeB = typeof b + + if (typeA === 'number' && typeB === 'number') { + return a >= b + } + + if (typeA === 'string' && typeB === 'string') { + return a >= b + } + + return null } getDescription() { @@ -782,6 +837,10 @@ export class RuntimeGreaterThanOrEqual extends RuntimeFormulaFunction { formula: '"Ambarella" >= "fig"', result: 'false', }, + { + formula: 'now() >= now()', + result: 'false', + }, ] } } @@ -811,7 +870,18 @@ export class RuntimeLessThanOrEqual extends RuntimeFormulaFunction { } execute(context, [a, b]) { - return a <= b + const typeA = typeof a + const typeB = typeof b + + if (typeA === 'number' && typeB === 'number') { + return a <= b + } + + if (typeA === 'string' && typeB === 'string') { + return a <= b + } + + return null } getDescription() { @@ -833,6 +903,10 @@ export class RuntimeLessThanOrEqual extends RuntimeFormulaFunction { formula: '"fig" <= "Ambarella"', result: 'false', }, + { + formula: 'now() <= now()', + result: 'true', + }, ] } } @@ -1752,3 +1826,589 @@ export class RuntimeOr extends RuntimeFormulaFunction { ] } } + +export class RuntimeReplace extends RuntimeFormulaFunction { + static getType() { + return 'replace' + } + + static getFormulaType() { + return FORMULA_TYPE.FUNCTION + } + + static getCategoryType() { + return FORMULA_CATEGORY.TEXT + } + + get args() { + return [ + new TextBaserowRuntimeFormulaArgumentType(), + new TextBaserowRuntimeFormulaArgumentType(), + new TextBaserowRuntimeFormulaArgumentType(), + ] + } + + execute(context, args) { + return args[0].replaceAll(args[1], args[2]) + } + + getDescription() { + const { i18n } = this.app + return i18n.t('runtimeFormulaTypes.replaceDescription') + } + + getExamples() { + return [ + { + formula: "replace('Hello, world!', 'l', '-')", + result: "'He--o, wor-d!'", + }, + ] + } +} + +export class RuntimeLength extends RuntimeFormulaFunction { + static getType() { + return 'length' + } + + static getFormulaType() { + return FORMULA_TYPE.FUNCTION + } + + static getCategoryType() { + return FORMULA_CATEGORY.TEXT + } + + get args() { + return [new AnyBaserowRuntimeFormulaArgumentType()] + } + + execute(context, [value]) { + if (Array.isArray(value)) { + return value.length + } else if (value !== null && typeof value === 'object') { + return Object.keys(value).length + } else if (typeof value === 'string') { + return value.length + } + + return null + } + + getDescription() { + const { i18n } = this.app + return i18n.t('runtimeFormulaTypes.lengthDescription') + } + + getExamples() { + return [ + { + formula: "length('Hello, world!')", + result: '13', + }, + { + formula: 'length(to_array("foo, bar"))', + result: '2', + }, + ] + } +} + +export class RuntimeContains extends RuntimeFormulaFunction { + static getType() { + return 'contains' + } + + static getFormulaType() { + return FORMULA_TYPE.FUNCTION + } + + static getCategoryType() { + return FORMULA_CATEGORY.TEXT + } + + get args() { + return [ + new AnyBaserowRuntimeFormulaArgumentType(), + new AnyBaserowRuntimeFormulaArgumentType(), + ] + } + + execute(context, args) { + const value = args[0] + const toCheck = args[1] + + if (Array.isArray(value)) { + return value.includes(toCheck) + } else if (value !== null && typeof value === 'object') { + return Object.keys(value).includes(toCheck) + } else if (typeof value === 'string') { + return value.includes(toCheck) + } + + return null + } + + getDescription() { + const { i18n } = this.app + return i18n.t('runtimeFormulaTypes.containsDescription') + } + + getExamples() { + return [ + { + formula: "contains('Hello, world!', 'll')", + result: 'true', + }, + { + formula: 'contains(to_array("foo, bar"), "foo")', + result: 'true', + }, + ] + } +} + +export class RuntimeReverse extends RuntimeFormulaFunction { + static getType() { + return 'reverse' + } + + static getFormulaType() { + return FORMULA_TYPE.FUNCTION + } + + static getCategoryType() { + return FORMULA_CATEGORY.TEXT + } + + get args() { + return [new AnyBaserowRuntimeFormulaArgumentType()] + } + + execute(context, [arg]) { + if (Array.isArray(arg)) { + return arg.reverse() + } + + if (typeof arg === 'string') { + return reverseString(arg) + } + + return null + } + + getDescription() { + const { i18n } = this.app + return i18n.t('runtimeFormulaTypes.reverseDescription') + } + + getExamples() { + return [ + { + formula: "reverse('Hello, world!')", + result: "'!dlrow ,olleH'", + }, + { + formula: "reverse('😀💙🚀')", + result: "'🚀💙😀", + }, + { + formula: 'reverse(to_array("foo, bar"))', + result: "'bar,foo'", + }, + ] + } +} + +export class RuntimeJoin extends RuntimeFormulaFunction { + static getType() { + return 'join' + } + + static getFormulaType() { + return FORMULA_TYPE.FUNCTION + } + + static getCategoryType() { + return FORMULA_CATEGORY.TEXT + } + + get args() { + return [ + new AnyBaserowRuntimeFormulaArgumentType(), + new TextBaserowRuntimeFormulaArgumentType({ optional: true }), + ] + } + + execute(context, args) { + const val = args[0] + let separator = ',' + if (args.length === 2) { + separator = args[1] + } + + if (Array.isArray(val)) { + return val.join(separator) + } + + if (typeof val === 'string') { + return val.split('').join(separator) + } + + return null + } + + getDescription() { + const { i18n } = this.app + return i18n.t('runtimeFormulaTypes.joinDescription') + } + + getExamples() { + return [ + { + formula: 'join(to_array("foo, bar"))', + result: "'foo,bar'", + }, + { + formula: 'join(to_array("foo, bar"), " * ")', + result: "'foo * bar'", + }, + ] + } +} + +export class RuntimeSplit extends RuntimeFormulaFunction { + static getType() { + return 'split' + } + + static getFormulaType() { + return FORMULA_TYPE.FUNCTION + } + + static getCategoryType() { + return FORMULA_CATEGORY.TEXT + } + + get args() { + return [ + new TextBaserowRuntimeFormulaArgumentType(), + new TextBaserowRuntimeFormulaArgumentType({ optional: true }), + ] + } + + execute(context, args) { + let separator = '' + if (args.length === 2) { + separator = args[1] + } + return args[0].split(separator) + } + + getDescription() { + const { i18n } = this.app + return i18n.t('runtimeFormulaTypes.splitDescription') + } + + getExamples() { + return [ + { + formula: 'split("foobar")', + result: "'f,o,o,b,a,r'", + }, + { + formula: 'split("foobar", "b")', + result: "'foo,ar'", + }, + ] + } +} + +export class RuntimeIsEmpty extends RuntimeFormulaFunction { + static getType() { + return 'is_empty' + } + + static getFormulaType() { + return FORMULA_TYPE.FUNCTION + } + + static getCategoryType() { + return FORMULA_CATEGORY.BOOLEAN + } + + get args() { + return [new AnyBaserowRuntimeFormulaArgumentType()] + } + + execute(context, [arg]) { + if (arg === undefined || arg === null) { + return true + } + + if (Array.isArray(arg)) { + return arg.length === 0 + } + + if (typeof arg === 'object') { + return Object.keys(arg).length === 0 + } + + if (typeof arg === 'string') { + return arg.trim().length === 0 + } + + return false + } + + getDescription() { + const { i18n } = this.app + return i18n.t('runtimeFormulaTypes.isEmptyDescription') + } + + getExamples() { + return [ + { + formula: "is_empty('')", + result: 'true', + }, + { + formula: 'is_empty(0)', + result: 'true', + }, + { + formula: 'is_empty(to_array(""))', + result: 'true', + }, + { + formula: "is_empty('foo')", + result: 'false', + }, + { + formula: 'is_empty(1)', + result: 'false', + }, + { + formula: 'is_empty(to_array("foo,bar"))', + result: 'false', + }, + ] + } +} + +export class RuntimeStrip extends RuntimeFormulaFunction { + static getType() { + return 'strip' + } + + static getFormulaType() { + return FORMULA_TYPE.FUNCTION + } + + static getCategoryType() { + return FORMULA_CATEGORY.TEXT + } + + get args() { + return [new TextBaserowRuntimeFormulaArgumentType()] + } + + execute(context, [arg]) { + if (typeof arg === 'string' && isNaN(Number(arg))) { + return arg.trim() + } + + return null + } + + getDescription() { + const { i18n } = this.app + return i18n.t('runtimeFormulaTypes.stripDescription') + } + + getExamples() { + return [ + { + formula: "strip(' foo ')", + result: "'foo'", + }, + ] + } +} + +export class RuntimeSum extends RuntimeFormulaFunction { + static getType() { + return 'sum' + } + + static getFormulaType() { + return FORMULA_TYPE.FUNCTION + } + + static getCategoryType() { + return FORMULA_CATEGORY.NUMBER + } + + get args() { + return [new ArrayBaserowRuntimeFormulaArgumentType()] + } + + execute(context, [arg]) { + try { + return sum(arg, { strict: true }) + } catch { + return null + } + } + + getDescription() { + const { i18n } = this.app + return i18n.t('runtimeFormulaTypes.sumDescription') + } + + getExamples() { + return [ + { + formula: 'sum(to_array("1, 2, 3"))', + result: '6', + }, + { + formula: 'sum(to_array("1, 2.5, 3"))', + result: '6.5', + }, + ] + } +} + +export class RuntimeAvg extends RuntimeFormulaFunction { + static getType() { + return 'avg' + } + + static getFormulaType() { + return FORMULA_TYPE.FUNCTION + } + + static getCategoryType() { + return FORMULA_CATEGORY.NUMBER + } + + get args() { + return [new ArrayBaserowRuntimeFormulaArgumentType()] + } + + execute(context, [arg]) { + try { + return avg(arg, { strict: true }) + } catch { + return null + } + } + + getDescription() { + const { i18n } = this.app + return i18n.t('runtimeFormulaTypes.avgDescription') + } + + getExamples() { + return [ + { + formula: "avg(to_array('1, 2, 3, 4'))", + result: '2.5', + }, + ] + } +} + +export class RuntimeAt extends RuntimeFormulaFunction { + static getType() { + return 'at' + } + + static getFormulaType() { + return FORMULA_TYPE.FUNCTION + } + + static getCategoryType() { + return FORMULA_CATEGORY.TEXT + } + + get args() { + return [ + new AnyBaserowRuntimeFormulaArgumentType(), + new NumberBaserowRuntimeFormulaArgumentType({ castToInt: true }), + ] + } + + execute(context, args) { + const [value, index] = args + + if ( + (Array.isArray(value) || typeof value === 'string') && + value.length > index + ) { + return value[index] + } + + return null + } + + getDescription() { + const { i18n } = this.app + return i18n.t('runtimeFormulaTypes.atDescription') + } + + getExamples() { + return [ + { + formula: 'at(to_array("foo, bar"), 1)', + result: '"bar"', + }, + { + formula: 'at(to_array("foo, bar"), 3)', + result: 'null', + }, + ] + } +} + +export class RuntimeToArray extends RuntimeFormulaFunction { + static getType() { + return 'to_array' + } + + static getFormulaType() { + return FORMULA_TYPE.FUNCTION + } + + static getCategoryType() { + return FORMULA_CATEGORY.TEXT + } + + get args() { + return [new TextBaserowRuntimeFormulaArgumentType()] + } + + execute(context, [arg]) { + try { + return ensureArray(arg) + } catch { + return null + } + } + + getDescription() { + const { i18n } = this.app + return i18n.t('runtimeFormulaTypes.toArrayDescription') + } + + getExamples() { + return [ + { + formula: "to_array('foo,bar')", + result: '["foo", "bar"]', + }, + ] + } +} diff --git a/web-frontend/modules/core/utils/number.js b/web-frontend/modules/core/utils/number.js index 5773405a2c..3b79dd8593 100644 --- a/web-frontend/modules/core/utils/number.js +++ b/web-frontend/modules/core/utils/number.js @@ -15,3 +15,33 @@ export const ceil = (n, digits = 0) => { export const clamp = (value, min, max) => { return Math.max(min, Math.min(value, max)) } + +export const sum = (arr, { strict = false } = {}) => { + return arr.reduce((total, val) => { + const num = Number(val) + if (Number.isFinite(num)) { + return total + num + } else if (strict) { + throw new Error(`Invalid number: ${val}`) + } + return total + }, 0) +} + +export const avg = (arr, { strict = false } = {}) => { + let validNumbers = 0 + const _sum = arr.reduce((total, val) => { + const num = Number(val) + if (Number.isFinite(num)) { + validNumbers++ + return total + num + } else if (strict) { + throw new Error(`Invalid number: ${val}`) + } + return total + }, 0) + if (validNumbers > 0) { + return _sum / validNumbers + } + return _sum +} diff --git a/web-frontend/modules/core/utils/string.js b/web-frontend/modules/core/utils/string.js index fa647d9a9e..3e9b23fd6c 100644 --- a/web-frontend/modules/core/utils/string.js +++ b/web-frontend/modules/core/utils/string.js @@ -223,3 +223,13 @@ export function collatedStringCompare(stringA, stringB, order) { ? stringA.localeCompare(stringB, 'en') : stringB.localeCompare(stringA, 'en') } + +/** + * Reverses a string. + * + * @param {string} value + * @returns string + */ +export const reverseString = (value) => { + return [...value].reverse().join('') +} diff --git a/web-frontend/test/unit/core/formula/runtimeFormulaTypes.spec.js b/web-frontend/test/unit/core/formula/runtimeFormulaTypes.spec.js index b29f5315da..0ad0bed521 100644 --- a/web-frontend/test/unit/core/formula/runtimeFormulaTypes.spec.js +++ b/web-frontend/test/unit/core/formula/runtimeFormulaTypes.spec.js @@ -26,6 +26,7 @@ import { RuntimeNotEqual, RuntimeNow, RuntimeOr, + RuntimeReplace, RuntimeRandomBool, RuntimeRandomFloat, RuntimeRandomInt, @@ -34,6 +35,17 @@ import { RuntimeToday, RuntimeUpper, RuntimeYear, + RuntimeLength, + RuntimeContains, + RuntimeReverse, + RuntimeJoin, + RuntimeSplit, + RuntimeIsEmpty, + RuntimeStrip, + RuntimeSum, + RuntimeAvg, + RuntimeAt, + RuntimeToArray, } from '@baserow/modules/core/runtimeFormulaTypes' import { expect } from '@jest/globals' @@ -352,6 +364,8 @@ describe('RuntimeGreaterThan', () => { { args: [3, 2], expected: true }, { args: ['apple', 'ball'], expected: false }, { args: ['ball', 'apple'], expected: true }, + { args: ['a', 1], expected: null }, + { args: [1, 'a'], expected: null }, ])('execute returns expected value', ({ args, expected }) => { const formulaType = new RuntimeGreaterThan() const parsedArgs = formulaType.parseArgs(args) @@ -396,6 +410,8 @@ describe('RuntimeLessThan', () => { { args: [3, 2], expected: false }, { args: ['apple', 'ball'], expected: true }, { args: ['ball', 'apple'], expected: false }, + { args: ['a', 1], expected: null }, + { args: [1, 'a'], expected: null }, ])('execute returns expected value', ({ args, expected }) => { const formulaType = new RuntimeLessThan() const parsedArgs = formulaType.parseArgs(args) @@ -440,6 +456,8 @@ describe('RuntimeGreaterThanOrEqual', () => { { args: [3, 2], expected: true }, { args: ['apple', 'ball'], expected: false }, { args: ['ball', 'apple'], expected: true }, + { args: ['a', 1], expected: null }, + { args: [1, 'a'], expected: null }, ])('execute returns expected value', ({ args, expected }) => { const formulaType = new RuntimeGreaterThanOrEqual() const parsedArgs = formulaType.parseArgs(args) @@ -484,6 +502,8 @@ describe('RuntimeLessThanOrEqual', () => { { args: [3, 2], expected: false }, { args: ['apple', 'ball'], expected: true }, { args: ['ball', 'apple'], expected: false }, + { args: ['a', 1], expected: null }, + { args: [1, 'a'], expected: null }, ])('execute returns expected value', ({ args, expected }) => { const formulaType = new RuntimeLessThanOrEqual() const parsedArgs = formulaType.parseArgs(args) @@ -1450,3 +1470,414 @@ describe('RuntimeOr', () => { expect(result).toStrictEqual(expected) }) }) + +describe('RuntimeReplace', () => { + test.each([ + { args: ['Hello, world!', 'l', '-'], expected: 'He--o, wor-d!' }, + { args: ['1112111', 2, 3], expected: '1113111' }, + ])('execute returns expected value', ({ args, expected }) => { + const formulaType = new RuntimeReplace() + const parsedArgs = formulaType.parseArgs(args) + const result = formulaType.execute({}, parsedArgs) + expect(result).toEqual(expected) + }) + + test.each([ + { args: ['foo', 'bar', 'baz'], expected: undefined }, + { args: [100, 200, 300], expected: undefined }, + ])('validates type of args', ({ args, expected }) => { + const formulaType = new RuntimeReplace() + const result = formulaType.validateTypeOfArgs(args) + expect(result).toStrictEqual(expected) + }) + + test.each([ + { args: [], expected: false }, + { args: ['foo'], expected: false }, + { args: ['foo', 'bar'], expected: false }, + { args: ['foo', 'bar', 'baz'], expected: true }, + { args: ['foo', 'bar', 'baz', 'x'], expected: false }, + ])('validates number of args', ({ args, expected }) => { + const formulaType = new RuntimeReplace() + const result = formulaType.validateNumberOfArgs(args) + expect(result).toStrictEqual(expected) + }) +}) + +describe('RuntimeLength', () => { + test.each([ + { args: ['Hello, world!'], expected: 13 }, + { args: ['0'], expected: 1 }, + { args: [4], expected: null }, + { args: [{ a: 'b', c: 'd' }], expected: 2 }, + { args: [['a', 'b', 'c', 'd']], expected: 4 }, + ])('execute returns expected value', ({ args, expected }) => { + const formulaType = new RuntimeLength() + const parsedArgs = formulaType.parseArgs(args) + const result = formulaType.execute({}, parsedArgs) + expect(result).toEqual(expected) + }) + + test.each([ + { args: ['foo'], expected: undefined }, + { args: ['{"foo": "bar"}'], expected: undefined }, + { args: ['["foo", "bar"]'], expected: undefined }, + ])('validates type of args', ({ args, expected }) => { + const formulaType = new RuntimeLength() + const result = formulaType.validateTypeOfArgs(args) + expect(result).toStrictEqual(expected) + }) + + test.each([ + { args: [], expected: false }, + { args: ['foo'], expected: true }, + { args: ['foo', 'bar'], expected: false }, + ])('validates number of args', ({ args, expected }) => { + const formulaType = new RuntimeLength() + const result = formulaType.validateNumberOfArgs(args) + expect(result).toStrictEqual(expected) + }) +}) + +describe('RuntimeContains', () => { + test.each([ + { args: ['Hello, world!', 'll'], expected: true }, + { args: ['Hello, world!', 'goodbye'], expected: false }, + { args: [{ foo: 'bar' }, 'foo'], expected: true }, + { args: [['foo', 'bar'], 'foo'], expected: true }, + { args: [1, 'foo'], expected: null }, + ])('execute returns expected value', ({ args, expected }) => { + const formulaType = new RuntimeContains() + const parsedArgs = formulaType.parseArgs(args) + const result = formulaType.execute({}, parsedArgs) + expect(result).toEqual(expected) + }) + + test.each([ + { args: ['foo'], expected: undefined }, + { args: ['{"foo": "bar"}'], expected: undefined }, + { args: ['["foo", "bar"]'], expected: undefined }, + ])('validates type of args', ({ args, expected }) => { + const formulaType = new RuntimeContains() + const result = formulaType.validateTypeOfArgs(args) + expect(result).toStrictEqual(expected) + }) + + test.each([ + { args: [], expected: false }, + { args: ['foo'], expected: false }, + { args: ['foo', 'bar'], expected: true }, + { args: ['foo', 'bar', 'baz'], expected: false }, + ])('validates number of args', ({ args, expected }) => { + const formulaType = new RuntimeContains() + const result = formulaType.validateNumberOfArgs(args) + expect(result).toStrictEqual(expected) + }) +}) + +describe('RuntimeReverse', () => { + test.each([ + { args: ['Hello, world!'], expected: '!dlrow ,olleH' }, + { args: ['😀💙🚀'], expected: '🚀💙😀' }, + { args: [['foo', 'bar']], expected: ['bar', 'foo'] }, + ])('execute returns expected value', ({ args, expected }) => { + const formulaType = new RuntimeReverse() + const parsedArgs = formulaType.parseArgs(args) + const result = formulaType.execute({}, parsedArgs) + expect(result).toEqual(expected) + }) + + test.each([ + { args: ['foo'], expected: undefined }, + { args: ['😀💙🚀'], expected: undefined }, + { args: ['["foo", "bar"]'], expected: undefined }, + ])('validates type of args', ({ args, expected }) => { + const formulaType = new RuntimeReverse() + const result = formulaType.validateTypeOfArgs(args) + expect(result).toStrictEqual(expected) + }) + + test.each([ + { args: [], expected: false }, + { args: ['foo'], expected: true }, + { args: ['foo', 'bar'], expected: false }, + ])('validates number of args', ({ args, expected }) => { + const formulaType = new RuntimeReverse() + const result = formulaType.validateNumberOfArgs(args) + expect(result).toStrictEqual(expected) + }) +}) + +describe('RuntimeJoin', () => { + test.each([ + { args: [['foo', 'bar']], expected: 'foo,bar' }, + { args: ['foo', '*'], expected: 'f*o*o' }, + { args: [1], expected: null }, + ])('execute returns expected value', ({ args, expected }) => { + const formulaType = new RuntimeJoin() + const parsedArgs = formulaType.parseArgs(args) + const result = formulaType.execute({}, parsedArgs) + expect(result).toEqual(expected) + }) + + test.each([ + { args: ['["foo", "bar"]'], expected: undefined }, + { args: ['["foo", "bar"]', 'baz'], expected: undefined }, + ])('validates type of args', ({ args, expected }) => { + const formulaType = new RuntimeJoin() + const result = formulaType.validateTypeOfArgs(args) + expect(result).toStrictEqual(expected) + }) + + test.each([ + { args: [], expected: false }, + { args: ['foo'], expected: true }, + { args: ['foo', 'bar'], expected: true }, + { args: ['foo', 'bar', 'baz'], expected: false }, + ])('validates number of args', ({ args, expected }) => { + const formulaType = new RuntimeJoin() + const result = formulaType.validateNumberOfArgs(args) + expect(result).toStrictEqual(expected) + }) +}) + +describe('RuntimeSplit', () => { + test.each([ + { args: ['foobar', 'b'], expected: ['foo', 'ar'] }, + { args: ['foobar'], expected: ['f', 'o', 'o', 'b', 'a', 'r'] }, + ])('execute returns expected value', ({ args, expected }) => { + const formulaType = new RuntimeSplit() + const parsedArgs = formulaType.parseArgs(args) + const result = formulaType.execute({}, parsedArgs) + expect(result).toEqual(expected) + }) + + test.each([ + { args: ['foobar'], expected: undefined }, + { args: ['foobar', 'baz'], expected: undefined }, + ])('validates type of args', ({ args, expected }) => { + const formulaType = new RuntimeSplit() + const result = formulaType.validateTypeOfArgs(args) + expect(result).toStrictEqual(expected) + }) + + test.each([ + { args: [], expected: false }, + { args: ['foo'], expected: true }, + { args: ['foo', 'bar'], expected: true }, + { args: ['foo', 'bar', 'baz'], expected: false }, + ])('validates number of args', ({ args, expected }) => { + const formulaType = new RuntimeSplit() + const result = formulaType.validateNumberOfArgs(args) + expect(result).toStrictEqual(expected) + }) +}) + +describe('RuntimeIsEmpty', () => { + test.each([ + { args: [''], expected: true }, + { args: [undefined], expected: true }, + { args: [null], expected: true }, + { args: [[]], expected: true }, + { args: [{}], expected: true }, + { args: ['[]'], expected: false }, + { args: ['{}'], expected: false }, + { args: [' '], expected: true }, + { args: ['0'], expected: false }, + { args: [0], expected: false }, + { args: [0.1], expected: false }, + { args: ['foo'], expected: false }, + { args: [['foo']], expected: false }, + { args: [{ foo: 'bar' }], expected: false }, + { args: ['["foo"]'], expected: false }, + { args: ['{"foo": "bar"}'], expected: false }, + ])('execute returns expected value', ({ args, expected }) => { + const formulaType = new RuntimeIsEmpty() + const parsedArgs = formulaType.parseArgs(args) + const result = formulaType.execute({}, parsedArgs) + expect(result).toEqual(expected) + }) + + test.each([ + { args: [''], expected: undefined }, + { args: ['foobar'], expected: undefined }, + ])('validates type of args', ({ args, expected }) => { + const formulaType = new RuntimeIsEmpty() + const result = formulaType.validateTypeOfArgs(args) + expect(result).toStrictEqual(expected) + }) + + test.each([ + { args: [], expected: false }, + { args: ['foo'], expected: true }, + { args: ['foo', 'bar'], expected: false }, + ])('validates number of args', ({ args, expected }) => { + const formulaType = new RuntimeIsEmpty() + const result = formulaType.validateNumberOfArgs(args) + expect(result).toStrictEqual(expected) + }) +}) + +describe('RuntimeStrip', () => { + test.each([ + { args: [''], expected: null }, + { args: [' '], expected: null }, + { args: [' foo '], expected: 'foo' }, + { args: ['foo'], expected: 'foo' }, + ])('execute returns expected value', ({ args, expected }) => { + const formulaType = new RuntimeStrip() + const parsedArgs = formulaType.parseArgs(args) + const result = formulaType.execute({}, parsedArgs) + expect(result).toEqual(expected) + }) + + test.each([ + { args: [''], expected: undefined }, + { args: ['foobar'], expected: undefined }, + ])('validates type of args', ({ args, expected }) => { + const formulaType = new RuntimeStrip() + const result = formulaType.validateTypeOfArgs(args) + expect(result).toStrictEqual(expected) + }) + + test.each([ + { args: [], expected: false }, + { args: ['foo'], expected: true }, + { args: ['foo', 'bar'], expected: false }, + ])('validates number of args', ({ args, expected }) => { + const formulaType = new RuntimeStrip() + const result = formulaType.validateNumberOfArgs(args) + expect(result).toStrictEqual(expected) + }) +}) + +describe('RuntimeSum', () => { + test.each([ + { args: [[2.5, 3, 'foo', 4]], expected: null }, + { args: [['2', '3', '4']], expected: 9 }, + { args: [[2.5, 3, 4]], expected: 9.5 }, + ])('execute returns expected value', ({ args, expected }) => { + const formulaType = new RuntimeSum() + const parsedArgs = formulaType.parseArgs(args) + const result = formulaType.execute({}, parsedArgs) + expect(result).toEqual(expected) + }) + + test.each([ + { args: [''], expected: undefined }, + { args: ['[]'], expected: undefined }, + { args: [[]], expected: undefined }, + ])('validates type of args', ({ args, expected }) => { + const formulaType = new RuntimeSum() + const result = formulaType.validateTypeOfArgs(args) + expect(result).toStrictEqual(expected) + }) + + test.each([ + { args: [], expected: false }, + { args: ['foo'], expected: true }, + { args: ['foo', 'bar'], expected: false }, + ])('validates number of args', ({ args, expected }) => { + const formulaType = new RuntimeSum() + const result = formulaType.validateNumberOfArgs(args) + expect(result).toStrictEqual(expected) + }) +}) + +describe('RuntimeAvg', () => { + test.each([ + { args: [[1, 2, 'foo', 3, 4]], expected: null }, + { args: [[1, 2, 3, 4]], expected: 2.5 }, + ])('execute returns expected value', ({ args, expected }) => { + const formulaType = new RuntimeAvg() + const parsedArgs = formulaType.parseArgs(args) + const result = formulaType.execute({}, parsedArgs) + expect(result).toEqual(expected) + }) + + test.each([ + { args: [''], expected: undefined }, + { args: ['[]'], expected: undefined }, + { args: [[]], expected: undefined }, + ])('validates type of args', ({ args, expected }) => { + const formulaType = new RuntimeAvg() + const result = formulaType.validateTypeOfArgs(args) + expect(result).toStrictEqual(expected) + }) + + test.each([ + { args: [], expected: false }, + { args: ['foo'], expected: true }, + { args: ['foo', 'bar'], expected: false }, + ])('validates number of args', ({ args, expected }) => { + const formulaType = new RuntimeAvg() + const result = formulaType.validateNumberOfArgs(args) + expect(result).toStrictEqual(expected) + }) +}) + +describe('RuntimeAt', () => { + test.each([ + { args: [['foo', 'bar'], 0], expected: 'foo' }, + { args: ['foobar', 3], expected: 'b' }, + ])('execute returns expected value', ({ args, expected }) => { + const formulaType = new RuntimeAt() + const parsedArgs = formulaType.parseArgs(args) + const result = formulaType.execute({}, parsedArgs) + expect(result).toEqual(expected) + }) + + test.each([ + { args: ['[]', 1], expected: undefined }, + { args: [[], '2'], expected: undefined }, + ])('validates type of args', ({ args, expected }) => { + const formulaType = new RuntimeAt() + const result = formulaType.validateTypeOfArgs(args) + expect(result).toStrictEqual(expected) + }) + + test.each([ + { args: [], expected: false }, + { args: ['foo'], expected: false }, + { args: ['foo', 'bar'], expected: true }, + { args: ['foo', 'bar', 'baz'], expected: false }, + ])('validates number of args', ({ args, expected }) => { + const formulaType = new RuntimeAt() + const result = formulaType.validateNumberOfArgs(args) + expect(result).toStrictEqual(expected) + }) +}) + +describe('RuntimeToArray', () => { + test.each([ + { args: ['1,2,foo,bar'], expected: ['1', '2', 'foo', 'bar'] }, + { args: ['1'], expected: ['1'] }, + { args: [1], expected: ['1'] }, + { args: ['foo'], expected: ['foo'] }, + { args: [''], expected: [] }, + ])('execute returns expected value', ({ args, expected }) => { + const formulaType = new RuntimeToArray() + const parsedArgs = formulaType.parseArgs(args) + const result = formulaType.execute({}, parsedArgs) + expect(result).toEqual(expected) + }) + + test.each([ + { args: [''], expected: undefined }, + { args: [[]], expected: undefined }, + ])('validates type of args', ({ args, expected }) => { + const formulaType = new RuntimeToArray() + const result = formulaType.validateTypeOfArgs(args) + expect(result).toStrictEqual(expected) + }) + + test.each([ + { args: [], expected: false }, + { args: ['foo'], expected: true }, + { args: ['foo', 'bar'], expected: false }, + ])('validates number of args', ({ args, expected }) => { + const formulaType = new RuntimeToArray() + const result = formulaType.validateNumberOfArgs(args) + expect(result).toStrictEqual(expected) + }) +})