From f9de56d8618371c262e44100cddf1f396431acba Mon Sep 17 00:00:00 2001 From: "Dailey, Alaric" Date: Tue, 6 Oct 2026 09:21:34 -0500 Subject: [PATCH] Read and write negative intervals DuckDB's interval micros is signed (duckdb_interval.micros is int64_t) and days can be negative, but DuckDBInterval converted both as unsigned: reading any negative interval as a TimeSpan threw ArgumentOutOfRangeException ("INTERVAL '-90 minutes'", even "-2 days"), a negative month was silently dropped instead of refused, and binding or appending a negative TimeSpan threw OverflowException. Micros stays a ulong for compatibility and is read and written as its two's complement. A non-zero month count, either sign, is refused, and the range check is done in Int128. Co-Authored-By: Claude Opus 5.5 --- DuckDB.NET.Bindings/DuckDBInterval.cs | 43 +++-------- DuckDB.NET.Test/DuckDBIntervalTests.cs | 44 ++++++++++++ DuckDB.NET.Test/Parameters/IntervalTests.cs | 79 +++++++++++++++++++++ 3 files changed, 132 insertions(+), 34 deletions(-) create mode 100644 DuckDB.NET.Test/Parameters/IntervalTests.cs diff --git a/DuckDB.NET.Bindings/DuckDBInterval.cs b/DuckDB.NET.Bindings/DuckDBInterval.cs index ec832c2d..ebb48c94 100644 --- a/DuckDB.NET.Bindings/DuckDBInterval.cs +++ b/DuckDB.NET.Bindings/DuckDBInterval.cs @@ -5,7 +5,6 @@ namespace DuckDB.NET.Native; [StructLayout(LayoutKind.Sequential)] public readonly struct DuckDBInterval(int months, int days, ulong micros) { - private const ulong MillisecondsByDay = (ulong)(24 * 60 * 60 * 1e6); public int Months { get; } = months; public int Days { get; } = days; @@ -24,48 +23,24 @@ public bool TryConvert([NotNullWhen(true)] out TimeSpan? timeSpan) return exception is null; } + // DuckDB's micros is signed (duckdb_interval.micros is int64_t), and Days can be negative too. Micros + // exposes the value as ulong, so it is read and written as its two's complement. private static (TimeSpan?, Exception?) ToTimeSpan(DuckDBInterval interval) { - if (interval.Months > 0) + if (interval.Months != 0) { - return (null, new ArgumentOutOfRangeException(nameof(interval), $"Cannot convert a value of type {nameof(DuckDBInterval)} to type {nameof(TimeSpan)} when the attribute 'Months' is greater or equal to 1")); + return (null, new ArgumentOutOfRangeException(nameof(interval), $"Cannot convert a value of type {nameof(DuckDBInterval)} to type {nameof(TimeSpan)} when the attribute 'Months' is not 0")); } - var days = 0; - var micros = interval.Micros; - - if (interval.Micros >= MillisecondsByDay) - { - days = Convert.ToInt32(Math.Floor((double)(interval.Micros / MillisecondsByDay))); - if (days > int.MaxValue - interval.Days) - { - return (null, new ArgumentOutOfRangeException(nameof(interval), $"Cannot convert a value of type {nameof(DuckDBInterval)} to type {nameof(TimeSpan)} when the total days value is larger than {int.MaxValue}")); - } - - if (days > 0) - { - micros = interval.Micros - ((ulong)days * MillisecondsByDay); - } - days = interval.Days + days; - } - else - { - days = interval.Days; - } - - if (micros * 10 > long.MaxValue) - { - return (null, new ArgumentOutOfRangeException(nameof(interval), $"Cannot convert a value of type {nameof(DuckDBInterval)} to type {nameof(TimeSpan)} when the value of microseconds is larger than {long.MaxValue / 10}")); - } - - if ((ulong)days * MillisecondsByDay * 100 + micros * 10 > long.MaxValue) + var ticks = (Int128)interval.Days * TimeSpan.TicksPerDay + (Int128)unchecked((long)interval.Micros) * TimeSpan.TicksPerMicrosecond; + if (ticks > long.MaxValue || ticks < long.MinValue) { - return (null, new ArgumentOutOfRangeException(nameof(interval), $"Cannot convert a value of type {nameof(DuckDBInterval)} to type {nameof(TimeSpan)} when the value of total microseconds is larger than {long.MaxValue}")); + return (null, new ArgumentOutOfRangeException(nameof(interval), $"Cannot convert a value of type {nameof(DuckDBInterval)} to type {nameof(TimeSpan)} when the total value is outside the range of {nameof(TimeSpan)}")); } - return (new TimeSpan(days, 0, 0, 0) + new TimeSpan((long)micros * 10), null); + return (new TimeSpan((long)ticks), null); } private static DuckDBInterval FromTimeSpan(TimeSpan timeSpan) - => new(0, timeSpan.Days, Convert.ToUInt64(timeSpan.Ticks / 10 - new TimeSpan(timeSpan.Days, 0, 0, 0).Ticks / 10)); + => new(0, timeSpan.Days, unchecked((ulong)(timeSpan.Ticks % TimeSpan.TicksPerDay / TimeSpan.TicksPerMicrosecond))); } \ No newline at end of file diff --git a/DuckDB.NET.Test/DuckDBIntervalTests.cs b/DuckDB.NET.Test/DuckDBIntervalTests.cs index cd0cbc0e..6f80ca4d 100644 --- a/DuckDB.NET.Test/DuckDBIntervalTests.cs +++ b/DuckDB.NET.Test/DuckDBIntervalTests.cs @@ -47,6 +47,50 @@ public void ToTimeSpan_TooBigDays_Exception() Assert.Throws(() => (TimeSpan)interval); } + // DuckDB stores an interval's micros as a signed 64-bit value (duckdb_interval.micros is int64_t). + [Theory] + [InlineData(0, -5_400_000_000L, "-01:30:00")] + [InlineData(0, -17L, "-00:00:00.000017")] + [InlineData(-1, -3_600_000_000L, "-1.01:00:00")] + [InlineData(1, -3_600_000_000L, "23:00:00")] + [InlineData(-2, 0L, "-2.00:00:00")] + [InlineData(-1, 25 * 3_600_000_000L, "01:00:00")] + public void ToTimeSpan_NegativeValue_ExpectedResult(int days, long micros, string ts) + { + var interval = new DuckDBInterval(0, days, unchecked((ulong)micros)); + Assert.True(interval.TryConvert(out var timeSpan)); + Assert.Equal(TimeSpan.Parse(ts), timeSpan); + Assert.Equal(TimeSpan.Parse(ts), (TimeSpan)interval); + } + + [Fact] + public void ToTimeSpan_NegativeMonthInterval_Exception() + { + var interval = new DuckDBInterval(-1, 0, 0); + Assert.False(interval.TryConvert(out var ts)); + Assert.Throws(() => (TimeSpan)interval); + } + + [Fact] + public void ToTimeSpan_TooSmall_Exception() + { + var interval = new DuckDBInterval(0, int.MinValue, unchecked((ulong)long.MinValue)); + Assert.False(interval.TryConvert(out var ts)); + Assert.Throws(() => (TimeSpan)interval); + } + + [Theory] + [InlineData("-01:30:00", 0, -5_400_000_000L)] + [InlineData("-1.01:00:00", -1, -3_600_000_000L)] + [InlineData("-00:00:00.0000017", 0, -1L)] + public void ToDuckDBInterval_NegativeValue_ExpectedResult(string ts, int days, long micros) + { + DuckDBInterval interval = TimeSpan.Parse(ts); + Assert.Equal(0, interval.Months); + Assert.Equal(days, interval.Days); + Assert.Equal(micros, unchecked((long)interval.Micros)); + } + [Fact] public void ToDuckDBInterval_ValidValue_ExpectedResult() { diff --git a/DuckDB.NET.Test/Parameters/IntervalTests.cs b/DuckDB.NET.Test/Parameters/IntervalTests.cs new file mode 100644 index 00000000..00742a85 --- /dev/null +++ b/DuckDB.NET.Test/Parameters/IntervalTests.cs @@ -0,0 +1,79 @@ +namespace DuckDB.NET.Test.Parameters; + +// DuckDB intervals can be negative: duckdb_interval.micros is a signed 64-bit value. +public class IntervalTests(DuckDBDatabaseFixture db) : DuckDBTestBase(db) +{ + [Theory] + [InlineData("-90 minutes", -90 * 60)] + [InlineData("-1 day", -86_400)] + [InlineData("-1 day 2 hours", -79_200)] + [InlineData("1 day -2 hours", 79_200)] + public void QueryNegativeInterval(string interval, int seconds) + { + Command.CommandText = $"SELECT INTERVAL '{interval}';"; + + using var reader = Command.ExecuteReader(); + reader.Read(); + + reader.GetValue(0).Should().Be(TimeSpan.FromSeconds(seconds)); + reader.GetFieldValue(0).Should().Be(TimeSpan.FromSeconds(seconds)); + } + + [Theory] + [InlineData("-1 month")] + [InlineData("1 month")] + public void QueryMonthInterval_AsTimeSpan_Throws(string interval) + { + Command.CommandText = $"SELECT INTERVAL '{interval}';"; + + using var reader = Command.ExecuteReader(); + reader.Read(); + + reader.Invoking(r => r.GetFieldValue(0)).Should().Throw(); + reader.GetFieldValue(0).Months.Should().Be(interval.StartsWith('-') ? -1 : 1); + } + + [Theory] + [InlineData(-5_400_000_000L)] + [InlineData(-1L)] + [InlineData(-90_061_000_001L)] + public void InsertNegativeTimeSpanParameter(long micros) + { + var expected = TimeSpan.FromTicks(micros * 10); + + Command.CommandText = "CREATE OR REPLACE TABLE NegativeIntervalParameterTable (v INTERVAL);"; + Command.ExecuteNonQuery(); + + Command.CommandText = "INSERT INTO NegativeIntervalParameterTable (v) VALUES (?);"; + Command.Parameters.Add(new DuckDBParameter(expected)); + Command.ExecuteNonQuery(); + Command.Parameters.Clear(); + + Command.CommandText = $"SELECT v, v = CAST('{micros} microseconds' AS INTERVAL) FROM NegativeIntervalParameterTable;"; + using var reader = Command.ExecuteReader(); + reader.Read(); + + reader.GetFieldValue(0).Should().Be(expected); + reader.GetBoolean(1).Should().BeTrue(); + } + + [Fact] + public void AppendNegativeTimeSpan() + { + Command.CommandText = "CREATE TABLE NegativeIntervalTable (v INTERVAL);"; + Command.ExecuteNonQuery(); + var expected = TimeSpan.FromMinutes(-90).Add(TimeSpan.FromTicks(-10)); + + using (var appender = Connection.CreateAppender("NegativeIntervalTable")) + { + appender.CreateRow().AppendValue((TimeSpan?)expected).EndRow(); + } + + Command.CommandText = "SELECT v, v = INTERVAL '-5400000001 microseconds' FROM NegativeIntervalTable;"; + using var reader = Command.ExecuteReader(); + reader.Read(); + + reader.GetFieldValue(0).Should().Be(expected); + reader.GetBoolean(1).Should().BeTrue(); + } +}