diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml
index 75d6272..ea25f76 100644
--- a/.github/workflows/pr-validation.yml
+++ b/.github/workflows/pr-validation.yml
@@ -35,8 +35,13 @@ jobs:
with:
dotnet-version: ${{ matrix.dotnetVersion }}
dotnet-quality: ga
- - run: dotnet build DacFx.sln
- - run: dotnet test DacFx.sln --no-build -f ${{ matrix.targetFramework }}
+ - run: dotnet build DacFx.slnx
+
+ # The tests for Microsoft.SqlServer.VectorData currently require an Azure SQL instance, since on-premise
+ # SQL Server 2025 doesn't have the latest vector support. Once it does, we can turn on the tests here
+ # (they use testcontainers).
+ # - run: dotnet test DacFx.slnx --no-build -f ${{ matrix.targetFramework }}
+ - run: dotnet test test/Microsoft.Build.Sql.Tests --no-build -f ${{ matrix.targetFramework }}
# Test SDK builds with full framework MSBuild on Windows, with SDK itself and against SSDT installation.
msbuildTest:
@@ -57,5 +62,10 @@ jobs:
with:
dotnet-version: ${{ env.LATEST_DOTNET_VERSION }}
dotnet-quality: preview
- - run: dotnet build DacFx.sln
- - run: dotnet test DacFx.sln --no-build -f net472
\ No newline at end of file
+ - run: dotnet build DacFx.slnx
+
+ # The tests for Microsoft.SqlServer.VectorData currently require an Azure SQL instance, since on-premise
+ # SQL Server 2025 doesn't have the latest vector support. Once it does, we can turn on the tests here
+ # (they use testcontainers).
+ # - run: dotnet test DacFx.slnx --no-build -f net472
+ - run: dotnet test test/Microsoft.Build.Sql.Tests --no-build -f net472
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index af16f14..c549502 100644
--- a/.gitignore
+++ b/.gitignore
@@ -353,4 +353,6 @@ MigrationBackup/
src/Microsoft.Build.Sql/[Tt]ools/
## Ignore packages generated for testing
-test/Microsoft.Build.Sql.Tests/pkg/
\ No newline at end of file
+test/Microsoft.Build.Sql.Tests/pkg/
+
+*.lscache
diff --git a/DacFx.sln b/DacFx.sln
deleted file mode 100644
index 90dde50..0000000
--- a/DacFx.sln
+++ /dev/null
@@ -1,37 +0,0 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.3.32929.385
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Build.Sql", "src\Microsoft.Build.Sql\Microsoft.Build.Sql.csproj", "{7C194D72-97FE-49BB-8D03-B3F7C9A91835}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Build.Sql.Tests", "test\Microsoft.Build.Sql.Tests\Microsoft.Build.Sql.Tests.csproj", "{4F50196D-E946-4A58-992E-C1AE25332CE2}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Build.Sql.Templates", "src\Microsoft.Build.Sql.Templates\Microsoft.Build.Sql.Templates.csproj", "{1AAE57EF-E614-4C58-8287-C830F098A7DA}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {7C194D72-97FE-49BB-8D03-B3F7C9A91835}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {7C194D72-97FE-49BB-8D03-B3F7C9A91835}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {7C194D72-97FE-49BB-8D03-B3F7C9A91835}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {7C194D72-97FE-49BB-8D03-B3F7C9A91835}.Release|Any CPU.Build.0 = Release|Any CPU
- {4F50196D-E946-4A58-992E-C1AE25332CE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {4F50196D-E946-4A58-992E-C1AE25332CE2}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {4F50196D-E946-4A58-992E-C1AE25332CE2}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {4F50196D-E946-4A58-992E-C1AE25332CE2}.Release|Any CPU.Build.0 = Release|Any CPU
- {1AAE57EF-E614-4C58-8287-C830F098A7DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {1AAE57EF-E614-4C58-8287-C830F098A7DA}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {1AAE57EF-E614-4C58-8287-C830F098A7DA}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {1AAE57EF-E614-4C58-8287-C830F098A7DA}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ExtensibilityGlobals) = postSolution
- SolutionGuid = {D45B5BBF-A8CB-4D39-A2EA-0FB386311429}
- EndGlobalSection
-EndGlobal
diff --git a/DacFx.slnx b/DacFx.slnx
new file mode 100644
index 0000000..c9d3afc
--- /dev/null
+++ b/DacFx.slnx
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3f7f61d..2cc9e77 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -13,6 +13,8 @@
+
+
@@ -23,11 +25,19 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/LegacySupport/CallerArgumentExpressionAttribute.cs b/src/LegacySupport/CallerArgumentExpressionAttribute.cs
new file mode 100644
index 0000000..364f9ef
--- /dev/null
+++ b/src/LegacySupport/CallerArgumentExpressionAttribute.cs
@@ -0,0 +1,32 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable IDE0079
+#pragma warning disable SA1101
+#pragma warning disable SA1512
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace System.Runtime.CompilerServices;
+
+///
+/// Tags parameter that should be filled with specific caller name.
+///
+[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
+[ExcludeFromCodeCoverage]
+internal sealed class CallerArgumentExpressionAttribute : Attribute
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Function parameter to take the name from.
+ public CallerArgumentExpressionAttribute(string parameterName)
+ {
+ ParameterName = parameterName;
+ }
+
+ ///
+ /// Gets name of the function parameter that name should be taken from.
+ ///
+ public string ParameterName { get; }
+}
diff --git a/src/LegacySupport/Index.cs b/src/LegacySupport/Index.cs
new file mode 100644
index 0000000..fa276aa
--- /dev/null
+++ b/src/LegacySupport/Index.cs
@@ -0,0 +1,160 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+
+#pragma warning disable CS0436 // Type conflicts with imported type
+#pragma warning disable S3427 // Method overloads with default parameter values should not overlap
+#pragma warning disable SA1642 // Constructor summary documentation should begin with standard text
+#pragma warning disable IDE0011 // Add braces
+#pragma warning disable SA1623 // Property summary documentation should match accessors
+#pragma warning disable IDE0023 // Use block body for conversion operator
+#pragma warning disable S3928 // Parameter names used into ArgumentException constructors should match an existing one
+#pragma warning disable LA0001 // Use the 'Microsoft.Shared.Diagnostics.Throws' class instead of explicitly throwing exception for improved performance
+#pragma warning disable CA1305 // Specify IFormatProvider
+
+namespace System
+{
+ internal readonly struct Index : IEquatable
+ {
+ private readonly int _value;
+
+ /// Construct an Index using a value and indicating if the index is from the start or from the end.
+ /// The index value. it has to be zero or positive number.
+ /// Indicating if the index is from the start or from the end.
+ ///
+ /// If the Index constructed from the end, index value 1 means pointing at the last element and index value 0 means pointing at beyond last element.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Index(int value, bool fromEnd = false)
+ {
+ if (value < 0)
+ {
+ ThrowValueArgumentOutOfRange_NeedNonNegNumException();
+ }
+
+ if (fromEnd)
+ _value = ~value;
+ else
+ _value = value;
+ }
+
+ // The following private constructors mainly created for perf reason to avoid the checks
+ private Index(int value)
+ {
+ _value = value;
+ }
+
+ /// Create an Index pointing at first element.
+ public static Index Start => new Index(0);
+
+ /// Create an Index pointing at beyond last element.
+ public static Index End => new Index(~0);
+
+ /// Create an Index from the start at the position indicated by the value.
+ /// The index value from the start.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Index FromStart(int value)
+ {
+ if (value < 0)
+ {
+ ThrowValueArgumentOutOfRange_NeedNonNegNumException();
+ }
+
+ return new Index(value);
+ }
+
+ /// Create an Index from the end at the position indicated by the value.
+ /// The index value from the end.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Index FromEnd(int value)
+ {
+ if (value < 0)
+ {
+ ThrowValueArgumentOutOfRange_NeedNonNegNumException();
+ }
+
+ return new Index(~value);
+ }
+
+ /// Returns the index value.
+ public int Value
+ {
+ get
+ {
+ if (_value < 0)
+ return ~_value;
+ else
+ return _value;
+ }
+ }
+
+ /// Indicates whether the index is from the start or the end.
+ public bool IsFromEnd => _value < 0;
+
+ /// Calculate the offset from the start using the giving collection length.
+ /// The length of the collection that the Index will be used with. length has to be a positive value.
+ ///
+ /// For performance reason, we don't validate the input length parameter and the returned offset value against negative values.
+ /// we don't validate either the returned offset is greater than the input length.
+ /// It is expected Index will be used with collections which always have non negative length/count. If the returned offset is negative and
+ /// then used to index a collection will get out of range exception which will be same affect as the validation.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public int GetOffset(int length)
+ {
+ int offset = _value;
+ if (IsFromEnd)
+ {
+ // offset = length - (~value)
+ // offset = length + (~(~value) + 1)
+ // offset = length + value + 1
+
+ offset += length + 1;
+ }
+
+ return offset;
+ }
+
+ /// Indicates whether the current Index object is equal to another object of the same type.
+ /// An object to compare with this object.
+ public override bool Equals([NotNullWhen(true)] object? value) => value is Index && _value == ((Index)value)._value;
+
+ /// Indicates whether the current Index object is equal to another Index object.
+ /// An object to compare with this object.
+ public bool Equals(Index other) => _value == other._value;
+
+ /// Returns the hash code for this instance.
+ public override int GetHashCode() => _value;
+
+ /// Converts integer number to an Index.
+ public static implicit operator Index(int value) => FromStart(value);
+
+ /// Converts the value of the current Index object to its equivalent string representation.
+ public override string ToString()
+ {
+ if (IsFromEnd)
+ return ToStringFromEnd();
+
+ return ((uint)Value).ToString();
+ }
+
+ private static void ThrowValueArgumentOutOfRange_NeedNonNegNumException()
+ {
+ throw new ArgumentOutOfRangeException("value", "value must be non-negative");
+ }
+
+ private string ToStringFromEnd()
+ {
+#if (!NETSTANDARD2_0 && !NETFRAMEWORK)
+ Span span = stackalloc char[11]; // 1 for ^ and 10 for longest possible uint value
+ bool formatted = ((uint)Value).TryFormat(span.Slice(1), out int charsWritten);
+ span[0] = '^';
+ return new string(span.Slice(0, charsWritten + 1));
+#else
+ return '^' + Value.ToString();
+#endif
+ }
+ }
+}
diff --git a/src/LegacySupport/IsExternalInit.cs b/src/LegacySupport/IsExternalInit.cs
new file mode 100644
index 0000000..4e1b8ba
--- /dev/null
+++ b/src/LegacySupport/IsExternalInit.cs
@@ -0,0 +1,13 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable IDE0079
+#pragma warning disable S3903
+
+/* This enables support for C# 9/10 records on older frameworks */
+
+namespace System.Runtime.CompilerServices;
+
+internal static class IsExternalInit
+{
+}
diff --git a/src/LegacySupport/NullableAttributes.cs b/src/LegacySupport/NullableAttributes.cs
new file mode 100644
index 0000000..5fe5f9e
--- /dev/null
+++ b/src/LegacySupport/NullableAttributes.cs
@@ -0,0 +1,174 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable IDE0079
+#pragma warning disable SA1101
+#pragma warning disable SA1116
+#pragma warning disable SA1117
+#pragma warning disable SA1402
+#pragma warning disable SA1512
+#pragma warning disable SA1623
+#pragma warning disable SA1642
+#pragma warning disable SA1649
+#pragma warning disable S3903
+#pragma warning disable IDE0021 // Use block body for constructors
+#pragma warning disable CA1019
+
+namespace System.Diagnostics.CodeAnalysis;
+
+#if !NETCOREAPP3_1_OR_GREATER
+/// Specifies that null is allowed as an input even if the corresponding type disallows it.
+[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)]
+[ExcludeFromCodeCoverage]
+internal sealed class AllowNullAttribute : Attribute
+{
+}
+
+/// Specifies that null is disallowed as an input even if the corresponding type allows it.
+[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property, Inherited = false)]
+[ExcludeFromCodeCoverage]
+internal sealed class DisallowNullAttribute : Attribute
+{
+}
+
+/// Specifies that an output may be null even if the corresponding type disallows it.
+[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)]
+[ExcludeFromCodeCoverage]
+internal sealed class MaybeNullAttribute : Attribute
+{
+}
+
+/// Specifies that an output will not be null even if the corresponding type allows it. Specifies that an input argument was not null when the call returns.
+[AttributeUsage(AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, Inherited = false)]
+[ExcludeFromCodeCoverage]
+internal sealed class NotNullAttribute : Attribute
+{
+}
+
+/// Specifies that when a method returns , the parameter may be null even if the corresponding type disallows it.
+[AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
+[ExcludeFromCodeCoverage]
+internal sealed class MaybeNullWhenAttribute : Attribute
+{
+ /// Initializes the attribute with the specified return value condition.
+ ///
+ /// The return value condition. If the method returns this value, the associated parameter may be .
+ ///
+ public MaybeNullWhenAttribute(bool returnValue) => ReturnValue = returnValue;
+
+ /// Gets the return value condition.
+ public bool ReturnValue { get; }
+}
+
+/// Specifies that when a method returns , the parameter will not be null even if the corresponding type allows it.
+[AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
+[ExcludeFromCodeCoverage]
+internal sealed class NotNullWhenAttribute : Attribute
+{
+ /// Initializes the attribute with the specified return value condition.
+ ///
+ /// The return value condition. If the method returns this value, the associated parameter will not be .
+ ///
+ public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue;
+
+ /// Gets the return value condition.
+ public bool ReturnValue { get; }
+}
+
+/// Specifies that the method or property will ensure that the listed field and property members have not-null values.
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)]
+[ExcludeFromCodeCoverage]
+internal sealed class MemberNotNullAttribute : Attribute
+{
+ /// Initializes the attribute with a field or property member.
+ ///
+ /// The field or property member that is promised to be not-null.
+ ///
+ public MemberNotNullAttribute(string member) => Members = [member];
+
+ /// Initializes the attribute with the list of field and property members.
+ ///
+ /// The list of field and property members that are promised to be not-null.
+ ///
+ public MemberNotNullAttribute(params string[] members) => Members = members;
+
+ /// Gets field or property member names.
+ public string[] Members { get; }
+}
+
+/// Specifies that the method or property will ensure that the listed field and property members have not-null values when returning with the specified return value condition.
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)]
+[ExcludeFromCodeCoverage]
+internal sealed class MemberNotNullWhenAttribute : Attribute
+{
+ /// Initializes the attribute with the specified return value condition and a field or property member.
+ ///
+ /// The return value condition. If the method returns this value, the associated parameter will not be .
+ ///
+ ///
+ /// The field or property member that is promised to be not-null.
+ ///
+ public MemberNotNullWhenAttribute(bool returnValue, string member)
+ {
+ ReturnValue = returnValue;
+ Members = [member];
+ }
+
+ /// Initializes the attribute with the specified return value condition and list of field and property members.
+ ///
+ /// The return value condition. If the method returns this value, the associated parameter will not be .
+ ///
+ ///
+ /// The list of field and property members that are promised to be not-null.
+ ///
+ public MemberNotNullWhenAttribute(bool returnValue, params string[] members)
+ {
+ ReturnValue = returnValue;
+ Members = members;
+ }
+
+ /// Gets the return value condition.
+ public bool ReturnValue { get; }
+
+ /// Gets field or property member names.
+ public string[] Members { get; }
+}
+
+/// Specifies that the output will be non-null if the named parameter is non-null.
+[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.ReturnValue, AllowMultiple = true, Inherited = false)]
+[ExcludeFromCodeCoverage]
+internal sealed class NotNullIfNotNullAttribute : Attribute
+{
+ /// Initializes the attribute with the associated parameter name.
+ ///
+ /// The associated parameter name. The output will be non-null if the argument to the parameter specified is non-null.
+ ///
+ public NotNullIfNotNullAttribute(string parameterName) => ParameterName = parameterName;
+
+ /// Gets the associated parameter name.
+ public string ParameterName { get; }
+}
+
+/// Applied to a method that will never return under any circumstance.
+[AttributeUsage(AttributeTargets.Method, Inherited = false)]
+[ExcludeFromCodeCoverage]
+internal sealed class DoesNotReturnAttribute : Attribute
+{
+}
+
+/// Specifies that the method will not return if the associated Boolean parameter is passed the specified value.
+[AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
+[ExcludeFromCodeCoverage]
+internal sealed class DoesNotReturnIfAttribute : Attribute
+{
+ /// Initializes the attribute with the specified parameter value.
+ ///
+ /// The condition parameter value. Code after the method will be considered unreachable by diagnostics if the argument to
+ /// the associated parameter matches this value.
+ ///
+ public DoesNotReturnIfAttribute(bool parameterValue) => ParameterValue = parameterValue;
+
+ /// Gets the condition parameter value.
+ public bool ParameterValue { get; }
+}
+#endif
diff --git a/src/LegacySupport/RequiresDynamicCodeAttribute.cs b/src/LegacySupport/RequiresDynamicCodeAttribute.cs
new file mode 100644
index 0000000..072701f
--- /dev/null
+++ b/src/LegacySupport/RequiresDynamicCodeAttribute.cs
@@ -0,0 +1,49 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable IDE0079
+#pragma warning disable SA1101
+#pragma warning disable SA1116
+#pragma warning disable SA1117
+#pragma warning disable SA1512
+#pragma warning disable SA1623
+#pragma warning disable SA1642
+#pragma warning disable S3903
+#pragma warning disable S3996
+
+namespace System.Diagnostics.CodeAnalysis;
+
+///
+/// Indicates that the specified method requires the ability to generate new code at runtime,
+/// for example through .
+///
+///
+/// This allows tools to understand which methods are unsafe to call when compiling ahead of time.
+///
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Constructor | AttributeTargets.Class, Inherited = false)]
+[ExcludeFromCodeCoverage]
+internal sealed class RequiresDynamicCodeAttribute : Attribute
+{
+ ///
+ /// Initializes a new instance of the class
+ /// with the specified message.
+ ///
+ ///
+ /// A message that contains information about the usage of dynamic code.
+ ///
+ public RequiresDynamicCodeAttribute(string message)
+ {
+ Message = message;
+ }
+
+ ///
+ /// Gets a message that contains information about the usage of dynamic code.
+ ///
+ public string Message { get; }
+
+ ///
+ /// Gets or sets an optional URL that contains more information about the method,
+ /// why it requires dynamic code, and what options a consumer has to deal with it.
+ ///
+ public string? Url { get; set; }
+}
diff --git a/src/LegacySupport/RequiresUnreferencedCodeAttribute.cs b/src/LegacySupport/RequiresUnreferencedCodeAttribute.cs
new file mode 100644
index 0000000..6ee4305
--- /dev/null
+++ b/src/LegacySupport/RequiresUnreferencedCodeAttribute.cs
@@ -0,0 +1,50 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#pragma warning disable IDE0079
+#pragma warning disable SA1101
+#pragma warning disable SA1116
+#pragma warning disable SA1117
+#pragma warning disable SA1512
+#pragma warning disable SA1623
+#pragma warning disable SA1642
+#pragma warning disable S3903
+#pragma warning disable S3996
+
+namespace System.Diagnostics.CodeAnalysis;
+
+///
+/// /// Indicates that the specified method requires dynamic access to code that is not referenced
+/// statically, for example through .
+///
+///
+/// This allows tools to understand which methods are unsafe to call when removing unreferenced
+/// code from an application.
+///
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Constructor | AttributeTargets.Class, Inherited = false)]
+[ExcludeFromCodeCoverage]
+internal sealed class RequiresUnreferencedCodeAttribute : Attribute
+{
+ ///
+ /// Initializes a new instance of the class
+ /// with the specified message.
+ ///
+ ///
+ /// A message that contains information about the usage of unreferenced code.
+ ///
+ public RequiresUnreferencedCodeAttribute(string message)
+ {
+ Message = message;
+ }
+
+ ///
+ /// Gets a message that contains information about the usage of unreferenced code.
+ ///
+ public string Message { get; }
+
+ ///
+ /// Gets or sets an optional URL that contains more information about the method,
+ /// why it requires unreferenced code, and what options a consumer has to deal with it.
+ ///
+ public string? Url { get; set; }
+}
diff --git a/src/LegacySupport/UnreachableException.cs b/src/LegacySupport/UnreachableException.cs
new file mode 100644
index 0000000..702dd43
--- /dev/null
+++ b/src/LegacySupport/UnreachableException.cs
@@ -0,0 +1,48 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+
+#pragma warning disable CA1064 // Exceptions should be public
+#pragma warning disable CA1812 // Internal class that is (sometimes) never instantiated.
+
+namespace System.Diagnostics;
+
+///
+/// Exception thrown when the program executes an instruction that was thought to be unreachable.
+///
+[ExcludeFromCodeCoverage]
+internal sealed class UnreachableException : Exception
+{
+ private const string MessageText = "The program executed an instruction that was thought to be unreachable.";
+
+ ///
+ /// Initializes a new instance of the class with the default error message.
+ ///
+ public UnreachableException()
+ : base(MessageText)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the
+ /// class with a specified error message.
+ ///
+ /// The error message that explains the reason for the exception.
+ public UnreachableException(string? message)
+ : base(message ?? MessageText)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the
+ /// class with a specified error message and a reference to the inner exception that is the cause of
+ /// this exception.
+ ///
+ /// The error message that explains the reason for the exception.
+ /// The exception that is the cause of the current exception.
+ public UnreachableException(string? message, Exception? innerException)
+ : base(message ?? MessageText, innerException)
+ {
+ }
+}
diff --git a/src/Microsoft.Build.Sql/VersionCheckTask.cs b/src/Microsoft.Build.Sql/VersionCheckTask.cs
index bc73085..eab1155 100644
--- a/src/Microsoft.Build.Sql/VersionCheckTask.cs
+++ b/src/Microsoft.Build.Sql/VersionCheckTask.cs
@@ -2,6 +2,7 @@
// Licensed under the MIT License.
using System;
+using System.Net.Http;
using System.Threading;
using Microsoft.Build.Framework;
using NuGet.Versioning;
@@ -47,7 +48,11 @@ public override bool Execute()
Log.LogMessage(MessageImportance.Low, $"Already using the latest version of {PackageName}: {currentVersion}.");
}
}
- catch (Exception ex)
+ catch (OperationCanceledException)
+ {
+ // Build was canceled or version check timed out
+ }
+ catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException)
{
Log.LogMessage(MessageImportance.Low, $"Failed to check for the latest version of {PackageName} on NuGet: {ex.Message}");
}
diff --git a/src/Microsoft.SqlServer.VectorData/AssemblyInfo.cs b/src/Microsoft.SqlServer.VectorData/AssemblyInfo.cs
new file mode 100644
index 0000000..cbb67c1
--- /dev/null
+++ b/src/Microsoft.SqlServer.VectorData/AssemblyInfo.cs
@@ -0,0 +1 @@
+// Copyright (c) Microsoft. All rights reserved.
diff --git a/src/Microsoft.SqlServer.VectorData/README.md b/src/Microsoft.SqlServer.VectorData/README.md
new file mode 100644
index 0000000..aa65d48
--- /dev/null
+++ b/src/Microsoft.SqlServer.VectorData/README.md
@@ -0,0 +1,50 @@
+# Microsoft.SqlServer.VectorData
+
+SQL Server and Azure SQL provider for [Microsoft.Extensions.VectorData](https://learn.microsoft.com/en-us/dotnet/ai/vector-stores/overview).
+
+## Usage
+
+```csharp
+using Microsoft.Extensions.VectorData;
+using Microsoft.SqlServer.VectorData;
+
+// Define your record model
+public sealed class BlogPost
+{
+ [VectorStoreKey]
+ public int Id { get; set; }
+
+ [VectorStoreData]
+ public string? Title { get; set; }
+
+ [VectorStoreData]
+ public string? Url { get; set; }
+
+ [VectorStoreData]
+ public string? Content { get; set; }
+
+ [VectorStoreVector(Dimensions: 1536)]
+ public ReadOnlyMemory ContentEmbedding { get; set; }
+}
+
+// Create the vector store and get a collection
+var vectorStore = new SqlServerVectorStore(connectionString);
+var collection = vectorStore.GetCollection("BlogPosts");
+await collection.EnsureCollectionExistsAsync();
+
+// Upsert records
+await collection.UpsertAsync(new BlogPost
+{
+ Id = 1,
+ Title = "Vector search in Azure SQL",
+ Content = "...",
+ ContentEmbedding = embedding // ReadOnlyMemory from your embedding provider
+});
+
+// Search
+var results = await collection.SearchAsync(queryEmbedding, top: 5).ToListAsync();
+```
+
+## Documentation
+
+- [Vector stores in .NET](https://learn.microsoft.com/en-us/dotnet/ai/vector-stores/overview)
diff --git a/src/Microsoft.SqlServer.VectorData/SqlFilterTranslator.cs b/src/Microsoft.SqlServer.VectorData/SqlFilterTranslator.cs
new file mode 100644
index 0000000..1b96676
--- /dev/null
+++ b/src/Microsoft.SqlServer.VectorData/SqlFilterTranslator.cs
@@ -0,0 +1,372 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Diagnostics;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Text;
+using Microsoft.Extensions.VectorData.ProviderServices;
+using Microsoft.Extensions.VectorData.ProviderServices.Filter;
+
+namespace Microsoft.SqlServer.VectorData;
+
+#pragma warning disable MEVD9001 // Microsoft.Extensions.VectorData experimental connector-facing APIs
+
+internal abstract class SqlFilterTranslator : FilterTranslatorBase
+{
+ protected readonly StringBuilder _sql;
+ private readonly Expression _preprocessedExpression;
+
+ internal SqlFilterTranslator(
+ CollectionModel model,
+ LambdaExpression lambdaExpression,
+ StringBuilder? sql = null)
+ {
+ Debug.Assert(lambdaExpression.Parameters.Count == 1);
+ this._sql = sql ?? new();
+
+ this._preprocessedExpression = this.PreprocessFilter(lambdaExpression, model, new FilterPreprocessingOptions { SupportsParameterization = true });
+ }
+
+ internal StringBuilder Clause => this._sql;
+
+ internal void Translate(bool appendWhere)
+ {
+ if (appendWhere)
+ {
+ this._sql.Append("WHERE ");
+ }
+
+ this.Translate(this._preprocessedExpression, isSearchCondition: true);
+ }
+
+ protected void Translate(Expression? node, bool isSearchCondition = false)
+ {
+ switch (node)
+ {
+ case BinaryExpression binary:
+ this.TranslateBinary(binary);
+ return;
+
+ case ConstantExpression constant:
+ this.TranslateConstant(constant.Value, isSearchCondition);
+ return;
+
+ case QueryParameterExpression { Name: var name, Value: var value }:
+ this.TranslateQueryParameter(value);
+ return;
+
+ case MemberExpression member:
+ this.TranslateMember(member, isSearchCondition);
+ return;
+
+ case MethodCallExpression methodCall:
+ this.TranslateMethodCall(methodCall, isSearchCondition);
+ return;
+
+ case UnaryExpression unary:
+ this.TranslateUnary(unary, isSearchCondition);
+ return;
+
+ default:
+ throw new NotSupportedException("Unsupported NodeType in filter: " + node?.NodeType);
+ }
+ }
+
+ protected void TranslateBinary(BinaryExpression binary)
+ {
+ // Special handling for null comparisons
+ switch (binary.NodeType)
+ {
+ case ExpressionType.Equal when IsNull(binary.Right):
+ this._sql.Append('(');
+ this.Translate(binary.Left);
+ this._sql.Append(" IS NULL)");
+ return;
+ case ExpressionType.NotEqual when IsNull(binary.Right):
+ this._sql.Append('(');
+ this.Translate(binary.Left);
+ this._sql.Append(" IS NOT NULL)");
+ return;
+
+ case ExpressionType.Equal when IsNull(binary.Left):
+ this._sql.Append('(');
+ this.Translate(binary.Right);
+ this._sql.Append(" IS NULL)");
+ return;
+ case ExpressionType.NotEqual when IsNull(binary.Left):
+ this._sql.Append('(');
+ this.Translate(binary.Right);
+ this._sql.Append(" IS NOT NULL)");
+ return;
+ }
+
+ this._sql.Append('(');
+ this.Translate(binary.Left, isSearchCondition: binary.NodeType is ExpressionType.AndAlso or ExpressionType.OrElse);
+
+ this._sql.Append(binary.NodeType switch
+ {
+ ExpressionType.Equal => " = ",
+ ExpressionType.NotEqual => " <> ",
+
+ ExpressionType.GreaterThan => " > ",
+ ExpressionType.GreaterThanOrEqual => " >= ",
+ ExpressionType.LessThan => " < ",
+ ExpressionType.LessThanOrEqual => " <= ",
+
+ ExpressionType.AndAlso => " AND ",
+ ExpressionType.OrElse => " OR ",
+
+ _ => throw new NotSupportedException("Unsupported binary expression node type: " + binary.NodeType)
+ });
+
+ this.Translate(binary.Right, isSearchCondition: binary.NodeType is ExpressionType.AndAlso or ExpressionType.OrElse);
+
+ this._sql.Append(')');
+
+ static bool IsNull(Expression expression)
+ => expression is ConstantExpression { Value: null } or QueryParameterExpression { Value: null };
+ }
+
+ protected virtual void TranslateConstant(object? value, bool isSearchCondition)
+ {
+ switch (value)
+ {
+ case byte b:
+ this._sql.Append(b);
+ return;
+ case short s:
+ this._sql.Append(s);
+ return;
+ case int i:
+ this._sql.Append(i);
+ return;
+ case long l:
+ this._sql.Append(l);
+ return;
+
+ case float f:
+ this._sql.Append(f);
+ return;
+ case double d:
+ this._sql.Append(d);
+ return;
+ case decimal d:
+ this._sql.Append(d);
+ return;
+
+ case string untrustedInput:
+ // This is the only place where we allow untrusted input to be passed in, so we need to quote and escape it.
+ // Luckily for us, values are escaped in the same way for every provider that we support so far.
+ this._sql.Append('\'').Append(untrustedInput.Replace("'", "''")).Append('\'');
+ return;
+ case bool b:
+ this._sql.Append(b ? "TRUE" : "FALSE");
+ return;
+ case Guid g:
+ this._sql.Append('\'').Append(g.ToString()).Append('\'');
+ return;
+
+ case DateTime dateTime:
+ case DateTimeOffset dateTimeOffset:
+ case Array:
+#if NET
+ case DateOnly dateOnly:
+ case TimeOnly timeOnly:
+#endif
+ throw new UnreachableException("Database-specific format, needs to be implemented in the provider's derived translator.");
+
+ case null:
+ this._sql.Append("NULL");
+ return;
+
+ default:
+ throw new NotSupportedException("Unsupported constant type: " + value.GetType().Name);
+ }
+ }
+
+ private void TranslateMember(MemberExpression memberExpression, bool isSearchCondition)
+ {
+ if (this.TryBindProperty(memberExpression, out var property))
+ {
+ this.GenerateColumn(property, isSearchCondition);
+ return;
+ }
+
+ throw new NotSupportedException($"Member access for '{memberExpression.Member.Name}' is unsupported - only member access over the filter parameter are supported");
+ }
+
+ protected virtual void GenerateColumn(PropertyModel property, bool isSearchCondition = false)
+ // StorageName is considered to be a safe input, we quote and escape it mostly to produce valid SQL.
+ => this._sql.Append('"').Append(property.StorageName.Replace("\"", "\"\"")).Append('"');
+
+ protected abstract void TranslateQueryParameter(object? value);
+
+ private void TranslateMethodCall(MethodCallExpression methodCall, bool isSearchCondition = false)
+ {
+ // Dictionary access for dynamic mapping (r => r["SomeString"] == "foo")
+ if (this.TryBindProperty(methodCall, out var property))
+ {
+ this.GenerateColumn(property, isSearchCondition);
+ return;
+ }
+
+ switch (methodCall)
+ {
+ // Enumerable.Contains(), List.Contains(), MemoryExtensions.Contains()
+ case var _ when TryMatchContains(methodCall, out var source, out var item):
+ this.TranslateContains(source, item);
+ return;
+
+ // Enumerable.Any() with a Contains predicate (r => r.Strings.Any(s => array.Contains(s)))
+ case { Method.Name: nameof(Enumerable.Any), Arguments: [var anySource, LambdaExpression lambda] } any
+ when any.Method.DeclaringType == typeof(Enumerable):
+ this.TranslateAny(anySource, lambda);
+ return;
+
+ default:
+ throw new NotSupportedException($"Unsupported method call: {methodCall.Method.DeclaringType?.Name}.{methodCall.Method.Name}");
+ }
+ }
+
+ private void TranslateContains(Expression source, Expression item)
+ {
+ switch (source)
+ {
+ // Contains over array column (r => r.Strings.Contains("foo"))
+ case var _ when this.TryBindProperty(source, out _):
+ this.TranslateContainsOverArrayColumn(source, item);
+ return;
+
+ // Contains over inline array (r => new[] { "foo", "bar" }.Contains(r.String))
+ case NewArrayExpression newArray:
+ this.Translate(item);
+ this._sql.Append(" IN (");
+
+ var isFirst = true;
+ foreach (var element in newArray.Expressions)
+ {
+ if (isFirst)
+ {
+ isFirst = false;
+ }
+ else
+ {
+ this._sql.Append(", ");
+ }
+
+ this.Translate(element);
+ }
+
+ this._sql.Append(')');
+ return;
+
+ // Contains over captured array (r => arrayLocalVariable.Contains(r.String))
+ case QueryParameterExpression { Value: var value }:
+ this.TranslateContainsOverParameterizedArray(source, item, value);
+ return;
+
+ default:
+ throw new NotSupportedException("Unsupported Contains expression");
+ }
+ }
+
+ protected abstract void TranslateContainsOverArrayColumn(Expression source, Expression item);
+
+ protected abstract void TranslateContainsOverParameterizedArray(Expression source, Expression item, object? value);
+
+ ///
+ /// Translates an Any() call with a Contains predicate, e.g. r.Strings.Any(s => array.Contains(s)).
+ /// This checks whether any element in the array column is contained in the given values.
+ ///
+ private void TranslateAny(Expression source, LambdaExpression lambda)
+ {
+ // We only support the pattern: r.ArrayColumn.Any(x => values.Contains(x))
+ // where 'values' is an inline array, captured array, or captured list.
+ if (!this.TryBindProperty(source, out var property)
+ || lambda.Body is not MethodCallExpression containsCall
+ || !TryMatchContains(containsCall, out var valuesExpression, out var itemExpression))
+ {
+ throw new NotSupportedException("Unsupported method call: Enumerable.Any");
+ }
+
+ // Verify that the item is the lambda parameter
+ if (itemExpression != lambda.Parameters[0])
+ {
+ throw new NotSupportedException("Unsupported method call: Enumerable.Any");
+ }
+
+ // Now extract the values from valuesExpression
+ switch (valuesExpression)
+ {
+ // Inline array: r.Strings.Any(s => new[] { "a", "b" }.Contains(s))
+ case NewArrayExpression newArray:
+ {
+ var values = new object?[newArray.Expressions.Count];
+ for (var i = 0; i < newArray.Expressions.Count; i++)
+ {
+ values[i] = newArray.Expressions[i] switch
+ {
+ ConstantExpression { Value: var v } => v,
+ QueryParameterExpression { Value: var v } => v,
+ _ => throw new NotSupportedException("Unsupported method call: Enumerable.Any")
+ };
+ }
+
+ this.TranslateAnyContainsOverArrayColumn(property, values);
+ return;
+ }
+
+ // Captured/parameterized array or list: r.Strings.Any(s => capturedArray.Contains(s))
+ case QueryParameterExpression { Value: var value }:
+ this.TranslateAnyContainsOverArrayColumn(property, value);
+ return;
+
+ // Constant array: shouldn't normally happen, but handle it
+ case ConstantExpression { Value: var value }:
+ this.TranslateAnyContainsOverArrayColumn(property, value);
+ return;
+
+ default:
+ throw new NotSupportedException("Unsupported method call: Enumerable.Any");
+ }
+ }
+
+ protected abstract void TranslateAnyContainsOverArrayColumn(PropertyModel property, object? values);
+
+ private void TranslateUnary(UnaryExpression unary, bool isSearchCondition)
+ {
+ switch (unary.NodeType)
+ {
+ case ExpressionType.Not:
+ // Special handling for !(a == b) and !(a != b)
+ if (unary.Operand is BinaryExpression { NodeType: ExpressionType.Equal or ExpressionType.NotEqual } binary)
+ {
+ this.TranslateBinary(
+ Expression.MakeBinary(
+ binary.NodeType is ExpressionType.Equal ? ExpressionType.NotEqual : ExpressionType.Equal,
+ binary.Left,
+ binary.Right));
+ return;
+ }
+
+ this._sql.Append("(NOT ");
+ this.Translate(unary.Operand, isSearchCondition);
+ this._sql.Append(')');
+ return;
+
+ // Handle converting non-nullable to nullable; such nodes are found in e.g. r => r.Int == nullableInt
+ case ExpressionType.Convert when Nullable.GetUnderlyingType(unary.Type) == unary.Operand.Type:
+ this.Translate(unary.Operand, isSearchCondition);
+ return;
+
+ // Handle convert over member access, for dynamic dictionary access (r => (int)r["SomeInt"] == 8)
+ case ExpressionType.Convert when this.TryBindProperty(unary.Operand, out var property) && unary.Type == property.Type:
+ this.GenerateColumn(property, isSearchCondition);
+ return;
+
+ default:
+ throw new NotSupportedException("Unsupported unary expression node type: " + unary.NodeType);
+ }
+ }
+}
diff --git a/src/Microsoft.SqlServer.VectorData/SqlServer.csproj b/src/Microsoft.SqlServer.VectorData/SqlServer.csproj
new file mode 100644
index 0000000..9362a46
--- /dev/null
+++ b/src/Microsoft.SqlServer.VectorData/SqlServer.csproj
@@ -0,0 +1,42 @@
+
+
+
+ 1.0.0-preview.1
+ Microsoft.SqlServer.VectorData
+ $(AssemblyName)
+ netstandard2.0;net8.0;net462
+ preview
+ enable
+ enable
+ $(NoWarn);MEVD9000,MEVD9001
+
+ SQL Server provider for Microsoft.Extensions.VectorData
+ SQL Server provider for Microsoft.Extensions.VectorData
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Microsoft.SqlServer.VectorData/SqlServerCollection.cs b/src/Microsoft.SqlServer.VectorData/SqlServerCollection.cs
new file mode 100644
index 0000000..f92d55f
--- /dev/null
+++ b/src/Microsoft.SqlServer.VectorData/SqlServerCollection.cs
@@ -0,0 +1,889 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Data.Common;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Data.SqlClient;
+using Microsoft.Data.SqlTypes;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.VectorData;
+using Microsoft.Extensions.VectorData.ProviderServices;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.SqlServer.VectorData;
+
+///
+/// An implementation of backed by a SQL Server or Azure SQL database.
+///
+#pragma warning disable CA1711 // Identifiers should not have incorrect suffix (Collection)
+public class SqlServerCollection
+#pragma warning restore CA1711
+ : VectorStoreCollection,
+ IKeywordHybridSearchable
+ where TKey : notnull
+ where TRecord : class
+{
+ /// Metadata about vector store record collection.
+ private readonly VectorStoreCollectionMetadata _collectionMetadata;
+
+ private static readonly VectorSearchOptions s_defaultVectorSearchOptions = new();
+ private static readonly HybridSearchOptions s_defaultHybridSearchOptions = new();
+
+ private readonly string _connectionString;
+ private readonly CollectionModel _model;
+ private readonly SqlServerMapper _mapper;
+
+ /// The database schema.
+ private readonly string? _schema;
+
+ /// Whether the model contains any DiskAnn vector properties, requiring Azure SQL.
+ private readonly bool _requiresAzureSql;
+
+ /// Cached result of the Azure SQL engine edition check (null = not yet checked).
+ private bool? _isAzureSql;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Database connection string.
+ /// The name of the collection.
+ /// Optional configuration options.
+ [RequiresUnreferencedCode("The SQL Server provider is currently incompatible with trimming.")]
+ [RequiresDynamicCode("The SQL Server provider is currently incompatible with NativeAOT.")]
+ public SqlServerCollection(
+ string connectionString,
+ string name,
+ SqlServerCollectionOptions? options = null)
+ : this(
+ connectionString,
+ name,
+ static options => typeof(TRecord) == typeof(Dictionary)
+ ? throw new NotSupportedException(VectorDataStrings.NonDynamicCollectionWithDictionaryNotSupported(typeof(SqlServerDynamicCollection)))
+ : new SqlServerModelBuilder().Build(typeof(TRecord), typeof(TKey), options.Definition, options.EmbeddingGenerator),
+ options)
+ {
+ }
+
+ internal SqlServerCollection(string connectionString, string name, Func modelFactory, SqlServerCollectionOptions? options)
+ {
+ Throw.IfNullOrWhitespace(connectionString);
+ Throw.IfNull(name);
+
+ options ??= SqlServerCollectionOptions.Default;
+ this._schema = options.Schema;
+
+ this._connectionString = connectionString;
+ this.Name = name;
+ this._model = modelFactory(options);
+
+ this._mapper = new SqlServerMapper(this._model);
+
+ // Check if any vector property uses DiskAnn, which requires Azure SQL.
+ foreach (var vp in this._model.VectorProperties)
+ {
+ if (vp.IndexKind == IndexKind.DiskAnn)
+ {
+ this._requiresAzureSql = true;
+ break;
+ }
+ }
+
+ var connectionStringBuilder = new SqlConnectionStringBuilder(connectionString);
+
+ this._collectionMetadata = new()
+ {
+ VectorStoreSystemName = SqlServerConstants.VectorStoreSystemName,
+ VectorStoreName = connectionStringBuilder.InitialCatalog,
+ CollectionName = name
+ };
+ }
+
+ ///
+ public override string Name { get; }
+
+ ///
+ public override async Task CollectionExistsAsync(CancellationToken cancellationToken = default)
+ {
+ using SqlConnection connection = new(this._connectionString);
+ using SqlCommand command = SqlServerCommandBuilder.SelectTableName(
+ connection, this._schema, this.Name);
+
+ return await connection.ExecuteWithErrorHandlingAsync(
+ this._collectionMetadata,
+ "CollectionExists",
+ async () =>
+ {
+ using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+ return await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
+ },
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public override Task EnsureCollectionExistsAsync(CancellationToken cancellationToken = default)
+ => this.CreateCollectionAsync(ifNotExists: true, cancellationToken);
+
+ private async Task CreateCollectionAsync(bool ifNotExists, CancellationToken cancellationToken)
+ {
+ using SqlConnection connection = new(this._connectionString);
+
+ if (this._requiresAzureSql)
+ {
+ await this.EnsureAzureSqlForDiskAnnAsync(connection, cancellationToken).ConfigureAwait(false);
+ }
+
+ List commands = SqlServerCommandBuilder.CreateTable(
+ connection,
+ this._schema,
+ this.Name,
+ ifNotExists,
+ this._model);
+
+ foreach (SqlCommand command in commands)
+ {
+ using (command)
+ {
+ await connection.ExecuteWithErrorHandlingAsync(
+ this._collectionMetadata,
+ "CreateCollection",
+ () => command.ExecuteNonQueryAsync(cancellationToken),
+ cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ ///
+ public override async Task EnsureCollectionDeletedAsync(CancellationToken cancellationToken = default)
+ {
+ using SqlConnection connection = new(this._connectionString);
+ using SqlCommand command = SqlServerCommandBuilder.DropTableIfExists(
+ connection, this._schema, this.Name);
+
+ await connection.ExecuteWithErrorHandlingAsync(
+ this._collectionMetadata,
+ "DeleteCollection",
+ () => command.ExecuteNonQueryAsync(cancellationToken),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public override async Task DeleteAsync(TKey key, CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(key);
+
+ using SqlConnection connection = new(this._connectionString);
+ using SqlCommand command = SqlServerCommandBuilder.DeleteSingle(
+ connection,
+ this._schema,
+ this.Name,
+ this._model.KeyProperty,
+ key);
+
+ await connection.ExecuteWithErrorHandlingAsync(
+ this._collectionMetadata,
+ "Delete",
+ () => command.ExecuteNonQueryAsync(cancellationToken),
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public override async Task DeleteAsync(IEnumerable keys, CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(keys);
+
+ using SqlConnection connection = new(this._connectionString);
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+
+ using SqlTransaction transaction = connection.BeginTransaction();
+ int taken = 0;
+
+ try
+ {
+ while (true)
+ {
+#if NET
+ SqlCommand command = new("", connection, transaction);
+ await using (command.ConfigureAwait(false))
+#else
+ using (SqlCommand command = new("", connection, transaction))
+#endif
+ {
+ if (!SqlServerCommandBuilder.DeleteMany(
+ command,
+ this._schema,
+ this.Name,
+ this._model.KeyProperty,
+ keys.Skip(taken).Take(SqlServerConstants.MaxParameterCount)))
+ {
+ break; // keys is empty, there is nothing to delete
+ }
+
+ checked
+ {
+ taken += command.Parameters.Count;
+ }
+
+ await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ if (taken > 0)
+ {
+#if NET
+ await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+#else
+ transaction.Commit();
+#endif
+ }
+ }
+ catch (DbException ex)
+ {
+#if NET
+ await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
+#else
+ transaction.Rollback();
+#endif
+
+ throw new VectorStoreException(ex.Message, ex)
+ {
+ VectorStoreSystemName = SqlServerConstants.VectorStoreSystemName,
+ VectorStoreName = this._collectionMetadata.VectorStoreName,
+ CollectionName = this.Name,
+ OperationName = "DeleteBatch"
+ };
+ }
+ catch (Exception)
+ {
+#if NET
+ await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
+#else
+ transaction.Rollback();
+#endif
+
+ throw;
+ }
+ }
+
+ ///
+ public override async Task GetAsync(TKey key, RecordRetrievalOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(key);
+
+ bool includeVectors = options?.IncludeVectors is true;
+ if (includeVectors && this._model.EmbeddingGenerationRequired)
+ {
+ throw new NotSupportedException(VectorDataStrings.IncludeVectorsNotSupportedWithEmbeddingGeneration);
+ }
+
+ using SqlConnection connection = new(this._connectionString);
+ using SqlCommand command = SqlServerCommandBuilder.SelectSingle(
+ connection,
+ this._schema,
+ this.Name,
+ this._model,
+ key,
+ includeVectors);
+
+ return await connection.ExecuteWithErrorHandlingAsync(
+ this._collectionMetadata,
+ operationName: "Get",
+ async () =>
+ {
+ using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+ await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
+ return reader.HasRows
+ ? this._mapper.MapFromStorageToDataModel(reader, includeVectors)
+ : null;
+ },
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public override async IAsyncEnumerable GetAsync(IEnumerable keys, RecordRetrievalOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(keys);
+
+ bool includeVectors = options?.IncludeVectors is true;
+ if (includeVectors && this._model.EmbeddingGenerationRequired)
+ {
+ throw new NotSupportedException(VectorDataStrings.IncludeVectorsNotSupportedWithEmbeddingGeneration);
+ }
+
+ using SqlConnection connection = new(this._connectionString);
+ using SqlCommand command = connection.CreateCommand();
+ int taken = 0;
+
+ do
+ {
+ if (command.Parameters.Count > 0)
+ {
+ command.Parameters.Clear(); // We reuse the same command for the next batch.
+ }
+
+ if (!SqlServerCommandBuilder.SelectMany(
+ command,
+ this._schema,
+ this.Name,
+ this._model,
+ keys.Skip(taken).Take(SqlServerConstants.MaxParameterCount),
+ includeVectors))
+ {
+ yield break; // keys is empty
+ }
+
+ checked
+ {
+ taken += command.Parameters.Count;
+ }
+
+ using SqlDataReader reader = await connection.ExecuteWithErrorHandlingAsync(
+ this._collectionMetadata,
+ operationName: "GetBatch",
+ () => command.ExecuteReaderAsync(cancellationToken),
+ cancellationToken).ConfigureAwait(false);
+
+ while (true)
+ {
+ TRecord? record = await VectorStoreErrorHandler.RunOperationAsync(
+ this._collectionMetadata,
+ "GetBatch",
+ async () => await reader.ReadAsync(cancellationToken).ConfigureAwait(false)
+ ? this._mapper.MapFromStorageToDataModel(reader, includeVectors)
+ : null)
+ .ConfigureAwait(false);
+
+ if (record is null)
+ {
+ break;
+ }
+
+ yield return record;
+ }
+ } while (command.Parameters.Count == SqlServerConstants.MaxParameterCount);
+ }
+
+ ///
+ public override async Task UpsertAsync(TRecord record, CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(record);
+
+ Dictionary>? generatedEmbeddings = null;
+
+ var vectorPropertyCount = this._model.VectorProperties.Count;
+ for (var i = 0; i < vectorPropertyCount; i++)
+ {
+ var vectorProperty = this._model.VectorProperties[i];
+
+ if (SqlServerModelBuilder.IsVectorPropertyTypeValidCore(vectorProperty.Type, out _))
+ {
+ continue;
+ }
+
+ // We have a vector property whose type isn't natively supported - we need to generate embeddings.
+ Debug.Assert(vectorProperty.EmbeddingGenerator is not null);
+
+ // TODO: Ideally we'd group together vector properties using the same generator (and with the same input and output properties),
+ // and generate embeddings for them in a single batch. That's some more complexity though.
+ generatedEmbeddings ??= new Dictionary>(vectorPropertyCount);
+ generatedEmbeddings[vectorProperty] = [await vectorProperty.GenerateEmbeddingAsync(vectorProperty.GetValueAsObject(record), cancellationToken).ConfigureAwait(false)];
+ }
+
+ using SqlConnection connection = new(this._connectionString);
+ using SqlCommand command = connection.CreateCommand();
+ SqlServerCommandBuilder.Upsert(
+ command,
+ this._schema,
+ this.Name,
+ this._model,
+ [record],
+ firstRecordIndex: 0,
+ generatedEmbeddings);
+
+ var keyProperty = this._model.KeyProperty;
+
+ await connection.ExecuteWithErrorHandlingAsync(
+ this._collectionMetadata,
+ "Upsert",
+ async () =>
+ {
+ using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+ await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
+
+ // Inject the generated key into the record if auto-generation was used
+ if (keyProperty.IsAutoGenerated && Equals(keyProperty.GetValueAsObject(record), default(TKey)))
+ {
+ var keyValue = reader.GetFieldValue(0);
+ keyProperty.SetValue(record, keyValue);
+ }
+
+ return 0;
+ },
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public override async Task UpsertAsync(IEnumerable records, CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(records);
+
+ IReadOnlyList? recordsList = null;
+
+ // If an embedding generator is defined, invoke it once per property for all records.
+ Dictionary>? generatedEmbeddings = null;
+
+ var vectorPropertyCount = this._model.VectorProperties.Count;
+ for (var i = 0; i < vectorPropertyCount; i++)
+ {
+ var vectorProperty = this._model.VectorProperties[i];
+
+ if (SqlServerModelBuilder.IsVectorPropertyTypeValidCore(vectorProperty.Type, out _))
+ {
+ continue;
+ }
+
+ // We have a vector property whose type isn't natively supported - we need to generate embeddings.
+ Debug.Assert(vectorProperty.EmbeddingGenerator is not null);
+
+ // We have a property with embedding generation; materialize the records' enumerable if needed, to
+ // prevent multiple enumeration.
+ if (recordsList is null)
+ {
+ recordsList = records is IReadOnlyList r ? r : records.ToList();
+
+ if (recordsList.Count == 0)
+ {
+ return;
+ }
+
+ records = recordsList;
+ }
+
+ // TODO: Ideally we'd group together vector properties using the same generator (and with the same input and output properties),
+ // and generate embeddings for them in a single batch. That's some more complexity though.
+ generatedEmbeddings ??= new Dictionary>(vectorPropertyCount);
+ generatedEmbeddings[vectorProperty] = await vectorProperty.GenerateEmbeddingsAsync(records.Select(r => vectorProperty.GetValueAsObject(r)), cancellationToken).ConfigureAwait(false);
+ }
+
+ // If key auto-generation is enabled, we need to read back generated keys and inject them into records.
+ // Materialize the records' enumerable if needed, to allow iteration for key injection.
+ var keyProperty = this._model.KeyProperty;
+ if (keyProperty.IsAutoGenerated && recordsList is null)
+ {
+ recordsList = records is IReadOnlyList r ? r : records.ToList();
+
+ if (recordsList.Count == 0)
+ {
+ return;
+ }
+
+ records = recordsList;
+ }
+
+ using SqlConnection connection = new(this._connectionString);
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+
+ using SqlTransaction transaction = connection.BeginTransaction();
+ int parametersPerRecord = this._model.Properties.Count;
+ int taken = 0;
+ int batchSize = SqlServerConstants.MaxParameterCount / parametersPerRecord;
+
+ try
+ {
+ while (true)
+ {
+ // Materialize the batch to a list so we can iterate multiple times:
+ // once for building the command, once for reading back results.
+ var batch = records.Skip(taken).Take(batchSize).ToList();
+ if (batch.Count == 0)
+ {
+ break;
+ }
+
+#if NET
+ SqlCommand command = new("", connection, transaction);
+ await using (command.ConfigureAwait(false))
+#else
+ using (SqlCommand command = new("", connection, transaction))
+#endif
+ {
+ if (!SqlServerCommandBuilder.Upsert(
+ command,
+ this._schema,
+ this.Name,
+ this._model,
+ batch,
+ firstRecordIndex: taken,
+ generatedEmbeddings))
+ {
+ break; // records is empty (shouldn't happen given check above, but defensive)
+ }
+
+ // Execute and read back the generated keys.
+ // Each MERGE statement returns a single result set with one row containing the key.
+ using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
+
+ // Iterate through the records in this batch and inject generated keys where needed.
+ foreach (var record in batch)
+ {
+ await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
+
+ // Only inject key if auto-generation is enabled and record had a default key value
+ if (keyProperty.IsAutoGenerated && Equals(keyProperty.GetValueAsObject(record), default(TKey)))
+ {
+ var keyValue = reader.GetFieldValue(0);
+ keyProperty.SetValue(record, keyValue);
+ }
+
+ await reader.NextResultAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ checked
+ {
+ taken += batch.Count;
+ }
+ }
+ }
+
+ if (taken > 0)
+ {
+#if NET
+ await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+#else
+ transaction.Commit();
+#endif
+ }
+ }
+ catch (DbException ex)
+ {
+#if NET
+ await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
+#else
+ transaction.Rollback();
+#endif
+
+ throw new VectorStoreException(ex.Message, ex)
+ {
+ VectorStoreSystemName = SqlServerConstants.VectorStoreSystemName,
+ VectorStoreName = this._collectionMetadata.VectorStoreName,
+ CollectionName = this.Name,
+ OperationName = "UpsertBatch"
+ };
+ }
+ catch (Exception)
+ {
+#if NET
+ await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
+#else
+ transaction.Rollback();
+#endif
+ throw;
+ }
+ }
+
+ #region Search
+
+ ///
+ public override async IAsyncEnumerable> SearchAsync(
+ TInput searchValue,
+ int top,
+ VectorSearchOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(searchValue);
+ Throw.IfLessThan(top, 1);
+
+ options ??= s_defaultVectorSearchOptions;
+ if (options.IncludeVectors && this._model.EmbeddingGenerationRequired)
+ {
+ throw new NotSupportedException(VectorDataStrings.IncludeVectorsNotSupportedWithEmbeddingGeneration);
+ }
+
+ var vectorProperty = this._model.GetVectorPropertyOrSingle(options);
+
+ SqlVector vector = searchValue switch
+ {
+ SqlVector v => v,
+ ReadOnlyMemory r => new(r),
+ float[] f => new(f),
+ Embedding e => new(e.Vector),
+
+ _ when vectorProperty.EmbeddingGenerationDispatcher is not null
+ => new(((Embedding)await vectorProperty.GenerateEmbeddingAsync(searchValue, cancellationToken).ConfigureAwait(false)).Vector),
+
+ _ => vectorProperty.EmbeddingGenerator is null
+ ? throw new NotSupportedException(VectorDataStrings.InvalidSearchInputAndNoEmbeddingGeneratorWasConfigured(searchValue.GetType(), SqlServerModelBuilder.SupportedVectorTypes))
+ : throw new InvalidOperationException(VectorDataStrings.IncompatibleEmbeddingGeneratorWasConfiguredForInputType(typeof(TInput), vectorProperty.EmbeddingGenerator.GetType()))
+ };
+
+#pragma warning disable CA2000 // Dispose objects before losing scope
+ // Connection and command are going to be disposed by the ReadVectorSearchResultsAsync,
+ // when the user is done with the results.
+ SqlConnection connection = new(this._connectionString);
+
+ if (vectorProperty.IndexKind == IndexKind.DiskAnn)
+ {
+ await this.EnsureAzureSqlForDiskAnnAsync(connection, cancellationToken).ConfigureAwait(false);
+ }
+
+ SqlCommand command = SqlServerCommandBuilder.SelectVector(
+ connection,
+ this._schema,
+ this.Name,
+ vectorProperty,
+ this._model,
+ top,
+ options,
+ vector);
+#pragma warning restore CA2000 // Dispose objects before losing scope
+
+ await foreach (var record in this.ReadVectorSearchResultsAsync(connection, command, options.IncludeVectors, cancellationToken).ConfigureAwait(false))
+ {
+ yield return record;
+ }
+ }
+
+ ///
+ public async IAsyncEnumerable> HybridSearchAsync(
+ TInput searchValue,
+ ICollection keywords,
+ int top,
+ HybridSearchOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ where TInput : notnull
+ {
+ Throw.IfNull(searchValue);
+ Throw.IfNull(keywords);
+ Throw.IfLessThan(top, 1);
+
+ options ??= s_defaultHybridSearchOptions;
+ if (options.IncludeVectors && this._model.EmbeddingGenerationRequired)
+ {
+ throw new NotSupportedException(VectorDataStrings.IncludeVectorsNotSupportedWithEmbeddingGeneration);
+ }
+
+ var vectorProperty = this._model.GetVectorPropertyOrSingle(new VectorSearchOptions { VectorProperty = options.VectorProperty });
+ var textDataProperty = this._model.GetFullTextDataPropertyOrSingle(options.AdditionalProperty);
+
+ SqlVector vector = searchValue switch
+ {
+ SqlVector v => v,
+ ReadOnlyMemory r => new(r),
+ float[] f => new(f),
+ Embedding e => new(e.Vector),
+
+ _ when vectorProperty.EmbeddingGenerationDispatcher is not null
+ => new(((Embedding)await vectorProperty.GenerateEmbeddingAsync(searchValue, cancellationToken).ConfigureAwait(false)).Vector),
+
+ _ => vectorProperty.EmbeddingGenerator is null
+ ? throw new NotSupportedException(VectorDataStrings.InvalidSearchInputAndNoEmbeddingGeneratorWasConfigured(searchValue.GetType(), SqlServerModelBuilder.SupportedVectorTypes))
+ : throw new InvalidOperationException(VectorDataStrings.IncompatibleEmbeddingGeneratorWasConfiguredForInputType(typeof(TInput), vectorProperty.EmbeddingGenerator.GetType()))
+ };
+
+ var keywordsCombined = string.Join(" ", keywords);
+
+#pragma warning disable CA2000 // Dispose objects before losing scope
+ // Connection and command are going to be disposed by the ReadVectorSearchResultsAsync,
+ // when the user is done with the results.
+ SqlConnection connection = new(this._connectionString);
+
+ if (vectorProperty.IndexKind == IndexKind.DiskAnn)
+ {
+ await this.EnsureAzureSqlForDiskAnnAsync(connection, cancellationToken).ConfigureAwait(false);
+ }
+
+ SqlCommand command = SqlServerCommandBuilder.SelectHybrid(
+ connection,
+ this._schema,
+ this.Name,
+ vectorProperty,
+ textDataProperty,
+ this._model,
+ top,
+ options,
+ vector,
+ keywordsCombined);
+#pragma warning restore CA2000 // Dispose objects before losing scope
+
+ await foreach (var record in this.ReadHybridSearchResultsAsync(connection, command, options, cancellationToken).ConfigureAwait(false))
+ {
+ yield return record;
+ }
+ }
+
+ #endregion Search
+
+ ///
+ public override object? GetService(Type serviceType, object? serviceKey = null)
+ {
+ Throw.IfNull(serviceType);
+
+ return
+ serviceKey is not null ? null :
+ serviceType == typeof(VectorStoreCollectionMetadata) ? this._collectionMetadata :
+ serviceType.IsInstanceOfType(this) ? this :
+ null;
+ }
+
+ private async IAsyncEnumerable> ReadVectorSearchResultsAsync(
+ SqlConnection connection,
+ SqlCommand command,
+ bool includeVectors,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ try
+ {
+ var vectorProperties = includeVectors ? this._model.VectorProperties : [];
+
+ using SqlDataReader reader = await connection.ExecuteWithErrorHandlingAsync(
+ this._collectionMetadata,
+ operationName: "VectorizedSearch",
+ () => command.ExecuteReaderAsync(cancellationToken),
+ cancellationToken).ConfigureAwait(false);
+
+ int scoreIndex = -1;
+ while (await reader.ReadWithErrorHandlingAsync(
+ this._collectionMetadata,
+ operationName: "VectorizedSearch",
+ cancellationToken).ConfigureAwait(false))
+ {
+ if (scoreIndex < 0)
+ {
+ scoreIndex = reader.GetOrdinal("score");
+ }
+
+ yield return new VectorSearchResult(
+ this._mapper.MapFromStorageToDataModel(reader, includeVectors),
+ reader.GetDouble(scoreIndex));
+ }
+ }
+ finally
+ {
+ command.Dispose();
+ connection.Dispose();
+ }
+ }
+
+ private async IAsyncEnumerable> ReadHybridSearchResultsAsync(
+ SqlConnection connection,
+ SqlCommand command,
+ HybridSearchOptions options,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ try
+ {
+ using SqlDataReader reader = await connection.ExecuteWithErrorHandlingAsync(
+ this._collectionMetadata,
+ operationName: "HybridSearch",
+ () => command.ExecuteReaderAsync(cancellationToken),
+ cancellationToken).ConfigureAwait(false);
+
+ int scoreIndex = -1;
+ while (await reader.ReadWithErrorHandlingAsync(
+ this._collectionMetadata,
+ operationName: "HybridSearch",
+ cancellationToken).ConfigureAwait(false))
+ {
+ if (scoreIndex < 0)
+ {
+ scoreIndex = reader.GetOrdinal("score");
+ }
+
+ yield return new VectorSearchResult(
+ this._mapper.MapFromStorageToDataModel(reader, options.IncludeVectors),
+ reader.GetDouble(scoreIndex));
+ }
+ }
+ finally
+ {
+ command.Dispose();
+ connection.Dispose();
+ }
+ }
+
+ ///
+ public override async IAsyncEnumerable GetAsync(Expression> filter, int top,
+ FilteredRecordRetrievalOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ Throw.IfNull(filter);
+ Throw.IfLessThan(top, 1);
+
+ options ??= new();
+
+ using SqlConnection connection = new(this._connectionString);
+ using SqlCommand command = SqlServerCommandBuilder.SelectWhere(
+ filter,
+ top,
+ options,
+ connection,
+ this._schema,
+ this.Name,
+ this._model);
+
+ using SqlDataReader reader = await connection.ExecuteWithErrorHandlingAsync(
+ this._collectionMetadata,
+ operationName: "GetAsync",
+ () => command.ExecuteReaderAsync(cancellationToken),
+ cancellationToken).ConfigureAwait(false);
+
+ var vectorProperties = options.IncludeVectors ? this._model.VectorProperties : [];
+ while (await reader.ReadWithErrorHandlingAsync(
+ this._collectionMetadata,
+ operationName: "GetAsync",
+ cancellationToken).ConfigureAwait(false))
+ {
+ yield return this._mapper.MapFromStorageToDataModel(reader, options.IncludeVectors);
+ }
+ }
+
+ ///
+ /// Validates that the connection is to Azure SQL Database or SQL database in Microsoft Fabric,
+ /// which is required for DiskAnn vector indexes and the VECTOR_SEARCH function.
+ ///
+ private async Task EnsureAzureSqlForDiskAnnAsync(SqlConnection connection, CancellationToken cancellationToken)
+ {
+ if (this._isAzureSql is true)
+ {
+ return;
+ }
+
+ if (this._isAzureSql is false)
+ {
+ connection.Dispose();
+ throw new NotSupportedException(
+ "DiskAnn vector indexes and the VECTOR_SEARCH function require Azure SQL Database or SQL database in Microsoft Fabric. " +
+ "They are not supported on SQL Server. Use a Flat index kind with VECTOR_DISTANCE instead.");
+ }
+
+ if (connection.State != System.Data.ConnectionState.Open)
+ {
+ await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ using var command = connection.CreateCommand();
+ command.CommandText = "SELECT SERVERPROPERTY('EngineEdition')";
+ var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
+ var engineEdition = Convert.ToInt32(result);
+
+ // 5 = Azure SQL Database, 11 = SQL database in Microsoft Fabric
+ this._isAzureSql = engineEdition is 5 or 11;
+
+ if (!this._isAzureSql.Value)
+ {
+ // Dispose the connection before throwing; in SearchAsync/HybridSearchAsync the connection
+ // is not in a using block (it's normally disposed by ReadVectorSearchResultsAsync).
+ connection.Dispose();
+
+ throw new NotSupportedException(
+ "DiskAnn vector indexes and the VECTOR_SEARCH function require Azure SQL Database or SQL database in Microsoft Fabric. " +
+ "They are not supported on SQL Server. Use a Flat index kind with VECTOR_DISTANCE instead.");
+ }
+ }
+}
diff --git a/src/Microsoft.SqlServer.VectorData/SqlServerCollectionOptions.cs b/src/Microsoft.SqlServer.VectorData/SqlServerCollectionOptions.cs
new file mode 100644
index 0000000..64becf6
--- /dev/null
+++ b/src/Microsoft.SqlServer.VectorData/SqlServerCollectionOptions.cs
@@ -0,0 +1,30 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using Microsoft.Extensions.VectorData;
+
+namespace Microsoft.SqlServer.VectorData;
+
+///
+/// Options when creating a .
+///
+public sealed class SqlServerCollectionOptions : VectorStoreCollectionOptions
+{
+ internal static readonly SqlServerCollectionOptions Default = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public SqlServerCollectionOptions()
+ {
+ }
+
+ internal SqlServerCollectionOptions(SqlServerCollectionOptions? source) : base(source)
+ {
+ this.Schema = source?.Schema;
+ }
+
+ ///
+ /// Gets or sets the database schema.
+ ///
+ public string? Schema { get; set; }
+}
diff --git a/src/Microsoft.SqlServer.VectorData/SqlServerCommandBuilder.cs b/src/Microsoft.SqlServer.VectorData/SqlServerCommandBuilder.cs
new file mode 100644
index 0000000..210d683
--- /dev/null
+++ b/src/Microsoft.SqlServer.VectorData/SqlServerCommandBuilder.cs
@@ -0,0 +1,1106 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq.Expressions;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Data.SqlClient;
+using Microsoft.Data.SqlTypes;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.VectorData;
+using Microsoft.Extensions.VectorData.ProviderServices;
+using Microsoft.Shared.Diagnostics;
+
+#pragma warning disable CA2100 // Review SQL queries for security vulnerabilities
+
+namespace Microsoft.SqlServer.VectorData;
+
+internal static class SqlServerCommandBuilder
+{
+ internal static List CreateTable(
+ SqlConnection connection,
+ string? schema,
+ string tableName,
+ bool ifNotExists,
+ CollectionModel model)
+ {
+ List commands = [];
+
+ StringBuilder sb = new(200);
+ if (ifNotExists)
+ {
+ sb.Append("IF OBJECT_ID(N'");
+ sb.AppendTableNameInsideLiteral(schema, tableName);
+ sb.AppendLine("', N'U') IS NULL");
+ }
+ sb.AppendLine("BEGIN");
+ sb.Append("CREATE TABLE ");
+ sb.AppendTableName(schema, tableName);
+ sb.AppendLine(" (");
+
+ var keyStoreType = Map(model.KeyProperty);
+ sb.AppendIdentifier(model.KeyProperty.StorageName).Append(' ').Append(keyStoreType);
+ if (model.KeyProperty.IsAutoGenerated)
+ {
+ switch (keyStoreType.ToUpperInvariant())
+ {
+ case "SMALLINT":
+ case "INT":
+ case "BIGINT":
+ sb.Append(" IDENTITY");
+ break;
+ case "UNIQUEIDENTIFIER":
+ sb.Append(" DEFAULT NEWSEQUENTIALID()");
+ break;
+ default:
+ throw new UnreachableException();
+ }
+ }
+
+ sb.AppendLine(",");
+
+ foreach (var property in model.DataProperties)
+ {
+ sb.AppendIdentifier(property.StorageName).Append(' ').Append(Map(property));
+ if (!property.IsNullable)
+ {
+ sb.Append(" NOT NULL");
+ }
+ sb.AppendLine(",");
+ }
+
+ foreach (var property in model.VectorProperties)
+ {
+ sb.AppendIdentifier(property.StorageName).Append(" VECTOR(").Append(property.Dimensions).Append(')');
+ if (!property.IsNullable)
+ {
+ sb.Append(" NOT NULL");
+ }
+ sb.AppendLine(",");
+ }
+
+ sb.Append("PRIMARY KEY (").AppendIdentifier(model.KeyProperty.StorageName).AppendLine(")");
+ sb.AppendLine(");"); // end the table definition
+
+ foreach (var dataProperty in model.DataProperties)
+ {
+ if (dataProperty.IsIndexed)
+ {
+ var sqlType = Map(dataProperty);
+ if (sqlType == "JSON")
+ {
+ sb.Append("CREATE JSON INDEX ");
+ }
+ else
+ {
+ sb.Append("CREATE INDEX ");
+ }
+ sb.AppendIndexName(tableName, dataProperty.StorageName);
+ sb.Append(" ON ").AppendTableName(schema, tableName);
+ sb.Append('(').AppendIdentifier(dataProperty.StorageName).AppendLine(");");
+ }
+ }
+
+ // Create full-text catalog and index for properties marked as IsFullTextIndexed
+ var fullTextProperties = new List();
+ foreach (var dataProperty in model.DataProperties)
+ {
+ if (dataProperty.IsFullTextIndexed)
+ {
+ fullTextProperties.Add(dataProperty);
+ }
+ }
+
+ if (fullTextProperties.Count > 0)
+ {
+ // Generate a unique catalog name based on the table name
+ var catalogName = $"ftcat_{tableName}".Replace(" ", "_");
+
+ // Create full-text catalog if it doesn't exist
+ sb.Append("IF NOT EXISTS (SELECT 1 FROM sys.fulltext_catalogs WHERE name = '").Append(catalogName.Replace("'", "''")).AppendLine("')");
+ sb.Append(" CREATE FULLTEXT CATALOG ").AppendIdentifier(catalogName).AppendLine(";");
+
+ // Create full-text index on the table using dynamic SQL to look up the PK constraint name
+ // Full-text indexes require a unique index (we use the primary key)
+ sb.AppendLine("DECLARE @pkIndexName NVARCHAR(128);");
+ sb.Append("SELECT @pkIndexName = name FROM sys.indexes WHERE object_id = OBJECT_ID(N'");
+ sb.AppendTableNameInsideLiteral(schema, tableName);
+ sb.AppendLine("') AND is_primary_key = 1;");
+
+ sb.AppendLine("DECLARE @ftSql NVARCHAR(MAX);");
+ sb.Append("SET @ftSql = N'CREATE FULLTEXT INDEX ON ");
+ sb.AppendTableNameInsideLiteral(schema, tableName).Append(" (");
+ for (int i = 0; i < fullTextProperties.Count; i++)
+ {
+ sb.AppendIdentifierInsideLiteral(fullTextProperties[i].StorageName);
+ if (i < fullTextProperties.Count - 1)
+ {
+ sb.Append(',');
+ }
+ }
+ sb.Append(") KEY INDEX ' + QUOTENAME(@pkIndexName) + N' ON ");
+ sb.AppendIdentifierInsideLiteral(catalogName).AppendLine("';");
+ sb.AppendLine("EXEC sp_executesql @ftSql;");
+ }
+
+ sb.Append("END;");
+
+ commands.Add(connection.CreateCommand(sb));
+
+ // CREATE VECTOR INDEX must be in a separate batch from CREATE TABLE.
+ // It is also a preview feature in SQL Server 2025, requiring PREVIEW_FEATURES to be enabled.
+ bool hasVectorIndex = false;
+ foreach (var vectorProperty in model.VectorProperties)
+ {
+ switch (vectorProperty.IndexKind)
+ {
+ case IndexKind.Flat or null or "":
+ continue;
+
+ case IndexKind.DiskAnn:
+ if (!hasVectorIndex)
+ {
+ SqlCommand enablePreview = connection.CreateCommand();
+ enablePreview.CommandText = "ALTER DATABASE SCOPED CONFIGURATION SET PREVIEW_FEATURES = ON;";
+ commands.Add(enablePreview);
+ hasVectorIndex = true;
+ }
+
+ string distanceFunction = vectorProperty.DistanceFunction ?? DistanceFunction.CosineDistance;
+ (string distanceMetric, _) = MapDistanceFunction(distanceFunction);
+
+ StringBuilder vectorIndexSb = new(200);
+ vectorIndexSb.Append("CREATE VECTOR INDEX ");
+ vectorIndexSb.AppendIndexName(tableName, vectorProperty.StorageName);
+ vectorIndexSb.Append(" ON ").AppendTableName(schema, tableName);
+ vectorIndexSb.Append('(').AppendIdentifier(vectorProperty.StorageName).Append(')');
+ vectorIndexSb.Append(" WITH (METRIC = '").Append(distanceMetric).AppendLine("', TYPE = 'DISKANN');");
+ commands.Add(connection.CreateCommand(vectorIndexSb));
+ break;
+
+ default:
+ throw new NotSupportedException($"Index kind '{vectorProperty.IndexKind}' is not supported by the SQL Server connector.");
+ }
+ }
+
+ return commands;
+ }
+
+ internal static SqlCommand DropTableIfExists(SqlConnection connection, string? schema, string tableName)
+ {
+ StringBuilder sb = new(50);
+ sb.Append("DROP TABLE IF EXISTS ");
+ sb.AppendTableName(schema, tableName);
+
+ return connection.CreateCommand(sb);
+ }
+
+ internal static SqlCommand SelectTableName(SqlConnection connection, string? schema, string tableName)
+ {
+ SqlCommand command = connection.CreateCommand();
+ command.CommandText = """
+ SELECT TABLE_NAME
+ FROM INFORMATION_SCHEMA.TABLES
+ WHERE TABLE_TYPE = 'BASE TABLE'
+ AND (@schema is NULL or TABLE_SCHEMA = @schema)
+ AND TABLE_NAME = @tableName
+ """;
+ command.Parameters.AddWithValue("@schema", string.IsNullOrEmpty(schema) ? DBNull.Value : schema);
+ command.Parameters.AddWithValue("@tableName", tableName); // the name is not escaped by us, just provided as parameter
+ return command;
+ }
+
+ internal static SqlCommand SelectTableNames(SqlConnection connection, string? schema)
+ {
+ SqlCommand command = connection.CreateCommand();
+ command.CommandText = """
+ SELECT TABLE_NAME
+ FROM INFORMATION_SCHEMA.TABLES
+ WHERE TABLE_TYPE = 'BASE TABLE'
+ AND (@schema is NULL or TABLE_SCHEMA = @schema)
+ """;
+ command.Parameters.AddWithValue("@schema", string.IsNullOrEmpty(schema) ? DBNull.Value : schema);
+ return command;
+ }
+
+ ///
+ /// Checks if the key property uses SQL Server IDENTITY (for int/bigint) as opposed to DEFAULT (for GUID).
+ /// IDENTITY columns require SET IDENTITY_INSERT ON to insert explicit values.
+ ///
+ private static bool UsesIdentity(KeyPropertyModel keyProperty)
+ {
+ if (!keyProperty.IsAutoGenerated)
+ {
+ return false;
+ }
+
+ var keyStoreType = Map(keyProperty).ToUpperInvariant();
+ return keyStoreType is "SMALLINT" or "INT" or "BIGINT";
+ }
+
+ // Note: since keys may be auto-generated, we can't use a single multi-value MERGE statement, since that would return
+ // the generated keys in undefined order (OUTPUT order is not guaranteed in MERGE).
+ // Use a batch of single-row MERGE statements instead - each returns a separate result set.
+ internal static bool Upsert(
+ SqlCommand command,
+ string? schema,
+ string tableName,
+ CollectionModel model,
+ IEnumerable