Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

// Text is sent as nvarchar, and a char or varchar column read against it compares by Unicode rules even under a SQL
// collation, where `ß` equals `ss`. The column's own rules keep `Weßels` and `Wessels` apart, so a unique key can hold
// both and `=` alone matched both rows. Comparing the stored text by code point keeps the one row the value was read
// from, with trailing spaces padded as `=` pads them (measured on SQL Server 2022). Every character type gets it, since
// a result can read a varchar column through a cast to nvarchar.
public enum MSSQLRowMatch {
private static let characterTypes: Set<String> = ["char", "varchar", "text", "nchar", "nvarchar", "ntext", "sysname"]

public static func comparesText(typeName: String?) -> Bool {
guard let typeName else { return false }
let base = typeName.lowercased().prefix { $0 != "(" }.trimmingCharacters(in: .whitespaces)
return characterTypes.contains(base)
}

// `value` is the placeholder or literal the row match already compares the column with.
public static func exactText(_ quotedColumn: String, equals value: String) -> String {
"CAST(\(quotedColumn) AS NVARCHAR(MAX)) COLLATE Latin1_General_100_BIN2 = \(value)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ public struct FilterSQLGenerator: Sendable {
"\(stringLiteralPrefix)'\(literalBody(value))'"
}

// By scalar: a combining mark after the quote joins it into one Character, so `hasPrefix` misses it.
private func isStringLiteral(_ sql: String) -> Bool {
sql.unicodeScalars.starts(with: "\(stringLiteralPrefix)'".unicodeScalars)
}

// A number, a boolean or a bit has no case, and compared folded as text `5.50` would no longer equal `5.5`.
// A pattern still folds: `contains TRUE` finds PostgreSQL's `true`.
private func caseFolding(for filter: TableFilter, column: ColumnInfo?) -> PluginSQLCaseFolding {
Expand All @@ -146,7 +151,7 @@ public struct FilterSQLGenerator: Sendable {
private func comparisonCondition(
_ column: String, _ operand: String, _ operatorText: String, _ literal: String, folding: PluginSQLCaseFolding
) -> String {
guard folding.foldsComparisonOperands, literal.hasPrefix("'") || literal.hasPrefix("N'") else {
guard folding.foldsComparisonOperands, isStringLiteral(literal) else {
return "\(column) \(operatorText) \(literal)"
}
return "\(folding.fold(operand)) \(operatorText) \(folding.fold(literal))"
Expand Down Expand Up @@ -201,7 +206,7 @@ public struct FilterSQLGenerator: Sendable {
) -> String {
let keyword = negated ? "NOT IN" : "IN"
let items = listItems(rawValue).map { escapeValue($0, kind: kind) }
let foldable = folding.foldsComparisonOperands && items.allSatisfy { $0.hasPrefix("'") || $0.hasPrefix("N'") }
let foldable = folding.foldsComparisonOperands && items.allSatisfy(isStringLiteral)
let rendered = foldable ? items.map { folding.fold($0) } : items
let subject = foldable ? folding.fold(operand) : column
return "\(subject) \(keyword) (\(rendered.joined(separator: ", ")))"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation
@testable import TableProMSSQLCore
import Testing

struct MSSQLRowMatchTests {
@Test("Character columns need the exact comparison", arguments: [
"char", "varchar", "text", "VARCHAR", "varchar(20)", "char(4)", "varchar(max)", " varchar (10)",
"nchar", "nvarchar(50)", "ntext", "sysname"
])
func characterTypes(typeName: String) {
#expect(MSSQLRowMatch.comparesText(typeName: typeName))
}

// The value of a number, a date or a binary column is not the text a cast to nvarchar prints.
@Test("Other columns keep the plain comparison", arguments: [
"varbinary(max)", "int", "decimal(10,2)", "datetime", "uniqueidentifier", "xml", "sql_variant", "", "textual"
])
func otherTypes(typeName: String) {
#expect(!MSSQLRowMatch.comparesText(typeName: typeName))
}

@Test("A column with no known type keeps the plain comparison")
func unknownType() {
#expect(!MSSQLRowMatch.comparesText(typeName: nil))
}

@Test("The exact comparison reads the stored text by code point")
func exactText() {
#expect(MSSQLRowMatch.exactText("[k]", equals: "?")
== "CAST([k] AS NVARCHAR(MAX)) COLLATE Latin1_General_100_BIN2 = ?")
#expect(MSSQLRowMatch.exactText("[k]", equals: "N'Weßels'")
== "CAST([k] AS NVARCHAR(MAX)) COLLATE Latin1_General_100_BIN2 = N'Weßels'")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,17 @@ struct FilterSQLGeneratorTests {
== "WHERE CAST(\"f\" AS TEXT) ILIKE '%TRUE%' ESCAPE '!'")
}

@Test("Match Case off still folds a value that opens with a combining mark")
func leadingCombiningMarkIsFolded() {
let column = ColumnInfo(name: "c", typeName: "text")
#expect(engineClause(.postgresql, column, "\u{0301}ABC", isCaseSensitive: false, dialect: castingDialect)
== "WHERE LOWER(\"c\") = LOWER('\u{0301}ABC')")
#expect(engineClause(.postgresql, column, "\u{0301}A, B", op: .in, isCaseSensitive: false, dialect: castingDialect)
== "WHERE LOWER(\"c\") IN (LOWER('\u{0301}A'), LOWER('B'))")
#expect(engineClause(.mssql, column, "\u{0301}ABC", isCaseSensitive: false, dialect: castingDialect)
.hasSuffix("= LOWER(N'\u{0301}ABC')"))
}

@Test("A character column keeps its own operand", arguments: ["character varying", "text", "citext", "bpchar", "name"])
func characterColumnIsNotCast(typeName: String) {
let column = ColumnInfo(name: "c", typeName: typeName)
Expand Down Expand Up @@ -557,6 +568,7 @@ struct FilterSQLGeneratorTests {
#expect(FilterColumnKind.keyLiteral("05", column: nil, databaseType: .sqlite) == .text("05"))
#expect(FilterColumnKind.keyLiteral("2.5", column: nil, databaseType: .sqlite) == .numberOrText("2.5"))
}

@Test("SQL Server filters keep Unicode and backslash line endings")
func sqlServerTextConstants() {
let generator = FilterSQLGenerator(dialect: dialect, databaseType: .mssql)
Expand Down
9 changes: 9 additions & 0 deletions apple/Plugins/MSSQLDriverPlugin/MSSQLStatementGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,12 +169,21 @@ struct MSSQLStatementGenerator {
} else {
parameters.append(value)
conditions.append("\(compared) = ?")
if value.asBytes == nil, MSSQLRowMatch.comparesText(typeName: typeName(of: column)) {
parameters.append(value)
conditions.append(MSSQLRowMatch.exactText(Self.quote(column), equals: "?"))
}
}
}
guard !conditions.isEmpty else { return nil }
return (sql: conditions.joined(separator: " AND "), parameters: parameters)
}

private func typeName(of column: String) -> String? {
context.columnTypeNames[column]
?? context.columnTypeNames.first { $0.key.caseInsensitiveCompare(column) == .orderedSame }?.value
}

/// SQL Server refuses `=` on `ntext`, `text`, `image` and `xml` (Msg 402), `sql_variant` (206) and the spatial
/// types (403). Cast to the type the value is bound as, each compares exactly (measured on SQL Server 2022).
private func keylessExpression(for column: String, value: PluginCellValue) -> String {
Expand Down
15 changes: 11 additions & 4 deletions apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ nonisolated enum SQLBuilder {
return "SELECT * FROM \(quoted) \(pagination)"
}

// Every pair of a composite key is matched: one column alone can return another tenant's row.
// Every pair of a composite key is matched: one column alone can return another tenant's row. A foreign key's
// value finds its parent by the column's collation, so `abc` finds `ABC`, and is never held to the exact text.
static func buildSingleRowLookup(
table: String,
schema: String?,
Expand All @@ -67,7 +68,8 @@ nonisolated enum SQLBuilder {
driver: any DatabaseDriver
) -> String {
let quotedTable = qualifiedIdentifier(table: table, schema: schema, for: type)
let predicate = key.isEmpty ? "1 = 0" : keyPredicate(key, columns: columns, type: type, driver: driver)
let predicate = key.isEmpty
? "1 = 0" : keyPredicate(key, columns: columns, type: type, driver: driver, exactText: false)
let pagination = paginationClause(orderBy: "", limit: 1, offset: 0, for: type)
return "SELECT * FROM \(quotedTable) WHERE \(predicate) \(pagination)"
}
Expand All @@ -81,7 +83,8 @@ nonisolated enum SQLBuilder {
columns: [ColumnInfo],
type: DatabaseType,
driver: any DatabaseDriver,
uniqueIn quotedTable: String? = nil
uniqueIn quotedTable: String? = nil,
exactText: Bool = true
) -> String {
let predicate = key.map { pair in
let column = quoteIdentifier(pair.column, for: type)
Expand All @@ -90,7 +93,11 @@ nonisolated enum SQLBuilder {
case .number(let value):
return "\(column) = \(value)"
case .text(let value):
return "\(column) = \(stringLiteral(value, for: type, driver: driver))"
let literal = stringLiteral(value, for: type, driver: driver)
guard exactText, type == .mssql, MSSQLRowMatch.comparesText(typeName: info?.typeName) else {
return "\(column) = \(literal)"
}
return "\(column) = \(literal) AND \(MSSQLRowMatch.exactText(column, equals: literal))"
case .numberOrText(let value):
// CAST is the text the grid read, so a REAL another spelling of the same number parses to stays out.
return "\(column) IN (\(value), '\(value)') AND CAST(\(column) AS TEXT) = '\(value)'"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,5 +189,37 @@ struct SQLBuilderSingleRowLookupTests {
#expect(SQLBuilder.buildInsert(table: "t", schema: nil, type: .mssql, driver: sqlDriver,
columns: ["k"], values: [value]) == "INSERT INTO [t] ([k]) VALUES (" + literal + ")")
}

// Under a SQL collation a varchar key holds both `Wessels` and `Weßels`, which compare equal against N'…' text.
@Test("A SQL Server text key also matches its exact stored text")
func sqlServerNarrowKeyMatchesExactly() {
let sqlDriver = MSSQLDriver(connection: DatabaseConnection(name: "test", type: .mssql), password: nil)
let exact = "[k] = N'Weßels' AND CAST([k] AS NVARCHAR(MAX)) COLLATE Latin1_General_100_BIN2 = N'Weßels'"
let narrow = [ColumnInfo(name: "k", typeName: "varchar(20)")]
#expect(SQLBuilder.buildDelete(
table: "t", schema: "dbo", type: .mssql, driver: sqlDriver, primaryKeys: [("k", "Weßels")], columns: narrow
) == "DELETE FROM [dbo].[t] WHERE " + exact)
#expect(SQLBuilder.buildUpdate(
table: "t", schema: "dbo", type: .mssql, driver: sqlDriver,
changes: [("v", "x")], primaryKeys: [("k", "Weßels")], columns: narrow
) == "UPDATE [dbo].[t] SET [v] = N'x' WHERE " + exact)
#expect(SQLBuilder.keyPredicate([("k", "Weßels")], columns: narrow, type: .mssql, driver: sqlDriver) == exact)
let unicode = [ColumnInfo(name: "k", typeName: "nvarchar(20)")]
#expect(SQLBuilder.buildDelete(
table: "t", schema: "dbo", type: .mssql, driver: sqlDriver, primaryKeys: [("k", "Weßels")], columns: unicode
) == "DELETE FROM [dbo].[t] WHERE " + exact)
let number = [ColumnInfo(name: "k", typeName: "int")]
#expect(SQLBuilder.buildDelete(
table: "t", schema: "dbo", type: .mssql, driver: sqlDriver, primaryKeys: [("k", "5")], columns: number
) == "DELETE FROM [dbo].[t] WHERE [k] = 5")
}

