From 1a983ae9497c1b05fb70e8c31c4ade5843a27d24 Mon Sep 17 00:00:00 2001 From: Kayvan Zahiri <123409429+Kayvan-Zahiri@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:17:02 -0700 Subject: [PATCH 1/3] fix: preserve negative timedelta values in MySQL parameters --- singlestoredb/mysql/converters.py | 9 +++-- .../tests/test_timedelta_parameters.py | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 singlestoredb/tests/test_timedelta_parameters.py diff --git a/singlestoredb/mysql/converters.py b/singlestoredb/mysql/converters.py index 7ddfb205c..f3d550fa4 100644 --- a/singlestoredb/mysql/converters.py +++ b/singlestoredb/mysql/converters.py @@ -146,14 +146,17 @@ def escape_None(value: str, mapping: Optional[Encoders] = None) -> str: def escape_timedelta(obj: datetime.timedelta, mapping: Optional[Encoders] = None) -> str: + negative = obj.days < 0 + if negative: + obj = -obj seconds = int(obj.seconds) % 60 minutes = int(obj.seconds // 60) % 60 hours = int(obj.seconds // 3600) % 24 + int(obj.days) * 24 if obj.microseconds: - fmt = "'{0:02d}:{1:02d}:{2:02d}.{3:06d}'" + fmt = "'{0}{1:02d}:{2:02d}:{3:02d}.{4:06d}'" else: - fmt = "'{0:02d}:{1:02d}:{2:02d}'" - return fmt.format(hours, minutes, seconds, obj.microseconds) + fmt = "'{0}{1:02d}:{2:02d}:{3:02d}'" + return fmt.format('-' if negative else '', hours, minutes, seconds, obj.microseconds) def escape_time(obj: datetime.time, mapping: Optional[Encoders] = None) -> str: diff --git a/singlestoredb/tests/test_timedelta_parameters.py b/singlestoredb/tests/test_timedelta_parameters.py new file mode 100644 index 000000000..d8b38f6f0 --- /dev/null +++ b/singlestoredb/tests/test_timedelta_parameters.py @@ -0,0 +1,33 @@ +"""Offline cursor parameter tests for signed TIME durations.""" +import datetime + +import pytest + +from singlestoredb.converters import timedelta_fromisoformat +from singlestoredb.mysql import connect + + +@pytest.mark.parametrize( + 'duration, literal', + [ + (datetime.timedelta(microseconds=-1), '-00:00:00.000001'), + (datetime.timedelta(seconds=-1), '-00:00:01'), + (datetime.timedelta(minutes=-1), '-00:01:00'), + (datetime.timedelta(hours=-1), '-01:00:00'), + (datetime.timedelta(hours=-25, seconds=-17), '-25:00:17'), + (datetime.timedelta(seconds=-1, microseconds=-123456), '-00:00:01.123456'), + (datetime.timedelta(days=-2), '-48:00:00'), + (datetime.timedelta(0), '00:00:00'), + (datetime.timedelta(microseconds=1), '00:00:00.000001'), + (datetime.timedelta(hours=25, seconds=17), '25:00:17'), + ], +) +def test_mogrify_preserves_timedelta_sign_and_magnitude( + duration: datetime.timedelta, literal: str, +) -> None: + """Bind the correct TIME literal without opening a database connection.""" + connection = connect(defer_connect=True) + cursor = connection.cursor() + + assert cursor.mogrify('SELECT %s', (duration,)) == f"SELECT '{literal}'" + assert timedelta_fromisoformat(literal) == duration From abf76a0dd719cdd80cbb09eb4f4598156e88987a Mon Sep 17 00:00:00 2001 From: kzahiri1 Date: Mon, 26 Oct 2026 09:23:10 -0700 Subject: [PATCH 2/3] fix: preserve negative timedelta values in HTTP API parameters --- singlestoredb/http/connection.py | 9 +++-- .../tests/test_timedelta_parameters.py | 39 ++++++++++++------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/singlestoredb/http/connection.py b/singlestoredb/http/connection.py index fb01d5a58..1f82c5e04 100644 --- a/singlestoredb/http/connection.py +++ b/singlestoredb/http/connection.py @@ -182,14 +182,17 @@ def b64decode_converter( def encode_timedelta(obj: datetime.timedelta) -> str: """Encode timedelta as str.""" + negative = obj.days < 0 + if negative: + obj = -obj seconds = int(obj.seconds) % 60 minutes = int(obj.seconds // 60) % 60 hours = int(obj.seconds // 3600) % 24 + int(obj.days) * 24 if obj.microseconds: - fmt = '{0:02d}:{1:02d}:{2:02d}.{3:06d}' + fmt = '{0}{1:02d}:{2:02d}:{3:02d}.{4:06d}' else: - fmt = '{0:02d}:{1:02d}:{2:02d}' - return fmt.format(hours, minutes, seconds, obj.microseconds) + fmt = '{0}{1:02d}:{2:02d}:{3:02d}' + return fmt.format('-' if negative else '', hours, minutes, seconds, obj.microseconds) def encode_time(obj: datetime.time) -> str: diff --git a/singlestoredb/tests/test_timedelta_parameters.py b/singlestoredb/tests/test_timedelta_parameters.py index d8b38f6f0..ad0bd80ae 100644 --- a/singlestoredb/tests/test_timedelta_parameters.py +++ b/singlestoredb/tests/test_timedelta_parameters.py @@ -4,24 +4,25 @@ import pytest from singlestoredb.converters import timedelta_fromisoformat +from singlestoredb.http.connection import encode_timedelta from singlestoredb.mysql import connect -@pytest.mark.parametrize( - 'duration, literal', - [ - (datetime.timedelta(microseconds=-1), '-00:00:00.000001'), - (datetime.timedelta(seconds=-1), '-00:00:01'), - (datetime.timedelta(minutes=-1), '-00:01:00'), - (datetime.timedelta(hours=-1), '-01:00:00'), - (datetime.timedelta(hours=-25, seconds=-17), '-25:00:17'), - (datetime.timedelta(seconds=-1, microseconds=-123456), '-00:00:01.123456'), - (datetime.timedelta(days=-2), '-48:00:00'), - (datetime.timedelta(0), '00:00:00'), - (datetime.timedelta(microseconds=1), '00:00:00.000001'), - (datetime.timedelta(hours=25, seconds=17), '25:00:17'), - ], -) +CASES = [ + (datetime.timedelta(microseconds=-1), '-00:00:00.000001'), + (datetime.timedelta(seconds=-1), '-00:00:01'), + (datetime.timedelta(minutes=-1), '-00:01:00'), + (datetime.timedelta(hours=-1), '-01:00:00'), + (datetime.timedelta(hours=-25, seconds=-17), '-25:00:17'), + (datetime.timedelta(seconds=-1, microseconds=-123456), '-00:00:01.123456'), + (datetime.timedelta(days=-2), '-48:00:00'), + (datetime.timedelta(0), '00:00:00'), + (datetime.timedelta(microseconds=1), '00:00:00.000001'), + (datetime.timedelta(hours=25, seconds=17), '25:00:17'), +] + + +@pytest.mark.parametrize('duration, literal', CASES) def test_mogrify_preserves_timedelta_sign_and_magnitude( duration: datetime.timedelta, literal: str, ) -> None: @@ -31,3 +32,11 @@ def test_mogrify_preserves_timedelta_sign_and_magnitude( assert cursor.mogrify('SELECT %s', (duration,)) == f"SELECT '{literal}'" assert timedelta_fromisoformat(literal) == duration + + +@pytest.mark.parametrize('duration, literal', CASES) +def test_http_encode_timedelta_preserves_sign_and_magnitude( + duration: datetime.timedelta, literal: str, +) -> None: + """The HTTP API encoder writes the same TIME literal.""" + assert encode_timedelta(duration) == literal From e32e0efabef8f2d59db79e421f9c4dcdb70c72f2 Mon Sep 17 00:00:00 2001 From: Kayvan-Zahiri Date: Mon, 2 Nov 2026 11:37:07 -0800 Subject: [PATCH 3/3] Rename test file to test_parameters.py --- .../tests/{test_timedelta_parameters.py => test_parameters.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename singlestoredb/tests/{test_timedelta_parameters.py => test_parameters.py} (100%) diff --git a/singlestoredb/tests/test_timedelta_parameters.py b/singlestoredb/tests/test_parameters.py similarity index 100% rename from singlestoredb/tests/test_timedelta_parameters.py rename to singlestoredb/tests/test_parameters.py