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

Filter by extension

Filter by extension

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

// Unknown types fall to `.text`: `int_col = '5'` still matches everywhere, `text_col = 02134` finds the wrong row.
nonisolated public enum FilterColumnKind {
public static func kind(forTypeName typeName: String) -> PluginColumnKind {
let base = typeName.lowercased().prefix { $0 != "(" }
let words = base.split(whereSeparator: \.isWhitespace).filter { $0 != "unsigned" }
// PostgreSQL's `bit varying` is a bit string, and `= 101` has no operator there.
guard let first = words.first.map(String.init), !words.contains("varying") else { return .text }
if integerNames.contains(first) { return .integer }
if decimalNames.contains(first) { return .decimal }
return .text
}

public static func column(named name: String, in columns: [ColumnInfo]) -> ColumnInfo? {
if let exact = columns.first(where: { $0.name == name }) { return exact }
let folded = columns.filter { $0.name.caseInsensitiveCompare(name) == .orderedSame }
return folded.count == 1 ? folded.first : nil
}

// A SQLite column with no affinity (typeless, any BLOB, ANY in a STRICT table) does not match a
// stored 5 to `= '5'`: keep the untyped rule there.
public static func kind(of column: ColumnInfo?) -> PluginColumnKind? {
guard let column else { return nil }
let typeName = column.typeName.trimmingCharacters(in: .whitespaces).lowercased()
guard !typeName.isEmpty, !typeName.contains("blob"), typeName != "any" else { return nil }
return kind(forTypeName: typeName)
}

private static let integerNames: Set<String> = [
"int", "integer", "tinyint", "smallint", "mediumint", "bigint",
"int1", "int2", "int3", "int4", "int8",
"serial", "smallserial", "bigserial",
"hugeint", "uhugeint", "utinyint", "usmallint", "uinteger", "ubigint",
"bit", "bool", "boolean"
]

private static let decimalNames: Set<String> = [
"decimal", "numeric", "number", "real", "float", "double",
"float4", "float8", "binary_float", "binary_double"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,28 @@ public struct FilterSQLGenerator: Sendable {

public func generateWhereClause(
from filters: [TableFilter],
logicMode: FilterLogicMode
logicMode: FilterLogicMode,
columns: [ColumnInfo] = []
) -> String {
let activeFilters = filters.filter { $0.isEnabled && $0.isValid }
guard !activeFilters.isEmpty else { return "" }

let conditions = activeFilters.compactMap { generateCondition(for: $0) }
let conditions = activeFilters.compactMap { generateCondition(for: $0, columns: columns) }
guard !conditions.isEmpty else { return "" }

let joined = conditions.joined(separator: " \(logicMode.rawValue) ")
return "WHERE \(joined)"
}

private func generateCondition(for filter: TableFilter) -> String? {
private func generateCondition(for filter: TableFilter, columns: [ColumnInfo]) -> String? {
if filter.columnName == TableFilter.rawSQLColumn {
guard let rawSQL = filter.rawSQL, !rawSQL.isEmpty else { return nil }
return rawSQL
}

let quotedColumn = quoteIdentifier(filter.columnName)
let escapedValue = escapeValue(filter.value)
let kind = FilterColumnKind.kind(of: FilterColumnKind.column(named: filter.columnName, in: columns))
let escapedValue = escapeValue(filter.value, kind: kind)
let folding = caseFolding(for: filter)

switch filter.filterOperator {
Expand All @@ -55,11 +57,11 @@ public struct FilterSQLGenerator: Sendable {
case .isNotNull:
return "\(quotedColumn) IS NOT NULL"
case .in:
return listCondition(quotedColumn, filter.value, negated: false, folding: folding)
return listCondition(quotedColumn, filter.value, kind: kind, negated: false, folding: folding)
case .notIn:
return listCondition(quotedColumn, filter.value, negated: true, folding: folding)
return listCondition(quotedColumn, filter.value, kind: kind, negated: true, folding: folding)
case .between:
let escapedSecond = escapeValue(filter.secondValue)
let escapedSecond = escapeValue(filter.secondValue, kind: kind)
return "\(quotedColumn) BETWEEN \(escapedValue) AND \(escapedSecond)"
case .contains:
let pattern = escapeLikePattern(filter.value)
Expand Down Expand Up @@ -98,11 +100,11 @@ public struct FilterSQLGenerator: Sendable {
}

private func listCondition(
_ column: String, _ rawValue: String, negated: Bool, folding: PluginSQLCaseFolding
_ column: String, _ rawValue: String, kind: PluginColumnKind?, negated: Bool, folding: PluginSQLCaseFolding
) -> String {
let keyword = negated ? "NOT IN" : "IN"
let items = rawValue.split(separator: ",")
.map { escapeValue($0.trimmingCharacters(in: .whitespaces)) }
.map { escapeValue($0.trimmingCharacters(in: .whitespaces), kind: kind) }
let foldable = folding.foldsComparisonOperands && items.allSatisfy { $0.hasPrefix("'") }
let rendered = foldable ? items.map { folding.fold($0) } : items
let subject = foldable ? folding.fold(column) : column
Expand All @@ -119,21 +121,31 @@ public struct FilterSQLGenerator: Sendable {
}

private func quoteIdentifier(_ name: String) -> String {
let q = dialect.identifierQuote
let escaped = name.replacingOccurrences(of: q, with: "\(q)\(q)")
return "\(q)\(escaped)\(q)"
let open = dialect.identifierQuote
let close = open == "[" ? "]" : open
let escaped = name.replacingOccurrences(of: close, with: "\(close)\(close)")
return "\(open)\(escaped)\(close)"
}

private func escapeValue(_ value: String) -> String {
if Int64(value) != nil || Double(value) != nil {
private func escapeValue(_ value: String, kind: PluginColumnKind?) -> String {
if isBareNumber(value, kind: kind) {
return value
}
let escaped = value
.replacingOccurrences(of: "'", with: "''")
.replacingOccurrences(of: "\0", with: "")
var escaped = value.replacingOccurrences(of: "\0", with: "")
if dialect.requiresBackslashEscaping {
escaped = escaped.replacingOccurrences(of: "\\", with: "\\\\")
}
escaped = escaped.replacingOccurrences(of: "'", with: "''")
return "'\(escaped)'"
}

// Character.isNumber takes digits of every script, and no engine parses those as a number.
// An integer column takes a fraction bare too: PostgreSQL and SQL Server reject `int_col > '5.5'`.
private func isBareNumber(_ value: String, kind: PluginColumnKind?) -> Bool {
guard value.unicodeScalars.allSatisfy(\.isASCII) else { return false }
return kind != .text && PluginNumericLiteral.isValid(value)
}

private func escapeLikePattern(_ value: String) -> String {
var result = value
.replacingOccurrences(of: "'", with: "''")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import Foundation
@testable import TableProQuery
import Testing

@Suite("FilterSQLGenerator Tests")
struct FilterSQLGeneratorTests {
private var dialect: SQLDialectDescriptor {
SQLDialectDescriptor(
Expand Down Expand Up @@ -156,4 +155,164 @@ struct FilterSQLGeneratorTests {
#expect(result.contains("'active'"))
#expect(result.contains("'pending'"))
}

private func makeDialect(quote: String, backslashEscaping: Bool = false) -> SQLDialectDescriptor {
SQLDialectDescriptor(
identifierQuote: quote,
keywords: [],
functions: [],
dataTypes: [],
likeEscapeStyle: backslashEscaping ? .implicit : .explicit,
requiresBackslashEscaping: backslashEscaping
)
}

private func clause(
_ column: String,
_ value: String,
op filterOperator: FilterOperator = .equal,
columns: [ColumnInfo] = [],
dialect: SQLDialectDescriptor? = nil
) -> String {
FilterSQLGenerator(dialect: dialect ?? self.dialect).generateWhereClause(
from: [TableFilter(columnName: column, filterOperator: filterOperator, value: value)],
logicMode: .and,
columns: columns
)
}

@Test("A bracket-quoted identifier closes with a bracket")
func bracketIdentifierCloses() {
#expect(clause("id", "5", dialect: makeDialect(quote: "[")) == "WHERE [id] = 5")
}

@Test("A closing bracket inside a bracket-quoted identifier is doubled")
func bracketInsideIdentifierIsDoubled() {
#expect(clause("a]b", "5", dialect: makeDialect(quote: "[")) == "WHERE [a]]b] = 5")
}

@Test("A backslash is doubled where the engine reads it as an escape")
func backslashIsDoubled() {
let mysql = makeDialect(quote: "`", backslashEscaping: true)
#expect(clause("path", "C:\\temp\\", dialect: mysql) == "WHERE `path` = 'C:\\\\temp\\\\'")
#expect(clause("code", "a\\' OR 1=1 -- ", dialect: mysql) == "WHERE `code` = 'a\\\\'' OR 1=1 -- '")
}

@Test("A backslash stays single where the engine reads it as a character")
func backslashStaysSingle() {
#expect(clause("path", "C:\\temp") == "WHERE \"path\" = 'C:\\temp'")
}

@Test("A number-like value against a text column is quoted")
func numberLikeTextIsQuoted() {
let columns = [ColumnInfo(name: "code", typeName: "varchar(10)")]
#expect(clause("code", "02134", columns: columns) == "WHERE \"code\" = '02134'")
}

@Test("A filter column spelled in another case still finds its type")
func caseFoldedColumnFindsType() {
let columns = [ColumnInfo(name: "code", typeName: "TEXT")]
#expect(clause("CODE", "02134", columns: columns) == "WHERE \"CODE\" = '02134'")
}

@Test("Two case-insensitive matches resolve no column, so the untyped rule applies")
func ambiguousCaseFoldedColumn() {
let columns = [ColumnInfo(name: "Code", typeName: "TEXT"), ColumnInfo(name: "CODE", typeName: "TEXT")]
#expect(FilterColumnKind.column(named: "code", in: columns)?.name == nil)
#expect(clause("code", "02134", columns: columns) == "WHERE \"code\" = 02134")
}

@Test("An exact name wins over a case-insensitive one")
func exactNameWins() {
let columns = [ColumnInfo(name: "code", typeName: "TEXT"), ColumnInfo(name: "CODE", typeName: "integer")]
#expect(clause("CODE", "5", columns: columns) == "WHERE \"CODE\" = 5")
}

@Test("Types outside the numeric allowlist are quoted", arguments: ["interval", "point", "uuid", "money"])
func unlistedTypeIsQuoted(typeName: String) {
let columns = [ColumnInfo(name: "v", typeName: typeName)]
#expect(clause("v", "5", columns: columns) == "WHERE \"v\" = '5'")
}

@Test("Integer and decimal types stay bare", arguments: ["bit", "tinyint(1)", "NUMBER(10)", "int(11) unsigned", "double precision"])
func numericTypeStaysBare(typeName: String) {
let columns = [ColumnInfo(name: "v", typeName: typeName)]
#expect(clause("v", "1", columns: columns) == "WHERE \"v\" = 1")
}

@Test("A greater-than against an INT column stays bare")
func intGreaterThanStaysBare() {
let columns = [ColumnInfo(name: "age", typeName: "INT")]
#expect(clause("age", "25", op: .greaterThan, columns: columns) == "WHERE \"age\" > 25")
}

@Test("A fraction against an integer column stays bare, since PostgreSQL rejects bigint > '5.5'")
func fractionAgainstIntegerStaysBare() {
let columns = [ColumnInfo(name: "id", typeName: "bigint")]
#expect(clause("id", "5.5", op: .greaterThan, columns: columns) == "WHERE \"id\" > 5.5")
}

@Test("A typeless column keeps the untyped rule")
func typelessColumnStaysUntyped() {
let columns = [ColumnInfo(name: "v", typeName: "")]
#expect(clause("v", "5", columns: columns) == "WHERE \"v\" = 5")
}

@Test("A column without SQLite affinity keeps the untyped rule", arguments: ["BLOB", "longblob", "ANY"])
func noAffinityColumnStaysUntyped(typeName: String) {
let columns = [ColumnInfo(name: "v", typeName: typeName)]
#expect(clause("v", "5", columns: columns) == "WHERE \"v\" = 5")
}

@Test("Values Swift parses as a number but SQL does not are quoted", arguments: ["nan", "inf", "Infinity", "0x1F"])
func swiftOnlyNumbersAreQuoted(value: String) {
#expect(clause("v", value) == "WHERE \"v\" = '\(value)'")
let columns = [ColumnInfo(name: "v", typeName: "double")]
#expect(clause("v", value, columns: columns) == "WHERE \"v\" = '\(value)'")
}

@Test("Digits of another script are quoted")
func nonASCIIDigitsAreQuoted() {
#expect(clause("v", "\u{0663}") == "WHERE \"v\" = '\u{0663}'")
}

@Test("Every IN item and both BETWEEN bounds follow the column's type")
func listAndRangeFollowType() {
let columns = [ColumnInfo(name: "code", typeName: "varchar")]
#expect(clause("code", "01, 02", op: .in, columns: columns) == "WHERE \"code\" IN ('01', '02')")
let range = FilterSQLGenerator(dialect: dialect).generateWhereClause(
from: [TableFilter(columnName: "code", filterOperator: .between, value: "01", secondValue: "09")],
logicMode: .and,
columns: columns
)
#expect(range == "WHERE \"code\" BETWEEN '01' AND '09'")
}

@Test("Type names map to a column kind", arguments: [
("INTEGER", PluginColumnKind.integer),
("int4", .integer),
("bigint(20) unsigned zerofill", .integer),
("UNSIGNED INTEGER", .integer),
("tinyint(1)", .integer),
("bit", .integer),
("boolean", .integer),
("bool", .integer),
("NUMBER(10)", .decimal),
("numeric(10,2)", .decimal),
("double precision", .decimal),
("REAL", .decimal),
("float8", .decimal),
("DECIMAL(18,3)", .decimal),
("varchar(255)", .text),
("character varying", .text),
("TEXT", .text),
("interval", .text),
("point", .text),
("bit varying", .text),
("BIT VARYING(8)", .text),
("", .text)
])
func typeNameKind(typeName: String, kind: PluginColumnKind) {
#expect(FilterColumnKind.kind(forTypeName: typeName) == kind)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,8 @@ nonisolated final class MSSQLDriver: DatabaseDriver, @unchecked Sendable {
return result.rows.compactMap { row in
MSSQLSchemaQueries.parseForeignKeyRow(row).map {
ForeignKeyInfo(name: $0.constraintName, column: $0.columnName,
referencedTable: $0.referencedTable, referencedColumn: $0.referencedColumn)
referencedTable: $0.referencedTable, referencedColumn: $0.referencedColumn,
referencedSchema: $0.referencedSchema)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,14 @@ nonisolated internal enum MySQLColumnListing {
return .quotedCatalog
}

/// The catalog read for one table's defaults, or nil when `SHOW FULL COLUMNS` is the only source.
///
/// It filters on `DATABASE()` because the unqualified `SHOW FULL COLUMNS` reads the session's
/// database, and the two answers are matched by column name alone.
static func catalogDefaultsQuery(table: String, source: DefaultSource) -> String? {
// Its rows are matched to SHOW FULL COLUMNS by column name alone, so both read the same database.
static func catalogDefaultsQuery(table: String, database: String?, source: DefaultSource) -> String? {
switch source {
case .quotedCatalog, .oceanBaseCatalog:
return """
SELECT COLUMN_NAME, COLUMN_DEFAULT FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '\(SQLEscaping.backslashStringLiteral(table))'
WHERE TABLE_SCHEMA = \(MySQLDriver.databaseLiteral(database))
AND TABLE_NAME = '\(SQLEscaping.backslashStringLiteral(table))'
"""
case .showFullColumns, .asReported:
return nil
Expand Down
Loading
Loading