diff --git a/dotnet/src/VectorData/SqlServer/SqlServerCommandBuilder.cs b/dotnet/src/VectorData/SqlServer/SqlServerCommandBuilder.cs index eb5343681b34..5ebca005225a 100644 --- a/dotnet/src/VectorData/SqlServer/SqlServerCommandBuilder.cs +++ b/dotnet/src/VectorData/SqlServer/SqlServerCommandBuilder.cs @@ -31,7 +31,7 @@ internal static List CreateTable( if (ifNotExists) { sb.Append("IF OBJECT_ID(N'"); - sb.AppendTableName(schema, tableName); + sb.AppendTableNameInsideLiteral(schema, tableName); sb.AppendLine("', N'U') IS NULL"); } sb.AppendLine("BEGIN"); @@ -125,22 +125,22 @@ internal static List CreateTable( // 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.AppendTableName(schema, tableName); + 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.AppendTableName(schema, tableName).Append(" ("); + sb.AppendTableNameInsideLiteral(schema, tableName).Append(" ("); for (int i = 0; i < fullTextProperties.Count; i++) { - sb.AppendIdentifier(fullTextProperties[i].StorageName); + sb.AppendIdentifierInsideLiteral(fullTextProperties[i].StorageName); if (i < fullTextProperties.Count - 1) { sb.Append(','); } } sb.Append(") KEY INDEX ' + QUOTENAME(@pkIndexName) + N' ON "); - sb.AppendIdentifier(catalogName).AppendLine("';"); + sb.AppendIdentifierInsideLiteral(catalogName).AppendLine("';"); sb.AppendLine("EXEC sp_executesql @ftSql;"); } @@ -897,6 +897,30 @@ internal static StringBuilder AppendIdentifier(this StringBuilder sb, string ide return sb; } + /// + /// Same as , but for use inside a SQL string literal (N'...'), + /// where single quotes must be escaped by doubling them. + /// + internal static StringBuilder AppendTableNameInsideLiteral(this StringBuilder sb, string? schema, string tableName) + { + int start = sb.Length; + sb.AppendTableName(schema, tableName); + sb.Replace("'", "''", start, sb.Length - start); + return sb; + } + + /// + /// Same as , but for use inside a SQL string literal (N'...'), + /// where single quotes must be escaped by doubling them. + /// + internal static StringBuilder AppendIdentifierInsideLiteral(this StringBuilder sb, string identifier) + { + int start = sb.Length; + sb.AppendIdentifier(identifier); + sb.Replace("'", "''", start, sb.Length - start); + return sb; + } + private static StringBuilder AppendIdentifiers(this StringBuilder sb, IEnumerable properties, string? prefix = null, diff --git a/dotnet/test/VectorData/SqlServer.ConformanceTests/SqlServerCommandBuilderTests.cs b/dotnet/test/VectorData/SqlServer.ConformanceTests/SqlServerCommandBuilderTests.cs index 40f5ddbc963e..5ed41af97471 100644 --- a/dotnet/test/VectorData/SqlServer.ConformanceTests/SqlServerCommandBuilderTests.cs +++ b/dotnet/test/VectorData/SqlServer.ConformanceTests/SqlServerCommandBuilderTests.cs @@ -27,6 +27,36 @@ public void AppendTableName(string? schema, string table, string expectedFullNam Assert.Equal(expectedFullName, result.ToString()); } + [Theory] + [InlineData("schema", "name", "[schema].[name]")] + [InlineData(null, "name", "[name]")] + [InlineData("schema", "it's", "[schema].[it''s]")] + [InlineData("it's", "name", "[it''s].[name]")] + [InlineData("it's", "it's", "[it''s].[it''s]")] + [InlineData(null, "it's", "[it''s]")] + [InlineData("schema", "[brackets]", "[schema].[[brackets]]]")] + public void AppendTableNameInsideLiteral(string? schema, string table, string expectedFullName) + { + StringBuilder result = new(); + + SqlServerCommandBuilder.AppendTableNameInsideLiteral(result, schema, table); + + Assert.Equal(expectedFullName, result.ToString()); + } + + [Theory] + [InlineData("name", "[name]")] + [InlineData("it's", "[it''s]")] + [InlineData("two''quotes", "[two''''quotes]")] + public void AppendIdentifierInsideLiteral(string identifier, string expected) + { + StringBuilder result = new(); + + SqlServerCommandBuilder.AppendIdentifierInsideLiteral(result, identifier); + + Assert.Equal(expected, result.ToString()); + } + [Theory] [InlineData("name", "@name_")] // typical name [InlineData("na me", "@na_")] // contains a whitespace, an illegal parameter name character @@ -149,6 +179,34 @@ PRIMARY KEY ([id]) Assert.Equal(expectedCommand, command.CommandText, ignoreLineEndingDifferences: true); } + [Fact] + public void CreateTable_WithSingleQuoteInName() + { + var model = BuildModel( + [ + new VectorStoreKeyProperty("id", typeof(long)), + new VectorStoreDataProperty("name", typeof(string)), + ]); + + using SqlConnection connection = CreateConnection(); + + var commands = SqlServerCommandBuilder.CreateTable(connection, "it's", "ta'ble", ifNotExists: true, model); + + var command = Assert.Single(commands); + Assert.Equal( + "IF OBJECT_ID(N'[it''s].[ta''ble]', N'U') IS NULL" + Environment.NewLine + + """ + BEGIN + CREATE TABLE [it's].[ta'ble] ( + [id] BIGINT IDENTITY, + [name] NVARCHAR(MAX), + PRIMARY KEY ([id]) + ); + END; + """, + command.CommandText, ignoreLineEndingDifferences: true); + } + [Fact] public void CreateTable_WithDiskAnnIndex() {