Skip to content

Commit 3bc2a64

Browse files
authored
.Net: [MEVD] [SQL Server] Support DateTimeOffset and string arrays (#13269)
Closes #13268 /cc @yorek @uc-msft (this uses JSON and JSON_CONTAINS() to map `string[]` to the database and perform Contains over them.
1 parent 7fbe037 commit 3bc2a64

File tree

7 files changed

+77
-38
lines changed

7 files changed

+77
-38
lines changed

dotnet/src/VectorData/SqlServer/SqlServerCommandBuilder.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Collections.Generic;
55
using System.Linq.Expressions;
66
using System.Text;
7+
using System.Text.Json;
78
using Microsoft.Data.SqlClient;
89
using Microsoft.Data.SqlTypes;
910
using Microsoft.Extensions.AI;
@@ -57,7 +58,15 @@ internal static SqlCommand CreateTable(
5758
{
5859
if (dataProperty.IsIndexed)
5960
{
60-
sb.AppendFormat("CREATE INDEX ");
61+
var sqlType = Map(dataProperty);
62+
if (sqlType == "JSON")
63+
{
64+
sb.AppendFormat("CREATE JSON INDEX ");
65+
}
66+
else
67+
{
68+
sb.AppendFormat("CREATE INDEX ");
69+
}
6170
sb.AppendIndexName(tableName, dataProperty.StorageName);
6271
sb.AppendFormat(" ON ").AppendTableName(schema, tableName);
6372
sb.AppendFormat("([{0}]);", dataProperty.StorageName);
@@ -621,6 +630,13 @@ private static void AddParameter(this SqlCommand command, PropertyModel? propert
621630
command.Parameters.AddWithValue(name, new SqlVector<float>(vectorArray));
622631
break;
623632

633+
case string[] strings:
634+
command.Parameters.AddWithValue(name, JsonSerializer.Serialize(strings, SqlServerJsonSerializerContext.Default.StringArray));
635+
break;
636+
case List<string> strings:
637+
command.Parameters.AddWithValue(name, JsonSerializer.Serialize(strings, SqlServerJsonSerializerContext.Default.ListString));
638+
break;
639+
624640
default:
625641
command.Parameters.AddWithValue(name, value);
626642
break;
@@ -641,6 +657,7 @@ private static string Map(PropertyModel property)
641657
Type t when t == typeof(byte[]) => "VARBINARY(MAX)",
642658
Type t when t == typeof(bool) => "BIT",
643659
Type t when t == typeof(DateTime) => "DATETIME2",
660+
Type t when t == typeof(DateTimeOffset) => "DATETIMEOFFSET",
644661
#if NET
645662
Type t when t == typeof(DateOnly) => "DATE",
646663
Type t when t == typeof(TimeOnly) => "TIME",
@@ -649,6 +666,8 @@ private static string Map(PropertyModel property)
649666
Type t when t == typeof(double) => "FLOAT",
650667
Type t when t == typeof(float) => "REAL",
651668

669+
Type t when t == typeof(string[]) || t == typeof(List<string>) => "JSON",
670+
652671
_ => throw new NotSupportedException($"Type {property.Type} is not supported.")
653672
};
654673

dotnet/src/VectorData/SqlServer/SqlServerFilterTranslator.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ protected override void TranslateConstant(object? value)
5252
: string.Format(CultureInfo.InvariantCulture, @"'{0:HH\:mm\:ss\.FFFFFFF}'", value));
5353
return;
5454
#endif
55+
5556
default:
5657
base.TranslateConstant(value);
5758
break;
@@ -72,7 +73,18 @@ protected override void GenerateColumn(PropertyModel property, bool isSearchCond
7273
}
7374

7475
protected override void TranslateContainsOverArrayColumn(Expression source, Expression item)
75-
=> throw new NotSupportedException("Unsupported Contains expression");
76+
{
77+
if (item.Type != typeof(string))
78+
{
79+
throw new NotSupportedException("Unsupported Contains expression");
80+
}
81+
82+
this._sql.Append("JSON_CONTAINS(");
83+
this.Translate(source);
84+
this._sql.Append(", ");
85+
this.Translate(item);
86+
this._sql.Append(") = 1");
87+
}
7688

7789
protected override void TranslateContainsOverParameterizedArray(Expression source, Expression item, object? value)
7890
{
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Collections.Generic;
4+
using System.Text.Json.Serialization;
5+
6+
namespace Microsoft.SemanticKernel.Connectors.SqlServer;
7+
8+
// For mapping string[] properties to SQL Server JSON columns
9+
[JsonSerializable(typeof(string[]))]
10+
[JsonSerializable(typeof(List<string>))]
11+
internal partial class SqlServerJsonSerializerContext : JsonSerializerContext;

dotnet/src/VectorData/SqlServer/SqlServerMapper.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
// Copyright (c) Microsoft. All rights reserved.
22

33
using System;
4+
using System.Collections.Generic;
45
using System.Diagnostics;
56
using System.Runtime.InteropServices;
7+
using System.Text.Json;
68
using Microsoft.Data.SqlClient;
79
using Microsoft.Data.SqlTypes;
810
using Microsoft.Extensions.AI;
@@ -112,7 +114,9 @@ static void PopulateValue(SqlDataReader reader, PropertyModel property, object r
112114
case var t when t == typeof(DateTime):
113115
property.SetValue(record, reader.GetDateTime(ordinal)); // DATETIME2
114116
break;
115-
117+
case var t when t == typeof(DateTimeOffset):
118+
property.SetValue(record, reader.GetDateTimeOffset(ordinal)); // DATETIMEOFFSET
119+
break;
116120
#if NET
117121
case var t when t == typeof(DateOnly):
118122
property.SetValue(record, reader.GetFieldValue<DateOnly>(ordinal)); // DATE
@@ -122,6 +126,18 @@ static void PopulateValue(SqlDataReader reader, PropertyModel property, object r
122126
break;
123127
#endif
124128

129+
// We map string[] and List<string> properties to SQL Server JSON columns, so deserialize from JSON here.
130+
case var t when t == typeof(string[]):
131+
property.SetValue(record, JsonSerializer.Deserialize<string[]>(
132+
reader.GetString(ordinal),
133+
SqlServerJsonSerializerContext.Default.StringArray));
134+
break;
135+
case var t when t == typeof(List<string>):
136+
property.SetValue(record, JsonSerializer.Deserialize<List<string>>(
137+
reader.GetString(ordinal),
138+
SqlServerJsonSerializerContext.Default.ListString));
139+
break;
140+
125141
default:
126142
throw new NotSupportedException($"Unsupported type '{property.Type.Name}' for property '{property.ModelName}'.");
127143
}

dotnet/src/VectorData/SqlServer/SqlServerModelBuilder.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft. All rights reserved.
22

33
using System;
4+
using System.Collections.Generic;
45
using System.Diagnostics.CodeAnalysis;
56
using Microsoft.Data.SqlTypes;
67
using Microsoft.Extensions.AI;
@@ -33,7 +34,7 @@ protected override bool IsKeyPropertyTypeValid(Type type, [NotNullWhen(false)] o
3334

3435
protected override bool IsDataPropertyTypeValid(Type type, [NotNullWhen(false)] out string? supportedTypes)
3536
{
36-
supportedTypes = "string, short, int, long, double, float, decimal, bool, DateTime, DateTimeOffset, DateOnly, TimeOnly, Guid, byte[]";
37+
supportedTypes = "string, short, int, long, double, float, decimal, bool, DateTime, DateTimeOffset, DateOnly, TimeOnly, Guid, byte[], string[], List<string>";
3738

3839
if (Nullable.GetUnderlyingType(type) is Type underlyingType)
3940
{
@@ -49,6 +50,7 @@ protected override bool IsDataPropertyTypeValid(Type type, [NotNullWhen(false)]
4950
|| type == typeof(byte[]) // VARBINARY
5051
|| type == typeof(bool) // BIT
5152
|| type == typeof(DateTime) // DATETIME2
53+
|| type == typeof(DateTimeOffset) // DATETIMEOFFSET
5254
#if NET
5355
|| type == typeof(DateOnly) // DATE
5456
// We don't support mapping TimeSpan to TIME on purpose
@@ -57,7 +59,11 @@ protected override bool IsDataPropertyTypeValid(Type type, [NotNullWhen(false)]
5759
#endif
5860
|| type == typeof(decimal) // DECIMAL
5961
|| type == typeof(double) // FLOAT
60-
|| type == typeof(float); // REAL
62+
|| type == typeof(float) // REAL
63+
64+
// We map string[] to the SQL Server 2025 JSON data type (anyone using vector search is already using 2025)
65+
|| type == typeof(string[]) // JSON
66+
|| type == typeof(List<string>); // JSON
6167
}
6268

6369
protected override bool IsVectorPropertyTypeValid(Type type, [NotNullWhen(false)] out string? supportedTypes)

dotnet/test/VectorData/SqlServer.ConformanceTests/Filter/SqlServerBasicFilterTests.cs

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// Copyright (c) Microsoft. All rights reserved.
22

3-
using Microsoft.Extensions.VectorData;
43
using SqlServer.ConformanceTests.Support;
54
using VectorData.ConformanceTests.Filter;
65
using VectorData.ConformanceTests.Support;
@@ -36,25 +35,6 @@ await this.TestFilterAsync(
3635
r => r["String"] != null && r["String"] != "foo");
3736
}
3837

39-
public override Task Contains_over_field_string_array()
40-
=> Assert.ThrowsAsync<InvalidOperationException>(() => base.Contains_over_field_string_array());
41-
42-
public override Task Contains_over_field_string_List()
43-
=> Assert.ThrowsAsync<InvalidOperationException>(() => base.Contains_over_field_string_List());
44-
45-
public override Task Contains_with_Enumerable_Contains()
46-
=> Assert.ThrowsAsync<InvalidOperationException>(() => base.Contains_with_Enumerable_Contains());
47-
48-
#if !NETFRAMEWORK
49-
public override Task Contains_with_MemoryExtensions_Contains()
50-
=> Assert.ThrowsAsync<InvalidOperationException>(() => base.Contains_with_MemoryExtensions_Contains());
51-
#endif
52-
53-
#if NET10_0_OR_GREATER
54-
public override Task Contains_with_MemoryExtensions_Contains_with_null_comparer()
55-
=> Assert.ThrowsAsync<InvalidOperationException>(() => base.Contains_with_MemoryExtensions_Contains_with_null_comparer());
56-
#endif
57-
5838
[Fact(Skip = "Not supported")]
5939
[Obsolete("Legacy filters are not supported")]
6040
public override Task Legacy_And() => throw new NotSupportedException();
@@ -78,12 +58,5 @@ public override Task Contains_with_MemoryExtensions_Contains_with_null_comparer(
7858
public override TestStore TestStore => SqlServerTestStore.Instance;
7959

8060
public override string CollectionName => s_uniqueName;
81-
82-
// Override to remove the string collection properties, which aren't (currently) supported on SqlServer
83-
public override VectorStoreCollectionDefinition CreateRecordDefinition()
84-
=> new()
85-
{
86-
Properties = base.CreateRecordDefinition().Properties.Where(p => p.Type != typeof(string[]) && p.Type != typeof(List<string>)).ToList()
87-
};
8861
}
8962
}

dotnet/test/VectorData/SqlServer.ConformanceTests/SqlServerDataTypeTests.cs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@ namespace SqlServer.ConformanceTests;
1010
public class SqlServerDataTypeTests(SqlServerDataTypeTests.Fixture fixture)
1111
: DataTypeTests<Guid, DataTypeTests<Guid>.DefaultRecord>(fixture), IClassFixture<SqlServerDataTypeTests.Fixture>
1212
{
13+
public override Task String_array()
14+
=> this.Test<string[]>(
15+
"StringArray",
16+
["foo", "bar"],
17+
["foo", "baz"],
18+
// SQL Server doesn't support comparing JSON
19+
isFilterable: false);
20+
1321
public new class Fixture : DataTypeTests<Guid, DataTypeTests<Guid>.DefaultRecord>.Fixture
1422
{
1523
public override TestStore TestStore => SqlServerTestStore.Instance;
16-
17-
public override Type[] UnsupportedDefaultTypes { get; } =
18-
[
19-
typeof(DateTimeOffset),
20-
typeof(string[])
21-
];
2224
}
2325
}

0 commit comments

Comments
 (0)