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

Draft: Resolve "Support for division/multiplication/tonumber formula for duration field"

This commit is contained in:
Davide Silvestri 2024-05-08 09:21:55 +00:00
parent 64b491908e
commit aeb4ff6684
28 changed files with 767 additions and 160 deletions
backend
changelog/entries/unreleased/feature
premium/backend
src/baserow_premium/prompts
tests/baserow_premium_tests/export
web-frontend
locales
modules/database
test/unit/database/formula

View file

@ -18,7 +18,10 @@ from baserow.contrib.database.fields.constants import (
)
from baserow.contrib.database.fields.models import Field
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.fields.utils.duration import prepare_duration_value_for_db
from baserow.contrib.database.fields.utils.duration import (
postgres_interval_to_seconds,
prepare_duration_value_for_db,
)
from baserow.core.utils import split_comma_separated_string
@ -300,12 +303,19 @@ class DurationFieldSerializer(serializers.Field):
)
def to_representation(self, value):
if isinstance(value, (int, float)):
# Durations are stored as the number of seconds for lookups/arrays in
# formula land, so just return the value in that case.
if isinstance(value, timedelta):
return value.total_seconds()
elif isinstance(value, str):
# Durations are stored as strings in the postgres format for lookups/arrays
# in formula land, so parse the string accordingly and return the value in
# seconds.
return postgres_interval_to_seconds(value)
elif isinstance(value, (int, float)):
# DEPRECATED: durations were stored as the number of seconds in formula
# land, so just return the value in that case.
return value
else:
return value.total_seconds()
raise ValueError("Invalid duration value.")
class PasswordSerializer(serializers.CharField):

View file

