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
1 change: 0 additions & 1 deletion .github/macos-test-quarantine.txt
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,3 @@ DataChangeManagerTests
CoordinatorEditorLoadTests
CommandActionsDispatchTests
ChatToolSpecCopilotTests
SQLStatementScannerLocatedTests
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Fixed a query with a comment after the closing semicolon, such as `SELECT 1; -- note`, being run as two statements, with the trailing comment sent to the server as a failing second statement. Running a comment-only query or selection now does nothing instead of producing a server error, and AI and MCP clients are no longer told such a query is multi-statement. (#1895)
- Fixed duplicating a connection dropping its Cloudflare Tunnel and Cloud SQL Auth Proxy settings and stored secrets.
- Fixed the tunnel panes warning about only some of the other enabled connection methods. Each pane now lists every conflicting method with a button to turn it off.
- Fixed Copy as, the context menu's Delete, and the Edit menu's copy and delete commands acting on only the active row instead of every row in a cell-range or column selection in the data grid. (#1898)
Expand Down
37 changes: 25 additions & 12 deletions TablePro/Core/Utilities/SQL/SQLStatementScanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ enum SQLStatementScanner {
/// Returns statements with trailing semicolons stripped, for driver execution.
static func allStatements(in sql: String, dialect: SqlDialect = .generic) -> [String] {
var results: [String] = []
scan(sql: sql, cursorPosition: nil, dialect: dialect) { rawSQL, _ in
scan(sql: sql, cursorPosition: nil, dialect: dialect) { rawSQL, _, hasStatementContent in
guard hasStatementContent else { return true }
var trimmed = rawSQL.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasSuffix(";") {
trimmed = String(trimmed.dropLast())
Expand All @@ -32,7 +33,8 @@ enum SQLStatementScanner {
/// Returns statements preserving trailing semicolons, for display/history/favorites.
static func allStatementsPreservingSemicolons(in sql: String) -> [String] {
var results: [String] = []
scan(sql: sql, cursorPosition: nil) { rawSQL, _ in
scan(sql: sql, cursorPosition: nil) { rawSQL, _, hasStatementContent in
guard hasStatementContent else { return true }
let trimmed = rawSQL.trimmingCharacters(in: .whitespacesAndNewlines)
let withoutSemicolon = trimmed.hasSuffix(";")
? String(trimmed.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
Expand All @@ -58,7 +60,7 @@ enum SQLStatementScanner {

static func locatedStatementAtCursor(in sql: String, cursorPosition: Int, dialect: SqlDialect = .generic) -> LocatedStatement {
var result = LocatedStatement(sql: "", offset: 0)
scan(sql: sql, cursorPosition: cursorPosition, dialect: dialect) { rawSQL, offset in
scan(sql: sql, cursorPosition: cursorPosition, dialect: dialect) { rawSQL, offset, _ in
result = LocatedStatement(sql: rawSQL, offset: offset)
return false
}
Expand All @@ -77,22 +79,25 @@ enum SQLStatementScanner {
private static let newline = UInt16(UnicodeScalar("\n").value)
private static let backslash = UInt16(UnicodeScalar("\\").value)
private static let dollar = UInt16(UnicodeScalar("$").value)
private static let exclamationMark = UInt16(UnicodeScalar("!").value)
private static let space = UInt16(UnicodeScalar(" ").value)
private static let tab = UInt16(UnicodeScalar("\t").value)
private static let carriageReturn = UInt16(UnicodeScalar("\r").value)

private static func isWhitespace(_ ch: UInt16) -> Bool {
ch == space || ch == tab || ch == newline || ch == carriageReturn
}

private static func scan(
sql: String,
cursorPosition: Int?,
dialect: SqlDialect = .generic,
onStatement: (_ rawSQL: String, _ offset: Int) -> Bool
onStatement: (_ rawSQL: String, _ offset: Int, _ hasStatementContent: Bool) -> Bool
) {
let nsQuery = sql as NSString
let length = nsQuery.length
guard length > 0 else { return }

guard nsQuery.range(of: ";").location != NSNotFound else {
_ = onStatement(sql, 0)
return
}

let safePosition = cursorPosition.map { min(max(0, $0), length) }

var currentStart = 0
Expand All @@ -102,6 +107,7 @@ enum SQLStatementScanner {
var inBlockComment = false
var inDollarQuote = false
var dollarTag = ""
var hasStatementContent = false
let dollarQuotesEnabled = dialect.supportsDollarQuotes
var i = 0

Expand Down Expand Up @@ -143,6 +149,9 @@ enum SQLStatementScanner {
}

if !inString && ch == slash && i + 1 < length && nsQuery.character(at: i + 1) == star {
if i + 2 < length && nsQuery.character(at: i + 2) == exclamationMark {
hasStatementContent = true
}
inBlockComment = true
i += 2
continue
Expand Down Expand Up @@ -170,6 +179,7 @@ enum SQLStatementScanner {
case .opener(let openerLength, let tag) = SqlDollarQuote.scanOpener(at: i, in: nsQuery, bufLen: length) {
inDollarQuote = true
dollarTag = tag
hasStatementContent = true
i += openerLength
continue
}
Expand All @@ -180,23 +190,26 @@ enum SQLStatementScanner {
if let cursor = safePosition {
if cursor >= currentStart && cursor <= stmtEnd {
let stmtRange = NSRange(location: currentStart, length: stmtEnd - currentStart)
_ = onStatement(nsQuery.substring(with: stmtRange), currentStart)
_ = onStatement(nsQuery.substring(with: stmtRange), currentStart, hasStatementContent)
return
}
} else {
let stmtRange = NSRange(location: currentStart, length: stmtEnd - currentStart)
if !onStatement(nsQuery.substring(with: stmtRange), currentStart) { return }
if !onStatement(nsQuery.substring(with: stmtRange), currentStart, hasStatementContent) { return }
}

currentStart = stmtEnd
hasStatementContent = false
} else if !isWhitespace(ch) {
hasStatementContent = true
}

i += 1
}

if currentStart < length {
let stmtRange = NSRange(location: currentStart, length: length - currentStart)
_ = onStatement(nsQuery.substring(with: stmtRange), currentStart)
_ = onStatement(nsQuery.substring(with: stmtRange), currentStart, hasStatementContent)
}
}
}
13 changes: 13 additions & 0 deletions TableProTests/Core/Services/Execution/ExecutionGateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,19 @@ struct ExecutionGateTests {
#expect(allowed.isAuthorized)
}

@Test("A trailing comment after the semicolon is not denied as multi-statement")
func trailingCommentNotDeniedAsMultiStatement() async {
let confirm = StubConfirming(answer: true)
let auth = StubAuthenticating(answer: true)
let gate = makeGate(level: .silent, confirm: confirm, auth: auth)

let decision = await gate.authorize(
makeRequest(sql: "SELECT 1; -- note", kind: .readQuery, capabilities: [.mayWrite])
)

#expect(decision.isAuthorized)
}

// MARK: - Pre-cleared and cannot-prompt

@Test("Pre-cleared caller skips confirmation and auth")
Expand Down
19 changes: 19 additions & 0 deletions TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,22 @@ struct QueryClassifierKeywordBoundaryTests {
#expect(QueryClassifier.classifyTier("UPDATE\nt SET x = 1", databaseType: .mysql) == .write)
}
}

@Suite("QueryClassifier isMultiStatement")
struct QueryClassifierMultiStatementTests {
@Test("A trailing comment after the terminating semicolon is not a second statement")
func trailingCommentIsNotMultiStatement() {
#expect(!QueryClassifier.isMultiStatement("SELECT 1; -- note", databaseType: .mysql))
#expect(!QueryClassifier.isMultiStatement("SELECT 1; /* note */", databaseType: .postgresql))
}

@Test("Two real statements are still multi-statement")
func twoRealStatementsAreMultiStatement() {
#expect(QueryClassifier.isMultiStatement("SELECT 1; SELECT 2", databaseType: .mysql))
}

@Test("A comment-only query is not multi-statement")
func commentOnlyQueryIsNotMultiStatement() {
#expect(!QueryClassifier.isMultiStatement("-- note", databaseType: .mysql))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ struct SQLStatementScannerLocatedTests {
@Test("Handles very large input without crashing")
func largeInput() {
var parts: [String] = []
for i in 0..<200 {
for i in 0..<300 {
parts.append("SELECT \(i) FROM very_long_table_name_for_testing;")
}
let sql = parts.joined(separator: " ")
Expand Down Expand Up @@ -180,4 +180,18 @@ struct SQLStatementScannerLocatedTests {
#expect(first.sql == "SELECT 'it''s here';")
#expect(first.offset == 0)
}

@Test("Cursor inside a trailing comment still returns the raw comment text")
func cursorInsideTrailingCommentReturnsRawText() {
let sql = "SELECT 1; -- note"
let located = SQLStatementScanner.locatedStatementAtCursor(in: sql, cursorPosition: 15)
#expect(located.sql == " -- note")
#expect(located.offset == 9)
}

@Test("statementAtCursor returns comment text for a cursor sitting in a trailing comment")
func statementAtCursorReturnsCommentTextForCursorInComment() {
let result = SQLStatementScanner.statementAtCursor(in: "SELECT 1; -- note", cursorPosition: 15)
#expect(result == "-- note")
}
}
87 changes: 86 additions & 1 deletion TableProTests/Core/Utilities/SQLStatementScannerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,91 @@ final class SQLStatementScannerTests: XCTestCase {
)
}

// MARK: - Comment-Only Segments

func testTrailingLineCommentAfterSemicolonIsDropped() {
XCTAssertEqual(
SQLStatementScanner.allStatements(in: "SELECT 1; -- note"),
["SELECT 1"]
)
}

func testTrailingBlockCommentAfterSemicolonIsDropped() {
XCTAssertEqual(
SQLStatementScanner.allStatements(in: "SELECT 1; /* note */"),
["SELECT 1"]
)
}

func testMiddleCommentOnlySegmentIsDropped() {
XCTAssertEqual(
SQLStatementScanner.allStatements(in: "SELECT 1; /* x */; SELECT 2"),
["SELECT 1", "SELECT 2"]
)
}

func testCommentOnlyWholeInputReturnsNoStatements() {
XCTAssertEqual(SQLStatementScanner.allStatements(in: "-- note"), [])
XCTAssertEqual(SQLStatementScanner.allStatements(in: "/* note */"), [])
}

func testMixedCommentAndWhitespaceMultilineSegmentIsDropped() {
let sql = "SELECT 1;\n -- first\n /* second */ \n; SELECT 2"
XCTAssertEqual(
SQLStatementScanner.allStatements(in: sql),
["SELECT 1", "SELECT 2"]
)
}

func testConsecutiveSemicolonsStillDropped() {
XCTAssertEqual(
SQLStatementScanner.allStatements(in: "SELECT 1;;"),
["SELECT 1"]
)
}

func testUnclosedTrailingBlockCommentIsDropped() {
XCTAssertEqual(
SQLStatementScanner.allStatements(in: "SELECT 1; /* note"),
["SELECT 1"]
)
}

func testLineCommentAtEndOfInputIsDropped() {
XCTAssertEqual(
SQLStatementScanner.allStatements(in: "SELECT 1; --"),
["SELECT 1"]
)
}

func testMySQLVersionedCommentSegmentIsKept() {
XCTAssertEqual(
SQLStatementScanner.allStatements(in: "/*!40101 SET @OLD=1 */; SELECT 1", dialect: .mysql),
["/*!40101 SET @OLD=1 */", "SELECT 1"]
)
}

func testVersionedCommentKeptRegardlessOfDialect() {
XCTAssertEqual(
SQLStatementScanner.allStatements(in: "/*!40101 SET @OLD=1 */;", dialect: .generic),
["/*!40101 SET @OLD=1 */"]
)
}

func testInStatementCommentsArePreserved() {
XCTAssertEqual(
SQLStatementScanner.allStatements(in: "SELECT 1 -- hint\n; SELECT 2 /* keep */"),
["SELECT 1 -- hint", "SELECT 2 /* keep */"]
)
}

func testPreservingSemicolonsDropsCommentOnlySegment() {
XCTAssertEqual(
SQLStatementScanner.allStatementsPreservingSemicolons(in: "SELECT 1; -- note"),
["SELECT 1;"]
)
}

// MARK: - Dollar Quoting (PostgreSQL)

func testDollarQuotedDoBlockKeepsInternalSemicolons() {
Expand Down Expand Up @@ -238,7 +323,7 @@ final class SQLStatementScannerTests: XCTestCase {
)
}

func testNoSemicolonsFastPath() {
func testNoSemicolonsWholeInputIsOneStatement() {
let sql = "SELECT * FROM users"
XCTAssertEqual(
SQLStatementScanner.statementAtCursor(in: sql, cursorPosition: 5),
Expand Down
Loading