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
Expand Up @@ -87,13 +87,13 @@ public struct FilterSQLGenerator: Sendable {
let escapedSecond = escapeValue(filter.secondValue, kind: kind)
return "\(quotedColumn) BETWEEN \(escapedValue) AND \(escapedSecond)"
case .contains:
let pattern = escapeLikePattern(filter.value)
let pattern = likePatternBody(filter.value)
return likeCondition(operand, "'%\(pattern)%'", negated: false, folding: folding)
case .startsWith:
let pattern = escapeLikePattern(filter.value)
let pattern = likePatternBody(filter.value)
return likeCondition(operand, "'\(pattern)%'", negated: false, folding: folding)
case .endsWith:
let pattern = escapeLikePattern(filter.value)
let pattern = likePatternBody(filter.value)
return likeCondition(operand, "'%\(pattern)'", negated: false, folding: folding)
}
}
Expand Down Expand Up @@ -159,7 +159,7 @@ public struct FilterSQLGenerator: Sendable {
_ column: String, _ pattern: String, negated: Bool, folding: PluginSQLCaseFolding
) -> String {
let keyword = negated ? folding.notLikeKeyword : folding.likeKeyword
return "\(folding.foldingLikeOperand(column)) \(keyword) \(folding.foldingLikeOperand(pattern))\(likeEscape)"
return "\(folding.foldingLikeOperand(column)) \(keyword) \(folding.foldingLikeOperand(pattern))\(likeEscapeClause)"
}

