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
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -182,5 +182,7 @@ Native/DamengBridge/lib
.docs/
/plans/reports

# Working copies produced by scripts/localization.py; the catalog is the source of truth
Localization/
# Working copies produced by scripts/localization.py; the catalog is the source of truth.
# Anchored: unanchored, it also matched TableProTests/Localization/, so the string-catalog
# guard suite existed on disk and was invisible to git and to CI.
/Localization/
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Procedures, functions and triggers in the quick switcher.
- Argument signatures on routine rows, shown when two routines in a section share a name.
- Schema-wide `list_triggers` for MCP clients, and `return_type` and `language` on `list_routines`.
- Compare, a fourth EXPLAIN plan mode that reports what changed against an earlier run of the same query. (#2380)
- Pinning a saved EXPLAIN plan, to keep it through history cleanup. (#2380)

### Changed

Expand Down
34 changes: 29 additions & 5 deletions TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ extension QueryExecutionCoordinator {
executionTime: executionTime,
rowCount: rows.count,
sql: sql,
historySQL: historySQL ?? sql,
connection: conn,
queryParameterValues: queryParameterValues,
anchor: anchor
Expand Down Expand Up @@ -290,10 +291,30 @@ extension QueryExecutionCoordinator {
executionTime: TimeInterval,
rowCount: Int,
sql: String,
historySQL: String,
connection conn: DatabaseConnection,
queryParameterValues: [QueryParameter]?,
anchor: StatementAnchor? = nil
) {
let databaseName = historyDatabaseName(tabId: tabId)
let schemaName = historySchemaName(tabId: tabId)
let historyId = UUID()
let captured = QueryPlanCaptureBuilder.make(
subjectSQL: routed.subjectSQL,
rawPlan: routed.rawText,
format: routed.format,
variantKey: routed.variantKey,
scope: QueryPlanScope(
connectionId: conn.id,
databaseType: conn.type,
databaseName: databaseName,
schemaName: schemaName
),
executionTime: executionTime,
capturedAt: Date(),
historyId: historyId,
queryParameters: queryParameterValues
)
parent.flushBufferToActiveResult(tabId: tabId, pinnedOnly: true)
parent.tabManager.mutate(tabId: tabId) { tab in
tab.execution.executionTime = executionTime
Expand All @@ -307,7 +328,8 @@ extension QueryExecutionCoordinator {
plan: routed.plan,
sql: sql,
executionTime: executionTime,
anchor: anchor
anchor: anchor,
planContext: captured.context
)]
)
if tab.display.isResultsCollapsed {
Expand All @@ -319,15 +341,17 @@ extension QueryExecutionCoordinator {

recordHistory(
QueryHistoryRecordRequest(
query: sql,
id: historyId,
query: historySQL,
connectionId: conn.id,
databaseName: historyDatabaseName(tabId: tabId),
databaseName: databaseName,
databaseType: conn.type,
schemaName: historySchemaName(tabId: tabId),
schemaName: schemaName,
source: .explain,
executionTime: executionTime,
rowCount: rowCount,
wasSuccessful: true
wasSuccessful: true,
planCapture: captured.capture
)
)
}
Expand Down
57 changes: 55 additions & 2 deletions TablePro/Core/Services/Query/ExplainResultRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ enum ExplainResultRouter {
struct RoutedPlan {
let rawText: String
let plan: QueryPlan?
let format: ExplainPlanFormat
let variantKey: QueryPlanVariantKey
let subjectSQL: String
}

/// A plan either arrives in one column, or is multi-column output the app can actually read
Expand All @@ -30,12 +33,62 @@ enum ExplainResultRouter {
let text = ExplainPlanTextFlattener.flatten(rows: rows)
guard !text.isEmpty else { return nil }

let explainSQL = QueryClassifier.strippingLeadingComments(sql)
let variant = ExplainFormatResolver.matchingVariant(
sql: explainSQL, declaredVariants: declaredVariants
)
let format = ExplainFormatResolver.resolve(
sql: sql, databaseType: databaseType, declaredVariants: declaredVariants
declared: variant?.format ?? .plainText, databaseType: databaseType
)
let plan = ExplainPlanParserRegistry.plan(from: text, format: format)

guard columns.count == 1 || plan != nil else { return nil }
return RoutedPlan(rawText: text, plan: plan)

let subjectSQL = QueryClassifier.explainedStatement(in: explainSQL) ?? sql
return RoutedPlan(
rawText: text,
plan: plan,
format: format,
variantKey: variantKey(
explainSQL: explainSQL,
subjectSQL: subjectSQL,
declaredVariants: declaredVariants,
matched: variant
),
subjectSQL: subjectSQL
)
}

/// Which chain of saved plans this run belongs to.
///
/// A typed `EXPLAIN (ANALYZE, BUFFERS)` and a plain `EXPLAIN` describe the same statement but
/// report different things, so they are separate chains. The options the user typed are the
/// only thing that distinguishes them, and they are keyed by their normalized spelling rather
/// than by a digest of it, so a stored key stays readable in the picker and in a database
/// browser.
///
/// A typed statement that happens to spell a variant the driver declares is folded onto that
/// variant's key, so running EXPLAIN from the toolbar and typing the same thing by hand share
/// one history.
private static func variantKey(
explainSQL: String,
subjectSQL: String,
declaredVariants: [ExplainVariant],
matched: ExplainVariant?
) -> QueryPlanVariantKey {
guard let subjectRange = explainSQL.range(of: subjectSQL, options: [.literal, .backwards]) else {
return matched.map { .declared($0.id) } ?? .driverBuilt
}

let preamble = SQLPreambleNormalizer.normalize(String(explainSQL[..<subjectRange.lowerBound]))
guard !preamble.isEmpty else {
return matched.map { .declared($0.id) } ?? .driverBuilt
}
if let declared = declaredVariants.first(where: {
SQLPreambleNormalizer.normalize($0.sqlPrefix) == preamble
}) {
return .declared(declared.id)
}
return .typed(preamble: preamble)
}
}
46 changes: 44 additions & 2 deletions TablePro/Core/Storage/QueryHistoryManager.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Combine
import Foundation

final class QueryHistoryManager: QueryHistoryRecording, QueryHistoryReading, Sendable {
final class QueryHistoryManager: QueryHistoryRecording, QueryHistoryReading, QueryPlanSnapshotReading, Sendable {
static let shared = QueryHistoryManager()

private let storage: QueryHistoryStorage
Expand All @@ -20,6 +20,7 @@ final class QueryHistoryManager: QueryHistoryRecording, QueryHistoryReading, Sen
@discardableResult
func record(_ request: QueryHistoryRecordRequest) async -> Bool {
let entry = QueryHistoryEntry(
id: request.id,
query: request.query,
connectionId: request.connectionId,
databaseName: request.databaseName,
Expand All @@ -31,7 +32,15 @@ final class QueryHistoryManager: QueryHistoryRecording, QueryHistoryReading, Sen
wasSuccessful: request.wasSuccessful,
errorMessage: request.errorMessage
)
return await record(entry)
let stored = await record(entry)

/// Written after the history row rather than with it: `plan_snapshots.history_id` is a
/// foreign key, so the row it points at has to exist first, and a plan that fails to store
/// must not take the history entry down with it.
if stored, let capture = request.planCapture {
await storage.recordPlanSnapshot(capture)
}
return stored
}

/// The single writer, so pausing here covers every source: the editor, the grid, structure
Expand Down Expand Up @@ -63,6 +72,39 @@ final class QueryHistoryManager: QueryHistoryRecording, QueryHistoryReading, Sen
await storage.count(scope: scope)
}

// MARK: - Plan snapshots

func planSnapshots(
matching identity: QueryPlanIdentity,
excluding excludedId: UUID?,
limit: Int
) async -> [QueryPlanSnapshotSummary] {
await storage.planSnapshots(matching: identity, excluding: excludedId, limit: limit)
}

func planSnapshotRawText(id: UUID) async -> String? {
await storage.planSnapshotRawText(id: id)
}

func planSnapshotUsage() async -> QueryPlanStorageUsage {
await storage.planSnapshotUsage()
}

@discardableResult
func setPlanSnapshotPinned(id: UUID, isPinned: Bool) async -> Bool {
await storage.setPlanSnapshotPinned(id: id, isPinned: isPinned)
}

@discardableResult
func deletePlanSnapshot(id: UUID) async -> Bool {
await storage.deletePlanSnapshot(id: id)
}

@discardableResult
func clearPlanSnapshots() async -> Bool {
await storage.clearPlanSnapshots()
}

func insights(
_ request: QueryInsightsRequest,
slowestRanking: QueryInsightsSlowestRanking
Expand Down
17 changes: 17 additions & 0 deletions TablePro/Core/Storage/QueryHistoryRecording.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,23 @@ protocol QueryHistoryReading: Sendable {
) async -> QueryInsightsSnapshot
}

/// Saved EXPLAIN plans. Separate from `QueryHistoryReading` because a plan is a different artifact
/// with a different lifetime, and because the comparison pane needs only these four calls.
protocol QueryPlanSnapshotReading: Sendable {
func planSnapshots(
matching identity: QueryPlanIdentity,
excluding excludedId: UUID?,
limit: Int
) async -> [QueryPlanSnapshotSummary]

func planSnapshotRawText(id: UUID) async -> String?

@discardableResult
func setPlanSnapshotPinned(id: UUID, isPinned: Bool) async -> Bool

func isStoreAvailable() async -> Bool
}

extension QueryHistoryReading {
func isStoreAvailable() async -> Bool { true }

Expand Down
Loading
Loading