1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-03 04:35:31 +00:00

Merge branch '2217-add-formats-with-days-to-the-duration-field' into 'develop'

1️⃣ Support for duration in formula: add formats with days

Closes 

See merge request 
This commit is contained in:
Davide Silvestri 2024-01-18 08:36:56 +00:00
commit 21b66a7aaa
21 changed files with 1355 additions and 369 deletions
backend
changelog/entries/unreleased/feature
premium/backend/tests/baserow_premium_tests/export
web-frontend

View file

@ -142,6 +142,9 @@ def construct_all_possible_field_kwargs(
{"name": "duration_hms_s", "duration_format": "h:mm:ss.s"},
{"name": "duration_hms_ss", "duration_format": "h:mm:ss.ss"},
{"name": "duration_hms_sss", "duration_format": "h:mm:ss.sss"},
{"name": "duration_dh", "duration_format": "d h"},
{"name": "duration_dhm", "duration_format": "d h:mm"},
{"name": "duration_dhms", "duration_format": "d h:mm:ss"},
],
"link_row": [
{"name": "link_row", "link_row_table": link_table},

View file

@ -190,9 +190,12 @@ from .registries import (
field_type_registry,
)
from .utils.duration import (
DURATION_FORMAT_TOKENS,
DURATION_FORMATS,
convert_duration_input_value_to_timedelta,
duration_value_sql_to_text,
duration_value_to_timedelta,
format_duration_value,
get_duration_search_expression,
is_duration_format_conversion_lossy,
prepare_duration_value_for_db,
)
@ -1716,32 +1719,19 @@ class DurationFieldType(FieldType):
return prepare_duration_value_for_db(value, instance.duration_format)
def get_search_expression(self, field: Field, queryset: QuerySet) -> Expression:
search_exprs = []
for token in field.duration_format.split(":"):
search_expr = DURATION_FORMAT_TOKENS[token]["search_expr"](field.db_column)
search_exprs.append(search_expr)
separators = [Value(" ")] * len(search_exprs)
# interleave a separator between each extract_expr
exprs = [expr for pair in zip(search_exprs, separators) for expr in pair][:-1]
return Func(*exprs, function="CONCAT")
return get_duration_search_expression(field)
def random_value(self, instance, fake, cache):
random_seconds = fake.random.random() * 60 * 60 * 2
return convert_duration_input_value_to_timedelta(
random_seconds, instance.duration_format
)
random_seconds = fake.random.random() * 60 * 60 * 24
# if we have days in the format, ensure the random value is picked accordingly
if "d" in instance.duration_format:
random_seconds *= 30
return duration_value_to_timedelta(random_seconds, instance.duration_format)
def get_alter_column_prepare_old_value(self, connection, from_field, to_field):
to_field_type = field_type_registry.get_by_model(to_field)
if to_field_type.type in (TextFieldType.type, LongTextFieldType.type):
format_func = " || ':' || ".join(
[
DURATION_FORMAT_TOKENS[format_token]["sql_to_text"]
for format_token in from_field.duration_format.split(":")
]
)
return f"p_in = {format_func};"
return f"p_in = {duration_value_sql_to_text(from_field)};"
elif to_field_type.type == NumberFieldType.type:
return "p_in = EXTRACT(EPOCH FROM CAST(p_in AS INTERVAL))::NUMERIC;"
@ -1768,19 +1758,7 @@ class DurationFieldType(FieldType):
:return: The formatted string.
"""
if value is None:
return None
secs_in_a_min = 60
secs_in_an_hour = 60 * 60
total_seconds = value.total_seconds()
hours = int(total_seconds / secs_in_an_hour)
minutes = int(total_seconds % secs_in_an_hour / secs_in_a_min)
seconds = total_seconds % secs_in_a_min
format_func = DURATION_FORMATS[duration_format]["format_func"]
return format_func(hours, minutes, seconds)
return format_duration_value(value, duration_format)
def get_export_value(
self,
@ -1788,22 +1766,9 @@ class DurationFieldType(FieldType):
field_object: "FieldObject",
rich_value: bool = False,
) -> Optional[str]:
if value is None:
return None
secs_in_a_min = 60
secs_in_an_hour = 60 * 60
total_seconds = value.total_seconds()
hours = int(total_seconds / secs_in_an_hour)
mins = int(total_seconds % secs_in_an_hour / secs_in_a_min)
secs = total_seconds % secs_in_a_min
field = field_object["field"]
duration_format = field.duration_format
format_func = DURATION_FORMATS[duration_format]["format_func"]
return format_func(hours, mins, secs)
return self.format_duration(value, duration_format)
def should_backup_field_data_for_same_type_update(
self, old_field: DurationField, new_field_attrs: Dict[str, Any]
@ -1812,17 +1777,14 @@ class DurationFieldType(FieldType):
"duration_format", old_field.duration_format
)
formats_needing_a_backup = DURATION_FORMATS[old_field.duration_format][
"backup_field_if_changing_to"
]
return new_duration_format in formats_needing_a_backup
return is_duration_format_conversion_lossy(
new_duration_format, old_field.duration_format
)
def force_same_type_alter_column(self, from_field, to_field):
curr_format = from_field.duration_format
formats_needing_alter_column = DURATION_FORMATS[curr_format][
"backup_field_if_changing_to"
]
return to_field.duration_format in formats_needing_alter_column
return is_duration_format_conversion_lossy(
to_field.duration_format, from_field.duration_format
)
def serialize_metadata_for_row_history(
self,

View file

@ -9,9 +9,7 @@ from django.db.models.fields.related_descriptors import (
)
from django.utils.functional import cached_property
from baserow.contrib.database.fields.utils.duration import (
convert_duration_input_value_to_timedelta,
)
from baserow.contrib.database.fields.utils.duration import duration_value_to_timedelta
from baserow.contrib.database.formula import BaserowExpression, FormulaHandler
from baserow.core.fields import SyncedDateTimeField
@ -318,7 +316,7 @@ class DurationField(models.DurationField):
super().__init__(*args, **kwargs)
def get_prep_value(self, value: Any) -> Any:
value = convert_duration_input_value_to_timedelta(value, self.duration_format)
value = duration_value_to_timedelta(value, self.duration_format)
return super().get_prep_value(value)

View file

@ -1,5 +1,6 @@
import re
from datetime import timedelta
from typing import Optional, Union
from typing import List, Optional, Union
from django.core.exceptions import ValidationError
from django.db.models import (
@ -10,144 +11,290 @@ from django.db.models import (
TextField,
Value,
)
from django.db.models.functions import Cast, Extract
from django.db.models.functions import Cast, Extract, Mod
H_M = "h:mm"
H_M_S = "h:mm:ss"
H_M_S_S = "h:mm:ss.s"
H_M_S_SS = "h:mm:ss.ss"
H_M_S_SSS = "h:mm:ss.sss"
D_H = "d h"
D_H_M = "d h:mm"
D_H_M_S = "d h:mm:ss"
MOST_ACCURATE_DURATION_FORMAT = H_M_S_SSS
def total_secs(days=0, hours=0, mins=0, secs=0):
return int(days) * 86400 + int(hours) * 3600 + int(mins) * 60 + float(secs)
# 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
DURATION_REGEXPS = {
re.compile(r"^(\d+)(?:d\s*|\s+)(\d+):(\d+):(\d+|\d+.\d+)$"): {
"default": lambda d, h, m, s: total_secs(days=d, hours=h, mins=m, secs=s),
},
re.compile(r"^(\d+):(\d+):(\d+|\d+.\d+)$"): {
"default": lambda h, m, s: total_secs(hours=h, mins=m, secs=s),
},
re.compile(r"^(\d+)(?:d\s*|\s+)(\d+)h$"): {
"default": lambda d, h: total_secs(days=d, hours=h),
},
re.compile(r"^(\d+)h$"): {
"default": lambda h: total_secs(hours=h),
},
re.compile(r"^(\d+)d$"): {
"default": lambda d: total_secs(days=d),
},
re.compile(r"^(\d+)(?:d\s*|\s+)(\d+):(\d+)$"): {
H_M: lambda d, h, m: total_secs(days=d, hours=h, mins=m),
D_H: lambda d, h, m: total_secs(days=d, hours=h, mins=m),
D_H_M: lambda d, h, m: total_secs(days=d, hours=h, mins=m),
"default": lambda d, m, s: total_secs(days=d, mins=m, secs=s),
},
re.compile(r"^(\d+)(?:d\s*|\s+)(\d+):(\d+.\d+)$"): {
"default": lambda d, m, s: total_secs(days=d, mins=m, secs=s),
},
re.compile(r"^(\d+):(\d+)$"): {
H_M: lambda h, m: total_secs(hours=h, mins=m),
D_H: lambda h, m: total_secs(hours=h, mins=m),
D_H_M: lambda h, m: total_secs(hours=h, mins=m),
"default": lambda m, s: total_secs(mins=m, secs=s),
},
re.compile(r"^(\d+):(\d+.\d+)$"): {
"default": lambda m, s: total_secs(mins=m, secs=s),
},
re.compile(r"^(\d+)(?:d\s*|\s+)(\d+)$"): {
H_M: lambda d, m: total_secs(days=d, mins=m),
D_H: lambda d, h: total_secs(days=d, hours=h),
D_H_M: lambda d, m: total_secs(days=d, mins=m),
"default": lambda d, s: total_secs(days=d, secs=s),
},
re.compile(r"^(\d+)(?:d\s*|\s+)(\d+.\d+)$"): {
"default": lambda d, s: total_secs(days=d, secs=s),
},
re.compile(r"^(\d+)$"): {
H_M: lambda m: total_secs(mins=float(m)),
D_H: lambda h: total_secs(hours=float(h)),
D_H_M: lambda m: total_secs(mins=float(m)),
"default": lambda s: total_secs(secs=s),
},
re.compile(r"^(\d+.\d+)$"): {
"default": lambda s: total_secs(secs=s),
},
}
def rround(value: float, ndigits: int = 0) -> int:
"""
Rounds a float to the specified number of decimal places. Python round will round
0.5 to 0, but we want it to round to 1. See
https://docs.python.org/3/library/functions.html#round for more info.
:param value: the value to round
:param ndigits: the number of digits to round to
"""
digit_value = 10**ndigits
return int(value * digit_value + 0.5) / digit_value
DURATION_FORMATS = {
"h:mm": {
H_M: {
"name": "hours:minutes",
"backup_field_if_changing_to": set(),
"round_func": lambda value: round(value / 60, 0) * 60,
"round_func": lambda value: rround(value / 60) * 60,
"sql_round_func": "(EXTRACT(EPOCH FROM p_in::INTERVAL) / 60)::int * 60",
"format_func": lambda hours, mins, _: "%d:%02d" % (hours, mins),
"format_func": lambda d, h, m, s: "%d:%02d" % (d * 24 + h, m),
},
"h:mm:ss": {
H_M_S: {
"name": "hours:minutes:seconds",
"backup_field_if_changing_to": {"h:mm"},
"round_func": lambda value: round(value, 0),
"round_func": lambda value: rround(value, 0),
"sql_round_func": "EXTRACT(EPOCH FROM p_in::INTERVAL)::int",
"format_func": lambda hours, mins, secs: "%d:%02d:%02d" % (hours, mins, secs),
"format_func": lambda d, h, m, s: "%d:%02d:%02d" % (d * 24 + h, m, s),
},
"h:mm:ss.s": {
H_M_S_S: {
"name": "hours:minutes:seconds:deciseconds",
"round_func": lambda value: round(value, 1),
"round_func": lambda value: rround(value, 1),
"sql_round_func": "ROUND(EXTRACT(EPOCH FROM p_in::INTERVAL)::NUMERIC, 1)",
"backup_field_if_changing_to": {"h:mm", "h:mm:ss"},
"format_func": lambda hours, mins, secs: "%d:%02d:%04.1f" % (hours, mins, secs),
"format_func": lambda d, h, m, s: "%d:%02d:%04.1f" % (d * 24 + h, m, s),
},
"h:mm:ss.ss": {
H_M_S_SS: {
"name": "hours:minutes:seconds:centiseconds",
"backup_field_if_changing_to": {"h:mm", "h:mm:ss", "h:mm:ss.s"},
"round_func": lambda value: round(value, 2),
"round_func": lambda value: rround(value, 2),
"sql_round_func": "ROUND(EXTRACT(EPOCH FROM p_in::INTERVAL)::NUMERIC, 2)",
"format_func": lambda hours, mins, secs: "%d:%02d:%05.2f" % (hours, mins, secs),
"format_func": lambda d, h, m, s: "%d:%02d:%05.2f" % (d * 24 + h, m, s),
},
"h:mm:ss.sss": {
H_M_S_SSS: {
"name": "hours:minutes:seconds:milliseconds",
"backup_field_if_changing_to": {
"h:mm",
"h:mm:ss",
"h:mm:ss.s",
"h:mm:ss.ss",
},
"round_func": lambda value: round(value, 3),
"round_func": lambda value: rround(value, 3),
"sql_round_func": "ROUND(EXTRACT(EPOCH FROM p_in::INTERVAL)::NUMERIC, 3)",
"format_func": lambda hours, mins, secs: "%d:%02d:%06.3f" % (hours, mins, secs),
"format_func": lambda d, h, m, s: "%d:%02d:%06.3f" % (d * 24 + h, m, s),
},
D_H: {
"name": "days:hours",
"round_func": lambda value: rround(value / 3600) * 3600,
"sql_round_func": "(EXTRACT(EPOCH FROM p_in::INTERVAL) / 3600)::int * 3600",
"format_func": lambda d, h, m, s: "%dd %dh" % (d, h),
},
D_H_M: {
"name": "days:hours:minutes",
"round_func": lambda value: rround(value / 60) * 60,
"sql_round_func": "(EXTRACT(EPOCH FROM p_in::INTERVAL) / 60)::int * 60",
"format_func": lambda d, h, m, s: "%dd %d:%02d" % (d, h, m),
},
D_H_M_S: {
"name": "days:hours:minutes:seconds",
"round_func": lambda value: rround(value, 0),
"sql_round_func": "EXTRACT(EPOCH FROM p_in::INTERVAL)::int",
"format_func": lambda d, h, m, s: "%dd %d:%02d:%02d" % (d, h, m, s),
},
}
# The tokens used in the format strings and their utility functions.
HOURS_WITH_DAYS_SQL_TO_TEXT = (
"(EXTRACT(HOUR FROM CAST(p_in AS INTERVAL))::INTEGER %% 24)"
)
def hours_with_days_search_expr(field_name):
return Mod(
Cast(Extract(field_name, "hour"), output_field=IntegerField()), Value(24)
)
# The tokens used in the format strings and their utility functions. NOTE: the order of
# duration units is crucial, as it ranges from the largest to the smallest unit. This
# order is significant because it helps determine the precision of different duration
# formats (see is_duration_format_conversion_lossy down below), which are created by
# combining various tokens. If there's a need to convert between these formats, it might
# be necessary to backup data beforehand to prevent loss of precision.
DURATION_FORMAT_TOKENS = {
"h": {
"multiplier": 3600,
"parse_func": int,
"sql_to_text": "TRUNC(EXTRACT(EPOCH FROM CAST(p_in AS INTERVAL))::INTEGER / 3600)",
"search_expr": lambda field_name: Cast(
Func(
Extract(field_name, "epoch", output_field=IntegerField()) / Value(3600),
function="TRUNC",
output_field=IntegerField(),
"d": {
"sql_to_text": {
"default": "CASE WHEN p_in IS null THEN null ELSE CONCAT(TRUNC(EXTRACT(EPOCH FROM CAST(p_in AS INTERVAL))::INTEGER / 86400), 'd') END",
},
"search_expr": {
"default": lambda field_name: Func(
Cast(
Func(
Extract(field_name, "epoch", output_field=IntegerField())
/ Value(86400),
function="TRUNC",
output_field=IntegerField(),
),
output_field=TextField(),
),
Value("d"),
function="CONCAT",
),
output_field=TextField(),
),
},
},
"h": {
"sql_to_text": {
D_H: f"CASE WHEN p_in IS null THEN null ELSE CONCAT({HOURS_WITH_DAYS_SQL_TO_TEXT}, 'h') END",
D_H_M: HOURS_WITH_DAYS_SQL_TO_TEXT,
D_H_M_S: HOURS_WITH_DAYS_SQL_TO_TEXT,
"default": "TRUNC(EXTRACT(EPOCH FROM CAST(p_in AS INTERVAL))::INTEGER / 3600)",
},
"search_expr": {
D_H: lambda field_name: Func(
hours_with_days_search_expr(field_name), Value("h"), function="CONCAT"
),
D_H_M: hours_with_days_search_expr,
D_H_M_S: hours_with_days_search_expr,
"default": lambda field_name: Cast(
Func(
Extract(field_name, "epoch", output_field=IntegerField())
/ Value(3600),
function="TRUNC",
output_field=IntegerField(),
),
output_field=TextField(),
),
},
},
"mm": {
"multiplier": 60,
"parse_func": int,
"sql_to_text": "TO_CHAR(EXTRACT(MINUTE FROM CAST(p_in AS INTERVAL))::INTEGER, 'FM00')",
"search_expr": lambda field_name: Func(
Extract(field_name, "minutes", output_field=IntegerField()),
Value("FM00"),
function="TO_CHAR",
output_field=TextField(),
),
"sql_to_text": {
"default": "TO_CHAR(EXTRACT(MINUTE FROM CAST(p_in AS INTERVAL))::INTEGER, 'FM00')",
},
"search_expr": {
"default": lambda field_name: Func(
Extract(field_name, "minutes", output_field=IntegerField()),
Value("FM00"),
function="TO_CHAR",
output_field=TextField(),
),
},
},
"ss": {
"multiplier": 1,
"parse_func": lambda value: round(float(value), 0),
"sql_to_text": "TO_CHAR(CAST(EXTRACT(SECOND FROM CAST(p_in AS INTERVAL)) AS NUMERIC(15, 0)), 'FM00')",
"search_expr": lambda field_name: Func(
Cast(
Extract(field_name, "seconds", output_field=FloatField()),
output_field=DecimalField(max_digits=15, decimal_places=0),
"sql_to_text": {
"default": "TO_CHAR(CAST(EXTRACT(SECOND FROM CAST(p_in AS INTERVAL)) AS NUMERIC(15, 0)), 'FM00')",
},
"search_expr": {
"default": lambda field_name: Func(
Cast(
Extract(field_name, "seconds", output_field=FloatField()),
output_field=DecimalField(max_digits=15, decimal_places=0),
),
Value("FM00"),
function="TO_CHAR",
output_field=TextField(),
),
Value("FM00"),
function="TO_CHAR",
output_field=TextField(),
),
},
},
"ss.s": {
"multiplier": 1,
"parse_func": lambda value: round(float(value), 1),
"sql_to_text": "TO_CHAR(CAST(EXTRACT(SECOND FROM CAST(p_in AS INTERVAL)) AS NUMERIC(15, 1)), 'FM00.0')",
"search_expr": lambda field_name: Func(
Cast(
Extract(field_name, "seconds", output_field=DecimalField()),
output_field=DecimalField(max_digits=15, decimal_places=1),
),
Value("FM00.0"),
function="TO_CHAR",
output_field=TextField(),
),
"sql_to_text": {
"default": "TO_CHAR(CAST(EXTRACT(SECOND FROM CAST(p_in AS INTERVAL)) AS NUMERIC(15, 1)), 'FM00.0')"
},
"search_expr": {
"default": lambda field_name: Func(
Cast(
Extract(field_name, "seconds", output_field=DecimalField()),
output_field=DecimalField(max_digits=15, decimal_places=1),
),
Value("FM00.0"),
function="TO_CHAR",
output_field=TextField(),
)
},
},
"ss.ss": {
"multiplier": 1,
"parse_func": lambda value: round(float(value), 2),
"sql_to_text": "TO_CHAR(CAST(EXTRACT(SECOND FROM CAST(p_in AS INTERVAL)) AS NUMERIC(15, 2)), 'FM00.00')",
"search_expr": lambda field_name: Func(
Cast(
Extract(field_name, "seconds", output_field=DecimalField()),
output_field=DecimalField(max_digits=15, decimal_places=2),
),
Value("FM00.00"),
function="TO_CHAR",
output_field=TextField(),
),
"sql_to_text": {
"default": "TO_CHAR(CAST(EXTRACT(SECOND FROM CAST(p_in AS INTERVAL)) AS NUMERIC(15, 2)), 'FM00.00')"
},
"search_expr": {
"default": lambda field_name: Func(
Cast(
Extract(field_name, "seconds", output_field=DecimalField()),
output_field=DecimalField(max_digits=15, decimal_places=2),
),
Value("FM00.00"),
function="TO_CHAR",
output_field=TextField(),
)
},
},
"ss.sss": {
"multiplier": 1,
"parse_func": lambda value: round(float(value), 3),
"sql_to_text": "TO_CHAR(CAST(EXTRACT(SECOND FROM CAST(p_in AS INTERVAL)) AS NUMERIC(15, 3)), 'FM00.000')",
"search_expr": lambda field_name: Func(
Cast(
Extract(field_name, "seconds", output_field=DecimalField()),
output_field=DecimalField(max_digits=15, decimal_places=3),
),
Value("FM00.000"),
function="TO_CHAR",
output_field=TextField(),
),
"sql_to_text": {
"default": "TO_CHAR(CAST(EXTRACT(SECOND FROM CAST(p_in AS INTERVAL)) AS NUMERIC(15, 3)), 'FM00.000')"
},
"search_expr": {
"default": lambda field_name: Func(
Cast(
Extract(field_name, "seconds", output_field=DecimalField()),
output_field=DecimalField(max_digits=15, decimal_places=3),
),
Value("FM00.000"),
function="TO_CHAR",
output_field=TextField(),
)
},
},
}
MOST_ACCURATE_DURATION_FORMAT = "h:mm:ss.sss"
MAX_NUMBER_OF_TOKENS_IN_DURATION_FORMAT = len(MOST_ACCURATE_DURATION_FORMAT.split(":"))
# Keep this function in sync with the one in the frontend:
# web-frontend/modules/database/utils/duration.js ->
# guessDurationValueFromString
def parse_formatted_duration(
formatted_value: str, format: str, strict: bool = False
) -> float:
def parse_duration_value(formatted_value: str, format: str) -> float:
"""
Parses a formatted duration string into a number of seconds according to the
provided format. If the format doesn't match exactly, it will still try to
@ -155,46 +302,24 @@ def parse_formatted_duration(
:param formatted_value: The formatted duration string.
:param format: The format of the duration string.
:param strict: If True, the formatted value must match the format exactly or
a ValueError will be raised. If False, the formatted value can be any
acceptable format and will be parsed as best as possible.
:return: The number of seconds.
:return: The total number of seconds for the given formatted duration.
:raises ValueError: If the format is invalid.
"""
if format not in DURATION_FORMATS:
raise ValueError(f"{format} is not a valid duration format.")
tokens = format.split(":")
parts = formatted_value.split(":")
if len(parts) > len(tokens):
if strict or len(parts) > MAX_NUMBER_OF_TOKENS_IN_DURATION_FORMAT:
raise ValueError(
f"Too many parts in formatted value {formatted_value} for format {format}."
)
else:
# In this case we still want to parse the value as best as possible,
# so use the most accurate format and round the value later.
tokens = MOST_ACCURATE_DURATION_FORMAT.split(":")
for regex, format_funcs in DURATION_REGEXPS.items():
match = regex.match(formatted_value)
if match:
format_func = format_funcs.get(format, format_funcs["default"])
return format_func(*match.groups())
total_seconds = 0
for i, token in enumerate(reversed(tokens)):
if len(parts) <= i:
break
# pick the corresponding part from the end of the list
part = parts[-(i + 1)]
token_options = DURATION_FORMAT_TOKENS[token]
multiplier = token_options["multiplier"]
parse_func = token_options["parse_func"]
total_seconds += parse_func(part) * multiplier
total_seconds = DURATION_FORMATS[format]["round_func"](total_seconds)
return total_seconds
# None of the regexps matches the formatted value
raise ValueError(f"{formatted_value} is not a valid duration string.")
def convert_duration_input_value_to_timedelta(
def duration_value_to_timedelta(
value: Union[int, float, timedelta, str, None], format: str
) -> Optional[timedelta]:
"""
@ -207,8 +332,7 @@ def convert_duration_input_value_to_timedelta(
:param format: The format to use when parsing the value if it is a string
and to round the value to.
:return: The timedelta object.
:raises ValueError: If the value is not a valid integer or string according
to the format.
:raises ValueError: If the value has an invalid type.
:raise OverflowError: If the value is too big to be converted to a timedelta.
"""
@ -220,10 +344,9 @@ def convert_duration_input_value_to_timedelta(
elif isinstance(value, timedelta):
return value
# Since our view_filters are storing the number of seconds as string, let's try to
# convert it to a float first. Please note that this is different in the frontend
# where the input value is a string and immediately use the field format to parse
# it.
# Since our view_filters are storing the number of seconds as string, let's try
# to convert it to a number first. Please note that this is different in the
# frontend where the input value is parsed accordingly to the field format.
try:
value = float(value)
except ValueError:
@ -232,8 +355,7 @@ def convert_duration_input_value_to_timedelta(
if isinstance(value, (int, float)) and value >= 0:
total_seconds = value
elif isinstance(value, str):
formatted_duration = value
total_seconds = parse_formatted_duration(formatted_duration, format)
total_seconds = parse_duration_value(value, format)
else:
raise ValueError(
"The provided value should be a valid integer or string according to the "
@ -262,7 +384,7 @@ def prepare_duration_value_for_db(
"""
try:
return convert_duration_input_value_to_timedelta(value, duration_format)
return duration_value_to_timedelta(value, duration_format)
except ValueError:
raise default_exc(
f"The value {value} is not a valid duration.",
@ -273,3 +395,107 @@ def prepare_duration_value_for_db(
f"Value {value} is too large. The maximum is {timedelta.max}.",
code="invalid",
)
def format_duration_value(
duration: Optional[timedelta], duration_format
) -> Optional[str]:
"""
Format a duration value according to the provided format.
:param duration: The duration to format.
:param duration_format: The format to use.
:return: The formatted duration.
"""
if duration is None:
return None
days = duration.days
hours = duration.seconds // 3600
mins = duration.seconds % 3600 // 60
secs = duration.seconds % 60 + duration.microseconds / 10**6
format_func = DURATION_FORMATS[duration_format]["format_func"]
return format_func(days, hours, mins, secs)
def tokenize_formatted_duration(duration_format: str) -> List[str]:
"""
Tokenize a formatted duration format returning a list of tokens.
:param formatted_value: The formatted duration string.
:return: A list with tokens.
"""
return re.split("[: ]", duration_format)
def is_duration_format_conversion_lossy(new_format, old_format):
"""
Returns True if converting from starting_format to ending_format will result in a
loss of precision.
:param starting_format: The format to convert from.
:param ending_format: The format to convert to.
"""
ordered_tokens = list(DURATION_FORMAT_TOKENS.keys())
new_lst = tokenize_formatted_duration(new_format)[-1]
old_lst = tokenize_formatted_duration(old_format)[-1]
return ordered_tokens.index(old_lst) > ordered_tokens.index(new_lst)
def get_duration_search_expression(field) -> Func:
"""
Returns a search expression that can be used to search the field as a formatted
string.
:param field: The field to get the search expression for.
:return: A search expression that can be used to search the field as a formatted
string.
"""
search_exprs = []
for token in tokenize_formatted_duration(field.duration_format):
search_exp_functions = DURATION_FORMAT_TOKENS[token]["search_expr"]
search_expr_func = search_exp_functions.get(
field.duration_format, search_exp_functions["default"]
)
search_exprs.append(search_expr_func(field.db_column))
separators = [Value(" ")] * len(search_exprs)
# interleave a separator between each extract_expr
exprs = [expr for pair in zip(search_exprs, separators) for expr in pair][:-1]
return Func(*exprs, function="CONCAT")
def duration_value_sql_to_text(field) -> str:
"""
Returns a SQL expression that can be used to convert the duration value to a
formatted string.
:param field: The field to get the SQL expression for.
:return: A string containing the SQL expression that can be used to convert the
duration value to a formatted string.
"""
format_func = ""
tokens = tokenize_formatted_duration(field.duration_format)
for i, format_token in enumerate(tokens):
sql_to_text_funcs = DURATION_FORMAT_TOKENS[format_token]["sql_to_text"]
sql_to_text = sql_to_text_funcs.get(
field.duration_format, sql_to_text_funcs["default"]
)
format_func += sql_to_text
# Add the proper separator between each token, if it's not the last one
if i == len(tokens) - 1:
break
elif format_token == "d": # nosec b105
format_func += " || ' ' || "
else:
format_func += " || ':' || "
(format_func)
return format_func

View file

@ -0,0 +1,31 @@
# Generated by Django 4.0.10 on 2024-01-03 15:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("database", "0148_add_formula_button_type"),
]
operations = [
migrations.AlterField(
model_name="durationfield",
name="duration_format",
field=models.CharField(
choices=[
("h:mm", "hours:minutes"),
("h:mm:ss", "hours:minutes:seconds"),
("h:mm:ss.s", "hours:minutes:seconds:deciseconds"),
("h:mm:ss.ss", "hours:minutes:seconds:centiseconds"),
("h:mm:ss.sss", "hours:minutes:seconds:milliseconds"),
("d h", "days:hours"),
("d h:mm", "days:hours:minutes"),
("d h:mm:ss", "days:hours:minutes:seconds"),
],
default="h:mm",
help_text="The format of the duration.",
max_length=32,
),
),
]

View file

@ -195,11 +195,14 @@ def setup_interesting_test_table(
"created_on_datetime_eu_tzone": None,
"last_modified_by": None,
"created_by": None,
"duration_hm": timedelta(seconds=3660),
"duration_hms": timedelta(seconds=3666),
"duration_hms_s": timedelta(seconds=3666.6),
"duration_hms_ss": timedelta(seconds=3666.66),
"duration_hms_sss": timedelta(seconds=3666.666),
"duration_hm": timedelta(hours=1, minutes=1),
"duration_hms": timedelta(hours=1, minutes=1, seconds=6),
"duration_hms_s": timedelta(hours=1, minutes=1, seconds=6.6),
"duration_hms_ss": timedelta(hours=1, minutes=1, seconds=6.66),
"duration_hms_sss": timedelta(hours=1, minutes=1, seconds=6.666),
"duration_dh": timedelta(days=1, hours=1),
"duration_dhm": timedelta(days=1, hours=1, minutes=1),
"duration_dhms": timedelta(days=1, hours=1, minutes=1, seconds=6),
# We will setup link rows manually later
"link_row": None,
"self_link_row": None,

View file

@ -7,13 +7,14 @@ from baserow.contrib.database.api.fields.serializers import DurationFieldSeriali
from baserow.contrib.database.fields.utils.duration import (
DURATION_FORMAT_TOKENS,
DURATION_FORMATS,
tokenize_formatted_duration,
)
@pytest.mark.parametrize(
"duration_format,user_input,saved_value",
[
# Normal input:
# number input:
("h:mm", 0, timedelta(seconds=0)),
("h:mm", 3660, timedelta(seconds=3660)),
("h:mm", 86400, timedelta(days=1)),
@ -23,6 +24,14 @@ from baserow.contrib.database.fields.utils.duration import (
("h:mm:ss.ss", 3661.12, timedelta(seconds=3661.12)),
("h:mm:ss.sss", 0, timedelta(seconds=0)),
("h:mm:ss.sss", 3661.123, timedelta(seconds=3661.123)),
("d h", 0, timedelta(seconds=0)),
("d h", 3600, timedelta(hours=1)),
("d h", 90000, timedelta(days=1, hours=1)),
("d h:mm", 0, timedelta(seconds=0)),
("d h:mm", 3660, timedelta(hours=1, minutes=1)),
("d h:mm", 86400, timedelta(days=1)),
("d h:mm:ss", 0, timedelta(seconds=0)),
("d h:mm:ss", 3661, timedelta(seconds=3661)),
# Rounding:
("h:mm", 3661.123, timedelta(seconds=3660)),
("h:mm", 3661.999, timedelta(seconds=3660)),
@ -34,12 +43,39 @@ from baserow.contrib.database.fields.utils.duration import (
("h:mm:ss.ss", 3661.789, timedelta(seconds=3661.79)),
("h:mm:ss.sss", 3661.1234, timedelta(seconds=3661.123)),
("h:mm:ss.sss", 3661.6789, timedelta(seconds=3661.679)),
("d h", 86400 + 3600.123, timedelta(days=1, hours=1)),
("d h", 29.9 * 60, timedelta(seconds=0)),
("d h", 30 * 60, timedelta(hours=1)),
("d h:mm", 29, timedelta(seconds=0)),
("d h:mm", 30, timedelta(minutes=1)),
("d h:mm:ss", 0.49, timedelta(seconds=0)),
("d h:mm:ss", 0.5, timedelta(seconds=1)),
# String input:
("h:mm", "1:01", timedelta(seconds=3660)),
("h:mm:ss", "1:01:01", timedelta(seconds=3661)),
("h:mm:ss.s", "1:01:01.1", timedelta(seconds=3661.1)),
("h:mm:ss.ss", "1:01:01.12", timedelta(seconds=3661.12)),
("h:mm:ss.sss", "1:01:01.123", timedelta(seconds=3661.123)),
("d h", "1 1", timedelta(days=1, hours=1)),
("d h", "1d1h", timedelta(days=1, hours=1)),
("d h", "1d 1h", timedelta(days=1, hours=1)),
("d h", "1 1h", timedelta(days=1, hours=1)),
("d h", "1d 1", timedelta(days=1, hours=1)),
("d h", "1d1", timedelta(days=1, hours=1)),
("d h", "12h", timedelta(hours=12)),
("d h", "3d", timedelta(days=3)),
("d h:mm", "1 1:01", timedelta(days=1, seconds=3660)),
("d h:mm", "1d1:01", timedelta(days=1, seconds=3660)),
("d h:mm", "1d 1:01", timedelta(days=1, seconds=3660)),
("d h:mm", "1 1:1", timedelta(days=1, seconds=3660)),
("d h:mm", "1d1:1", timedelta(days=1, seconds=3660)),
("d h:mm", "1d 1:1", timedelta(days=1, seconds=3660)),
("d h:mm:ss", "1 1:01:01", timedelta(days=1, seconds=3661)),
("d h:mm:ss", "1d1:01:01", timedelta(days=1, seconds=3661)),
("d h:mm:ss", "1d 1:01:01", timedelta(days=1, seconds=3661)),
("d h:mm:ss", "1 1:1:1", timedelta(days=1, seconds=3661)),
("d h:mm:ss", "1d1:1:1", timedelta(days=1, seconds=3661)),
("d h:mm:ss", "1d 1:1:1", timedelta(days=1, seconds=3661)),
# String input with rounding:
("h:mm", "1:01:01.123", timedelta(seconds=3660)),
("h:mm", "1:01:01.999", timedelta(seconds=3660)),
@ -51,18 +87,35 @@ from baserow.contrib.database.fields.utils.duration import (
("h:mm:ss.ss", "1:01:01.789", timedelta(seconds=3661.79)),
("h:mm:ss.sss", "1:01:01.1234", timedelta(seconds=3661.123)),
("h:mm:ss.sss", "1:01:01.6789", timedelta(seconds=3661.679)),
("d h", "1 1:01:01.123", timedelta(days=1, hours=1)),
("d h", "1.123", timedelta(seconds=0)),
("d h", "29:59.999", timedelta(seconds=0)),
("d h", "30:00", timedelta(hours=30)),
("d h", "30:00.001", timedelta(hours=1)),
("d h:mm", "1 1:01:01.123", timedelta(days=1, hours=1, minutes=1)),
("d h:mm", "1.123", timedelta(seconds=0)),
("d h:mm", "29.999", timedelta(seconds=0)),
("d h:mm", "30", timedelta(minutes=1)),
("d h:mm", "30.001", timedelta(minutes=1)),
("d h:mm:ss", "1 1:01:01.123", timedelta(days=1, seconds=3661)),
("d h:mm:ss", "1.123", timedelta(seconds=1)),
("d h:mm:ss", "29.999", timedelta(seconds=30)),
("d h:mm:ss", "70", timedelta(seconds=70)),
("d h:mm:ss", "0.5", timedelta(seconds=1)),
# None should be None in every format:
("h:mm", None, None),
("h:mm:ss", None, None),
("h:mm:ss.s", None, None),
("h:mm:ss.ss", None, None),
("h:mm:ss.sss", None, None),
("d h", None, None),
("d h:mm", None, None),
("d h:mm:ss", None, None),
],
)
@pytest.mark.django_db
@pytest.mark.field_duration
def test_duration_serializer_to_internal_value(
data_fixture, duration_format, user_input, saved_value
duration_format, user_input, saved_value
):
"""
Tests that for the Duration Serializer, the value is always serialized as
@ -88,12 +141,15 @@ def test_duration_serializer_to_internal_value(
("h:mm:ss.s", "aaaaaaa"),
("invalid format", 1),
("h:m", timedelta.max.total_seconds() + 1), # Overflow
("d h", "1dd"),
("d h", "1hh"),
("d h", "1d1d"),
("d h", "1h1h"),
],
)
@pytest.mark.django_db
@pytest.mark.field_duration
def test_duration_serializer_to_internal_value_with_invalid_values(
data_fixture, duration_format, user_input
duration_format, user_input
):
serializer = DurationFieldSerializer(duration_format=duration_format)
with pytest.raises(serializers.ValidationError):
@ -103,38 +159,34 @@ def test_duration_serializer_to_internal_value_with_invalid_values(
@pytest.mark.parametrize(
"duration_format,user_input,returned_value",
[
("h:mm", 3660, 3660),
("h:mm:ss", 3661, 3661),
("h:mm:ss.s", 3661.1, 3661.1),
("h:mm:ss.ss", 3661.12, 3661.12),
("h:mm:ss.sss", 3661.123, 3661.123),
("h:mm:ss.sss", 3661.1234, 3661.1234),
("h:mm", timedelta(seconds=0), 0),
("h:mm", timedelta(hours=1, minutes=1), 3660),
("h:mm:ss", timedelta(hours=1, minutes=1, seconds=1), 3661),
("h:mm:ss.s", timedelta(hours=1, minutes=1, seconds=1.1), 3661.1),
("h:mm:ss.ss", timedelta(hours=1, minutes=1, seconds=1.12), 3661.12),
("h:mm:ss.sss", timedelta(hours=1, minutes=1, seconds=1.123), 3661.123),
("h:mm:ss.sss", timedelta(hours=1, minutes=1, seconds=1.1234), 3661.1234),
("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),
],
)
@pytest.mark.django_db
@pytest.mark.field_duration
def test_duration_serializer_to_representation(
data_fixture, duration_format, user_input, returned_value
duration_format, user_input, returned_value
):
"""
Tests that for the Duration Serializer, the representation is returned in
seconds (value.total_seconds()) from the database for every duration format.
"""
serializer = DurationFieldSerializer(duration_format=duration_format)
assert serializer.to_representation(timedelta(seconds=user_input)) == returned_value
assert serializer.to_representation(user_input) == returned_value
@pytest.mark.django_db
@pytest.mark.field_duration
def test_duration_token_options(data_fixture):
def test_duration_token_options():
"""
Tests that the token options are correct for the duration field.
Tests that format are made of tokens that are in DURATION_FORMAT_TOKENS.
"""
for format in DURATION_FORMATS.keys():
for token in format.split(":"):
for token in tokenize_formatted_duration(format):
assert token in DURATION_FORMAT_TOKENS, (
f"{token} not in DURATION_FORMAT_TOKENS. Please add it with the correct "
"options to be able to convert a formatted string to a timedelta."

View file

@ -250,6 +250,9 @@ def test_get_row_serializer_with_user_field_names(data_fixture):
"duration_hms_s": 3666.6,
"duration_hms_ss": 3666.66,
"duration_hms_sss": 3666.666,
"duration_dh": 90000,
"duration_dhm": 90060,
"duration_dhms": 90066,
"email": "test@example.com",
"file": [
{

View file

@ -1,4 +1,5 @@
import pytest
from pytest_unordered import unordered
from baserow.contrib.database.api.views.serializers import serialize_group_by_metadata
from baserow.contrib.database.fields.models import Field
@ -90,7 +91,7 @@ def test_serialize_group_by_metadata_on_all_fields_in_interesting_table(data_fix
# comparison.
for result in serialized:
result[f"field_{field.name}"] = result.pop(f"field_{str(field.id)}")
actual_result_per_field_name[field.name] = serialized
actual_result_per_field_name[field.name] = unordered(serialized)
assert actual_result_per_field_name == {
"text": [
@ -237,4 +238,16 @@ def test_serialize_group_by_metadata_on_all_fields_in_interesting_table(data_fix
{"count": 1, "field_duration_hms_sss": 3666.666},
{"count": 1, "field_duration_hms_sss": None},
],
"duration_dh": [
{"count": 1, "field_duration_dh": 90000.0},
{"count": 1, "field_duration_dh": None},
],
"duration_dhm": [
{"count": 1, "field_duration_dhm": 90060.0},
{"count": 1, "field_duration_dhm": None},
],
"duration_dhms": [
{"count": 1, "field_duration_dhms": 90066.0},
{"count": 1, "field_duration_dhms": None},
],
}

View file

@ -227,14 +227,15 @@ def test_can_export_every_interesting_different_field_to_csv(
"last_modified_date_eu,last_modified_datetime_eu_tzone,created_on_datetime_us,"
"created_on_date_us,created_on_datetime_eu,created_on_date_eu,created_on_datetime_eu_tzone,"
"last_modified_by,created_by,duration_hm,duration_hms,duration_hms_s,duration_hms_ss,"
"duration_hms_sss,link_row,self_link_row,link_row_without_related,decimal_link_row,"
"duration_hms_sss,duration_dh,duration_dhm,duration_dhms,"
"link_row,self_link_row,link_row_without_related,decimal_link_row,"
"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\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,"
"user@example.com,user@example.com,,,,,,,,,,,,,,,,,,,test FORMULA,1,True,33.3333333333,"
"1 day,2020-01-01,,,label (https://google.com),https://google.com,,0,0.000,,"
"00000000-0000-4000-8000-000000000001,1\r\n"
"2,text,long_text,https://www.google.com,test@example.com,-1,1,-1.2,1.2,3,True,"
@ -242,7 +243,7 @@ def test_can_export_every_interesting_different_field_to_csv(
"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,"
"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","1.234,-123.456,unnamed row 3",'
'"name.txt (http://localhost:8000/media/user_files/test_hash.txt),unnamed row 2",'

View file

@ -196,6 +196,15 @@ def test_remove_duration_field(data_fixture):
(timedelta(seconds=3665.5), "h:mm:ss.s", "1:01:05.5"),
(timedelta(seconds=3665.55), "h:mm:ss.ss", "1:01:05.55"),
(timedelta(seconds=3665.555), "h:mm:ss.sss", "1:01:05.555"),
(timedelta(days=1, hours=2), "d h", "1d 2h"),
(timedelta(days=1, hours=1, minutes=2), "d h:mm", "1d 1:02"),
(timedelta(days=1, hours=1, minutes=2, seconds=3), "d h:mm:ss", "1d 1:02:03"),
(None, "h:mm", None),
(None, "d h", None),
(None, "d h:mm", None),
(None, "d h:mm:ss", None),
(0, "h:mm", "0:00"),
(0, "d h", "0d 0h"),
],
)
@pytest.mark.django_db
@ -261,6 +270,21 @@ def test_convert_duration_field_to_text_field(
"1:01:05.555",
timedelta(seconds=3665.555),
),
(
"d h",
"1d 2h",
timedelta(days=1, hours=2),
),
(
"d h:mm",
"1d 1:02",
timedelta(days=1, hours=1, minutes=2),
),
(
"d h:mm:ss",
"1d 1:02:03",
timedelta(days=1, hours=1, minutes=2, seconds=3),
),
],
)
@pytest.mark.django_db
@ -315,11 +339,38 @@ def test_convert_text_field_to_duration_field(
@pytest.mark.parametrize(
"expected,field_kwargs",
[
([0, 0, 60, 120, 120], {"duration_format": "h:mm"}),
([1, 10, 51, 100, 122], {"duration_format": "h:mm:ss"}),
([1.2, 10.1, 50.7, 100.1, 122], {"duration_format": "h:mm:ss.s"}),
([1.20, 10.11, 50.68, 100.1, 122], {"duration_format": "h:mm:ss.ss"}),
([1.199, 10.11, 50.679, 100.1, 122], {"duration_format": "h:mm:ss.sss"}),
(
[0, 0, 60, 120, 120, 86460, 90000],
{"duration_format": "h:mm"},
),
(
[1, 10, 51, 100, 122, 86461, 90002],
{"duration_format": "h:mm:ss"},
),
(
[1.2, 10.1, 50.7, 100.1, 122, 86461, 90001.8],
{"duration_format": "h:mm:ss.s"},
),
(
[1.20, 10.11, 50.68, 100.1, 122, 86461, 90001.8],
{"duration_format": "h:mm:ss.ss"},
),
(
[1.199, 10.11, 50.679, 100.1, 122, 86461, 90001.8],
{"duration_format": "h:mm:ss.sss"},
),
(
[0, 0, 0, 0, 0, 86400, 90000],
{"duration_format": "d h"},
),
(
[0, 0, 60, 120, 120, 86460, 90000],
{"duration_format": "d h:mm"},
),
(
[1, 10, 51, 100, 122, 86461, 90002],
{"duration_format": "d h:mm:ss"},
),
],
)
def test_alter_duration_format(expected, field_kwargs, data_fixture):
@ -329,7 +380,7 @@ def test_alter_duration_format(expected, field_kwargs, data_fixture):
user=user, table=table, duration_format="h:mm:ss.sss"
)
original_values = [1.199, 10.11, 50.6789, 100.1, 122]
original_values = [1.199, 10.11, 50.6789, 100.1, 122, 86461, 90001.8]
RowHandler().create_rows(
user,
@ -353,11 +404,38 @@ def test_alter_duration_format(expected, field_kwargs, data_fixture):
@pytest.mark.parametrize(
"expected,field_kwargs",
[
([0, 0, 60, 120, 120], {"duration_format": "h:mm"}),
([1, 10, 51, 100, 122], {"duration_format": "h:mm:ss"}),
([1.2, 10.1, 50.7, 100.1, 122], {"duration_format": "h:mm:ss.s"}),
([1.20, 10.11, 50.68, 100.1, 122], {"duration_format": "h:mm:ss.ss"}),
([1.199, 10.11, 50.679, 100.1, 122], {"duration_format": "h:mm:ss.sss"}),
(
[0, 0, 60, 120, 120, 86460, 90000],
{"duration_format": "h:mm"},
),
(
[1, 10, 51, 100, 122, 86461, 90002],
{"duration_format": "h:mm:ss"},
),
(
[1.2, 10.1, 50.7, 100.1, 122, 86461, 90001.8],
{"duration_format": "h:mm:ss.s"},
),
(
[1.20, 10.11, 50.68, 100.1, 122, 86461, 90001.8],
{"duration_format": "h:mm:ss.ss"},
),
(
[1.199, 10.11, 50.679, 100.1, 122, 86461, 90001.8],
{"duration_format": "h:mm:ss.sss"},
),
(
[0, 0, 0, 0, 0, 86400, 90000],
{"duration_format": "d h"},
),
(
[0, 0, 60, 120, 120, 86460, 90000],
{"duration_format": "d h:mm"},
),
(
[1, 10, 51, 100, 122, 86461, 90002],
{"duration_format": "d h:mm:ss"},
),
],
)
def test_alter_duration_format_can_be_undone(expected, field_kwargs, data_fixture):
@ -368,7 +446,7 @@ def test_alter_duration_format_can_be_undone(expected, field_kwargs, data_fixtur
user=user, table=table, duration_format="h:mm:ss.sss"
)
original_values = [1.199, 10.11, 50.679, 100.1, 122]
original_values = [1.199, 10.11, 50.679, 100.1, 122, 86461, 90001.8]
RowHandler().create_rows(
user,
@ -416,7 +494,7 @@ def test_duration_field_view_filters(data_fixture):
{field.db_column: 1.123},
{field.db_column: 60}, # 1min
{field.db_column: "24:0:0"}, # 1day
{field.db_column: 86400}, # 1day
{field.db_column: "1 0"}, # 1day
{field.db_column: 3601}, # 1hour 1sec
{field.db_column: "1:0:0"}, # 1 hour
],

View file

@ -1563,6 +1563,27 @@ def test_local_baserow_table_service_generate_schema_with_interesting_test_table
"metadata": {},
"type": "string",
},
field_db_column_by_name["duration_dh"]: {
"title": "duration_dh",
"default": None,
"original_type": "duration",
"metadata": {},
"type": "string",
},
field_db_column_by_name["duration_dhm"]: {
"title": "duration_dhm",
"default": None,
"original_type": "duration",
"metadata": {},
"type": "string",
},
field_db_column_by_name["duration_dhms"]: {
"title": "duration_dhms",
"default": None,
"original_type": "duration",
"metadata": {},
"type": "string",
},
"id": {"metadata": {}, "type": "number", "title": "Id"},
}

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Add formats with days to the duration field.",
"issue_number": 2217,
"bullet_points": [],
"created_at": "2024-01-04"
}

View file

@ -71,6 +71,9 @@ def test_can_export_every_interesting_different_field_to_json(
"duration_hms_s": "",
"duration_hms_ss": "",
"duration_hms_sss": "",
"duration_dh": "",
"duration_dhm": "",
"duration_dhms": "",
"link_row": [],
"self_link_row": [],
"link_row_without_related": [],
@ -138,6 +141,9 @@ def test_can_export_every_interesting_different_field_to_json(
"duration_hms_s": "1:01:06.6",
"duration_hms_ss": "1:01:06.66",
"duration_hms_sss": "1:01:06.666",
"duration_dh": "1d 1h",
"duration_dhm": "1d 1:01",
"duration_dhms": "1d 1:01:06",
"link_row": [
"linked_row_1",
"linked_row_2",
@ -328,6 +334,9 @@ def test_can_export_every_interesting_different_field_to_xml(
<duration-hms-s/>
<duration-hms-ss/>
<duration-hms-sss/>
<duration-dh/>
<duration-dhm/>
<duration-dhms/>
<link-row/>
<self-link-row/>
<link-row-without-related/>
@ -395,6 +404,9 @@ def test_can_export_every_interesting_different_field_to_xml(
<duration-hms-s>1:01:06.6</duration-hms-s>
<duration-hms-ss>1:01:06.66</duration-hms-ss>
<duration-hms-sss>1:01:06.666</duration-hms-sss>
<duration-dh>1d 1h</duration-dh>
<duration-dhm>1d 1:01</duration-dhm>
<duration-dhms>1d 1:01:06</duration-dhms>
<link-row>
<item>linked_row_1</item>
<item>linked_row_2</item>

View file

@ -38,7 +38,9 @@ export default {
setCopyAndDelayedUpdate(value, immediately = false) {
const newValue = this.updateCopy(this.field, value)
if (newValue !== undefined) {
this.delayedUpdate(this.copy, immediately)
// a filter Value cannot be null, so send an empty string in case.
const filterValue = newValue === null ? '' : newValue
this.delayedUpdate(filterValue, immediately)
}
},
getValidationError(value) {

View file

@ -1,6 +1,6 @@
import BigNumber from 'bignumber.js'
import {
formatDuration,
formatDurationValue,
parseDurationValue,
roundDurationValueToFormat,
DURATION_FORMATS,
@ -2285,31 +2285,17 @@ export class DurationFieldType extends FieldType {
getSort(name, order) {
return (a, b) => {
const aValue = parseDurationValue(a[name])
const bValue = parseDurationValue(b[name])
const aValue = a[name]
const bValue = b[name]
if (aValue === bValue) {
return 0
}
if (
(aValue === null && order === 'ASC') ||
(bValue === null && order === 'DESC')
) {
return -1
}
if (
(bValue === null && order === 'ASC') ||
(aValue === null && order === 'DESC')
) {
return 1
}
if (order === 'ASC') {
return aValue < bValue ? -1 : 1
return aValue === null || (bValue !== null && aValue < bValue) ? -1 : 1
} else {
return bValue < aValue ? -1 : 1
return bValue === null || (aValue !== null && bValue < aValue) ? -1 : 1
}
}
}
@ -2361,7 +2347,7 @@ export class DurationFieldType extends FieldType {
}
static formatValue(field, value) {
return formatDuration(value, field.duration_format)
return formatDurationValue(value, field.duration_format)
}
static parseInputValue(field, value) {

View file

@ -48,7 +48,8 @@ export default {
}
},
onKeyPress(field, event) {
if (!/\d/.test(event.key) && event.key !== '.' && event.key !== ':') {
const allowedChars = ['.', ':', ' ', 'd', 'h']
if (!/\d/.test(event.key) && !allowedChars.includes(event.key)) {
return event.preventDefault()
}
},

View file

@ -1,28 +1,122 @@
const MOST_ACCURATE_DURATION_FORMAT = 'h:mm:ss.sss'
const MAX_NUMBER_OF_TOKENS_IN_DURATION_FORMAT =
MOST_ACCURATE_DURATION_FORMAT.split(':').length
export const MAX_BACKEND_DURATION_VALUE_NUMBER_OF_SECS = 86400000000000 // taken from backend timedelta.max.total_seconds()
// taken from backend timedelta.max.total_seconds() == 1_000_000_000 days
export const MAX_BACKEND_DURATION_VALUE_NUMBER_OF_SECS = 86400000000000
export const DEFAULT_DURATION_FORMAT = 'h:mm'
const D_H = 'd h'
const D_H_M = 'd h:mm'
const D_H_M_S = 'd h:mm:ss'
const H_M = 'h:mm'
const H_M_S = 'h:mm:ss'
const H_M_S_S = 'h:mm:ss.s'
const H_M_S_SS = 'h:mm:ss.ss'
const H_M_S_SSS = 'h:mm:ss.sss'
const SECS_IN_DAY = 86400
const SECS_IN_HOUR = 3600
const SECS_IN_MIN = 60
function totalSecs({ secs = 0, mins = 0, hours = 0, days = 0 }) {
return (
parseInt(days) * SECS_IN_DAY +
parseInt(hours) * SECS_IN_HOUR +
parseInt(mins) * SECS_IN_MIN +
parseFloat(secs)
)
}
const DURATION_REGEXPS = new Map([
[
/^(\d+)(?:d\s*|\s+)(\d+):(\d+):(\d+|\d+\.\d+)$/,
{
default: (days, hours, mins, secs) =>
totalSecs({ days, hours, mins, secs }),
},
],
[
/^(\d+):(\d+):(\d+|\d+\.\d+)$/,
{
default: (hours, mins, secs) => totalSecs({ hours, mins, secs }),
},
],
[
/^(\d+)(?:d\s*|\s+)(\d+):(\d+)$/,
{
[D_H]: (days, hours, mins) => totalSecs({ days, hours, mins }),
[D_H_M]: (days, hours, mins) => totalSecs({ days, hours, mins }),
default: (days, mins, secs) => totalSecs({ days, mins, secs }),
},
],
[
/^(\d+)(?:d\s*|\s+)(\d+):(\d+\.\d+)$/,
{ default: (days, mins, secs) => totalSecs({ days, mins, secs }) },
],
[/^(\d+)h$/, { default: (hours) => totalSecs({ hours }) }],
[
/^(\d+)(?:d\s*|\s+)(\d+)h$/,
{
default: (days, hours) => totalSecs({ days, hours }),
},
],
[/^(\d+)d$/, { default: (days) => totalSecs({ days }) }],
[
/^(\d+)(?:d\s*|\s+)(\d+)$/,
{
[D_H]: (days, hours) => totalSecs({ days, hours }),
[D_H_M]: (days, mins) => totalSecs({ days, mins }),
[H_M]: (days, mins) => totalSecs({ days, mins }),
default: (days, secs) => totalSecs({ days, secs }),
},
],
[
/^(\d+)(?:d\s*|\s+)(\d+\.\d+)$/,
{ default: (days, secs) => totalSecs({ days, secs }) },
],
[
/^(\d+):(\d+)$/,
{
[D_H]: (hours, mins) => totalSecs({ hours, mins }),
[D_H_M]: (hours, mins) => totalSecs({ hours, mins }),
[H_M]: (hours, mins) => totalSecs({ hours, mins }),
default: (mins, secs) => totalSecs({ mins, secs }),
},
],
[
/^(\d+):(\d+\.\d+)$/,
{ default: (mins, secs) => totalSecs({ mins, secs }) },
],
[/^(\d+\.\d+)$/, { default: (secs) => totalSecs({ secs }) }],
[
/^(\d+)$/,
{
[D_H]: (hours) => totalSecs({ hours }),
[D_H_M]: (mins) => totalSecs({ mins }),
[H_M]: (mins) => totalSecs({ mins }),
default: (secs) => totalSecs({ secs }),
},
],
])
// Map guarantees the order of the entries
export const DURATION_FORMATS = new Map([
[
'h:mm',
H_M,
{
description: 'h:mm (1:23)',
example: '1:23',
toString(hours, minutes) {
return `${hours}:${minutes.toString().padStart(2, '0')}`
toString(d, h, m, s) {
return `${d * 24 + h}:${m.toString().padStart(2, '0')}`
},
round: (value) => Math.round(value / 60) * 60,
},
],
[
'h:mm:ss',
H_M_S,
{
description: 'h:mm:ss (1:23:40)',
example: '1:23:40',
toString(hours, minutes, seconds) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
toString(d, h, m, s) {
return `${d * 24 + h}:${m.toString().padStart(2, '0')}:${s
.toString()
.padStart(2, '0')}`
},
@ -30,12 +124,12 @@ export const DURATION_FORMATS = new Map([
},
],
[
'h:mm:ss.s',
H_M_S_S,
{
description: 'h:mm:ss.s (1:23:40.0)',
example: '1:23:40.0',
toString(hours, minutes, seconds) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
toString(d, h, m, s) {
return `${d * 24 + h}:${m.toString().padStart(2, '0')}:${s
.toFixed(1)
.padStart(4, '0')}`
},
@ -43,12 +137,12 @@ export const DURATION_FORMATS = new Map([
},
],
[
'h:mm:ss.ss',
H_M_S_SS,
{
description: 'h:mm:ss.ss (1:23:40.00)',
example: '1:23:40.00',
toString(hours, minutes, seconds) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
toString(d, h, m, s) {
return `${d * 24 + h}:${m.toString().padStart(2, '0')}:${s
.toFixed(2)
.padStart(5, '0')}`
},
@ -56,54 +150,65 @@ export const DURATION_FORMATS = new Map([
},
],
[
'h:mm:ss.sss',
H_M_S_SSS,
{
description: 'h:mm:ss.sss (1:23:40.000)',
example: '1:23:40.000',
toString(hours, minutes, seconds) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
toString(d, h, m, s) {
return `${d * 24 + h}:${m.toString().padStart(2, '0')}:${s
.toFixed(3)
.padStart(6, '0')}`
},
round: (value) => Math.round(value * 1000) / 1000,
},
],
[
D_H,
{
description: 'd h (1d 2h)',
example: '1d 2h',
toString(d, h, m, s) {
return `${d}d ${h}h`
},
round: (value) => Math.round(value / 3600) * 3600,
},
],
[
D_H_M,
{
description: 'd h:mm (1d 2:34)',
example: '1d 2:34',
toString(d, h, m, s) {
return `${d}d ${h}:${m.toString().padStart(2, '0')}`
},
round: (value) => Math.round(value / 60) * 60,
},
],
[
D_H_M_S,
{
description: 'd h:mm:ss (1d 2:34:56)',
example: '1d 2:34:56',
toString(d, h, m, s) {
return `${d}d ${h}:${m.toString().padStart(2, '0')}:${s
.toString()
.padStart(2, '0')}`
},
round: (value) => Math.round(value),
},
],
])
export const DURATION_TOKENS = {
h: {
multiplier: 3600,
parse: (value) => parseInt(value),
},
mm: {
multiplier: 60,
parse: (value) => parseInt(value),
},
ss: {
multiplier: 1,
parse: (value) => parseInt(value),
},
'ss.s': {
multiplier: 1,
parse: (value) => Math.round(parseFloat(value) * 10) / 10,
},
'ss.ss': {
multiplier: 1,
parse: (value) => Math.round(parseFloat(value) * 100) / 100,
},
'ss.sss': {
multiplier: 1,
parse: (value) => Math.round(parseFloat(value) * 1000) / 1000,
},
}
export const roundDurationValueToFormat = (value, format) => {
if (value === null) {
return null
}
const roundFunc = DURATION_FORMATS.get(format).round
return roundFunc(value)
const durationFormatOptions = DURATION_FORMATS.get(format)
if (!durationFormatOptions) {
throw new Error(`Unknown duration format ${format}`)
}
return durationFormatOptions.round(value)
}
/**
@ -113,65 +218,40 @@ export const roundDurationValueToFormat = (value, format) => {
*/
export const parseDurationValue = (
inputValue,
format = MOST_ACCURATE_DURATION_FORMAT,
strict = false
format = MOST_ACCURATE_DURATION_FORMAT
) => {
if (inputValue === null || inputValue === undefined || inputValue === '') {
return null
}
// If the input value is a number, we assume it is in seconds.
// If the value is a number, we assume it's already in seconds (i.e. from the backend).
if (Number.isFinite(inputValue)) {
return inputValue > 0 ? inputValue : null
}
const parts = inputValue.split(':').reverse()
let tokens = format.split(':').reverse()
if (parts.length > tokens.length) {
if (strict || parts.length > MAX_NUMBER_OF_TOKENS_IN_DURATION_FORMAT) {
throw new Error(
`Invalid duration format: ${inputValue} does not match ${format}`
)
} else {
tokens = MOST_ACCURATE_DURATION_FORMAT.split(':').reverse()
for (const [fmtRegExp, formatFuncs] of DURATION_REGEXPS) {
const match = inputValue.match(fmtRegExp)
if (match) {
const formatFunc = formatFuncs[format] || formatFuncs.default
return formatFunc(...match.slice(1))
}
}
try {
return tokens.reduce((acc, token, index) => {
if (index >= parts.length) {
return acc
}
const part = parts[index]
const parseFunc = DURATION_TOKENS[token].parse
const number = parseFunc(part)
if (isNaN(number) || number < 0) {
throw new Error(
`Invalid duration format: ${inputValue} does not match ${format}`
)
}
const multiplier = DURATION_TOKENS[token].multiplier
return acc + number * multiplier
}, 0)
} catch (e) {
return null
}
return null
}
/**
* It formats the given duration value using the given format.
*/
export const formatDuration = (value, format) => {
export const formatDurationValue = (value, format) => {
if (value === null || value === undefined || value === '') {
return ''
}
const hours = Math.floor(value / 3600)
const mins = Math.floor((value - hours * 3600) / 60)
const secs = value - hours * 3600 - mins * 60
const days = Math.floor(value / 86400)
const hours = Math.floor((value % 86400) / 3600)
const mins = Math.floor((value % 3600) / 60)
const secs = value % 60
const formatFunc = DURATION_FORMATS.get(format).toString
return formatFunc(hours, mins, secs)
return formatFunc(days, hours, mins, secs)
}

View file

@ -12,7 +12,7 @@ const tableRows = [
{
id: 2,
order: '2.00000000000000000000',
field: '0:1:0',
field: 60,
},
{
id: 3,
@ -32,12 +32,12 @@ const tableRows = [
{
id: 6,
order: '5.00000000000000000000',
field: '2:0:0.123',
field: 7200.123,
},
{
id: 7,
order: '6.00000000000000000000',
field: '1.12'
field: 1.12
}
]
@ -71,11 +71,11 @@ describe('LastModifiedByFieldType.getSort()', () => {
)
const expected = [
null,
'1.12',
'0:1:0',
1.12,
60,
120,
3600,
'2:0:0.123',
7200.123,
86400,
]
@ -89,11 +89,11 @@ describe('LastModifiedByFieldType.getSort()', () => {
const expectedReversed = [
86400,
'2:0:0.123',
7200.123,
3600,
120,
'0:1:0',
'1.12',
60,
1.12,
null,
]

View file

@ -0,0 +1,507 @@
import {
parseDurationValue,
formatDurationValue,
roundDurationValueToFormat,
} from '@baserow/modules/database/utils/duration'
const SECS_IN_MIN = 60
const SECS_IN_HOUR = 60 * 60
const VALID_DURATION_VALUES = {
'd h': [
['1d 2h', 26 * SECS_IN_HOUR],
['2 1h', 49 * SECS_IN_HOUR],
['2 2', 50 * SECS_IN_HOUR],
['1 8', 32 * SECS_IN_HOUR],
['1d0h', 24 * SECS_IN_HOUR],
['1d25', 49 * SECS_IN_HOUR],
['5h', 5 * SECS_IN_HOUR],
['2d', 48 * SECS_IN_HOUR],
['10', 10 * SECS_IN_HOUR],
['3600.0', 1 * SECS_IN_HOUR],
['60:0.0', 1 * SECS_IN_HOUR],
['0', 0],
],
'd h:mm': [
['1d 2:30', 26 * SECS_IN_HOUR + 30 * 60],
['2 1:30', 49 * SECS_IN_HOUR + 30 * 60],
['2 1:61', 50 * SECS_IN_HOUR + 1 * 60],
['1 8:30', 32 * SECS_IN_HOUR + 30 * 60],
['1d0:61', 25 * SECS_IN_HOUR + 1 * 60],
['1d0:30', 24 * SECS_IN_HOUR + 30 * 60],
['1d1:30', 25 * SECS_IN_HOUR + 30 * 60],
['5:30', 5 * SECS_IN_HOUR + 30 * 60],
['10:30', 10 * SECS_IN_HOUR + 30 * 60],
['0:30', 30 * 60],
['0:0', 0],
],
'd h:mm:ss': [
['1d 2:30:45', 26 * SECS_IN_HOUR + 30 * 60 + 45],
['2 2:30:45', 50 * SECS_IN_HOUR + 30 * 60 + 45],
['1 8:30:45', 32 * SECS_IN_HOUR + 30 * 60 + 45],
['1d0:30:45', 24 * SECS_IN_HOUR + 30 * 60 + 45],
['1d1:30:45', 25 * SECS_IN_HOUR + 30 * 60 + 45],
['1d2:30:61', 26 * SECS_IN_HOUR + 31 * 60 + 1],
['5:30:45', 5 * SECS_IN_HOUR + 30 * 60 + 45],
['10:30:45', 10 * SECS_IN_HOUR + 30 * 60 + 45],
['0:30:45', 30 * 60 + 45],
['0:0:45', 45],
['0:0:0', 0],
],
'h:mm': [
['2:30', 2 * SECS_IN_HOUR + 30 * 60],
['1:30', 1 * SECS_IN_HOUR + 30 * 60],
['2:30', 2 * SECS_IN_HOUR + 30 * 60],
['8:30', 8 * SECS_IN_HOUR + 30 * 60],
['0:30', 30 * 60],
['0:61', 61 * 60],
['0:0', 0],
['0', 0],
],
'h:mm:ss': [
['2:30:45', 2 * SECS_IN_HOUR + 30 * 60 + 45],
['1:30:45', 1 * SECS_IN_HOUR + 30 * 60 + 45],
['2:30:45', 2 * SECS_IN_HOUR + 30 * 60 + 45],
['8:30:45', 8 * SECS_IN_HOUR + 30 * 60 + 45],
['0:30:45', 30 * 60 + 45],
['0:0:45', 45],
['0:0:61', 61],
['0:0:0', 0],
],
'h:mm:ss.s': [
['2:30:45.1', 2 * SECS_IN_HOUR + 30 * 60 + 45.1],
['1:30:45.2', 1 * SECS_IN_HOUR + 30 * 60 + 45.2],
['2:30:45.3', 2 * SECS_IN_HOUR + 30 * 60 + 45.3],
['8:30:45.9', 8 * SECS_IN_HOUR + 30 * 60 + 45.9],
['0:30:45.5', 30 * 60 + 45.5],
['0:0:45.0', 45],
['0:0:59.9', 59.9],
['0:0:0.0', 0],
],
'h:mm:ss.ss': [
['2:30:45.11', 2 * SECS_IN_HOUR + 30 * 60 + 45.11],
['1:30:45.22', 1 * SECS_IN_HOUR + 30 * 60 + 45.22],
['2:30:45.33', 2 * SECS_IN_HOUR + 30 * 60 + 45.33],
['8:30:45.46', 8 * SECS_IN_HOUR + 30 * 60 + 45.46],
['0:30:45.50', 30 * 60 + 45.5],
['0:0:45.00', 45],
['0:0:59.99', 59.99],
['0', 0],
],
'h:mm:ss.sss': [
['2:30:45.111', 2 * SECS_IN_HOUR + 30 * 60 + 45.111],
['1:30:45.222', 1 * SECS_IN_HOUR + 30 * 60 + 45.222],
['2:30:45.333', 2 * SECS_IN_HOUR + 30 * 60 + 45.333],
['8:30:45.456', 8 * SECS_IN_HOUR + 30 * 60 + 45.456],
['0:30:45.500', 30 * 60 + 45.5],
['0:0:45.000', 45],
['0:0:59.999', 59.999],
['0', 0],
],
}
const INVALID_DURATION_VALUES = [
'd',
'h',
'1d 2h 3m',
'1d 2h 3s',
'1day',
'2 days',
'1 hour',
'8 hours',
'1:2:3:4',
'aaaaa',
'1dd',
'2hh',
'1h2m',
]
const DURATION_FORMATTED_VALUES = {
'd h': [
[0, '0d 0h'],
[1 * SECS_IN_HOUR, '0d 1h'],
[24 * SECS_IN_HOUR, '1d 0h'],
[25 * SECS_IN_HOUR, '1d 1h'],
[49 * SECS_IN_HOUR, '2d 1h'],
[50 * SECS_IN_HOUR, '2d 2h'],
[32 * SECS_IN_HOUR, '1d 8h'],
[5 * SECS_IN_HOUR, '0d 5h'],
],
'd h:mm': [
[0, '0d 0:00'],
[1 * SECS_IN_HOUR, '0d 1:00'],
[24 * SECS_IN_HOUR, '1d 0:00'],
[2 * SECS_IN_HOUR, '0d 2:00'],
[49 * SECS_IN_HOUR, '2d 1:00'],
[50 * SECS_IN_HOUR, '2d 2:00'],
[32 * SECS_IN_HOUR, '1d 8:00'],
[5 * SECS_IN_HOUR, '0d 5:00'],
[5 * SECS_IN_HOUR + 30 * SECS_IN_MIN, '0d 5:30'],
],
'd h:mm:ss': [
[0, '0d 0:00:00'],
[1 * SECS_IN_HOUR, '0d 1:00:00'],
[24 * SECS_IN_HOUR, '1d 0:00:00'],
[25 * SECS_IN_HOUR, '1d 1:00:00'],
[49 * SECS_IN_HOUR, '2d 1:00:00'],
[50 * SECS_IN_HOUR, '2d 2:00:00'],
[32 * SECS_IN_HOUR, '1d 8:00:00'],
[5 * SECS_IN_HOUR, '0d 5:00:00'],
[5 * SECS_IN_HOUR + 30 * SECS_IN_MIN, '0d 5:30:00'],
[5 * SECS_IN_HOUR + 5 * SECS_IN_MIN + 5, '0d 5:05:05'],
],
'h:mm': [
[0, '0:00'],
[1 * SECS_IN_MIN, '0:01'],
[61 * SECS_IN_MIN, '1:01'],
[24 * SECS_IN_HOUR, '24:00'],
[25 * SECS_IN_HOUR, '25:00'],
[49 * SECS_IN_HOUR, '49:00'],
[50 * SECS_IN_HOUR, '50:00'],
[32 * SECS_IN_HOUR, '32:00'],
[5 * SECS_IN_HOUR, '5:00'],
[5 * SECS_IN_HOUR + 30 * SECS_IN_MIN, '5:30'],
],
'h:mm:ss': [
[0, '0:00:00'],
[1, '0:00:01'],
[61, '0:01:01'],
[1 * SECS_IN_MIN, '0:01:00'],
[61 * SECS_IN_MIN, '1:01:00'],
[24 * SECS_IN_HOUR, '24:00:00'],
[25 * SECS_IN_HOUR, '25:00:00'],
[49 * SECS_IN_HOUR, '49:00:00'],
[50 * SECS_IN_HOUR, '50:00:00'],
[32 * SECS_IN_HOUR, '32:00:00'],
[5 * SECS_IN_HOUR, '5:00:00'],
[5 * SECS_IN_HOUR + 30 * SECS_IN_MIN, '5:30:00'],
[5 * SECS_IN_HOUR + 5 * SECS_IN_MIN + 5, '5:05:05'],
],
'h:mm:ss.s': [
[0, '0:00:00.0'],
[1.1, '0:00:01.1'],
[61.9, '0:01:01.9'],
[1 * SECS_IN_MIN + 1.1, '0:01:01.1'],
[61 * SECS_IN_MIN + 2.2, '1:01:02.2'],
[24 * SECS_IN_HOUR + 3.3, '24:00:03.3'],
[25 * SECS_IN_HOUR + 9.9, '25:00:09.9'],
[49 * SECS_IN_HOUR, '49:00:00.0'],
[50 * SECS_IN_HOUR, '50:00:00.0'],
[32 * SECS_IN_HOUR, '32:00:00.0'],
[5 * SECS_IN_HOUR, '5:00:00.0'],
[5 * SECS_IN_HOUR + 30 * SECS_IN_MIN, '5:30:00.0'],
[5 * SECS_IN_HOUR + 5 * SECS_IN_MIN + 5, '5:05:05.0'],
[5 * SECS_IN_HOUR + 5 * SECS_IN_MIN + 5.1, '5:05:05.1'],
],
'h:mm:ss.ss': [
[0, '0:00:00.00'],
[1.11, '0:00:01.11'],
[61.22, '0:01:01.22'],
[1 * SECS_IN_MIN + 1.11, '0:01:01.11'],
[61 * SECS_IN_MIN + 2.22, '1:01:02.22'],
[24 * SECS_IN_HOUR + 3.33, '24:00:03.33'],
[25 * SECS_IN_HOUR + 9.99, '25:00:09.99'],
[49 * SECS_IN_HOUR, '49:00:00.00'],
[50 * SECS_IN_HOUR, '50:00:00.00'],
[32 * SECS_IN_HOUR, '32:00:00.00'],
[5 * SECS_IN_HOUR, '5:00:00.00'],
[5 * SECS_IN_HOUR + 30 * SECS_IN_MIN, '5:30:00.00'],
[5 * SECS_IN_HOUR + 5 * SECS_IN_MIN + 5, '5:05:05.00'],
[5 * SECS_IN_HOUR + 5 * SECS_IN_MIN + 5.1, '5:05:05.10'],
],
'h:mm:ss.sss': [
[0, '0:00:00.000'],
[1.111, '0:00:01.111'],
[61.222, '0:01:01.222'],
[1 * SECS_IN_MIN + 1.111, '0:01:01.111'],
[61 * SECS_IN_MIN + 2.222, '1:01:02.222'],
[24 * SECS_IN_HOUR + 3.333, '24:00:03.333'],
[25 * SECS_IN_HOUR + 9.999, '25:00:09.999'],
[49 * SECS_IN_HOUR, '49:00:00.000'],
[50 * SECS_IN_HOUR, '50:00:00.000'],
[32 * SECS_IN_HOUR, '32:00:00.000'],
[5 * SECS_IN_HOUR, '5:00:00.000'],
[5 * SECS_IN_HOUR + 30 * SECS_IN_MIN, '5:30:00.000'],
[5 * SECS_IN_HOUR + 5 * SECS_IN_MIN + 5, '5:05:05.000'],
[5 * SECS_IN_HOUR + 5 * SECS_IN_MIN + 5.1, '5:05:05.100'],
],
}
const DURATION_ROUNDING_VALUES = {
'd h': [
[null, null],
[0, 0],
[1.9, 0],
[30, 0],
[60, 0],
[29 * SECS_IN_MIN, 0],
[30 * SECS_IN_MIN, 1 * SECS_IN_HOUR],
[59 * SECS_IN_MIN, 1 * SECS_IN_HOUR],
[1 * SECS_IN_HOUR + 29 * SECS_IN_MIN, 1 * SECS_IN_HOUR],
[1 * SECS_IN_HOUR + 30 * SECS_IN_MIN, 2 * SECS_IN_HOUR],
],
'd h:mm': [
[null, null],
[0, 0],
[1, 0],
[29, 0],
[30, 1 * SECS_IN_MIN],
[59, 1 * SECS_IN_MIN],
[29 * SECS_IN_MIN + 29.9, 29 * SECS_IN_MIN],
[29 * SECS_IN_MIN + 30, 30 * SECS_IN_MIN],
],
'd h:mm:ss': [
[null, null],
[0, 0],
[0.499, 0],
[0.5, 1],
[0.9, 1],
[1, 1],
[1.5, 2],
],
'h:mm': [
[null, null],
[0, 0],
[1, 0],
[29, 0],
[30, 1 * SECS_IN_MIN],
[59, 1 * SECS_IN_MIN],
[29 * SECS_IN_MIN + 29.9, 29 * SECS_IN_MIN],
[29 * SECS_IN_MIN + 30, 30 * SECS_IN_MIN],
],
'h:mm:ss': [
[null, null],
[0, 0],
[0.499, 0],
[0.5, 1],
[0.9, 1],
[1, 1],
[1.5, 2],
],
'h:mm:ss.s': [
[null, null],
[0, 0],
[0.449, 0.4],
[0.45, 0.5],
[0.99, 1],
[1.12, 1.1],
[1.9, 1.9],
[1.99, 2],
],
'h:mm:ss.ss': [
[null, null],
[0, 0],
[0.49, 0.49],
[0.494, 0.49],
[0.499, 0.5],
[0.999, 1],
[1.123, 1.12],
[1.99, 1.99],
[1.999999, 2],
],
'h:mm:ss.sss': [
[null, null],
[0, 0],
[0.4994, 0.499],
[0.4995, 0.5],
[0.9991, 0.999],
[0.9995, 1],
[1.123, 1.123],
[1.999, 1.999],
[1.999999, 2],
],
}
describe('Duration field type utilities', () => {
describe('parse format: d h', () => {
for (const [input, expected] of VALID_DURATION_VALUES['d h']) {
test(`"${input}" should be parsed to ${expected}`, () => {
expect(parseDurationValue(input, 'd h')).toBe(expected)
})
}
})
describe('parse format: d h:mm', () => {
for (const [input, expected] of VALID_DURATION_VALUES['d h:mm']) {
test(`"${input}" should be parsed to ${expected}`, () => {
expect(parseDurationValue(input, 'd h:mm')).toBe(expected)
})
}
})
describe('parse format: d h:mm:ss', () => {
for (const [input, expected] of VALID_DURATION_VALUES['d h:mm:ss']) {
test(`"${input}" should be parsed to ${expected}`, () => {
expect(parseDurationValue(input, 'd h:mm:ss')).toBe(expected)
})
}
})
describe('parse format: h:mm', () => {
for (const [input, expected] of VALID_DURATION_VALUES['h:mm']) {
test(`"${input}" should be parsed to ${expected}`, () => {
expect(parseDurationValue(input, 'h:mm')).toBe(expected)
})
}
})
describe('parse format: h:mm:ss', () => {
for (const [input, expected] of VALID_DURATION_VALUES['h:mm:ss']) {
test(`"${input}" should be parsed to ${expected}`, () => {
expect(parseDurationValue(input, 'h:mm:ss')).toBe(expected)
})
}
})
describe('parse format: h:mm:ss.s', () => {
for (const [input, expected] of VALID_DURATION_VALUES['h:mm:ss.s']) {
test(`"${input}" should be parsed to ${expected}`, () => {
expect(parseDurationValue(input, 'h:mm:ss.s')).toBe(expected)
})
}
})
describe('parse format: h:mm:ss.ss', () => {
for (const [input, expected] of VALID_DURATION_VALUES['h:mm:ss.ss']) {
test(`"${input}" should be parsed to ${expected}`, () => {
expect(parseDurationValue(input, 'h:mm:ss.ss')).toBe(expected)
})
}
})
describe('parse format: h:mm:ss.sss', () => {
for (const [input, expected] of VALID_DURATION_VALUES['h:mm:ss.sss']) {
test(`"${input}" should be parsed to ${expected}`, () => {
expect(parseDurationValue(input, 'h:mm:ss.sss')).toBe(expected)
})
}
})
describe('parse invalid values', () => {
for (const input of INVALID_DURATION_VALUES) {
test(`"${input}" should be parsed to null`, () => {
expect(parseDurationValue(input, 'h:mm:ss.sss')).toBe(null)
})
}
})
describe('format format: d h', () => {
for (const [input, expected] of DURATION_FORMATTED_VALUES['d h']) {
test(`"${input}" should be formatted to ${expected}`, () => {
expect(formatDurationValue(input, 'd h')).toBe(expected)
})
}
})
describe('format format: d h:mm', () => {
for (const [input, expected] of DURATION_FORMATTED_VALUES['d h:mm']) {
test(`"${input}" should be formatted to ${expected}`, () => {
expect(formatDurationValue(input, 'd h:mm')).toBe(expected)
})
}
})
describe('format format: d h:mm:ss', () => {
for (const [input, expected] of DURATION_FORMATTED_VALUES['d h:mm:ss']) {
test(`"${input}" should be formatted to ${expected}`, () => {
expect(formatDurationValue(input, 'd h:mm:ss')).toBe(expected)
})
}
})
describe('format format: h:mm', () => {
for (const [input, expected] of DURATION_FORMATTED_VALUES['h:mm']) {
test(`"${input}" should be formatted to ${expected}`, () => {
expect(formatDurationValue(input, 'h:mm')).toBe(expected)
})
}
})
describe('format format: h:mm:ss', () => {
for (const [input, expected] of DURATION_FORMATTED_VALUES['h:mm:ss']) {
test(`"${input}" should be formatted to ${expected}`, () => {
expect(formatDurationValue(input, 'h:mm:ss')).toBe(expected)
})
}
})
describe('format format: h:mm:ss.s', () => {
for (const [input, expected] of DURATION_FORMATTED_VALUES['h:mm:ss.s']) {
test(`"${input}" should be formatted to ${expected}`, () => {
expect(formatDurationValue(input, 'h:mm:ss.s')).toBe(expected)
})
}
})
describe('format format: h:mm:ss.ss', () => {
for (const [input, expected] of DURATION_FORMATTED_VALUES['h:mm:ss.ss']) {
test(`"${input}" should be formatted to ${expected}`, () => {
expect(formatDurationValue(input, 'h:mm:ss.ss')).toBe(expected)
})
}
})
describe('format format: h:mm:ss.sss', () => {
for (const [input, expected] of DURATION_FORMATTED_VALUES['h:mm:ss.sss']) {
test(`"${input}" should be formatted to ${expected}`, () => {
expect(formatDurationValue(input, 'h:mm:ss.sss')).toBe(expected)
})
}
})
describe('round value: d h', () => {
for (const [input, expected] of DURATION_ROUNDING_VALUES['d h']) {
test(`"${input}" should be rounded to ${expected}`, () => {
expect(roundDurationValueToFormat(input, 'd h')).toBe(expected)
})
}
})
describe('round value: d h:mm', () => {
for (const [input, expected] of DURATION_ROUNDING_VALUES['d h:mm']) {
test(`"${input}" should be rounded to ${expected}`, () => {
expect(roundDurationValueToFormat(input, 'd h:mm')).toBe(expected)
})
}
})
describe('round value: d h:mm:ss', () => {
for (const [input, expected] of DURATION_ROUNDING_VALUES['d h:mm:ss']) {
test(`"${input}" should be rounded to ${expected}`, () => {
expect(roundDurationValueToFormat(input, 'd h:mm:ss')).toBe(expected)
})
}
})
describe('round value: h:mm', () => {
for (const [input, expected] of DURATION_ROUNDING_VALUES['h:mm']) {
test(`"${input}" should be rounded to ${expected}`, () => {
expect(roundDurationValueToFormat(input, 'h:mm')).toBe(expected)
})
}
})
describe('round value: h:mm:ss', () => {
for (const [input, expected] of DURATION_ROUNDING_VALUES['h:mm:ss']) {
test(`"${input}" should be rounded to ${expected}`, () => {
expect(roundDurationValueToFormat(input, 'h:mm:ss')).toBe(expected)
})
}
})
describe('round value: h:mm:ss.s', () => {
for (const [input, expected] of DURATION_ROUNDING_VALUES['h:mm:ss.s']) {
test(`"${input}" should be rounded to ${expected}`, () => {
expect(roundDurationValueToFormat(input, 'h:mm:ss.s')).toBe(expected)
})
}
})
describe('round value: h:mm:ss.ss', () => {
for (const [input, expected] of DURATION_ROUNDING_VALUES['h:mm:ss.ss']) {
test(`"${input}" should be rounded to ${expected}`, () => {
expect(roundDurationValueToFormat(input, 'h:mm:ss.ss')).toBe(expected)
})
}
})
describe('round value: h:mm:ss.sss', () => {
for (const [input, expected] of DURATION_ROUNDING_VALUES['h:mm:ss.sss']) {
test(`"${input}" should be rounded to ${expected}`, () => {
expect(roundDurationValueToFormat(input, 'h:mm:ss.sss')).toBe(expected)
})
}
})
})

View file

@ -5,7 +5,7 @@ import {
} from '@baserow/modules/database/utils/row'
import { TestApp } from '@baserow/test/helpers/testApp'
describe('Row untilities', () => {
describe('Row utilities', () => {
let testApp = null
let store = null