Skip to content

feat: Add the interval type to Spanner #14254

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public BindingTests(SpannerDatabaseFixture fixture) =>
SpannerDbType.Bytes,
SpannerDbType.Float32,
SpannerDbType.Json,
SpannerDbType.Interval,
SpannerDbType.FromClrType(typeof(Duration)),
SpannerDbType.FromClrType(typeof(Rectangle)),
SpannerDbType.FromClrType(typeof(Person)),
Expand All @@ -75,8 +76,8 @@ public BindingTests(SpannerDatabaseFixture fixture) =>
SpannerDbType.ArrayOf(SpannerDbType.Float32),
SpannerDbType.ArrayOf(SpannerDbType.Json),
SpannerDbType.ArrayOf(SpannerDbType.FromClrType(typeof(Duration))),
SpannerDbType.ArrayOf(SpannerDbType.FromClrType(typeof(Rectangle))),
SpannerDbType.ArrayOf(SpannerDbType.FromClrType(typeof(Person))),
SpannerDbType.ArrayOf(SpannerDbType.FromClrType(typeof(Rectangle))),
SpannerDbType.ArrayOf(SpannerDbType.FromClrType(typeof(Person))),
SpannerDbType.ArrayOf(SpannerDbType.FromClrType(typeof(ValueWrapper))),
SpannerDbType.ArrayOf(SpannerDbType.FromClrType(typeof(Value))),
};
Expand Down Expand Up @@ -299,6 +300,21 @@ public async Task BindJsonEmptyArray() => await TestBindNonNull(
SpannerDbType.ArrayOf(SpannerDbType.Json),
new string[] { });

[Fact]
public async Task BindInterval() => await TestBindNonNull(
SpannerDbType.Interval,
Interval.Parse("P1Y"));

[Fact]
public async Task BindIntervalArray() => await TestBindNonNull(
SpannerDbType.ArrayOf(SpannerDbType.Interval),
new Interval[] {Interval.Parse("P1Y"), null, Interval.Parse("PT1H")});

[Fact]
public async Task BindIntervalEmptyArray() => await TestBindNonNull(
SpannerDbType.ArrayOf(SpannerDbType.Interval),
new Interval[] { });

