From 2c2c7a04dba1d41be325ed1420a030b8393f0f96 Mon Sep 17 00:00:00 2001 From: sophiathedev Date: Sat, 22 Aug 2026 15:08:23 +0700 Subject: [PATCH 1/2] feat(explain): save and diff query plans --- CHANGELOG.md | 1 + .../QueryExecutionCoordinator+Helpers.swift | 32 +- .../Services/Query/ExplainResultRouter.swift | 69 +- .../Core/Storage/QueryHistoryManager.swift | 31 +- .../QueryHistoryStorage+ExplainPlans.swift | 267 +++++ .../Core/Storage/QueryHistoryStorage.swift | 117 ++- .../Core/Utilities/SQL/QueryClassifier.swift | 48 +- .../Models/Query/ExplainPlanHistory.swift | 70 ++ TablePro/Models/Query/ExplainRequest.swift | 12 +- .../Query/ExplainResultSetFactory.swift | 4 +- .../Query/QueryHistoryRecordRequest.swift | 5 +- TablePro/Models/Query/QueryPlan.swift | 4 +- .../Models/Query/QueryPlanComparison.swift | 607 +++++++++++ TablePro/Models/Query/ResultSet.swift | 1 + TablePro/Resources/Localizable.xcstrings | 952 ++++++++++++++++++ .../Main/Child/MainEditorContentView.swift | 21 +- .../MainContentCoordinator+Explain.swift | 31 +- .../QueryPlan/QueryPlanHistoryView.swift | 634 ++++++++++++ .../Views/QueryPlan/QueryPlanResultView.swift | 53 + .../Query/ExplainResultRouterTests.swift | 92 +- .../ExplainPlanHistoryStorageTests.swift | 535 ++++++++++ .../Storage/QueryHistoryMigrationTests.swift | 63 ++ .../Utilities/SQL/QueryClassifierTests.swift | 47 +- .../Models/Query/ExplainRequestTests.swift | 94 ++ .../Query/QueryPlanComparisonTests.swift | 278 +++++ .../QueryPlanHistoryLocalizationTests.swift | 122 +++ .../QueryPlanPresentationTests.swift | 87 ++ TableProUITests/QueryPlanResultUITests.swift | 122 +++ docs/features/explain-visualization.mdx | 10 + docs/features/query-history.mdx | 8 + 30 files changed, 4374 insertions(+), 43 deletions(-) create mode 100644 TablePro/Core/Storage/QueryHistoryStorage+ExplainPlans.swift create mode 100644 TablePro/Models/Query/ExplainPlanHistory.swift create mode 100644 TablePro/Models/Query/QueryPlanComparison.swift create mode 100644 TablePro/Views/QueryPlan/QueryPlanHistoryView.swift create mode 100644 TableProTests/Core/Storage/ExplainPlanHistoryStorageTests.swift create mode 100644 TableProTests/Models/Query/QueryPlanComparisonTests.swift create mode 100644 TableProTests/Views/QueryPlan/QueryPlanHistoryLocalizationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e20639c9e..098ecd39b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- EXPLAIN plans now keep history and compare against earlier compatible runs to show cost, row-estimate and operation changes. - Database favorites can be tagged by environment, filtered in the Favorites sidebar, opened directly, and synced through iCloud. (#1553) - Open in Window on a row inspector text field, for reading or editing a long value on a bigger surface. diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 156cfc180..0abfd0048 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -129,6 +129,7 @@ extension QueryExecutionCoordinator { executionTime: executionTime, rowCount: rows.count, sql: sql, + historySQL: historySQL ?? sql, connection: conn, queryParameterValues: queryParameterValues, anchor: anchor @@ -290,10 +291,29 @@ 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 captureContext = ExplainPlanHistoryContext( + historyId: UUID(), + subjectQuery: routed.subjectSQL, + connectionId: conn.id, + databaseName: databaseName, + databaseType: conn.type, + schemaName: schemaName, + variantId: routed.variantId, + formatRawValue: routed.format.rawValue, + capturedAt: Date() + ) + let historyCapture = ExplainPlanHistoryCapture.make( + context: captureContext, + rawText: routed.rawText, + queryParameters: queryParameterValues + ) parent.flushBufferToActiveResult(tabId: tabId, pinnedOnly: true) parent.tabManager.mutate(tabId: tabId) { tab in tab.execution.executionTime = executionTime @@ -307,7 +327,8 @@ extension QueryExecutionCoordinator { plan: routed.plan, sql: sql, executionTime: executionTime, - anchor: anchor + anchor: anchor, + historyContext: historyCapture?.context )] ) if tab.display.isResultsCollapsed { @@ -319,15 +340,16 @@ extension QueryExecutionCoordinator { recordHistory( QueryHistoryRecordRequest( - query: sql, + 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, + explainPlan: historyCapture?.record ) ) } diff --git a/TablePro/Core/Services/Query/ExplainResultRouter.swift b/TablePro/Core/Services/Query/ExplainResultRouter.swift index cd1ea2c56..d7306f929 100644 --- a/TablePro/Core/Services/Query/ExplainResultRouter.swift +++ b/TablePro/Core/Services/Query/ExplainResultRouter.swift @@ -12,6 +12,9 @@ enum ExplainResultRouter { struct RoutedPlan { let rawText: String let plan: QueryPlan? + let format: ExplainPlanFormat + let variantId: String? + let subjectSQL: String } /// A plan either arrives in one column, or is multi-column output the app can actually read @@ -30,12 +33,74 @@ 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, + variantId: historyVariantIdentifier( + explainSQL: explainSQL, + subjectSQL: subjectSQL, + declaredVariants: declaredVariants, + fallback: variant?.id + ), + subjectSQL: subjectSQL + ) + } + + private static func historyVariantIdentifier( + explainSQL: String, + subjectSQL: String, + declaredVariants: [ExplainVariant], + fallback: String? + ) -> String? { + guard let subjectRange = explainSQL.range( + of: subjectSQL, + options: [.literal, .backwards] + ) else { return fallback } + + let preamble = normalizePreamble(String(explainSQL[.. String { + var components: [String] = [] + var token = "" + + func appendToken() { + guard !token.isEmpty else { return } + components.append(token.uppercased()) + token.removeAll(keepingCapacity: true) + } + + for character in sql { + if character.isLetter || character.isNumber || character == "_" { + token.append(character) + } else { + appendToken() + if !character.isWhitespace { + components.append(String(character)) + } + } + } + appendToken() + return components.joined(separator: " ") } } diff --git a/TablePro/Core/Storage/QueryHistoryManager.swift b/TablePro/Core/Storage/QueryHistoryManager.swift index 8d9ecc31c..3815e0d84 100644 --- a/TablePro/Core/Storage/QueryHistoryManager.swift +++ b/TablePro/Core/Storage/QueryHistoryManager.swift @@ -19,19 +19,25 @@ final class QueryHistoryManager: QueryHistoryRecording, QueryHistoryReading, Sen @discardableResult func record(_ request: QueryHistoryRecordRequest) async -> Bool { + guard !isCapturePaused() else { return false } + + let planContext = request.explainPlan?.context let entry = QueryHistoryEntry( + id: planContext?.historyId ?? UUID(), query: request.query, connectionId: request.connectionId, databaseName: request.databaseName, databaseType: request.databaseType, schemaName: request.schemaName, source: request.source, + executedAt: planContext?.capturedAt ?? Date(), executionTime: request.executionTime, rowCount: request.rowCount, wasSuccessful: request.wasSuccessful, errorMessage: request.errorMessage ) - return await record(entry) + let explainPlan = request.explainPlan.flatMap { $0.isWithinStorageLimit ? $0 : nil } + return await storeAndPublish(entry, explainPlan: explainPlan) } /// The single writer, so pausing here covers every source: the editor, the grid, structure @@ -40,10 +46,18 @@ final class QueryHistoryManager: QueryHistoryRecording, QueryHistoryReading, Sen func record(_ entry: QueryHistoryEntry) async -> Bool { guard !isCapturePaused() else { return false } - let success = await storage.record(entry) + return await storeAndPublish(entry, explainPlan: nil) + } + + private func storeAndPublish( + _ entry: QueryHistoryEntry, + explainPlan: ExplainPlanHistoryRecord? + ) async -> Bool { + let success = await storage.record(entry, explainPlan: explainPlan) if success { + let updatedConnectionId: UUID? = explainPlan == nil ? entry.connectionId : nil await MainActor.run { - AppEvents.shared.queryHistoryDidUpdate.send(entry.connectionId) + AppEvents.shared.queryHistoryDidUpdate.send(updatedConnectionId) } } return success @@ -63,6 +77,17 @@ final class QueryHistoryManager: QueryHistoryRecording, QueryHistoryReading, Sen await storage.count(scope: scope) } + func explainPlanHistory( + matching context: ExplainPlanHistoryContext, + limit: Int + ) async -> [ExplainPlanHistorySnapshot] { + await storage.explainPlanHistory(matching: context, limit: limit) + } + + func explainPlanRawText(historyId: UUID) async -> String? { + await storage.explainPlanRawText(historyId: historyId) + } + func insights( _ request: QueryInsightsRequest, slowestRanking: QueryInsightsSlowestRanking diff --git a/TablePro/Core/Storage/QueryHistoryStorage+ExplainPlans.swift b/TablePro/Core/Storage/QueryHistoryStorage+ExplainPlans.swift new file mode 100644 index 000000000..b31a7ecbf --- /dev/null +++ b/TablePro/Core/Storage/QueryHistoryStorage+ExplainPlans.swift @@ -0,0 +1,267 @@ +import Foundation +import SQLite3 + +extension QueryHistoryStorage { + private static let maximumExplainPlanHistoryLimit = 100 + + func createExplainPlanStorage() { + execute(""" + CREATE TABLE IF NOT EXISTS query_plan_snapshots ( + history_id TEXT PRIMARY KEY NOT NULL REFERENCES history(id) ON DELETE CASCADE, + subject_query TEXT NOT NULL, + subject_fingerprint_hash INTEGER NOT NULL, + variant_id TEXT, + format TEXT NOT NULL, + raw_text TEXT NOT NULL, + raw_byte_count INTEGER NOT NULL, + parser_schema_version INTEGER NOT NULL + ); + """) + execute(""" + CREATE INDEX IF NOT EXISTS idx_query_plan_snapshots_subject + ON query_plan_snapshots(subject_fingerprint_hash, format, variant_id); + """) + execute(""" + CREATE TRIGGER IF NOT EXISTS history_plan_snapshots_ad AFTER DELETE ON history BEGIN + DELETE FROM query_plan_snapshots WHERE history_id = old.id; + END; + """) + } + + func insertExplainPlanSnapshot(_ record: ExplainPlanHistoryRecord) -> Bool { + guard let db, record.isWithinStorageLimit else { return false } + + let sql = """ + INSERT INTO query_plan_snapshots ( + history_id, subject_query, subject_fingerprint_hash, variant_id, + format, raw_text, raw_byte_count, parser_schema_version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?); + """ + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + logSqliteError(context: "prepare explain plan insert") + return false + } + + let context = record.context + let transient = QueryHistorySqlBinding.transient + sqlite3_bind_text(statement, 1, context.historyId.uuidString, -1, transient) + sqlite3_bind_text(statement, 2, context.subjectQuery, -1, transient) + sqlite3_bind_int64( + statement, + 3, + SQLQueryFingerprint.hash(context.subjectQuery, databaseType: context.databaseType) + ) + if let variantId = context.variantId { + sqlite3_bind_text(statement, 4, variantId, -1, transient) + } else { + sqlite3_bind_null(statement, 4) + } + sqlite3_bind_text(statement, 5, context.formatRawValue, -1, transient) + sqlite3_bind_text(statement, 6, record.rawText, -1, transient) + sqlite3_bind_int64(statement, 7, Int64(record.rawByteCount)) + sqlite3_bind_int(statement, 8, Int32(clamping: record.parserSchemaVersion)) + + guard sqlite3_step(statement) == SQLITE_DONE else { + logSqliteError(context: "explain plan insert") + return false + } + return true + } + + func explainPlanHistory( + matching context: ExplainPlanHistoryContext, + limit: Int + ) -> [ExplainPlanHistorySnapshot] { + let boundedLimit = min(max(limit, 0), Self.maximumExplainPlanHistoryLimit) + guard db != nil, boundedLimit > 0 else { return [] } + + var clause = QueryHistorySqlClause() + clause.append(""" + SELECT p.history_id, h.connection_id, h.database_name, h.database_type, + h.schema_name, p.variant_id, p.format, + h.executed_at, h.execution_time + FROM query_plan_snapshots p + JOIN history h ON h.id = p.history_id + WHERE p.subject_fingerprint_hash = ? + AND p.subject_query = ? + AND h.connection_id = ? + AND h.database_name = ? + AND h.database_type = ? + AND p.format = ? + AND h.was_successful = 1 + AND h.executed_at < ? + AND p.history_id <> ? + """, + .int64(SQLQueryFingerprint.hash(context.subjectQuery, databaseType: context.databaseType)), + .text(context.subjectQuery), + .text(context.connectionId.uuidString), + .text(context.databaseName), + .text(context.databaseType.rawValue), + .text(context.formatRawValue), + .double(context.capturedAt.timeIntervalSince1970), + .text(context.historyId.uuidString) + ) + appendNullSafeMatch(column: "h.schema_name", value: context.schemaName, to: &clause) + appendNullSafeMatch(column: "p.variant_id", value: context.variantId, to: &clause) + clause.append( + " ORDER BY h.executed_at DESC, p.history_id DESC LIMIT ?;", + .int(Int32(boundedLimit)) + ) + + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + guard sqlite3_prepare_v2(db, clause.sql, -1, &statement, nil) == SQLITE_OK else { + logSqliteError(context: "prepare explain plan history") + return [] + } + for (offset, binding) in clause.bindings.enumerated() { + binding.bind(to: statement, at: Int32(offset + 1)) + } + + var snapshots: [ExplainPlanHistorySnapshot] = [] + while sqlite3_step(statement) == SQLITE_ROW { + if let snapshot = parseExplainPlanSnapshot( + from: statement, + subjectQuery: context.subjectQuery + ) { + snapshots.append(snapshot) + } + } + return snapshots + } + + func explainPlanRawText(historyId: UUID) -> String? { + guard !Task.isCancelled, let db else { return nil } + + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + guard sqlite3_prepare_v2( + db, + "SELECT raw_text FROM query_plan_snapshots WHERE history_id = ? LIMIT 1;", + -1, + &statement, + nil + ) == SQLITE_OK else { + logSqliteError(context: "prepare explain plan raw text") + return nil + } + QueryHistorySqlBinding.text(historyId.uuidString).bind(to: statement, at: 1) + + guard sqlite3_step(statement) == SQLITE_ROW else { return nil } + return sqlite3_column_text(statement, 0).map { String(cString: $0) } + } + + func pruneExplainPlanSnapshots( + toRawByteLimit rawByteLimit: Int64, + snapshotLimit: Int + ) -> Bool { + guard let db else { return false } + guard let usage = explainPlanStorageUsage() else { return false } + guard usage.rawBytes > rawByteLimit || usage.snapshotCount > snapshotLimit else { return true } + + let sql = """ + DELETE FROM query_plan_snapshots + WHERE history_id IN ( + SELECT history_id + FROM ( + SELECT p.history_id, + SUM(p.raw_byte_count) OVER ( + ORDER BY h.executed_at DESC, p.history_id DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS retained_bytes, + ROW_NUMBER() OVER ( + ORDER BY h.executed_at DESC, p.history_id DESC + ) AS retained_count + FROM query_plan_snapshots p + JOIN history h ON h.id = p.history_id + ) + WHERE retained_bytes > ? OR retained_count > ? + ); + """ + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + logSqliteError(context: "prepare explain plan prune") + return false + } + sqlite3_bind_int64(statement, 1, rawByteLimit) + sqlite3_bind_int64(statement, 2, Int64(snapshotLimit)) + guard sqlite3_step(statement) == SQLITE_DONE else { + logSqliteError(context: "explain plan prune") + return false + } + return true + } + + private func explainPlanStorageUsage() -> (rawBytes: Int64, snapshotCount: Int)? { + guard let db else { return nil } + + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + guard sqlite3_prepare_v2( + db, + "SELECT COALESCE(SUM(raw_byte_count), 0), COUNT(*) FROM query_plan_snapshots;", + -1, + &statement, + nil + ) == SQLITE_OK else { + logSqliteError(context: "prepare explain plan byte count") + return nil + } + guard sqlite3_step(statement) == SQLITE_ROW else { + logSqliteError(context: "explain plan byte count") + return nil + } + return ( + rawBytes: sqlite3_column_int64(statement, 0), + snapshotCount: Int(sqlite3_column_int64(statement, 1)) + ) + } + + private func appendNullSafeMatch( + column: String, + value: String?, + to clause: inout QueryHistorySqlClause + ) { + if let value { + clause.append(" AND \(column) = ?", .text(value)) + } else { + clause.append(" AND \(column) IS NULL") + } + } + + private func parseExplainPlanSnapshot( + from statement: OpaquePointer?, + subjectQuery: String + ) -> ExplainPlanHistorySnapshot? { + guard let statement, + let historyIdRaw = sqlite3_column_text(statement, 0).map({ String(cString: $0) }), + let historyId = UUID(uuidString: historyIdRaw), + let connectionIdRaw = sqlite3_column_text(statement, 1).map({ String(cString: $0) }), + let connectionId = UUID(uuidString: connectionIdRaw), + let databaseName = sqlite3_column_text(statement, 2).map({ String(cString: $0) }), + let databaseTypeRaw = sqlite3_column_text(statement, 3).map({ String(cString: $0) }), + let formatRawValue = sqlite3_column_text(statement, 6).map({ String(cString: $0) }) + else { + return nil + } + + let context = ExplainPlanHistoryContext( + historyId: historyId, + subjectQuery: subjectQuery, + connectionId: connectionId, + databaseName: databaseName, + databaseType: DatabaseType(rawValue: databaseTypeRaw), + schemaName: sqlite3_column_text(statement, 4).map { String(cString: $0) }, + variantId: sqlite3_column_text(statement, 5).map { String(cString: $0) }, + formatRawValue: formatRawValue, + capturedAt: Date(timeIntervalSince1970: sqlite3_column_double(statement, 7)) + ) + return ExplainPlanHistorySnapshot( + context: context, + executionTime: sqlite3_column_double(statement, 8) + ) + } +} diff --git a/TablePro/Core/Storage/QueryHistoryStorage.swift b/TablePro/Core/Storage/QueryHistoryStorage.swift index 3fce4015c..30ee5a55e 100644 --- a/TablePro/Core/Storage/QueryHistoryStorage.swift +++ b/TablePro/Core/Storage/QueryHistoryStorage.swift @@ -10,10 +10,16 @@ actor QueryHistoryStorage { var pointer: OpaquePointer? } + private enum TransactionCommitResult { + case committed + case rolledBack + case failed + } + private var dbHandle = DatabaseHandle() private var isPrepared = false - private var db: OpaquePointer? { + var db: OpaquePointer? { if !isPrepared { isPrepared = true setupDatabase() @@ -29,13 +35,19 @@ actor QueryHistoryStorage { private let databaseURL: URL private let removeDatabaseOnDeinit: Bool + private let explainPlanRawByteLimit: Int64 + private let explainPlanSnapshotLimit: Int init( databaseURL: URL = QueryHistoryStorage.defaultDatabaseURL(), - removeDatabaseOnDeinit: Bool = false + removeDatabaseOnDeinit: Bool = false, + explainPlanRawByteLimit: Int64 = ExplainPlanHistoryRecord.maximumTotalRawByteCount, + explainPlanSnapshotLimit: Int = ExplainPlanHistoryRecord.maximumStoredSnapshotCount ) { self.databaseURL = databaseURL self.removeDatabaseOnDeinit = removeDatabaseOnDeinit + self.explainPlanRawByteLimit = max(0, explainPlanRawByteLimit) + self.explainPlanSnapshotLimit = max(0, explainPlanSnapshotLimit) } static func defaultDatabaseURL() -> URL { @@ -77,11 +89,13 @@ actor QueryHistoryStorage { execute("PRAGMA journal_mode=WAL;") execute("PRAGMA synchronous=NORMAL;") + execute("PRAGMA foreign_keys=ON;") sqlite3_busy_timeout(db, 3_000) createTables() migrateIfNeeded() createFingerprintIndex() + createExplainPlanStorage() protectDatabaseFiles(at: dbPath) } @@ -395,7 +409,7 @@ actor QueryHistoryStorage { // MARK: - Statement Helpers - private func execute(_ sql: String) { + func execute(_ sql: String) { guard let db else { return } var statement: OpaquePointer? defer { sqlite3_finalize(statement) } @@ -410,29 +424,109 @@ actor QueryHistoryStorage { } } - private func logSqliteError(context: String) { + func logSqliteError(context: String) { guard let db, let message = sqlite3_errmsg(db) else { return } Self.logger.error("Query history SQL \(context, privacy: .public) failed: \(String(cString: message), privacy: .public)") } - private func beginTransaction() { - guard let db else { return } + @discardableResult + private func beginTransaction() -> Bool { + guard let db else { return false } if sqlite3_exec(db, "BEGIN IMMEDIATE;", nil, nil, nil) != SQLITE_OK { logSqliteError(context: "begin") + return false } + return true } - private func commitTransaction() { - guard let db else { return } + @discardableResult + private func commitTransaction() -> TransactionCommitResult { + guard let db else { return .failed } if sqlite3_exec(db, "COMMIT;", nil, nil, nil) != SQLITE_OK { logSqliteError(context: "commit") - sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) + return rollbackTransaction() ? .rolledBack : .failed + } + return .committed + } + + @discardableResult + private func rollbackTransaction() -> Bool { + guard let db else { return false } + if sqlite3_exec(db, "ROLLBACK;", nil, nil, nil) != SQLITE_OK { + logSqliteError(context: "rollback") + return false } + return true } // MARK: - Writes func record(_ entry: QueryHistoryEntry) -> Bool { + record(entry, explainPlan: nil) + } + + func record( + _ entry: QueryHistoryEntry, + explainPlan candidate: ExplainPlanHistoryRecord? + ) -> Bool { + guard db != nil else { return false } + + let explainPlan = candidate.flatMap { + entry.wasSuccessful && $0.isWithinStorageLimit ? $0 : nil + } + if let explainPlan { + guard explainPlanContext(explainPlan.context, matches: entry) else { + return recordHistoryEntryOnly(entry) + } + guard beginTransaction() else { return false } + guard insertHistoryEntry(entry) else { + rollbackTransaction() + return false + } + guard insertExplainPlanSnapshot(explainPlan), + pruneExplainPlanSnapshots( + toRawByteLimit: explainPlanRawByteLimit, + snapshotLimit: explainPlanSnapshotLimit + ) + else { + guard rollbackTransaction() else { return false } + return recordHistoryEntryOnly(entry) + } + switch commitTransaction() { + case .committed: + break + case .rolledBack: + return recordHistoryEntryOnly(entry) + case .failed: + return false + } + } else { + return recordHistoryEntryOnly(entry) + } + + finishSuccessfulRecord() + return true + } + + private func recordHistoryEntryOnly(_ entry: QueryHistoryEntry) -> Bool { + guard insertHistoryEntry(entry) else { return false } + finishSuccessfulRecord() + return true + } + + private func explainPlanContext( + _ context: ExplainPlanHistoryContext, + matches entry: QueryHistoryEntry + ) -> Bool { + context.historyId == entry.id + && context.connectionId == entry.connectionId + && context.databaseName == entry.databaseName + && context.databaseType == entry.databaseType + && context.schemaName == entry.schemaName + && context.capturedAt == entry.executedAt + } + + private func insertHistoryEntry(_ entry: QueryHistoryEntry) -> Bool { guard let db else { return false } let sql = """ @@ -479,12 +573,15 @@ actor QueryHistoryStorage { return false } + return true + } + + private func finishSuccessfulRecord() { insertsSinceCleanup += 1 if cachedAutoCleanup, insertsSinceCleanup >= Self.cleanupInsertInterval { insertsSinceCleanup = 0 performCleanup() } - return true } // MARK: - Reads diff --git a/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/TablePro/Core/Utilities/SQL/QueryClassifier.swift index 67aecea5f..72daa56c2 100644 --- a/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -86,6 +86,13 @@ enum QueryClassifier { } } + static func explainedStatement(in sql: String) -> String? { + let trimmed = strippingLeadingComments(sql).trimmingCharacters(in: .whitespacesAndNewlines) + let keyword = leadingKeyword(of: trimmed) + guard explainPrefixes.contains(keyword) else { return nil } + return explainInnerStatement(trimmed, keyword: keyword)?.statement + } + static func leadingKeyword(of sql: String) -> String { let stripped = strippingLeadingComments(sql) return stripped.prefix { $0.isLetter || $0.isNumber || $0 == "_" }.uppercased() @@ -320,10 +327,24 @@ private extension QueryClassifier { ) -> (statement: String, executesStatement: Bool)? { var remainder = Substring(trimmed).dropFirst(keyword.count) var options = keyword == "ANALYZE" ? "ANALYZE" : "" + var statementTriviaStart: String.Index? while true { remainder = remainder.drop { $0.isWhitespace } guard let first = remainder.first else { return nil } + if remainder.hasPrefix("--") { + statementTriviaStart = statementTriviaStart ?? remainder.startIndex + guard let newline = remainder.firstIndex(where: { $0 == "\n" || $0 == "\r" }) else { return nil } + remainder = remainder[remainder.index(after: newline)...] + continue + } + if remainder.hasPrefix("/*") { + statementTriviaStart = statementTriviaStart ?? remainder.startIndex + guard let afterComment = remainderAfterBlockComment(in: remainder) else { return nil } + remainder = afterComment + continue + } if first == "(" { + statementTriviaStart = nil var depth = 0 var index = remainder.startIndex while index < remainder.endIndex { @@ -344,6 +365,7 @@ private extension QueryClassifier { let token = remainder.prefix { $0.isLetter || $0.isNumber || $0 == "_" } guard !token.isEmpty else { if first == "=" || first == "," { + statementTriviaStart = nil remainder = remainder.dropFirst() continue } @@ -351,13 +373,37 @@ private extension QueryClassifier { } let upperToken = token.uppercased() if statementStartKeywords.contains(upperToken) { - return (String(remainder), options.contains("ANALYZE")) + let statement = statementTriviaStart.map { trimmed[$0...] } ?? remainder + return (String(statement), options.contains("ANALYZE")) } options += " " + upperToken + statementTriviaStart = nil remainder = remainder.dropFirst(token.count) } } + static func remainderAfterBlockComment(in sql: Substring) -> Substring? { + var depth = 1 + var index = sql.index(sql.startIndex, offsetBy: 2) + while index < sql.endIndex { + let next = sql.index(after: index) + guard next < sql.endIndex else { return nil } + let pair = sql[index...next] + if pair == "/*" { + depth += 1 + index = sql.index(after: next) + } else if pair == "*/" { + depth -= 1 + let afterClose = sql.index(after: next) + if depth == 0 { return sql[afterClose...] } + index = afterClose + } else { + index = next + } + } + return nil + } + static func containsWord(_ body: String, _ word: String) -> Bool { var searchRange = body.startIndex.. ExplainPlanHistoryCapture { + ExplainPlanHistoryCapture( + context: context, + record: ExplainPlanHistoryRecord(context: context, rawText: rawText) + ) + } + + static func make( + context: ExplainPlanHistoryContext, + rawText: String, + queryParameters: [QueryParameter]? + ) -> ExplainPlanHistoryCapture? { + guard queryParameters == nil else { return nil } + return make(context: context, rawText: rawText) + } +} + +struct ExplainPlanHistorySnapshot: Identifiable, Hashable, Sendable { + var id: UUID { context.historyId } + + let context: ExplainPlanHistoryContext + let executionTime: TimeInterval +} diff --git a/TablePro/Models/Query/ExplainRequest.swift b/TablePro/Models/Query/ExplainRequest.swift index 9c094d8e2..d025ecf36 100644 --- a/TablePro/Models/Query/ExplainRequest.swift +++ b/TablePro/Models/Query/ExplainRequest.swift @@ -10,7 +10,9 @@ import TableProPluginKit struct ExplainRequest: Equatable { let sql: String + let subjectSQL: String let format: ExplainPlanFormat + let variantId: String? /// A driver that declares no variants and builds its own statement may return anything, /// including a multi-column document. Those results go through the ordinary query pipeline @@ -29,15 +31,23 @@ struct ExplainRequest: Equatable { guard let resolved = variant ?? declaredVariants.first else { return nil } return ExplainRequest( sql: "\(resolved.sqlPrefix) \(statement)", + subjectSQL: statement, format: ExplainFormatResolver.resolve(declared: resolved.format, databaseType: databaseType), + variantId: resolved.id, isDriverBuilt: false ) } - static func driverBuilt(sql: String, databaseType: DatabaseType) -> ExplainRequest { + static func driverBuilt( + sql: String, + databaseType: DatabaseType, + subjectSQL: String? = nil + ) -> ExplainRequest { ExplainRequest( sql: sql, + subjectSQL: subjectSQL ?? sql, format: ExplainFormatResolver.resolve(declared: .plainText, databaseType: databaseType), + variantId: nil, isDriverBuilt: true ) } diff --git a/TablePro/Models/Query/ExplainResultSetFactory.swift b/TablePro/Models/Query/ExplainResultSetFactory.swift index 2d33747d6..5f7668d44 100644 --- a/TablePro/Models/Query/ExplainResultSetFactory.swift +++ b/TablePro/Models/Query/ExplainResultSetFactory.swift @@ -15,7 +15,8 @@ enum ExplainResultSetFactory { plan: QueryPlan?, sql: String, executionTime: TimeInterval?, - anchor: StatementAnchor? = nil + anchor: StatementAnchor? = nil, + historyContext: ExplainPlanHistoryContext? = nil ) -> ResultSet { let resultSet = ResultSet(label: String(localized: "Plan")) resultSet.explainRawText = rawText @@ -23,6 +24,7 @@ enum ExplainResultSetFactory { resultSet.baseQuery = sql resultSet.executionTime = executionTime resultSet.statementAnchor = anchor + resultSet.explainHistoryContext = historyContext return resultSet } } diff --git a/TablePro/Models/Query/QueryHistoryRecordRequest.swift b/TablePro/Models/Query/QueryHistoryRecordRequest.swift index 4efa3e22a..cca96f32b 100644 --- a/TablePro/Models/Query/QueryHistoryRecordRequest.swift +++ b/TablePro/Models/Query/QueryHistoryRecordRequest.swift @@ -11,6 +11,7 @@ struct QueryHistoryRecordRequest: Sendable { let rowCount: Int let wasSuccessful: Bool var errorMessage: String? + var explainPlan: ExplainPlanHistoryRecord? init( query: String, @@ -22,7 +23,8 @@ struct QueryHistoryRecordRequest: Sendable { executionTime: TimeInterval, rowCount: Int, wasSuccessful: Bool, - errorMessage: String? = nil + errorMessage: String? = nil, + explainPlan: ExplainPlanHistoryRecord? = nil ) { self.query = query self.connectionId = connectionId @@ -34,5 +36,6 @@ struct QueryHistoryRecordRequest: Sendable { self.rowCount = rowCount self.wasSuccessful = wasSuccessful self.errorMessage = errorMessage + self.explainPlan = explainPlan } } diff --git a/TablePro/Models/Query/QueryPlan.swift b/TablePro/Models/Query/QueryPlan.swift index 7562f3799..96b0b234d 100644 --- a/TablePro/Models/Query/QueryPlan.swift +++ b/TablePro/Models/Query/QueryPlan.swift @@ -8,7 +8,7 @@ import Foundation /// A single node in an EXPLAIN query plan tree. -struct QueryPlanNode: Identifiable { +struct QueryPlanNode: Identifiable, Sendable { let id = UUID() let operation: String let relation: String? @@ -44,7 +44,7 @@ struct QueryPlanNode: Identifiable { } /// A parsed EXPLAIN query plan. -struct QueryPlan { +struct QueryPlan: Sendable { var rootNode: QueryPlanNode let planningTime: Double? let executionTime: Double? diff --git a/TablePro/Models/Query/QueryPlanComparison.swift b/TablePro/Models/Query/QueryPlanComparison.swift new file mode 100644 index 000000000..26b3cdc00 --- /dev/null +++ b/TablePro/Models/Query/QueryPlanComparison.swift @@ -0,0 +1,607 @@ +// +// QueryPlanComparison.swift +// TablePro +// +// Deterministic comparison between an older and a current query plan. +// + +import Foundation + +struct QueryPlanMetricDelta: Equatable, Sendable { + let previous: Double? + let current: Double? + + var delta: Double? { + guard let previous, let current else { return nil } + return current - previous + } + + var percentChange: Double? { + guard let previous, let current, + previous != 0, + previous.isFinite, + current.isFinite else { return nil } + return ((current - previous) / previous) * 100 + } + + var hasChanges: Bool { + previous != current + } +} + +struct QueryPlanSummaryComparison: Equatable, Sendable { + let rootEstimatedTotalCost: QueryPlanMetricDelta + let rootEstimatedRows: QueryPlanMetricDelta + let planningTime: QueryPlanMetricDelta + let executionTime: QueryPlanMetricDelta + let nodeCount: QueryPlanMetricDelta + + var hasChanges: Bool { + rootEstimatedTotalCost.hasChanges || + rootEstimatedRows.hasChanges || + planningTime.hasChanges || + executionTime.hasChanges || + nodeCount.hasChanges + } +} + +enum QueryPlanNodeChangeKind: String, Equatable, Sendable { + case added + case removed + case modified +} + +enum QueryPlanNodeValueCategory: String, Equatable, Sendable { + case metric + case property +} + +struct QueryPlanNodeValueChange: Equatable, Identifiable, Sendable { + let category: QueryPlanNodeValueCategory + let name: String + let previousValue: String? + let currentValue: String? + + var id: String { + "\(category.rawValue):\(name)" + } +} + +struct QueryPlanNodeChange: Equatable, Identifiable, Sendable { + let kind: QueryPlanNodeChangeKind + let semanticPathID: String + let operation: String + let relation: String? + let schema: String? + let alias: String? + let valueChanges: [QueryPlanNodeValueChange] + + var id: String { + "\(kind.rawValue):\(semanticPathID)" + } +} + +struct QueryPlanComparison: Equatable, Sendable { + /// Exact sibling LCS is bounded; wider plans use deterministic linear matching. + static let maximumLCSCellCount = 1_000_000 + + let summary: QueryPlanSummaryComparison + let nodeChanges: [QueryPlanNodeChange] + + var hasChanges: Bool { + summary.hasChanges || !nodeChanges.isEmpty + } + + static func usesLinearSiblingMatcher(previousCount: Int, currentCount: Int) -> Bool { + let (cellCount, overflow) = previousCount.multipliedReportingOverflow(by: currentCount) + return overflow || cellCount > maximumLCSCellCount + } + + init(previous: QueryPlan, current: QueryPlan) { + summary = QueryPlanSummaryComparison( + rootEstimatedTotalCost: QueryPlanMetricDelta( + previous: previous.rootNode.estimatedTotalCost, + current: current.rootNode.estimatedTotalCost + ), + rootEstimatedRows: QueryPlanMetricDelta( + previous: previous.rootNode.estimatedRows.map(Double.init), + current: current.rootNode.estimatedRows.map(Double.init) + ), + planningTime: QueryPlanMetricDelta( + previous: previous.planningTime, + current: current.planningTime + ), + executionTime: QueryPlanMetricDelta( + previous: previous.executionTime, + current: current.executionTime + ), + nodeCount: QueryPlanMetricDelta( + previous: Double(Self.nodeCount(in: previous.rootNode)), + current: Double(Self.nodeCount(in: current.rootNode)) + ) + ) + + var changes: [QueryPlanNodeChange] = [] + Self.compareRoots(previous.rootNode, current.rootNode, changes: &changes) + nodeChanges = changes.sorted(by: Self.changeOrder) + } +} + +private extension QueryPlanComparison { + struct SemanticKey: Hashable, Comparable { + let operation: String + let schema: String? + let relation: String? + let alias: String? + let identifyingProperties: [(key: String, value: String)] + + static func == (lhs: SemanticKey, rhs: SemanticKey) -> Bool { + lhs.operation == rhs.operation && + lhs.schema == rhs.schema && + lhs.relation == rhs.relation && + lhs.alias == rhs.alias && + lhs.identifyingProperties.elementsEqual(rhs.identifyingProperties) { + $0.key == $1.key && $0.value == $1.value + } + } + + func hash(into hasher: inout Hasher) { + hasher.combine(operation) + hasher.combine(schema) + hasher.combine(relation) + hasher.combine(alias) + for property in identifyingProperties { + hasher.combine(property.key) + hasher.combine(property.value) + } + } + + static func < (lhs: SemanticKey, rhs: SemanticKey) -> Bool { + lhs.sortKey < rhs.sortKey + } + + var pathComponent: String { + let fields = [ + "operation=\(Self.escape(operation))", + "schema=\(Self.escape(schema))", + "relation=\(Self.escape(relation))", + "alias=\(Self.escape(alias))", + ] + let properties = identifyingProperties.map { + "property.\(Self.escape($0.key))=\(Self.escape($0.value))" + } + return (fields + properties).joined(separator: "&") + } + + private var sortKey: String { + let properties = identifyingProperties.flatMap { [$0.key, $0.value] } + return ([operation, schema, relation, alias].map(Self.sortField) + properties.map(Self.sortField)) + .joined(separator: "|") + } + + private static func escape(_ value: String?) -> String { + guard let value else { return "~" } + return value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? value + } + + private static func sortField(_ value: String?) -> String { + guard let value else { return "N" } + return "S\(value.utf8.count):\(value)" + } + } + + struct IndexedNode { + let node: QueryPlanNode + let key: SemanticKey + let occurrence: Int + } + + struct NodeMatch { + let previousIndex: Int + let currentIndex: Int + } + + struct ValueKey: Hashable, Comparable { + let category: QueryPlanNodeValueCategory + let name: String + + static func < (lhs: ValueKey, rhs: ValueKey) -> Bool { + if lhs.category.rawValue != rhs.category.rawValue { + return lhs.category.rawValue < rhs.category.rawValue + } + return lhs.name < rhs.name + } + } + + static let identifyingPropertyKeys: Set = [ + "CTE Name", + "Index Name", + "Join Type", + "Parent Relationship", + "Strategy", + "Subplan Name", + ] + + static func nodeCount(in node: QueryPlanNode) -> Int { + 1 + node.children.reduce(0) { $0 + nodeCount(in: $1) } + } + + static func compareRoots( + _ previous: QueryPlanNode, + _ current: QueryPlanNode, + changes: inout [QueryPlanNodeChange] + ) { + let previousKey = semanticKey(for: previous) + let currentKey = semanticKey(for: current) + + guard previousKey == currentKey else { + appendSubtree( + previous, + kind: .removed, + path: semanticPathComponent(for: previousKey, occurrence: 1), + changes: &changes + ) + appendSubtree( + current, + kind: .added, + path: semanticPathComponent(for: currentKey, occurrence: 1), + changes: &changes + ) + return + } + + compareMatched( + previous, + current, + path: semanticPathComponent(for: currentKey, occurrence: 1), + changes: &changes + ) + } + + static func compareMatched( + _ previous: QueryPlanNode, + _ current: QueryPlanNode, + path: String, + changes: inout [QueryPlanNodeChange] + ) { + let valueChanges = valueChanges(previous: previous, current: current) + if !valueChanges.isEmpty { + changes.append(change(kind: .modified, node: current, path: path, valueChanges: valueChanges)) + } + + let previousNodes = indexedNodes(previous.children) + let currentNodes = indexedNodes(current.children) + let matches = longestCommonSubsequenceMatches(previousNodes, currentNodes) + var previousIndex = 0 + var currentIndex = 0 + + for match in matches { + while previousIndex < match.previousIndex { + appendChild( + previousNodes[previousIndex], + kind: .removed, + parentPath: path, + changes: &changes + ) + previousIndex += 1 + } + while currentIndex < match.currentIndex { + appendChild( + currentNodes[currentIndex], + kind: .added, + parentPath: path, + changes: &changes + ) + currentIndex += 1 + } + + let previousNode = previousNodes[match.previousIndex] + let currentNode = currentNodes[match.currentIndex] + let currentPathComponent = semanticPathComponent( + for: currentNode.key, + occurrence: currentNode.occurrence + ) + compareMatched( + previousNode.node, + currentNode.node, + path: "\(path)/\(currentPathComponent)", + changes: &changes + ) + previousIndex = match.previousIndex + 1 + currentIndex = match.currentIndex + 1 + } + + while previousIndex < previousNodes.count { + appendChild( + previousNodes[previousIndex], + kind: .removed, + parentPath: path, + changes: &changes + ) + previousIndex += 1 + } + while currentIndex < currentNodes.count { + appendChild( + currentNodes[currentIndex], + kind: .added, + parentPath: path, + changes: &changes + ) + currentIndex += 1 + } + } + + static func longestCommonSubsequenceMatches( + _ previous: [IndexedNode], + _ current: [IndexedNode] + ) -> [NodeMatch] { + guard !previous.isEmpty, !current.isEmpty else { return [] } + if usesLinearSiblingMatcher(previousCount: previous.count, currentCount: current.count) { + return orderedLinearMatches(previous, current) + } + + let cellCount = previous.count * current.count + + let wordCount = (cellCount / 64) + (cellCount.isMultiple(of: 64) ? 0 : 1) + var skipsPrevious = [UInt64](repeating: 0, count: wordCount) + var suffixAfter = [Int](repeating: 0, count: current.count + 1) + var suffixCurrent = suffixAfter + + for previousIndex in stride(from: previous.count - 1, through: 0, by: -1) { + suffixCurrent[current.count] = 0 + for currentIndex in stride(from: current.count - 1, through: 0, by: -1) { + if previous[previousIndex].key == current[currentIndex].key { + suffixCurrent[currentIndex] = suffixAfter[currentIndex + 1] + 1 + } else if suffixAfter[currentIndex] >= suffixCurrent[currentIndex + 1] { + suffixCurrent[currentIndex] = suffixAfter[currentIndex] + let bitIndex = (previousIndex * current.count) + currentIndex + skipsPrevious[bitIndex / 64] |= UInt64(1) << UInt64(bitIndex % 64) + } else { + suffixCurrent[currentIndex] = suffixCurrent[currentIndex + 1] + } + } + swap(&suffixAfter, &suffixCurrent) + } + + var matches: [NodeMatch] = [] + matches.reserveCapacity(suffixAfter[0]) + var previousIndex = 0 + var currentIndex = 0 + while previousIndex < previous.count, currentIndex < current.count { + if previous[previousIndex].key == current[currentIndex].key { + matches.append(NodeMatch( + previousIndex: previousIndex, + currentIndex: currentIndex + )) + previousIndex += 1 + currentIndex += 1 + continue + } + + let bitIndex = (previousIndex * current.count) + currentIndex + let shouldSkipPrevious = skipsPrevious[bitIndex / 64] + & (UInt64(1) << UInt64(bitIndex % 64)) != 0 + if shouldSkipPrevious { + previousIndex += 1 + } else { + currentIndex += 1 + } + } + return matches + } + + static func orderedLinearMatches( + _ previous: [IndexedNode], + _ current: [IndexedNode] + ) -> [NodeMatch] { + var prefixCount = 0 + let shortestCount = min(previous.count, current.count) + while prefixCount < shortestCount, + previous[prefixCount].key == current[prefixCount].key { + prefixCount += 1 + } + + var previousEnd = previous.count + var currentEnd = current.count + var suffixMatches: [NodeMatch] = [] + while previousEnd > prefixCount, currentEnd > prefixCount, + previous[previousEnd - 1].key == current[currentEnd - 1].key { + previousEnd -= 1 + currentEnd -= 1 + suffixMatches.append(NodeMatch( + previousIndex: previousEnd, + currentIndex: currentEnd + )) + } + + var matches = (0.. QueryPlanNodeChange { + QueryPlanNodeChange( + kind: kind, + semanticPathID: path, + operation: node.operation, + relation: node.relation, + schema: node.schema, + alias: node.alias, + valueChanges: valueChanges + ) + } + + static func indexedNodes(_ nodes: [QueryPlanNode]) -> [IndexedNode] { + var occurrences: [SemanticKey: Int] = [:] + return nodes.map { node in + let key = semanticKey(for: node) + let occurrence = occurrences[key, default: 0] + 1 + occurrences[key] = occurrence + return IndexedNode(node: node, key: key, occurrence: occurrence) + } + } + + static func semanticKey(for node: QueryPlanNode) -> SemanticKey { + let identifyingProperties = QueryPlanLabels.visibleProperties(of: node) + .filter { identifyingPropertyKeys.contains($0.key) } + return SemanticKey( + operation: node.operation, + schema: node.schema, + relation: node.relation, + alias: node.alias, + identifyingProperties: identifyingProperties + ) + } + + static func semanticPathComponent(for key: SemanticKey, occurrence: Int) -> String { + "\(key.pathComponent)#\(occurrence)" + } + + static func valueChanges( + previous: QueryPlanNode, + current: QueryPlanNode + ) -> [QueryPlanNodeValueChange] { + let previousValues = values(for: previous) + let currentValues = values(for: current) + let keys = Set(previousValues.keys).union(currentValues.keys).sorted() + + return keys.compactMap { key in + let previousValue = previousValues[key] + let currentValue = currentValues[key] + guard previousValue != currentValue else { return nil } + return QueryPlanNodeValueChange( + category: key.category, + name: displayName(for: key), + previousValue: previousValue, + currentValue: currentValue + ) + } + } + + static func allValueChanges( + for node: QueryPlanNode, + kind: QueryPlanNodeChangeKind + ) -> [QueryPlanNodeValueChange] { + values(for: node).sorted { $0.key < $1.key }.map { key, value in + QueryPlanNodeValueChange( + category: key.category, + name: displayName(for: key), + previousValue: kind == .removed ? value : nil, + currentValue: kind == .added ? value : nil + ) + } + } + + static func values(for node: QueryPlanNode) -> [ValueKey: String] { + var values: [ValueKey: String] = [:] + + func addMetric(_ name: String, _ value: T?) { + if let value { + values[ValueKey(category: .metric, name: name)] = String(describing: value) + } + } + + addMetric("Estimated Startup Cost", node.estimatedStartupCost) + addMetric("Estimated Total Cost", node.estimatedTotalCost) + addMetric("Estimated Rows", node.estimatedRows) + addMetric("Estimated Width", node.estimatedWidth) + addMetric("Actual Startup Time", node.actualStartupTime) + addMetric("Actual Total Time", node.actualTotalTime) + addMetric("Actual Rows", node.actualRows) + addMetric("Actual Loops", node.actualLoops) + + for property in QueryPlanLabels.visibleProperties(of: node) { + values[ValueKey(category: .property, name: property.key)] = property.value + } + return values + } + + static func displayName(for key: ValueKey) -> String { + guard key.category == .metric else { return key.name } + switch key.name { + case "Estimated Startup Cost": return String(localized: "Estimated Startup Cost") + case "Estimated Total Cost": return String(localized: "Estimated Total Cost") + case "Estimated Rows": return String(localized: "Estimated Rows") + case "Estimated Width": return String(localized: "Estimated Width") + case "Actual Startup Time": return String(localized: "Actual Startup Time") + case "Actual Total Time": return String(localized: "Actual Total Time") + case "Actual Rows": return String(localized: "Actual Rows") + case "Actual Loops": return String(localized: "Actual Loops") + default: return key.name + } + } + + static func changeOrder(_ lhs: QueryPlanNodeChange, _ rhs: QueryPlanNodeChange) -> Bool { + if lhs.semanticPathID != rhs.semanticPathID { + return lhs.semanticPathID < rhs.semanticPathID + } + return kindOrder(lhs.kind) < kindOrder(rhs.kind) + } + + static func kindOrder(_ kind: QueryPlanNodeChangeKind) -> Int { + switch kind { + case .removed: 0 + case .added: 1 + case .modified: 2 + } + } +} diff --git a/TablePro/Models/Query/ResultSet.swift b/TablePro/Models/Query/ResultSet.swift index cd4e6affe..ddb144c4b 100644 --- a/TablePro/Models/Query/ResultSet.swift +++ b/TablePro/Models/Query/ResultSet.swift @@ -54,6 +54,7 @@ final class ResultSet: Identifiable { /// and history. It carries a plan instead of rows. var queryPlan: QueryPlan? var explainRawText: String? + var explainHistoryContext: ExplainPlanHistoryContext? var isExplainResult: Bool { explainRawText != nil } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index da439984e..fde65a93c 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -337,6 +337,74 @@ } } }, + "%+.3f ms" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%+.3f밀리초" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%+.3f milisaniye" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%+.3f mili giây" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%+.3f 毫秒" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%+.3f 毫秒" + } + } + } + }, + "%.3f ms" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.3f밀리초" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.3f milisaniye" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.3f mili giây" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.3f 毫秒" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%.3f 毫秒" + } + } + } + }, "%.3fms" : { "comment" : "A value of a time in milliseconds.", "isCommentAutoGenerated" : true, @@ -8247,6 +8315,40 @@ } } }, + "A structured comparison is unavailable because one plan could not be parsed. Showing raw output." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "한 계획을 구문 분석할 수 없어 구조화된 비교를 사용할 수 없습니다. 원시 출력을 표시합니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planlardan biri ayrıştırılamadığı için yapılandırılmış karşılaştırma kullanılamıyor. Ham çıktı gösteriliyor." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể so sánh có cấu trúc vì không phân tích được một kế hoạch. Đang hiển thị kết quả thô." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "由于无法解析其中一个执行计划,因此无法进行结构化比较。显示原始输出。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "由於無法剖析其中一個執行計畫,因此無法進行結構化比較。顯示原始輸出。" + } + } + } + }, "A superuser bypasses all permission checks." : { "localizations" : { "ko" : { @@ -10734,6 +10836,40 @@ } } }, + "Actual Loops" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실제 루프" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gerçek Döngüler" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Số vòng lặp thực tế" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "实际循环次数" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "實際迴圈次數" + } + } + } + }, "Actual Rows" : { "comment" : "Label for the number of rows in the actual result set.", "isCommentAutoGenerated" : true, @@ -10770,6 +10906,40 @@ } } }, + "Actual Startup Time" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실제 시작 시간" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gerçek Başlangıç Süresi" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thời gian bắt đầu thực tế" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "实际启动时间" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "實際啟動時間" + } + } + } + }, "Actual Time" : { "comment" : "Label for the actual time taken by a query.", "isCommentAutoGenerated" : true, @@ -10806,6 +10976,40 @@ } } }, + "Actual Total Time" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실제 총 시간" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gerçek Toplam Süre" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tổng thời gian thực tế" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "实际总时间" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "實際總時間" + } + } + } + }, "Add" : { "localizations" : { "ko" : { @@ -17553,6 +17757,40 @@ } } }, + "Baseline" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기준" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Referans" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cơ sở" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "基准" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "基準" + } + } + } + }, "Between %@ and %@" : { "localizations" : { "ko" : { @@ -26607,6 +26845,40 @@ } } }, + "Compare this plan with an earlier run" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 계획을 이전 실행과 비교" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu planı önceki bir çalıştırmayla karşılaştır" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "So sánh kế hoạch này với lần chạy trước" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将此执行计划与先前运行进行比较" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "將此執行計畫與先前的執行結果進行比較" + } + } + } + }, "Complete Sign In" : { "localizations" : { "ko" : { @@ -33544,6 +33816,40 @@ } } }, + "Current" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geçerli" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiện tại" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "目前" + } + } + } + }, "Current %1$@: %2$@" : { "localizations" : { "ko" : { @@ -42202,6 +42508,40 @@ } } }, + "Earlier Runs" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이전 실행" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önceki Çalıştırmalar" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Các lần chạy trước" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "先前运行" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "先前執行" + } + } + } + }, "Earliest executed_at to include, Unix epoch seconds (inclusive, optional)" : { "localizations" : { "ko" : { @@ -45603,6 +45943,176 @@ } } }, + "Estimated Rows" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예상 행" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tahmini Satır" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Số hàng ước tính" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "估算行数" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "估算列數" + } + } + } + }, + "Estimated Startup Cost" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예상 시작 비용" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tahmini Başlangıç Maliyeti" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chi phí khởi động ước tính" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "估算启动成本" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "估算啟動成本" + } + } + } + }, + "Estimated Total Cost" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예상 총 비용" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tahmini Toplam Maliyet" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tổng chi phí ước tính" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "估算总成本" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "估算總成本" + } + } + } + }, + "Estimated Width" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예상 너비" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tahmini Genişlik" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Độ rộng ước tính" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "估算宽度" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "估算寬度" + } + } + } + }, + "Estimated rows" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "예상 행" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tahmini satır" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Số hàng ước tính" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "估算行数" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "估算列數" + } + } + } + }, "Execute" : { "localizations" : { "ko" : { @@ -46184,6 +46694,40 @@ } } }, + "Execution time" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실행 시간" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Çalıştırma süresi" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thời gian thực thi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "执行时间" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "執行時間" + } + } + } + }, "Execution: %.3fms" : { "localizations" : { "ko" : { @@ -65616,6 +66160,40 @@ } } }, + "Loading plan history…" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "계획 기록을 불러오는 중…" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plan geçmişi yükleniyor…" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang tải lịch sử kế hoạch…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在加载执行计划历史记录…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在載入執行計畫歷程記錄…" + } + } + } + }, "Loading plugins..." : { "extractionState" : "stale", "localizations" : { @@ -68678,6 +69256,40 @@ } } }, + "Metric" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지표" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metrik" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chỉ số" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "指标" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "指標" + } + } + } + }, "Microsoft Entra ID" : { "localizations" : { "ko" : { @@ -73487,6 +74099,40 @@ } } }, + "No Earlier Plans" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이전 계획 없음" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önceki Plan Yok" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có kế hoạch trước đó" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有先前的执行计划" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "沒有先前的執行計畫" + } + } + } + }, "No Favorites" : { "localizations" : { "ko" : { @@ -75863,6 +76509,40 @@ } } }, + "No node changes." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노드 변경 사항이 없습니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düğüm değişikliği yok." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có thay đổi nút." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "节点没有变化。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "節點沒有變更。" + } + } + } + }, "No objects found" : { "extractionState" : "stale", "localizations" : { @@ -76422,6 +77102,40 @@ } } }, + "No raw plan output." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "원시 계획 출력이 없습니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ham plan çıktısı yok." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có kết quả kế hoạch thô." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有原始执行计划输出。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "沒有原始執行計畫輸出。" + } + } + } + }, "No results for \"%@\"" : { "localizations" : { "ko" : { @@ -77212,6 +77926,74 @@ } } }, + "Node Changes" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노드 변경 사항" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düğüm Değişiklikleri" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thay đổi nút" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "节点变化" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "節點變更" + } + } + } + }, + "Node count" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "노드 수" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düğüm sayısı" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Số nút" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "节点数" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "節點數" + } + } + } + }, "None" : { "localizations" : { "ko" : { @@ -83963,6 +84745,74 @@ } } }, + "Plan History" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "계획 기록" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plan Geçmişi" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lịch sử kế hoạch" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "执行计划历史记录" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "執行計畫歷程記錄" + } + } + } + }, + "Plan History Unavailable" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "계획 기록을 사용할 수 없음" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plan Geçmişi Kullanılamıyor" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không dùng được lịch sử kế hoạch" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "执行计划历史记录不可用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "執行計畫歷程記錄無法使用" + } + } + } + }, "Plan: %@" : { "localizations" : { "ko" : { @@ -83997,6 +84847,40 @@ } } }, + "Planning time" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "계획 수립 시간" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planlama süresi" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thời gian lập kế hoạch" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "规划时间" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "規劃時間" + } + } + } + }, "Planning: %.3fms" : { "localizations" : { "ko" : { @@ -97486,6 +98370,40 @@ } } }, + "Run this EXPLAIN again to create a comparison baseline." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 EXPLAIN을 다시 실행하여 비교 기준을 만드십시오." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karşılaştırma referansı oluşturmak için bu EXPLAIN komutunu yeniden çalıştırın." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chạy lại EXPLAIN này để tạo cơ sở so sánh." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "再次运行此 EXPLAIN 以创建比较基准。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "再次執行此 EXPLAIN 以建立比較基準。" + } + } + } + }, "Running Threads" : { "localizations" : { "ko" : { @@ -118032,6 +118950,40 @@ } } }, + "The query history store could not be opened." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "쿼리 기록 저장소를 열 수 없습니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sorgu geçmişi deposu açılamadı." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể mở kho lưu trữ lịch sử truy vấn." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法打开查询历史记录存储。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法開啟查詢歷程記錄儲存區。" + } + } + } + }, "The secret manager did not return valid JSON." : { "localizations" : { "ko" : { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index d321dd2cc..55b89cc1c 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -653,11 +653,7 @@ struct MainEditorContentView: View { case .chart: resultTabBarSection(tab: tab) if let explain = tab.display.activeExplainResult { - QueryPlanResultView( - rawText: explain.explainRawText ?? "", - executionTime: explain.executionTime, - plan: explain.queryPlan - ) + queryPlanResultView(for: explain) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let resultSet = tab.display.activeResultSet { ResultChartView( @@ -679,11 +675,7 @@ struct MainEditorContentView: View { case .data: resultTabBarSection(tab: tab) if let explain = tab.display.activeExplainResult { - QueryPlanResultView( - rawText: explain.explainRawText ?? "", - executionTime: explain.executionTime, - plan: explain.queryPlan - ) + queryPlanResultView(for: explain) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { let resolvedRows = resolvedTableRows(for: tab) @@ -767,6 +759,15 @@ struct MainEditorContentView: View { } } + private func queryPlanResultView(for resultSet: ResultSet) -> QueryPlanResultView { + QueryPlanResultView( + rawText: resultSet.explainRawText ?? "", + executionTime: resultSet.executionTime, + plan: resultSet.queryPlan, + historyContext: resultSet.explainHistoryContext + ) + } + @ViewBuilder private func resultTabBarSection(tab: QueryTab) -> some View { if ResultTabBarPolicy.showsTabBar(tabType: tab.tabType, display: tab.display) { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Explain.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Explain.swift index b2b5bf1fb..f888f8c3e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Explain.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Explain.swift @@ -104,7 +104,11 @@ extension MainContentCoordinator { let fallbackSQL = adapter.buildExplainQuery(statement) else { return nil } - return ExplainRequest.driverBuilt(sql: fallbackSQL, databaseType: connection.type) + return ExplainRequest.driverBuilt( + sql: fallbackSQL, + databaseType: connection.type, + subjectSQL: statement + ) } // MARK: - Execution @@ -169,6 +173,21 @@ extension MainContentCoordinator { databaseName: operationDatabaseName(tabId: tabId), outcome: .succeeded(OperationSummary()) ) + let historyContext = ExplainPlanHistoryContext( + historyId: UUID(), + subjectQuery: request.subjectSQL, + connectionId: conn.id, + databaseName: scope.database, + databaseType: conn.type, + schemaName: scope.schema, + variantId: request.variantId, + formatRawValue: request.format.rawValue, + capturedAt: Date() + ) + let historyCapture = ExplainPlanHistoryCapture.make( + context: historyContext, + rawText: rawText + ) flushBufferToActiveResult(tabId: tabId, pinnedOnly: true) tabManager.mutate(tabId: tabId) { tab in tab.execution.executionTime = fetchResult.executionTime @@ -182,7 +201,8 @@ extension MainContentCoordinator { plan: plan, sql: request.sql, executionTime: fetchResult.executionTime, - anchor: anchor + anchor: anchor, + historyContext: historyContext )] ) if tab.display.isResultsCollapsed { @@ -196,13 +216,14 @@ extension MainContentCoordinator { QueryHistoryRecordRequest( query: request.sql, connectionId: conn.id, - databaseName: queryExecutionCoordinator.historyDatabaseName(tabId: tabId), + databaseName: historyContext.databaseName, databaseType: conn.type, - schemaName: queryExecutionCoordinator.historySchemaName(tabId: tabId), + schemaName: historyContext.schemaName, source: .explain, executionTime: fetchResult.executionTime, rowCount: fetchResult.rows.count, - wasSuccessful: true + wasSuccessful: true, + explainPlan: historyCapture.record ) ) } diff --git a/TablePro/Views/QueryPlan/QueryPlanHistoryView.swift b/TablePro/Views/QueryPlan/QueryPlanHistoryView.swift new file mode 100644 index 000000000..70916fa36 --- /dev/null +++ b/TablePro/Views/QueryPlan/QueryPlanHistoryView.swift @@ -0,0 +1,634 @@ +// +// QueryPlanHistoryView.swift +// TablePro +// +// Compares the current EXPLAIN result with one compatible earlier capture. +// + +import SwiftUI +import TableProPluginKit + +enum QueryPlanHistoryComparisonPresentation: Sendable { + case structured(QueryPlanComparison) + case rawOnly(previous: String, current: String) + + enum Kind: Equatable { + case structured + case rawOnly + } + + static func resolve( + previousPlan: QueryPlan?, + previousRawText: String, + currentPlan: QueryPlan?, + currentRawText: String + ) -> QueryPlanHistoryComparisonPresentation { + guard let previousPlan, let currentPlan else { + return .rawOnly( + previous: QueryPlanHistoryRawText.bounded(previousRawText), + current: QueryPlanHistoryRawText.bounded(currentRawText) + ) + } + return .structured(QueryPlanComparison(previous: previousPlan, current: currentPlan)) + } + + var kind: Kind { + switch self { + case .structured: return .structured + case .rawOnly: return .rawOnly + } + } +} + +enum QueryPlanHistoryRawText { + static let maximumDisplayedUTF16Length = 100_000 + + static func bounded(_ text: String) -> String { + let text = text as NSString + guard text.length > maximumDisplayedUTF16Length else { return text as String } + return text.substring(to: maximumDisplayedUTF16Length) + + "\n\n… " + + String(localized: "Output truncated for display") + } +} + +enum QueryPlanHistoryNodeChangeAccessibility { + static func value(_ change: QueryPlanNodeChange) -> String { + change.valueChanges + .map(detail) + .joined(separator: ", ") + } + + static func detail(_ change: QueryPlanNodeValueChange) -> String { + "\(change.name): \(change.previousValue ?? "–") → \(change.currentValue ?? "–")" + } +} + +enum QueryPlanHistoryComparisonDecision { + static func requiresBaselineParsing(currentPlan: QueryPlan?) -> Bool { + currentPlan != nil + } +} + +actor QueryPlanHistoryComparisonWorker { + func resolve( + previousRawText: String, + formatRawValue: String, + currentPlan: QueryPlan?, + currentRawText: String + ) -> QueryPlanHistoryComparisonPresentation? { + guard !Task.isCancelled else { return nil } + guard QueryPlanHistoryComparisonDecision.requiresBaselineParsing(currentPlan: currentPlan), + let currentPlan + else { + return QueryPlanHistoryComparisonPresentation.resolve( + previousPlan: nil, + previousRawText: previousRawText, + currentPlan: nil, + currentRawText: currentRawText + ) + } + let format = ExplainPlanFormat(rawValue: formatRawValue) + let previousPlan = ExplainPlanParserRegistry.plan(from: previousRawText, format: format) + guard !Task.isCancelled else { return nil } + return QueryPlanHistoryComparisonPresentation.resolve( + previousPlan: previousPlan, + previousRawText: previousRawText, + currentPlan: currentPlan, + currentRawText: currentRawText + ) + } +} + +struct QueryPlanHistoryView: View { + let context: ExplainPlanHistoryContext + let currentRawText: String + let currentExecutionTime: TimeInterval? + let currentPlan: QueryPlan? + + @Environment(\.dismiss) private var dismiss + + @State private var loadState: LoadState = .idle + @State private var snapshots: [ExplainPlanHistorySnapshot] = [] + @State private var selectedBaselineID: UUID? + @State private var comparisonPresentation: QueryPlanHistoryComparisonPresentation? + @State private var historyReloadVersion = 0 + @State private var comparisonWorker = QueryPlanHistoryComparisonWorker() + + private enum LoadState { + case idle + case loading + case loaded + case failed(String) + } + + private var selectedBaseline: ExplainPlanHistorySnapshot? { + snapshots.first { $0.id == selectedBaselineID } + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + content + Divider() + footer + } + .frame( + minWidth: 780, + idealWidth: 980, + maxWidth: .infinity, + minHeight: 480, + idealHeight: 640, + maxHeight: .infinity + ) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("query-plan-history-sheet") + .task(id: historyReloadVersion) { + await loadHistory(showProgress: snapshots.isEmpty) + } + .task(id: selectedBaselineID) { + await resolveSelectedComparison() + } + .onReceive(AppEvents.shared.queryHistoryDidUpdate) { _ in + historyReloadVersion += 1 + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "Plan History")) + .font(.headline) + Text(queryPreview(context.subjectQuery)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(2) + .textSelection(.enabled) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + } + + @ViewBuilder + private var content: some View { + switch loadState { + case .idle, .loading: + ProgressView(String(localized: "Loading plan history…")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .failed(let message): + ContentUnavailableView( + String(localized: "Plan History Unavailable"), + systemImage: "exclamationmark.triangle", + description: Text(message) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .loaded where snapshots.isEmpty: + ContentUnavailableView( + String(localized: "No Earlier Plans"), + systemImage: "clock.arrow.circlepath", + description: Text(String(localized: "Run this EXPLAIN again to create a comparison baseline.")) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .loaded: + historySplitView + } + } + + private var historySplitView: some View { + HSplitView { + VStack(alignment: .leading, spacing: 0) { + Text(String(localized: "Earlier Runs")) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider() + + List(snapshots, selection: $selectedBaselineID) { snapshot in + baselineRow(snapshot) + .tag(snapshot.id) + } + .listStyle(.sidebar) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("query-plan-history-baseline-list") + } + .frame(minWidth: 250, idealWidth: 290) + + comparisonPane + .frame(minWidth: 480) + } + } + + private func baselineRow(_ snapshot: ExplainPlanHistorySnapshot) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(snapshot.context.capturedAt.formatted(date: .abbreviated, time: .shortened)) + .font(.callout.weight(.medium)) + Spacer(minLength: 4) + Text(QueryDurationFormatter.string(from: snapshot.executionTime)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + } + + @ViewBuilder + private var comparisonPane: some View { + if let selectedBaseline, let comparisonPresentation { + VStack(spacing: 0) { + comparisonHeader(selectedBaseline) + Divider() + + switch comparisonPresentation { + case .structured(let comparison): + structuredComparison(comparison) + case .rawOnly(let previous, let current): + rawComparison(previous: previous, current: current) + } + } + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func comparisonHeader(_ baseline: ExplainPlanHistorySnapshot) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "Baseline")) + .font(.caption) + .foregroundStyle(.secondary) + Text(baseline.context.capturedAt.formatted(date: .abbreviated, time: .shortened)) + .font(.callout.weight(.medium)) + Text(QueryDurationFormatter.string(from: baseline.executionTime)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + + Image(systemName: "arrow.right") + .foregroundStyle(.tertiary) + + VStack(alignment: .leading, spacing: 2) { + Text(String(localized: "Current")) + .font(.caption) + .foregroundStyle(.secondary) + Text(context.capturedAt.formatted(date: .abbreviated, time: .shortened)) + .font(.callout.weight(.medium)) + Text(currentExecutionTime.map { QueryDurationFormatter.string(from: $0) } ?? "–") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + + Spacer() + } + .padding(12) + .background(Color(nsColor: .controlBackgroundColor)) + } + + private func structuredComparison(_ comparison: QueryPlanComparison) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + metricGrid(comparison.summary) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "Node Changes")) + .font(.headline) + + if comparison.nodeChanges.isEmpty { + Text(String(localized: "No node changes.")) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 12) + } else { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(comparison.nodeChanges) { change in + nodeChangeRow(change) + Divider() + } + } + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("query-plan-history-change-list") + } + .padding(16) + } + } + + private func metricGrid(_ summary: QueryPlanSummaryComparison) -> some View { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 7) { + GridRow { + Text(String(localized: "Metric")) + Text(String(localized: "Baseline")) + Text(String(localized: "Current")) + Text(String(localized: "Change")) + } + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + + Divider().gridCellColumns(4) + + metricRow( + String(localized: "Cost"), + delta: summary.rootEstimatedTotalCost, + format: .decimal + ) + metricRow( + String(localized: "Estimated rows"), + delta: summary.rootEstimatedRows, + format: .integer + ) + metricRow( + String(localized: "Planning time"), + delta: summary.planningTime, + format: .milliseconds + ) + metricRow( + String(localized: "Execution time"), + delta: summary.executionTime, + format: .milliseconds + ) + metricRow( + String(localized: "Node count"), + delta: summary.nodeCount, + format: .integer + ) + } + .monospacedDigit() + } + + private func metricRow( + _ name: String, + delta: QueryPlanMetricDelta, + format: MetricFormat + ) -> some View { + GridRow { + Text(name) + .font(.callout) + Text(format.value(delta.previous)) + .foregroundStyle(.secondary) + Text(format.value(delta.current)) + Text(format.change(delta)) + .foregroundStyle(delta.hasChanges ? Color.primary : Color.secondary) + } + } + + private func nodeChangeRow(_ change: QueryPlanNodeChange) -> some View { + let style = NodeChangeStyle(change.kind) + return HStack(alignment: .top, spacing: 10) { + Image(systemName: style.symbolName) + .foregroundStyle(style.color) + .frame(width: 18) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(style.label) + .font(.caption.weight(.medium)) + .foregroundStyle(style.color) + Text(nodeTitle(change)) + .font(.callout.weight(.medium)) + } + + ForEach(change.valueChanges) { valueChange in + Text(valueChangeText(valueChange)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + + Spacer(minLength: 0) + } + .padding(.vertical, 8) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("query-plan-history-change-\(change.kind.rawValue)") + .accessibilityLabel("\(style.label): \(nodeTitle(change))") + .accessibilityValue(QueryPlanHistoryNodeChangeAccessibility.value(change)) + } + + private func rawComparison(previous: String, current: String) -> some View { + VStack(spacing: 0) { + HStack(spacing: 6) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + Text(String(localized: "A structured comparison is unavailable because one plan could not be parsed. Showing raw output.")) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(nsColor: .controlBackgroundColor)) + + Divider() + + HSplitView { + rawColumn(title: String(localized: "Baseline"), text: previous) + .frame(minWidth: 220) + rawColumn(title: String(localized: "Current"), text: current) + .frame(minWidth: 220) + } + } + } + + private func rawColumn(title: String, text: String) -> some View { + VStack(alignment: .leading, spacing: 0) { + Text(title) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .controlBackgroundColor)) + + Divider() + + ScrollView([.horizontal, .vertical]) { + Text(verbatim: rawTextForDisplay(text)) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(12) + } + .background(Color(nsColor: .textBackgroundColor)) + } + } + + private var footer: some View { + HStack { + Spacer() + Button(String(localized: "Close")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + } + .padding(16) + } + + private func loadHistory(showProgress: Bool) async { + if showProgress { + loadState = .loading + } + + let manager = QueryHistoryManager.shared + guard await manager.isStoreAvailable() else { + guard !Task.isCancelled else { return } + loadState = .failed(String(localized: "The query history store could not be opened.")) + return + } + + let loaded = await manager.explainPlanHistory(matching: context, limit: 50) + guard !Task.isCancelled else { return } + + let selectedID = selectedBaselineID + snapshots = loaded + .filter { $0.id != context.historyId } + .sorted { lhs, rhs in + if lhs.context.capturedAt != rhs.context.capturedAt { + return lhs.context.capturedAt > rhs.context.capturedAt + } + return lhs.id.uuidString > rhs.id.uuidString + } + if let selectedID, snapshots.contains(where: { $0.id == selectedID }) { + selectedBaselineID = selectedID + } else { + selectedBaselineID = snapshots.first?.id + } + loadState = .loaded + } + + private func resolveSelectedComparison() async { + guard let selectedBaseline, let selectionID = selectedBaselineID else { + comparisonPresentation = nil + return + } + + comparisonPresentation = nil + do { + try await Task.sleep(for: .milliseconds(150)) + } catch { + return + } + guard selectedBaselineID == selectionID else { return } + + let currentPlan = currentPlan + let currentRawText = currentRawText + let previousRawText = await QueryHistoryManager.shared.explainPlanRawText(historyId: selectionID) + guard !Task.isCancelled, selectedBaselineID == selectionID else { return } + guard let previousRawText else { + comparisonPresentation = .rawOnly( + previous: "", + current: QueryPlanHistoryRawText.bounded(currentRawText) + ) + return + } + let presentation = await comparisonWorker.resolve( + previousRawText: previousRawText, + formatRawValue: selectedBaseline.context.formatRawValue, + currentPlan: currentPlan, + currentRawText: currentRawText + ) + guard !Task.isCancelled, + selectedBaselineID == selectionID, + let presentation + else { return } + comparisonPresentation = presentation + } + + private func queryPreview(_ query: String) -> String { + let query = query as NSString + let inputWasTruncated = query.length > 2_000 + let bounded = query.substring(to: min(query.length, 2_000)) + let collapsed = bounded + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .joined(separator: " ") + let collapsedText = collapsed as NSString + guard collapsedText.length > 160 || inputWasTruncated else { return collapsed } + return collapsedText.substring(to: min(collapsedText.length, 160)) + "…" + } + + private func nodeTitle(_ change: QueryPlanNodeChange) -> String { + guard let relation = change.relation, !relation.isEmpty else { return change.operation } + return "\(change.operation) · \(relation)" + } + + private func valueChangeText(_ change: QueryPlanNodeValueChange) -> String { + QueryPlanHistoryNodeChangeAccessibility.detail(change) + } + + private func rawTextForDisplay(_ text: String) -> String { + text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? String(localized: "No raw plan output.") + : text + } +} + +private enum MetricFormat { + case decimal + case integer + case milliseconds + + func value(_ value: Double?) -> String { + guard let value else { return "–" } + switch self { + case .decimal: + return value.formatted(.number.precision(.fractionLength(0...2))) + case .integer: + return value.formatted(.number.precision(.fractionLength(0))) + case .milliseconds: + return String(format: String(localized: "%.3f ms"), value) + } + } + + func change(_ delta: QueryPlanMetricDelta) -> String { + guard delta.hasChanges else { return "–" } + guard let value = delta.delta else { return String(localized: "Changed") } + + let formatted: String + switch self { + case .decimal: + formatted = String(format: "%+.2f", value) + case .integer: + formatted = String(format: "%+.0f", value) + case .milliseconds: + formatted = String(format: String(localized: "%+.3f ms"), value) + } + + guard let percent = delta.percentChange else { return formatted } + return "\(formatted) (\(String(format: "%+.1f%%", percent)))" + } +} + +private struct NodeChangeStyle { + let symbolName: String + let label: String + let color: Color + + init(_ kind: QueryPlanNodeChangeKind) { + switch kind { + case .added: + symbolName = "plus.circle.fill" + label = String(localized: "Added") + color = .green + case .removed: + symbolName = "minus.circle.fill" + label = String(localized: "Removed") + color = .red + case .modified: + symbolName = "pencil.circle.fill" + label = String(localized: "Modified") + color = .orange + } + } +} diff --git a/TablePro/Views/QueryPlan/QueryPlanResultView.swift b/TablePro/Views/QueryPlan/QueryPlanResultView.swift index 16467288b..1472b4417 100644 --- a/TablePro/Views/QueryPlan/QueryPlanResultView.swift +++ b/TablePro/Views/QueryPlan/QueryPlanResultView.swift @@ -7,6 +7,15 @@ import SwiftUI +private struct QueryPlanHistorySheetInput: Identifiable { + var id: UUID { context.historyId } + + let context: ExplainPlanHistoryContext + let rawText: String + let executionTime: TimeInterval? + let plan: QueryPlan? +} + enum QueryPlanViewMode: String, CaseIterable, Identifiable { case diagram case tree @@ -65,10 +74,12 @@ struct QueryPlanResultView: View { let rawText: String let executionTime: TimeInterval? let plan: QueryPlan? + let historyContext: ExplainPlanHistoryContext? @AppStorage(PreferenceKeys.queryPlanRawFontSize.name) private var fontSize: Double = 13 @State private var showCopyConfirmation = false @State private var copyResetTask: Task? + @State private var historySheetInput: QueryPlanHistorySheetInput? @State private var viewMode: QueryPlanViewMode = .diagram /// Shared by the diagram and the outline, so switching view mode keeps the selected step. @@ -78,12 +89,32 @@ struct QueryPlanResultView: View { QueryPlanPresentation.resolve(plan: plan, rawText: rawText) } + init( + rawText: String, + executionTime: TimeInterval?, + plan: QueryPlan?, + historyContext: ExplainPlanHistoryContext? = nil + ) { + self.rawText = rawText + self.executionTime = executionTime + self.plan = plan + self.historyContext = historyContext + } + var body: some View { VStack(spacing: 0) { toolbar Divider() content } + .sheet(item: $historySheetInput) { input in + QueryPlanHistoryView( + context: input.context, + currentRawText: input.rawText, + currentExecutionTime: input.executionTime, + currentPlan: input.plan + ) + } } @ViewBuilder @@ -163,6 +194,18 @@ struct QueryPlanResultView: View { .transition(.opacity) } + if historyContext != nil { + Button { + showHistory() + } label: { + Label(String(localized: "History"), systemImage: "clock.arrow.circlepath") + } + .buttonStyle(.bordered) + .controlSize(.small) + .help(String(localized: "Compare this plan with an earlier run")) + .accessibilityIdentifier("query-plan-history-button") + } + Button(action: copyText) { Label(String(localized: "Copy"), systemImage: "doc.on.doc") } @@ -226,6 +269,16 @@ struct QueryPlanResultView: View { } } + private func showHistory() { + guard let historyContext else { return } + historySheetInput = QueryPlanHistorySheetInput( + context: historyContext, + rawText: rawText, + executionTime: executionTime, + plan: plan + ) + } + private func formattedDuration(_ duration: TimeInterval) -> String { if duration < 0.001 { return "<1ms" diff --git a/TableProTests/Core/Services/Query/ExplainResultRouterTests.swift b/TableProTests/Core/Services/Query/ExplainResultRouterTests.swift index a994a68df..0851f33d9 100644 --- a/TableProTests/Core/Services/Query/ExplainResultRouterTests.swift +++ b/TableProTests/Core/Services/Query/ExplainResultRouterTests.swift @@ -17,7 +17,13 @@ struct ExplainResultRouterTests { ] private let mysqlVariants = [ - ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .mysqlComposite) + ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .mysqlComposite), + ExplainVariant( + id: "explain-json", + label: "EXPLAIN (JSON)", + sqlPrefix: "EXPLAIN FORMAT=JSON", + format: .mysqlComposite + ), ] @Test("Joins single-column explain rows with newlines") @@ -31,6 +37,10 @@ struct ExplainResultRouterTests { declaredVariants: mysqlVariants ) #expect(routed?.rawText == "-> Limit: 5 row(s)\n -> Sort") + #expect(routed?.subjectSQL == "SELECT 1") + #expect(routed?.format == .mysqlComposite) + #expect(routed?.variantId?.hasPrefix("__typed_explain__:") == true) + #expect(routed?.variantId != "explain") } @Test("A multi-column plan the app can read routes to the viewer") @@ -45,6 +55,9 @@ struct ExplainResultRouterTests { ) #expect(routed?.rawText == "2\t0\t0\tSCAN users") #expect(routed?.plan != nil) + #expect(routed?.subjectSQL == "SELECT 1") + #expect(routed?.format == .sqliteQueryPlan) + #expect(routed?.variantId == "plan") } /// MySQL declares an `EXPLAIN` variant, so prefix matching alone would drag its tabular @@ -112,4 +125,81 @@ struct ExplainResultRouterTests { ) == nil ) } + + @Test("Falls back to the exact SQL when no inner statement can be derived") + func preservesExactSQLFallback() { + let sql = "EXPLAIN VERBOSE" + let routed = ExplainResultRouter.route( + sql: sql, + columns: ["EXPLAIN"], + rows: [[.text("plan")]], + databaseType: .mysql, + declaredVariants: mysqlVariants + ) + + #expect(routed?.subjectSQL == sql) + } + + @Test("Typed MySQL invocation preambles have separate history scopes") + func scopesTypedMySQLInvocations() { + let statements = [ + "EXPLAIN SELECT * FROM users", + "EXPLAIN FORMAT=TREE SELECT * FROM users", + "EXPLAIN ANALYZE SELECT * FROM users", + ] + let identifiers = statements.compactMap { sql in + ExplainResultRouter.route( + sql: sql, + columns: ["EXPLAIN"], + rows: [[.text("-> Table scan on users")]], + databaseType: .mysql, + declaredVariants: mysqlVariants + )?.variantId + } + + #expect(identifiers.count == 3) + #expect(Set(identifiers).count == 3) + #expect(identifiers[0] == "explain") + #expect(identifiers[1].hasPrefix("__typed_explain__:")) + #expect(identifiers[2].hasPrefix("__typed_explain__:")) + } + + @Test("Typed history preambles normalize case and spacing") + func normalizesTypedHistoryPreambles() { + let compact = routeMySQL("EXPLAIN FORMAT=TREE SELECT * FROM users") + let spaced = routeMySQL(" explain format = tree SELECT * FROM users") + + #expect(compact?.variantId == spaced?.variantId) + #expect(compact?.subjectSQL == spaced?.subjectSQL) + } + + @Test("Typed declared JSON keeps its variant identifier") + func preservesDeclaredJSONVariant() { + let routed = routeMySQL("EXPLAIN FORMAT=JSON SELECT * FROM users") + + #expect(routed?.variantId == "explain-json") + #expect(routed?.format == .mysqlComposite) + #expect(routed?.plan != nil) + } + + @Test("Typed history discriminator is bounded and hides the preamble") + func boundsTypedHistoryDiscriminator() throws { + let sql = "EXPLAIN " + String(repeating: "OPTION ", count: 1_000) + "SELECT 1" + let routed = try #require(routeMySQL(sql)) + let identifier = try #require(routed.variantId) + + #expect(identifier.hasPrefix("__typed_explain__:")) + #expect(identifier.count == "__typed_explain__:".count + 64) + #expect(!identifier.contains("OPTION")) + } + + private func routeMySQL(_ sql: String) -> ExplainResultRouter.RoutedPlan? { + ExplainResultRouter.route( + sql: sql, + columns: ["EXPLAIN"], + rows: [[.text("-> Table scan on users")]], + databaseType: .mysql, + declaredVariants: mysqlVariants + ) + } } diff --git a/TableProTests/Core/Storage/ExplainPlanHistoryStorageTests.swift b/TableProTests/Core/Storage/ExplainPlanHistoryStorageTests.swift new file mode 100644 index 000000000..321c95c71 --- /dev/null +++ b/TableProTests/Core/Storage/ExplainPlanHistoryStorageTests.swift @@ -0,0 +1,535 @@ +import Foundation +import SQLite3 +@testable import TablePro +import Testing + +@Suite("Explain plan history storage") +struct ExplainPlanHistoryStorageTests { + private func makeURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("explain_plan_history_\(UUID().uuidString).db") + } + + private func makeContext( + historyId: UUID = UUID(), + subjectQuery: String = "SELECT * FROM users WHERE id = 1", + connectionId: UUID = UUID(), + databaseName: String = "app", + databaseType: DatabaseType = .postgresql, + schemaName: String? = "public", + variantId: String? = "analyze", + formatRawValue: String = "json", + capturedAt: Date = Date() + ) -> ExplainPlanHistoryContext { + ExplainPlanHistoryContext( + historyId: historyId, + subjectQuery: subjectQuery, + connectionId: connectionId, + databaseName: databaseName, + databaseType: databaseType, + schemaName: schemaName, + variantId: variantId, + formatRawValue: formatRawValue, + capturedAt: capturedAt + ) + } + + private func makeEntry( + context: ExplainPlanHistoryContext, + executionTime: TimeInterval = 0.25, + wasSuccessful: Bool = true + ) -> QueryHistoryEntry { + QueryHistoryEntry( + id: context.historyId, + query: context.subjectQuery, + connectionId: context.connectionId, + databaseName: context.databaseName, + databaseType: context.databaseType, + schemaName: context.schemaName, + source: .explain, + executedAt: context.capturedAt, + executionTime: executionTime, + rowCount: 1, + wasSuccessful: wasSuccessful + ) + } + + @discardableResult + private func record( + _ context: ExplainPlanHistoryContext, + rawText: String, + executionTime: TimeInterval = 0.25, + wasSuccessful: Bool = true, + in storage: QueryHistoryStorage + ) async -> Bool { + await storage.record( + makeEntry(context: context, executionTime: executionTime, wasSuccessful: wasSuccessful), + explainPlan: ExplainPlanHistoryRecord(context: context, rawText: rawText) + ) + } + + private func scalarInt(at url: URL, sql: String) -> Int { + var database: OpaquePointer? + guard sqlite3_open(url.path(percentEncoded: false), &database) == SQLITE_OK else { return -1 } + defer { sqlite3_close_v2(database) } + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else { return -1 } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW else { return -1 } + return Int(sqlite3_column_int64(statement, 0)) + } + + @Test("metadata round-trips and raw payload loads by selected ID") + func metadataRoundTripsAndRawPayloadLoadsBySelectedID() async { + let url = makeURL() + let capturedAt = Date(timeIntervalSince1970: 1_800_000_000) + let context = makeContext(capturedAt: capturedAt) + let first = QueryHistoryStorage(databaseURL: url, removeDatabaseOnDeinit: false) + #expect(await record(context, rawText: "[{\"Plan\":{}}]", executionTime: 1.5, in: first)) + + let reopened = QueryHistoryStorage(databaseURL: url, removeDatabaseOnDeinit: true) + let current = makeContext( + subjectQuery: context.subjectQuery, + connectionId: context.connectionId, + databaseName: context.databaseName, + databaseType: context.databaseType, + schemaName: context.schemaName, + variantId: context.variantId, + formatRawValue: context.formatRawValue, + capturedAt: capturedAt.addingTimeInterval(1) + ) + let snapshots = await reopened.explainPlanHistory(matching: current, limit: 10) + + #expect(snapshots.count == 1) + #expect(snapshots.first?.id == context.historyId) + #expect(snapshots.first?.context == context) + #expect(snapshots.first?.executionTime == 1.5) + #expect(await reopened.explainPlanRawText(historyId: context.historyId) == "[{\"Plan\":{}}]") + #expect(await reopened.explainPlanRawText(historyId: UUID()) == nil) + } + + @Test("baseline matching isolates every scope field and the exact query") + func baselineMatchingIsExact() async { + let storage = QueryHistoryStorage(databaseURL: makeURL(), removeDatabaseOnDeinit: true) + let connectionId = UUID() + let now = Date(timeIntervalSince1970: 1_800_000_000) + let current = makeContext(connectionId: connectionId, capturedAt: now) + let valid = makeContext(connectionId: connectionId, capturedAt: now.addingTimeInterval(-10)) + #expect(await record(valid, rawText: "valid", in: storage)) + + let mismatches = [ + makeContext( + subjectQuery: "SELECT * FROM users WHERE id = 2", + connectionId: connectionId, + capturedAt: now.addingTimeInterval(-9) + ), + makeContext(connectionId: UUID(), capturedAt: now.addingTimeInterval(-8)), + makeContext(connectionId: connectionId, databaseName: "other", capturedAt: now.addingTimeInterval(-7)), + makeContext(connectionId: connectionId, databaseType: .mysql, capturedAt: now.addingTimeInterval(-6)), + makeContext(connectionId: connectionId, schemaName: "private", capturedAt: now.addingTimeInterval(-5)), + makeContext(connectionId: connectionId, variantId: nil, capturedAt: now.addingTimeInterval(-4)), + makeContext(connectionId: connectionId, formatRawValue: "text", capturedAt: now.addingTimeInterval(-3)) + ] + for (index, mismatch) in mismatches.enumerated() { + #expect(await record(mismatch, rawText: "mismatch-\(index)", in: storage)) + } + + let snapshots = await storage.explainPlanHistory(matching: current, limit: 20) + #expect(snapshots.map(\.id) == [valid.historyId]) + #expect(await storage.explainPlanRawText(historyId: valid.historyId) == "valid") + } + + @Test("nullable schema and variant are matched null-safely") + func nullableScopeIsExact() async { + let storage = QueryHistoryStorage(databaseURL: makeURL(), removeDatabaseOnDeinit: true) + let connectionId = UUID() + let now = Date(timeIntervalSince1970: 1_800_000_000) + let previous = makeContext( + connectionId: connectionId, + schemaName: nil, + variantId: nil, + capturedAt: now.addingTimeInterval(-1) + ) + #expect(await record(previous, rawText: "nullable", in: storage)) + + let current = makeContext( + connectionId: connectionId, + schemaName: nil, + variantId: nil, + capturedAt: now + ) + #expect(await storage.explainPlanHistory(matching: current, limit: 10).map(\.id) == [previous.historyId]) + } + + @Test("history returns newest prior successful snapshots only") + func historyReturnsNewestPriorSuccessfulSnapshots() async { + let url = makeURL() + let storage = QueryHistoryStorage(databaseURL: url, removeDatabaseOnDeinit: true) + let connectionId = UUID() + let now = Date(timeIntervalSince1970: 1_800_000_000) + let current = makeContext(connectionId: connectionId, capturedAt: now) + let older = makeContext(connectionId: connectionId, capturedAt: now.addingTimeInterval(-30)) + let newer = makeContext(connectionId: connectionId, capturedAt: now.addingTimeInterval(-10)) + let failed = makeContext(connectionId: connectionId, capturedAt: now.addingTimeInterval(-5)) + let future = makeContext(connectionId: connectionId, capturedAt: now.addingTimeInterval(10)) + + for (context, rawText, successful) in [ + (older, "older", true), + (newer, "newer", true), + (failed, "failed", false), + (future, "future", true) + ] { + #expect(await record(context, rawText: rawText, wasSuccessful: successful, in: storage)) + } + #expect(await record(current, rawText: "current", in: storage)) + #expect(scalarInt(at: url, sql: "SELECT COUNT(*) FROM query_plan_snapshots;") == 4) + + let snapshots = await storage.explainPlanHistory(matching: current, limit: 10) + #expect(snapshots.map(\.id) == [newer.historyId, older.historyId]) + #expect(await storage.explainPlanHistory(matching: current, limit: 1).map(\.id) == [newer.historyId]) + #expect(await storage.explainPlanHistory(matching: current, limit: 0).isEmpty) + #expect(await storage.explainPlanRawText(historyId: newer.historyId) == "newer") + } + + @Test("ordinary history records have no plan snapshot") + func ordinaryHistoryHasNoSnapshot() async { + let storage = QueryHistoryStorage(databaseURL: makeURL(), removeDatabaseOnDeinit: true) + let context = makeContext(capturedAt: Date(timeIntervalSince1970: 1_800_000_000)) + #expect(await storage.record(makeEntry(context: context))) + + let current = makeContext( + subjectQuery: context.subjectQuery, + connectionId: context.connectionId, + databaseName: context.databaseName, + databaseType: context.databaseType, + schemaName: context.schemaName, + variantId: context.variantId, + formatRawValue: context.formatRawValue, + capturedAt: context.capturedAt.addingTimeInterval(1) + ) + #expect(await storage.explainPlanHistory(matching: current, limit: 10).isEmpty) + } + + @Test("two megabytes is stored and one byte more keeps only the parent") + func snapshotSizeBoundary() async { + let url = makeURL() + let storage = QueryHistoryStorage(databaseURL: url, removeDatabaseOnDeinit: true) + let base = Date(timeIntervalSince1970: 1_800_000_000) + let exact = makeContext(subjectQuery: "SELECT exact", capturedAt: base) + let oversized = makeContext(subjectQuery: "SELECT oversized", capturedAt: base.addingTimeInterval(1)) + let exactText = String(repeating: "é", count: ExplainPlanHistoryRecord.maximumRawByteCount / 2) + let oversizedText = exactText + "x" + + #expect(await record(exact, rawText: exactText, in: storage)) + #expect(await record(oversized, rawText: oversizedText, in: storage)) + #expect(await storage.count(scope: .all) == 2) + #expect(scalarInt(at: url, sql: "SELECT COUNT(*) FROM query_plan_snapshots;") == 1) + #expect( + scalarInt( + at: url, + sql: "SELECT raw_byte_count FROM query_plan_snapshots WHERE history_id = '\(exact.historyId.uuidString)';" + ) == ExplainPlanHistoryRecord.maximumRawByteCount + ) + + let exactCurrent = makeContext( + subjectQuery: exact.subjectQuery, + connectionId: exact.connectionId, + databaseName: exact.databaseName, + databaseType: exact.databaseType, + schemaName: exact.schemaName, + variantId: exact.variantId, + formatRawValue: exact.formatRawValue, + capturedAt: base.addingTimeInterval(10) + ) + #expect(await storage.explainPlanHistory(matching: exactCurrent, limit: 1).first?.id == exact.historyId) + #expect(await storage.explainPlanRawText(historyId: exact.historyId) == exactText) + #expect(await storage.explainPlanRawText(historyId: oversized.historyId) == nil) + } + + @Test("manager uses the plan identity and timestamp and obeys capture pause") + func managerUsesPlanContextAndPause() async { + let storage = QueryHistoryStorage(databaseURL: makeURL(), removeDatabaseOnDeinit: true) + let context = makeContext(capturedAt: Date(timeIntervalSince1970: 1_800_000_000)) + let record = ExplainPlanHistoryRecord(context: context, rawText: "plan") + let request = QueryHistoryRecordRequest( + query: context.subjectQuery, + connectionId: context.connectionId, + databaseName: context.databaseName, + databaseType: context.databaseType, + schemaName: context.schemaName, + source: .explain, + executionTime: 0.5, + rowCount: 1, + wasSuccessful: true, + explainPlan: record + ) + + let paused = QueryHistoryManager(storage: storage, isCapturePaused: { true }) + #expect(await paused.record(request) == false) + #expect(await storage.count(scope: .all) == 0) + + let manager = QueryHistoryManager(storage: storage, isCapturePaused: { false }) + #expect(await manager.record(request)) + let parent = await storage.fetch(.init(scope: .all), after: nil, limit: 1).entries.first + #expect(parent?.id == context.historyId) + #expect(parent?.executedAt == context.capturedAt) + #expect(await manager.explainPlanRawText(historyId: context.historyId) == "plan") + + let oversizedContext = makeContext( + subjectQuery: "SELECT oversized", + connectionId: context.connectionId, + databaseName: context.databaseName, + databaseType: context.databaseType, + schemaName: context.schemaName, + variantId: context.variantId, + formatRawValue: context.formatRawValue, + capturedAt: context.capturedAt.addingTimeInterval(1) + ) + let oversizedPlan = ExplainPlanHistoryRecord( + context: oversizedContext, + rawText: String(repeating: "x", count: ExplainPlanHistoryRecord.maximumRawByteCount + 1) + ) + let oversizedRequest = QueryHistoryRecordRequest( + query: oversizedContext.subjectQuery, + connectionId: oversizedContext.connectionId, + databaseName: oversizedContext.databaseName, + databaseType: oversizedContext.databaseType, + schemaName: oversizedContext.schemaName, + source: .explain, + executionTime: 0.5, + rowCount: 1, + wasSuccessful: true, + explainPlan: oversizedPlan + ) + #expect(await manager.record(oversizedRequest)) + #expect(await storage.count(scope: .all) == 2) + #expect(await manager.explainPlanRawText(historyId: oversizedContext.historyId) == nil) + } + + @Test("plan byte budget prunes oldest payloads but keeps history parents") + func planByteBudgetPrunesOldestPayloads() async { + let url = makeURL() + let storage = QueryHistoryStorage( + databaseURL: url, + removeDatabaseOnDeinit: true, + explainPlanRawByteLimit: 10 + ) + let connectionIds = [UUID(), UUID(), UUID()] + let base = Date(timeIntervalSince1970: 1_800_000_000) + let contexts = (0..<3).map { offset in + makeContext( + connectionId: connectionIds[offset], + capturedAt: base.addingTimeInterval(TimeInterval(offset)) + ) + } + + for context in contexts { + #expect(await record(context, rawText: "123456", in: storage)) + } + + #expect(await storage.count(scope: .all) == 3) + #expect(scalarInt(at: url, sql: "SELECT COALESCE(SUM(raw_byte_count), 0) FROM query_plan_snapshots;") == 6) + #expect(await storage.explainPlanRawText(historyId: contexts[0].historyId) == nil) + #expect(await storage.explainPlanRawText(historyId: contexts[1].historyId) == nil) + #expect(await storage.explainPlanRawText(historyId: contexts[2].historyId) == "123456") + } + + @Test("plan count budget prunes oldest payloads but keeps history parents") + func planCountBudgetPrunesOldestPayloads() async { + let storage = QueryHistoryStorage( + databaseURL: makeURL(), + removeDatabaseOnDeinit: true, + explainPlanRawByteLimit: 100, + explainPlanSnapshotLimit: 2 + ) + let connectionId = UUID() + let base = Date(timeIntervalSince1970: 1_800_000_000) + let contexts = (0..<3).map { offset in + makeContext( + connectionId: connectionId, + capturedAt: base.addingTimeInterval(TimeInterval(offset)) + ) + } + + for context in contexts { + #expect(await record(context, rawText: "x", in: storage)) + } + + #expect(await storage.count(scope: .all) == 3) + #expect(await storage.explainPlanRawText(historyId: contexts[0].historyId) == nil) + #expect(await storage.explainPlanRawText(historyId: contexts[1].historyId) == "x") + #expect(await storage.explainPlanRawText(historyId: contexts[2].historyId) == "x") + } + + @Test("snapshot context mismatch keeps only its parent") + func snapshotContextMismatchKeepsOnlyParent() async { + let context = makeContext() + let entry = makeEntry(context: context) + let mismatches = [ + makeContext( + subjectQuery: context.subjectQuery, + connectionId: context.connectionId, + databaseName: context.databaseName, + databaseType: context.databaseType, + schemaName: context.schemaName, + variantId: context.variantId, + formatRawValue: context.formatRawValue, + capturedAt: context.capturedAt + ), + makeContext( + historyId: context.historyId, + subjectQuery: context.subjectQuery, + databaseName: context.databaseName, + databaseType: context.databaseType, + schemaName: context.schemaName, + variantId: context.variantId, + formatRawValue: context.formatRawValue, + capturedAt: context.capturedAt + ), + makeContext( + historyId: context.historyId, + subjectQuery: context.subjectQuery, + connectionId: context.connectionId, + databaseName: "other", + databaseType: context.databaseType, + schemaName: context.schemaName, + variantId: context.variantId, + formatRawValue: context.formatRawValue, + capturedAt: context.capturedAt + ), + makeContext( + historyId: context.historyId, + subjectQuery: context.subjectQuery, + connectionId: context.connectionId, + databaseName: context.databaseName, + databaseType: .mysql, + schemaName: context.schemaName, + variantId: context.variantId, + formatRawValue: context.formatRawValue, + capturedAt: context.capturedAt + ), + makeContext( + historyId: context.historyId, + subjectQuery: context.subjectQuery, + connectionId: context.connectionId, + databaseName: context.databaseName, + databaseType: context.databaseType, + schemaName: "other", + variantId: context.variantId, + formatRawValue: context.formatRawValue, + capturedAt: context.capturedAt + ), + makeContext( + historyId: context.historyId, + subjectQuery: context.subjectQuery, + connectionId: context.connectionId, + databaseName: context.databaseName, + databaseType: context.databaseType, + schemaName: context.schemaName, + variantId: context.variantId, + formatRawValue: context.formatRawValue, + capturedAt: context.capturedAt.addingTimeInterval(1) + ) + ] + + for mismatch in mismatches { + let storage = QueryHistoryStorage(databaseURL: makeURL(), removeDatabaseOnDeinit: true) + let plan = ExplainPlanHistoryRecord(context: mismatch, rawText: "plan") + #expect(await storage.record(entry, explainPlan: plan)) + #expect(await storage.count(scope: .all) == 1) + #expect(await storage.explainPlanRawText(historyId: context.historyId) == nil) + } + } + + @Test("missing snapshot storage keeps parent history") + func missingSnapshotStorageKeepsParentHistory() async { + let storage = QueryHistoryStorage(databaseURL: makeURL(), removeDatabaseOnDeinit: true) + _ = await storage.count(scope: .all) + await storage.execute("DROP TABLE query_plan_snapshots;") + let context = makeContext() + + #expect(await record(context, rawText: "plan", in: storage)) + #expect(await storage.count(scope: .all) == 1) + let parent = await storage.fetch(.init(scope: .all), after: nil, limit: 1).entries.first + #expect(parent?.id == context.historyId) + #expect(await storage.explainPlanRawText(historyId: context.historyId) == nil) + } + + @Test("prune failure rolls back child and keeps parent history") + func pruneFailureRollsBackChildAndKeepsParentHistory() async { + let url = makeURL() + let storage = QueryHistoryStorage( + databaseURL: url, + removeDatabaseOnDeinit: true, + explainPlanRawByteLimit: 0 + ) + _ = await storage.count(scope: .all) + await storage.execute(""" + CREATE TRIGGER reject_plan_prune BEFORE DELETE ON query_plan_snapshots BEGIN + SELECT RAISE(ABORT, 'forced prune failure'); + END; + """) + let context = makeContext() + + #expect(await record(context, rawText: "plan", in: storage)) + #expect(await storage.count(scope: .all) == 1) + #expect(scalarInt(at: url, sql: "SELECT COUNT(*) FROM query_plan_snapshots;") == 0) + #expect(await storage.explainPlanRawText(historyId: context.historyId) == nil) + } + + @Test("commit failure rolls back child and keeps parent history") + func commitFailureRollsBackChildAndKeepsParentHistory() async { + let url = makeURL() + let storage = QueryHistoryStorage(databaseURL: url, removeDatabaseOnDeinit: true) + _ = await storage.count(scope: .all) + await storage.execute("CREATE TABLE missing_plan_parents (id INTEGER PRIMARY KEY);") + await storage.execute(""" + CREATE TABLE rejected_plan_commits ( + id INTEGER PRIMARY KEY, + parent_id INTEGER NOT NULL REFERENCES missing_plan_parents(id) + DEFERRABLE INITIALLY DEFERRED + ); + """) + await storage.execute(""" + CREATE TRIGGER reject_plan_commit AFTER INSERT ON query_plan_snapshots BEGIN + INSERT INTO rejected_plan_commits (id, parent_id) VALUES (1, 1); + END; + """) + let context = makeContext() + + #expect(await record(context, rawText: "plan", in: storage)) + #expect(await storage.count(scope: .all) == 1) + #expect(scalarInt(at: url, sql: "SELECT COUNT(*) FROM query_plan_snapshots;") == 0) + #expect(scalarInt(at: url, sql: "SELECT COUNT(*) FROM rejected_plan_commits;") == 0) + #expect(await storage.explainPlanRawText(historyId: context.historyId) == nil) + } + + @Test("history deletion paths remove child snapshots") + func deletionPathsRemoveSnapshots() async { + let url = makeURL() + let storage = QueryHistoryStorage(databaseURL: url, removeDatabaseOnDeinit: true) + let dropConnection = UUID() + let keepConnection = UUID() + let base = Date(timeIntervalSince1970: 1_800_000_000) + let deleteDirectly = makeContext(connectionId: dropConnection, capturedAt: base) + let clearWithConnection = makeContext(connectionId: dropConnection, capturedAt: base.addingTimeInterval(1)) + let keep = makeContext(connectionId: keepConnection, capturedAt: base.addingTimeInterval(2)) + for context in [deleteDirectly, clearWithConnection, keep] { + #expect(await record(context, rawText: context.historyId.uuidString, in: storage)) + } + #expect(scalarInt(at: url, sql: "SELECT COUNT(*) FROM query_plan_snapshots;") == 3) + + #expect(await storage.delete(id: deleteDirectly.historyId)) + #expect(scalarInt(at: url, sql: "SELECT COUNT(*) FROM query_plan_snapshots;") == 2) + #expect(await storage.clear(matching: .init(scope: .connection(dropConnection)))) + #expect(scalarInt(at: url, sql: "SELECT COUNT(*) FROM query_plan_snapshots;") == 1) + + let newest = makeContext(connectionId: keepConnection, capturedAt: base.addingTimeInterval(3)) + #expect(await record(newest, rawText: "newest", in: storage)) + await storage.updateSettingsCache(maxEntries: 1, maxDays: 0, autoCleanup: true) + #expect(await storage.cleanup()) + #expect(scalarInt(at: url, sql: "SELECT COUNT(*) FROM query_plan_snapshots;") == 1) + } +} diff --git a/TableProTests/Core/Storage/QueryHistoryMigrationTests.swift b/TableProTests/Core/Storage/QueryHistoryMigrationTests.swift index 8aad9679b..53ae13606 100644 --- a/TableProTests/Core/Storage/QueryHistoryMigrationTests.swift +++ b/TableProTests/Core/Storage/QueryHistoryMigrationTests.swift @@ -106,6 +106,18 @@ struct QueryHistoryMigrationTests { return names } + private func scalarInt(in url: URL, sql: String) -> Int { + var db: OpaquePointer? + guard sqlite3_open(url.path(percentEncoded: false), &db) == SQLITE_OK else { return -1 } + defer { sqlite3_close_v2(db) } + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return -1 } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW else { return -1 } + return Int(sqlite3_column_int64(statement, 0)) + } + @Test("migration keeps every existing row") func migrationPreservesRows() async { let connId = UUID() @@ -264,4 +276,55 @@ struct QueryHistoryMigrationTests { let entries = await second.fetch(QueryHistoryFilter(scope: .connection(connId)), after: nil, limit: 10).entries #expect(entries.first?.query == "SELECT * FROM once") } + + @Test("plan snapshot schema is added after legacy migration and is idempotent") + func planSnapshotSchemaMigratesLegacyDatabaseIdempotently() async { + let connectionId = UUID() + let url = makeLegacyDatabase(rows: [ + (UUID(), "SELECT * FROM legacy_plan", connectionId, Date(), nil) + ]) + + let first = QueryHistoryStorage(databaseURL: url, removeDatabaseOnDeinit: false) + #expect(await first.count(scope: .all) == 1) + #expect(columnNames(in: url, table: "query_plan_snapshots") == [ + "history_id", "subject_query", "subject_fingerprint_hash", "variant_id", + "format", "raw_text", "raw_byte_count", "parser_schema_version" + ]) + #expect(scalarInt(in: url, sql: "PRAGMA user_version;") == 4) + + let second = QueryHistoryStorage(databaseURL: url, removeDatabaseOnDeinit: true) + #expect(await second.count(scope: .all) == 1) + #expect( + scalarInt( + in: url, + sql: "SELECT COUNT(*) FROM sqlite_master WHERE name = 'query_plan_snapshots';" + ) == 1 + ) + #expect( + scalarInt( + in: url, + sql: "SELECT COUNT(*) FROM sqlite_master WHERE name = 'history_plan_snapshots_ad';" + ) == 1 + ) + } + + @Test("fresh plan snapshot schema is idempotent without changing schema version") + func freshPlanSnapshotSchemaIsIdempotent() async { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("fresh_plan_history_\(UUID().uuidString).db") + let first = QueryHistoryStorage(databaseURL: url, removeDatabaseOnDeinit: false) + #expect(await first.count(scope: .all) == 0) + let second = QueryHistoryStorage(databaseURL: url, removeDatabaseOnDeinit: true) + #expect(await second.count(scope: .all) == 0) + + #expect(scalarInt(in: url, sql: "PRAGMA user_version;") == 4) + #expect( + scalarInt( + in: url, + sql: "SELECT COUNT(*) FROM sqlite_master WHERE name IN (" + + "'query_plan_snapshots', 'idx_query_plan_snapshots_subject', 'history_plan_snapshots_ad');" + ) == 3 + ) + } } diff --git a/TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift b/TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift index e7fe153d9..c9814f15a 100644 --- a/TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift +++ b/TableProTests/Core/Utilities/SQL/QueryClassifierTests.swift @@ -4,8 +4,8 @@ // import Foundation -import Testing @testable import TablePro +import Testing @Suite("QueryClassifier isExplainStatement") struct QueryClassifierExplainTests { @@ -46,6 +46,51 @@ struct QueryClassifierExplainTests { } } +@Suite("QueryClassifier explainedStatement") +struct QueryClassifierExplainedStatementTests { + @Test("Preserves line comments between EXPLAIN options and the statement") + func preservesLineCommentBeforeStatement() throws { + let subject = "-- compare this plan\nSELECT * FROM users" + let explicitSubject = try #require(SQLStatementScanner.executableStatements(in: subject).first?.sql) + + #expect( + QueryClassifier.explainedStatement(in: "EXPLAIN QUERY PLAN \(subject)") + == explicitSubject + ) + } + + @Test("Preserves block comments between parenthesized options and the statement") + func preservesBlockCommentBeforeStatement() throws { + let subject = "/* compare this plan */ SELECT * FROM users" + let explicitSubject = try #require(SQLStatementScanner.executableStatements(in: subject).first?.sql) + + #expect( + QueryClassifier.explainedStatement(in: "EXPLAIN (ANALYZE, BUFFERS) \(subject)") + == explicitSubject + ) + } + + @Test("Preserves nested block comments before the statement") + func preservesNestedBlockCommentBeforeStatement() throws { + let subject = "/* outer /* inner */ still outer */ SELECT 1" + let explicitSubject = try #require(SQLStatementScanner.executableStatements(in: subject).first?.sql) + + #expect( + QueryClassifier.explainedStatement(in: "EXPLAIN (FORMAT JSON) \(subject)") + == explicitSubject + ) + } + + @Test("Comments inside EXPLAIN options do not become statement comments") + func skipsCommentsInsideOptions() { + #expect( + QueryClassifier.explainedStatement( + in: "EXPLAIN FORMAT /* option separator */ = JSON /* statement */ SELECT 1" + ) == "/* statement */ SELECT 1" + ) + } +} + @Suite("QueryClassifier classification with leading comments") struct QueryClassifierLeadingCommentTests { @Test("isWriteQuery detects writes preceded by comments") diff --git a/TableProTests/Models/Query/ExplainRequestTests.swift b/TableProTests/Models/Query/ExplainRequestTests.swift index a29fc81d0..d0479151c 100644 --- a/TableProTests/Models/Query/ExplainRequestTests.swift +++ b/TableProTests/Models/Query/ExplainRequestTests.swift @@ -36,7 +36,9 @@ struct ExplainRequestTests { ) #expect(request.sql == "EXPLAIN (FORMAT JSON) SELECT 1") + #expect(request.subjectSQL == "SELECT 1") #expect(request.format == .postgresJson) + #expect(request.variantId == "explain") } @Test("An explicit variant overrides the default") @@ -51,6 +53,8 @@ struct ExplainRequestTests { ) #expect(request.sql == "EXPLAIN (ANALYZE, FORMAT JSON) SELECT 1") + #expect(request.subjectSQL == "SELECT 1") + #expect(request.variantId == "analyze") } @Test("A driver that declares no variants has no request to build") @@ -82,7 +86,20 @@ struct ExplainRequestTests { let request = ExplainRequest.driverBuilt(sql: "EXPLAIN SELECT 1", databaseType: .duckdb) #expect(request.sql == "EXPLAIN SELECT 1") + #expect(request.subjectSQL == "EXPLAIN SELECT 1") #expect(request.format == .indentedText) + #expect(request.variantId == nil) + } + + @Test("A driver-built statement retains a separately known subject") + func driverBuiltRetainsSubject() { + let request = ExplainRequest.driverBuilt( + sql: "EXPLAIN SELECT 1", + databaseType: .duckdb, + subjectSQL: "SELECT 1" + ) + + #expect(request.subjectSQL == "SELECT 1") } @Test("A driver-built statement is marked so it keeps the ordinary result grid") @@ -108,4 +125,81 @@ struct ExplainRequestTests { let request = ExplainRequest.driverBuilt(sql: "DEBUG OBJECT key", databaseType: .redis) #expect(request.format == .plainText) } + + @Test("The result factory retains its history provenance") + @MainActor + func resultFactoryRetainsHistoryContext() { + let context = ExplainPlanHistoryContext( + historyId: UUID(), + subjectQuery: "SELECT * FROM users", + connectionId: UUID(), + databaseName: "app", + databaseType: .postgresql, + schemaName: "public", + variantId: "analyze", + formatRawValue: ExplainPlanFormat.postgresJson.rawValue, + capturedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + let result = ExplainResultSetFactory.make( + rawText: "[]", + plan: nil, + sql: "EXPLAIN (ANALYZE, FORMAT JSON) SELECT * FROM users", + executionTime: 0.25, + historyContext: context + ) + + #expect(result.explainHistoryContext == context) + #expect(result.baseQuery == "EXPLAIN (ANALYZE, FORMAT JSON) SELECT * FROM users") + } + + @Test("Parameterized plans never persist raw output") + func parameterizedPlansDoNotPersistRawOutput() { + let context = ExplainPlanHistoryContext( + historyId: UUID(), + subjectQuery: "EXPLAIN SELECT * FROM users WHERE token = :secret", + connectionId: UUID(), + databaseName: "app", + databaseType: .postgresql, + schemaName: "public", + variantId: "explain", + formatRawValue: ExplainPlanFormat.postgresJson.rawValue, + capturedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + let rawText = #"[{"Plan":{"Filter":"token = 'must-not-reach-history'"}}]"# + + #expect(ExplainPlanHistoryCapture.make(context: context, rawText: rawText).record.rawText == rawText) + #expect( + ExplainPlanHistoryCapture.make( + context: context, + rawText: rawText, + queryParameters: [QueryParameter(name: "secret", value: "must-not-reach-history")] + ) == nil + ) + } + + @Test("Typed EXPLAIN forwards parameters through the raw-output privacy gate") + func typedExplainUsesParameterizedCapturePath() throws { + let sourceURL = repositoryRoot() + .appendingPathComponent("TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift") + let source = try String(contentsOf: sourceURL, encoding: .utf8) + let parameterizedCaptureCall = + #"ExplainPlanHistoryCapture\.make\s*\(\s*context:\s*captureContext,"# + + #"\s*rawText:\s*routed\.rawText,"# + + #"\s*queryParameters:\s*queryParameterValues\s*\)"# + + #expect(source.range(of: parameterizedCaptureCall, options: .regularExpression) != nil) + } + + private func repositoryRoot() -> URL { + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + while directory.path != "/" { + if FileManager.default.fileExists( + atPath: directory.appendingPathComponent("TablePro.xcodeproj").path + ) { + return directory + } + directory.deleteLastPathComponent() + } + return directory + } } diff --git a/TableProTests/Models/Query/QueryPlanComparisonTests.swift b/TableProTests/Models/Query/QueryPlanComparisonTests.swift new file mode 100644 index 000000000..3c3f4c44a --- /dev/null +++ b/TableProTests/Models/Query/QueryPlanComparisonTests.swift @@ -0,0 +1,278 @@ +// +// QueryPlanComparisonTests.swift +// TableProTests +// +// Tests for deterministic older-to-current query plan comparison. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Query Plan Comparison") +struct QueryPlanComparisonTests { + private func node( + _ operation: String, + relation: String? = nil, + schema: String? = nil, + alias: String? = nil, + totalCost: Double? = nil, + rows: Int? = nil, + properties: [String: String] = [:], + children: [QueryPlanNode] = [] + ) -> QueryPlanNode { + QueryPlanNode( + operation: operation, + relation: relation, + schema: schema, + alias: alias, + estimatedStartupCost: nil, + estimatedTotalCost: totalCost, + estimatedRows: rows, + estimatedWidth: nil, + actualStartupTime: nil, + actualTotalTime: nil, + actualRows: nil, + actualLoops: nil, + properties: properties, + children: children + ) + } + + private func plan( + root: QueryPlanNode, + planningTime: Double? = nil, + executionTime: Double? = nil + ) -> QueryPlan { + QueryPlan( + rootNode: root, + planningTime: planningTime, + executionTime: executionTime, + rawText: "" + ) + } + + @Test("Fresh UUIDs do not make identical trees different") + func ignoresNodeIDs() { + let older = plan(root: node("Nested Loop", children: [ + node("Seq Scan", relation: "users", schema: "public", alias: "u", totalCost: 3), + ])) + let current = plan(root: node("Nested Loop", children: [ + node("Seq Scan", relation: "users", schema: "public", alias: "u", totalCost: 3), + ])) + + #expect(older.rootNode.id != current.rootNode.id) + #expect(!QueryPlanComparison(previous: older, current: current).hasChanges) + } + + @Test("Metric-only changes modify the matching node") + func reportsMetricChange() throws { + let comparison = QueryPlanComparison( + previous: plan(root: node("Seq Scan", relation: "users", totalCost: 10, rows: 100)), + current: plan(root: node("Seq Scan", relation: "users", totalCost: 12, rows: 80)) + ) + + let change = try #require(comparison.nodeChanges.first) + #expect(comparison.nodeChanges.count == 1) + #expect(change.kind == .modified) + #expect(change.operation == "Seq Scan") + #expect(change.relation == "users") + #expect( + change.valueChanges.map(\.name) == [ + String(localized: "Estimated Rows"), + String(localized: "Estimated Total Cost"), + ] + ) + #expect(comparison.summary.rootEstimatedTotalCost.delta == 2) + #expect(comparison.summary.rootEstimatedRows.delta == -20) + } + + @Test("A scan added and another removed keep older-to-current direction") + func reportsAddedAndRemovedScan() { + let older = plan(root: node("Append", children: [ + node("Seq Scan", relation: "users"), + ])) + let current = plan(root: node("Append", children: [ + node("Index Scan", relation: "orders", properties: ["Index Name": "orders_pkey"]), + ])) + let changes = QueryPlanComparison(previous: older, current: current).nodeChanges + + #expect(changes.count == 2) + #expect(changes.contains { $0.kind == .removed && $0.relation == "users" }) + #expect(changes.contains { $0.kind == .added && $0.relation == "orders" }) + } + + @Test("An operation change is a removal plus an addition") + func reportsOperationReplacement() { + let comparison = QueryPlanComparison( + previous: plan(root: node("Seq Scan", relation: "users")), + current: plan(root: node("Index Scan", relation: "users", properties: ["Index Name": "users_pkey"])) + ) + + #expect(comparison.nodeChanges.map(\.kind).sorted(by: { $0.rawValue < $1.rawValue }) == [.added, .removed]) + #expect(comparison.nodeChanges.allSatisfy { $0.kind != .modified }) + } + + @Test("A changed identifying property replaces the node") + func usesVisibleIdentifyingProperties() { + let comparison = QueryPlanComparison( + previous: plan(root: node("Index Scan", relation: "users", properties: [ + "Index Name": "users_email_idx", + ])), + current: plan(root: node("Index Scan", relation: "users", properties: [ + "Index Name": "users_pkey", + ])) + ) + + #expect(comparison.nodeChanges.count == 2) + #expect(Set(comparison.nodeChanges.map(\.kind)) == [.added, .removed]) + } + + @Test("Duplicate siblings pair by occurrence order") + func pairsDuplicateSiblingsByOccurrence() { + let older = plan(root: node("Append", children: [ + node("Seq Scan", relation: "events", totalCost: 1), + node("Seq Scan", relation: "events", totalCost: 2), + ])) + let current = plan(root: node("Append", children: [ + node("Seq Scan", relation: "events", totalCost: 10), + node("Seq Scan", relation: "events", totalCost: 20), + ])) + let changes = QueryPlanComparison(previous: older, current: current).nodeChanges + + #expect(changes.count == 2) + #expect(changes.allSatisfy { $0.kind == .modified }) + #expect(changes.map(\.semanticPathID).contains { $0.hasSuffix("#1") }) + #expect(changes.map(\.semanticPathID).contains { $0.hasSuffix("#2") }) + #expect(changes[0].valueChanges.first?.previousValue == "1.0") + #expect(changes[0].valueChanges.first?.currentValue == "10.0") + #expect(changes[1].valueChanges.first?.previousValue == "2.0") + #expect(changes[1].valueChanges.first?.currentValue == "20.0") + } + + @Test("Reordering nested-loop inputs reports a deterministic move") + func reportsNestedLoopInputReorder() { + let users = node("Table scan", relation: "users", totalCost: 1) + let orders = node("Index lookup", relation: "orders", totalCost: 2, properties: [ + "Index Name": "user_id_idx", + ]) + let older = plan(root: node("Nested loop inner join", children: [users, orders])) + let current = plan(root: node("Nested loop inner join", children: [orders, users])) + let first = QueryPlanComparison(previous: older, current: current) + let second = QueryPlanComparison(previous: older, current: current) + + #expect(first.hasChanges) + #expect(first.nodeChanges.count == 2) + #expect(first.nodeChanges.map(\.kind) == [.removed, .added]) + #expect(first.nodeChanges.allSatisfy { $0.relation == "users" }) + #expect(first.nodeChanges == second.nodeChanges) + #expect(Set(first.nodeChanges.map(\.id)).count == first.nodeChanges.count) + } + + @Test("Duplicate siblings retain occurrence paths when one is removed") + func keepsDuplicateOccurrencePaths() throws { + let older = plan(root: node("Append", children: [ + node("Seq Scan", relation: "events"), + node("Seq Scan", relation: "events"), + ])) + let current = plan(root: node("Append", children: [ + node("Seq Scan", relation: "events"), + ])) + let change = try #require( + QueryPlanComparison(previous: older, current: current).nodeChanges.first + ) + + #expect(change.kind == .removed) + #expect(change.semanticPathID.hasSuffix("#2")) + } + + @Test("Wide sibling plans use the bounded ordered fallback") + func boundsWidePlanMatching() { + let width = Int(Double(QueryPlanComparison.maximumLCSCellCount).squareRoot()) + 1 + #expect(width * width > QueryPlanComparison.maximumLCSCellCount) + #expect(QueryPlanComparison.usesLinearSiblingMatcher( + previousCount: width, + currentCount: width + )) + + let olderChildren = (0..