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:
parent
64b491908e
commit
aeb4ff6684
28 changed files with 767 additions and 160 deletions
backend
src/baserow
contrib/database
api/fields
fields
formula
test_utils
tests/baserow/contrib
database
api
export
field
formula
integrations/local_baserow
changelog/entries/unreleased/feature
premium/backend
src/baserow_premium/prompts
tests/baserow_premium_tests/export
web-frontend
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
@ -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.")
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -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]
|
||||
)
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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"},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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')
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -94,7 +94,7 @@ export default {
|
|||
},
|
||||
rollupFunctions() {
|
||||
return Object.values(this.$registry.getAll('formula_function')).filter(
|
||||
(f) => f.isRollupCompatible()
|
||||
(f) => f.isRollupCompatible(this.targetFieldFormulaType)
|
||||
)
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -76,6 +76,8 @@ describe('Formula Functions Test', () => {
|
|||
'todate',
|
||||
'todate_tz',
|
||||
'today',
|
||||
'toduration',
|
||||
'toseconds',
|
||||
'date_diff',
|
||||
'date_interval',
|
||||
'add',
|
||||
|
|
Loading…
Add table
Reference in a new issue