diff --git a/apple/Packages/TableProCore/Sources/TableProQuery/QueryRunTarget.swift b/apple/Packages/TableProCore/Sources/TableProQuery/QueryRunTarget.swift new file mode 100644 index 0000000000..75f25a555f --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProQuery/QueryRunTarget.swift @@ -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) + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProQuery/QueryScript.swift b/apple/Packages/TableProCore/Sources/TableProQuery/QueryScript.swift index f8968eb867..c96af80636 100644 --- a/apple/Packages/TableProCore/Sources/TableProQuery/QueryScript.swift +++ b/apple/Packages/TableProCore/Sources/TableProQuery/QueryScript.swift @@ -64,10 +64,6 @@ 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, @@ -75,15 +71,11 @@ public struct QueryScript: Sendable, Equatable { 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 { @@ -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. diff --git a/apple/Packages/TableProCore/Sources/TableProQuery/RedisCommandSplitter.swift b/apple/Packages/TableProCore/Sources/TableProQuery/RedisCommandSplitter.swift new file mode 100644 index 0000000000..1b3e6545fd --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProQuery/RedisCommandSplitter.swift @@ -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 = [" ", "\t"] + private static let lineBreaks: Set = ["\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) + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProQuery/SQLWriteClassifier.swift b/apple/Packages/TableProCore/Sources/TableProQuery/SQLWriteClassifier.swift index e0b4d74e03..bc1da6cbec 100644 --- a/apple/Packages/TableProCore/Sources/TableProQuery/SQLWriteClassifier.swift +++ b/apple/Packages/TableProCore/Sources/TableProQuery/SQLWriteClassifier.swift @@ -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 @@ -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 } diff --git a/apple/Packages/TableProCore/Tests/TableProQueryTests/QueryRunTargetTests.swift b/apple/Packages/TableProCore/Tests/TableProQueryTests/QueryRunTargetTests.swift new file mode 100644 index 0000000000..36ee93c6f5 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProQueryTests/QueryRunTargetTests.swift @@ -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)) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProQueryTests/QueryScriptTests.swift b/apple/Packages/TableProCore/Tests/TableProQueryTests/QueryScriptTests.swift index f1f8713870..5c6816855b 100644 --- a/apple/Packages/TableProCore/Tests/TableProQueryTests/QueryScriptTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProQueryTests/QueryScriptTests.swift @@ -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 { @@ -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;") + } } diff --git a/apple/Packages/TableProCore/Tests/TableProQueryTests/RedisCommandSplitterTests.swift b/apple/Packages/TableProCore/Tests/TableProQueryTests/RedisCommandSplitterTests.swift new file mode 100644 index 0000000000..f9c38a9adc --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProQueryTests/RedisCommandSplitterTests.swift @@ -0,0 +1,99 @@ +import Foundation +import TableProQuery +import Testing + +struct RedisCommandSplitterTests { + private func texts(_ script: String) -> [String] { + RedisCommandSplitter.commands(in: script).map { $0.text } + } + + private func command(at caret: Int, in script: String) -> String? { + RedisCommandSplitter.command(at: caret, in: script)?.text + } + + @Test("A semicolon ends a command and is not part of it") + func semicolonEndsACommand() { + #expect(texts("GET a; GET b;") == ["GET a", "GET b"]) + #expect(texts("GET a;GET b") == ["GET a", "GET b"]) + } + + @Test("A line break ends a command, CRLF and a lone CR as one break each") + func lineBreakEndsACommand() { + #expect(texts("GET a\nGET b") == ["GET a", "GET b"]) + #expect(texts("GET a\r\nGET b\r\n") == ["GET a", "GET b"]) + #expect(texts("GET a\rGET b") == ["GET a", "GET b"]) + } + + @Test("A semicolon or a line break inside quotes stays in the command") + func quotedSeparatorsStayInTheCommand() { + #expect(texts("SET k \"a;b\"; SET j 'c\nd'\nGET k") == ["SET k \"a;b\"", "SET j 'c\nd'", "GET k"]) + #expect(texts("SET k \"it's; fine\"") == ["SET k \"it's; fine\""]) + } + + @Test("A backslash escapes the quote, semicolon or line break after it") + func backslashEscapesTheNextCharacter() { + #expect(texts("SET k \"a\\\";b\"; GET k") == ["SET k \"a\\\";b\"", "GET k"]) + #expect(texts("SET k a\\;b; GET k") == ["SET k a\\;b", "GET k"]) + } + + @Test("A backslash before LF or CRLF keeps one command, as the driver parses it") + func backslashBeforeALineBreak() { + #expect(texts("SET k a\\\nb") == ["SET k a\\\nb"]) + #expect(texts("SET k a\\\r\nb") == ["SET k a\\\r\nb"]) + #expect(texts("SET k a\\\nb\nGET k") == ["SET k a\\\nb", "GET k"]) + } + + @Test("An unclosed quote runs to the end of the text") + func unclosedQuoteRunsToTheEnd() { + #expect(texts("SET k \"abc; GET k\nDEL k") == ["SET k \"abc; GET k\nDEL k"]) + #expect(texts("GET a\nSET k 'x") == ["GET a", "SET k 'x"]) + } + + @Test("Blank lines, spaces and empty commands give nothing") + func blanksGiveNothing() { + #expect(texts("").isEmpty) + #expect(texts(" \n\t\n ; ;\r\n").isEmpty) + #expect(texts("\n\n GET a \n\n") == ["GET a"]) + } + + @Test("A command's range covers its trimmed text in UTF-16 units") + func rangesAreTrimmedUTF16() { + #expect(RedisCommandSplitter.commands(in: " GET a ;") == [ + RedisCommandSplitter.Command(text: "GET a", range: NSRange(location: 2, length: 5)), + ]) + #expect(RedisCommandSplitter.commands(in: "SET 😀 1\nGET 😀") == [ + RedisCommandSplitter.Command(text: "SET 😀 1", range: NSRange(location: 0, length: 8)), + RedisCommandSplitter.Command(text: "GET 😀", range: NSRange(location: 9, length: 6)), + ]) + } + + @Test("A caret belongs to the command its semicolon ends, its line's end, or the next command after blanks") + func caretRule() { + let script = "GET a; GET b\n\nGET c \n" + let expected: [(Int, String?)] = [ + (0, "GET a"), (5, "GET a"), (6, "GET a"), (7, "GET b"), (12, "GET b"), + (13, "GET c"), (14, "GET c"), (20, "GET c"), (21, "GET c"), (22, nil), + ] + for (caret, text) in expected { + #expect(command(at: caret, in: script) == text, "caret \(caret)") + } + } + + @Test("A caret after the last command, or in text with none, has no command") + func caretPastEveryCommand() { + #expect(command(at: 6, in: "GET a\n") == nil) + #expect(command(at: 7, in: "GET a; ") == nil) + #expect(command(at: 6, in: "GET a;") == "GET a") + #expect(command(at: 0, in: "") == nil) + #expect(command(at: 1, in: " \n ") == nil) + } + + @Test("A caret inside a quoted line break belongs to the quoted command") + func caretInsideAQuotedLineBreak() { + let script = "SET k \"a\nb\"\nGET k" + #expect(RedisCommandSplitter.command(at: 9, in: script) + == RedisCommandSplitter.Command(text: "SET k \"a\nb\"", range: NSRange(location: 0, length: 11))) + #expect(RedisCommandSplitter.command(at: 12, in: script) + == RedisCommandSplitter.Command(text: "GET k", range: NSRange(location: 12, length: 5))) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProQueryTests/SQLWriteClassifierTests.swift b/apple/Packages/TableProCore/Tests/TableProQueryTests/SQLWriteClassifierTests.swift index 2a498d1db0..54a8dbe498 100644 --- a/apple/Packages/TableProCore/Tests/TableProQueryTests/SQLWriteClassifierTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProQueryTests/SQLWriteClassifierTests.swift @@ -120,6 +120,17 @@ struct SQLWriteClassifierTests { #expect(isWrite("SOMETHINGNEW key", .redis)) } + @Test("a Redis write on any line or after any semicolon makes the text a write") + func redisWriteInALaterCommand() { + #expect(isWrite("GET a\nDEL b", .redis)) + #expect(isWrite("GET a; DEL b", .redis)) + #expect(isWrite("GET a\r\nFLUSHALL", .redis)) + #expect(!isWrite("GET a\nGET b", .redis)) + #expect(!isWrite("GET a; GET b;\n", .redis)) + #expect(!isWrite("GET \"a\nDEL b\"", .redis)) + #expect(!isWrite(" \n ; ", .redis)) + } + @Test("empty and comment-only input is not a write") func emptyInput() { #expect(!isWrite("")) diff --git a/apple/TableProMobile/TableProMobile/Drivers/RedisDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/RedisDriver.swift index eb54b5acfa..638feaddc0 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/RedisDriver.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/RedisDriver.swift @@ -67,7 +67,9 @@ nonisolated final class RedisDriver: DatabaseDriver, KeyContentsBrowsing, @unche func execute(query: String) async throws -> QueryResult { let start = Date() - let args = Self.parseRedisCommand(query) + guard let args = Self.parseRedisCommand(query) else { + throw RedisError.queryFailed(String(localized: "unbalanced quotes")) + } guard !args.isEmpty else { throw RedisError.queryFailed("Empty command") } @@ -180,9 +182,8 @@ nonisolated final class RedisDriver: DatabaseDriver, KeyContentsBrowsing, @unche /// separate arguments outside quotes, as they do in the Mac's `RedisArgumentCodec`. private static let argumentSeparators: Set = [" ", "\t", "\n", "\r", "\r\n"] - /// A quote opens an argument even when nothing is inside it: `LPUSH l "" x` pushes two values, and dropping the - /// empty one pushed only `x`. - static func parseRedisCommand(_ input: String) -> [String] { + // An empty quote is still an argument: `LPUSH l "" x` must push two values, not only `x`. + static func parseRedisCommand(_ input: String) -> [String]? { var args: [String] = [] var current = "" var hasArgument = false @@ -224,6 +225,7 @@ nonisolated final class RedisDriver: DatabaseDriver, KeyContentsBrowsing, @unche current.append(ch) hasArgument = true } + if inQuote != nil { return nil } if hasArgument { args.append(current) } return args } diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index ca1c8643d8..2817eb1155 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -14933,6 +14933,9 @@ } } } + }, + "Run All Statements" : { + }, "Run queries and find the ones you ran before in History." : { "localizations" : { @@ -14961,6 +14964,12 @@ } } } + }, + "Run Selection" : { + + }, + "Run Statement" : { + }, "Running" : { "localizations" : { @@ -15024,6 +15033,9 @@ }, "Running statement %1$d of %2$d" : { + }, + "Runs the statement at the cursor, or the selected text." : { + }, "Safe Mode" : { "localizations" : { @@ -18427,6 +18439,9 @@ } } } + }, + "The cursor is not in a statement." : { + }, "The data could not be read: %@" : { "localizations" : { @@ -21022,6 +21037,9 @@ } } } + }, + "unbalanced quotes" : { + }, "Unfavorite" : { "localizations" : { diff --git a/apple/TableProMobile/TableProMobile/Views/Components/SQLEditorHandle.swift b/apple/TableProMobile/TableProMobile/Views/Components/SQLEditorHandle.swift new file mode 100644 index 0000000000..fe02f6d5a8 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/SQLEditorHandle.swift @@ -0,0 +1,110 @@ +import Foundation +import Observation +import TableProModels +import TableProQuery +import TableProSQLGrammar +import UIKit + +@MainActor +@Observable +final class SQLEditorHandle { + private(set) var hasSelection = false + private(set) var runNote: String? + + @ObservationIgnored var databaseType: DatabaseType? { + didSet { if databaseType != oldValue { refreshBand() } } + } + + @ObservationIgnored var sessionFacts: SQLSessionLexicalFacts? { + didSet { if sessionFacts != oldValue { refreshBand() } } + } + + @ObservationIgnored private(set) weak var textView: UITextView? + @ObservationIgnored private var band: StatementBand? + @ObservationIgnored private var nothingToRunState: EditorState? + @ObservationIgnored private var isPublishScheduled = false + + private struct EditorState: Equatable { + let text: String + let selection: NSRange + } + + func attach(_ textView: UITextView) { + self.textView = textView + band = StatementBand(textView: textView) + editorDidChange() + } + + func runTarget() -> QueryRunTarget { + guard let databaseType, let textView else { return .nothing } + return QueryRunTarget.resolve( + in: textView.text ?? "", + selection: textView.selectedRange, + databaseType: databaseType, + session: sessionFacts + ) + } + + // Not through the focus binding: its resign runs inside updateUIView, and a Run tap then hung in a view graph cycle. + func endEditing() { + textView?.resignFirstResponder() + } + + func noteNothingToRun() { + nothingToRunState = currentState() + schedulePublish() + AccessibilityNotification.Announcement(Self.nothingToRunMessage).post() + } + + func clearRunNote() { + nothingToRunState = nil + schedulePublish() + } + + func editorDidChange() { + refreshBand() + // Tapping Run resigns the editor after the note is posted, which is no reason to drop it. + if let nothingToRunState, nothingToRunState != currentState() { + self.nothingToRunState = nil + } + schedulePublish() + } + + private static var nothingToRunMessage: String { + String(localized: "The cursor is not in a statement.") + } + + private func currentState() -> EditorState { + EditorState(text: textView?.text ?? "", selection: textView?.selectedRange ?? NSRange(location: 0, length: 0)) + } + + private func refreshBand() { + guard let band else { return } + band.show(bandRange()) + } + + private func bandRange() -> NSRange? { + // The band stops where colouring stops. + guard let textView, textView.textStorage.length <= SQLSyntaxHighlighter.maxHighlightLength, + case .statement(_, let range) = runTarget() else { return nil } + return range + } + + // The editor reports changes from inside a SwiftUI update (updateUIView's text set, a resign), where an + // observed write is not allowed. + private func schedulePublish() { + guard !isPublishScheduled else { return } + isPublishScheduled = true + DispatchQueue.main.async { [weak self] in + self?.publish() + } + } + + private func publish() { + isPublishScheduled = false + let selected = (textView?.selectedRange.length ?? 0) > 0 + if hasSelection != selected { hasSelection = selected } + let note = nothingToRunState == nil ? nil : Self.nothingToRunMessage + if runNote != note { runNote = note } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift b/apple/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift index 7987c2dec8..a65b566426 100644 --- a/apple/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift +++ b/apple/TableProMobile/TableProMobile/Views/Components/SQLHighlightTextView.swift @@ -4,12 +4,14 @@ import UIKit struct SQLHighlightTextView: UIViewRepresentable { @Binding var text: String @Binding var isFocused: Bool + let handle: SQLEditorHandle? private static let font = UIFont.monospacedSystemFont(ofSize: 15, weight: .regular) - init(text: Binding, isFocused: Binding = .constant(false)) { + init(text: Binding, isFocused: Binding = .constant(false), handle: SQLEditorHandle? = nil) { self._text = text self._isFocused = isFocused + self.handle = handle } func makeUIView(context: Context) -> UITextView { @@ -28,6 +30,7 @@ struct SQLHighlightTextView: UIViewRepresentable { textView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4) textView.textStorage.delegate = context.coordinator textView.inputAccessoryView = context.coordinator.makeAccessoryToolbar() + handle?.attach(textView) return textView } @@ -40,6 +43,7 @@ struct SQLHighlightTextView: UIViewRepresentable { SQLSyntaxHighlighter.highlight(textView.textStorage, in: NSRange(location: 0, length: length)) } context.coordinator.isUpdating = false + handle?.editorDidChange() } if isFocused, !textView.isFirstResponder { textView.becomeFirstResponder() @@ -62,14 +66,26 @@ struct SQLHighlightTextView: UIViewRepresentable { func textViewDidChange(_ textView: UITextView) { guard !isUpdating else { return } parent.text = textView.text + parent.handle?.editorDidChange() + } + + func textViewDidChangeSelection(_ textView: UITextView) { + parent.handle?.editorDidChange() } func textViewDidBeginEditing(_ textView: UITextView) { if !parent.isFocused { parent.isFocused = true } + parent.handle?.editorDidChange() } func textViewDidEndEditing(_ textView: UITextView) { if parent.isFocused { parent.isFocused = false } + // A selection draws nothing once the editor resigns, so Run would act on text nobody can see. + let selection = textView.selectedRange + if selection.length > 0 { + textView.selectedRange = NSRange(location: selection.location, length: 0) + } + parent.handle?.editorDidChange() } func textStorage( diff --git a/apple/TableProMobile/TableProMobile/Views/Components/SQLSyntaxHighlighter.swift b/apple/TableProMobile/TableProMobile/Views/Components/SQLSyntaxHighlighter.swift index 953f50ab1a..97114462c4 100644 --- a/apple/TableProMobile/TableProMobile/Views/Components/SQLSyntaxHighlighter.swift +++ b/apple/TableProMobile/TableProMobile/Views/Components/SQLSyntaxHighlighter.swift @@ -1,7 +1,7 @@ import UIKit enum SQLSyntaxHighlighter { - private static let maxHighlightLength = 10_000 + static let maxHighlightLength = 10_000 private static let defaultFont = UIFont.monospacedSystemFont(ofSize: 15, weight: .regular) diff --git a/apple/TableProMobile/TableProMobile/Views/Components/StatementBand.swift b/apple/TableProMobile/TableProMobile/Views/Components/StatementBand.swift new file mode 100644 index 0000000000..5eb2afa8ef --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/StatementBand.swift @@ -0,0 +1,55 @@ +import UIKit + +@MainActor +final class StatementBand { + private weak var textView: UITextView? + private var range: NSRange? + + init(textView: UITextView) { + self.textView = textView + guard let manager = textView.textLayoutManager else { return } + let previous = manager.renderingAttributesValidator + // A fragment drops its rendering attributes each time it lays out again. + manager.renderingAttributesValidator = { [weak self] manager, fragment in + previous?(manager, fragment) + self?.restore(in: fragment, of: manager) + } + } + + func show(_ range: NSRange?) { + let previous = self.range + self.range = range + guard let textView, let manager = textView.textLayoutManager else { return } + // A band moves and splits with the text it covers, so a range kept from before an edit misses pieces of it. + manager.removeRenderingAttribute(.backgroundColor, for: manager.documentRange) + if let range, let textRange = Self.textRange(for: range, in: manager) { + manager.addRenderingAttribute(.backgroundColor, value: UIColor.tertiarySystemFill, for: textRange) + } + guard previous != range else { return } + // A fragment on screen keeps what it drew until it lays out again, so a moved band would stay where it was. + relayoutLines(covering: [previous, range].compactMap { $0 }, of: textView, in: manager) + } + + private func relayoutLines(covering ranges: [NSRange], of textView: UITextView, in manager: NSTextLayoutManager) { + guard let first = ranges.first else { return } + let text = textView.textStorage.string as NSString + let union = ranges.dropFirst().reduce(first, NSUnionRange) + let lines = text.paragraphRange(for: NSIntersectionRange(union, NSRange(location: 0, length: text.length))) + guard let textRange = Self.textRange(for: lines, in: manager) else { return } + manager.invalidateLayout(for: textRange) + } + + private func restore(in fragment: NSTextLayoutFragment, of manager: NSTextLayoutManager) { + guard let range, + let textRange = Self.textRange(for: range, in: manager), + let overlap = textRange.intersection(fragment.rangeInElement) else { return } + manager.addRenderingAttribute(.backgroundColor, value: UIColor.tertiarySystemFill, for: overlap) + } + + private static func textRange(for range: NSRange, in manager: NSTextLayoutManager) -> NSTextRange? { + guard range.length > 0, let content = manager.textContentManager, + let start = content.location(manager.documentRange.location, offsetBy: range.location), + let end = content.location(start, offsetBy: range.length) else { return nil } + return NSTextRange(location: start, end: end) + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 28e0bf935d..b29d800f28 100644 --- a/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -13,6 +13,7 @@ struct QueryEditorView: View { @State private var query = "" @State private var editorFocused = false + @State private var editorHandle = SQLEditorHandle() @State private var viewModel = QueryEditorViewModel() @State private var appError: AppError? @State private var isExecuting = false @@ -49,6 +50,7 @@ struct QueryEditorView: View { } .onGeometryChange(for: CGSize.self) { $0.size } action: { containerSize = $0 } .onAppear { + editorHandle.databaseType = databaseType if let pending = coordinator.pendingQuery { query = pending coordinator.pendingQuery = nil @@ -56,6 +58,9 @@ struct QueryEditorView: View { query = UserDefaults.standard.string(forKey: ConnectionDefaultsKey.lastQuery.name(for: connectionId)) ?? "" } } + .task(id: session.map { ObjectIdentifier($0.driver) }) { + editorHandle.sessionFacts = await session?.driver.sessionLexicalFacts() + } .onChange(of: query) { _, newValue in saveQueryTask?.cancel() saveQueryTask = Task { @@ -130,7 +135,7 @@ struct QueryEditorView: View { private var editorSection: some View { VStack(spacing: 0) { - SQLHighlightTextView(text: $query, isFocused: $editorFocused) + SQLHighlightTextView(text: $query, isFocused: $editorFocused, handle: editorHandle) .frame(minHeight: 80, maxHeight: editorMaxHeight) actionBar @@ -139,30 +144,51 @@ struct QueryEditorView: View { private var actionBar: some View { HStack(spacing: 12) { - Button { - if isExecuting { + if isExecuting { + Button { executeTask?.cancel() viewModel.stop() Task { try? await session?.driver.cancelCurrentQuery() } - } else { - executeTask = Task { await executeQuery() } + } label: { + Label(String(localized: "Stop"), systemImage: "stop.fill") + .font(.subheadline.weight(.medium)) } - } label: { - Label( - isExecuting ? String(localized: "Stop") : String(localized: "Run"), - systemImage: isExecuting ? "stop.fill" : "play.fill" - ) - .font(.subheadline.weight(.medium)) + .buttonStyle(.bordered) + .controlSize(.small) + .tint(.red) + .keyboardShortcut(".", modifiers: .command) + } else { + Menu { + Button { + runAtCursor(dismissingKeyboard: true) + } label: { + Label(runAtCursorTitle, systemImage: "play") + } + Button { + runAllStatements(dismissingKeyboard: true) + } label: { + Label("Run All Statements", systemImage: "play.square.stack") + } + } label: { + Label(String(localized: "Run"), systemImage: "play.fill") + .font(.subheadline.weight(.medium)) + } primaryAction: { + runAtCursor(dismissingKeyboard: true) + } + .menuStyle(.button) + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!canRun) + .accessibilityHint(Text("Runs the statement at the cursor, or the selected text.")) } - .buttonStyle(.bordered) - .controlSize(.small) - .tint(isExecuting ? .red : .accentColor) - .disabled(!isExecuting && query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - .keyboardShortcut(.return, modifiers: .command) Spacer() - if isExecuting, let startTime = executionStartTime { + if let runNote = editorHandle.runNote { + Text(verbatim: runNote) + .font(.caption2) + .foregroundStyle(.secondary) + } else if isExecuting, let startTime = executionStartTime { TimelineView(.periodic(from: startTime, by: 0.1)) { context in let elapsed = context.date.timeIntervalSince(startTime) Text(String(format: "%.1fs", elapsed)) @@ -175,7 +201,7 @@ struct QueryEditorView: View { .foregroundStyle(.secondary) } - if resultRowCount > 0 { + if resultRowCount > 0, editorHandle.runNote == nil { Text("\(resultRowCount) rows") .font(.caption2) .foregroundStyle(.secondary) @@ -185,6 +211,24 @@ struct QueryEditorView: View { } .padding(.horizontal, 12) .padding(.vertical, 6) + .background { + Button(runAtCursorTitle) { runAtCursor(dismissingKeyboard: false) } + .keyboardShortcut(.return, modifiers: .command) + .disabled(!canRun) + .hidden() + Button("Run All Statements") { runAllStatements(dismissingKeyboard: false) } + .keyboardShortcut(.return, modifiers: [.command, .shift]) + .disabled(!canRun) + .hidden() + } + } + + private var canRun: Bool { + !isExecuting && !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var runAtCursorTitle: String { + editorHandle.hasSelection ? String(localized: "Run Selection") : String(localized: "Run Statement") } // MARK: - Results @@ -419,6 +463,13 @@ struct QueryEditorView: View { Label("History", systemImage: "clock") } + Button { + runAllStatements(dismissingKeyboard: true) + } label: { + Label("Run All Statements", systemImage: "play.square.stack") + } + .disabled(!canRun) + if !tables.isEmpty, databaseType.speaksSQL { Menu { ForEach(tables) { table in @@ -482,8 +533,26 @@ struct QueryEditorView: View { // MARK: - Execution - private func executeQuery() async { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + private func runAtCursor(dismissingKeyboard: Bool) { + let target = editorHandle.runTarget() + if dismissingKeyboard { editorHandle.endEditing() } + switch target { + case .selection(let text), .statement(let text, _): + executeTask = Task { await executeQuery(text) } + case .nothing: + editorHandle.noteNothingToRun() + hapticError.toggle() + } + } + + private func runAllStatements(dismissingKeyboard: Bool) { + if dismissingKeyboard { editorHandle.endEditing() } + executeTask = Task { await executeQuery(query) } + } + + private func executeQuery(_ text: String) async { + guard !isExecuting else { return } + let trimmed = QueryScript.trimmed(text, databaseType: databaseType) guard !trimmed.isEmpty else { return } guard let session else { return } // Text that is only comments plans to nothing under any session setting, so this needs no driver round trip. @@ -512,16 +581,15 @@ struct QueryEditorView: View { await executeQueryDirect(trimmed) } - /// The run is claimed before the first await, so a second tap on Run while the session is asked how it reads - /// literals finds it running and does not start another. + // Claimed before the first await, so a second Run while the session reports how it reads literals starts nothing. private func executeQueryDirect(_ trimmed: String) async { guard let session, !isExecuting else { return } - editorFocused = false isExecuting = true let startedAt = Date() executionStartTime = startedAt appError = nil + editorHandle.clearRunNote() let script = await plan(trimmed, on: session) guard !script.isEmpty else { @@ -554,6 +622,11 @@ struct QueryEditorView: View { let phase = viewModel.phase let outcome = QueryExecutionOutcome(phase: phase) + // A stopped request may still hold the driver, and reading the session would wait for the statement Stop ends. + if outcome == .completed || outcome == .failed, coordinator.session?.driver === session.driver { + editorHandle.sessionFacts = await session.driver.sessionLexicalFacts() + } + if case .error(let err) = phase { appError = err hapticError.toggle() diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/RedisCommandParsingTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/RedisCommandParsingTests.swift index 3723b379de..c098bd043e 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/RedisCommandParsingTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/RedisCommandParsingTests.swift @@ -1,10 +1,12 @@ import Foundation @testable import TableProMobile +import TableProModels +import TableProQuery import Testing @Suite("Redis command parsing") struct RedisCommandParsingTests { - private func parse(_ input: String) -> [String] { + private func parse(_ input: String) -> [String]? { RedisDriver.parseRedisCommand(input) } @@ -27,7 +29,7 @@ struct RedisCommandParsingTests { @Test("leading and trailing line breaks add no argument") func surroundingLineBreaks() { #expect(parse("\n\r\nGET k\n\n") == ["GET", "k"]) - #expect(parse("\n\r\n\r").isEmpty) + #expect(parse("\n\r\n\r")?.isEmpty == true) } @Test("a double-quoted argument keeps its line breaks") @@ -67,7 +69,42 @@ struct RedisCommandParsingTests { @Test("empty and blank input give no arguments") func emptyInput() { - #expect(parse("").isEmpty) - #expect(parse(" \t ").isEmpty) + #expect(parse("")?.isEmpty == true) + #expect(parse(" \t ")?.isEmpty == true) + } + + @Test("an unclosed quote gives no command, so a cut value is never stored") + func unclosedQuoteGivesNil() { + #expect(parse("SET greeting \"hello") == nil) + #expect(parse("SET greeting 'hello") == nil) + #expect(parse("SET k \"a\\\"") == nil) + } + + @Test("a backslash before a line break keeps one command, split and parsed alike") + func escapedLineBreakMatchesSplitter() throws { + try expectOneCommand("SET k a\\\nb", parsesTo: ["SET", "k", "a\nb"]) + try expectOneCommand("SET k a\\\r\nb", parsesTo: ["SET", "k", "a\r\nb"]) + } + + @Test("Run on a command ending in an escaped blank sends that blank in its key") + func escapedTrailingBlankReachesTheDriver() { + let script = "DEL victim\\ ; GET other" + let target = QueryRunTarget.resolve( + in: script, selection: NSRange(location: 2, length: 0), databaseType: .redis, session: nil + ) + #expect(target == .statement("DEL victim\\ ", range: NSRange(location: 0, length: 12))) + for text in ["DEL victim\\ ", script] { + let runText = QueryScript.trimmed(text, databaseType: .redis) + let units = QueryScript.plan(runText, databaseType: .redis, sendsBatchesWhole: false).units + let arguments = units.first.flatMap { parse($0.sql) } + #expect(arguments == ["DEL", "victim "]) + } + } + + private func expectOneCommand(_ script: String, parsesTo expected: [String]) throws { + let commands = RedisCommandSplitter.commands(in: script) + #expect(commands.count == 1) + let command = try #require(commands.first) + #expect(parse(command.text) == expected) } } diff --git a/apple/TableProMobile/TableProMobileTests/Views/SQLEditorHandleTests.swift b/apple/TableProMobile/TableProMobileTests/Views/SQLEditorHandleTests.swift new file mode 100644 index 0000000000..10a1715c41 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Views/SQLEditorHandleTests.swift @@ -0,0 +1,265 @@ +import SwiftUI +@testable import TableProMobile +import TableProModels +import TableProQuery +import Testing +import UIKit + +@MainActor +@Suite(.serialized, .timeLimit(.minutes(1))) +struct SQLEditorHandleTests { + private static let script = "SELECT 1 AS a;\nSELECT 2 AS b;\nSELECT 3 AS c;" + private static let first = NSRange(location: 0, length: 14) + private static let second = NSRange(location: 15, length: 14) + + @Test("Run reads the caret and the selection from the editor as they stand") + func runTargetReadsTheLiveEditor() throws { + let editor = try HostedEditor(text: Self.script) + defer { editor.tearDown() } + + editor.textView.selectedRange = NSRange(location: 20, length: 0) + #expect(editor.handle.runTarget() == .statement("SELECT 2 AS b;", range: Self.second)) + + editor.textView.selectedRange = Self.first + #expect(editor.handle.runTarget() == .selection("SELECT 1 AS a;")) + } + + @Test("Leaving the editor collapses the selection to its start") + func endingEditingCollapsesTheSelection() throws { + let editor = try HostedEditor(text: Self.script) + defer { editor.tearDown() } + try editor.focus() + + editor.textView.selectedRange = Self.second + #expect(editor.handle.runTarget() == .selection("SELECT 2 AS b;")) + + editor.textView.resignFirstResponder() + #expect(editor.textView.selectedRange == NSRange(location: 15, length: 0)) + #expect(editor.handle.runTarget() == .statement("SELECT 2 AS b;", range: Self.second)) + } + + @Test("The menu hears of a selection and of its end") + func selectionIsPublished() async throws { + let editor = try HostedEditor(text: Self.script) + defer { editor.tearDown() } + try editor.focus() + + editor.textView.selectedRange = Self.first + await ObservedCondition.wait { editor.handle.hasSelection } + + editor.textView.selectedRange = NSRange(location: 3, length: 0) + await ObservedCondition.wait { !editor.handle.hasSelection } + } + + @Test("The band covers the statement at the caret and follows the caret") + func bandFollowsTheCaret() throws { + let editor = try HostedEditor(text: Self.script) + defer { editor.tearDown() } + try editor.focus() + + editor.textView.selectedRange = NSRange(location: 20, length: 0) + #expect(editor.bandedRanges() == [Self.second]) + + editor.textView.selectedRange = NSRange(location: 3, length: 0) + #expect(editor.bandedRanges() == [Self.first]) + #expect(editor.textView.textLayoutManager != nil) + } + + @Test("The band is drawn on the statement the caret moves to, and leaves the one it left") + func bandIsRedrawnWhenTheCaretMoves() throws { + let editor = try HostedEditor(text: Self.script) + defer { editor.tearDown() } + try editor.focus() + + editor.textView.selectedRange = NSRange(location: 44, length: 0) + #expect(try editor.isBandDrawn(atCharacter: 38)) + #expect(try !editor.isBandDrawn(atCharacter: 23)) + + editor.textView.selectedRange = NSRange(location: 20, length: 0) + #expect(try editor.isBandDrawn(atCharacter: 23)) + #expect(try !editor.isBandDrawn(atCharacter: 38)) + } + + @Test("A selection hides the band") + func selectionHidesTheBand() throws { + let editor = try HostedEditor(text: Self.script) + defer { editor.tearDown() } + try editor.focus() + + editor.textView.selectedRange = NSRange(location: 15, length: 6) + #expect(editor.bandedRanges().isEmpty) + } + + @Test("Past the length where colouring stops there is no band, and Run still finds the statement") + func longTextHasNoBand() throws { + let text = String(repeating: "SELECT 1;\n", count: SQLSyntaxHighlighter.maxHighlightLength / 10 + 1) + let editor = try HostedEditor(text: text) + defer { editor.tearDown() } + try editor.focus() + + editor.textView.selectedRange = NSRange(location: 3, length: 0) + #expect(editor.bandedRanges().isEmpty) + #expect(editor.handle.runTarget() == .statement("SELECT 1;", range: NSRange(location: 0, length: 9))) + } + + @Test("A semicolon typed inside the band leaves no band on the text after it") + func semicolonInsideTheBandLeavesNoResidue() async throws { + let editor = try HostedEditor(text: Self.script) + defer { editor.tearDown() } + try editor.focus() + + editor.textView.selectedRange = NSRange(location: 23, length: 0) + #expect(editor.bandedRanges() == [Self.second]) + + editor.textView.insertText(";") + let cut = NSRange(location: 15, length: 9) + #expect(editor.bandedRanges() == [cut]) + + await nextMainQueueTurn() + editor.textView.layoutIfNeeded() + #expect(editor.bandedRanges() == [cut]) + #expect(editor.textView.textLayoutManager != nil) + } + + @Test("Run with nothing at the caret leaves a note that outlasts the resign and goes with the next edit") + func nothingToRunNoteLastsUntilAnEdit() async throws { + let editor = try HostedEditor(text: "SELECT 1;\n") + defer { editor.tearDown() } + try editor.focus() + + editor.textView.selectedRange = NSRange(location: 10, length: 0) + #expect(editor.handle.runTarget() == .nothing) + #expect(editor.bandedRanges().isEmpty) + + editor.handle.noteNothingToRun() + await ObservedCondition.wait { editor.handle.runNote != nil } + + editor.textView.resignFirstResponder() + await nextMainQueueTurn() + #expect(editor.handle.runNote != nil) + + try editor.focus() + editor.textView.insertText("S") + await ObservedCondition.wait { editor.handle.runNote == nil } + } + + @Test("Text set from outside the editor moves the band and drops the note") + func externalTextMovesTheBand() async throws { + let editor = try HostedEditor(text: "SELECT 1;\n") + defer { editor.tearDown() } + + editor.textView.selectedRange = NSRange(location: 10, length: 0) + editor.handle.noteNothingToRun() + await ObservedCondition.wait { editor.handle.runNote != nil } + + editor.model.text = "SELECT 9;" + await ObservedCondition.wait { editor.handle.runNote == nil } + #expect(editor.bandedRanges() == [NSRange(location: 0, length: 9)]) + } + + private func nextMainQueueTurn() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } +} + +@MainActor +@Observable +private final class EditorModel { + var text: String + var isFocused = false + + init(text: String) { + self.text = text + } +} + +private struct EditorRoot: View { + let model: EditorModel + let handle: SQLEditorHandle + + var body: some View { + @Bindable var model = model + // Read here so a text set from outside re-renders the editor, as the @State query does in QueryEditorView. + let _ = model.text + SQLHighlightTextView(text: $model.text, isFocused: $model.isFocused, handle: handle) + .frame(height: 320) + } +} + +@MainActor +private struct HostedEditor { + let window: UIWindow + let model: EditorModel + let handle: SQLEditorHandle + let textView: UITextView + + init(text: String) throws { + let scene = try #require( + UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + ) + let model = EditorModel(text: text) + let handle = SQLEditorHandle() + handle.databaseType = .sqlite + let window = UIWindow(windowScene: scene) + window.rootViewController = UIHostingController(rootView: EditorRoot(model: model, handle: handle)) + window.makeKeyAndVisible() + window.layoutIfNeeded() + + self.window = window + self.model = model + self.handle = handle + textView = try #require(handle.textView, "the editor never attached its text view") + try #require(textView.text == text) + } + + func focus() throws { + try #require(textView.becomeFirstResponder(), "the editor did not take first responder") + } + + func bandedRanges() -> [NSRange] { + guard let manager = textView.textLayoutManager, let content = manager.textContentManager else { return [] } + let start = manager.documentRange.location + var ranges: [NSRange] = [] + manager.enumerateRenderingAttributes(from: start, reverse: false) { _, attributes, range in + guard attributes[.backgroundColor] is UIColor else { return true } + let location = content.offset(from: start, to: range.location) + let length = content.offset(from: range.location, to: range.endLocation) + if let last = ranges.last, NSMaxRange(last) == location { + ranges[ranges.count - 1].length += length + } else { + ranges.append(NSRange(location: location, length: length)) + } + return true + } + return ranges + } + + // Compares the drawn pixel under a space with one past the end of its line, which the band never reaches. + func isBandDrawn(atCharacter offset: Int) throws -> Bool { + let start = try #require(textView.position(from: textView.beginningOfDocument, offset: offset)) + let end = try #require(textView.position(from: start, offset: 1)) + let rect = textView.firstRect(for: try #require(textView.textRange(from: start, to: end))) + let inside = try pixel(at: textView.convert(CGPoint(x: rect.midX, y: rect.midY), to: window)) + let outside = try pixel(at: textView.convert(CGPoint(x: textView.bounds.maxX - 40, y: rect.midY), to: window)) + return inside != outside + } + + private func pixel(at point: CGPoint) throws -> [UInt8] { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let bounds = CGRect(x: point.x.rounded(.down), y: point.y.rounded(.down), width: 1, height: 1) + let image = UIGraphicsImageRenderer(bounds: bounds, format: format).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + let data = try #require(image.cgImage?.dataProvider?.data as Data?) + return Array(data.prefix(4)) + } + + func tearDown() { + textView.resignFirstResponder() + window.isHidden = true + window.rootViewController = nil + } +} diff --git a/apple/changelog.d/3681.added.md b/apple/changelog.d/3681.added.md new file mode 100644 index 0000000000..5e587aa2d7 --- /dev/null +++ b/apple/changelog.d/3681.added.md @@ -0,0 +1 @@ +Query on iPhone and iPad runs the statement at the cursor or the selection, with Run All Statements in the Run menu. diff --git a/apple/changelog.d/3681.fixed.1.md b/apple/changelog.d/3681.fixed.1.md new file mode 100644 index 0000000000..b8642da9bc --- /dev/null +++ b/apple/changelog.d/3681.fixed.1.md @@ -0,0 +1 @@ +A Redis command with an unclosed quote storing a cut value on iPhone and iPad instead of failing. diff --git a/apple/changelog.d/3681.fixed.md b/apple/changelog.d/3681.fixed.md new file mode 100644 index 0000000000..09b50f1235 --- /dev/null +++ b/apple/changelog.d/3681.fixed.md @@ -0,0 +1 @@ +Redis commands on separate lines running as one command on iPhone and iPad.