Skip to content
Open
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Explain Analyze running write statements on Read-Only connections and skipping the Alert and Safe Mode confirmation.
- Remote deletions of connections, groups, tags, SSH profiles and table favorites applied with their sync category off.
- **Local only** connections taking edits and deletions made on another device.
- Saved filters, layout, favorite and Recent entry kept by a table dropped or renamed from a query tab or MCP client.
- Export dialog offering a SQL row scope on MongoDB, Redis and other engines without SQL.
- CSV, XLSX and MQL exports ignoring a table's row filter, row limit and column choice.
- MQL export rounding 64-bit integers past 2^53 and restoring whole doubles and small 64-bit integers as 32-bit ones.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,25 @@ struct SyncRecordMapperTests {
let decoded = try #require(SyncRecordMapper.toConnection(record))
#expect(decoded.safeModeLevel == .confirmWrites)
}

@Test("An unrecognized wire value keeps the legacy read-only restriction")
func unknownWireValuePreservesReadOnly() throws {
let record = makeRawRecord(safeModeLevelRaw: "someFutureLevel", isReadOnly: true)
let decoded = try #require(SyncRecordMapper.toConnection(record))
#expect(decoded.safeModeLevel == .readOnly)
}

@Test("A rename preserves an unrecognized wire value and requires confirmation")
func renamePreservesUnknownWireValue() throws {
let record = makeRawRecord(safeModeLevelRaw: "someFutureLevel")
var connection = try #require(SyncRecordMapper.toConnection(record))
connection.name = "Renamed"

SyncRecordMapper.updateRecord(record, with: connection)

#expect(record["safeModeLevel"] as? String == "someFutureLevel")
let decoded = try #require(SyncRecordMapper.toConnection(record))
#expect(decoded.name == "Renamed")
#expect(decoded.safeModeLevel == .confirmWrites)
}
}
28 changes: 21 additions & 7 deletions TablePro/Core/Coordinators/QueryExecutionCoordinator+Batches.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ private struct BatchRun {
let outcome: BatchStatementOutcome<BatchOutput>
let plan: BatchTransactionPlan
let sessionState: PluginSessionTransactionState
let startState: PluginSessionTransactionState
var failureOutput: PluginServerOutput = .none
}

