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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- iOS: a connection's Safe Mode setting now survives relaunch. iCloud sync no longer drops the value, so a connection set to Confirm Writes or Read-Only no longer reverts to Off after reopening the app.
- iOS: running a query that returns a very large result no longer crashes the app. The query editor keeps the first rows it loads, stops before memory runs low, and tells you to add LIMIT to fetch more.
- iOS: Safe Mode "Confirm Writes" now prompts before saving a row edit or inserting a row, matching the query editor. Previously grid edits and inserts saved with no confirmation.
- Redshift: schema switching now works, along with the contains, starts with, and ends with filters and table search. All previously failed with a SQL syntax error. (#1439)

## [0.45.0] - 2026-05-26

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ public struct FilterSQLGenerator: Sendable {
private var likeEscape: String {
switch dialect.likeEscapeStyle {
case .explicit:
return " ESCAPE '\\'"
return " ESCAPE '!'"
case .implicit:
return ""
}
Expand All @@ -103,11 +103,20 @@ public struct FilterSQLGenerator: Sendable {
var result = value
.replacingOccurrences(of: "'", with: "''")
.replacingOccurrences(of: "\0", with: "")
if dialect.requiresBackslashEscaping {
result = result.replacingOccurrences(of: "\\", 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: "\\_")
}
result = result.replacingOccurrences(of: "%", with: "\\%")
result = result.replacingOccurrences(of: "_", with: "\\_")
return result
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,39 @@ struct FilterSQLGeneratorTests {
#expect(result.contains("LIKE '%test%'"))
}

@Test("Explicit dialect LIKE uses a non-backslash ESCAPE clause")
func explicitLikeEscapeIsNotBackslash() {
let generator = FilterSQLGenerator(dialect: dialect)
let filter = TableFilter(columnName: "name", filterOperator: .contains, value: "50%")
let result = generator.generateWhereClause(from: [filter], logicMode: .and)
#expect(result.contains("ESCAPE '!'"))
#expect(!result.contains("ESCAPE '\\'"))
}

@Test("Explicit dialect LIKE escapes a literal exclamation mark in the value")
func explicitLikeEscapesExclamation() {
let generator = FilterSQLGenerator(dialect: dialect)
let filter = TableFilter(columnName: "name", filterOperator: .contains, value: "a!b")
let result = generator.generateWhereClause(from: [filter], logicMode: .and)
#expect(result.contains("a!!b"))
}

@Test("Implicit dialect LIKE keeps backslash escaping and no ESCAPE clause")
func implicitLikeUsesBackslash() {
let implicit = SQLDialectDescriptor(
identifierQuote: "`",
keywords: [],
functions: [],
dataTypes: [],
likeEscapeStyle: .implicit
)
let generator = FilterSQLGenerator(dialect: implicit)
let filter = TableFilter(columnName: "name", filterOperator: .contains, value: "a_b")
let result = generator.generateWhereClause(from: [filter], logicMode: .and)
#expect(result.contains("a\\_b"))
#expect(!result.contains("ESCAPE"))
}

@Test("Raw SQL filter passes through")
func rawSQLFilter() {
let generator = FilterSQLGenerator(dialect: dialect)
Expand Down
12 changes: 6 additions & 6 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ enum PostgreSQLSchemaQueries {
/// namespaces and `information_schema`.
///
/// The underscore in the `LIKE` pattern is escaped so it is matched
/// literally; without `ESCAPE '\'`, `_` would be SQL LIKE's single-char
/// wildcard and `'pg_%'` would also exclude legitimate user schemas such
/// as `pgboss`, `pgcrypto`, or `pgvector`.
/// literally; without an `ESCAPE` clause, `_` would be SQL LIKE's
/// single-char wildcard and `'pg_%'` would also exclude legitimate user
/// schemas such as `pgboss`, `pgcrypto`, or `pgvector`.
static let listSchemas = """
SELECT schema_name FROM information_schema.schemata
WHERE schema_name NOT LIKE 'pg\\_%' ESCAPE '\\'
WHERE schema_name NOT LIKE 'pg!_%' ESCAPE '!'
AND schema_name <> 'information_schema'
ORDER BY schema_name
"""
Expand All @@ -27,8 +27,8 @@ enum PostgreSQLSchemaQueries {
/// requires the connected role to hold `USAGE` on the schema.
static let listSchemasRedshift = """
SELECT nspname FROM pg_namespace
WHERE nspname NOT LIKE 'pg\\_%' ESCAPE '\\'
AND nspname <> 'information_schema'
WHERE nspname NOT LIKE 'pg!_%' ESCAPE '!'
AND nspname NOT IN ('information_schema', 'catalog_history')
AND has_schema_privilege(current_user, nspname, 'USAGE')
ORDER BY nspname
"""
Expand Down
14 changes: 7 additions & 7 deletions TablePro/Core/Database/FilterSQLGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ struct FilterSQLGenerator {
/// Explicit style: requires an ESCAPE declaration.
private var likeEscapeClause: String {
if dialect.likeEscapeStyle == .implicit { return "" }
return " ESCAPE '\\'"
return " ESCAPE '!'"
}

private func generateLikeCondition(column: String, pattern: String) -> String {
Expand Down Expand Up @@ -231,7 +231,7 @@ struct FilterSQLGenerator {
}

/// Escape only single quotes for SQL string literal context.
/// Used for LIKE patterns where backslashes are already escaped
/// 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 }
Expand All @@ -255,9 +255,8 @@ struct FilterSQLGenerator {
}

private func escapeLikeWildcards(_ value: String) -> String {
guard value.contains("\\") || value.contains("%") || value.contains("_") else { return value }

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.
Expand All @@ -266,10 +265,11 @@ struct FilterSQLGenerator {
.replacingOccurrences(of: "%", with: "\\\\%")
.replacingOccurrences(of: "_", with: "\\\\_")
}
guard value.contains("!") || value.contains("%") || value.contains("_") else { return value }
return value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "%", with: "\\%")
.replacingOccurrences(of: "_", with: "\\_")
.replacingOccurrences(of: "!", with: "!!")
.replacingOccurrences(of: "%", with: "!%")
.replacingOccurrences(of: "_", with: "!_")
}

// MARK: - Raw SQL Validation
Expand Down
17 changes: 12 additions & 5 deletions TableProMobile/TableProMobile/Helpers/SQLBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ enum SQLBuilder {

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

let conditions = columns.map { col -> String in
let quotedCol = quoteIdentifier(col.name, for: type)
Expand Down Expand Up @@ -259,11 +259,18 @@ enum SQLBuilder {
var result = value
.replacingOccurrences(of: "'", with: "''")
.replacingOccurrences(of: "\0", with: "")
if dialect.requiresBackslashEscaping {
result = result.replacingOccurrences(of: "\\", 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: "\\_")
}
result = result.replacingOccurrences(of: "%", with: "\\%")
result = result.replacingOccurrences(of: "_", with: "\\_")
return result
}

Expand Down
22 changes: 20 additions & 2 deletions TableProTests/Core/Database/FilterSQLGeneratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1008,7 +1008,7 @@ struct FilterSQLGeneratorTests {
#expect(result == "\"active\" = FALSE")
}

@Test("Redshift LIKE uses ESCAPE clause")
@Test("Redshift LIKE uses a non-backslash ESCAPE clause")
func testRedshiftLikeEscape() {
let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect)
let filter = TableFilter(
Expand All @@ -1022,7 +1022,25 @@ struct FilterSQLGeneratorTests {
rawSQL: nil
)
let result = generator.generateCondition(from: filter)
#expect(result?.contains("ESCAPE") == true)
#expect(result?.contains("ESCAPE '!'") == true)
#expect(result?.contains("ESCAPE '\\'") == false)
}

@Test("Explicit-dialect LIKE escapes a literal exclamation mark in the value")
func testExplicitLikeEscapesExclamation() {
let generator = FilterSQLGenerator(dialect: Self.postgresqlDialect)
let filter = TableFilter(
id: UUID(),
columnName: "name",
filterOperator: .contains,
value: "a!b",
secondValue: nil,
isSelected: true,
isEnabled: true,
rawSQL: nil
)
let result = generator.generateCondition(from: filter)
#expect(result?.contains("a!!b") == true)
}

@Test("Redshift AND mode with 2 filters generates AND clause")
Expand Down
11 changes: 11 additions & 0 deletions TableProTests/Plugins/PostgreSQLSchemaFilterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ struct RedshiftListSchemasTests {
}
}

@Suite("PostgreSQLSchemaQueries escape character")
struct PostgreSQLSchemaEscapeTests {
@Test("schema queries avoid the backslash escape that Redshift rejects", arguments: [
PostgreSQLSchemaQueries.listSchemas, PostgreSQLSchemaQueries.listSchemasRedshift
])
func usesNonBackslashEscape(query: String) {
#expect(!query.contains("ESCAPE '\\'"))
#expect(query.contains("ESCAPE '!'"))
}
}

private func filterRejects(_ name: String, query: String) -> Bool {
if query.contains("'\(name)'") { return true }

Expand Down
Loading