diff --git a/Sources/Simpleton/AppDelegate.swift b/Sources/Simpleton/AppDelegate.swift index b0360d7..85ff572 100644 --- a/Sources/Simpleton/AppDelegate.swift +++ b/Sources/Simpleton/AppDelegate.swift @@ -1013,15 +1013,31 @@ class AppDelegate: NSObject, NSApplicationDelegate { // 6. Database switcher: connect loads the database list; SQLite exposes "main" as active. let dbLoaded = model.databases.contains("main") && model.selectedDatabase == "main" + // 7. Multi-statement run → one read-only result tab per statement; selecting a tab shows it. + model.queryText = "SELECT id FROM t; SELECT name FROM t" + await model.runQuery() + let twoTabs = model.results.count == 2 && model.editable == nil + model.selectResult(0) + var firstIsID = false + if case .rows(let cols, _) = model.result { firstIsID = cols.first?.name == "id" } + model.selectResult(1) + var secondIsName = false + if case .rows(let cols, _) = model.result { secondIsName = cols.first?.name == "name" } + let tabsOK = twoTabs && firstIsID && secondIsName + // A single statement collapses back to one tab and stays editable. + model.queryText = "SELECT id, name FROM t" + await model.runQuery() + let singleTab = model.results.count == 1 && model.editable?.table == "t" + let ok = connected && editableDetected && aggregateNotEditable && committedOK && wroteValue - && timedOK && clearedOnError && savedOK && removedOK && dbLoaded + && timedOK && clearedOnError && savedOK && removedOK && dbLoaded && tabsOK && singleTab NSLog( "SIMP-SQLE2E RESULT %@: connected=%@ editable=%@ aggNotEditable=%@ committed=%@ wrote=%@ " - + "timed=%@ clearedOnErr=%@ saved=%@ removed=%@ dbLoaded=%@ error=%@", + + "timed=%@ clearedOnErr=%@ saved=%@ removed=%@ dbLoaded=%@ tabs=%@ singleTab=%@ error=%@", ok ? "PASS" : "FAIL", "\(connected)", "\(editableDetected)", "\(aggregateNotEditable)", "\(committedOK)", "\(wroteValue)", "\(timedOK)", "\(clearedOnError)", "\(savedOK)", "\(removedOK)", - "\(dbLoaded)", model.errorMessage ?? "nil") + "\(dbLoaded)", "\(tabsOK)", "\(singleTab)", model.errorMessage ?? "nil") NSApp.terminate(nil) } } diff --git a/Sources/Simpleton/Panels/SQL/SQLPanelModel.swift b/Sources/Simpleton/Panels/SQL/SQLPanelModel.swift index 7dd38a5..694e1df 100644 --- a/Sources/Simpleton/Panels/SQL/SQLPanelModel.swift +++ b/Sources/Simpleton/Panels/SQL/SQLPanelModel.swift @@ -31,12 +31,27 @@ struct CommitOutcome: Equatable { let errorMessage: String? } +/// One statement's result within a multi-statement run: the statement text and what it returned. The +/// results view shows one tab per `StatementResult` when a script produced more than one. +struct StatementResult { + let sql: String + let result: QueryResult +} + @MainActor final class SQLPanelModel: ObservableObject { @Published var connections: [Connection] = [] @Published var selectedID: UUID? @Published var queryText: String = "" + /// The currently displayed result — the active tab's result when a script returned several. Kept in + /// sync with `selectedResultIndex` so the existing single-result plumbing (grid, editable, FK) is + /// unchanged. @Published var result: QueryResult? + /// One entry per statement in the last run. Length > 1 means a multi-statement script; the results + /// view then shows a tab per statement. Single-statement runs hold exactly one entry. + @Published var results: [StatementResult] = [] + /// Which of `results` is shown (and mirrored into `result`). Reset per run. + @Published var selectedResultIndex = 0 @Published var errorMessage: String? @Published var isConnecting = false @Published var isConnected = false @@ -246,6 +261,8 @@ final class SQLPanelModel: ObservableObject { driver = nil isConnected = false result = nil + results = [] + selectedResultIndex = 0 editable = nil foreignKeyMatches = [:] lastCommit = nil @@ -277,18 +294,33 @@ final class SQLPanelModel: ObservableObject { /// `lastCommit`. private func performQuery(sql overrideSQL: String? = nil) async { guard let driver else { return } - let sql = (overrideSQL ?? queryText).trimmingCharacters(in: .whitespacesAndNewlines) - guard !sql.isEmpty else { return } + let script = (overrideSQL ?? queryText).trimmingCharacters(in: .whitespacesAndNewlines) + let statements = SQLStatementSplitter.split(script) + guard !statements.isEmpty else { return } errorMessage = nil let start = DispatchTime.now() do { - let queryResult = try await driver.run(sql) + // Run each statement in order, one result tab per statement. Editing/FK navigation stay + // single-statement affordances: they need a single-table SELECT context that a script + // doesn't provide, so a multi-statement run is read-only. + var collected: [StatementResult] = [] + for statement in statements { + collected.append(StatementResult(sql: statement, result: try await driver.run(statement))) + } lastQueryDuration = Self.elapsed(since: start) - result = queryResult - editable = await detectEditable(sql: sql, result: queryResult, driver: driver) - foreignKeyMatches = await detectForeignKeys(result: queryResult, editable: editable, driver: driver) + results = collected + selectedResultIndex = collected.count - 1 // show the last statement's result by default + result = collected[selectedResultIndex].result + if statements.count == 1 { + editable = await detectEditable(sql: statements[0], result: collected[0].result, driver: driver) + foreignKeyMatches = await detectForeignKeys( + result: collected[0].result, editable: editable, driver: driver) + } else { + editable = nil + foreignKeyMatches = [:] + } if let id = selectedConnection?.id { - await history.record(sql, for: id) + await history.record(script, for: id) historyItems = await history.history(for: id) } } catch { @@ -299,6 +331,13 @@ final class SQLPanelModel: ObservableObject { } } + /// Show a different statement's result tab (updates `result` for the grid). Bounds-checked no-op. + func selectResult(_ index: Int) { + guard results.indices.contains(index) else { return } + selectedResultIndex = index + result = results[index].result + } + /// Compute the navigable FK cells for `result`. The source table is known only when editable /// detection resolved one (the same conservative single-table parse), so FKs are offered exactly /// then. Reads the table's declared foreign keys from the driver and matches them, by name, to the @@ -334,6 +373,8 @@ final class SQLPanelModel: ObservableObject { let queryResult = try await driver.execute(sql, [value]) lastQueryDuration = Self.elapsed(since: start) result = queryResult + results = [StatementResult(sql: sql, result: queryResult)] + selectedResultIndex = 0 editable = await detectEditable(sql: sql, result: queryResult, driver: driver) foreignKeyMatches = await detectForeignKeys(result: queryResult, editable: editable, driver: driver) if let id = selectedConnection?.id { diff --git a/Sources/Simpleton/Panels/SQL/SQLPanelView.swift b/Sources/Simpleton/Panels/SQL/SQLPanelView.swift index cf180c3..5f6aec4 100644 --- a/Sources/Simpleton/Panels/SQL/SQLPanelView.swift +++ b/Sources/Simpleton/Panels/SQL/SQLPanelView.swift @@ -73,7 +73,10 @@ struct SQLPanelView: View { await model.navigateForeignKey( referencedTable: match.referencedTable, referencedColumn: match.referencedColumn, value: value) - } + }, + statementResults: model.results, + selectedResultIndex: model.selectedResultIndex, + onSelectResult: { model.selectResult($0) } ) } } diff --git a/Sources/Simpleton/Panels/SQL/SQLResultsView.swift b/Sources/Simpleton/Panels/SQL/SQLResultsView.swift index 5cdce79..52e1842 100644 --- a/Sources/Simpleton/Panels/SQL/SQLResultsView.swift +++ b/Sources/Simpleton/Panels/SQL/SQLResultsView.swift @@ -45,9 +45,25 @@ struct SQLResultsView: View { /// Navigate a foreign key: run `SELECT * FROM ref WHERE refcol = ?` with the clicked cell's value /// bound (never interpolated) and show the referenced row. let onNavigateForeignKey: (SQLForeignKeyMatcher.Match, SQLValue) async -> Void + /// One entry per statement from the last run; a tab strip appears when there is more than one. + let statementResults: [StatementResult] + /// Which statement's result is shown (drives `result`, kept in sync by the model). + let selectedResultIndex: Int + /// Switch to another statement's result tab. + let onSelectResult: (Int) -> Void @ObservedObject private var themeSettings = ThemeSettings.shared var body: some View { + VStack(spacing: 0) { + if statementResults.count > 1 { + resultTabBar + ThemedDivider() + } + resultContent + } + } + + @ViewBuilder private var resultContent: some View { switch result { case .none: hint("Run a query to see results.") @@ -67,6 +83,39 @@ struct SQLResultsView: View { } } + /// A horizontal strip of statement tabs, one per result, showing the statement's leading keyword + /// and row/affected count. The active tab is highlighted; clicking one shows that result. + private var resultTabBar: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(Array(statementResults.enumerated()), id: \.offset) { index, sr in + Button { + onSelectResult(index) + } label: { + Text(Self.tabLabel(sr, index)) + .font(DT.monoFont(size: 10)) + .foregroundColor(index == selectedResultIndex ? DT.textPrimary : DT.textTertiary) + .padding(.horizontal, 8).padding(.vertical, 4) + .background(index == selectedResultIndex ? DT.hover : Color.clear) + .cornerRadius(5) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 8).padding(.vertical, 4) + } + } + + /// A compact tab label: 1-based position, the statement's leading keyword, and its row/affected + /// count — e.g. `1. SELECT (12)`. + private static func tabLabel(_ sr: StatementResult, _ index: Int) -> String { + let verb = sr.sql.split(whereSeparator: { $0.isWhitespace }).first.map { String($0).uppercased() } ?? "SQL" + switch sr.result { + case .rows(_, let rows): return "\(index + 1). \(verb) (\(rows.count))" + case .status(let affected, _): return "\(index + 1). \(verb) (\(affected))" + } + } + /// A per-result identity so SwiftUI recreates `SQLRowsView` (resetting its /// mode/sort/selection state) when the query changes shape. private func resultIdentity(columns: [Column], rowCount: Int) -> String { diff --git a/Sources/Simpleton/Panels/SQL/SQLWorkspaceView.swift b/Sources/Simpleton/Panels/SQL/SQLWorkspaceView.swift index b37f62d..7ec2709 100644 --- a/Sources/Simpleton/Panels/SQL/SQLWorkspaceView.swift +++ b/Sources/Simpleton/Panels/SQL/SQLWorkspaceView.swift @@ -159,7 +159,10 @@ struct SQLWorkspaceView: View { await model.navigateForeignKey( referencedTable: match.referencedTable, referencedColumn: match.referencedColumn, value: value) - } + }, + statementResults: model.results, + selectedResultIndex: model.selectedResultIndex, + onSelectResult: { model.selectResult($0) } ) .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/Sources/SimpletonSQL/SQLStatementSplitter.swift b/Sources/SimpletonSQL/SQLStatementSplitter.swift new file mode 100644 index 0000000..5a21025 --- /dev/null +++ b/Sources/SimpletonSQL/SQLStatementSplitter.swift @@ -0,0 +1,45 @@ +// Sources/SimpletonSQL/SQLStatementSplitter.swift +import Foundation + +/// Splits a SQL script into individual statements on top-level `;`. Semicolons inside string literals, +/// comments, and quoted identifiers do not split — the boundaries come from `SQLTokenizer`, so the +/// rule matches the editor's own lexing. Pure and headless, so the splitting is unit-tested without a +/// driver. Used to run a multi-statement script and show one result tab per statement. +public enum SQLStatementSplitter { + /// Return the non-empty, trimmed statements in `sql`. A script with no top-level `;` yields one + /// element (the whole trimmed text); trailing/empty fragments are dropped. Whitespace/comment-only + /// input yields `[]`. + public static func split(_ sql: String) -> [String] { + let ns = sql as NSString + guard ns.length > 0 else { return [] } + // Offsets covered by a string / comment / quoted-identifier token, where a `;` is not a + // separator. (Bare identifiers can't contain `;`, so including them is harmless.) + let protectedRanges = + SQLTokenizer.tokens(in: sql) + .filter { $0.kind == .string || $0.kind == .comment || $0.kind == .identifier } + .map { NSRange(location: $0.location, length: $0.length) } + + func isProtected(_ location: Int) -> Bool { + protectedRanges.contains { location >= $0.location && location < $0.location + $0.length } + } + + let semicolon = UInt16(UnicodeScalar(";").value) + var statements: [String] = [] + var start = 0 + var i = 0 + func flush(upTo end: Int) { + let fragment = ns.substring(with: NSRange(location: start, length: end - start)) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !fragment.isEmpty { statements.append(fragment) } + } + while i < ns.length { + if ns.character(at: i) == semicolon && !isProtected(i) { + flush(upTo: i) + start = i + 1 + } + i += 1 + } + flush(upTo: ns.length) + return statements + } +} diff --git a/Tests/CoreChecks/SQLStatementSplitterChecks.swift b/Tests/CoreChecks/SQLStatementSplitterChecks.swift new file mode 100644 index 0000000..ed7c8e6 --- /dev/null +++ b/Tests/CoreChecks/SQLStatementSplitterChecks.swift @@ -0,0 +1,39 @@ +import Foundation +import SimpletonSQL + +func runSQLStatementSplitterChecks(_ t: TestRunner) { + t.suite("SQLStatementSplitter basics") { + t.expectEqual(SQLStatementSplitter.split("SELECT 1; SELECT 2"), ["SELECT 1", "SELECT 2"], "two statements") + t.expectEqual(SQLStatementSplitter.split("SELECT 1"), ["SELECT 1"], "single statement, no semicolon") + t.expectEqual(SQLStatementSplitter.split("SELECT 1;"), ["SELECT 1"], "trailing semicolon dropped") + t.expectEqual(SQLStatementSplitter.split(" ;; "), [], "only separators / whitespace → empty") + t.expectEqual(SQLStatementSplitter.split(""), [], "empty input → empty") + } + + t.suite("SQLStatementSplitter ignores ; in strings + comments") { + t.expectEqual( + SQLStatementSplitter.split("SELECT ';'; SELECT 2"), + ["SELECT ';'", "SELECT 2"], "semicolon inside a string does not split") + t.expectEqual( + SQLStatementSplitter.split("SELECT 1 -- a; b\n; SELECT 2"), + ["SELECT 1 -- a; b", "SELECT 2"], "semicolon inside a line comment does not split") + t.expectEqual( + SQLStatementSplitter.split("SELECT /* a; b */ 1; SELECT 2"), + ["SELECT /* a; b */ 1", "SELECT 2"], "semicolon inside a block comment does not split") + } + + t.suite("SQLStatementSplitter ignores ; in quoted identifiers") { + t.expectEqual( + SQLStatementSplitter.split("SELECT 1 AS \"a;b\"; SELECT 2"), + ["SELECT 1 AS \"a;b\"", "SELECT 2"], "semicolon inside a quoted identifier does not split") + } + + t.suite("SQLStatementSplitter trims whitespace + blank fragments") { + t.expectEqual( + SQLStatementSplitter.split(" SELECT 1 ;\n\n SELECT 2 ;\n"), + ["SELECT 1", "SELECT 2"], "each statement trimmed, blank tail dropped") + t.expectEqual( + SQLStatementSplitter.split("SELECT 1;; SELECT 2"), + ["SELECT 1", "SELECT 2"], "empty statement between semicolons dropped") + } +} diff --git a/Tests/CoreChecks/main.swift b/Tests/CoreChecks/main.swift index bfcf30d..0924610 100644 --- a/Tests/CoreChecks/main.swift +++ b/Tests/CoreChecks/main.swift @@ -39,6 +39,7 @@ runSQLClientCommandChecks(runner) runSQLCellFormattingChecks(runner) runSQLGridDataChecks(runner) runSQLTokenizerChecks(runner) +runSQLStatementSplitterChecks(runner) runSQLResultExporterChecks(runner) runSQLRunStatsChecks(runner) runSQLEditableQueryChecks(runner)