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,37 @@
import Foundation
import TableProModels
import TableProSQLGrammar

public enum QueryRunTarget: Sendable, Equatable {
case selection(String)
case statement(String, range: NSRange)
case nothing

public static func resolve(
in text: String,
selection: NSRange,
databaseType: DatabaseType,
session: SQLSessionLexicalFacts?
) -> QueryRunTarget {
let source = text as NSString
let selected = NSIntersectionRange(selection, NSRange(location: 0, length: source.length))
if selected.length > 0 {
return .selection(source.substring(with: selected))
}
let caret = min(max(selection.location, 0), source.length)

if databaseType == .redis {
guard let command = RedisCommandSplitter.command(at: caret, in: text) else { return .nothing }
return .statement(command.text, range: command.range)
}

// Not statementAtCursor: it returns a comment-only segment's text as a statement.
let located = SQLStatementScanner.locatedStatementAtCursor(
in: text,
cursorPosition: caret,
grammar: QueryScript.executionGrammar(for: databaseType, session: session)
)
guard located.hasContent, located.contentRange.length > 0 else { return .nothing }
return .statement(source.substring(with: located.contentRange), range: located.contentRange)
}
}
27 changes: 16 additions & 11 deletions apple/Packages/TableProCore/Sources/TableProQuery/QueryScript.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,26 +64,18 @@ public struct QueryScript: Sendable, Equatable {
units.reduce(0) { $0 + max($1.repeatCount, 1) }
}

