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
22 changes: 19 additions & 3 deletions Sources/Simpleton/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
55 changes: 48 additions & 7 deletions Sources/Simpleton/Panels/SQL/SQLPanelModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -246,6 +261,8 @@ final class SQLPanelModel: ObservableObject {
driver = nil
isConnected = false
result = nil
results = []
selectedResultIndex = 0
editable = nil
foreignKeyMatches = [:]
lastCommit = nil
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion Sources/Simpleton/Panels/SQL/SQLPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
)
}
}
Expand Down
49 changes: 49 additions & 0 deletions Sources/Simpleton/Panels/SQL/SQLResultsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand All @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion Sources/Simpleton/Panels/SQL/SQLWorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
45 changes: 45 additions & 0 deletions Sources/SimpletonSQL/SQLStatementSplitter.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
39 changes: 39 additions & 0 deletions Tests/CoreChecks/SQLStatementSplitterChecks.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
1 change: 1 addition & 0 deletions Tests/CoreChecks/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ runSQLClientCommandChecks(runner)
runSQLCellFormattingChecks(runner)
runSQLGridDataChecks(runner)
runSQLTokenizerChecks(runner)
runSQLStatementSplitterChecks(runner)
runSQLResultExporterChecks(runner)
runSQLRunStatsChecks(runner)
runSQLEditableQueryChecks(runner)
Expand Down
Loading