Expand Down Expand Up @@ -137,7 +138,7 @@ extension QueryExecutionCoordinator {
let batchTask = Task { [weak self, parent] in
guard let self else { return }
let run = await runBatches(prepared, scope: scope, mode: mode, claim: claim, lease: lease)
postRanStatements(of: prepared, outcome: run.outcome, connection: conn)
postRanStatements(of: prepared, run: run, scope: scope, connection: conn)

let sessionNotice = Self.runNotice(outcome: run.outcome, sessionState: run.sessionState)
switch run.outcome {
Expand Down Expand Up @@ -236,7 +237,8 @@ extension QueryExecutionCoordinator {
route: DatabaseManager.shared.executionRoute(for: scope),
cancellation: .cancellableRead(lease)
) { driver in
let plan = BatchTransactionPlan.autocommit.joining(await driver.heldSessionTransactionState())
let startState = await driver.heldSessionTransactionState()
let plan = BatchTransactionPlan.autocommit.joining(startState)
let outcome = await BatchStatementRun.run(
prepared,
plan: plan,
Expand All @@ -251,18 +253,21 @@ extension QueryExecutionCoordinator {
try await Self.runBatch(batch, driver: driver, failureOutput: failureOutput)
}
let sessionState = await driver.heldSessionTransactionState()
return BatchRun(outcome: outcome, plan: plan, sessionState: sessionState)
return BatchRun(outcome: outcome, plan: plan, sessionState: sessionState, startState: startState)
}
run.failureOutput = failureOutput.output
return run
} catch {
if DatabaseCancellationDiagnosis.isCancellation(error) || Task.isCancelled {
return BatchRun(outcome: .cancelled(results: []), plan: .autocommit, sessionState: .unknown)
return BatchRun(
outcome: .cancelled(results: []), plan: .autocommit, sessionState: .unknown, startState: .unknown
)
}
return BatchRun(
outcome: .failed(results: [], failure: .connection, errorDescription: error.localizedDescription),
plan: .autocommit,
sessionState: .unknown
sessionState: .unknown,
startState: .unknown
)
}
}
Expand Down Expand Up @@ -316,11 +321,12 @@ extension QueryExecutionCoordinator {
/// fetch, missing what did leaves the sidebar wrong.
private func postRanStatements(
of prepared: [PreparedBatch],
outcome: BatchStatementOutcome<BatchOutput>,
run: BatchRun,
scope: DatabaseScope,
connection: DatabaseConnection
) {
let ranCount: Int
switch outcome {
switch run.outcome {
case .completed, .cancelled:
ranCount = prepared.count
case .failed(let outputs, let failure, _):
Expand All @@ -330,6 +336,14 @@ extension QueryExecutionCoordinator {
CatalogChangeService.post(
.statementsRan(connectionId: connection.id, statements: statements, databaseType: connection.type)
)
CatalogChangeService.post(.statementsSucceeded(SucceededStatements(
scope: scope,
databaseType: connection.type,
statements: prepared.prefix(run.outcome.succeededCount).flatMap(\.batch.executedStatementTexts),
commit: .run(
startedIn: run.startState, endedIn: run.sessionState, plan: run.plan, completed: run.outcome.isCompleted
)
)))
}

// MARK: - Results
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ private struct MultiStatementRun {
let outcome: BatchStatementOutcome<QueryResult>
let plan: BatchTransactionPlan
let sessionState: PluginSessionTransactionState
let startState: PluginSessionTransactionState
/// What the session held once the run ended, asked of a run that joined the session's
/// transaction or drops or renames a table, and `.unknown` for any other.
let endState: PluginSessionTransactionState
var failureOutput: PluginServerOutput = .none
}

Expand Down Expand Up @@ -153,28 +157,31 @@ extension QueryExecutionCoordinator {

let boundValues = BoundParameterValues(values: parameters)
let failureOutput = ServerOutputBox()
let grammar = parent.lexicalGrammar
let parameterizedTask = Task { [weak self, parent] in
guard let self else { return }

let schemaTask = QueryExecutor.schemaFetch(tableName: needsMetadataFetch ? tableName : nil, scope: scope)

do {
let fetchResult = try await DatabaseManager.shared.withScopedDriver(
let (fetchResult, tableEdits) = try await DatabaseManager.shared.withScopedDriver(
scope: scope,
route: DatabaseManager.shared.executionRoute(for: scope),
cancellation: .cancellableRead(lease)
) { [queryExecutor = parent.queryExecutor, boundValues] driver in
try await queryExecutor.executeQuery(
let fetched = try await queryExecutor.executeQuery(
driver: driver,
sql: statement.sql,
parameters: boundValues.values,
rowCap: rowCap,
capturingOutputInto: failureOutput
)
let edits = await SucceededStatements.single(
statement.sql, scope: scope, databaseType: conn.type, grammar: grammar, ranOn: driver
)
return (fetched, edits)
}
CatalogChangeService.post(
.statementsRan(connectionId: conn.id, statements: [statement.sql], databaseType: conn.type)
)
MainContentCoordinator.postStatementRan(statement.sql, on: conn, succeeded: tableEdits)

guard !Task.isCancelled else {
schemaTask?.cancel()
Expand Down Expand Up @@ -313,6 +320,9 @@ extension QueryExecutionCoordinator {
grammar: grammar
)
}
let asksEndState = SucceededStatements.runNeedsEndState(
prepared.map(\.sentSQL), databaseType: conn.type, grammar: grammar
)

let multiStatementTask = Task { [weak self, parent] in
guard let self else { return }
Expand All @@ -322,6 +332,7 @@ extension QueryExecutionCoordinator {
scope: scope,
mode: transactionKind.transactionAccessMode,
plan: plan,
asksEndState: asksEndState,
claim: claim,
lease: lease
)
Expand All @@ -341,6 +352,14 @@ extension QueryExecutionCoordinator {
CatalogChangeService.post(
.statementsRan(connectionId: conn.id, statements: ranStatements, databaseType: conn.type)
)
CatalogChangeService.post(.statementsSucceeded(SucceededStatements(
scope: scope,
databaseType: conn.type,
statements: prepared.prefix(outcome.succeededCount).map(\.sentSQL),
commit: .run(
startedIn: run.startState, endedIn: run.endState, plan: run.plan, completed: outcome.isCompleted
)
)))

switch outcome {
case .cancelled(let results):
Expand Down Expand Up @@ -426,6 +445,7 @@ extension QueryExecutionCoordinator {
scope: DatabaseScope,
mode: PluginTransactionAccessMode,
plan: BatchTransactionPlan,
asksEndState: Bool,
claim: TabExecutionClaim,
lease: DriverLeaseOwner
) async -> MultiStatementRun {
Expand All @@ -436,7 +456,8 @@ extension QueryExecutionCoordinator {
route: DatabaseManager.shared.executionRoute(for: scope),
cancellation: .cancellableRead(lease)
) { driver in
let sessionPlan = plan.joining(await driver.heldSessionTransactionState())
let startState = await driver.heldSessionTransactionState()
let sessionPlan = plan.joining(startState)
let outcome = await BatchStatementRun.run(
prepared,
plan: sessionPlan,
Expand All @@ -457,24 +478,44 @@ extension QueryExecutionCoordinator {
}
}
guard sessionPlan == .sessionTransaction else {
return MultiStatementRun(outcome: outcome, plan: sessionPlan, sessionState: .idle)
let endState: PluginSessionTransactionState = asksEndState
? await driver.heldSessionTransactionState()
: .unknown
return MultiStatementRun(
outcome: outcome,
plan: sessionPlan,
sessionState: .idle,
startState: startState,
endState: endState
)
}
let sessionState = await driver.heldSessionTransactionState()
return MultiStatementRun(
outcome: outcome,
plan: sessionPlan,
sessionState: await driver.heldSessionTransactionState()
sessionState: sessionState,
startState: startState,
endState: sessionState
)
}
run.failureOutput = failureOutput.output
return run
} catch {
if DatabaseCancellationDiagnosis.isCancellation(error) || Task.isCancelled {
return MultiStatementRun(outcome: .cancelled(results: []), plan: plan, sessionState: .unknown)
return MultiStatementRun(
outcome: .cancelled(results: []),
plan: plan,
sessionState: .unknown,
startState: .unknown,
endState: .unknown
)
}
return MultiStatementRun(
outcome: .failed(results: [], failure: .connection, errorDescription: error.localizedDescription),
plan: plan,
sessionState: .unknown
sessionState: .unknown,
startState: .unknown,
endState: .unknown
)
}
}
Expand Down
Loading
Loading