@ -180,8 +180,26 @@ class PathBasedUpdateStatementCollector:
updated_rows = 0
if self.update_statements:
updated_rows = qs.exclude(**self.update_statements).update(
**self.update_statements
annotations, filters = {}, Q()
for field, expr in self.update_statements.items():
if expr is None or not field.startswith("field_"):
continue
annotated_field = f"{field}_expr"
annotations[annotated_field] = expr
# Because the expression can evaluate to null and because of how the
# comparison with null should be handle in SQL
# (https://www.postgresql.org/docs/15/functions-comparison.html), we
# need to properly filter rows to correctly update only the ones that
# need to be updated.
filters |= Q(
**{f"{field}__isnull": False, f"{annotated_field}__isnull": True}
) | ~Q(**{field: expr})
updated_rows = (
qs.annotate(**annotations)
.filter(filters)
.update(**self.update_statements)
)
return updated_rows

View file

@ -223,7 +223,21 @@ def construct_all_possible_field_kwargs(
"target_field_name": "decimal_field",
"rollup_function": "sum",
"number_decimal_places": 3,
}
},
{
"name": "duration_rollup_sum",
"through_field_name": "link_row",
"target_field_name": "duration_field",
"rollup_function": "sum",
"duration_format": "h:mm",
},
{
"name": "duration_rollup_avg",
"through_field_name": "link_row",
"target_field_name": "duration_field",
"rollup_function": "avg",
"duration_format": "h:mm",
},
],
"lookup": [
{

View file

@ -51,6 +51,51 @@ def total_secs(
)
POSTGRES_INTERVAL_FORMAT = re.compile(
r"""
(?P<years>-?\d+)\s+years?\s*|
(?P<months>-?\d+)\s+mons?\s*|
(?P<days>-?\d+)\s+days?\s*|
(?P<time>(-?\d{1,2}):(\d{2}):(\d{2}))?
""",
re.VERBOSE,
)
def postgres_interval_to_seconds(interval_str: str) -> Optional[float]:
matches = POSTGRES_INTERVAL_FORMAT.finditer(interval_str)
params = {
"days": 0,
"seconds": 0,
"microseconds": 0,
"milliseconds": 0,
"minutes": 0,
"hours": 0,
"weeks": 0,
}
valid = False
for match in matches:
if match.group("years"):
params["days"] += int(match.group("years")) * 365
valid = True
if match.group("months"):
params["days"] += int(match.group("months")) * 30
valid = True
if match.group("days"):
params["days"] += int(match.group("days"))
valid = True
if match.group("time"):
time_parts = match.group("time").split(":")
params["hours"] += int(time_parts[0])
params["minutes"] += int(time_parts[1])
params["seconds"] += int(time_parts[2])
valid = True
return timedelta(**params).total_seconds() if valid else None
# These regexps are supposed to tokenize the provided duration value and to return a
# proper number of seconds based on format and the tokens. NOTE: Keep these in sync with
# web-frontend/modules/database/utils/duration.js:DURATION_REGEXPS
@ -401,7 +446,12 @@ def parse_duration_value(formatted_value: str, format: str) -> float:
except TypeError:
pass
# None of the regexps matches the formatted value
# If it's not one of the known formats, try to parse it as a postgres interval
# Lookups formula save duration in the postgres interval format in the database.
total_seconds = postgres_interval_to_seconds(formatted_value)
if total_seconds is not None:
return total_seconds
raise ValueError(f"{formatted_value} is not a valid duration string.")

View file

@ -1,4 +1,5 @@
import abc
from datetime import timedelta
from typing import List, Type
from django.db.models import (
@ -11,6 +12,7 @@ from django.db.models import (
Subquery,
Value,
)
from django.db.models.fields import DurationField
from django.db.models.functions import Coalesce
from baserow.contrib.database.formula.ast.tree import (
@ -274,6 +276,8 @@ def aggregate_wrapper(
# if the output field type is a number, return 0 instead of null
if isinstance(output_field, DecimalField):
expr = Coalesce(expr, Value(0), output_field=output_field)
elif isinstance(output_field, DurationField):
expr = Coalesce(expr, timedelta(hours=0), output_field=output_field)
return WrappedExpressionWithMetadata(
ExpressionWrapper(expr, output_field=output_field)

View file

@ -1,4 +1,5 @@
from abc import ABC
from datetime import timedelta
from decimal import Decimal
from typing import List
@ -204,6 +205,8 @@ def register_formula_functions(registry):
registry.register(BaserowToDateTz())
# Date interval functions
registry.register(BaserowDateInterval())
registry.register(BaserowSecondsToDuration())
registry.register(BaserowDurationToSeconds())
# Special functions
registry.register(BaserowAdd())
registry.register(BaserowMinus())
@ -541,18 +544,33 @@ class BaserowMultiply(TwoArgumentBaserowFunction):
arg1_type = [BaserowFormulaNumberType]
arg2_type = [BaserowFormulaNumberType]
@property
def arg_types(self) -> BaserowArgumentTypeChecker:
def type_checker(arg_index: int, arg_types: List[BaserowFormulaType]):
if arg_index == 1:
return arg_types[0].multipliable_types
else:
return [BaserowFormulaValidType]
return type_checker
def type_function(
self,
func_call: BaserowFunctionCall[UnTyped],
arg1: BaserowExpression[BaserowFormulaNumberType],
arg2: BaserowExpression[BaserowFormulaNumberType],
) -> BaserowExpression[BaserowFormulaType]:
return func_call.with_valid_type(
calculate_number_type([arg1.expression_type, arg2.expression_type])
)
return arg1.expression_type.multiply(func_call, arg1, arg2)
def to_django_expression(self, arg1: Expression, arg2: Expression) -> Expression:
return ExpressionWrapper(arg1 * arg2, output_field=arg1.output_field)
if isinstance(arg1.output_field, fields.DurationField):
total_secs = Extract(arg1, "epoch", output_field=arg2.output_field) * arg2
return ExpressionWrapper(
timedelta(seconds=1) * total_secs,
output_field=arg1.output_field,
)
else:
return ExpressionWrapper(arg1 * arg2, output_field=arg1.output_field)
class BaserowMinus(TwoArgumentBaserowFunction):
@ -1098,6 +1116,16 @@ class BaserowDivide(TwoArgumentBaserowFunction):
arg1_type = [BaserowFormulaNumberType]
arg2_type = [BaserowFormulaNumberType]
@property
def arg_types(self) -> BaserowArgumentTypeChecker:
def type_checker(arg_index: int, arg_types: List[BaserowFormulaType]):
if arg_index == 1:
return arg_types[0].dividable_types
else:
return [BaserowFormulaValidType]
return type_checker
def type_function(
self,
func_call: BaserowFunctionCall[UnTyped],
@ -1106,33 +1134,38 @@ class BaserowDivide(TwoArgumentBaserowFunction):
) -> BaserowExpression[BaserowFormulaType]:
# Show all the decimal places we can by default if the user makes a formula
# with a division to prevent weird results like `1/3=0`
return func_call.with_valid_type(
BaserowFormulaNumberType(NUMBER_MAX_DECIMAL_PLACES)
)
return arg1.expression_type.divide(func_call, arg1, arg2)
def to_django_expression(self, arg1: Expression, arg2: Expression) -> Expression:
# Prevent divide by zero's by swapping 0 for NaN causing the entire expression
# to evaluate to NaN. The front-end then treats NaN values as a per cell error
# to display to the user.
max_dp_output = fields.DecimalField(
max_digits=BaserowFormulaNumberType.MAX_DIGITS,
decimal_places=NUMBER_MAX_DECIMAL_PLACES,
)
return ExpressionWrapper(
arg1
/ Case(
When(
condition=(
EqualsExpr(arg2, Value(0), output_field=fields.BooleanField())
),
then=Value(Decimal("NaN")),
if isinstance(arg1.output_field, fields.DurationField):
expression = timedelta(seconds=1) * (
Extract(arg1, "epoch", output_field=arg2.output_field) / arg2
)
output_field = arg1.output_field
safe_value = Value(None)
else:
# Prevent divide by zero's by swapping 0 for NaN causing the entire
# expression to evaluate to NaN. The front-end then treats NaN values as a
# per cell error to display to the user.
output_field = fields.DecimalField(
max_digits=BaserowFormulaNumberType.MAX_DIGITS,
decimal_places=NUMBER_MAX_DECIMAL_PLACES,
)
expression = arg1 / Cast(arg2, output_field=output_field)
safe_value = Value(Decimal("NaN"))
safe_expression = Case(
When(
condition=(
EqualsExpr(arg2, Value(0), output_field=fields.BooleanField())
),
default=arg2,
output_field=max_dp_output,
then=safe_value,
),
output_field=max_dp_output,
default=expression,
output_field=output_field,
)
return ExpressionWrapper(safe_expression, output_field=output_field)
class BaserowHasOption(TwoArgumentBaserowFunction):
type = "has_option"
@ -1297,6 +1330,54 @@ class BaserowIf(ThreeArgumentBaserowFunction):
)
class BaserowDurationToSeconds(OneArgumentBaserowFunction):
type = "toseconds"
arg_type = [BaserowFormulaDurationType]
def type_function(
self,
func_call: BaserowFunctionCall[UnTyped],
arg: BaserowExpression[BaserowFormulaValidType],
) -> BaserowExpression[BaserowFormulaType]:
return func_call.with_valid_type(
BaserowFormulaNumberType(number_decimal_places=0)
)
def to_django_expression(self, arg: Expression) -> Expression:
return Extract(arg, "epoch", output_field=int_like_numeric_output_field())
class BaserowSecondsToDuration(OneArgumentBaserowFunction):
type = "toduration"
arg_type = [BaserowFormulaNumberType]
def type_function(
self,
func_call: BaserowFunctionCall[UnTyped],
arg: BaserowExpression[BaserowFormulaValidType],
) -> BaserowExpression[BaserowFormulaType]:
return func_call.with_valid_type(BaserowFormulaDurationType(nullable=True))
def to_django_expression(self, arg: Expression) -> Expression:
return ExpressionWrapper(
Case(
When(
condition=(
EqualsExpr(
arg,
Value(Decimal("NaN")),
output_field=fields.BooleanField(),
)
),
then=Value(None),
),
default=timedelta(seconds=1) * arg,
output_field=fields.DurationField(),
),
output_field=fields.DurationField(),
)
class BaserowToNumber(OneArgumentBaserowFunction):
type = "tonumber"
arg_type = [BaserowFormulaTextType]
@ -2291,6 +2372,7 @@ class BaserowCount(OneArgumentBaserowFunction):
BaserowFormulaMultipleSelectType,
]
aggregate = True
try_coerce_nullable_args_to_not_null = False
def type_function(
self,
@ -2375,6 +2457,26 @@ class BaserowFilter(TwoArgumentBaserowFunction):
)
def _to_django_aggregate_number_or_duration_expression(
func: Expression, arg: Expression, **func_kwargs
):
"""
An utility function to create an aggregate expression for a number or duration
field.
:param func: The aggregate function to use.
:param arg: The expression to aggregate.
:param func_kwargs: Additional keyword arguments to pass to the aggregate function.
:return: The aggregate expression.
"""
if isinstance(arg.output_field, fields.DurationField):
expr = func(Extract(arg, "epoch"), **func_kwargs) * timedelta(seconds=1)
else:
expr = func(arg, **func_kwargs)
return ExpressionWrapper(expr, output_field=arg.output_field)
class BaserowAny(OneArgumentBaserowFunction):
type = "any"
arg_type = [MustBeManyExprChecker(BaserowFormulaBooleanType)]
@ -2415,9 +2517,11 @@ class BaserowMax(OneArgumentBaserowFunction):
BaserowFormulaNumberType,
BaserowFormulaCharType,
BaserowFormulaDateType,
BaserowFormulaDurationType,
),
]
aggregate = True
try_coerce_nullable_args_to_not_null = False
def type_function(
self,
@ -2427,7 +2531,7 @@ class BaserowMax(OneArgumentBaserowFunction):
return func_call.with_valid_type(arg.expression_type)
def to_django_expression(self, arg: Expression) -> Expression:
return Max(arg, output_field=arg.output_field)
return _to_django_aggregate_number_or_duration_expression(Max, arg)
class BaserowMin(OneArgumentBaserowFunction):
@ -2438,9 +2542,11 @@ class BaserowMin(OneArgumentBaserowFunction):
BaserowFormulaNumberType,
BaserowFormulaCharType,
BaserowFormulaDateType,
BaserowFormulaDurationType,
),
]
aggregate = True
try_coerce_nullable_args_to_not_null = False
def type_function(
self,
@ -2450,15 +2556,16 @@ class BaserowMin(OneArgumentBaserowFunction):
return func_call.with_valid_type(arg.expression_type)
def to_django_expression(self, arg: Expression) -> Expression:
return Min(arg, output_field=arg.output_field)
return _to_django_aggregate_number_or_duration_expression(Min, arg)
class BaserowAvg(OneArgumentBaserowFunction):
type = "avg"
arg_type = [
MustBeManyExprChecker(BaserowFormulaNumberType),
MustBeManyExprChecker(BaserowFormulaNumberType, BaserowFormulaDurationType),
]
aggregate = True
try_coerce_nullable_args_to_not_null = False
def type_function(
self,
@ -2468,13 +2575,16 @@ class BaserowAvg(OneArgumentBaserowFunction):
return func_call.with_valid_type(arg.expression_type)
def to_django_expression(self, arg: Expression) -> Expression:
return Avg(arg, output_field=arg.output_field)
return _to_django_aggregate_number_or_duration_expression(Avg, arg)
class BaserowStdDevPop(OneArgumentBaserowFunction):
type = "stddev_pop"
arg_type = [MustBeManyExprChecker(BaserowFormulaNumberType)]
arg_type = [
MustBeManyExprChecker(BaserowFormulaNumberType, BaserowFormulaDurationType)
]
aggregate = True
try_coerce_nullable_args_to_not_null = False
def type_function(
self,
@ -2484,13 +2594,18 @@ class BaserowStdDevPop(OneArgumentBaserowFunction):
return func_call.with_valid_type(arg.expression_type)
def to_django_expression(self, arg: Expression) -> Expression:
return StdDev(arg, sample=False, output_field=arg.output_field)
return _to_django_aggregate_number_or_duration_expression(
StdDev, arg, sample=False
)
class BaserowStdDevSample(OneArgumentBaserowFunction):
type = "stddev_sample"
arg_type = [MustBeManyExprChecker(BaserowFormulaNumberType)]
arg_type = [
MustBeManyExprChecker(BaserowFormulaNumberType, BaserowFormulaDurationType)
]
aggregate = True
try_coerce_nullable_args_to_not_null = False
def type_function(
self,
@ -2500,7 +2615,9 @@ class BaserowStdDevSample(OneArgumentBaserowFunction):
return func_call.with_valid_type(arg.expression_type)
def to_django_expression(self, arg: Expression) -> Expression:
return StdDev(arg, sample=True, output_field=arg.output_field)
return _to_django_aggregate_number_or_duration_expression(
StdDev, arg, sample=True
)
class BaserowAggJoin(TwoArgumentBaserowFunction):
@ -2555,7 +2672,9 @@ class BaserowAggJoin(TwoArgumentBaserowFunction):
class BaserowSum(OneArgumentBaserowFunction):
type = "sum"
aggregate = True
arg_type = [MustBeManyExprChecker(BaserowFormulaNumberType)]
arg_type = [
MustBeManyExprChecker(BaserowFormulaNumberType, BaserowFormulaDurationType),
]
def type_function(
self,
@ -2565,13 +2684,16 @@ class BaserowSum(OneArgumentBaserowFunction):
return func_call.with_valid_type(arg.expression_type)
def to_django_expression(self, arg: Expression) -> Expression:
return Sum(arg, output_field=arg.output_field)
return _to_django_aggregate_number_or_duration_expression(Sum, arg)
class BaserowVarianceSample(OneArgumentBaserowFunction):
type = "variance_sample"
aggregate = True
arg_type = [MustBeManyExprChecker(BaserowFormulaNumberType)]
arg_type = [
MustBeManyExprChecker(BaserowFormulaNumberType, BaserowFormulaDurationType)
]
try_coerce_nullable_args_to_not_null = False
def type_function(
self,
@ -2581,13 +2703,18 @@ class BaserowVarianceSample(OneArgumentBaserowFunction):
return func_call.with_valid_type(arg.expression_type)
def to_django_expression(self, arg: Expression) -> Expression:
return Variance(arg, sample=True, output_field=arg.output_field)
return _to_django_aggregate_number_or_duration_expression(
Variance, arg, sample=True
)
class BaserowVariancePop(OneArgumentBaserowFunction):
type = "variance_pop"
aggregate = True
arg_type = [MustBeManyExprChecker(BaserowFormulaNumberType)]
arg_type = [
MustBeManyExprChecker(BaserowFormulaNumberType, BaserowFormulaDurationType)
]
try_coerce_nullable_args_to_not_null = False
def type_function(
self,
@ -2597,7 +2724,9 @@ class BaserowVariancePop(OneArgumentBaserowFunction):
return func_call.with_valid_type(arg.expression_type)
def to_django_expression(self, arg: Expression) -> Expression:
return Variance(arg, sample=False, output_field=arg.output_field)
return _to_django_aggregate_number_or_duration_expression(
Variance, arg, sample=False
)
class BaserowGetSingleSelectValue(OneArgumentBaserowFunction):

View file

@ -18,7 +18,7 @@ from django.db.models import (
When,
fields,
)
from django.db.models.functions import Cast, Coalesce, Extract, JSONObject
from django.db.models.functions import Cast, Coalesce, JSONObject
from baserow.contrib.database.formula.ast.exceptions import UnknownFieldReference
from baserow.contrib.database.formula.ast.tree import (
@ -372,7 +372,6 @@ class BaserowExpressionToDjangoExpressionGenerator(
self, db_column: str, model_field: fields.Field, already_in_subquery: bool
) -> Expression:
from baserow.contrib.database.fields.fields import (
DurationField,
MultipleSelectManyToManyField,
SingleSelectForeignKey,
)
@ -418,14 +417,6 @@ class BaserowExpressionToDjangoExpressionGenerator(
),
Value([], output_field=JSONField()),
)
elif isinstance(model_field, DurationField) and already_in_subquery:
# already_in_subquery is set to True in a lookup, but the JSON produced by
# looking up a duration field cannot contains intervals/timedelta, so we
# need to convert the value to a number of seconds instead.
return ExpressionWrapper(
Extract(db_column, "epoch"),
output_field=model_field,
)
else:
return ExpressionWrapper(
F(db_column),

View file

@ -153,6 +153,14 @@ class BaserowFormulaType(abc.ABC):
return []
@property
def multipliable_types(self) -> List[Type["BaserowFormulaValidType"]]:
return []
@property
def dividable_types(self) -> List[Type["BaserowFormulaValidType"]]:
return []
@property
@abc.abstractmethod
def limit_comparable_types(self) -> List[Type["BaserowFormulaValidType"]]:
@ -404,6 +412,28 @@ class BaserowFormulaType(abc.ABC):
f"{arg2.expression_type}"
)
def multiply(
self,
multiply_func_call: "tree.BaserowFunctionCall[UnTyped]",
arg1: "tree.BaserowExpression[BaserowFormulaValidType]",
arg2: "tree.BaserowExpression[BaserowFormulaValidType]",
) -> "tree.BaserowExpression[BaserowFormulaType]":
return multiply_func_call.with_invalid_type(
f"cannot perform multiplication on type {arg1.expression_type} and "
f"{arg2.expression_type}"
)
def divide(
self,
divide_func_call: "tree.BaserowFunctionCall[UnTyped]",
arg1: "tree.BaserowExpression[BaserowFormulaValidType]",
arg2: "tree.BaserowExpression[BaserowFormulaValidType]",
) -> "tree.BaserowExpression[BaserowFormulaType]":
return divide_func_call.with_invalid_type(
f"cannot perform division on type {arg1.expression_type} and "
f"{arg2.expression_type}"
)
def placeholder_empty_value(self) -> Expression:
"""
Should be a valid value safe to store in a formula field of this type as a

View file

@ -21,7 +21,10 @@ from baserow.contrib.database.fields.expressions import (
)
from baserow.contrib.database.fields.field_sortings import OptionallyAnnotatedOrderBy
from baserow.contrib.database.fields.mixins import get_date_time_format
from baserow.contrib.database.fields.utils.duration import D_H_M_S
from baserow.contrib.database.fields.utils.duration import (
D_H_M_S,
postgres_interval_to_seconds,
)
from baserow.contrib.database.formula.ast.tree import (
BaserowBooleanLiteral,
BaserowDecimalLiteral,
@ -309,6 +312,14 @@ class BaserowFormulaNumberType(
def subtractable_types(self) -> List[Type["BaserowFormulaValidType"]]:
return [type(self)]
@property
def multipliable_types(self) -> List[Type["BaserowFormulaValidType"]]:
return [type(self), BaserowFormulaDurationType]
@property
def dividable_types(self) -> List[Type["BaserowFormulaValidType"]]:
return [type(self)]
def add(
self,
add_func_call: "BaserowFunctionCall[UnTyped]",
@ -329,6 +340,31 @@ class BaserowFormulaNumberType(
calculate_number_type([arg1.expression_type, arg2.expression_type])
)
def multiply(
self,
multiply_func_call: "BaserowFunctionCall[UnTyped]",
arg1: "BaserowExpression[BaserowFormulaNumberType]",
arg2: "BaserowExpression[BaserowFormulaNumberType]",
):
if isinstance(arg2.expression_type, BaserowFormulaDurationType):
return multiply_func_call.with_valid_type(arg2.expression_type)
else:
return multiply_func_call.with_valid_type(
calculate_number_type([arg1.expression_type, arg2.expression_type])
)
def divide(
self,
divide_func_call: "BaserowFunctionCall[UnTyped]",
arg1: "BaserowExpression[BaserowFormulaNumberType]",
arg2: "BaserowExpression[BaserowFormulaNumberType]",
):
from baserow.contrib.database.fields.models import NUMBER_MAX_DECIMAL_PLACES
return divide_func_call.with_valid_type(
BaserowFormulaNumberType(NUMBER_MAX_DECIMAL_PLACES)
)
def should_recreate_when_old_type_was(self, old_type: "BaserowFormulaType") -> bool:
if isinstance(old_type, BaserowFormulaNumberType):
return self.number_decimal_places != old_type.number_decimal_places
@ -538,7 +574,8 @@ class BaserowFormulaDateIntervalType(
def placeholder_empty_baserow_expression(
self,
) -> "BaserowExpression[BaserowFormulaValidType]":
return literal(datetime.timedelta(hours=0))
func = formula_function_registry.get("date_interval")
return func(literal("0 hours"))
def is_searchable(self, field):
return True
@ -574,10 +611,18 @@ class BaserowFormulaDurationType(
def addable_types(self) -> List[Type["BaserowFormulaValidType"]]:
return [type(self), BaserowFormulaDateType]
@property
def multipliable_types(self) -> List[Type["BaserowFormulaValidType"]]:
return [BaserowFormulaNumberType]
@property
def subtractable_types(self) -> List[Type["BaserowFormulaValidType"]]:
return [type(self)]
@property
def dividable_types(self) -> List[Type["BaserowFormulaValidType"]]:
return [BaserowFormulaNumberType]
def add(
self,
add_func_call: "BaserowFunctionCall[UnTyped]",
@ -601,13 +646,40 @@ class BaserowFormulaDurationType(
)
)
def multiply(
self,
multiply_func_call: "BaserowFunctionCall[UnTyped]",
arg1: "BaserowExpression[BaserowFormulaNumberType]",
arg2: "BaserowExpression[BaserowFormulaNumberType]",
):
return multiply_func_call.with_valid_type(
BaserowFormulaDurationType(
duration_format=self.duration_format,
nullable=arg1.expression_type.nullable or arg2.expression_type.nullable,
)
)
def divide(
self,
multiply_func_call: "BaserowFunctionCall[UnTyped]",
arg1: "BaserowExpression[BaserowFormulaNumberType]",
arg2: "BaserowExpression[BaserowFormulaNumberType]",
):
return multiply_func_call.with_valid_type(
BaserowFormulaDurationType(
duration_format=self.duration_format,
nullable=arg1.expression_type.nullable or arg2.expression_type.nullable,
)
)
def placeholder_empty_value(self):
return Value(datetime.timedelta(hours=0), output_field=models.DurationField())
def placeholder_empty_baserow_expression(
self,
) -> "BaserowExpression[BaserowFormulaValidType]":
return literal(datetime.timedelta(hours=0))
func = formula_function_registry.get("date_interval")
return func(literal("0 hours"))
def get_order_by_in_array_expr(self, field, field_name, order_direction):
return JSONBSingleKeyArrayExpression(
@ -1085,9 +1157,10 @@ class BaserowFormulaArrayType(BaserowFormulaValidType):
list_item = parser.isoparse(list_item)
elif list_item is not None and self.sub_type.type == "duration":
# Arrays are stored as JSON which means the durations are converted to
# the number of seconds, we need to reparse them back first before
# a string, we need to parse them back first before
# giving the duration field type.
list_item = datetime.timedelta(seconds=list_item)
total_seconds = postgres_interval_to_seconds(list_item)
list_item = datetime.timedelta(seconds=total_seconds)
export_value = map_func(list_item)
if export_value is None:
export_value = ""
@ -1398,7 +1471,7 @@ def _lookup_formula_type_from_string(formula_type_string):
def literal(
arg: Union[str, int, bool, Decimal, datetime.timedelta]
arg: Union[str, int, bool, Decimal]
) -> BaserowExpression[BaserowFormulaValidType]:
"""
A helper function for building BaserowExpressions with literals
@ -1420,10 +1493,8 @@ def literal(
return decimal_literal_expr.with_valid_type(
BaserowFormulaNumberType(decimal_literal_expr.num_decimal_places())
)
elif isinstance(arg, datetime.timedelta):
return formula_function_registry.get("date_interval")(literal("0 hours"))
raise TypeError(f"Unknown literal type {type(arg)}")
else:
raise TypeError(f"Unknown literal type {type(arg)}")
class JSONBSingleKeyArrayExpression(Expression):

View file

@ -118,9 +118,12 @@ def setup_interesting_test_table(
link_table = data_fixture.create_database_table(
database=database, user=user, name="link_table"
)
other_table_primary_text_field = data_fixture.create_text_field(
link_table_primary_text_field = data_fixture.create_text_field(
table=link_table, name="text_field", primary=True
)
link_table_duration_field = data_fixture.create_duration_field(
table=link_table, name="duration_field"
)
decimal_link_table = data_fixture.create_database_table(
database=database, user=user, name="decimal_link_table"
)
@ -132,14 +135,14 @@ def setup_interesting_test_table(
)
name_to_field_id = {}
i = 0
other_table_primary_decimal_field = data_fixture.create_number_field(
decimal_table_primary_decimal_field = data_fixture.create_number_field(
table=decimal_link_table,
name="decimal_field",
primary=True,
number_decimal_places=3,
number_negative=True,
)
other_table_primary_file_field = data_fixture.create_file_field(
file_link_table_primary_file_field = data_fixture.create_file_field(
table=file_link_table,
name="file_field",
primary=True,
@ -262,7 +265,15 @@ def setup_interesting_test_table(
set(name_to_field_id.keys())
- set(values.keys())
- set([f["name"] for f in all_possible_kwargs_per_type["formula"]])
- {"lookup", "count", "rollup", "uuid", "autonumber"}
- {
"lookup",
"count",
"rollup",
"uuid",
"autonumber",
"duration_rollup_avg",
"duration_rollup_sum",
}
)
assert missing_fields == set(), (
"Please update the dictionary above with interesting test values for your new "
@ -287,42 +298,44 @@ def setup_interesting_test_table(
user=user,
table=link_table,
values={
other_table_primary_text_field.id: "linked_row_1",
link_table_primary_text_field.id: "linked_row_1",
link_table_duration_field.id: timedelta(minutes=1),
},
)
linked_row_2 = row_handler.create_row(
user=user,
table=link_table,
values={
other_table_primary_text_field.id: "linked_row_2",
link_table_primary_text_field.id: "linked_row_2",
link_table_duration_field.id: timedelta(minutes=3),
},
)
linked_row_3 = row_handler.create_row(
user=user,
table=link_table,
values={
other_table_primary_text_field.id: None,
link_table_primary_text_field.id: "",
},
)
linked_row_4 = row_handler.create_row(
user=user,
table=decimal_link_table,
values={
other_table_primary_decimal_field.id: "1.234",
decimal_table_primary_decimal_field.id: "1.234",
},
)
linked_row_5 = row_handler.create_row(
user=user,
table=decimal_link_table,
values={
other_table_primary_decimal_field.id: "-123.456",
decimal_table_primary_decimal_field.id: "-123.456",
},
)
linked_row_6 = row_handler.create_row(
user=user,
table=decimal_link_table,
values={
other_table_primary_decimal_field.id: None,
decimal_table_primary_decimal_field.id: None,
},
)
with freeze_time("2020-01-01 12:00"):
@ -335,14 +348,14 @@ def setup_interesting_test_table(
user=user,
table=file_link_table,
values={
other_table_primary_file_field.id: [{"name": user_file_1.name}],
file_link_table_primary_file_field.id: [{"name": user_file_1.name}],
},
)
linked_row_8 = row_handler.create_row(
user=user,
table=file_link_table,
values={
other_table_primary_file_field.id: None,
file_link_table_primary_file_field.id: None,
},
)

View file

@ -7,6 +7,7 @@ from baserow.contrib.database.api.fields.serializers import DurationFieldSeriali
from baserow.contrib.database.fields.utils.duration import (
DURATION_FORMAT_TOKENS,
DURATION_FORMATS,
postgres_interval_to_seconds,
tokenize_formatted_duration,
)
@ -139,6 +140,37 @@ def test_duration_serializer_to_internal_value(
)
@pytest.mark.parametrize(
"user_input,parsed_value",
(
("1 year", timedelta(days=365)),
("2 mons", timedelta(days=60)),
("3 days", timedelta(days=3)),
("04:05:06", timedelta(hours=4, minutes=5, seconds=6)),
(
"1 year 2 mons 3 days 04:05:06",
timedelta(days=365 + 60 + 3, hours=4, minutes=5, seconds=6),
),
(
"-1 year -2 mons +3 days 04:05:06",
timedelta(days=-(365 + 60) + 3, hours=4, minutes=5, seconds=6),
),
(
"1 year 2 mons -3 days 04:05:06",
timedelta(days=365 + 60 - 3, hours=4, minutes=5, seconds=6),
),
("1 year 1 mon 1 day", timedelta(days=365 + 30 + 1)),
("2 years 2 mons", timedelta(days=365 * 2 + 60)),
("1 year 1:02:03", timedelta(days=365, hours=1, minutes=2, seconds=3)),
("2 mons 3 days", timedelta(days=60 + 3)),
("3 days 03:04:05", timedelta(days=3, hours=3, minutes=4, seconds=5)),
("04:05:06", timedelta(hours=4, minutes=5, seconds=6)),
),
)
def test_postgres_interval_to_seconds(user_input, parsed_value):
assert postgres_interval_to_seconds(user_input) == parsed_value.total_seconds()
@pytest.mark.parametrize(
"duration_format,user_input",
[
@ -168,7 +200,7 @@ def test_duration_serializer_to_internal_value_with_invalid_values(
@pytest.mark.parametrize(
"duration_format,user_input,returned_value",
"duration_format,formula_lookup_value,serialized_value",
[
("h:mm", timedelta(seconds=0), 0),
("h:mm", timedelta(hours=1, minutes=1), 3660),
@ -180,14 +212,42 @@ def test_duration_serializer_to_internal_value_with_invalid_values(
("d h", timedelta(days=1, hours=1), 90000),
("d h:mm", timedelta(days=1, hours=1, minutes=1), 90060),
("d h:mm:ss", timedelta(days=1, hours=1, minutes=1, seconds=1), 90061),
# the field format doesn't matter for the following tests. Lookups return
# durations as strings in the postgres interval format, and those value don't
# depend on the field format.
(
"d h:mm:ss",
"1 year 1 mon 1 day",
timedelta(days=365 + 30 + 1).total_seconds(),
),
(
"d h:mm:ss",
"2 years 2 mons",
timedelta(days=365 * 2 + 60).total_seconds(),
),
(
"d h:mm:ss",
"1 year 1:02:03",
timedelta(days=365, hours=1, minutes=2, seconds=3).total_seconds(),
),
(
"d h:mm:ss",
"2 mons 3 days",
timedelta(days=60 + 3).total_seconds(),
),
(
"d h:mm:ss",
"3 days 03:04:05",
timedelta(days=3, hours=3, minutes=4, seconds=5).total_seconds(),
),
],
)
@pytest.mark.field_duration
def test_duration_serializer_to_representation(
duration_format, user_input, returned_value
duration_format, formula_lookup_value, serialized_value
):
serializer = DurationFieldSerializer(duration_format=duration_format)
assert serializer.to_representation(user_input) == returned_value
assert serializer.to_representation(formula_lookup_value) == serialized_value
@pytest.mark.field_duration

View file

@ -362,10 +362,12 @@ def test_get_row_serializer_with_user_field_names(data_fixture):
"formula_text": "test FORMULA",
"count": "3",
"rollup": "-122.222",
"duration_rollup_sum": 240.0,
"duration_rollup_avg": 120.0,
"lookup": [
{"id": 1, "value": "linked_row_1"},
{"id": 2, "value": "linked_row_2"},
{"id": 3, "value": None},
{"id": 3, "value": ""},
],
"formula_link_url_only": {"label": None, "url": "https://google.com"},
"formula_link_with_label": {

View file

@ -218,6 +218,14 @@ def test_serialize_group_by_metadata_on_all_fields_in_interesting_table(data_fix
{"count": 1, "field_rollup": "-122.222"},
{"count": 1, "field_rollup": "0.000"},
],
"duration_rollup_sum": [
{"count": 1, "field_duration_rollup_sum": 240.0},
{"count": 1, "field_duration_rollup_sum": 0.0},
],
"duration_rollup_avg": [
{"count": 1, "field_duration_rollup_avg": 120.0},
{"count": 1, "field_duration_rollup_avg": 0.0},
],
"duration_hm": [
{"count": 1, "field_duration_hm": 3660.0},
{"count": 1, "field_duration_hm": None},

View file

@ -232,28 +232,28 @@ def test_can_export_every_interesting_different_field_to_csv(
"file_link_row,file,single_select,multiple_select,multiple_collaborators,"
"phone_number,formula_text,formula_int,formula_bool,formula_decimal,formula_dateinterval,"
"formula_date,formula_singleselect,formula_email,formula_link_with_label,"
"formula_link_url_only,formula_multipleselect,count,rollup,lookup,uuid,"
"autonumber,password,ai\r\n"
"formula_link_url_only,formula_multipleselect,count,rollup,duration_rollup_sum,"
"duration_rollup_avg,lookup,uuid,autonumber,password,ai\r\n"
"1,,,,,,,,,0,False,,,,,,,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,"
"02/01/2021 13:00,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,02/01/2021 13:00,"
"user@example.com,user@example.com,,,,,,,,,,,,,,,,,,,test FORMULA,1,True,33.3333333333,"
"1d 0:00,2020-01-01,,,label (https://google.com),https://google.com,,0,0.000,,"
"00000000-0000-4000-8000-000000000002,1,,\r\n"
"1d 0:00,2020-01-01,,,label (https://google.com),https://google.com,,0,0.000,"
"0:00,0:00,,00000000-0000-4000-8000-000000000002,1,,\r\n"
"2,text,long_text,https://www.google.com,test@example.com,-1,1,-1.2,1.2,3,True,"
"02/01/2020 01:23,02/01/2020,01/02/2020 01:23,01/02/2020,01/02/2020 02:23,"
"01/02/2020 02:23,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,"
"02/01/2021 13:00,01/02/2021 12:00,01/02/2021,02/01/2021 12:00,02/01/2021,"
"02/01/2021 13:00,user@example.com,user@example.com,"
"1:01,1:01:06,1:01:06.6,1:01:06.66,1:01:06.666,1d 1h,1d 1:01,1d 1:01:06,"
'"linked_row_1,linked_row_2,unnamed row 3",unnamed row 1,'
'"linked_row_1,linked_row_2,",unnamed row 1,'
'"linked_row_1,linked_row_2","1.234,-123.456,unnamed row 3",'
'"name.txt (http://localhost:8000/media/user_files/test_hash.txt),unnamed row 2",'
'"a.txt (http://localhost:8000/media/user_files/hashed_name.txt),'
'b.txt (http://localhost:8000/media/user_files/other_name.txt)",A,"D,C,E",'
'"user2@example.com,user3@example.com",\'+4412345678,test FORMULA,1,True,33.3333333333,'
"1d 0:00,2020-01-01,A,test@example.com,label (https://google.com),https://google.com,"
'"D,C,E",3,-122.222,"linked_row_1,linked_row_2,",00000000-0000-4000-8000-000000000003,'
"2,True,I'm an AI.\r\n"
'"D,C,E",3,-122.222,0:04,0:02,"linked_row_1,linked_row_2,",'
"00000000-0000-4000-8000-000000000003,2,True,I'm an AI.\r\n"
)
assert contents == expected

View file

@ -1,4 +1,5 @@
import json
import math
from datetime import timedelta
from io import BytesIO
@ -786,6 +787,65 @@ def test_duration_field_can_be_used_in_formulas(data_fixture):
]
@pytest.mark.field_duration
@pytest.mark.django_db
def test_toduration_formula_set_null_values_if_the_argument_is_invalid(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
number_field = data_fixture.create_number_field(table=table)
formula_field = data_fixture.create_formula_field(
table=table, formula=f"field('{number_field.name}') / 2"
)
toduration_field = data_fixture.create_formula_field(
table=table,
formula=f"toduration(field('{formula_field.name}'))",
)
RowHandler().create_rows(
user,
table,
rows_values=[
{number_field.db_column: 3600},
{number_field.db_column: 60},
{},
],
)
model = table.get_model()
assert list(
model.objects.all().values(
formula_field.db_column,
toduration_field.db_column,
)
) == [
{
formula_field.db_column: 1800,
toduration_field.db_column: timedelta(seconds=1800),
},
{
formula_field.db_column: 30,
toduration_field.db_column: timedelta(seconds=30),
},
{
formula_field.db_column: 0,
toduration_field.db_column: timedelta(seconds=0),
},
]
FieldHandler().update_field(
user=user, field=formula_field, formula=f"field('{number_field.name}') / 0"
)
rows = model.objects.all().values(
formula_field.db_column,
toduration_field.db_column,
)
for r in rows:
assert math.isnan(r[formula_field.db_column])
assert r[toduration_field.db_column] is None
@pytest.mark.field_duration
@pytest.mark.django_db
def test_duration_field_can_be_looked_up(data_fixture):
@ -813,14 +873,14 @@ def test_duration_field_can_be_looked_up(data_fixture):
user=user,
table=table_b,
rows_values=[
{duration_field.db_column: 3600},
{duration_field.db_column: 24 * 3600},
{duration_field.db_column: 60},
],
model=model_b,
)
assert list(model_b.objects.values_list(duration_formula.db_column, flat=True)) == [
timedelta(seconds=3600 + 60),
timedelta(seconds=24 * 3600 + 60),
timedelta(seconds=60 + 60),
]
@ -834,13 +894,13 @@ def test_duration_field_can_be_looked_up(data_fixture):
model=model_a,
)
assert getattr(row, f"field_{lookup_field.id}") == [
{"id": row_b_1.id, "value": 3600},
{"id": row_b_2.id, "value": 60},
{"id": row_b_1.id, "value": "1 day"},
{"id": row_b_2.id, "value": "00:01:00"},
]
assert getattr(row, f"field_{lookup_formula.id}") == [
{"id": row_b_1.id, "value": 3600 + 60},
{"id": row_b_2.id, "value": 60 + 60},
{"id": row_b_1.id, "value": "1 day 00:01:00"},
{"id": row_b_2.id, "value": "00:02:00"},
]

View file

@ -17,7 +17,6 @@ from baserow.contrib.database.fields.exceptions import FieldDoesNotExist
from baserow.contrib.database.fields.field_types import TextFieldType
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.fields.models import (
Field,
LinkRowField,
MultipleSelectField,
NumberField,
@ -1363,13 +1362,15 @@ def test_can_undo_redo_duplicate_fields_of_interesting_table(api_client, data_fi
database = data_fixture.create_database_application(user=user)
field_handler = FieldHandler()
table, _, _, _, context = setup_interesting_test_table(data_fixture, user, database)
table, _, _, _, _ = setup_interesting_test_table(data_fixture, user, database)
original_field_set = list(table.field_set.all())
duplicated_fields = {}
for field in original_field_set:
duplicated_field, updated_fields = action_type_registry.get_by_type(
duplicated_field, _ = action_type_registry.get_by_type(
DuplicateFieldActionType
).do(user, field, duplicate_data=True)
).do(user, field.specific, duplicate_data=True)
duplicated_fields[field.id] = duplicated_field
assert field_handler.get_field(duplicated_field.id).name == f"{field.name} 2"
@ -1401,9 +1402,7 @@ def test_can_undo_redo_duplicate_fields_of_interesting_table(api_client, data_fi
for row in response_json["results"]:
for field in original_field_set:
row_1_value = extract_serialized_field_value(row[field.db_column])
duplicated_field = Field.objects.get(
table_id=table.id, name=f"{field.name} 2"
)
duplicated_field = duplicated_fields[field.id]
row_2_value = extract_serialized_field_value(
row[duplicated_field.db_column]
)

View file

@ -578,6 +578,8 @@ def test_human_readable_values(data_fixture):
"formula_multipleselect": "",
"lookup": "",
"autonumber": "1",
"duration_rollup_sum": "0:00",
"duration_rollup_avg": "0:00",
}
for key, value in blank_expected.items():
@ -605,7 +607,7 @@ def test_human_readable_values(data_fixture):
"email": "test@example.com",
"file": "a.txt, b.txt",
"file_link_row": "name.txt, unnamed row 2",
"link_row": "linked_row_1, linked_row_2, unnamed row 3",
"link_row": "linked_row_1, linked_row_2, ",
"long_text": "long_text",
"negative_decimal": "-1.2",
"negative_int": "-1",
@ -633,6 +635,8 @@ def test_human_readable_values(data_fixture):
"formula_multipleselect": "D, C, E",
"lookup": "linked_row_1, linked_row_2, ",
"autonumber": "2",
"duration_rollup_sum": "0:04",
"duration_rollup_avg": "0:02",
}
for key, value in expected.items():

View file

@ -176,6 +176,13 @@ VALID_FORMULA_TESTS = [
-366 * 24 * 3600,
),
("date_interval('1 year') - date_interval('1 day')", 364 * 24 * 3600),
("date_interval('1 minute') * 2", 60 * 2),
("3 * date_interval('1 minute')", 60 * 3),
("date_interval('1 minute') / 2", 30),
("date_interval('1 minute') / 0", None),
("toseconds(date_interval('1 minute'))", "60"),
("toseconds(toduration(60))", "60"),
("toduration(1 / 0)", None),
("now() > todate('20200101', 'YYYYMMDD')", True),
("todate('01123456', 'DDMMYYYY') < now()", False),
("todate('01123456', 'DDMMYYYY') < today()", False),
@ -593,7 +600,7 @@ def test_can_lookup_date_intervals(data_fixture, api_client):
)
assert response.status_code == HTTP_200_OK
assert [o[lookup_formula.db_column] for o in response.json()["results"]] == [
[{"id": row_1.id, "value": 2 * 24 * 3600}]
[{"id": row_1.id, "value": 172800}],
]
@ -1003,8 +1010,8 @@ INVALID_FORMULA_TESTS = [
"ERROR_WITH_FORMULA",
(
"Error with formula: argument number 1 given to function sum was of type "
"number but the only usable type for this argument is a list of number "
"values obtained from a lookup."
"number but the only usable type for this argument is a list of number, or "
"duration values obtained from a lookup."
),
),
(
@ -1029,8 +1036,8 @@ INVALID_FORMULA_TESTS = [
(
"Error with formula: argument number 1 given to function sum was of type "
"link "
"but the only usable type for this argument is a list of number values "
"obtained from a lookup."
"but the only usable type for this argument is a list of number, or "
"duration values obtained from a lookup."
),
),
(
@ -1605,6 +1612,8 @@ NULLABLE_FORMULA_TESTS = [
([{"type": "date", "name": "dt"}], "totext(field('dt'))", False),
([{"type": "date", "name": "dt"}], "field('dt') + date_interval('1d')", True),
([{"type": "date", "name": "dt"}], "field('dt') - date_interval('1d')", True),
([], "date_interval('1d') / 2", True),
([], "date_interval('1d') * 2", True),
(
[
{"type": "date", "name": "dt"},

View file

@ -1642,6 +1642,20 @@ def test_local_baserow_table_service_generate_schema_with_interesting_test_table
"metadata": {},
"type": "string",
},
field_db_column_by_name["duration_rollup_sum"]: {
"default": None,
"metadata": {},
"original_type": "rollup",
"title": "duration_rollup_sum",
"type": "string",
},
field_db_column_by_name["duration_rollup_avg"]: {
"default": None,
"metadata": {},
"original_type": "rollup",
"title": "duration_rollup_avg",
"type": "string",
},
field_db_column_by_name["lookup"]: {
"title": "lookup",
"default": None,

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Add support for divisions,multiplications, and rollup formulas with the duration field.",
"issue_number": 2416,
"bullet_points": [],
"created_at": "2024-04-19"
}

View file

@ -23,16 +23,16 @@ The markdown tables below contains the formula related functions.
| --- | --- | --- | --- |
| variance sample | Calculates the sample variance of the values and returns the result. The sample variance should be used when the provided values are only for a sample or subset of values for an underlying population. | variance_sample(numbers from lookup() or field()) | variance_sample(lookup("link field", "number field")) variance_sample(field("lookup field")) variance_sample(field("link field with number primary field")) |
| variance pop | Calculates the population variance of the values and returns the result. The population variance should be used when the provided values contain a value for every single piece of data in the population. | variance_pop(numbers from lookup() or field()) | variance_pop(lookup("link field", "number field")) variance_pop(field("lookup field")) variance_pop(field("link field with number primary field")) |
| sum | Sums all of the values and returns the result. | sum(numbers from lookup() or field()) | sum(lookup("link field", "number field")) sum(field("lookup field")) sum(field("link field with number primary field")) |
| stddev sample | Calculates the sample standard deviation of the values and returns the result. The sample deviation should be used when the provided values are only for a sample or subset of values for an underlying population. | stddev_sample(numbers from lookup() or field()) | stddev_sample(lookup("link field", "number field")) stddev_sample(field("lookup field")) stddev_sample(field("link field with number primary field")) |
| stddev pop | Calculates the population standard deviation of the values and returns the result. The population standard deviation should be used when the provided values contain a value for every single piece of data in the population. | stddev_pop(numbers from lookup() or field()) | stddev_pop(lookup("link field", "number field")) stddev_pop(field("lookup field")) stddev_pop(field("link field with number primary field")) |
| min | Returns the smallest number from all the looked up values provided. | min(numbers from a lookup() or field()) | min(lookup("link field", "number field")) min(field("lookup field")) min(field("link field with text primary field")) |
| max | Returns the largest number from all the looked up values provided. | max(numbers from a lookup() or field()) | max(lookup("link field", "number field")) max(field("lookup field")) max(field("link field with text primary field")) |
| sum | Sums all of the values and returns the result. | sum(numbers from lookup() or field()) | sum(lookup("link field", "number field")) sum(lookup("link field", "duration field")) sum(field("lookup field")) sum(field("link field with number primary field")) |
| stddev sample | Calculates the sample standard deviation of the values and returns the result. The sample deviation should be used when the provided values are only for a sample or subset of values for an underlying population. | stddev_sample(numbers from lookup() or field()) | stddev_sample(lookup("link field", "number field")) stddev_sample(lookup("link field", "duration field")) stddev_sample(field("lookup field")) stddev_sample(field("link field with number primary field")) |
| stddev pop | Calculates the population standard deviation of the values and returns the result. The population standard deviation should be used when the provided values contain a value for every single piece of data in the population. | stddev_pop(numbers from lookup() or field()) | stddev_pop(lookup("link field", "number field")) stddev_pop(lookup("link field", "duration field")) . stddev_pop(field("lookup field")) stddev_pop(field("link field with number primary field")) |
| min | Returns the smallest number from all the looked up values provided. | min(numbers from a lookup() or field()) | min(lookup("link field", "number field")) min(lookup("link field", "duration field")) . min(field("lookup field")) min(field("link field with text primary field")) |
| max | Returns the largest number from all the looked up values provided. | max(numbers from a lookup() or field()) | max(lookup("link field", "number field")) max(lookup("link field", "duration field")) max(field("lookup field")) max(field("link field with text primary field")) |
| join | Concats all of the values from the first input together using the values from the second input. | join(text from lookup() or field(), text) | join(lookup("link field", "number field"), "_") join(field("lookup field"), field("different lookup field")) join(field("link field with text primary field"), ",") |
| filter | Filters down an expression involving a lookup/link field reference or a lookup function call. | filter(an expression involving lookup() or field(a link/lookup field), boolean) | sum(filter(lookup("link field", "number field"), lookup("link field", "number field") > 10)) filter(field("lookup field"), contains(field("lookup field"), "a")) filter(field("link field") + "a", length(field("link field")) > 10") |
| every | Returns true if every one of the provided looked up values is true, false otherwise. | every(boolean values from a lookup() or field()) | every(field("my lookup") = "test") |
| count | Returns the number of items in its first argument. | count(array) | count(field('my link row field')) |
| avg | Averages all of the values and returns the result. | avg(numbers from lookup() or field()) | avg(lookup("link field", "number field")) avg(field("lookup field")) avg(field("link field with number primary field")) |
| avg | Averages all of the values and returns the result. | avg(numbers from lookup() or field()) | avg(lookup("link field", "number field")) avg(lookup("link field", "duration field")) avg(field("lookup field")) avg(field("link field with number primary field")) |
| any | Returns true if any one of the provided looked up values is true, false if they are all false. | any(boolean values from a lookup() or field()) | any(field("my lookup") = "test") |
```
@ -41,10 +41,10 @@ The markdown tables below contains the formula related functions.
| --- | --- | --- | --- |
| when empty | If the first input is calculated to be empty the second input will be returned instead, otherwise if the first input is not empty the first will be returned. | when_empty(any, same type as the first) | when_empty(field("a"), "default") |
| row id | Returns the rows unique identifying number. | row_id() | concat("Row ", row_id()) |
| minus `-` | Returns its two arguments subtracted. | number - number minus(number, number) date - date date - date_interval date_interval - date_interval | 3-1 = 2 |
| minus `-` | Returns its two arguments subtracted. | number - number minus(number, number) date - date date - duration duration - duration | 3-1 = 2 |
| lookup | Looks up the values from a field in another table for rows in a link row field. The first argument should be the name of a link row field in the current table and the second should be the name of a field in the linked table. | lookup('a link row field name', 'field name in other the table') | lookup('link row field', 'first name') = lookup('link row field', 'last name') |
| field | Returns the field named by the single text argument. | field('a field name') | field('my text field') = 'flag' |
| add `+` | Returns its two arguments added together. | number + number text + text date + date_interval date_interval + date_interval date_interval + date add(number, number) | 1+1 = 2 'a' + 'b' = 'ab' |
| add `+` | Returns its two arguments added together. | number + number text + text date + duration duration + duration duration + date add(number, number) | 1+1 = 2 'a' + 'b' = 'ab' |
| date interval | Returns the date interval corresponding to the provided argument. | date_interval(text) | date_interval('1 year') date_interval('2 seconds') |
```
@ -66,7 +66,9 @@ Functions | Details | Syntax | Examples |
| day | Returns the day of the month as a number between 1 to 31 from the argument. | day(date) | day(todate('20210101', 'YYYYMMDD')) = 1 |
| datetime_format | Converts the date to text given a way of formatting the date. | datetime_format(date, text) | datetime_format(field('date field'), 'YYYY') |
| date_diff | Given a date unit to measure in as the first argument ('year', 'month', 'week', 'day', 'hour', 'minute', 'seconds') calculates and returns the number of units from the second argument to the third. | date_diff(text, date, date) | date_diff('yy', todate('2000-01-01', 'YYYY-MM-DD'), todate('2020-01-01', 'YYYY-MM-DD')) = 20 |
| datetime_format_tz|
| datetime_format_tz| Returns the first argument converted into a date given a date format string as the second argument and the timezone provided as third argument. | datetime_format_tz(date, text, text) | datetime_format(field('date field'), 'YYYY', 'Europe/Rome')|
| toduration | Converts the number of seconds provided into a duration. | toduration(number) | toduration(3600) = date_interval('1 hour') |
| toseconds | Converts the duration provided into the corresponding number of seconds. | toseconds(duration) | toseconds(date_interval('1 hour')) == 3600 |
Boolean functions
@ -101,7 +103,7 @@ The markdown table below contains the number functions.
| sqrt | Returns the square root of the argument provided. | sqrt(number) | sqrt(9) = 3 |
| least | Returns the smallest of the two inputs. | least(number, number) | least(1,2) = 1 |
| greatest | Returns the greatest value of the two inputs. | greatest(number, number) | greatest(1,2) = 2 |
| divide `/` | Returns its two arguments divided, the first divided by the second. | number / number divide(number, number) | 10/2 = 5 |
| divide `/` | Returns its two arguments divided, the first divided by the second. | number / number duration / number divide(number, number) | 10/2 = 5 date_interval('1 minute') / 60 = date_interval('1 second') |
| abs | Returns the absolute value for the argument number provided. | abs(number) | abs(1.49) = 1.49 |
| ceil | Returns the smallest integer that is greater than or equal the argument number provided. | ceil(number) | ceil(1.49) = 2 |
| even | Returns true if the argument provided is an even number, false otherwise. | even(number) | even(2) = true |
@ -111,7 +113,7 @@ The markdown table below contains the number functions.
| ln | Natural logarithm function: returns the exponent to which the constant e ≈ 2.718 must be raised to produce the argument. | ln(number) | ln(2.718) = 1.000 |
| log | Logarithm function: returns the exponent to which the first argument must be raised to produce the second argument. | log(number, number) | log(3, 9) = 2 |
| mod | Returns the remainder of the division between the first argument and the second argument. | mod(number, number) | mod(5, 2) = 1 |
| multiply `*` | Returns its two arguments multiplied together. | multiply(number, number) | 2*5 = 10 |
| multiply `*` | Returns its two arguments multiplied together. | multiply(number, number) multiply(duration, number) | 2*5 = 10 date_interval('1 second') * 60 = date_interval('1 minute') |
| odd | Returns true if the argument provided is an odd number, false otherwise. | odd(number) | odd(2) = false |
| power | Returns the result of the first argument raised to the second argument exponent. | power(number, number) | power(3, 2) = 9 |
| round | Returns first argument rounded to the number of digits specified by the second argument. | round(number, number) | round(1.12345,2) = 1.12 |
@ -209,7 +211,9 @@ Use the todate function to create a constant date inside a formula like so: toda
Using Date intervals
Subtracting two dates returns the difference in time between the two dates: field('date a') - field('date b'). The date_interval function lets you create intervals inside the formula to work with.
Subtracting two dates returns a duration representing the difference in time between the two dates: field('date a') - field('date b'). The date_interval function lets you create intervals inside the formula to work with.
Multiplying a duration and a number the result will be a duration where the number of seconds are multiplied for the number argument.
Need to calculate a new date based on a date/time interval? Use the date_interval function like so: field('my date column') - date_interval('1 year')

View file

@ -102,6 +102,8 @@ def test_can_export_every_interesting_different_field_to_json(
"formula_multipleselect": [],
"count": 0,
"rollup": "0.000",
"duration_rollup_sum": "0:00",
"duration_rollup_avg": "0:00",
"lookup": [],
"uuid": "00000000-0000-4000-8000-000000000002",
"autonumber": 1,
@ -149,7 +151,7 @@ def test_can_export_every_interesting_different_field_to_json(
"link_row": [
"linked_row_1",
"linked_row_2",
"unnamed row 3"
""
],
"self_link_row": [
"unnamed row 1"
@ -215,6 +217,8 @@ def test_can_export_every_interesting_different_field_to_json(
],
"count": 3,
"rollup": "-122.222",
"duration_rollup_sum": "0:04",
"duration_rollup_avg": "0:02",
"lookup": [
"linked_row_1",
"linked_row_2",
@ -369,6 +373,8 @@ def test_can_export_every_interesting_different_field_to_xml(
<formula-multipleselect/>
<count>0</count>
<rollup>0.000</rollup>
<duration-rollup-sum>0:00</duration-rollup-sum>
<duration-rollup-avg>0:00</duration-rollup-avg>
<lookup/>
<uuid>00000000-0000-4000-8000-000000000002</uuid>
<autonumber>1</autonumber>
@ -416,7 +422,7 @@ def test_can_export_every_interesting_different_field_to_xml(
<link-row>
<item>linked_row_1</item>
<item>linked_row_2</item>
<item>unnamed row 3</item>
<item/>
</link-row>
<self-link-row>
<item>unnamed row 1</item>
@ -482,6 +488,8 @@ def test_can_export_every_interesting_different_field_to_xml(
</formula-multipleselect>
<count>3</count>
<rollup>-122.222</rollup>
<duration-rollup-sum>0:04</duration-rollup-sum>
<duration-rollup-avg>0:02</duration-rollup-avg>
<lookup>
<item>linked_row_1</item>
<item>linked_row_2</item>

View file

@ -475,6 +475,8 @@
"getImageHeightDescription": "Returns the image height from a single file returned from the index function.",
"getIsImageDescription": "Returns if the single file returned from the index function is an image or not.",
"indexDescription": "Returns the file from a file field at the position provided by the second argument.",
"secondsToDurationDescription": "Converts the number of seconds provided into a duration.",
"durationToSecondsDescription": "Converts the duration provided into the corresponding number of seconds.",
"hasOptionDescription": "Returns true if the first argument is a multiple select field or a lookup to a single select field and the second argument is one of the options."
},
"functionnalGridViewFieldLinkRow": {

View file

@ -94,7 +94,7 @@ export default {
},
rollupFunctions() {
return Object.values(this.$registry.getAll('formula_function')).filter(
(f) => f.isRollupCompatible()
(f) => f.isRollupCompatible(this.targetFieldFormulaType)
)
},
},

View file

@ -1,5 +1,5 @@
<template functional>
<div v-if="props.value" class="array-field__item">
<div v-if="props.value !== null" class="array-field__item">
<span>
{{ $options.methods.formatValue(props.field, props.value) }}
</span>

View file

@ -1,4 +1,10 @@
import { Registerable } from '@baserow/modules/core/registry'
import {
BaserowFormulaBooleanType,
BaserowFormulaNumberType,
BaserowFormulaDurationType,
BaserowFormulaTextType,
} from '@baserow/modules/database/formula/formulaTypes'
export class BaserowFunctionDefinition extends Registerable {
getDescription() {
@ -36,7 +42,7 @@ export class BaserowFunctionDefinition extends Registerable {
return ''
}
isRollupCompatible() {
isRollupCompatible(targetFieldType) {
return false
}
}
@ -146,9 +152,9 @@ export class BaserowAdd extends BaserowFunctionDefinition {
return [
'number + number',
'text + text',
'date + date_interval',
'date_interval + date_interval',
'date_interval + date',
'date + duration',
'duration + duration',
'duration + date',
'add(number, number)',
]
}
@ -185,8 +191,8 @@ export class BaserowMinus extends BaserowFunctionDefinition {
'number - number',
'minus(number, number)',
'date - date',
'date - date_interval',
'date_interval - date_interval',
'date - duration',
'duration - duration',
]
}
@ -218,7 +224,12 @@ export class BaserowMultiply extends BaserowFunctionDefinition {
}
getSyntaxUsage() {
return ['number * number', 'multiply(number, number)']
return [
'number * number',
'multiply(number, number)',
'multiply(duration, number)',
'multiply(number, duration)',
]
}
getExamples() {
@ -249,7 +260,11 @@ export class BaserowDivide extends BaserowFunctionDefinition {
}
getSyntaxUsage() {
return ['number / number', 'divide(number, number)']
return [
'number / number',
'divide(number, number)',
'divide(duration, number)',
]
}
getExamples() {
@ -569,6 +584,52 @@ export class BaserowIsBlank extends BaserowFunctionDefinition {
}
}
export class BaserowDurationToSeconds extends BaserowFunctionDefinition {
static getType() {
return 'toseconds'
}
getDescription() {
const { i18n } = this.app
return i18n.t('formulaFunctions.durationToSecondsDescription')
}
getSyntaxUsage() {
return ['toseconds(duration)']
}
getExamples() {
return ["toseconds(duration('10 minutes'))"]
}
getFormulaType() {
return 'number'
}
}
export class BaserowSecondsToDuration extends BaserowFunctionDefinition {
static getType() {
return 'toduration'
}
getDescription() {
const { i18n } = this.app
return i18n.t('formulaFunctions.secondsToDurationDescription')
}
getSyntaxUsage() {
return ['toduration(number)']
}
getExamples() {
return ['toduration(60)']
}
getFormulaType() {
return 'duration'
}
}
export class BaserowIsNull extends BaserowFunctionDefinition {
static getType() {
return 'is_null'
@ -992,7 +1053,7 @@ export class BaserowDateInterval extends BaserowFunctionDefinition {
}
getFormulaType() {
return 'date_interval'
return 'duration'
}
}
@ -1167,7 +1228,7 @@ export class BaserowCount extends BaserowFunctionDefinition {
return 'array'
}
isRollupCompatible() {
isRollupCompatible(targetFieldType) {
return true
}
}
@ -2009,8 +2070,8 @@ export class BaserowAny extends BaserowFunctionDefinition {
return 'array'
}
isRollupCompatible() {
return true
isRollupCompatible(targetFieldType) {
return targetFieldType === BaserowFormulaBooleanType.getType()
}
}
@ -2036,8 +2097,8 @@ export class BaserowEvery extends BaserowFunctionDefinition {
return 'array'
}
isRollupCompatible() {
return true
isRollupCompatible(targetFieldType) {
return targetFieldType === BaserowFormulaBooleanType.getType()
}
}
@ -2068,8 +2129,12 @@ export class BaserowMax extends BaserowFunctionDefinition {
return 'array'
}
isRollupCompatible() {
return true
isRollupCompatible(targetFieldType) {
return [
BaserowFormulaNumberType.getType(),
BaserowFormulaDurationType.getType(),
BaserowFormulaTextType.getType(),
].includes(targetFieldType)
}
}
@ -2100,8 +2165,12 @@ export class BaserowMin extends BaserowFunctionDefinition {
return 'array'
}
isRollupCompatible() {
return true
isRollupCompatible(targetFieldType) {
return [
BaserowFormulaNumberType.getType(),
BaserowFormulaDurationType.getType(),
BaserowFormulaTextType.getType(),
].includes(targetFieldType)
}
}
@ -2158,8 +2227,11 @@ export class BaserowStddevPop extends BaserowFunctionDefinition {
return 'array'
}
isRollupCompatible() {
return true
isRollupCompatible(targetFieldType) {
return [
BaserowFormulaNumberType.getType(),
BaserowFormulaDurationType.getType(),
].includes(targetFieldType)
}
}
@ -2189,8 +2261,11 @@ export class BaserowStddevSample extends BaserowFunctionDefinition {
return 'array'
}
isRollupCompatible() {
return true
isRollupCompatible(targetFieldType) {
return [
BaserowFormulaNumberType.getType(),
BaserowFormulaDurationType.getType(),
].includes(targetFieldType)
}
}
@ -2220,8 +2295,11 @@ export class BaserowVarianceSample extends BaserowFunctionDefinition {
return 'array'
}
isRollupCompatible() {
return true
isRollupCompatible(targetFieldType) {
return [
BaserowFormulaNumberType.getType(),
BaserowFormulaDurationType.getType(),
].includes(targetFieldType)
}
}
@ -2251,8 +2329,11 @@ export class BaserowVariancePop extends BaserowFunctionDefinition {
return 'array'
}
isRollupCompatible() {
return true
isRollupCompatible(targetFieldType) {
return [
BaserowFormulaNumberType.getType(),
BaserowFormulaDurationType.getType(),
].includes(targetFieldType)
}
}
@ -2282,8 +2363,11 @@ export class BaserowAvg extends BaserowFunctionDefinition {
return 'array'
}
isRollupCompatible() {
return true
isRollupCompatible(targetFieldType) {
return [
BaserowFormulaNumberType.getType(),
BaserowFormulaDurationType.getType(),
].includes(targetFieldType)
}
}
@ -2313,8 +2397,11 @@ export class BaserowSum extends BaserowFunctionDefinition {
return 'array'
}
isRollupCompatible() {
return true
isRollupCompatible(targetFieldType) {
return [
BaserowFormulaNumberType.getType(),
BaserowFormulaDurationType.getType(),
].includes(targetFieldType)
}
}

View file

@ -141,6 +141,8 @@ import {
BaserowIf,
BaserowIsBlank,
BaserowIsNull,
BaserowDurationToSeconds,
BaserowSecondsToDuration,
BaserowLessThan,
BaserowLessThanOrEqual,
BaserowLower,
@ -562,7 +564,16 @@ export default (context) => {
app.$registry.register('formula_function', new BaserowDateDiff(context))
// Date interval functions
app.$registry.register('formula_function', new BaserowDateInterval(context))
// Special functions
app.$registry.register(
'formula_function',
new BaserowDurationToSeconds(context)
)
app.$registry.register(
'formula_function',
new BaserowSecondsToDuration(context)
)
// Special functions. NOTE: rollup compatible functions are shown field sub-form in
// the same order as they are listed here.
app.$registry.register('formula_function', new BaserowAdd(context))
app.$registry.register('formula_function', new BaserowMinus(context))
app.$registry.register('formula_function', new BaserowField(context))
@ -581,16 +592,16 @@ export default (context) => {
app.$registry.register('formula_function', new BaserowWhenEmpty(context))
app.$registry.register('formula_function', new BaserowAny(context))
app.$registry.register('formula_function', new BaserowEvery(context))
app.$registry.register('formula_function', new BaserowMax(context))
app.$registry.register('formula_function', new BaserowMin(context))
app.$registry.register('formula_function', new BaserowMax(context))
app.$registry.register('formula_function', new BaserowCount(context))
app.$registry.register('formula_function', new BaserowSum(context))
app.$registry.register('formula_function', new BaserowAvg(context))
app.$registry.register('formula_function', new BaserowJoin(context))
app.$registry.register('formula_function', new BaserowStddevPop(context))
app.$registry.register('formula_function', new BaserowStddevSample(context))
app.$registry.register('formula_function', new BaserowVarianceSample(context))
app.$registry.register('formula_function', new BaserowVariancePop(context))
app.$registry.register('formula_function', new BaserowAvg(context))
app.$registry.register('formula_function', new BaserowSum(context))
app.$registry.register('formula_function', new BaserowFilter(context))
app.$registry.register('formula_function', new BaserowTrunc(context))
app.$registry.register('formula_function', new BaserowIsNaN(context))

View file

@ -76,6 +76,8 @@ describe('Formula Functions Test', () => {
'todate',
'todate_tz',
'today',
'toduration',
'toseconds',
'date_diff',
'date_interval',
'add',