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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ public struct DatabaseType: Hashable, Codable, Sendable, RawRepresentable {
}
}

// These engines run SQLite itself, so they share its grammar and its storage classes. DuckDB, which PluginKit's
// SqlDialect files under SQLite, types and compares values its own way.
public var isSQLiteFamily: Bool {
switch self {
case .sqlite, .cloudflareD1, .libsql, .turso: return true
default: return false
}
}

public var pluginTypeId: String {
switch self {
case .mariadb, .tidb, .databend, .oceanbase: return DatabaseType.mysql.rawValue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public final class ConnectionManager: @unchecked Sendable {
var effectivePort = connection.port
var forwardsToUnixSocket = false
var tunnelId: UUID?
if connection.sshEnabled, let ssh = connection.sshConfigurationForDialing {
if driverFactory.dialsSSHTunnel(for: connection), let ssh = connection.sshConfigurationForDialing {
guard let provider = sshProvider else {
throw ConnectionError.sshNotSupported
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ public protocol DriverFactory: Sendable {
func opensCatalogConnections(for connection: DatabaseConnection) -> Bool
// Runs once on a catalog connection after it connects, never on the user's session.
func prepareCatalogDriver(_ driver: any DatabaseDriver, for connection: DatabaseConnection) async throws
// Whether the connection's SSH tunnel is dialed. A record can carry a tunnel its engine has no use for, such as
// one kept from the type it had before, and dialing it would fail the connect before the driver is made.
func dialsSSHTunnel(for connection: DatabaseConnection) -> Bool
}

public extension DriverFactory {
Expand All @@ -33,4 +36,8 @@ public extension DriverFactory {
}

func prepareCatalogDriver(_ driver: any DatabaseDriver, for connection: DatabaseConnection) async throws {}

func dialsSSHTunnel(for connection: DatabaseConnection) -> Bool {
connection.sshEnabled
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ nonisolated public enum FilterColumnKind {
}

// SQLite keeps 5 and '5' apart in a column with no affinity, and the text the grid read cannot say which one it was.
// Every engine that runs SQLite stores values the same way.
public static func comparesEitherStorageClass(_ column: ColumnInfo?, databaseType: DatabaseType?) -> Bool {
databaseType == .sqlite && kind(of: column, databaseType: databaseType) == nil
databaseType?.isSQLiteFamily == true && kind(of: column, databaseType: databaseType) == nil
}

// Character.isNumber takes digits of every script, and no engine parses those as a number.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,16 +96,35 @@ public struct FilterSQLGenerator: Sendable {
return "\(quotedColumn) BETWEEN \(escapedValue) AND \(escapedSecond)"
case .contains:
let pattern = likePatternBody(filter.value)
if exceedsLikePatternLimit(pattern, wildcards: 2) {
return SQLSubstringMatch.contains(operand, literal: stringLiteral(filter.value), fold: dialect.caseFoldFunction)
}
return likeCondition(operand, "'%\(pattern)%'", negated: false, folding: folding)
case .startsWith:
let pattern = likePatternBody(filter.value)
if exceedsLikePatternLimit(pattern, wildcards: 1) {
return SQLSubstringMatch.startsWith(operand, literal: stringLiteral(filter.value), fold: dialect.caseFoldFunction)
}
return likeCondition(operand, "'\(pattern)%'", negated: false, folding: folding)
case .endsWith:
let pattern = likePatternBody(filter.value)
if exceedsLikePatternLimit(pattern, wildcards: 1) {
return SQLSubstringMatch.endsWith(operand, literal: stringLiteral(filter.value), fold: dialect.caseFoldFunction)
}
return likeCondition(operand, "'%\(pattern)'", negated: false, folding: folding)
}
}

// An engine that refuses a LIKE pattern this long (D1 past 50 bytes) gets the plain substring test instead, which
// reads no wildcard, so its literal carries no LIKE escape.
public func exceedsLikePatternLimit(_ patternBody: String, wildcards: Int) -> Bool {
SQLSubstringMatch.exceeds(patternBody, wildcards: wildcards, limit: dialect.likePatternByteLimit)
}

public func stringLiteral(_ value: String) -> String {
"'\(literalBody(value))'"
}

// A number, a boolean or a bit has no case, and compared folded as text `5.50` would no longer equal `5.5`.
// A pattern still folds: `contains TRUE` finds PostgreSQL's `true`.
private func caseFolding(for filter: TableFilter, column: ColumnInfo?) -> PluginSQLCaseFolding {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import Foundation

/// The row-limiting clauses of one statement, read only where the engine reads code: outside strings, comments and
/// dollar quotes, and at paren depth zero, so a CTE's or a subquery's LIMIT never counts for the whole statement.
public enum SQLStatementRowLimit {
/// Whether the statement already bounds its own rows with `LIMIT` or `FETCH`. `countsTop` also takes `TOP`, for
/// an engine that limits with `SELECT TOP n`; elsewhere `top` is an ordinary column name.
public static func hasExplicitRowLimit(_ sql: String, countsTop: Bool, grammar: SQLLexicalGrammar) -> Bool {
var found = false
forEachTopLevelIdentifier(in: sql, grammar: grammar) { buffer, start, end in
guard isLimitingKeyword(in: buffer, start: start, end: end, countsTop: countsTop, grammar: grammar) else {
return false
}
found = true
return true
}
return found
}

/// The UTF-16 offset of the first top-level row-limiting clause, so a caller splicing a clause
/// onto the end of a statement can put it before that clause rather than after it.
///
/// `TOP` is deliberately absent: it sits between SELECT and the column list, so it is not a
/// trailing clause and splitting there would cut the statement in half.
public static func firstRowLimitClauseOffset(_ sql: String, grammar: SQLLexicalGrammar) -> Int? {
var offset: Int?
forEachTopLevelIdentifier(in: sql, grammar: grammar) { buffer, start, end in
guard startsRowLimitClause(in: buffer, start: start, end: end, grammar: grammar) else { return false }
offset = start
return true
}
return offset
}

/// Whether a compound term after the first is a bare `VALUES` list, as in `SELECT 1 UNION ALL VALUES (2)`. SQLite
/// rejects a LIMIT after such a term, so a caller appending one leaves the statement as it is.
public static func hasCompoundValuesTerm(_ sql: String, grammar: SQLLexicalGrammar) -> Bool {
var found = false
var isFirstWord = true
forEachTopLevelIdentifier(in: sql, grammar: grammar) { buffer, start, end in
defer { isFirstWord = false }
guard !isFirstWord, matchesKeyword("VALUES", in: buffer, start: start, end: end) else { return false }
found = true
return true
}
return found
}

/// The statement with `LIMIT limit` after it, for a caller that has checked it states no limit of its own.
public static func appendingLimit(_ limit: Int, to sql: String) -> String {
"\(clauseHost(sql))\nLIMIT \(limit)"
}

/// The statement with the ANSI `FETCH FIRST limit ROWS ONLY` after it.
public static func appendingFetchFirst(_ limit: Int, to sql: String) -> String {
"\(clauseHost(sql))\nFETCH FIRST \(limit) ROWS ONLY"
}

/// The statement's first word, uppercased, past the blanks and comments `grammar` reads and a leading `(`, so a
/// caller can tell a `SELECT` or `WITH` from a `VALUES` that refuses an appended LIMIT. Nil when no word starts it.
public static func leadingKeyword(_ sql: String, grammar: SQLLexicalGrammar) -> String? {
let keyword = SQLLeadingComments.leadingCodeKeyword(SQLLeadingComments.stripping(sql, grammar: grammar))
return keyword.isEmpty ? nil : keyword
}

// The clause goes on a line of its own, so a trailing `--` comment cannot swallow it. A trailing `;` goes first,
// or the clause would start a statement of its own.
private static func clauseHost(_ sql: String) -> String {
var statement = StatementBlank.trimming(sql)
while statement.hasSuffix(";") {
statement = StatementBlank.trimming(String(statement.dropLast()))
}
return statement
}

/// `OFFSET` is non-reserved in MySQL and MariaDB, so `SELECT offset, name FROM events` reaches
/// here as a column name. What separates the clause from the identifier is what follows it: a
/// row count, a placeholder, or `FIRST`/`NEXT` for the ANSI `FETCH` form.
private static func startsRowLimitClause(
in buffer: NSString,
start: Int,
end: Int,
grammar: SQLLexicalGrammar
) -> Bool {
if matchesKeyword("FETCH", in: buffer, start: start, end: end) {
let (wordStart, wordEnd) = nextWordRange(in: buffer, from: end, grammar: grammar)
return matchesKeyword("FIRST", in: buffer, start: wordStart, end: wordEnd)
|| matchesKeyword("NEXT", in: buffer, start: wordStart, end: wordEnd)
}
guard matchesKeyword("LIMIT", in: buffer, start: start, end: end)
|| matchesKeyword("OFFSET", in: buffer, start: start, end: end)
else {
return false
}
let index = nextCodeIndex(in: buffer, from: end, grammar: grammar)
guard index < buffer.length else { return false }
let next = buffer.character(at: index)
if next >= zero, next <= nine { return true }
if next == question || next == colon || next == dollar || next == at { return true }
let (wordStart, wordEnd) = nextWordRange(in: buffer, from: end, grammar: grammar)
return matchesKeyword("ALL", in: buffer, start: wordStart, end: wordEnd)
}

private static func nextWordRange(in buffer: NSString, from index: Int, grammar: SQLLexicalGrammar) -> (Int, Int) {
let start = nextCodeIndex(in: buffer, from: index, grammar: grammar)
var end = start
while end < buffer.length, SqlDollarQuote.isIdentifierPart(buffer.character(at: end)) { end += 1 }
return (start, end)
}

// Past blanks and the comments `grammar` reads, so `FETCH /* page */ FIRST 10 ROWS ONLY` is still a clause.
private static func nextCodeIndex(in buffer: NSString, from index: Int, grammar: SQLLexicalGrammar) -> Int {
var index = index
while index < buffer.length {
if isSpace(buffer.character(at: index)) {
index += 1
} else if let span = SQLNonCodeSpan.span(at: index, in: buffer, grammar: grammar), span.kind.isComment {
index = max(span.end, index + 1)
} else {
break
}
}
return index
}

private static func isSpace(_ ch: UInt16) -> Bool {
ch == 0x20 || ch == 0x09 || ch == 0x0A || ch == 0x0D
}

/// Walks `sql` and reports every identifier that sits outside a string, comment or dollar quote
/// and at paren depth zero. The visitor returns true to stop the walk.
private static func forEachTopLevelIdentifier(
in sql: String,
grammar: SQLLexicalGrammar,
visit: (NSString, Int, Int) -> Bool
) {
let buffer = sql as NSString
let length = buffer.length
guard length > 0 else { return }

var parenDepth = 0
var i = 0

while i < length {
let ch = buffer.character(at: i)

if let span = SQLNonCodeSpan.span(at: i, in: buffer, grammar: grammar) {
i = max(span.end, i + 1)
continue
}

if ch == openParen {
parenDepth += 1
i += 1
continue
}

if ch == closeParen {
parenDepth -= 1
i += 1
continue
}

if SqlDollarQuote.isIdentifierStart(ch),
i == 0 || !SqlDollarQuote.isIdentifierContinuation(buffer.character(at: i - 1)) {
var end = i + 1
while end < length, SqlDollarQuote.isIdentifierPart(buffer.character(at: end)) { end += 1 }
if parenDepth == 0, visit(buffer, i, end) {
return
}
i = end
continue
}

i += 1
}
}

private static func isLimitingKeyword(
in buffer: NSString,
start: Int,
end: Int,
countsTop: Bool,
grammar: SQLLexicalGrammar
) -> Bool {
if matchesKeyword("LIMIT", in: buffer, start: start, end: end) {
return true
}
// SQLite does not reserve FETCH, so `SELECT * FROM fetch` names a table; only FETCH FIRST or NEXT limits rows.
if matchesKeyword("FETCH", in: buffer, start: start, end: end) {
return startsRowLimitClause(in: buffer, start: start, end: end, grammar: grammar)
}
return countsTop && matchesKeyword("TOP", in: buffer, start: start, end: end)
}

private static func matchesKeyword(_ keyword: String, in buffer: NSString, start: Int, end: Int) -> Bool {
let keywordBuffer = keyword as NSString
guard end - start == keywordBuffer.length else { return false }
for offset in 0..<keywordBuffer.length
where uppercased(buffer.character(at: start + offset)) != keywordBuffer.character(at: offset) {
return false
}
return true
}

private static func uppercased(_ ch: UInt16) -> UInt16 {
(ch >= 0x61 && ch <= 0x7A) ? ch - 0x20 : ch
}

private static let zero = UInt16(UnicodeScalar("0").value)
private static let nine = UInt16(UnicodeScalar("9").value)
private static let question = UInt16(UnicodeScalar("?").value)
private static let colon = UInt16(UnicodeScalar(":").value)
private static let at = UInt16(UnicodeScalar("@").value)
private static let dollar = UInt16(UnicodeScalar("$").value)
private static let openParen = UInt16(UnicodeScalar("(").value)
private static let closeParen = UInt16(UnicodeScalar(")").value)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Foundation

/// Substring tests written without LIKE, for an engine that refuses a LIKE pattern past a byte limit.
///
/// `operand` is the SQL expression searched, `literal` an already quoted string literal of the plain value, with no
/// LIKE escaping, since nothing here reads `%` or `_` as a wildcard. `fold` names the case-fold function applied to
/// both sides, such as `LOWER`. Measured on SQLite 3.54: every form gives NULL for a NULL operand, as LIKE does, so a
/// "does not contain" still leaves NULL rows out.
public enum SQLSubstringMatch {
public static func contains(_ operand: String, literal: String, fold: String) -> String {
"instr(\(fold)(\(operand)), \(fold)(\(literal))) > 0"
}

public static func notContains(_ operand: String, literal: String, fold: String) -> String {
"instr(\(fold)(\(operand)), \(fold)(\(literal))) = 0"
}

public static func startsWith(_ operand: String, literal: String, fold: String) -> String {
"instr(\(fold)(\(operand)), \(fold)(\(literal))) = 1"
}

/// Compares the tail, because `instr` reports only the first match: measured on SQLite 3.54, 'abab' ends with
/// 'ab', yet its first 'ab' is at 1, not 3. The value must not be empty: `substr(x, 0)` is the whole of `x`, so an
/// empty one matches no row where `LIKE '%'` matches every row. An empty value never exceeds a limit anyway.
public static func endsWith(_ operand: String, literal: String, fold: String) -> String {
"substr(\(fold)(\(operand)), -length(\(literal))) = \(fold)(\(literal))"
}

/// Whether the LIKE pattern as sent is longer than `limit` bytes of UTF-8: `likePatternBody` with its wildcard
/// escapes, plus the `wildcards` `%` the caller adds. Measured on SQLite 3.54 with the limit at 50: a 50-byte
/// pattern runs and a 51-byte one is "LIKE or GLOB pattern too complex", and a doubled quote counts once. A SQL
/// literal body therefore over-counts each quote, which only switches to the LIKE-free form early.
public static func exceeds(_ likePatternBody: String, wildcards: Int, limit: Int?) -> Bool {
guard let limit else { return false }
return likePatternBody.utf8.count + wildcards > limit
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import TableProCoreTypes
import Testing

@Test("The engines that run SQLite itself are its family", arguments: [
DatabaseType.sqlite, .cloudflareD1, .libsql, .turso
])
func runsSQLite(type: DatabaseType) {
#expect(type.isSQLiteFamily)
}

// PluginKit's SqlDialect files DuckDB under SQLite, yet DuckDB types and compares values its own way.
@Test("An engine that does not run SQLite is not its family", arguments: [
DatabaseType.duckdb, .postgresql, .mysql, .clickhouse
])
func doesNotRunSQLite(type: DatabaseType) {
#expect(!type.isSQLiteFamily)
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable {

final class MockDriverFactory: DriverFactory, @unchecked Sendable {
var drivers: [String: any DatabaseDriver] = [:]
var dialsTunnels = true
private(set) var receivedConnections: [DatabaseConnection] = []
private(set) var receivedPreTunnelHosts: [String?] = []

Expand All @@ -72,6 +73,8 @@ final class MockDriverFactory: DriverFactory, @unchecked Sendable {
}

func supportedTypes() -> [DatabaseType] { drivers.keys.map(DatabaseType.init(rawValue:)) }

func dialsSSHTunnel(for connection: DatabaseConnection) -> Bool { dialsTunnels && connection.sshEnabled }
}

final class MockSecureStore: SecureStore, Sendable {
Expand Down
Loading
Loading