// A foreign key's `abc` refers to a parent `ABC` under a case-insensitive collation.
@Test("A SQL Server foreign key lookup keeps the column's collation")
func sqlServerLookupKeepsCollation() {
let narrow = [ColumnInfo(name: "k", typeName: "varchar(20)")]
#expect(lookup([("k", "abc")], columns: narrow, type: .mssql)
== "SELECT * FROM [parent] WHERE [k] = N'abc' ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY")
}
}

1 change: 1 addition & 0 deletions apple/changelog.d/4039.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SQL Server edits on a char or varchar key could also change a row whose key differs only by characters like ß and ss.
29 changes: 29 additions & 0 deletions apple/scripts/probes/check-mssql-string-literals.sh
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,35 @@ struct Check {
}
print("PASS: \(newline.debugDescription) values, row targeting, nested names and LIKE")
}
// Under a SQL collation a varchar key holds both spellings, which compare equal against nvarchar text. A query
// that reads the key through a cast to nvarchar reports that type instead.
for (keyed, reported) in [(true, "varchar"), (false, "varchar"), (true, "nvarchar")] {
_ = try await driver.execute(query: "CREATE TABLE #narrow (k varchar(20) COLLATE SQL_Latin1_General_CP1_CI_AS"
+ (keyed ? " PRIMARY KEY" : "") + ", v int); INSERT #narrow VALUES (N'Wessels', 0), (N'Weßels', 0)")
var context = PluginRowWriteContext()
context.columnTypeNames = ["k": reported, "v": "int"]
let row: [PluginCellValue] = [.text("Weßels"), .text("0")]
let changes = [
PluginRowChange(rowIndex: 0, type: .update, cellChanges: [
(columnIndex: 1, columnName: "v", oldValue: .text("0"), newValue: .text("1"))
], originalRow: row),
PluginRowChange(rowIndex: 0, type: .delete, cellChanges: [], originalRow: [.text("Weßels"), .text("1")]),
]
for change in changes {
let writes = try driver.generateRowWrites(
table: "#narrow", schema: nil, columns: ["k", "v"], primaryKeyColumns: keyed ? ["k"] : [],
changes: [change], insertedRowData: [:], deletedRowIndices: [0], insertedRowIndices: [],
context: context
) ?? []
for write in writes {
_ = try await driver.executeParameterized(query: write.statement, parameters: write.parameters)
}
try await expect("SELECT CONVERT(varchar(10), v) FROM #narrow WHERE k = 'Wessels'", "0")
}
try await expect("SELECT CONVERT(varchar(10), COUNT(*)) FROM #narrow", "1")
_ = try await driver.execute(query: "DROP TABLE #narrow")
}
print("PASS: varchar keys edit only the row with the exact stored text")
print("PASS: \(checks) SQL Server checks through the shipped driver")
}
}
Expand Down
23 changes: 16 additions & 7 deletions core/crates/core/src/sql_syntax/history_recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,9 @@ fn runs_text_as_sql(grammar: SqlGrammar, statements: &[Vec<StatementToken<'_>>])
names_runner || statements.iter().any(|tokens| names_a_variable(grammar, tokens))
}

/// A T-SQL `@s` word, or MySQL's `@` before a name or a quoted name. A
/// `@@` system variable holds no text the server runs later.
/// A T-SQL `@s` word, or any MySQL `@` but the pair that opens a system
/// variable, which holds no text the server runs later. MySQL names a
/// user variable with a word, a number, a quoted name or a string.
fn names_a_variable(grammar: SqlGrammar, tokens: &[StatementToken<'_>]) -> bool {
tokens.iter().enumerate().any(|(at, token)| match (grammar, token) {
(SqlGrammar::MsSql, StatementToken::Word(word)) => word.starts_with('@') && !word.starts_with("@@"),
Expand All @@ -134,11 +135,7 @@ fn names_a_variable(grammar: SqlGrammar, tokens: &[StatementToken<'_>]) -> bool
.and_then(|index| tokens.get(index))
.is_some_and(|token| token.is_punct('@'))
};
!system(at.checked_sub(1))
&& !system(Some(at + 1))
&& tokens
.get(at + 1)
.is_some_and(|next| next.name().is_some() || matches!(next, StatementToken::StringLiteral(_)))
!system(at.checked_sub(1)) && !system(Some(at + 1))
}
_ => false,
})
Expand Down Expand Up @@ -619,6 +616,18 @@ mod tests {
SqlGrammar::MySql,
"SELECT 'CREATE USER app IDENTIFIED BY ''Hunter2!''' INTO @s",
),
(
SqlGrammar::MySql,
"SET @1 = 'CREATE USER app IDENTIFIED BY ''Hunter2!'''",
),
(
SqlGrammar::MySql,
"SET @.s = 'CREATE USER app IDENTIFIED BY ''Hunter2!'''",
),
(
SqlGrammar::MySql,
"SET @$s = 'CREATE USER app IDENTIFIED BY ''Hunter2!'''",
),
(
SqlGrammar::MsSql,
"DECLARE @s nvarchar(max) = N'CREATE USER app WITH PASSWORD = ''Hunter2!'''",
Expand Down
Loading
Loading