Skip to content

Commit dfd633b

Browse files
dyleeeeeeeeDyleeemartinschaer
authored
feat: support compound duration parsing in Duration.parse() (#213)
Co-authored-by: Dylee <oyinxdoubx@gmail.com> Co-authored-by: Martin Schaer <martin.schaer@surrealdb.com>
1 parent 40f1ec1 commit dfd633b

File tree

2 files changed

+63
-18
lines changed

2 files changed

+63
-18
lines changed

src/surrealdb/data/types/duration.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1+
import re
12
from dataclasses import dataclass
23
from math import floor
34
from typing import Union
45

56
UNITS = {
67
"ns": 1,
7-
"us": int(1e3),
8+
"µs": int(1e3), # Microsecond (µ symbol)
9+
"us": int(1e3), # Microsecond (us)
810
"ms": int(1e6),
911
"s": int(1e9),
1012
"m": int(60 * 1e9),
1113
"h": int(3600 * 1e9),
1214
"d": int(86400 * 1e9),
1315
"w": int(604800 * 1e9),
16+
"y": int(365 * 86400 * 1e9), # Year (365 days)
1417
}
1518

1619

@@ -23,18 +26,22 @@ def parse(value: Union[str, int], nanoseconds: int = 0) -> "Duration":
2326
if isinstance(value, int):
2427
return Duration(nanoseconds + value * UNITS["s"])
2528
else:
26-
# Check for multi-character units first
27-
for unit in ["ns", "us", "ms"]:
28-
if value.endswith(unit):
29-
num = int(value[: -len(unit)])
30-
return Duration(num * UNITS[unit])
31-
# Check for single-character units
32-
unit = value[-1]
33-
num = int(value[:-1])
34-
if unit in UNITS:
35-
return Duration(num * UNITS[unit])
36-
else:
37-
raise ValueError(f"Unknown duration unit: {unit}")
29+
# Support compound durations: "1h30m", "2d3h15m", etc.
30+
pattern = r"(\d+)(ns|µs|us|ms|[smhdwy])"
31+
matches = re.findall(pattern, value.lower())
32+
33+
if not matches:
34+
raise ValueError(f"Invalid duration format: {value}")
35+
36+
total_ns = nanoseconds
37+
for num_str, unit in matches:
38+
num = int(num_str)
39+
if unit not in UNITS:
40+
# this will never happen because the regex only matches valid units
41+
raise ValueError(f"Unknown duration unit: {unit}")
42+
total_ns += num * UNITS[unit]
43+
44+
return Duration(total_ns)
3845

3946
def get_seconds_and_nano(self) -> tuple[int, int]:
4047
sec = floor(self.elapsed / UNITS["s"])
@@ -78,8 +85,12 @@ def days(self) -> int:
7885
def weeks(self) -> int:
7986
return self.elapsed // UNITS["w"]
8087

88+
@property
89+
def years(self) -> int:
90+
return self.elapsed // UNITS["y"]
91+
8192
def to_string(self) -> str:
82-
for unit in ["w", "d", "h", "m", "s", "ms", "us", "ns"]:
93+
for unit in ["y", "w", "d", "h", "m", "s", "ms", "us", "ns"]:
8394
value = self.elapsed // UNITS[unit]
8495
if value > 0:
8596
return f"{value}{unit}"

tests/unit_tests/data_types/test_duration.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,46 @@ def test_duration_parse_str_weeks() -> None:
5050
assert duration.elapsed == 604800 * 1_000_000_000
5151

5252

53+
def test_duration_parse_str_years() -> None:
54+
"""Test Duration.parse with string input in years."""
55+
duration = Duration.parse("2y")
56+
assert duration.elapsed == 2 * 365 * 86400 * 1_000_000_000
57+
58+
5359
def test_duration_parse_str_milliseconds() -> None:
5460
"""Test Duration.parse with string input in milliseconds."""
5561
duration = Duration.parse("500ms")
5662
assert duration.elapsed == 500 * 1_000_000
5763

5864

5965
def test_duration_parse_str_microseconds() -> None:
60-
"""Test Duration.parse with string input in microseconds."""
61-
duration = Duration.parse("100us")
62-
assert duration.elapsed == 100 * 1_000
66+
"""Test Duration.parse with string input in microseconds (both us and µs variants)."""
67+
duration_us = Duration.parse("100us")
68+
duration_mu = Duration.parse("100µs")
69+
70+
# Both should equal 100 microseconds in nanoseconds
71+
assert duration_us.elapsed == 100 * 1_000
72+
assert duration_mu.elapsed == 100 * 1_000
73+
74+
# Both variants should produce identical results
75+
assert duration_us.elapsed == duration_mu.elapsed
76+
77+
78+
def test_duration_parse_str_compound() -> None:
79+
"""Test Duration.parse with comprehensive compound duration including all units."""
80+
duration = Duration.parse("1y2w3d4h5m6s7ms8us9ns")
81+
assert (
82+
duration.elapsed
83+
== (1 * 365 * 86400 * 1_000_000_000)
84+
+ (2 * 604800 * 1_000_000_000)
85+
+ (3 * 86400 * 1_000_000_000)
86+
+ (4 * 3600 * 1_000_000_000)
87+
+ (5 * 60 * 1_000_000_000)
88+
+ (6 * 1_000_000_000)
89+
+ (7 * 1_000_000)
90+
+ (8 * 1_000)
91+
+ 9
92+
)
6393

6494

6595
def test_duration_parse_str_nanoseconds() -> None:
@@ -70,7 +100,9 @@ def test_duration_parse_str_nanoseconds() -> None:
70100

71101
def test_duration_parse_invalid_unit() -> None:
72102
"""Test Duration.parse with invalid unit raises ValueError."""
73-
with pytest.raises(ValueError, match="Unknown duration unit: x"):
103+
# it fails when checking the format, before checking if the unit is valid,
104+
# which is ok.
105+
with pytest.raises(ValueError, match="Invalid duration format: 10x"):
74106
Duration.parse("10x")
75107

76108

@@ -115,6 +147,7 @@ def test_duration_properties() -> None:
115147
assert duration.hours == total_ns // (3600 * 1_000_000_000)
116148
assert duration.days == total_ns // (86400 * 1_000_000_000)
117149
assert duration.weeks == total_ns // (604800 * 1_000_000_000)
150+
assert duration.years == total_ns // (365 * 86400 * 1_000_000_000)
118151

119152

120153
def test_duration_to_string() -> None:
@@ -128,6 +161,7 @@ def test_duration_to_string() -> None:
128161
assert Duration(3600 * 1_000_000_000).to_string() == "1h"
129162
assert Duration(86400 * 1_000_000_000).to_string() == "1d"
130163
assert Duration(604800 * 1_000_000_000).to_string() == "1w"
164+
assert Duration(365 * 86400 * 1_000_000_000).to_string() == "1y"
131165

132166
# Test compound duration (should use largest unit)
133167
compound = Duration(3600 * 1_000_000_000 + 30 * 60 * 1_000_000_000) # 1h30m

0 commit comments

Comments
 (0)