Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 9 additions & 34 deletions DuckDB.NET.Bindings/DuckDBInterval.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)));
}
44 changes: 44 additions & 0 deletions DuckDB.NET.Test/DuckDBIntervalTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,50 @@ public void ToTimeSpan_TooBigDays_Exception()
Assert.Throws<ArgumentOutOfRangeException>(() => (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<ArgumentOutOfRangeException>(() => (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<ArgumentOutOfRangeException>(() => (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()
{
Expand Down
79 changes: 79 additions & 0 deletions DuckDB.NET.Test/Parameters/IntervalTests.cs
Original file line number Diff line number Diff line change
@@ -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<TimeSpan>(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<TimeSpan>(0)).Should().Throw<ArgumentOutOfRangeException>();
reader.GetFieldValue<DuckDBInterval>(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<TimeSpan>(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<TimeSpan>(0).Should().Be(expected);
reader.GetBoolean(1).Should().BeTrue();
}
}
Loading