From e7c9a48287c8eb1446ccb70a8d085809ce882669 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 6 Aug 2026 23:23:48 +0700 Subject: [PATCH] fix(datagrid): quote filter values by the column's declared type Claude-Session: https://claude.ai/code/session_01PaouzGXduVq1dr5SBCgVH8 --- CHANGELOG.md | 3 + .../BigQueryPluginDriver.swift | 19 +- .../BigQueryQueryBuilder.swift | 48 +++- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 35 ++- Plugins/OracleDriverPlugin/OraclePlugin.swift | 33 ++- .../SurrealDBPluginDriver.swift | 20 +- .../SurrealQueryBuilder.swift | 53 +++- .../TableProPluginKit/PluginColumnKind.swift | 110 ++++++++ .../PluginDatabaseDriver.swift | 57 +++++ .../Core/Coordinators/FilterCoordinator.swift | 15 +- .../Coordinators/PaginationCoordinator.swift | 4 +- .../QueryExecutionCoordinator+Helpers.swift | 5 +- .../Core/Database/FilterSQLGenerator.swift | 125 ++++++--- .../ColumnType+PluginColumnKind.swift | 24 ++ .../Services/Query/TableQueryBuilder.swift | 21 +- .../Utilities/SQL/ColumnTypeSQLQuoting.swift | 51 ++++ .../Utilities/SQL/InClauseConverter.swift | 22 +- .../MainContentCoordinator+FKNavigation.swift | 1 + .../FilterSQLGeneratorColumnTypeTests.swift | 239 ++++++++++++++++++ .../Database/FilterSQLGeneratorTests.swift | 2 +- .../SQL/ColumnTypeSQLQuotingTests.swift | 116 +++++++++ 21 files changed, 908 insertions(+), 95 deletions(-) create mode 100644 Plugins/TableProPluginKit/PluginColumnKind.swift create mode 100644 TablePro/Core/Services/ColumnType+PluginColumnKind.swift create mode 100644 TablePro/Core/Utilities/SQL/ColumnTypeSQLQuoting.swift create mode 100644 TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift create mode 100644 TableProTests/Core/Utilities/SQL/ColumnTypeSQLQuotingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cad7421e..44d6384cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Exporting a query's remaining rows while disconnected now reports the error instead of leaving the progress sheet up forever. (#2026) - Stopping a query now cancels the query itself rather than whichever background metadata read finished last. (#2026) - Reopening a window no longer loses a table tab's saved sort and page when the connection was still connecting. (#2026) +- Filtering a text column by a value that looks like a number, such as 68, now compares it as text. It used to compare as a number, which returned the wrong rows and stopped the database using the column's index. (#2029) +- Typing NULL, TRUE, or FALSE into a filter on a text column now matches that text instead of turning into the SQL keyword, so those values can be filtered for. (#2029) +- IS EMPTY on a number, date, or boolean column now checks only for NULL, instead of also comparing against an empty string, which some databases reject. (#2029) ### Changed diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift index e450743f4..b25635ced 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift @@ -546,6 +546,23 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send columns: [String], limit: Int, offset: Int + ) -> String? { + buildFilteredQuery( + table: table, schema: schema, filters: filters, logicMode: logicMode, + sortColumns: sortColumns, columns: columns, limit: limit, offset: offset, columnKinds: [:] + ) + } + + func buildFilteredQuery( + table: String, + schema: String?, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int, + columnKinds: [String: PluginColumnKind] ) -> String? { let dataset: String = lock.withLock { let ds = schema ?? _currentDataset ?? "" @@ -555,7 +572,7 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send return BigQueryQueryBuilder.encodeFilteredQuery( table: table, dataset: dataset, filters: filters, logicMode: logicMode, - sortColumns: sortColumns, limit: limit, offset: offset + sortColumns: sortColumns, limit: limit, offset: offset, columnKinds: columnKinds ) } diff --git a/Plugins/BigQueryDriverPlugin/BigQueryQueryBuilder.swift b/Plugins/BigQueryDriverPlugin/BigQueryQueryBuilder.swift index 4276ed20f..64c3b3b8a 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryQueryBuilder.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryQueryBuilder.swift @@ -33,6 +33,12 @@ internal struct BigQueryFilterSpec: Codable { let column: String let op: String let value: String + var kind: String? + + var columnKind: PluginColumnKind? { + guard let kind else { return nil } + return PluginColumnKind(rawValue: kind) + } } // MARK: - Query Builder @@ -73,7 +79,8 @@ internal struct BigQueryQueryBuilder { logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], limit: Int, - offset: Int + offset: Int, + columnKinds: [String: PluginColumnKind] = [:] ) -> String { let params = BigQueryQueryParams( table: table, @@ -81,7 +88,11 @@ internal struct BigQueryQueryBuilder { sortColumns: sortColumns.map { .init(columnIndex: $0.columnIndex, ascending: $0.ascending) }, limit: limit, offset: offset, - filters: filters.map { BigQueryFilterSpec(column: $0.column, op: $0.op, value: $0.value) }, + filters: filters.map { + BigQueryFilterSpec( + column: $0.column, op: $0.op, value: $0.value, kind: columnKinds[$0.column]?.rawValue + ) + }, logicMode: logicMode, searchText: nil, searchColumns: nil @@ -250,7 +261,18 @@ internal struct BigQueryQueryBuilder { // MARK: - Private - private static func formatFilterValue(_ value: String) -> String { + private static func formatFilterValue(_ value: String, kind: PluginColumnKind?) -> String { + guard let kind else { return legacyFormatFilterValue(value) } + return PluginSQLLiteral.escapedLiteral( + value, + kind: kind, + trueLiteral: "TRUE", + falseLiteral: "FALSE", + quote: { "'\($0.replacingOccurrences(of: "'", with: "''"))'" } + ) + } + + private static func legacyFormatFilterValue(_ value: String) -> String { let lower = value.lowercased() if lower == "true" { return "TRUE" } if lower == "false" { return "FALSE" } @@ -280,26 +302,28 @@ internal struct BigQueryQueryBuilder { ) -> String? { let col = quoteIdentifier(filter.column) let escaped = filter.value.replacingOccurrences(of: "'", with: "''") + let kind = filter.columnKind + let isNullKeyword = filter.value.lowercased() == "null" && !PluginSQLLiteral.isKnownTextLike(kind) switch filter.op.uppercased() { case "=": - if filter.value.lowercased() == "null" { + if isNullKeyword { return "\(col) IS NULL" } - return "\(col) = \(formatFilterValue(filter.value))" + return "\(col) = \(formatFilterValue(filter.value, kind: kind))" case "!=", "<>": - if filter.value.lowercased() == "null" { + if isNullKeyword { return "\(col) IS NOT NULL" } - return "\(col) != \(formatFilterValue(filter.value))" + return "\(col) != \(formatFilterValue(filter.value, kind: kind))" case ">": - return "\(col) > \(formatFilterValue(filter.value))" + return "\(col) > \(formatFilterValue(filter.value, kind: kind))" case ">=": - return "\(col) >= \(formatFilterValue(filter.value))" + return "\(col) >= \(formatFilterValue(filter.value, kind: kind))" case "<": - return "\(col) < \(formatFilterValue(filter.value))" + return "\(col) < \(formatFilterValue(filter.value, kind: kind))" case "<=": - return "\(col) <= \(formatFilterValue(filter.value))" + return "\(col) <= \(formatFilterValue(filter.value, kind: kind))" case "LIKE": return "\(col) LIKE '\(escaped)'" case "NOT LIKE": @@ -307,7 +331,7 @@ internal struct BigQueryQueryBuilder { case "IN", "NOT IN": let values = filter.value.split(separator: ",").map { val in let trimmed = val.trimmingCharacters(in: .whitespaces) - return formatFilterValue(trimmed) + return formatFilterValue(trimmed, kind: kind) } return "\(col) \(filter.op.uppercased()) (\(values.joined(separator: ", ")))" case "IS NULL": diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index fdbf2c7fb..5beeb6811 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -715,12 +715,30 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columns: [String], limit: Int, offset: Int + ) -> String? { + buildFilteredQuery( + table: table, schema: schema, filters: filters, logicMode: logicMode, + sortColumns: sortColumns, columns: columns, limit: limit, offset: offset, columnKinds: [:] + ) + } + + func buildFilteredQuery( + table: String, + schema: String?, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int, + columnKinds: [String: PluginColumnKind] ) -> String? { let whereClause = PluginSQLFilter.buildWhereClause( filters: filters, logicMode: logicMode, + columnKinds: columnKinds, quoteIdentifier: mssqlQuoteIdentifier, - escapeValue: mssqlEscapeValue, + escapeTypedValue: mssqlEscapeValue, regexCondition: { quoted, value in "\(quoted) LIKE '%\(value.replacingOccurrences(of: "'", with: "''"))%'" } @@ -740,13 +758,14 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { quoteIdentifier(identifier) } - private func mssqlEscapeValue(_ value: String) -> String { - let trimmed = value.trimmingCharacters(in: .whitespaces) - if trimmed.caseInsensitiveCompare("NULL") == .orderedSame { return "NULL" } - if trimmed.caseInsensitiveCompare("TRUE") == .orderedSame { return "1" } - if trimmed.caseInsensitiveCompare("FALSE") == .orderedSame { return "0" } - if Int(trimmed) != nil || Double(trimmed) != nil { return trimmed } - return "'\(trimmed.replacingOccurrences(of: "'", with: "''"))'" + private func mssqlEscapeValue(_ value: String, kind: PluginColumnKind?) -> String { + PluginSQLLiteral.escapedLiteral( + value, + kind: kind, + trueLiteral: "1", + falseLiteral: "0", + quote: { "'\($0.replacingOccurrences(of: "'", with: "''"))'" } + ) } diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 103fbda45..b84d5a6a3 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -1217,13 +1217,31 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { columns: [String], limit: Int, offset: Int + ) -> String? { + buildFilteredQuery( + table: table, schema: schema, filters: filters, logicMode: logicMode, + sortColumns: sortColumns, columns: columns, limit: limit, offset: offset, columnKinds: [:] + ) + } + + func buildFilteredQuery( + table: String, + schema: String?, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int, + columnKinds: [String: PluginColumnKind] ) -> String? { var query = "SELECT * FROM \(oracleQualifiedName(schema: schema, table: table))" let whereClause = PluginSQLFilter.buildWhereClause( filters: filters, logicMode: logicMode, + columnKinds: columnKinds, quoteIdentifier: oracleQuoteIdentifier, - escapeValue: oracleEscapeValue, + escapeTypedValue: oracleEscapeValue, regexCondition: { quoted, value in "REGEXP_LIKE(\(quoted), '\(value.replacingOccurrences(of: "'", with: "''"))')" } @@ -1251,11 +1269,14 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\"" } - private func oracleEscapeValue(_ value: String) -> String { - let trimmed = value.trimmingCharacters(in: .whitespaces) - if trimmed.caseInsensitiveCompare("NULL") == .orderedSame { return "NULL" } - if Int(trimmed) != nil || Double(trimmed) != nil { return trimmed } - return "'\(trimmed.replacingOccurrences(of: "'", with: "''"))'" + private func oracleEscapeValue(_ value: String, kind: PluginColumnKind?) -> String { + PluginSQLLiteral.escapedLiteral( + value, + kind: kind, + trueLiteral: nil, + falseLiteral: nil, + quote: { "'\($0.replacingOccurrences(of: "'", with: "''"))'" } + ) } diff --git a/Plugins/SurrealDBDriverPlugin/SurrealDBPluginDriver.swift b/Plugins/SurrealDBDriverPlugin/SurrealDBPluginDriver.swift index 2b40ecb55..1f23850b1 100644 --- a/Plugins/SurrealDBDriverPlugin/SurrealDBPluginDriver.swift +++ b/Plugins/SurrealDBDriverPlugin/SurrealDBPluginDriver.swift @@ -243,6 +243,23 @@ final class SurrealDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { columns: [String], limit: Int, offset: Int + ) -> String? { + buildFilteredQuery( + table: table, schema: schema, filters: filters, logicMode: logicMode, + sortColumns: sortColumns, columns: columns, limit: limit, offset: offset, columnKinds: [:] + ) + } + + func buildFilteredQuery( + table: String, + schema: String?, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int, + columnKinds: [String: PluginColumnKind] ) -> String? { SurrealQueryBuilder.filtered( table: table, @@ -251,7 +268,8 @@ final class SurrealDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { logicMode: logicMode, sortColumns: Self.sorts(sortColumns, columns: columns), limit: limit, - offset: offset + offset: offset, + columnKinds: columnKinds ) } diff --git a/Plugins/SurrealDBDriverPlugin/SurrealQueryBuilder.swift b/Plugins/SurrealDBDriverPlugin/SurrealQueryBuilder.swift index eed72857b..6397b5cf8 100644 --- a/Plugins/SurrealDBDriverPlugin/SurrealQueryBuilder.swift +++ b/Plugins/SurrealDBDriverPlugin/SurrealQueryBuilder.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit public struct SurrealScope: Equatable, Sendable { public let namespace: String? @@ -45,9 +46,10 @@ public enum SurrealQueryBuilder { logicMode: String, sortColumns: [(column: String, ascending: Bool)], limit: Int, - offset: Int + offset: Int, + columnKinds: [String: PluginColumnKind] = [:] ) -> String { - let clause = whereClause(filters: filters, logicMode: logicMode) + let clause = whereClause(filters: filters, logicMode: logicMode, columnKinds: columnKinds) return compose( scope: scope, statement: select(table: table, where: clause, sortColumns: sortColumns, limit: limit, offset: offset) @@ -111,15 +113,19 @@ public enum SurrealQueryBuilder { public static func whereClause( filters: [(column: String, op: String, value: String)], - logicMode: String + logicMode: String, + columnKinds: [String: PluginColumnKind] = [:] ) -> String? { - let conditions = filters.compactMap(condition) + let conditions = filters.compactMap { condition($0, kind: columnKinds[$0.column]) } guard !conditions.isEmpty else { return nil } let separator = logicMode.lowercased() == "or" ? " OR " : " AND " return conditions.joined(separator: separator) } - private static func condition(_ filter: (column: String, op: String, value: String)) -> String? { + private static func condition( + _ filter: (column: String, op: String, value: String), + kind: PluginColumnKind? + ) -> String? { guard !filter.column.isEmpty else { return nil } let column = SurrealQL.quoteIdentifier(filter.column) let op = filter.op.uppercased().trimmingCharacters(in: .whitespaces) @@ -139,22 +145,22 @@ public enum SurrealQueryBuilder { case "ENDS WITH": return "string::ends_with( \(column), \(SurrealQL.stringLiteral(value)))" case "IN": - return "\(column) INSIDE \(listLiteral(value))" + return "\(column) INSIDE \(listLiteral(value, kind: kind))" case "NOT IN": - return "\(column) NOTINSIDE \(listLiteral(value))" + return "\(column) NOTINSIDE \(listLiteral(value, kind: kind))" case "=", "!=", ">", ">=", "<", "<=": - return "\(column) \(op) \(literal(value))" + return "\(column) \(op) \(literal(value, kind: kind))" case "LIKE": return "string::contains( \(column), \(SurrealQL.stringLiteral(unwrapWildcards(value))))" default: - return "\(column) = \(literal(value))" + return "\(column) = \(literal(value, kind: kind))" } } - private static func listLiteral(_ value: String) -> String { + private static func listLiteral(_ value: String, kind: PluginColumnKind?) -> String { let items = value .split(separator: ",") - .map { literal($0.trimmingCharacters(in: .whitespaces)) } + .map { literal($0.trimmingCharacters(in: .whitespaces), kind: kind) } return "[" + items.joined(separator: ", ") + "]" } @@ -169,6 +175,31 @@ public enum SurrealQueryBuilder { return text } + public static func literal(_ value: String, kind: PluginColumnKind?) -> String { + guard let kind else { return literal(value) } + let trimmed = value.trimmingCharacters(in: .whitespaces) + + if !PluginSQLLiteral.isKnownTextLike(kind) { + let lowered = trimmed.lowercased() + if lowered == "null" { + return "NULL" + } + if lowered == "none" { + return "NONE" + } + if lowered == "true" || lowered == "false" { + return lowered + } + if PluginSQLLiteral.isNumericLiteral(trimmed, kind: kind) { + return trimmed + } + } + if let record = recordLiteral(trimmed) { + return record + } + return SurrealQL.stringLiteral(value) + } + public static func literal(_ value: String) -> String { let trimmed = value.trimmingCharacters(in: .whitespaces) let lowered = trimmed.lowercased() diff --git a/Plugins/TableProPluginKit/PluginColumnKind.swift b/Plugins/TableProPluginKit/PluginColumnKind.swift new file mode 100644 index 000000000..e2e41451c --- /dev/null +++ b/Plugins/TableProPluginKit/PluginColumnKind.swift @@ -0,0 +1,110 @@ +// +// PluginColumnKind.swift +// TableProPluginKit +// + +import Foundation + +public enum PluginColumnKind: String, Sendable { + case text + case integer + case decimal + case boolean + case other +} + +public enum PluginBooleanSynonym: Sendable { + case isTrue + case isFalse +} + +public enum PluginSQLLiteral { + public static func isIntegerLiteral(_ value: String) -> Bool { + var iter = value.unicodeScalars.makeIterator() + guard var first = iter.next() else { return false } + if first == "-" { + guard let next = iter.next() else { return false } + first = next + } + guard first >= "0" && first <= "9" else { return false } + while let next = iter.next() { + guard next >= "0" && next <= "9" else { return false } + } + return true + } + + public static func isNumericLiteral(_ value: String, kind: PluginColumnKind?) -> Bool { + guard let kind else { + return Int(value) != nil || Double(value) != nil + } + switch kind { + case .integer: + return isIntegerLiteral(value) + case .decimal: + return PluginNumericLiteral.isValid(value) + case .text, .boolean, .other: + return false + } + } + + public static func isKnownTextLike(_ kind: PluginColumnKind?) -> Bool { + kind == .text + } + + public static func supportsEmptyStringComparison(_ kind: PluginColumnKind?) -> Bool { + guard let kind else { return true } + return kind == .text + } + + public static func booleanSynonym(for value: String) -> PluginBooleanSynonym? { + switch value.lowercased() { + case "true", "1", "yes", "on": + return .isTrue + case "false", "0", "no", "off": + return .isFalse + default: + return nil + } + } + + public static func booleanSynonym(for value: String, kind: PluginColumnKind?) -> PluginBooleanSynonym? { + guard let kind else { return legacyBooleanSynonym(for: value) } + guard kind == .boolean else { return nil } + return booleanSynonym(for: value) + } + + public static func escapedLiteral( + _ value: String, + kind: PluginColumnKind?, + trueLiteral: String?, + falseLiteral: String?, + quote: (String) -> String + ) -> String { + let trimmed = value.trimmingCharacters(in: .whitespaces) + + if !isKnownTextLike(kind), trimmed.caseInsensitiveCompare("NULL") == .orderedSame { + return "NULL" + } + + if let trueLiteral, let falseLiteral, let synonym = booleanSynonym(for: trimmed, kind: kind) { + switch synonym { + case .isTrue: + return trueLiteral + case .isFalse: + return falseLiteral + } + } + + if isNumericLiteral(trimmed, kind: kind) { + return trimmed + } + + return quote(trimmed) + } + + private static func legacyBooleanSynonym(for value: String) -> PluginBooleanSynonym? { + if value.caseInsensitiveCompare("TRUE") == .orderedSame { return .isTrue } + if value.caseInsensitiveCompare("FALSE") == .orderedSame { return .isFalse } + return nil + } +} diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index cb63349c9..dde2116fa 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -132,6 +132,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func buildFilteredQuery(table: String, filters: [(column: String, op: String, value: String)], logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? func buildBrowseQuery(table: String, schema: String?, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? func buildFilteredQuery(table: String, schema: String?, filters: [(column: String, op: String, value: String)], logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? + func buildFilteredQuery(table: String, schema: String?, filters: [(column: String, op: String, value: String)], logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int, columnKinds: [String: PluginColumnKind]) -> String? // Filtered row count (optional, for NoSQL plugins; SQL plugins use COUNT(*) WHERE) func fetchFilteredRowCount(table: String, filters: [(column: String, op: String, value: String)], logicMode: String) async throws -> Int? // User-initiated exact row count (allowed to be slow; background count caps must not apply) @@ -333,6 +334,9 @@ public extension PluginDatabaseDriver { func buildFilteredQuery(table: String, schema: String?, filters: [(column: String, op: String, value: String)], logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? { buildFilteredQuery(table: table, filters: filters, logicMode: logicMode, sortColumns: sortColumns, columns: columns, limit: limit, offset: offset) } + func buildFilteredQuery(table: String, schema: String?, filters: [(column: String, op: String, value: String)], logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int, columnKinds: [String: PluginColumnKind]) -> String? { + buildFilteredQuery(table: table, schema: schema, filters: filters, logicMode: logicMode, sortColumns: sortColumns, columns: columns, limit: limit, offset: offset) + } func fetchFilteredRowCount(table: String, filters: [(column: String, op: String, value: String)], logicMode: String) async throws -> Int? { nil } func fetchExactRowCount(table: String, schema: String?, filters: [(column: String, op: String, value: String)], logicMode: String) async throws -> Int? { try await fetchFilteredRowCount(table: table, filters: filters, logicMode: logicMode) @@ -740,4 +744,57 @@ public enum PluginSQLFilter { default: return nil } } + + public static func buildWhereClause( + filters: [(column: String, op: String, value: String)], + logicMode: String, + columnKinds: [String: PluginColumnKind], + quoteIdentifier: (String) -> String, + escapeTypedValue: (_ value: String, _ kind: PluginColumnKind?) -> String, + regexCondition: (_ quotedColumn: String, _ value: String) -> String? + ) -> String { + let conditions = filters.compactMap { filter in + buildFilterCondition( + column: filter.column, + op: filter.op, + value: filter.value, + kind: columnKinds[filter.column], + quoteIdentifier: quoteIdentifier, + escapeTypedValue: escapeTypedValue, + regexCondition: regexCondition + ) + } + guard !conditions.isEmpty else { return "" } + let separator = logicMode == "and" ? " AND " : " OR " + return conditions.joined(separator: separator) + } + + public static func buildFilterCondition( + column: String, + op: String, + value: String, + kind: PluginColumnKind?, + quoteIdentifier: (String) -> String, + escapeTypedValue: (_ value: String, _ kind: PluginColumnKind?) -> String, + regexCondition: (_ quotedColumn: String, _ value: String) -> String? + ) -> String? { + let quoted = quoteIdentifier(column) + switch op { + case "IS EMPTY": + guard PluginSQLLiteral.supportsEmptyStringComparison(kind) else { return "\(quoted) IS NULL" } + return "(\(quoted) IS NULL OR \(quoted) = '')" + case "IS NOT EMPTY": + guard PluginSQLLiteral.supportsEmptyStringComparison(kind) else { return "\(quoted) IS NOT NULL" } + return "(\(quoted) IS NOT NULL AND \(quoted) != '')" + default: + return buildFilterCondition( + column: column, + op: op, + value: value, + quoteIdentifier: quoteIdentifier, + escapeValue: { escapeTypedValue($0, kind) }, + regexCondition: regexCondition + ) + } + } } diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index 98667b351..4abecf058 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -41,6 +41,7 @@ final class FilterCoordinator { logicMode: tab.filterState.filterLogicMode, sortState: tab.sortState, columns: buffer.columns, + columnTypes: buffer.columnTypes, selectColumns: parent.selectColumns(for: tab), limit: tab.pagination.pageSize, offset: tab.pagination.currentOffset @@ -171,9 +172,9 @@ final class FilterCoordinator { let tab = parent.tabManager.tabs[tabIndex] let buffer = parent.tabSessionRegistry.tableRows(for: tab.id) let hasFilters = tab.filterState.hasAppliedFilters - let columns = buffer.columns.isEmpty - ? parent.effectiveResultColumns(for: tab) - : buffer.columns + let hasBufferedColumns = !buffer.columns.isEmpty + let columns = hasBufferedColumns ? buffer.columns : parent.effectiveResultColumns(for: tab) + let columnTypes = hasBufferedColumns ? buffer.columnTypes : [] let newQuery: String if usesBrowseSearch, tab.filterState.hasActiveBrowseSearch { @@ -197,6 +198,7 @@ final class FilterCoordinator { logicMode: tab.filterState.filterLogicMode, sortState: tab.sortState, columns: columns, + columnTypes: columnTypes, selectColumns: parent.selectColumns(for: tab), limit: tab.pagination.pageSize, offset: tab.pagination.currentOffset @@ -608,7 +610,12 @@ final class FilterCoordinator { guard let dialect = PluginManager.shared.sqlDialect(for: databaseType) else { return "-- Filters are applied natively" } - let generator = FilterSQLGenerator(dialect: dialect) + let buffer = parent.tabManager.selectedTab.map { parent.tabSessionRegistry.tableRows(for: $0.id) } + let generator = FilterSQLGenerator( + dialect: dialect, + columns: buffer?.columns ?? [], + columnTypes: buffer?.columnTypes ?? [] + ) let filtersToPreview = filtersForPreview(in: state) if filtersToPreview.isEmpty && !state.filters.isEmpty { diff --git a/TablePro/Core/Coordinators/PaginationCoordinator.swift b/TablePro/Core/Coordinators/PaginationCoordinator.swift index 86806300e..207c50469 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -161,8 +161,10 @@ final class PaginationCoordinator { let filters = tab.filterState.hasAppliedFilters ? tab.filterState.appliedFilters : [] let logicMode = tab.filterState.filterLogicMode let isNonSQL = PluginManager.shared.editorLanguage(for: parent.connection.type) != .sql + let buffer = parent.tabSessionRegistry.tableRows(for: tabId) let countSQL = isNonSQL ? nil : parent.queryBuilder.buildFilteredCountQuery( - tableName: tableName, schemaName: schemaName, filters: filters, logicMode: logicMode + tableName: tableName, schemaName: schemaName, filters: filters, logicMode: logicMode, + columns: buffer.columns, columnTypes: buffer.columnTypes ) parent.tabManager.mutate(at: index) { $0.pagination.isCountingExact = true } diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index a9fb8ee0f..5e02437f9 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -433,11 +433,14 @@ extension QueryExecutionCoordinator { threshold: AppSettingsManager.shared.dataGrid.countRowsIfEstimateLessThan ) guard case let .exactCount(filtered) = plan else { return (plan, nil, scope) } + let buffer = parent.tabSessionRegistry.tableRows(for: tabId) let sql = parent.queryBuilder.buildFilteredCountQuery( tableName: tableName, schemaName: tab.tableContext.schemaName, filters: filtered ? tab.filterState.appliedFilters : [], - logicMode: tab.filterState.filterLogicMode + logicMode: tab.filterState.filterLogicMode, + columns: buffer.columns, + columnTypes: buffer.columnTypes ) return (plan, sql, scope) } diff --git a/TablePro/Core/Database/FilterSQLGenerator.swift b/TablePro/Core/Database/FilterSQLGenerator.swift index b5d6059f3..a3858bf87 100644 --- a/TablePro/Core/Database/FilterSQLGenerator.swift +++ b/TablePro/Core/Database/FilterSQLGenerator.swift @@ -10,15 +10,33 @@ import TableProPluginKit /// Generates SQL WHERE clauses from filter definitions struct FilterSQLGenerator { + private enum RenderedLiteral { + case null + case value(String) + + var sqlText: String { + switch self { + case .null: + return "NULL" + case .value(let text): + return text + } + } + } + private let dialect: SQLDialectDescriptor private let quoteIdentifierFn: (String) -> String + private let columnTypesByName: [String: ColumnType] init( dialect: SQLDialectDescriptor, + columns: [String] = [], + columnTypes: [ColumnType] = [], quoteIdentifier: ((String) -> String)? = nil ) { self.dialect = dialect self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(dialect) + self.columnTypesByName = ColumnTypeSQLQuoting.lookupByName(columns: columns, columnTypes: columnTypes) } // MARK: - Public API @@ -47,17 +65,24 @@ struct FilterSQLGenerator { } let quotedColumn = quoteIdentifierFn(filter.columnName) + let columnType = columnTypesByName[filter.columnName] switch filter.filterOperator { case .equal: - let escaped = escapeValue(filter.value) - if escaped == "NULL" { return "\(quotedColumn) IS NULL" } - return "\(quotedColumn) = \(escaped)" + switch renderLiteral(filter.value, columnType: columnType) { + case .null: + return "\(quotedColumn) IS NULL" + case .value(let literal): + return "\(quotedColumn) = \(literal)" + } case .notEqual: - let escaped = escapeValue(filter.value) - if escaped == "NULL" { return "\(quotedColumn) IS NOT NULL" } - return "\(quotedColumn) != \(escaped)" + switch renderLiteral(filter.value, columnType: columnType) { + case .null: + return "\(quotedColumn) IS NOT NULL" + case .value(let literal): + return "\(quotedColumn) != \(literal)" + } case .contains: return generateLikeCondition(column: quotedColumn, pattern: "%\(escapeLikeWildcards(filter.value))%") @@ -72,16 +97,16 @@ struct FilterSQLGenerator { return generateLikeCondition(column: quotedColumn, pattern: "%\(escapeLikeWildcards(filter.value))") case .greaterThan: - return "\(quotedColumn) > \(escapeValue(filter.value))" + return "\(quotedColumn) > \(renderLiteral(filter.value, columnType: columnType).sqlText)" case .greaterOrEqual: - return "\(quotedColumn) >= \(escapeValue(filter.value))" + return "\(quotedColumn) >= \(renderLiteral(filter.value, columnType: columnType).sqlText)" case .lessThan: - return "\(quotedColumn) < \(escapeValue(filter.value))" + return "\(quotedColumn) < \(renderLiteral(filter.value, columnType: columnType).sqlText)" case .lessOrEqual: - return "\(quotedColumn) <= \(escapeValue(filter.value))" + return "\(quotedColumn) <= \(renderLiteral(filter.value, columnType: columnType).sqlText)" case .isNull: return "\(quotedColumn) IS NULL" @@ -90,20 +115,32 @@ struct FilterSQLGenerator { return "\(quotedColumn) IS NOT NULL" case .isEmpty: + guard ColumnTypeSQLQuoting.supportsEmptyStringComparison(columnType) else { + return "\(quotedColumn) IS NULL" + } return "(\(quotedColumn) IS NULL OR \(quotedColumn) = '')" case .isNotEmpty: + guard ColumnTypeSQLQuoting.supportsEmptyStringComparison(columnType) else { + return "\(quotedColumn) IS NOT NULL" + } return "(\(quotedColumn) IS NOT NULL AND \(quotedColumn) != '')" case .inList: - return generateInCondition(column: quotedColumn, values: filter.value, negated: false) + return generateInCondition( + column: quotedColumn, values: filter.value, columnType: columnType, negated: false + ) case .notInList: - return generateInCondition(column: quotedColumn, values: filter.value, negated: true) + return generateInCondition( + column: quotedColumn, values: filter.value, columnType: columnType, negated: true + ) case .between: guard let secondValue = filter.secondValue, !secondValue.isEmpty else { return nil } - return "\(quotedColumn) BETWEEN \(escapeValue(filter.value)) AND \(escapeValue(secondValue))" + let lower = renderLiteral(filter.value, columnType: columnType).sqlText + let upper = renderLiteral(secondValue, columnType: columnType).sqlText + return "\(quotedColumn) BETWEEN \(lower) AND \(upper)" case .regex: let syntax = dialect.regexSyntax @@ -123,17 +160,23 @@ struct FilterSQLGenerator { /// Generate IN/NOT IN with proper NULL handling. /// SQL `IN (NULL)` never matches — extract NULLs into a separate IS NULL / IS NOT NULL clause. - private func generateInCondition(column: String, values: String, negated: Bool) -> String? { + private func generateInCondition( + column: String, + values: String, + columnType: ColumnType?, + negated: Bool + ) -> String? { let parsed = parseListValues(values) guard !parsed.isEmpty else { return nil } var nonNullValues: [String] = [] var hasNull = false for item in parsed { - if item.caseInsensitiveCompare("NULL") == .orderedSame { + switch renderLiteral(item, columnType: columnType) { + case .null: hasNull = true - } else { - nonNullValues.append(escapeValue(item)) + case .value(let literal): + nonNullValues.append(literal) } } @@ -204,27 +247,48 @@ struct FilterSQLGenerator { // MARK: - Value Escaping - /// Escape a value for SQL, auto-detecting type - private func escapeValue(_ value: String) -> String { + private func renderLiteral(_ value: String, columnType: ColumnType?) -> RenderedLiteral { let trimmed = value.trimmingCharacters(in: .whitespaces) - // Check for NULL literal (case-insensitive without allocating uppercased copy) - if trimmed.caseInsensitiveCompare("NULL") == .orderedSame { - return "NULL" + if !ColumnTypeSQLQuoting.isKnownTextLike(columnType), + trimmed.caseInsensitiveCompare("NULL") == .orderedSame { + return .null } - if trimmed.caseInsensitiveCompare("TRUE") == .orderedSame { - return dialect.booleanLiteralStyle == .truefalse ? "TRUE" : "1" + if let booleanLiteral = booleanLiteral(for: trimmed, columnType: columnType) { + return .value(booleanLiteral) } - if trimmed.caseInsensitiveCompare("FALSE") == .orderedSame { - return dialect.booleanLiteralStyle == .truefalse ? "FALSE" : "0" + + if ColumnTypeSQLQuoting.isNumericLiteral(trimmed, for: columnType) { + return .value(trimmed) } - if Int(trimmed) != nil || Double(trimmed) != nil { - return trimmed + return .value("'\(escapeStringValue(trimmed))'") + } + + private func booleanLiteral(for value: String, columnType: ColumnType?) -> String? { + guard let columnType else { return legacyBooleanLiteral(for: value) } + guard columnType.isBooleanType else { return nil } + guard let synonym = ColumnTypeSQLQuoting.booleanSynonym(for: value) else { return nil } + switch synonym { + case .isTrue: + return booleanText(isTrue: true) + case .isFalse: + return booleanText(isTrue: false) } + } - return "'\(escapeStringValue(trimmed))'" + private func legacyBooleanLiteral(for value: String) -> String? { + if value.caseInsensitiveCompare("TRUE") == .orderedSame { return booleanText(isTrue: true) } + if value.caseInsensitiveCompare("FALSE") == .orderedSame { return booleanText(isTrue: false) } + return nil + } + + private func booleanText(isTrue: Bool) -> String { + if dialect.booleanLiteralStyle == .truefalse { + return isTrue ? "TRUE" : "FALSE" + } + return isTrue ? "1" : "0" } /// Escape only single quotes for SQL string literal context. @@ -309,7 +373,8 @@ extension FilterSQLGenerator { table: tableName, schema: schemaName, filters: filterTuples, logicMode: logicMode == .and ? "and" : "or", sortColumns: [], columns: [], - limit: limit, offset: 0 + limit: limit, offset: 0, + columnKinds: columnTypesByName.mapValues(\.pluginColumnKind) ) { return result } diff --git a/TablePro/Core/Services/ColumnType+PluginColumnKind.swift b/TablePro/Core/Services/ColumnType+PluginColumnKind.swift new file mode 100644 index 000000000..7371b2b59 --- /dev/null +++ b/TablePro/Core/Services/ColumnType+PluginColumnKind.swift @@ -0,0 +1,24 @@ +// +// ColumnType+PluginColumnKind.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension ColumnType { + var pluginColumnKind: PluginColumnKind { + switch self { + case .text, .enumType, .set: + return .text + case .integer: + return .integer + case .decimal: + return .decimal + case .boolean: + return .boolean + case .date, .timestamp, .datetime, .blob, .json, .spatial: + return .other + } + } +} diff --git a/TablePro/Core/Services/Query/TableQueryBuilder.swift b/TablePro/Core/Services/Query/TableQueryBuilder.swift index 5b5441edf..3de84daa5 100644 --- a/TablePro/Core/Services/Query/TableQueryBuilder.swift +++ b/TablePro/Core/Services/Query/TableQueryBuilder.swift @@ -96,6 +96,7 @@ struct TableQueryBuilder { logicMode: FilterLogicMode = .and, sortState: SortState? = nil, columns: [String] = [], + columnTypes: [ColumnType] = [], selectColumns: [String]? = nil, limit: Int = 200, offset: Int = 0 @@ -108,7 +109,8 @@ struct TableQueryBuilder { if let result = pluginDriver.buildFilteredQuery( table: tableName, schema: schemaName, filters: filterTuples, logicMode: logicMode == .and ? "and" : "or", - sortColumns: sortCols, columns: selectColumns ?? columns, limit: limit, offset: offset + sortColumns: sortCols, columns: selectColumns ?? columns, limit: limit, offset: offset, + columnKinds: pluginColumnKinds(columns: columns, columnTypes: columnTypes) ) { return result } @@ -119,7 +121,9 @@ struct TableQueryBuilder { if let dialect { let activeFilters = filters.filter { $0.isEnabled } - let filterGen = FilterSQLGenerator(dialect: dialect, quoteIdentifier: dialectQuote) + let filterGen = FilterSQLGenerator( + dialect: dialect, columns: columns, columnTypes: columnTypes, quoteIdentifier: dialectQuote + ) let whereClause = filterGen.generateWhereClause(from: activeFilters, logicMode: logicMode) if !whereClause.isEmpty { query += " \(whereClause)" @@ -174,13 +178,17 @@ struct TableQueryBuilder { tableName: String, schemaName: String? = nil, filters: [TableFilter], - logicMode: FilterLogicMode = .and + logicMode: FilterLogicMode = .and, + columns: [String] = [], + columnTypes: [ColumnType] = [] ) -> String? { guard let dialect else { return nil } let quotedTable = qualifiedTable(tableName, schema: schemaName) let activeFilters = filters.filter { $0.isEnabled } - let filterGen = FilterSQLGenerator(dialect: dialect, quoteIdentifier: dialectQuote) + let filterGen = FilterSQLGenerator( + dialect: dialect, columns: columns, columnTypes: columnTypes, quoteIdentifier: dialectQuote + ) let whereClause = filterGen.generateWhereClause(from: activeFilters, logicMode: logicMode) guard !whereClause.isEmpty else { @@ -191,6 +199,11 @@ struct TableQueryBuilder { // MARK: - Private Helpers + private func pluginColumnKinds(columns: [String], columnTypes: [ColumnType]) -> [String: PluginColumnKind] { + ColumnTypeSQLQuoting.lookupByName(columns: columns, columnTypes: columnTypes) + .mapValues(\.pluginColumnKind) + } + private func selectClause(_ selectColumns: [String]?) -> String { guard let selectColumns, !selectColumns.isEmpty else { return "*" } return selectColumns.map { quote($0) }.joined(separator: ", ") diff --git a/TablePro/Core/Utilities/SQL/ColumnTypeSQLQuoting.swift b/TablePro/Core/Utilities/SQL/ColumnTypeSQLQuoting.swift new file mode 100644 index 000000000..5c3ce4f4f --- /dev/null +++ b/TablePro/Core/Utilities/SQL/ColumnTypeSQLQuoting.swift @@ -0,0 +1,51 @@ +// +// ColumnTypeSQLQuoting.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal enum ColumnTypeSQLQuoting { + static func booleanSynonym(for value: String) -> PluginBooleanSynonym? { + PluginSQLLiteral.booleanSynonym(for: value) + } + + static func isNumericLiteral(_ value: String, for type: ColumnType?) -> Bool { + guard let type else { + return Int(value) != nil || Double(value) != nil + } + switch type { + case .integer: + return RowValueCopyFormatter.isIntegerLiteral(value) + case .decimal: + return PluginNumericLiteral.isValid(value) + case .text, .date, .timestamp, .datetime, .boolean, .blob, .json, .enumType, .set, .spatial: + return false + } + } + + static func isKnownTextLike(_ type: ColumnType?) -> Bool { + guard let type else { return false } + switch type { + case .text, .enumType, .set: + return true + case .integer, .decimal, .date, .timestamp, .datetime, .boolean, .blob, .json, .spatial: + return false + } + } + + static func supportsEmptyStringComparison(_ type: ColumnType?) -> Bool { + guard let type else { return true } + return isKnownTextLike(type) + } + + static func lookupByName(columns: [String], columnTypes: [ColumnType]) -> [String: ColumnType] { + var lookup: [String: ColumnType] = [:] + for (index, name) in columns.enumerated() where columnTypes.indices.contains(index) { + guard lookup[name] == nil else { continue } + lookup[name] = columnTypes[index] + } + return lookup + } +} diff --git a/TablePro/Core/Utilities/SQL/InClauseConverter.swift b/TablePro/Core/Utilities/SQL/InClauseConverter.swift index 351736fd8..c27a2c137 100644 --- a/TablePro/Core/Utilities/SQL/InClauseConverter.swift +++ b/TablePro/Core/Utilities/SQL/InClauseConverter.swift @@ -38,25 +38,17 @@ internal struct InClauseConverter { } private func formatScalar(_ value: String, type: ColumnType) -> String { - switch type { - case .integer: - if RowValueCopyFormatter.isIntegerLiteral(value) { return value } - return quoted(value) - case .decimal: - if Double(value) != nil { return value } - return quoted(value) - case .boolean: - switch value.lowercased() { - case "true", "1", "yes", "on": + if type.isBooleanType { + guard let synonym = ColumnTypeSQLQuoting.booleanSynonym(for: value) else { return quoted(value) } + switch synonym { + case .isTrue: return "TRUE" - case "false", "0", "no", "off": + case .isFalse: return "FALSE" - default: - return quoted(value) } - case .blob, .text, .date, .timestamp, .datetime, .json, .enumType, .set, .spatial: - return quoted(value) } + guard ColumnTypeSQLQuoting.isNumericLiteral(value, for: type) else { return quoted(value) } + return value } private func quoted(_ value: String) -> String { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index f8a494cff..d581c084c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -197,6 +197,7 @@ extension MainContentCoordinator { schemaName: schemaName, filters: [filter], columns: tableRows.columns, + columnTypes: tableRows.columnTypes, limit: pagination.pageSize, offset: pagination.currentOffset ) diff --git a/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift b/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift new file mode 100644 index 000000000..87ab85276 --- /dev/null +++ b/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift @@ -0,0 +1,239 @@ +// +// FilterSQLGeneratorColumnTypeTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing +@testable import TablePro + +@Suite("Filter SQL Generator Column Types") +struct FilterSQLGeneratorColumnTypeTests { + + private static let mysqlDialect = SQLDialectDescriptor( + identifierQuote: "`", keywords: [], functions: [], dataTypes: [], + regexSyntax: .regexp, booleanLiteralStyle: .numeric, + likeEscapeStyle: .implicit, paginationStyle: .limit + ) + + private static let postgresqlDialect = SQLDialectDescriptor( + identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], + regexSyntax: .tilde, booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, paginationStyle: .limit + ) + + private static func generator( + columns: [String], + types: [ColumnType], + dialect: SQLDialectDescriptor = mysqlDialect + ) -> FilterSQLGenerator { + FilterSQLGenerator(dialect: dialect, columns: columns, columnTypes: types) + } + + private static func condition( + column: String, + type: ColumnType?, + op: FilterOperator = .equal, + value: String, + secondValue: String? = nil, + dialect: SQLDialectDescriptor = mysqlDialect + ) -> String? { + let generator = FilterSQLGenerator( + dialect: dialect, + columns: type == nil ? [] : [column], + columnTypes: type.map { [$0] } ?? [] + ) + let filter = TestFixtures.makeTableFilter( + column: column, op: op, value: value, secondValue: secondValue + ) + return generator.generateCondition(from: filter) + } + + @Test("A numeric-looking value on a text column is quoted") + func textColumnQuotesNumericShapedValue() { + let result = Self.condition(column: "code", type: .text(rawType: "VARCHAR(20)"), value: "68") + #expect(result == "`code` = '68'") + } + + @Test("A numeric-looking value on an integer column stays unquoted") + func integerColumnKeepsNumericUnquoted() { + let result = Self.condition(column: "id", type: .integer(rawType: "INT"), value: "68") + #expect(result == "`id` = 68") + } + + @Test("A non-numeric value on an integer column is quoted") + func integerColumnQuotesNonNumericValue() { + let result = Self.condition(column: "id", type: .integer(rawType: "INT"), value: "68a") + #expect(result == "`id` = '68a'") + } + + @Test("Scientific notation is unquoted on a decimal column and quoted on an integer column") + func decimalColumnAcceptsScientificNotation() { + let decimal = Self.condition(column: "amount", type: .decimal(rawType: "DECIMAL"), value: "1e5") + let integer = Self.condition(column: "id", type: .integer(rawType: "INT"), value: "1e5") + #expect(decimal == "`amount` = 1e5") + #expect(integer == "`id` = '1e5'") + } + + @Test("An enum column quotes a numeric-looking value so it never matches by ordinal") + func enumColumnQuotesNumericShapedValue() { + let result = Self.condition( + column: "status", type: .enumType(rawType: "ENUM", values: ["draft", "1"]), value: "1" + ) + #expect(result == "`status` = '1'") + } + + @Test("A boolean column maps synonyms to the dialect boolean literal") + func booleanColumnMapsSynonyms() { + let numericStyle = Self.condition(column: "active", type: .boolean(rawType: "TINYINT(1)"), value: "yes") + let wordStyle = Self.condition( + column: "active", type: .boolean(rawType: "BOOLEAN"), value: "on", dialect: Self.postgresqlDialect + ) + #expect(numericStyle == "`active` = 1") + #expect(wordStyle == "\"active\" = TRUE") + } + + @Test("TRUE on a non-boolean column is a quoted string, not a boolean literal") + func trueLiteralOnNonBooleanColumnIsQuoted() { + let text = Self.condition(column: "code", type: .text(rawType: "VARCHAR"), value: "TRUE") + let integer = Self.condition(column: "id", type: .integer(rawType: "INT"), value: "TRUE") + #expect(text == "`code` = 'TRUE'") + #expect(integer == "`id` = 'TRUE'") + } + + @Test("NULL typed into a text column filters for the literal text") + func nullLiteralOnTextColumnIsNotPromoted() { + let result = Self.condition(column: "code", type: .text(rawType: "VARCHAR"), value: "NULL") + #expect(result == "`code` = 'NULL'") + } + + @Test("NULL typed into a non-text column still becomes IS NULL") + func nullLiteralOnNonTextColumnIsPromoted() { + let integer = Self.condition(column: "id", type: .integer(rawType: "INT"), value: "NULL") + let notEqual = Self.condition( + column: "id", type: .integer(rawType: "INT"), op: .notEqual, value: "null" + ) + #expect(integer == "`id` IS NULL") + #expect(notEqual == "`id` IS NOT NULL") + } + + @Test("An unknown column type keeps the legacy shape heuristic") + func unknownColumnTypeFallsBackToShapeHeuristic() { + let numeric = Self.condition(column: "age", type: nil, value: "42") + let null = Self.condition(column: "age", type: nil, value: "NULL") + let boolean = Self.condition(column: "age", type: nil, value: "TRUE") + #expect(numeric == "`age` = 42") + #expect(null == "`age` IS NULL") + #expect(boolean == "`age` = 1") + } + + @Test("A filter on a column missing from the result set falls back to the shape heuristic") + func unmappedColumnFallsBackToShapeHeuristic() { + let generator = Self.generator(columns: ["id"], types: [.integer(rawType: "INT")]) + let filter = TestFixtures.makeTableFilter(column: "code", op: .equal, value: "68") + #expect(generator.generateCondition(from: filter) == "`code` = 68") + } + + @Test("Comparison operators use the column type") + func comparisonOperatorsUseColumnType() { + let text = Self.condition(column: "code", type: .text(rawType: "VARCHAR"), op: .greaterThan, value: "68") + let integer = Self.condition(column: "id", type: .integer(rawType: "INT"), op: .lessOrEqual, value: "68") + #expect(text == "`code` > '68'") + #expect(integer == "`id` <= 68") + } + + @Test("BETWEEN applies the column type to both bounds") + func betweenUsesColumnTypeForBothBounds() { + let result = Self.condition( + column: "code", type: .text(rawType: "VARCHAR"), op: .between, value: "10", secondValue: "68" + ) + #expect(result == "`code` BETWEEN '10' AND '68'") + } + + @Test("IN quotes each element by the column type") + func inListQuotesEachElementByColumnType() { + let text = Self.condition(column: "code", type: .text(rawType: "VARCHAR"), op: .inList, value: "68, 70") + let integer = Self.condition(column: "id", type: .integer(rawType: "INT"), op: .inList, value: "68, 70") + #expect(text == "`code` IN ('68', '70')") + #expect(integer == "`id` IN (68, 70)") + } + + @Test("IN keeps a literal NULL string on a text column") + func inListKeepsLiteralNullOnTextColumn() { + let text = Self.condition(column: "code", type: .text(rawType: "VARCHAR"), op: .inList, value: "68, NULL") + let integer = Self.condition(column: "id", type: .integer(rawType: "INT"), op: .inList, value: "68, NULL") + #expect(text == "`code` IN ('68', 'NULL')") + #expect(integer == "(`id` IN (68) OR `id` IS NULL)") + } + + @Test("IS EMPTY drops the empty string comparison on a non-text column") + func isEmptyDropsEmptyStringComparisonOnNonTextColumn() { + let integer = Self.condition(column: "id", type: .integer(rawType: "INT"), op: .isEmpty, value: "") + let notEmpty = Self.condition(column: "id", type: .integer(rawType: "INT"), op: .isNotEmpty, value: "") + #expect(integer == "`id` IS NULL") + #expect(notEmpty == "`id` IS NOT NULL") + } + + @Test("IS EMPTY keeps the empty string comparison on a text column and when the type is unknown") + func isEmptyKeepsEmptyStringComparisonOnTextColumn() { + let text = Self.condition(column: "code", type: .text(rawType: "VARCHAR"), op: .isEmpty, value: "") + let unknown = Self.condition(column: "code", type: nil, op: .isEmpty, value: "") + #expect(text == "(`code` IS NULL OR `code` = '')") + #expect(unknown == "(`code` IS NULL OR `code` = '')") + } + + @Test("A quoted value on a text column still escapes single quotes") + func textColumnEscapesQuotes() { + let result = Self.condition(column: "code", type: .text(rawType: "VARCHAR"), value: "O'Brien") + #expect(result == "`code` = 'O''Brien'") + } + + @Test("Leading zeros stay unquoted on an integer column and quoted on a text column") + func leadingZerosFollowColumnType() { + let integer = Self.condition(column: "id", type: .integer(rawType: "INT"), value: "0068") + let text = Self.condition(column: "code", type: .text(rawType: "VARCHAR"), value: "0068") + #expect(integer == "`id` = 0068") + #expect(text == "`code` = '0068'") + } + + @Test("A date column quotes a numeric-looking value") + func dateColumnQuotesNumericShapedValue() { + let result = Self.condition(column: "created_at", type: .timestamp(rawType: "TIMESTAMP"), value: "2026") + #expect(result == "`created_at` = '2026'") + } + + @Test("The first column wins when a result set repeats a column name") + func duplicateColumnNamesResolveToTheFirst() { + let generator = Self.generator( + columns: ["code", "code"], types: [.text(rawType: "VARCHAR"), .integer(rawType: "INT")] + ) + let filter = TestFixtures.makeTableFilter(column: "code", op: .equal, value: "68") + #expect(generator.generateCondition(from: filter) == "`code` = '68'") + } + + @Test("A short columnTypes array does not misalign the remaining columns") + func shortColumnTypesArrayDegradesGracefully() { + let generator = Self.generator(columns: ["id", "code"], types: [.integer(rawType: "INT")]) + let idFilter = TestFixtures.makeTableFilter(column: "id", op: .equal, value: "68") + let codeFilter = TestFixtures.makeTableFilter(column: "code", op: .equal, value: "68") + #expect(generator.generateCondition(from: idFilter) == "`id` = 68") + #expect(generator.generateCondition(from: codeFilter) == "`code` = 68") + } + + @Test("Whitespace around a value is trimmed before the type decides quoting") + func valueIsTrimmedBeforeQuoting() { + let integer = Self.condition(column: "id", type: .integer(rawType: "INT"), value: " 68 ") + let text = Self.condition(column: "code", type: .text(rawType: "VARCHAR"), value: " 68 ") + #expect(integer == "`id` = 68") + #expect(text == "`code` = '68'") + } + + @Test("LIKE operators always quote their pattern regardless of column type") + func likeOperatorsAlwaysQuote() { + let contains = Self.condition(column: "id", type: .integer(rawType: "INT"), op: .contains, value: "68") + let startsWith = Self.condition(column: "code", type: .text(rawType: "VARCHAR"), op: .startsWith, value: "68") + #expect(contains == "`id` LIKE '%68%'") + #expect(startsWith == "`code` LIKE '68%'") + } +} diff --git a/TableProTests/Core/Database/FilterSQLGeneratorTests.swift b/TableProTests/Core/Database/FilterSQLGeneratorTests.swift index d841d93c2..46a973bc7 100644 --- a/TableProTests/Core/Database/FilterSQLGeneratorTests.swift +++ b/TableProTests/Core/Database/FilterSQLGeneratorTests.swift @@ -381,7 +381,7 @@ struct FilterSQLGeneratorTests { #expect(result == "`active` = 0") } - @Test("Numeric value generates unquoted number") + @Test("Numeric value without a known column type falls back to the shape heuristic") func testNumericValue() { let generator = FilterSQLGenerator(dialect: Self.mysqlDialect) let filter = TableFilter( diff --git a/TableProTests/Core/Utilities/SQL/ColumnTypeSQLQuotingTests.swift b/TableProTests/Core/Utilities/SQL/ColumnTypeSQLQuotingTests.swift new file mode 100644 index 000000000..6c649c566 --- /dev/null +++ b/TableProTests/Core/Utilities/SQL/ColumnTypeSQLQuotingTests.swift @@ -0,0 +1,116 @@ +// +// ColumnTypeSQLQuotingTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("Column Type SQL Quoting") +struct ColumnTypeSQLQuotingTests { + + @Test("An integer column only treats a plain integer as numeric") + func integerColumnNumericShapes() { + let expectations: [String: Bool] = [ + "68": true, "-68": true, "0068": true, + "+68": false, "68.5": false, "1e5": false, "0x1F": false, "68a": false, "": false + ] + for (value, expected) in expectations { + #expect( + ColumnTypeSQLQuoting.isNumericLiteral(value, for: .integer(rawType: "INT")) == expected, + "integer column, value \(value)" + ) + } + } + + @Test("A decimal column accepts anything Double parses") + func decimalColumnNumericShapes() { + let expectations: [String: Bool] = [ + "68": true, "68.5": true, "-68.5": true, "+68": true, "1e5": true, + "68a": false, "": false, "0x1F": false, "nan": false, "infinity": false + ] + for (value, expected) in expectations { + #expect( + ColumnTypeSQLQuoting.isNumericLiteral(value, for: .decimal(rawType: "DECIMAL")) == expected, + "decimal column, value \(value)" + ) + } + } + + @Test("Non-numeric column types never treat a value as numeric") + func nonNumericColumnTypesAreNeverNumeric() { + let types: [ColumnType] = [ + .text(rawType: "VARCHAR"), + .boolean(rawType: "BOOLEAN"), + .date(rawType: "DATE"), + .timestamp(rawType: "TIMESTAMP"), + .datetime(rawType: "DATETIME"), + .blob(rawType: "BLOB"), + .json(rawType: "JSON"), + .enumType(rawType: "ENUM", values: nil), + .set(rawType: "SET", values: nil), + .spatial(rawType: "GEOMETRY") + ] + for type in types { + #expect(ColumnTypeSQLQuoting.isNumericLiteral("68", for: type) == false) + } + } + + @Test("An unknown column type keeps the legacy shape heuristic") + func unknownColumnTypeUsesShapeHeuristic() { + #expect(ColumnTypeSQLQuoting.isNumericLiteral("68", for: nil)) + #expect(ColumnTypeSQLQuoting.isNumericLiteral("68.5", for: nil)) + #expect(ColumnTypeSQLQuoting.isNumericLiteral("1e5", for: nil)) + #expect(ColumnTypeSQLQuoting.isNumericLiteral("68a", for: nil) == false) + } + + @Test("Only text, enum and set count as text-like") + func textLikeColumnTypes() { + #expect(ColumnTypeSQLQuoting.isKnownTextLike(.text(rawType: "VARCHAR"))) + #expect(ColumnTypeSQLQuoting.isKnownTextLike(.enumType(rawType: "ENUM", values: nil))) + #expect(ColumnTypeSQLQuoting.isKnownTextLike(.set(rawType: "SET", values: nil))) + #expect(ColumnTypeSQLQuoting.isKnownTextLike(.integer(rawType: "INT")) == false) + #expect(ColumnTypeSQLQuoting.isKnownTextLike(.json(rawType: "JSON")) == false) + #expect(ColumnTypeSQLQuoting.isKnownTextLike(nil) == false) + } + + @Test("Empty string comparison applies to text-like and unknown types only") + func emptyStringComparisonSupport() { + #expect(ColumnTypeSQLQuoting.supportsEmptyStringComparison(nil)) + #expect(ColumnTypeSQLQuoting.supportsEmptyStringComparison(.text(rawType: "VARCHAR"))) + #expect(ColumnTypeSQLQuoting.supportsEmptyStringComparison(.integer(rawType: "INT")) == false) + #expect(ColumnTypeSQLQuoting.supportsEmptyStringComparison(.date(rawType: "DATE")) == false) + } + + @Test("Boolean synonyms map both spellings") + func booleanSynonyms() { + for value in ["true", "TRUE", "1", "yes", "on"] { + #expect(ColumnTypeSQLQuoting.booleanSynonym(for: value) == .isTrue) + } + for value in ["false", "FALSE", "0", "no", "off"] { + #expect(ColumnTypeSQLQuoting.booleanSynonym(for: value) == .isFalse) + } + #expect(ColumnTypeSQLQuoting.booleanSynonym(for: "68") == nil) + } + + @Test("The name lookup is index aligned and keeps the first of a repeated name") + func nameLookupAlignment() { + let lookup = ColumnTypeSQLQuoting.lookupByName( + columns: ["id", "code", "code"], + columnTypes: [.integer(rawType: "INT"), .text(rawType: "VARCHAR"), .decimal(rawType: "DECIMAL")] + ) + #expect(lookup["id"] == .integer(rawType: "INT")) + #expect(lookup["code"] == .text(rawType: "VARCHAR")) + } + + @Test("The name lookup ignores columns beyond the type array") + func nameLookupIgnoresMissingTypes() { + let lookup = ColumnTypeSQLQuoting.lookupByName( + columns: ["id", "code"], + columnTypes: [.integer(rawType: "INT")] + ) + #expect(lookup["id"] == .integer(rawType: "INT")) + #expect(lookup["code"] == nil) + } +}