From c994611b23e62a1446eae20227d02de0eaacde7d Mon Sep 17 00:00:00 2001 From: Dylee Date: Mon, 10 Nov 2025 11:35:44 -0500 Subject: [PATCH 1/6] feat: Support compound duration parsing in Duration.parse() Enhance Duration.parse() to handle compound duration strings like \"1h30m\", \"2d3h15m\", etc., in addition to single-unit durations. - Modified parse() to use regex pattern matching - Added support for multiple units in a single duration string - Maintained backward compatibility with single-unit durations - Added comprehensive test coverage Resolves common use cases where users need to express durations with multiple units (e.g., \"1 hour and 30 minutes\" as \"1h30m\"). --- src/surrealdb/data/types/duration.py | 28 +++++++++++--------- tests/unit_tests/data_types/test_duration.py | 6 +++++ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/surrealdb/data/types/duration.py b/src/surrealdb/data/types/duration.py index d8470304..c7a1194f 100644 --- a/src/surrealdb/data/types/duration.py +++ b/src/surrealdb/data/types/duration.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from math import floor from typing import Union +import re UNITS = { "ns": 1, @@ -23,18 +24,21 @@ def parse(value: Union[str, int], nanoseconds: int = 0) -> "Duration": if isinstance(value, int): return Duration(nanoseconds + value * UNITS["s"]) else: - # Check for multi-character units first - for unit in ["ns", "us", "ms"]: - if value.endswith(unit): - num = int(value[: -len(unit)]) - return Duration(num * UNITS[unit]) - # Check for single-character units - unit = value[-1] - num = int(value[:-1]) - if unit in UNITS: - return Duration(num * UNITS[unit]) - else: - raise ValueError(f"Unknown duration unit: {unit}") + # Support compound durations: "1h30m", "2d3h15m", etc. + pattern = r'(\d+)(ns|µs|us|ms|[smhdwy])' + matches = re.findall(pattern, value.lower()) + + if not matches: + raise ValueError(f"Invalid duration format: {value}") + + total_ns = nanoseconds + for num_str, unit in matches: + num = int(num_str) + if unit not in UNITS: + raise ValueError(f"Unknown duration unit: {unit}") + total_ns += num * UNITS[unit] + + return Duration(total_ns) def get_seconds_and_nano(self) -> tuple[int, int]: sec = floor(self.elapsed / UNITS["s"]) diff --git a/tests/unit_tests/data_types/test_duration.py b/tests/unit_tests/data_types/test_duration.py index e550001b..83dd06b8 100644 --- a/tests/unit_tests/data_types/test_duration.py +++ b/tests/unit_tests/data_types/test_duration.py @@ -62,6 +62,12 @@ def test_duration_parse_str_microseconds() -> None: assert duration.elapsed == 100 * 1_000 +def test_duration_parse_str_compound() -> None: + """Test Duration.parse with string input in compound duration.""" + duration = Duration.parse("1h45m") + assert duration.elapsed == 3600 * 1_000_000_000 + 45 * 60 * 1_000_000_000 + + def test_duration_parse_str_nanoseconds() -> None: """Test Duration.parse with string input in nanoseconds.""" duration = Duration.parse("1000ns") From 5a5d7bf906a3abd8c499e6cb37179467daa27b2e Mon Sep 17 00:00:00 2001 From: Dylee Date: Wed, 12 Nov 2025 09:46:12 -0500 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20add=20missing=20duration=20units=20?= =?UTF-8?q?=C2=B5s=20and=20y=20to=20UNITS=20dict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regex pattern in Duration.parse() includes 'µs' (microsecond with µ symbol) and 'y' (year) but they were missing from the UNITS dictionary, causing ValueError when parsing durations with these units. Added: - 'µs': microsecond (same as 'us') = 1e3 nanoseconds - 'y': year (365 days) = 31,536,000,000,000,000 nanoseconds Also added years property and updated to_string() to include years in the unit hierarchy. --- src/surrealdb/data/types/duration.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/surrealdb/data/types/duration.py b/src/surrealdb/data/types/duration.py index c7a1194f..db408c8a 100644 --- a/src/surrealdb/data/types/duration.py +++ b/src/surrealdb/data/types/duration.py @@ -5,13 +5,15 @@ UNITS = { "ns": 1, - "us": int(1e3), + "µs": int(1e3), # Microsecond (µ symbol) + "us": int(1e3), # Microsecond (us) "ms": int(1e6), "s": int(1e9), "m": int(60 * 1e9), "h": int(3600 * 1e9), "d": int(86400 * 1e9), "w": int(604800 * 1e9), + "y": int(365 * 86400 * 1e9), # Year (365 days) } @@ -82,8 +84,12 @@ def days(self) -> int: def weeks(self) -> int: return self.elapsed // UNITS["w"] + @property + def years(self) -> int: + return self.elapsed // UNITS["y"] + def to_string(self) -> str: - for unit in ["w", "d", "h", "m", "s", "ms", "us", "ns"]: + for unit in ["y", "w", "d", "h", "m", "s", "ms", "us", "ns"]: value = self.elapsed // UNITS[unit] if value > 0: return f"{value}{unit}" From 2339872e59c9bb9023c4ee63052603df9edc95d6 Mon Sep 17 00:00:00 2001 From: Dylee Date: Wed, 12 Nov 2025 09:48:04 -0500 Subject: [PATCH 3/6] test: add comprehensive duration tests for all units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace simple compound test with comprehensive test using all units (1y2w3d4h5m6s7ms8us9ns) - Add individual tests for years (y) and microseconds with µ symbol (µs) - Update properties test to include years property - Add years to to_string test - Ensures complete coverage of newly added duration units --- tests/unit_tests/data_types/test_duration.py | 28 +++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/tests/unit_tests/data_types/test_duration.py b/tests/unit_tests/data_types/test_duration.py index 83dd06b8..2bfdc4f0 100644 --- a/tests/unit_tests/data_types/test_duration.py +++ b/tests/unit_tests/data_types/test_duration.py @@ -50,6 +50,12 @@ def test_duration_parse_str_weeks() -> None: assert duration.elapsed == 604800 * 1_000_000_000 +def test_duration_parse_str_years() -> None: + """Test Duration.parse with string input in years.""" + duration = Duration.parse("2y") + assert duration.elapsed == 2 * 365 * 86400 * 1_000_000_000 + + def test_duration_parse_str_milliseconds() -> None: """Test Duration.parse with string input in milliseconds.""" duration = Duration.parse("500ms") @@ -62,10 +68,24 @@ def test_duration_parse_str_microseconds() -> None: assert duration.elapsed == 100 * 1_000 +def test_duration_parse_str_microseconds_mu() -> None: + """Test Duration.parse with string input in microseconds using µ symbol.""" + duration = Duration.parse("100µs") + assert duration.elapsed == 100 * 1_000 + + def test_duration_parse_str_compound() -> None: - """Test Duration.parse with string input in compound duration.""" - duration = Duration.parse("1h45m") - assert duration.elapsed == 3600 * 1_000_000_000 + 45 * 60 * 1_000_000_000 + """Test Duration.parse with comprehensive compound duration including all units.""" + duration = Duration.parse("1y2w3d4h5m6s7ms8us9ns") + assert duration.elapsed == (1 * 365 * 86400 * 1_000_000_000) \ + + (2 * 604800 * 1_000_000_000) \ + + (3 * 86400 * 1_000_000_000) \ + + (4 * 3600 * 1_000_000_000) \ + + (5 * 60 * 1_000_000_000) \ + + (6 * 1_000_000_000) \ + + (7 * 1_000_000) \ + + (8 * 1_000) \ + + 9 def test_duration_parse_str_nanoseconds() -> None: @@ -121,6 +141,7 @@ def test_duration_properties() -> None: assert duration.hours == total_ns // (3600 * 1_000_000_000) assert duration.days == total_ns // (86400 * 1_000_000_000) assert duration.weeks == total_ns // (604800 * 1_000_000_000) + assert duration.years == total_ns // (365 * 86400 * 1_000_000_000) def test_duration_to_string() -> None: @@ -134,6 +155,7 @@ def test_duration_to_string() -> None: assert Duration(3600 * 1_000_000_000).to_string() == "1h" assert Duration(86400 * 1_000_000_000).to_string() == "1d" assert Duration(604800 * 1_000_000_000).to_string() == "1w" + assert Duration(365 * 86400 * 1_000_000_000).to_string() == "1y" # Test compound duration (should use largest unit) compound = Duration(3600 * 1_000_000_000 + 30 * 60 * 1_000_000_000) # 1h30m From 38fe9105ca975a7e0a89a505dafe57df2a486069 Mon Sep 17 00:00:00 2001 From: Dylee Date: Wed, 12 Nov 2025 09:52:12 -0500 Subject: [PATCH 4/6] =?UTF-8?q?test:=20consolidate=20microsecond=20tests?= =?UTF-8?q?=20to=20verify=20us=20and=20=C2=B5s=20equivalence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge separate tests into one that verifies both 'us' and 'µs' variants produce identical results, ensuring the µ symbol variant works correctly. --- tests/unit_tests/data_types/test_duration.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/unit_tests/data_types/test_duration.py b/tests/unit_tests/data_types/test_duration.py index 2bfdc4f0..468d860f 100644 --- a/tests/unit_tests/data_types/test_duration.py +++ b/tests/unit_tests/data_types/test_duration.py @@ -63,15 +63,16 @@ def test_duration_parse_str_milliseconds() -> None: def test_duration_parse_str_microseconds() -> None: - """Test Duration.parse with string input in microseconds.""" - duration = Duration.parse("100us") - assert duration.elapsed == 100 * 1_000 - - -def test_duration_parse_str_microseconds_mu() -> None: - """Test Duration.parse with string input in microseconds using µ symbol.""" - duration = Duration.parse("100µs") - assert duration.elapsed == 100 * 1_000 + """Test Duration.parse with string input in microseconds (both us and µs variants).""" + duration_us = Duration.parse("100us") + duration_mu = Duration.parse("100µs") + + # Both should equal 100 microseconds in nanoseconds + assert duration_us.elapsed == 100 * 1_000 + assert duration_mu.elapsed == 100 * 1_000 + + # Both variants should produce identical results + assert duration_us.elapsed == duration_mu.elapsed def test_duration_parse_str_compound() -> None: From c7637cd7f4d741db0827963780180161138c0470 Mon Sep 17 00:00:00 2001 From: Martin Schaer Date: Mon, 1 Dec 2025 10:34:11 +0000 Subject: [PATCH 5/6] format --- src/surrealdb/data/types/duration.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/surrealdb/data/types/duration.py b/src/surrealdb/data/types/duration.py index db408c8a..bf41f3f4 100644 --- a/src/surrealdb/data/types/duration.py +++ b/src/surrealdb/data/types/duration.py @@ -1,7 +1,7 @@ +import re from dataclasses import dataclass from math import floor from typing import Union -import re UNITS = { "ns": 1, @@ -27,19 +27,19 @@ def parse(value: Union[str, int], nanoseconds: int = 0) -> "Duration": return Duration(nanoseconds + value * UNITS["s"]) else: # Support compound durations: "1h30m", "2d3h15m", etc. - pattern = r'(\d+)(ns|µs|us|ms|[smhdwy])' + pattern = r"(\d+)(ns|µs|us|ms|[smhdwy])" matches = re.findall(pattern, value.lower()) - + if not matches: raise ValueError(f"Invalid duration format: {value}") - + total_ns = nanoseconds for num_str, unit in matches: num = int(num_str) if unit not in UNITS: raise ValueError(f"Unknown duration unit: {unit}") total_ns += num * UNITS[unit] - + return Duration(total_ns) def get_seconds_and_nano(self) -> tuple[int, int]: From c7201e53f8f21168d9e159af81691d09181ac8c7 Mon Sep 17 00:00:00 2001 From: Martin Schaer Date: Mon, 1 Dec 2025 11:07:44 +0000 Subject: [PATCH 6/6] fix test --- src/surrealdb/data/types/duration.py | 1 + tests/unit_tests/data_types/test_duration.py | 27 ++++++++++++-------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/surrealdb/data/types/duration.py b/src/surrealdb/data/types/duration.py index bf41f3f4..3c7e2259 100644 --- a/src/surrealdb/data/types/duration.py +++ b/src/surrealdb/data/types/duration.py @@ -37,6 +37,7 @@ def parse(value: Union[str, int], nanoseconds: int = 0) -> "Duration": for num_str, unit in matches: num = int(num_str) if unit not in UNITS: + # this will never happen because the regex only matches valid units raise ValueError(f"Unknown duration unit: {unit}") total_ns += num * UNITS[unit] diff --git a/tests/unit_tests/data_types/test_duration.py b/tests/unit_tests/data_types/test_duration.py index 468d860f..ead23eed 100644 --- a/tests/unit_tests/data_types/test_duration.py +++ b/tests/unit_tests/data_types/test_duration.py @@ -66,11 +66,11 @@ def test_duration_parse_str_microseconds() -> None: """Test Duration.parse with string input in microseconds (both us and µs variants).""" duration_us = Duration.parse("100us") duration_mu = Duration.parse("100µs") - + # Both should equal 100 microseconds in nanoseconds assert duration_us.elapsed == 100 * 1_000 assert duration_mu.elapsed == 100 * 1_000 - + # Both variants should produce identical results assert duration_us.elapsed == duration_mu.elapsed @@ -78,15 +78,18 @@ def test_duration_parse_str_microseconds() -> None: def test_duration_parse_str_compound() -> None: """Test Duration.parse with comprehensive compound duration including all units.""" duration = Duration.parse("1y2w3d4h5m6s7ms8us9ns") - assert duration.elapsed == (1 * 365 * 86400 * 1_000_000_000) \ - + (2 * 604800 * 1_000_000_000) \ - + (3 * 86400 * 1_000_000_000) \ - + (4 * 3600 * 1_000_000_000) \ - + (5 * 60 * 1_000_000_000) \ - + (6 * 1_000_000_000) \ - + (7 * 1_000_000) \ - + (8 * 1_000) \ + assert ( + duration.elapsed + == (1 * 365 * 86400 * 1_000_000_000) + + (2 * 604800 * 1_000_000_000) + + (3 * 86400 * 1_000_000_000) + + (4 * 3600 * 1_000_000_000) + + (5 * 60 * 1_000_000_000) + + (6 * 1_000_000_000) + + (7 * 1_000_000) + + (8 * 1_000) + 9 + ) def test_duration_parse_str_nanoseconds() -> None: @@ -97,7 +100,9 @@ def test_duration_parse_str_nanoseconds() -> None: def test_duration_parse_invalid_unit() -> None: """Test Duration.parse with invalid unit raises ValueError.""" - with pytest.raises(ValueError, match="Unknown duration unit: x"): + # it fails when checking the format, before checking if the unit is valid, + # which is ok. + with pytest.raises(ValueError, match="Invalid duration format: 10x"): Duration.parse("10x")