private func listCondition(
Expand All @@ -178,7 +178,7 @@ public struct FilterSQLGenerator: Sendable {
return "\(subject) \(keyword) (\(rendered.joined(separator: ", ")))"
}

private var likeEscape: String {
public var likeEscapeClause: String {
switch dialect.likeEscapeStyle {
case .explicit:
return " ESCAPE '!'"
Expand All @@ -187,6 +187,17 @@ public struct FilterSQLGenerator: Sendable {
}
}

// The LIKE layer first, with the escape `likeEscapeClause` names, then the literal rule over the whole pattern.
public func likePatternBody(_ value: String) -> String {
let escape = dialect.likeEscapeStyle == .implicit ? "\\" : "!"
// `[` opens a character class in a SQL Server LIKE pattern.
let metacharacters = [escape, "%", "_"] + (databaseType == .mssql ? ["["] : [])
let pattern = metacharacters.reduce(value) { pattern, metacharacter in
pattern.replacingOccurrences(of: metacharacter, with: escape + metacharacter, options: .literal)
}
return literalBody(pattern)
}

private func quoteIdentifier(_ name: String) -> String {
if let identifierQuoter { return identifierQuoter(name) }
let open = dialect.identifierQuote
Expand All @@ -199,32 +210,15 @@ public struct FilterSQLGenerator: Sendable {
if FilterColumnKind.isBareNumber(value, kind: kind) {
return value
}
var escaped = value.replacingOccurrences(of: "\0", with: "")
if dialect.requiresBackslashEscaping {
escaped = escaped.replacingOccurrences(of: "\\", with: "\\\\")
}
escaped = escaped.replacingOccurrences(of: "'", with: "''")
return "'\(escaped)'"
return "'\(literalBody(value))'"
}

private func escapeLikePattern(_ value: String) -> String {
var result = value
.replacingOccurrences(of: "'", with: "''")
.replacingOccurrences(of: "\0", with: "")
switch dialect.likeEscapeStyle {
case .explicit:
result = result
.replacingOccurrences(of: "!", with: "!!")
.replacingOccurrences(of: "%", with: "!%")
.replacingOccurrences(of: "_", with: "!_")
case .implicit:
if dialect.requiresBackslashEscaping {
result = result.replacingOccurrences(of: "\\", with: "\\\\")
}
result = result
.replacingOccurrences(of: "%", with: "\\%")
.replacingOccurrences(of: "_", with: "\\_")
// `.literal` compares code units: the default compares grapheme clusters and skips a quote a combining mark follows.
private func literalBody(_ value: String) -> String {
var body = value.replacingOccurrences(of: "\0", with: "", options: .literal)
if dialect.requiresBackslashEscaping {
body = body.replacingOccurrences(of: "\\", with: "\\\\", options: .literal)
}
return result
return body.replacingOccurrences(of: "'", with: "''", options: .literal)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,42 @@ struct FilterSQLGeneratorTests {
#expect(clause("code", "a\\' OR 1=1 -- ", dialect: mysql) == "WHERE `code` = 'a\\\\'' OR 1=1 -- '")
}

// Snowflake takes an ESCAPE clause for LIKE and still reads `\'` inside a literal as a quote.
@Test("A LIKE pattern doubles a backslash where the engine reads it as an escape, whatever the LIKE style")
func likeDoublesBackslashForTheLiteral() {
let explicitBackslash = SQLDialectDescriptor(
identifierQuote: "\"", keywords: [], functions: [], dataTypes: [],
likeEscapeStyle: .explicit, requiresBackslashEscaping: true
)
#expect(
clause("code", #"\' OR 1=1 -- "#, op: .contains, dialect: explicitBackslash)
== #"WHERE "code" LIKE '%\\'' OR 1=1 -- %' ESCAPE '!'"#
)
}

@Test("A MySQL LIKE keeps a backslash literal through the string and the pattern")
func mysqlLikeBackslashIsLiteral() {
let mysql = makeDialect(quote: "`", backslashEscaping: true)
#expect(clause("code", #"a\b%"#, op: .contains, dialect: mysql) == #"WHERE `code` LIKE '%a\\\\b\\%%'"#)
}

@Test("A SQL Server LIKE escapes an opening bracket")
func mssqlLikeEscapesBracket() {
let generator = FilterSQLGenerator(dialect: makeDialect(quote: "["), databaseType: .mssql)
let filter = TableFilter(columnName: "code", filterOperator: .contains, value: "[a]")
#expect(generator.generateWhereClause(from: [filter], logicMode: .and) == "WHERE [code] LIKE '%![a]%' ESCAPE '!'")
}

// A combining mark joins the quote into one grapheme cluster.
@Test("A quote fused with a combining mark is still escaped")
func fusedQuoteIsEscaped() {
#expect(clause("code", "x'\u{0301} OR 1=1 --") == "WHERE \"code\" = 'x''\u{0301} OR 1=1 --'")
#expect(
clause("code", "x'\u{0301})", op: .contains)
== "WHERE \"code\" LIKE '%x''\u{0301})%' ESCAPE '!'"
)
}

@Test("A backslash stays single where the engine reads it as a character")
func backslashStaysSingle() {
#expect(clause("path", "C:\\temp") == "WHERE \"path\" = 'C:\\temp'")
Expand Down
59 changes: 17 additions & 42 deletions apple/TablePro/Core/Database/FilterSQLGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ struct FilterSQLGenerator {
case .regex:
let operand = patternOperand(quotedColumn, columnType: columnType)
guard dialect.regexSyntax != .unsupported else {
let pattern = quotedLiteral("%\(escapeSQLQuote(filter.value))%")
let pattern = quotedLiteral("%\(escapeStringValue(filter.value))%")
return "\(folding.foldingLikeOperand(operand)) \(folding.likeKeyword) "
+ folding.foldingLikeOperand(pattern)
}
Expand Down Expand Up @@ -311,9 +311,7 @@ struct FilterSQLGenerator {

// MARK: - LIKE Conditions

/// Database-specific ESCAPE clause for LIKE patterns.
/// Implicit style (MySQL/MariaDB): backslash is the default LIKE escape, no clause needed.
/// Explicit style: requires an ESCAPE declaration.
// An implicit-style engine escapes LIKE with a backslash by default and takes no clause.
private var likeEscapeClause: String {
if dialect.likeEscapeStyle == .implicit { return "" }
return " ESCAPE '!'"
Expand Down Expand Up @@ -353,7 +351,7 @@ struct FilterSQLGenerator {
negated: Bool,
folding: PluginSQLCaseFolding
) -> String {
let quotedPattern = quotedLiteral(escapeSQLQuote(pattern))
let quotedPattern = quotedLiteral(escapeStringValue(pattern))
let keyword = negated ? folding.notLikeKeyword : folding.likeKeyword
let operand = folding.foldingLikeOperand(column)
return "\(operand) \(keyword) \(folding.foldingLikeOperand(quotedPattern))\(likeEscapeClause)"
Expand Down Expand Up @@ -512,50 +510,27 @@ struct FilterSQLGenerator {
return isTrue ? "1" : "0"
}

/// Escape only single quotes for SQL string literal context.
/// Used for LIKE patterns where wildcards are already escaped
/// by escapeLikeWildcards for the ESCAPE clause.
private func escapeSQLQuote(_ value: String) -> String {
guard value.contains("'") else { return value }
return value.replacingOccurrences(of: "'", with: "''")
// Snowflake takes `ESCAPE '!'` yet reads `\'` as a quote; BigQuery and Spanner read `\` without declaring it.
private var literalsReadBackslash: Bool {
dialect.requiresBackslashEscaping || dialect.likeEscapeStyle == .implicit
}

/// Escape special characters in string values
// `.literal` compares code units: the default compares grapheme clusters and skips a quote a combining mark follows.
private func escapeStringValue(_ value: String) -> String {
// Fast path: most values have no special chars
if dialect.likeEscapeStyle == .implicit {
// MySQL/MariaDB/ClickHouse: backslash is significant in string literals
guard value.contains("\\") || value.contains("'") else { return value }
return value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "''")
} else {
// ANSI SQL: only single-quote needs escaping
guard value.contains("'") else { return value }
return value.replacingOccurrences(of: "'", with: "''")
}
let body = literalsReadBackslash
? value.replacingOccurrences(of: "\\", with: "\\\\", options: .literal)
: value
return body.replacingOccurrences(of: "'", with: "''", options: .literal)
}

// Only the LIKE layer: escapeStringValue then spells the pattern as a literal.
private func escapeLikeWildcards(_ value: String) -> String {
if dialect.likeEscapeStyle == .implicit {
guard value.contains("\\") || value.contains("%") || value.contains("_") else { return value }
// MySQL uses \ as both string escape and default LIKE escape.
// Need double backslash in SQL string so string layer yields single \
// which LIKE then uses as escape char.
return value
.replacingOccurrences(of: "\\", with: "\\\\\\\\")
.replacingOccurrences(of: "%", with: "\\\\%")
.replacingOccurrences(of: "_", with: "\\\\_")
}
let escape = dialect.likeEscapeStyle == .implicit ? "\\" : "!"
// `[` opens a character class in a SQL Server LIKE pattern.
let escapesBracket = family == .mssql
guard value.contains("!") || value.contains("%") || value.contains("_")
|| escapesBracket && value.contains("[") else { return value }
let escaped = value
.replacingOccurrences(of: "!", with: "!!")
.replacingOccurrences(of: "%", with: "!%")
.replacingOccurrences(of: "_", with: "!_")
return escapesBracket ? escaped.replacingOccurrences(of: "[", with: "![") : escaped
let metacharacters = [escape, "%", "_"] + (family == .mssql ? ["["] : [])
return metacharacters.reduce(value) { pattern, metacharacter in
pattern.replacingOccurrences(of: metacharacter, with: escape + metacharacter, options: .literal)
}
}
}

Expand Down
26 changes: 3 additions & 23 deletions apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -338,15 +338,14 @@ nonisolated enum SQLBuilder {
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !columns.isEmpty else { return "" }

let dialect = dialectDescriptor(for: type)
let pattern = escapeLikePattern(trimmed, dialect: dialect)
let likeEscape: String = dialect.likeEscapeStyle == .explicit ? " ESCAPE '!'" : ""
let generator = filterGenerator(dialect: dialectDescriptor(for: type), type: type)
let pattern = generator.likePatternBody(trimmed)

let conditions = columns.map { col -> String in
let quotedCol = quoteIdentifier(col.name, for: type)
let castExpr = searchCastExpression(quotedCol, for: type)
let likeOp = (type == .postgresql || type == .redshift) ? "ILIKE" : "LIKE"
return "\(castExpr) \(likeOp) '%\(pattern)%'\(likeEscape)"
return "\(castExpr) \(likeOp) '%\(pattern)%'\(generator.likeEscapeClause)"
}

return "(\(conditions.joined(separator: " OR ")))"
Expand All @@ -370,25 +369,6 @@ nonisolated enum SQLBuilder {
}
}

private static func escapeLikePattern(_ value: String, dialect: SQLDialectDescriptor) -> String {
var result = value
.replacingOccurrences(of: "'", with: "''")
.replacingOccurrences(of: "\0", with: "")
if dialect.likeEscapeStyle == .explicit {
result = result
.replacingOccurrences(of: "!", with: "!!")
.replacingOccurrences(of: "%", with: "!%")
.replacingOccurrences(of: "_", with: "!_")
} else {
if dialect.requiresBackslashEscaping {
result = result.replacingOccurrences(of: "\\", with: "\\\\")
}
result = result.replacingOccurrences(of: "%", with: "\\%")
result = result.replacingOccurrences(of: "_", with: "\\_")
}
return result
}

private static func buildOrderByClause(_ sortState: SortState, for type: DatabaseType) -> String {
guard sortState.isSorting else { return "" }
let clauses = sortState.columns.map { col in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,31 @@ struct SQLBuilderFilterLiteralTests {
) == "SELECT COUNT(*) FROM `files` WHERE `path` = 'C:\\\\temp\\\\'")
}

private func search(_ type: DatabaseType, _ text: String) -> String {
SQLBuilder.buildSearchCount(
table: "t", schema: nil, type: type, searchText: text, searchColumns: [ColumnInfo(name: "name", typeName: "TEXT")]
)
}

@Test("A MySQL search keeps a backslash literal through the string and the pattern")
func mysqlSearchBackslashIsLiteral() {
#expect(search(.mysql, #"a\b"#) == #"SELECT COUNT(*) FROM `t` WHERE (CAST(`name` AS CHAR) LIKE '%a\\\\b%')"#)
}

@Test("A SQL Server search escapes an opening bracket")
func mssqlSearchEscapesBracket() {
#expect(search(.mssql, "[a]") == "SELECT COUNT(*) FROM [t] WHERE (CAST([name] AS NVARCHAR(MAX)) LIKE '%![a]%' ESCAPE '!')")
}

// A combining mark joins the quote into one grapheme cluster.
@Test("A search quote fused with a combining mark is still escaped")
func searchFusedQuoteIsEscaped() {
#expect(
search(.postgresql, "x'\u{0301})")
== "SELECT COUNT(*) FROM \"t\" WHERE (CAST(\"name\" AS TEXT) ILIKE '%x''\u{0301})%' ESCAPE '!')"
)
}

// ClickHouse reads backslash escapes inside backticks, so a name ending in a backslash would leave the quote open.
@Test("A ClickHouse filter quotes its column the way the table and the writes do")
func clickHouseColumnEndingInABackslash() {
Expand Down
67 changes: 67 additions & 0 deletions apple/TableProTests/Core/Database/FilterSQLGeneratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ struct FilterSQLGeneratorTests {
offsetFetchOrderBy: ""
)

private static let snowflakeDialect = SQLDialectDescriptor(
identifierQuote: "\"", keywords: [], functions: [], dataTypes: [],
regexSyntax: .regexpLike, booleanLiteralStyle: .truefalse,
likeEscapeStyle: .explicit, paginationStyle: .limit,
requiresBackslashEscaping: true
)

// MARK: - Per-Operator Tests (MySQL)

@Test("Equal operator generates correct condition")
Expand Down Expand Up @@ -1038,4 +1045,64 @@ struct FilterSQLGeneratorTests {
let result = generator.generateCondition(from: filter)
#expect(result == "`status` IS NULL")
}

// MARK: - Literal Escaping

private func condition(
_ dialect: SQLDialectDescriptor, _ filterOperator: FilterOperator, _ value: String, column: String = "code"
) -> String? {
FilterSQLGenerator(dialect: dialect).generateCondition(from: TableFilter(
columnName: column, filterOperator: filterOperator, value: value, isCaseSensitive: true
))
}

// Snowflake takes an ESCAPE clause for LIKE and still reads `\'` inside a literal as a quote.
@Test("A Snowflake filter value doubles its backslash, whatever the operator", arguments: [
(FilterOperator.equal, #""CODE" = '\\''; DROP TABLE AUDIT_LOG; --'"#),
(.inList, #""CODE" IN ('\\''; DROP TABLE AUDIT_LOG; --')"#),
(.contains, #""CODE" LIKE '%\\''; DROP TABLE AUDIT!_LOG; --%' ESCAPE '!'"#),
(.startsWith, #""CODE" LIKE '\\''; DROP TABLE AUDIT!_LOG; --%' ESCAPE '!'"#),
(.regex, #"REGEXP_LIKE("CODE", '\\''; DROP TABLE AUDIT_LOG; --')"#)
])
func snowflakeDoublesBackslash(filterOperator: FilterOperator, expected: String) {
#expect(condition(Self.snowflakeDialect, filterOperator, #"\'; DROP TABLE AUDIT_LOG; --"#, column: "CODE") == expected)
}

@Test("A Snowflake LIKE pattern reads a backslash as a character, not as its escape")
func snowflakeLikeBackslashIsLiteral() {
#expect(condition(Self.snowflakeDialect, .endsWith, #"C:\temp\"#) == #""code" LIKE '%C:\\temp\\' ESCAPE '!'"#)
}

@Test("The LIKE a regex falls back to doubles a backslash where the engine reads it as an escape")
func regexFallbackDoublesBackslash() {
let implicitWithoutRegex = SQLDialectDescriptor(
identifierQuote: "`", keywords: [], functions: [], dataTypes: [],
regexSyntax: .unsupported, booleanLiteralStyle: .truefalse,
likeEscapeStyle: .implicit, paginationStyle: .limit
)
#expect(condition(implicitWithoutRegex, .regex, #"\' OR 1=1 -- "#) == #"`code` LIKE '%\\'' OR 1=1 -- %'"#)
#expect(condition(Self.sqliteDialect, .regex, #"\' OR 1=1 -- "#) == #"`code` LIKE '%\'' OR 1=1 -- %'"#)
}

@Test("A MySQL LIKE keeps a backslash literal through the string and the pattern")
func mysqlLikeBackslash() {
#expect(condition(Self.mysqlDialect, .contains, #"a\b%"#) == #"`code` LIKE '%a\\\\b\\%%'"#)
}

// A combining mark or a Prepend scalar joins the quote or backslash into one grapheme cluster.
@Test("A quote or backslash fused with its neighbour is still escaped", arguments: [
("x'\u{0301} OR 1=1 --", #"`code` = 'x''"# + "\u{0301}" + #" OR 1=1 --'"#),
("\u{0600}\\' OR 1=1 --", "`code` = '\u{0600}" + #"\\'' OR 1=1 --'"#)
])
func fusedQuoteIsEscaped(value: String, expected: String) {
#expect(condition(Self.mysqlDialect, .equal, value) == expected)
}

@Test("A quote fused with a combining mark is escaped in a LIKE pattern")
func fusedQuoteInLikeIsEscaped() {
#expect(
condition(Self.postgresqlDialect, .contains, "x'\u{0301})")
== "\"code\" LIKE '%x''\u{0301})%' ESCAPE '!'"
)
}
}
1 change: 1 addition & 0 deletions apple/changelog.d/3906.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
On iOS, a search or filter with a backslash on MySQL or ClickHouse, or with `[` on SQL Server, matched the wrong rows.
1 change: 1 addition & 0 deletions apple/changelog.d/3906.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A backslash in a Snowflake filter value could end its string literal and run the rest as SQL.
Loading