[SkippableFact] //"b/348711708
public async Task BindProtobufValue() => await TestBindNonNull(
SpannerDbType.FromClrType(typeof(Value)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public async Task GetSchemaTable_WithFlagEnabled_ReturnsSchema(string columnName
{ "ProtobufValueValue", typeof(Value), SpannerDbType.FromClrType(typeof(Value)) },
{ "ProtobufPersonValue", typeof(Value), SpannerDbType.FromClrType(typeof(Person)) },
{ "ProtobufValueWrapperValue", typeof(Value), SpannerDbType.FromClrType(typeof(ValueWrapper)) },

// Array types.
{ "BoolArrayValue", typeof(List<bool>), SpannerDbType.ArrayOf(SpannerDbType.Bool) },
{ "Int64ArrayValue", typeof(List<long>), SpannerDbType.ArrayOf(SpannerDbType.Int64) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public void CreateKeyFromParameterCollection()
{ "", SpannerDbType.PgJsonb, "{\"key1\": \"value1\"}" },
{ "", SpannerDbType.Numeric, SpannerNumeric.Parse("10.1") },
{ "", SpannerDbType.PgNumeric, PgNumeric.Parse("20.1") },
{ "", SpannerDbType.Interval, Interval.Parse("P1Y2M3D") },
{ "", SpannerDbType.PgOid, 2 },
{ "", SpannerDbType.String, "test" },
{ "", SpannerDbType.Timestamp, new DateTime(2021, 9, 10, 9, 37, 10, DateTimeKind.Utc) }
Expand All @@ -57,6 +58,7 @@ public void CreateKeyFromParameterCollection()
Value.ForString("{\"key1\": \"value1\"}"),
Value.ForString("10.1"),
Value.ForString("20.1"),
Value.ForString("P1Y2M3D"),
Value.ForString("2"),
Value.ForString("test"),
Value.ForString("2021-09-10T09:37:10Z")
Expand Down Expand Up @@ -86,7 +88,8 @@ public void CreateKeyFromValues()
SpannerNumeric.Parse("10.1"),
PgNumeric.Parse("20.1"),
"test",
new DateTime(2021, 9, 10, 9, 37, 10, DateTimeKind.Utc)
new DateTime(2021, 9, 10, 9, 37, 10, DateTimeKind.Utc),
Interval.Parse("P1Y")
);

var actual = key.ToProtobuf(SpannerConversionOptions.Default);
Expand All @@ -102,7 +105,8 @@ public void CreateKeyFromValues()
Value.ForString("10.1"),
Value.ForString("20.1"),
Value.ForString("test"),
Value.ForString("2021-09-10T09:37:10Z")
Value.ForString("2021-09-10T09:37:10Z"),
Value.ForString("P1Y")
}
};
Assert.Equal(expected, actual);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1343,6 +1343,7 @@ public async Task CanExecuteReadCommand()
{"date", SpannerDbType.Date, new DateTime(2021, 9, 8, 0, 0, 0, DateTimeKind.Utc)},
{"timestamp", SpannerDbType.Timestamp, new DateTime(2021, 9, 8, 15, 22, 59, DateTimeKind.Utc)},
{"bool", SpannerDbType.Bool, true},
{"interval", SpannerDbType.Interval, Interval.Parse("P1Y2M3D")},
}));
using var reader = await command.ExecuteReaderAsync();
Assert.True(reader.HasRows);
Expand All @@ -1363,6 +1364,7 @@ public async Task CanExecuteReadCommand()
new Value { StringValue = "2021-09-08" },
new Value { StringValue = "2021-09-08T15:22:59Z" },
new Value { BoolValue = true },
new Value { StringValue = "P1Y2M3D" },
} } } })),
Arg.Any<CallSettings>());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,14 @@ public enum TestType
{ "PgNumericField", SpannerDbType.PgNumeric, PgNumeric.NaN },
{ "JsonField", SpannerDbType.Json, "{\"field\": \"value\"}" },
{ "PgJsonbField", SpannerDbType.PgJsonb, "{\"field1\": \"value1\"}" },
{ "PgOidField", SpannerDbType.PgOid, 3L }
{ "PgOidField", SpannerDbType.PgOid, 3L },
{ "IntervalField", SpannerDbType.Interval, Interval.Parse("P1Y2M3D") }
};

// Structs are serialized as lists of their values. The field names aren't present, as they're
// specified in the type.
private static readonly string s_sampleStructSerialized =
"[ \"stringValue\", \"2\", \"NaN\", \"NaN\", true, \"2017-01-31\", \"2017-01-31T03:15:30Z\", \"99999999999999999999999999999.999999999\", \"NaN\", \"{\\\"field\\\": \\\"value\\\"}\", \"{\\\"field1\\\": \\\"value1\\\"}\", \"3\" ]";
"[ \"stringValue\", \"2\", \"NaN\", \"NaN\", true, \"2017-01-31\", \"2017-01-31T03:15:30Z\", \"99999999999999999999999999999.999999999\", \"NaN\", \"{\\\"field\\\": \\\"value\\\"}\", \"{\\\"field1\\\": \\\"value1\\\"}\", \"3\", \"P1Y2M3D\" ]";

private static string Quote(string s) => $"\"{s}\"";

Expand Down Expand Up @@ -133,6 +134,13 @@ private static IEnumerable<PgNumeric> GetPgNumericsForArray()
yield return PgNumeric.NaN;
}

private static IEnumerable<Interval> GetIntervalsForArray()
{
yield return Interval.Parse("P1Y");
yield return Interval.Parse("P1Y2M3DT4H5M6.5S");
yield return Interval.Parse("PT0.9S");
}

