diff --git a/CHANGELOG.md b/CHANGELOG.md index e74fe55e1..09b2f6533 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift b/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift index 4a7e10307..d1f4ae34d 100644 --- a/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift +++ b/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift @@ -77,7 +77,7 @@ public struct FilterSQLGenerator: Sendable { private var likeEscape: String { switch dialect.likeEscapeStyle { case .explicit: - return " ESCAPE '\\'" + return " ESCAPE '!'" case .implicit: return "" } @@ -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 } diff --git a/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift b/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift index cc566b7d4..170d47a8f 100644 --- a/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift +++ b/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift @@ -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) diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift index 7fbe604d7..1b99977f2 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift @@ -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 """ @@ -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 """ diff --git a/TablePro/Core/Database/FilterSQLGenerator.swift b/TablePro/Core/Database/FilterSQLGenerator.swift index 95084f594..448e632ed 100644 --- a/TablePro/Core/Database/FilterSQLGenerator.swift +++ b/TablePro/Core/Database/FilterSQLGenerator.swift @@ -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 { @@ -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 } @@ -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. @@ -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 diff --git a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift index f59f8c27b..1408e6a4a 100644 --- a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift +++ b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -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) @@ -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 } diff --git a/TableProTests/Core/Database/FilterSQLGeneratorTests.swift b/TableProTests/Core/Database/FilterSQLGeneratorTests.swift index 5a2c5a784..0ef2c38d8 100644 --- a/TableProTests/Core/Database/FilterSQLGeneratorTests.swift +++ b/TableProTests/Core/Database/FilterSQLGeneratorTests.swift @@ -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( @@ -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") diff --git a/TableProTests/Plugins/PostgreSQLSchemaFilterTests.swift b/TableProTests/Plugins/PostgreSQLSchemaFilterTests.swift index 142d9241c..bea551d70 100644 --- a/TableProTests/Plugins/PostgreSQLSchemaFilterTests.swift +++ b/TableProTests/Plugins/PostgreSQLSchemaFilterTests.swift @@ -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 }