.Net: Fix single-quote escaping in OBJECT_ID and dynamic SQL string literals (#13900)

In the SQL Server MEVD provider, table/schema/column names embedded
inside SQL string literals (`N'...'`) were not escaping single quotes.
This could produce broken SQL when names contain single quotes.

For example, a table named `it's` would generate:
```sql
IF OBJECT_ID(N'[dbo].[it's]', N'U') IS NULL
```
which is invalid SQL. The fix produces:
```sql
IF OBJECT_ID(N'[dbo].[it''s]', N'U') IS NULL
```

### Changes

Added `AppendTableNameInsideLiteral` and `AppendIdentifierInsideLiteral`
helper methods that escape single quotes by doubling them, and updated
the 5 call sites where identifiers appear inside string literals:

- `OBJECT_ID` in `CreateTable` (ifNotExists check)
- `OBJECT_ID` in full-text index PK lookup
- Dynamic SQL table name, column names, and catalog name for full-text
indexes

### Tests

Added unit tests for `AppendTableNameInsideLiteral`,
`AppendIdentifierInsideLiteral`, and a
`CreateTable_WithSingleQuoteInName` integration test. All 283 tests pass
(unit + conformance).

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Shay Rojansky
2026-04-21 12:28:39 +00:00
committed by GitHub
co-authored by Copilot
parent 1b2f318a61
commit bf1b19c84c
2 changed files with 87 additions and 5 deletions
@@ -31,7 +31,7 @@ internal static class SqlServerCommandBuilder
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 class SqlServerCommandBuilder
// 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 class SqlServerCommandBuilder
return sb;
}
/// <summary>
/// Same as <see cref="AppendTableName"/>, but for use inside a SQL string literal (N'...'),
/// where single quotes must be escaped by doubling them.
/// </summary>
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;
}
/// <summary>
/// Same as <see cref="AppendIdentifier"/>, but for use inside a SQL string literal (N'...'),
/// where single quotes must be escaped by doubling them.
/// </summary>
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<PropertyModel> properties,
string? prefix = null,
@@ -27,6 +27,36 @@ public class SqlServerCommandBuilderTests
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 @@ public class SqlServerCommandBuilderTests
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()
{