private static readonly BigInteger MaxValueForPgNumeric = BigInteger.Pow(10, 147455) - 1;

private static readonly string ExpectedMaxValueForPgNumeric = MaxValueForPgNumeric.ToString();
Expand Down Expand Up @@ -320,6 +328,9 @@ public static IEnumerable<object[]> GetValidValueConversions()
yield return new object[] { "1", SpannerDbType.PgNumeric, Quote("1") };
yield return new object[] { "1.5", SpannerDbType.PgNumeric, Quote("1.5") };
yield return new object[] { DBNull.Value, SpannerDbType.PgNumeric, "null" };
// Interval tests
yield return new object[] { "P0Y", SpannerDbType.Interval, Quote("P0Y") };
yield return new object[] { Interval.Parse("P1Y2M3DT4H5M6S"), SpannerDbType.Interval, Quote("P1Y2M3DT4H5M6S") };

// Note the difference in C# conversions from special floats and doubles.
yield return new object[] { float.NegativeInfinity, SpannerDbType.String, Quote("-Infinity") };
Expand Down Expand Up @@ -404,6 +415,11 @@ public static IEnumerable<object[]> GetValidValueConversions()
new List<PgNumeric>(GetPgNumericsForArray()), SpannerDbType.ArrayOf(SpannerDbType.PgNumeric),
"[ \""+ExpectedMinValueForPgNumeric+"\", \""+ExpectedMaxValueForPgNumeric+"\", \"NaN\" ]"
};
yield return new object[]
{
new List<Interval>(GetIntervalsForArray()), SpannerDbType.ArrayOf(SpannerDbType.Interval),
"[ \"P1Y\", \"P1Y2M3DT4H5M6.5S\", \"PT0.9S\" ]"
};
// JSON can not be converted from Value to Clr, as there is no unique Clr type for JSON.
yield return new object[]
{
Expand Down Expand Up @@ -661,6 +677,11 @@ public static IEnumerable<object[]> GetInvalidValueConversions()
yield return new object[] { double.NegativeInfinity, SpannerDbType.PgNumeric };
yield return new object[] { double.PositiveInfinity, SpannerDbType.PgNumeric };
yield return new object[] { double.NaN, SpannerDbType.PgNumeric };

// Spanner type = Interval tests.
yield return new object[] { "invalid", SpannerDbType.Interval };
yield return new object[] { "1Y2M", SpannerDbType.Interval };
yield return new object[] { "P1H", SpannerDbType.Interval };
}

private static readonly CultureInfo[] s_cultures = new[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ public static IEnumerable<object[]> GetSpannerStringConversions()
yield return new object[] { " TIMESTAMP ", SpannerDbType.Timestamp };
yield return new object[] { " NUMERIC ", SpannerDbType.Numeric };
yield return new object[] { " NUMERIC{PG} ", SpannerDbType.PgNumeric };
yield return new object[] { " INTERVAL ", SpannerDbType.Interval };

yield return new object[] { "STRING(2)", SpannerDbType.String.WithSize(2) };
yield return new object[] { "STRING(100)", SpannerDbType.String.WithSize(100) };
Expand All @@ -123,6 +124,7 @@ public static IEnumerable<object[]> GetSpannerStringConversions()
yield return new object[] { "ARRAY<INT64>", SpannerDbType.ArrayOf(SpannerDbType.Int64) };
yield return new object[] { "ARRAY<OID{PG}>", SpannerDbType.ArrayOf(SpannerDbType.PgOid) };
yield return new object[] { "ARRAY<TIMESTAMP>", SpannerDbType.ArrayOf(SpannerDbType.Timestamp) };
yield return new object[] { "ARRAY<INTERVAL>", SpannerDbType.ArrayOf(SpannerDbType.Interval) };

yield return new object[] { "ARRAY<STRING(5)>", SpannerDbType.ArrayOf(SpannerDbType.String), false };
yield return new object[] { "ARRAY<BYTES(5)>", SpannerDbType.ArrayOf(SpannerDbType.Bytes), false };
Expand Down Expand Up @@ -182,9 +184,10 @@ public static IEnumerable<object[]> GetSpannerStringConversions()
{ "F10", SpannerDbType.Json, null },
{ "F11", SpannerDbType.PgNumeric, null },
{ "F12", SpannerDbType.PgJsonb, null },
{ "F13", SpannerDbType.PgOid, null }
{ "F13", SpannerDbType.PgOid, null },
{ "F14", SpannerDbType.Interval, null}
};
yield return new object[] { "STRUCT<F1:STRING,F2:INT64,F3:BOOL,F4:BYTES,F5:DATE,F6:FLOAT32,F7:FLOAT64,F8:TIMESTAMP,F9:NUMERIC,F10:JSON,F11:NUMERIC{PG},F12:JSONB{PG},F13:OID{PG}>", sampleStruct.GetSpannerDbType() };
yield return new object[] { "STRUCT<F1:STRING,F2:INT64,F3:BOOL,F4:BYTES,F5:DATE,F6:FLOAT32,F7:FLOAT64,F8:TIMESTAMP,F9:NUMERIC,F10:JSON,F11:NUMERIC{PG},F12:JSONB{PG},F13:OID{PG},F14:INTERVAL>", sampleStruct.GetSpannerDbType() };