/// Redis speaks commands, not SQL, and its driver reads the whole text as one command.
///
/// `session` is what the connection says about its own literals, such as PostgreSQL's
/// `standard_conforming_strings`: with it off, `'a\';b'` is one literal, and the default reading would cut it.
public static func plan(
_ text: String,
databaseType: DatabaseType,
sendsBatchesWhole: Bool,
session: SQLSessionLexicalFacts? = nil
) -> QueryScript {
if databaseType == .redis {
let command = text.trimmingCharacters(in: .whitespacesAndNewlines)
return QueryScript(units: command.isEmpty ? [] : [Unit(sql: command)], unitKind: .statement)
let commands = RedisCommandSplitter.commands(in: text)
return QueryScript(units: commands.map { Unit(sql: $0.text) }, unitKind: .statement)
}

let grammar = SQLLexicalReadings.resolve(
databaseTypeId: databaseType.rawValue,
declared: nil,
session: session
).execution
let grammar = executionGrammar(for: databaseType, session: session)
let statements = SQLStatementScanner.executableStatements(in: text, grammar: grammar)

guard sendsBatchesWhole else {
Expand All @@ -107,6 +99,19 @@ public struct QueryScript: Sendable, Equatable {
)
}

// A Redis backslash escapes a trailing blank, which a whitespace trim would cut from the last argument.
public static func trimmed(_ text: String, databaseType: DatabaseType) -> String {
guard databaseType == .redis else { return text.trimmingCharacters(in: .whitespacesAndNewlines) }
let commands = RedisCommandSplitter.commands(in: text)
guard let first = commands.first, let last = commands.last else { return "" }
let start = first.range.location
return (text as NSString).substring(with: NSRange(location: start, length: NSMaxRange(last.range) - start))
}

static func executionGrammar(for databaseType: DatabaseType, session: SQLSessionLexicalFacts?) -> SQLLexicalGrammar {
SQLLexicalReadings.resolve(databaseTypeId: databaseType.rawValue, declared: nil, session: session).execution
}

/// The text after unit `index`, split again under `session`, for a statement that changed how the session reads
/// literals: after `SET sql_mode = 'NO_BACKSLASH_ESCAPES'` a backslash no longer escapes a quote, so the split
/// made before it ran no longer matches what the server will read. Nil when the script cannot be split again.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Foundation

public enum RedisCommandSplitter {
public struct Command: Sendable, Equatable {
public let text: String
public let range: NSRange

public init(text: String, range: NSRange) {
self.text = text
self.range = range
}
}

// The iOS RedisDriver's argument separators, so a split and the parse of each command agree on every character.
private static let blanks: Set<Character> = [" ", "\t"]
private static let lineBreaks: Set<Character> = ["\n", "\r", "\r\n"]

public static func commands(in text: String) -> [Command] {
var commands: [Command] = []
scan(text) { command, _ in
commands.append(command)
return true
}
return commands
}

public static func command(at cursor: Int, in text: String) -> Command? {
var found: Command?
scan(text) { command, reach in
guard cursor <= reach else { return true }
found = command
return false
}
return found
}

// `reach` is the last caret offset that belongs to the command: the one right after its `;`, or the end of its line.
private static func scan(_ text: String, visit: (_ command: Command, _ reach: Int) -> Bool) {
let source = text as NSString
var offset = 0
var start: Int?
var end = 0
var quote: Character?
var escaping = false

func finish(reach: Int) -> Bool {
guard let location = start else { return true }
start = nil
let range = NSRange(location: location, length: end - location)
return visit(Command(text: source.substring(with: range), range: range), reach)
}

for character in text {
let location = offset
offset += character.utf16.count
if escaping {
escaping = false
} else if character == "\\" {
escaping = true
} else if let open = quote {
if character == open { quote = nil }
} else if character == "\"" || character == "'" {
quote = character
} else if character == ";" {
guard finish(reach: offset) else { return }
continue
} else if lineBreaks.contains(character) {
guard finish(reach: location) else { return }
continue
} else if blanks.contains(character) {
continue
}
if start == nil { start = location }
end = offset
}
_ = finish(reach: offset)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ public enum SQLWriteClassifier {
]

public static func isWriteQuery(_ sql: String, databaseType: DatabaseType) -> Bool {
if databaseType == .redis { return redisWrites(sql) }
if databaseType == .redis {
return RedisCommandSplitter.commands(in: sql).contains { redisWrites($0.text) }
}
let readings = SQLLexicalReadings.resolve(databaseTypeId: databaseType.rawValue, declared: nil, session: nil)
return readings.distinct(for: sql).contains { grammar in
SQLStatementScanner.executableStatements(in: sql, grammar: grammar).contains { statement in
Expand All @@ -55,8 +57,6 @@ public enum SQLWriteClassifier {
return !readKeywords.contains(keyword)
}

/// Redis speaks commands, not SQL, so the SQL path would call every `GET` a write. The read set
/// is the one QueryClassifier.swift:436-451 already curates for the Mac.
private static func redisWrites(_ command: String) -> Bool {
let verb = strippingLeadingTrivia(command)
.prefix { !$0.isWhitespace }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import Foundation
import TableProModels
import TableProQuery
import TableProSQLGrammar
import Testing

struct QueryRunTargetTests {
private static let threeStatements = "SELECT 1 AS a;\nSELECT 2 AS b;\nSELECT 3 AS c;"

private func target(
_ text: String,
caret: Int,
_ type: DatabaseType = .sqlite,
session: SQLSessionLexicalFacts? = nil
) -> QueryRunTarget {
QueryRunTarget.resolve(
in: text,
selection: NSRange(location: caret, length: 0),
databaseType: type,
session: session
)
}

private func target(_ text: String, selecting selection: NSRange) -> QueryRunTarget {
QueryRunTarget.resolve(in: text, selection: selection, databaseType: .sqlite, session: nil)
}

private func runText(_ target: QueryRunTarget) -> String? {
switch target {
case .selection(let text), .statement(let text, _):
return text
case .nothing:
return nil
}
}

@Test("The caret in the second of three statements runs only the second, as one unit")
func caretInTheSecondStatement() throws {
let resolved = target(Self.threeStatements, caret: 20)
#expect(resolved == .statement("SELECT 2 AS b;", range: NSRange(location: 15, length: 14)))

let sql = try #require(runText(resolved))
let script = QueryScript.plan(sql, databaseType: .sqlite, sendsBatchesWhole: false)
#expect(script.units == [QueryScript.Unit(sql: "SELECT 2 AS b")])
}

@Test("A caret right after a semicolon runs the statement it ends, one at the next line's start runs that line")
func caretAtAStatementEdge() {
#expect(target(Self.threeStatements, caret: 14) == .statement("SELECT 1 AS a;", range: NSRange(location: 0, length: 14)))
#expect(target(Self.threeStatements, caret: 15) == .statement("SELECT 2 AS b;", range: NSRange(location: 15, length: 14)))
#expect(target(Self.threeStatements, caret: 0) == .statement("SELECT 1 AS a;", range: NSRange(location: 0, length: 14)))
#expect(target(Self.threeStatements, caret: 44) == .statement("SELECT 3 AS c;", range: NSRange(location: 30, length: 14)))
}

@Test("A selection runs verbatim, across statements or inside one")
func selectionRunsVerbatim() {
#expect(target(Self.threeStatements, selecting: NSRange(location: 15, length: 6)) == .selection("SELECT"))
#expect(target(Self.threeStatements, selecting: NSRange(location: 0, length: 29))
== .selection("SELECT 1 AS a;\nSELECT 2 AS b;"))
#expect(target("-- note\n", selecting: NSRange(location: 0, length: 7)) == .selection("-- note"))
}

@Test("A selection running past the text is clamped to it")
func selectionIsClamped() {
#expect(target(Self.threeStatements, selecting: NSRange(location: 30, length: 100)) == .selection("SELECT 3 AS c;"))
}

@Test("A caret after the last statement, or in text holding only comments, runs nothing")
func deadZoneRunsNothing() {
#expect(target("SELECT 1;\n", caret: 10) == .nothing)
#expect(target("SELECT 1; -- done", caret: 17) == .nothing)
#expect(target("-- note\n/* more */", caret: 3) == .nothing)
#expect(target("", caret: 0) == .nothing)
#expect(target(" \n", caret: 2) == .nothing)
}

@Test("A comment before a statement runs with it, from the caret on the comment or on the statement")
func leadingCommentRidesAlong() {
let text = "SELECT 1;\n-- second\nSELECT 2;"
let expected = QueryRunTarget.statement("-- second\nSELECT 2;", range: NSRange(location: 10, length: 19))
#expect(target(text, caret: 25) == expected)
#expect(target(text, caret: 12) == expected)
}

@Test("Offsets and ranges are UTF-16 units, so an emoji counts two")
func emojiCountsInUTF16() {
let text = "SELECT '😀';\nSELECT 2;"
#expect(target(text, caret: 11) == .statement("SELECT '😀';", range: NSRange(location: 0, length: 12)))
#expect(target(text, caret: 12) == .statement("SELECT '😀';", range: NSRange(location: 0, length: 12)))
#expect(target(text, caret: 15) == .statement("SELECT 2;", range: NSRange(location: 13, length: 9)))
}

@Test("SQL Server runs the statement at the caret, a DECLARE without a semicolon with the SELECT after it")
func sqlServerStatementAtTheCaret() throws {
let text = "DECLARE @n int = 1\nSELECT @n\nGO\nSELECT 2"
let batch = QueryRunTarget.statement("DECLARE @n int = 1\nSELECT @n", range: NSRange(location: 0, length: 28))
#expect(target(text, caret: 22, .mssql) == batch)
#expect(target(text, caret: 30, .mssql) == batch)
#expect(target(text, caret: 36, .mssql) == .statement("SELECT 2", range: NSRange(location: 32, length: 8)))

let sql = try #require(runText(batch))
let script = QueryScript.plan(sql, databaseType: .mssql, sendsBatchesWhole: true)
#expect(script.units == [QueryScript.Unit(sql: "DECLARE @n int = 1\nSELECT @n")])
}

@Test("An Oracle block runs whole with its END semicolon, from inside it or from its slash line")
func oracleBlockRunsWhole() throws {
let text = "BEGIN\n NULL;\nEND;\n/\nSELECT 1 FROM dual;"
let block = QueryRunTarget.statement("BEGIN\n NULL;\nEND;", range: NSRange(location: 0, length: 18))
#expect(target(text, caret: 9, .oracle) == block)
#expect(target(text, caret: 19, .oracle) == block)
#expect(target(text, caret: 25, .oracle) == .statement("SELECT 1 FROM dual;", range: NSRange(location: 21, length: 19)))

let sql = try #require(runText(block))
let script = QueryScript.plan(sql, databaseType: .oracle, sendsBatchesWhole: false)
#expect(script.units == [QueryScript.Unit(sql: "BEGIN\n NULL;\nEND;")])
}

@Test("MySQL session facts decide where the statement at the caret ends")
func sessionFactsDecideTheStatement() {
let text = "SELECT 'a\\'; SELECT 2;"
let noEscapes = SQLSessionLexicalFacts(
determined: [.backslashEscapesInSingleQuotes, .backslashEscapesInDoubleQuotes],
enabled: []
)
#expect(target(text, caret: 20, .mysql) == .statement(text, range: NSRange(location: 0, length: 22)))
#expect(target(text, caret: 20, .mysql, session: noEscapes)
== .statement("SELECT 2;", range: NSRange(location: 13, length: 9)))
}

@Test("Redis runs the command on the caret's line or between its semicolons, a quoted line break inside it")
func redisCommandAtTheCaret() {
#expect(target("GET a\nGET b", caret: 7, .redis) == .statement("GET b", range: NSRange(location: 6, length: 5)))
#expect(target("GET a\nGET b", caret: 5, .redis) == .statement("GET a", range: NSRange(location: 0, length: 5)))

let semicolons = "SET k \"x;y\"; GET k"
#expect(target(semicolons, caret: 2, .redis) == .statement("SET k \"x;y\"", range: NSRange(location: 0, length: 11)))
#expect(target(semicolons, caret: 12, .redis) == .statement("SET k \"x;y\"", range: NSRange(location: 0, length: 11)))
#expect(target(semicolons, caret: 13, .redis) == .statement("GET k", range: NSRange(location: 13, length: 5)))

let quoted = "SET k \"a\nb\"\nGET k"
#expect(target(quoted, caret: 9, .redis) == .statement("SET k \"a\nb\"", range: NSRange(location: 0, length: 11)))
#expect(target("GET a\n", caret: 6, .redis) == .nothing)
}

@Test("A Redis selection runs verbatim")
func redisSelectionRunsVerbatim() {
let resolved = QueryRunTarget.resolve(
in: "GET a\nGET b",
selection: NSRange(location: 0, length: 11),
databaseType: .redis,
session: nil
)
#expect(resolved == .selection("GET a\nGET b"))
}

@Test("Safe Mode reads only the text the caret runs, so a SELECT before an UPDATE is not a write")
func safeModeReadsTheRunText() throws {
let text = "SELECT 1;\nUPDATE t SET x = 1;"
let select = try #require(runText(target(text, caret: 3)))
let update = try #require(runText(target(text, caret: 15)))
#expect(select == "SELECT 1;")
#expect(!SQLWriteClassifier.isWriteQuery(select, databaseType: .sqlite))
#expect(SQLWriteClassifier.isWriteQuery(update, databaseType: .sqlite))
#expect(SQLWriteClassifier.isWriteQuery(text, databaseType: .sqlite))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ struct QueryScriptTests {
("SELECT ';'; DROP TABLE t", .postgresql, false),
("SELECT 1 -- ;\n; UPDATE t SET a = 1", .mysql, false),
("SELECT 1\nGO\nDELETE FROM t", .mssql, true),
("GET a\nDEL b", .redis, false),
(Self.issue3399, .sqlite, false),
]
for (text, type, batches) in scripts {
Expand Down Expand Up @@ -151,9 +152,23 @@ struct QueryScriptTests {
#expect(QueryScript(units: script.units, unitKind: .statement).replanned(after: 0, session: noEscapes) == nil)
}

@Test("Redis text is one command, as its driver reads it")
func redisIsOneCommand() {
#expect(plan(" SET k \"a;b\"; GET k \n", .redis).units == [QueryScript.Unit(sql: "SET k \"a;b\"; GET k")])
@Test("Redis text runs one command per line or per semicolon, a quoted value kept whole")
func redisSplitsIntoCommands() {
#expect(plan("RPUSH jobs a\nRPUSH jobs b", .redis).units == [
QueryScript.Unit(sql: "RPUSH jobs a"), QueryScript.Unit(sql: "RPUSH jobs b"),
])
#expect(plan(" SET k \"a;b\"; GET k \n", .redis).units == [
QueryScript.Unit(sql: "SET k \"a;b\""), QueryScript.Unit(sql: "GET k"),
])
#expect(plan("SET k \"a\nb\"", .redis).units == [QueryScript.Unit(sql: "SET k \"a\nb\"")])
#expect(plan(" \n ", .redis).isEmpty)
}

@Test("Trimming a Redis text keeps a blank its backslash escapes")
func redisTrimKeepsAnEscapedBlank() {
#expect(QueryScript.trimmed("DEL victim\\ ", databaseType: .redis) == "DEL victim\\ ")
#expect(QueryScript.trimmed(" \nDEL victim\\ ; GET other \n", databaseType: .redis) == "DEL victim\\ ; GET other")
#expect(QueryScript.trimmed(" \n ; ", databaseType: .redis).isEmpty)
#expect(QueryScript.trimmed(" \nSELECT 1; \n", databaseType: .sqlite) == "SELECT 1;")
}
}
Loading
Loading