sampleStruct = new SpannerStruct
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

using Microsoft.VisualBasic;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Numerics;
using System.Threading;
using Xunit;

namespace Google.Cloud.Spanner.V1.Tests
{
public class IntervalTests
{
[Theory]
[InlineData("P-0Y", "P0Y")]
[InlineData("P0Y0M0DT0H0M0S", "P0Y")]
[InlineData("P16M", "P1Y4M")]
[InlineData("P2Y-2M", "P1Y10M")]
[InlineData("P-1Y2M", "P-10M")]
[InlineData("P372D", "P372D")]
[InlineData("P-372D", "P-372D")]
[InlineData("PT7200H", "PT7200H")]
[InlineData("PT1H69M72S", "PT2H10M12S")]
[InlineData("PT1H-5M-2S", "PT54M58S")]
[InlineData("PT0.5S", "PT0.5S")]
[InlineData("PT0.500S", "PT0.5S")]
[InlineData("PT.5S", "PT0.5S")]
[InlineData("P10000Y", "P10000Y")]
[InlineData("P-10000Y", "P-10000Y")]
[InlineData("P120000M", "P10000Y")]
[InlineData("P-120000M", "P-10000Y")]
[InlineData("P3660000D", "P3660000D")]
[InlineData("P-3660000D", "P-3660000D")]
[InlineData("PT316224000000S", "PT87840000H")]
[InlineData("PT-316224000000S", "PT-87840000H")]
[InlineData("PT0.999999999S", "PT0.999999999S")]
[InlineData("PT0.000000009S", "PT0.000000009S")]
public void ParseRoundTrip(string intervalString, string expectedString)
{
Interval interval = Interval.Parse(intervalString);
Assert.Equal(expectedString, interval.ToString());
}

[Theory]
[InlineData(5, "P5M")]
[InlineData(-5, "P-5M")]
[InlineData(13, "P1Y1M")]
[InlineData(-13, "P-1Y-1M")]
[InlineData(24, "P2Y")]
[InlineData(-24, "P-2Y")]
[InlineData(120000, "P10000Y")]
[InlineData(-120000, "P-10000Y")]
public void FromMonths(int totalMonths, string expectedString)
{
Interval interval = Interval.FromMonths(totalMonths);
Assert.Equal(expectedString, interval.ToString());
}

[Theory]
[InlineData(5, "P5D")]
[InlineData(-5, "P-5D")]
[InlineData(30, "P30D")]
[InlineData(-30, "P-30D")]
[InlineData(31, "P31D")]
[InlineData(-31, "P-31D")]
[InlineData(3660000, "P3660000D")]
[InlineData(-3660000, "P-3660000D")]
public void FromDays(int totalDays, string expectedString)
{
Interval interval = Interval.FromDays(totalDays);
Assert.Equal(expectedString, interval.ToString());
}

[Theory]
[InlineData(1, "PT1S")]
[InlineData(-1, "PT-1S")]
[InlineData(60, "PT1M")]
[InlineData(-60, "PT-1M")]
[InlineData(90, "PT1M30S")]
[InlineData(-90, "PT-1M-30S")]
[InlineData(316224000000, "PT87840000H")]
public void FromSeconds(long totalSeconds, string expectedString)
{
Interval interval = Interval.FromSeconds(totalSeconds);
Assert.Equal(expectedString, interval.ToString());
}

[Theory]
[InlineData(1, "PT0.001S")]
[InlineData(-1, "PT-0.001S")]
[InlineData(1000, "PT1S")]
[InlineData(-1000, "PT-1S")]
[InlineData(999, "PT0.999S")]
[InlineData(-999, "PT-0.999S")]
[InlineData(316224000000000, "PT87840000H")]
public void FromMilliseconds(long totalMilliseconds, string expectedString)
{
Interval interval = Interval.FromMilliseconds(totalMilliseconds);
Assert.Equal(expectedString, interval.ToString());
}

[Theory]
[InlineData(1, "PT0.000001S")]
[InlineData(-1, "PT-0.000001S")]
[InlineData(1000000, "PT1S")]
[InlineData(-1000000, "PT-1S")]
[InlineData(999999, "PT0.999999S")]
[InlineData(-999999, "PT-0.999999S")]
[InlineData(316224000000000000, "PT87840000H")]
public void FromMicroseconds(long totalMicroseconds, string expectedString)
{
Interval interval = Interval.FromMicroseconds(totalMicroseconds);
Assert.Equal(expectedString, interval.ToString());
}

[Theory]
[InlineData(1, "PT0.000000001S")]
[InlineData(-1, "PT-0.000000001S")]
[InlineData(1000000000, "PT1S")]
[InlineData(-1000000000, "PT-1S")]
[InlineData(999999999, "PT0.999999999S")]
[InlineData(-999999999, "PT-0.999999999S")]
public void FromNanoseconds(BigInteger totalNanoseconds, string expectedString)
{
Interval interval = Interval.FromNanoseconds(totalNanoseconds);
Assert.Equal(expectedString, interval.ToString());
}

[Theory]
[InlineData("P0.5Y")]
[InlineData("PT0.5.5S")]
[InlineData("P0.5M")]
[InlineData("P0.5D")]
[InlineData("PT0.5H")]
[InlineData("PT0.5M")]
[InlineData("P5S")]
[InlineData("P1Y3S")]
[InlineData("P1YT3M1")]
[InlineData("P1YT3M1.1.4S")]
[InlineData("")]
[InlineData("P")]
[InlineData("PT")]
[InlineData("PTS")]
[InlineData("PY")]
[InlineData("PM")]
[InlineData("PD")]
[InlineData("PTH")]
[InlineData("PTM")]
[InlineData("P1Y2Y")]
[InlineData("P1YT")]
[InlineData("1Y")]
public void InvalidString(string intervalString)
{
Assert.Throws<FormatException>(() => Interval.Parse(intervalString));
}

[Theory]
[InlineData("P10001Y")]
[InlineData("P-10001Y")]
[InlineData("P120001M")]
[InlineData("P-120001M")]
[InlineData("P3660001D")]
[InlineData("P-3660001D")]
[InlineData("PT316224000001S")]
[InlineData("PT-316224000001S")]
[InlineData("PT5270400001M")]
[InlineData("PT-5270400001M")]
[InlineData("PT87840001H")]
[InlineData("PT-87840001H")]
public void OutOfRangeValues(string intervalString)
{
Assert.Throws<ArgumentOutOfRangeException>(() => Interval.Parse(intervalString));
}
}
}
Loading