diff --git a/.gitignore b/.gitignore index 0924aacae..437827925 100644 --- a/.gitignore +++ b/.gitignore @@ -182,5 +182,7 @@ Native/DamengBridge/lib .docs/ /plans/reports -# Working copies produced by scripts/localization.py; the catalog is the source of truth -Localization/ +# Working copies produced by scripts/localization.py; the catalog is the source of truth. +# Anchored: unanchored, it also matched TableProTests/Localization/, so the string-catalog +# guard suite existed on disk and was invisible to git and to CI. +/Localization/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4be492027..97093b216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Procedures, functions and triggers in the quick switcher. - Argument signatures on routine rows, shown when two routines in a section share a name. - Schema-wide `list_triggers` for MCP clients, and `return_type` and `language` on `list_routines`. +- Compare, a fourth EXPLAIN plan mode that reports what changed against an earlier run of the same query. (#2380) +- Pinning a saved EXPLAIN plan, to keep it through history cleanup. (#2380) ### Changed diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 156cfc180..fc38c6ee3 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,30 @@ extension QueryExecutionCoordinator { executionTime: TimeInterval, rowCount: Int, sql: String, + historySQL: String, connection conn: DatabaseConnection, queryParameterValues: [QueryParameter]?, anchor: StatementAnchor? = nil ) { + let databaseName = historyDatabaseName(tabId: tabId) + let schemaName = historySchemaName(tabId: tabId) + let historyId = UUID() + let captured = QueryPlanCaptureBuilder.make( + subjectSQL: routed.subjectSQL, + rawPlan: routed.rawText, + format: routed.format, + variantKey: routed.variantKey, + scope: QueryPlanScope( + connectionId: conn.id, + databaseType: conn.type, + databaseName: databaseName, + schemaName: schemaName + ), + executionTime: executionTime, + capturedAt: Date(), + historyId: historyId, + queryParameters: queryParameterValues + ) parent.flushBufferToActiveResult(tabId: tabId, pinnedOnly: true) parent.tabManager.mutate(tabId: tabId) { tab in tab.execution.executionTime = executionTime @@ -307,7 +328,8 @@ extension QueryExecutionCoordinator { plan: routed.plan, sql: sql, executionTime: executionTime, - anchor: anchor + anchor: anchor, + planContext: captured.context )] ) if tab.display.isResultsCollapsed { @@ -319,15 +341,17 @@ extension QueryExecutionCoordinator { recordHistory( QueryHistoryRecordRequest( - query: sql, + id: historyId, + query: historySQL, connectionId: conn.id, - databaseName: historyDatabaseName(tabId: tabId), + databaseName: databaseName, databaseType: conn.type, - schemaName: historySchemaName(tabId: tabId), + schemaName: schemaName, source: .explain, executionTime: executionTime, rowCount: rowCount, - wasSuccessful: true + wasSuccessful: true, + planCapture: captured.capture ) ) } diff --git a/TablePro/Core/Services/Query/ExplainResultRouter.swift b/TablePro/Core/Services/Query/ExplainResultRouter.swift index cd1ea2c56..45d07cd70 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 variantKey: QueryPlanVariantKey + let subjectSQL: String } /// A plan either arrives in one column, or is multi-column output the app can actually read @@ -30,12 +33,62 @@ enum ExplainResultRouter { let text = ExplainPlanTextFlattener.flatten(rows: rows) guard !text.isEmpty else { return nil } + let explainSQL = QueryClassifier.strippingLeadingComments(sql) + let variant = ExplainFormatResolver.matchingVariant( + sql: explainSQL, declaredVariants: declaredVariants + ) let format = ExplainFormatResolver.resolve( - sql: sql, databaseType: databaseType, declaredVariants: declaredVariants + declared: variant?.format ?? .plainText, databaseType: databaseType ) let plan = ExplainPlanParserRegistry.plan(from: text, format: format) guard columns.count == 1 || plan != nil else { return nil } - return RoutedPlan(rawText: text, plan: plan) + + let subjectSQL = QueryClassifier.explainedStatement(in: explainSQL) ?? sql + return RoutedPlan( + rawText: text, + plan: plan, + format: format, + variantKey: variantKey( + explainSQL: explainSQL, + subjectSQL: subjectSQL, + declaredVariants: declaredVariants, + matched: variant + ), + subjectSQL: subjectSQL + ) + } + + /// Which chain of saved plans this run belongs to. + /// + /// A typed `EXPLAIN (ANALYZE, BUFFERS)` and a plain `EXPLAIN` describe the same statement but + /// report different things, so they are separate chains. The options the user typed are the + /// only thing that distinguishes them, and they are keyed by their normalized spelling rather + /// than by a digest of it, so a stored key stays readable in the picker and in a database + /// browser. + /// + /// A typed statement that happens to spell a variant the driver declares is folded onto that + /// variant's key, so running EXPLAIN from the toolbar and typing the same thing by hand share + /// one history. + private static func variantKey( + explainSQL: String, + subjectSQL: String, + declaredVariants: [ExplainVariant], + matched: ExplainVariant? + ) -> QueryPlanVariantKey { + guard let subjectRange = explainSQL.range(of: subjectSQL, options: [.literal, .backwards]) else { + return matched.map { .declared($0.id) } ?? .driverBuilt + } + + let preamble = SQLPreambleNormalizer.normalize(String(explainSQL[.. Bool { let entry = QueryHistoryEntry( + id: request.id, query: request.query, connectionId: request.connectionId, databaseName: request.databaseName, @@ -31,7 +32,15 @@ final class QueryHistoryManager: QueryHistoryRecording, QueryHistoryReading, Sen wasSuccessful: request.wasSuccessful, errorMessage: request.errorMessage ) - return await record(entry) + let stored = await record(entry) + + /// Written after the history row rather than with it: `plan_snapshots.history_id` is a + /// foreign key, so the row it points at has to exist first, and a plan that fails to store + /// must not take the history entry down with it. + if stored, let capture = request.planCapture { + await storage.recordPlanSnapshot(capture) + } + return stored } /// The single writer, so pausing here covers every source: the editor, the grid, structure @@ -63,6 +72,39 @@ final class QueryHistoryManager: QueryHistoryRecording, QueryHistoryReading, Sen await storage.count(scope: scope) } + // MARK: - Plan snapshots + + func planSnapshots( + matching identity: QueryPlanIdentity, + excluding excludedId: UUID?, + limit: Int + ) async -> [QueryPlanSnapshotSummary] { + await storage.planSnapshots(matching: identity, excluding: excludedId, limit: limit) + } + + func planSnapshotRawText(id: UUID) async -> String? { + await storage.planSnapshotRawText(id: id) + } + + func planSnapshotUsage() async -> QueryPlanStorageUsage { + await storage.planSnapshotUsage() + } + + @discardableResult + func setPlanSnapshotPinned(id: UUID, isPinned: Bool) async -> Bool { + await storage.setPlanSnapshotPinned(id: id, isPinned: isPinned) + } + + @discardableResult + func deletePlanSnapshot(id: UUID) async -> Bool { + await storage.deletePlanSnapshot(id: id) + } + + @discardableResult + func clearPlanSnapshots() async -> Bool { + await storage.clearPlanSnapshots() + } + func insights( _ request: QueryInsightsRequest, slowestRanking: QueryInsightsSlowestRanking diff --git a/TablePro/Core/Storage/QueryHistoryRecording.swift b/TablePro/Core/Storage/QueryHistoryRecording.swift index 4e7d8eb50..20857f658 100644 --- a/TablePro/Core/Storage/QueryHistoryRecording.swift +++ b/TablePro/Core/Storage/QueryHistoryRecording.swift @@ -21,6 +21,23 @@ protocol QueryHistoryReading: Sendable { ) async -> QueryInsightsSnapshot } +/// Saved EXPLAIN plans. Separate from `QueryHistoryReading` because a plan is a different artifact +/// with a different lifetime, and because the comparison pane needs only these four calls. +protocol QueryPlanSnapshotReading: Sendable { + func planSnapshots( + matching identity: QueryPlanIdentity, + excluding excludedId: UUID?, + limit: Int + ) async -> [QueryPlanSnapshotSummary] + + func planSnapshotRawText(id: UUID) async -> String? + + @discardableResult + func setPlanSnapshotPinned(id: UUID, isPinned: Bool) async -> Bool + + func isStoreAvailable() async -> Bool +} + extension QueryHistoryReading { func isStoreAvailable() async -> Bool { true } diff --git a/TablePro/Core/Storage/QueryHistoryStorage+PlanSnapshots.swift b/TablePro/Core/Storage/QueryHistoryStorage+PlanSnapshots.swift new file mode 100644 index 000000000..17f778ee4 --- /dev/null +++ b/TablePro/Core/Storage/QueryHistoryStorage+PlanSnapshots.swift @@ -0,0 +1,338 @@ +// +// QueryHistoryStorage+PlanSnapshots.swift +// TablePro +// +// Saved EXPLAIN plans. +// +// They live in the query-history database because that is where a statement's runs already live, +// but they are not owned by a history row. A plan is an artifact the user keeps deliberately, and +// a foreign key that cascaded would delete a pinned baseline the moment ordinary history retention +// aged out the run that produced it. `history_id` is provenance, nullable, and `ON DELETE SET +// NULL`. +// +// Writing a plan is a separate statement from writing the history row rather than one transaction +// covering both. Sharing a transaction means a failure on the large write, which is the one that +// can hit SQLITE_FULL, takes the small write down with it: SQLite auto-rolls back on a full disk, +// the explicit ROLLBACK then fails with "cannot rollback - no transaction is active", and the +// history entry is lost along with the plan. +// + +import Foundation +import SQLite3 +import TableProPluginKit + +extension QueryHistoryStorage { + /// A baseline list longer than this is a scrollbar, not a choice. + static let maximumPlanSnapshotListLength = 100 + + func createPlanSnapshotStorage() { + execute(""" + CREATE TABLE IF NOT EXISTS plan_snapshots ( + id TEXT PRIMARY KEY NOT NULL, + history_id TEXT REFERENCES history(id) ON DELETE SET NULL, + fingerprint_hash INTEGER NOT NULL, + subject_sql TEXT NOT NULL, + connection_id TEXT NOT NULL, + database_name TEXT NOT NULL, + database_type TEXT NOT NULL, + schema_name TEXT, + variant_key TEXT NOT NULL, + format TEXT NOT NULL, + raw_plan TEXT NOT NULL, + byte_count INTEGER NOT NULL, + execution_time REAL NOT NULL, + captured_at REAL NOT NULL, + is_pinned INTEGER NOT NULL DEFAULT 0 + ); + """) + execute(""" + CREATE INDEX IF NOT EXISTS idx_plan_snapshots_identity + ON plan_snapshots(fingerprint_hash, connection_id, database_name, variant_key, captured_at DESC); + """) + execute(""" + CREATE INDEX IF NOT EXISTS idx_plan_snapshots_retention + ON plan_snapshots(is_pinned, captured_at DESC); + """) + } + + // MARK: - Writing + + @discardableResult + func recordPlanSnapshot(_ capture: QueryPlanCapture) -> Bool { + guard let db, capture.isWithinPlanSizeLimit else { return false } + + let sql = """ + INSERT INTO plan_snapshots ( + id, history_id, fingerprint_hash, subject_sql, connection_id, database_name, + database_type, schema_name, variant_key, format, raw_plan, byte_count, + execution_time, captured_at, is_pinned + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0); + """ + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + logSqliteError(context: "prepare plan snapshot insert") + return false + } + + let identity = capture.identity + let bindings: [QueryHistorySqlBinding?] = [ + .text(capture.id.uuidString), + capture.historyId.map { .text($0.uuidString) }, + .int64(identity.fingerprintHash), + .text(capture.subjectSQL), + .text(identity.scope.connectionId.uuidString), + .text(identity.scope.databaseName), + .text(identity.scope.databaseType.rawValue), + identity.scope.schemaName.map { .text($0) }, + .text(identity.variantKey.rawValue), + .text(identity.format.rawValue), + .text(capture.rawPlan), + .int64(Int64(capture.byteCount)), + .double(capture.executionTime), + .double(capture.capturedAt.timeIntervalSince1970), + ] + bind(bindings, to: statement) + + guard sqlite3_step(statement) == SQLITE_DONE else { + logSqliteError(context: "plan snapshot insert") + return false + } + return true + } + + @discardableResult + func setPlanSnapshotPinned(id: UUID, isPinned: Bool) -> Bool { + guard let db else { return false } + + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + guard sqlite3_prepare_v2( + db, + "UPDATE plan_snapshots SET is_pinned = ? WHERE id = ?;", + -1, + &statement, + nil + ) == SQLITE_OK else { + logSqliteError(context: "prepare plan snapshot pin") + return false + } + QueryHistorySqlBinding.int(isPinned ? 1 : 0).bind(to: statement, at: 1) + QueryHistorySqlBinding.text(id.uuidString).bind(to: statement, at: 2) + guard sqlite3_step(statement) == SQLITE_DONE else { + logSqliteError(context: "plan snapshot pin") + return false + } + return sqlite3_changes(db) > 0 + } + + @discardableResult + func deletePlanSnapshot(id: UUID) -> Bool { + guard let db else { return false } + + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + guard sqlite3_prepare_v2(db, "DELETE FROM plan_snapshots WHERE id = ?;", -1, &statement, nil) == SQLITE_OK else { + logSqliteError(context: "prepare plan snapshot delete") + return false + } + QueryHistorySqlBinding.text(id.uuidString).bind(to: statement, at: 1) + guard sqlite3_step(statement) == SQLITE_DONE else { + logSqliteError(context: "plan snapshot delete") + return false + } + return sqlite3_changes(db) > 0 + } + + @discardableResult + func clearPlanSnapshots() -> Bool { + guard let db else { return false } + execute("DELETE FROM plan_snapshots;") + return sqlite3_changes(db) > 0 + } + + // MARK: - Reading + + /// Earlier runs of the same statement shape, newest first, excluding the run that is asking. + func planSnapshots( + matching identity: QueryPlanIdentity, + excluding excludedId: UUID?, + limit: Int + ) -> [QueryPlanSnapshotSummary] { + let boundedLimit = min(max(limit, 0), Self.maximumPlanSnapshotListLength) + guard db != nil, boundedLimit > 0 else { return [] } + + var clause = QueryHistorySqlClause() + clause.append(""" + SELECT id, subject_sql, execution_time, captured_at, is_pinned, byte_count + FROM plan_snapshots + WHERE fingerprint_hash = ? + AND connection_id = ? + AND database_name = ? + AND database_type = ? + AND variant_key = ? + AND format = ? + """, + .int64(identity.fingerprintHash), + .text(identity.scope.connectionId.uuidString), + .text(identity.scope.databaseName), + .text(identity.scope.databaseType.rawValue), + .text(identity.variantKey.rawValue), + .text(identity.format.rawValue) + ) + appendNullSafeMatch(column: "schema_name", value: identity.scope.schemaName, to: &clause) + if let excludedId { + clause.append(" AND id <> ?", .text(excludedId.uuidString)) + } + clause.append( + " ORDER BY captured_at DESC, 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 plan snapshot list") + return [] + } + for (offset, binding) in clause.bindings.enumerated() { + binding.bind(to: statement, at: Int32(offset + 1)) + } + + var summaries: [QueryPlanSnapshotSummary] = [] + while sqlite3_step(statement) == SQLITE_ROW { + guard let idRaw = sqlite3_column_text(statement, 0).map({ String(cString: $0) }), + let id = UUID(uuidString: idRaw) + else { continue } + summaries.append(QueryPlanSnapshotSummary( + id: id, + subjectSQL: sqlite3_column_text(statement, 1).map { String(cString: $0) } ?? "", + executionTime: sqlite3_column_double(statement, 2), + capturedAt: Date(timeIntervalSince1970: sqlite3_column_double(statement, 3)), + isPinned: sqlite3_column_int(statement, 4) != 0, + byteCount: Int(sqlite3_column_int64(statement, 5)) + )) + } + return summaries + } + + /// The plan text, loaded only when a baseline is actually selected. + func planSnapshotRawText(id: UUID) -> String? { + guard let db else { return nil } + + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + guard sqlite3_prepare_v2( + db, + "SELECT raw_plan FROM plan_snapshots WHERE id = ? LIMIT 1;", + -1, + &statement, + nil + ) == SQLITE_OK else { + logSqliteError(context: "prepare plan snapshot text") + return nil + } + QueryHistorySqlBinding.text(id.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 planSnapshotUsage() -> QueryPlanStorageUsage { + guard let db else { return QueryPlanStorageUsage(byteCount: 0, snapshotCount: 0, pinnedCount: 0) } + + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + guard sqlite3_prepare_v2( + db, + """ + SELECT COALESCE(SUM(byte_count), 0), COUNT(*), COALESCE(SUM(is_pinned), 0) + FROM plan_snapshots; + """, + -1, + &statement, + nil + ) == SQLITE_OK, sqlite3_step(statement) == SQLITE_ROW else { + logSqliteError(context: "plan snapshot usage") + return QueryPlanStorageUsage(byteCount: 0, snapshotCount: 0, pinnedCount: 0) + } + return QueryPlanStorageUsage( + byteCount: sqlite3_column_int64(statement, 0), + snapshotCount: Int(sqlite3_column_int64(statement, 1)), + pinnedCount: Int(sqlite3_column_int64(statement, 2)) + ) + } + + // MARK: - Retention + + /// Runs on query history's own cleanup cadence, never inside the per-EXPLAIN write, so a plan + /// insert never pays for a full scan of the table and never holds the write lock while it does. + /// + /// Pinned plans are exempt. A user who pinned a baseline said the one thing retention exists to + /// guess at. + @discardableResult + func prunePlanSnapshots(toByteLimit byteLimit: Int64) -> Bool { + guard let db else { return false } + let usage = planSnapshotUsage() + guard usage.byteCount > byteLimit else { return false } + + let sql = """ + DELETE FROM plan_snapshots + WHERE is_pinned = 0 + AND id IN ( + SELECT id FROM ( + SELECT id, + SUM(byte_count) OVER ( + ORDER BY captured_at DESC, id DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) AS retained + FROM plan_snapshots + WHERE is_pinned = 0 + ) + WHERE retained > ? + ); + """ + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + logSqliteError(context: "prepare plan snapshot prune") + return false + } + sqlite3_bind_int64(statement, 1, byteLimit) + guard sqlite3_step(statement) == SQLITE_DONE else { + logSqliteError(context: "plan snapshot prune") + return false + } + return sqlite3_changes(db) > 0 + } + + // MARK: - Helpers + + private func bind(_ bindings: [QueryHistorySqlBinding?], to statement: OpaquePointer?) { + for (offset, binding) in bindings.enumerated() { + let index = Int32(offset + 1) + guard let binding else { + sqlite3_bind_null(statement, index) + continue + } + binding.bind(to: statement, at: index) + } + } + + 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") + } + } +} + +struct QueryPlanStorageUsage: Hashable, Sendable { + let byteCount: Int64 + let snapshotCount: Int + let pinnedCount: Int +} diff --git a/TablePro/Core/Storage/QueryHistoryStorage.swift b/TablePro/Core/Storage/QueryHistoryStorage.swift index 3fce4015c..dbeef2cac 100644 --- a/TablePro/Core/Storage/QueryHistoryStorage.swift +++ b/TablePro/Core/Storage/QueryHistoryStorage.swift @@ -13,7 +13,10 @@ actor QueryHistoryStorage { private var dbHandle = DatabaseHandle() private var isPrepared = false - private var db: OpaquePointer? { + /// Internal rather than private so `QueryHistoryStorage+PlanSnapshots` can reach it. Splitting a + /// type across `+Category` files is what `CLAUDE.md` asks for as a file approaches its length + /// limit, and it is what forces this handful of members past `private`. + var db: OpaquePointer? { if !isPrepared { isPrepared = true setupDatabase() @@ -77,11 +80,16 @@ actor QueryHistoryStorage { execute("PRAGMA journal_mode=WAL;") execute("PRAGMA synchronous=NORMAL;") + /// Off by default in SQLite, and `plan_snapshots.history_id` needs it: without it the + /// `ON DELETE SET NULL` never fires and a pruned history row leaves a plan pointing at a + /// row that no longer exists. + execute("PRAGMA foreign_keys=ON;") sqlite3_busy_timeout(db, 3_000) createTables() migrateIfNeeded() createFingerprintIndex() + createPlanSnapshotStorage() protectDatabaseFiles(at: dbPath) } @@ -395,7 +403,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,7 +418,7 @@ 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)") } @@ -1109,6 +1117,13 @@ actor QueryHistoryStorage { } commitTransaction() + + /// After the commit, so the `ON DELETE SET NULL` on `plan_snapshots.history_id` has already + /// run and the byte budget is measured against what survives. Its own statement rather than + /// part of the transaction above: a full scan of the plan table has no business holding the + /// write lock that every history insert needs. + prunePlanSnapshots(toByteLimit: QueryPlanStorageLimits.maximumTotalByteCount) + return sqlite3_total_changes(db) != changesBefore } 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.. String { + var components: [String] = [] + var token = "" + + func flush() { + 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) + continue + } + flush() + if !character.isWhitespace { + components.append(String(character)) + } + } + flush() + return components.joined(separator: " ") + } +} diff --git a/TablePro/Models/Query/ExplainRequest.swift b/TablePro/Models/Query/ExplainRequest.swift index 9c094d8e2..82ed14365 100644 --- a/TablePro/Models/Query/ExplainRequest.swift +++ b/TablePro/Models/Query/ExplainRequest.swift @@ -10,8 +10,14 @@ import TableProPluginKit struct ExplainRequest: Equatable { let sql: String + let subjectSQL: String let format: ExplainPlanFormat + /// Which chain of saved plans a run of this request belongs to. Shared with the hand-typed + /// path, so the Explain action and the same statement typed into the editor build one history + /// rather than two. + let variantKey: QueryPlanVariantKey + /// 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 /// so they keep their grid rather than being forced into a plan pane. @@ -29,15 +35,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), + variantKey: .declared(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), + variantKey: .driverBuilt, isDriverBuilt: true ) } diff --git a/TablePro/Models/Query/ExplainResultSetFactory.swift b/TablePro/Models/Query/ExplainResultSetFactory.swift index 2d33747d6..b3785c6c1 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, + planContext: QueryPlanContext? = 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.explainPlanContext = planContext return resultSet } } diff --git a/TablePro/Models/Query/QueryHistoryRecordRequest.swift b/TablePro/Models/Query/QueryHistoryRecordRequest.swift index 4efa3e22a..99bca8310 100644 --- a/TablePro/Models/Query/QueryHistoryRecordRequest.swift +++ b/TablePro/Models/Query/QueryHistoryRecordRequest.swift @@ -1,6 +1,9 @@ import Foundation struct QueryHistoryRecordRequest: Sendable { + /// Chosen by the caller so a run that also saves a plan can link the two before either is + /// written. Defaulted, so every other caller ignores it. + var id = UUID() let query: String let connectionId: UUID let databaseName: String @@ -12,7 +15,13 @@ struct QueryHistoryRecordRequest: Sendable { let wasSuccessful: Bool var errorMessage: String? + /// The EXPLAIN plan this run produced, when it is one worth keeping. Written after the history + /// row and never inside its transaction, so a plan that cannot be stored never costs the run + /// its place in history. + var planCapture: QueryPlanCapture? + init( + id: UUID = UUID(), query: String, connectionId: UUID, databaseName: String, @@ -22,8 +31,10 @@ struct QueryHistoryRecordRequest: Sendable { executionTime: TimeInterval, rowCount: Int, wasSuccessful: Bool, - errorMessage: String? = nil + errorMessage: String? = nil, + planCapture: QueryPlanCapture? = nil ) { + self.id = id self.query = query self.connectionId = connectionId self.databaseName = databaseName @@ -34,5 +45,6 @@ struct QueryHistoryRecordRequest: Sendable { self.rowCount = rowCount self.wasSuccessful = wasSuccessful self.errorMessage = errorMessage + self.planCapture = planCapture } } 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/QueryPlanContext.swift b/TablePro/Models/Query/QueryPlanContext.swift new file mode 100644 index 000000000..a4e8098d1 --- /dev/null +++ b/TablePro/Models/Query/QueryPlanContext.swift @@ -0,0 +1,105 @@ +// +// QueryPlanContext.swift +// TablePro +// +// What a rendered EXPLAIN result knows about its own place in the plan history, and the single +// builder both EXPLAIN paths go through. +// +// There are two ways a plan reaches the app: the Explain action, which knows the variant it asked +// for, and a statement the user typed, which is recognised as a plan only after it comes back. +// They used to build this independently and had already drifted on where the database name came +// from, which is enough to split one statement's history into two chains that never compare. +// + +import Foundation +import TableProPluginKit + +/// Why this run's plan was not saved. The comparison pane says so rather than showing an empty list +/// that reads like a missing feature. +enum QueryPlanCaptureSkipReason: Hashable, Sendable { + /// The statement carried bind values, and a database is free to echo them into its plan output. + case parameterized + /// Larger than `QueryPlanStorageLimits.maximumPlanByteCount`. + case tooLarge + + var explanation: String { + switch self { + case .parameterized: + return String(localized: "Plans are not saved for queries that carry parameters, because a database can print the values into its plan output.") + case .tooLarge: + return String(localized: "This plan is too large to save.") + } + } +} + +/// Travels on the `ResultSet` so the plan pane can offer a comparison without asking a coordinator +/// anything. +struct QueryPlanContext: Hashable, Sendable, Identifiable { + let id: UUID + let identity: QueryPlanIdentity + let subjectSQL: String + let capturedAt: Date + let executionTime: TimeInterval + + /// Nil when this run was not stored, in which case `skipReason` says why. + let storedSnapshotId: UUID? + let skipReason: QueryPlanCaptureSkipReason? + + var isStored: Bool { storedSnapshotId != nil } +} + +enum QueryPlanCaptureBuilder { + /// Builds the context every EXPLAIN result carries, and the capture the store is offered when + /// the run is one we may keep. + /// + /// `capturedAt` is passed in rather than read here so the context, the capture and the history + /// row that links them all carry the same instant. + static func make( + subjectSQL: String, + rawPlan: String, + format: ExplainPlanFormat, + variantKey: QueryPlanVariantKey, + scope: QueryPlanScope, + executionTime: TimeInterval, + capturedAt: Date, + historyId: UUID, + queryParameters: [QueryParameter]? + ) -> (context: QueryPlanContext, capture: QueryPlanCapture?) { + let identity = QueryPlanIdentity( + fingerprintHash: SQLQueryFingerprint.hash(subjectSQL, databaseType: scope.databaseType), + scope: scope, + variantKey: variantKey, + format: format + ) + let snapshotId = UUID() + let capture = QueryPlanCapture( + id: snapshotId, + identity: identity, + subjectSQL: subjectSQL, + rawPlan: rawPlan, + executionTime: executionTime, + capturedAt: capturedAt, + historyId: historyId + ) + + let skipReason: QueryPlanCaptureSkipReason? + if queryParameters?.isEmpty == false { + skipReason = .parameterized + } else if !capture.isWithinPlanSizeLimit { + skipReason = .tooLarge + } else { + skipReason = nil + } + + let context = QueryPlanContext( + id: snapshotId, + identity: identity, + subjectSQL: subjectSQL, + capturedAt: capturedAt, + executionTime: executionTime, + storedSnapshotId: skipReason == nil ? snapshotId : nil, + skipReason: skipReason + ) + return (context, skipReason == nil ? capture : nil) + } +} diff --git a/TablePro/Models/Query/QueryPlanDiff.swift b/TablePro/Models/Query/QueryPlanDiff.swift new file mode 100644 index 000000000..641de97ad --- /dev/null +++ b/TablePro/Models/Query/QueryPlanDiff.swift @@ -0,0 +1,374 @@ +// +// QueryPlanDiff.swift +// TablePro +// +// What changed between an earlier plan and the current one. +// +// Siblings are matched with the standard library's `CollectionDifference`, which is Myers' diff +// over the nodes' semantic keys. A plan node has no stable identity across two runs, so identity +// is what the node says it is: the operation, the relation it reads, its alias, and the properties +// that decide what kind of node it is (the join type, the index it uses, the CTE it belongs to). +// +// Everything here is a pure function over two parsed plans, so it can be measured in a test and +// run off the main actor without dragging a view along. +// + +import Foundation + +struct QueryPlanNodeChange: Identifiable, Hashable, Sendable { + enum Kind: String, Hashable, Sendable { + case added + case removed + case changed + } + + let kind: Kind + let path: String + let operation: String + let relation: String? + let schema: String? + let alias: String? + let fieldChanges: [QueryPlanFieldChange] + + var id: String { "\(kind.rawValue):\(path)" } + + var title: String { + guard let relation, !relation.isEmpty else { return operation } + return "\(operation) · \(relation)" + } +} + +/// The one-line answer the pane leads with. A list of forty field changes does not tell a reader +/// whether the query got better, and that is the only question they opened the comparison to ask. +enum QueryPlanVerdict: Hashable, Sendable { + /// Both plans reported an execution time and the difference is outside the noise band. + case slower(ratio: Double) + case faster(ratio: Double) + /// Nodes were added or removed, with no measured time to judge the effect by. + case shapeChanged + /// Same nodes, but some of their values moved. + case valuesChanged + case unchanged +} + +struct QueryPlanDiff: Hashable, Sendable { + /// Two runs of an unchanged plan differ by a few percent on timing alone. Below this, the + /// comparison says the run is unchanged rather than manufacturing a regression out of jitter. + static let noiseRatio = 1.15 + + let verdict: QueryPlanVerdict + let summary: [QueryPlanFieldChange] + let nodeChanges: [QueryPlanNodeChange] + + var hasChanges: Bool { + verdict != .unchanged + } + + static func compare(baseline: QueryPlan, current: QueryPlan) -> QueryPlanDiff { + let summary = summaryChanges(baseline: baseline, current: current) + var nodeChanges: [QueryPlanNodeChange] = [] + compareRoots(baseline.rootNode, current.rootNode, into: &nodeChanges) + return QueryPlanDiff( + verdict: verdict(summary: summary, nodeChanges: nodeChanges), + summary: summary, + nodeChanges: nodeChanges + ) + } +} + +// MARK: - Verdict + +private extension QueryPlanDiff { + static func verdict( + summary: [QueryPlanFieldChange], + nodeChanges: [QueryPlanNodeChange] + ) -> QueryPlanVerdict { + if let executionRatio = summary.first(where: { $0.field == .summary(.executionTime) })?.ratio { + if executionRatio >= noiseRatio { return .slower(ratio: executionRatio) } + if executionRatio <= 1 / noiseRatio { return .faster(ratio: 1 / executionRatio) } + } + if nodeChanges.contains(where: { $0.kind != .changed }) { return .shapeChanged } + if !nodeChanges.isEmpty || summary.contains(where: isMeaningful) { return .valuesChanged } + return .unchanged + } + + /// Two runs of one unchanged plan differ by a few percent on timing alone. Counting that as a + /// change makes every rerun read as a difference, which is exactly the noise that makes a + /// comparison feature untrustworthy. A timing metric therefore only counts once it leaves the + /// noise band; everything else counts as soon as it moves. + static func isMeaningful(_ change: QueryPlanFieldChange) -> Bool { + guard change.hasChange else { return false } + guard change.field.unit == .milliseconds else { return true } + guard let ratio = change.ratio else { return true } + return ratio >= noiseRatio || ratio <= 1 / noiseRatio + } + + static func summaryChanges(baseline: QueryPlan, current: QueryPlan) -> [QueryPlanFieldChange] { + let values: [(QueryPlanSummaryMetric, Double?, Double?)] = [ + (.totalCost, baseline.rootNode.estimatedTotalCost, current.rootNode.estimatedTotalCost), + (.estimatedRows, baseline.rootNode.estimatedRows.map(Double.init), current.rootNode.estimatedRows.map(Double.init)), + (.planningTime, baseline.planningTime, current.planningTime), + (.executionTime, baseline.executionTime, current.executionTime), + (.nodeCount, Double(nodeCount(in: baseline.rootNode)), Double(nodeCount(in: current.rootNode))), + ] + return values.map { metric, before, after in + QueryPlanFieldChange( + field: .summary(metric), + before: before.map(QueryPlanFieldValue.number), + after: after.map(QueryPlanFieldValue.number) + ) + } + } + + static func nodeCount(in node: QueryPlanNode) -> Int { + node.children.reduce(1) { $0 + nodeCount(in: $1) } + } +} + +// MARK: - Node identity + +private extension QueryPlanDiff { + /// The properties that say what kind of node this is rather than how it performed. A node whose + /// join type or index changed is a different node, not the same node with a different number. + static let identifyingPropertyKeys: Set = [ + "CTE Name", + "Index Name", + "Join Type", + "Parent Relationship", + "Strategy", + "Subplan Name", + ] + + struct NodeKey: Hashable { + let operation: String + let schema: String? + let relation: String? + let alias: String? + let identifiers: [String] + } + + struct KeyedNode { + let node: QueryPlanNode + let key: NodeKey + let occurrence: Int + } + + static func key(for node: QueryPlanNode) -> NodeKey { + NodeKey( + operation: node.operation, + schema: node.schema, + relation: node.relation, + alias: node.alias, + identifiers: node.properties + .filter { identifyingPropertyKeys.contains($0.key) } + .sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + ) + } + + static func keyed(_ nodes: [QueryPlanNode]) -> [KeyedNode] { + var occurrences: [NodeKey: Int] = [:] + return nodes.map { node in + let key = key(for: node) + let occurrence = occurrences[key, default: 0] + 1 + occurrences[key] = occurrence + return KeyedNode(node: node, key: key, occurrence: occurrence) + } + } + + /// A path that is stable between two runs and unique inside one plan, so SwiftUI can key a row + /// by it and a test can assert on it. + static func pathComponent(_ keyed: KeyedNode) -> String { + var component = keyed.node.operation + if let relation = keyed.node.relation, !relation.isEmpty { + component += "[\(relation)]" + } + if let alias = keyed.node.alias, !alias.isEmpty, alias != keyed.node.relation { + component += "(\(alias))" + } + return component.replacingOccurrences(of: "/", with: "\u{2215}") + "#\(keyed.occurrence)" + } +} + +// MARK: - Tree walk + +private extension QueryPlanDiff { + static func compareRoots( + _ baseline: QueryPlanNode, + _ current: QueryPlanNode, + into changes: inout [QueryPlanNodeChange] + ) { + let baselineKeyed = keyed([baseline])[0] + let currentKeyed = keyed([current])[0] + guard baselineKeyed.key == currentKeyed.key else { + appendSubtree(baseline, kind: .removed, path: pathComponent(baselineKeyed), into: &changes) + appendSubtree(current, kind: .added, path: pathComponent(currentKeyed), into: &changes) + return + } + compareMatched(baseline, current, path: pathComponent(currentKeyed), into: &changes) + } + + static func compareMatched( + _ baseline: QueryPlanNode, + _ current: QueryPlanNode, + path: String, + into changes: inout [QueryPlanNodeChange] + ) { + let fieldChanges = changedFields(baseline: baseline, current: current) + if !fieldChanges.isEmpty { + changes.append(QueryPlanNodeChange( + kind: .changed, + path: path, + operation: current.operation, + relation: current.relation, + schema: current.schema, + alias: current.alias, + fieldChanges: fieldChanges + )) + } + + let baselineChildren = keyed(baseline.children) + let currentChildren = keyed(current.children) + let alignment = align(baselineChildren, currentChildren) + + for offset in alignment.removed { + let child = baselineChildren[offset] + appendSubtree( + child.node, + kind: .removed, + path: "\(path)/\(pathComponent(child))", + into: &changes + ) + } + for offset in alignment.inserted { + let child = currentChildren[offset] + appendSubtree( + child.node, + kind: .added, + path: "\(path)/\(pathComponent(child))", + into: &changes + ) + } + for match in alignment.matches { + let child = currentChildren[match.current] + compareMatched( + baselineChildren[match.baseline].node, + child.node, + path: "\(path)/\(pathComponent(child))", + into: &changes + ) + } + } + + struct Alignment { + struct Match { + let baseline: Int + let current: Int + } + + let matches: [Match] + let removed: [Int] + let inserted: [Int] + } + + /// `CollectionDifference` reports removals as offsets into the baseline and insertions as + /// offsets into the current list, and applying one then the other turns the baseline into the + /// current list. Everything it did not touch therefore pairs up in order, which is the match + /// set. + static func align(_ baseline: [KeyedNode], _ current: [KeyedNode]) -> Alignment { + let difference = current.map(\.key).difference(from: baseline.map(\.key)) + var removed: Set = [] + var inserted: Set = [] + for change in difference { + switch change { + case .remove(let offset, _, _): removed.insert(offset) + case .insert(let offset, _, _): inserted.insert(offset) + } + } + + var matches: [Alignment.Match] = [] + var baselineIndex = 0 + var currentIndex = 0 + while baselineIndex < baseline.count, currentIndex < current.count { + if removed.contains(baselineIndex) { + baselineIndex += 1 + continue + } + if inserted.contains(currentIndex) { + currentIndex += 1 + continue + } + matches.append(Alignment.Match(baseline: baselineIndex, current: currentIndex)) + baselineIndex += 1 + currentIndex += 1 + } + return Alignment(matches: matches, removed: removed.sorted(), inserted: inserted.sorted()) + } + + static func appendSubtree( + _ node: QueryPlanNode, + kind: QueryPlanNodeChange.Kind, + path: String, + into changes: inout [QueryPlanNodeChange] + ) { + changes.append(QueryPlanNodeChange( + kind: kind, + path: path, + operation: node.operation, + relation: node.relation, + schema: node.schema, + alias: node.alias, + fieldChanges: fields(of: node).sorted { $0.key.sortOrder < $1.key.sortOrder }.map { field, value in + QueryPlanFieldChange( + field: field, + before: kind == .removed ? value : nil, + after: kind == .added ? value : nil + ) + } + )) + + for child in keyed(node.children) { + appendSubtree( + child.node, + kind: kind, + path: "\(path)/\(pathComponent(child))", + into: &changes + ) + } + } +} + +// MARK: - Fields + +private extension QueryPlanDiff { + static func changedFields( + baseline: QueryPlanNode, + current: QueryPlanNode + ) -> [QueryPlanFieldChange] { + let before = fields(of: baseline) + let after = fields(of: current) + return Set(before.keys).union(after.keys) + .sorted { $0.sortOrder < $1.sortOrder } + .compactMap { field in + let change = QueryPlanFieldChange(field: field, before: before[field], after: after[field]) + return change.hasChange ? change : nil + } + } + + /// Reads `properties` directly rather than through `QueryPlanLabels.visibleProperties`, which + /// drops any value spelled `0` or `false`. Dropping those is right for a node inspector, where + /// they are noise, and wrong here: `Rows Removed by Filter` falling from 1000 to 0 is the + /// improvement the reader opened the comparison to find, and hiding the zero reported it as the + /// property being removed. + static func fields(of node: QueryPlanNode) -> [QueryPlanField: QueryPlanFieldValue] { + var fields: [QueryPlanField: QueryPlanFieldValue] = [:] + for metric in QueryPlanMetric.allCases { + guard let value = metric.value(of: node) else { continue } + fields[.metric(metric)] = .number(value) + } + for (key, value) in node.properties where !QueryPlanLabels.hiddenPropertyKeys.contains(key) { + fields[.property(key)] = .text(value) + } + return fields + } +} diff --git a/TablePro/Models/Query/QueryPlanField.swift b/TablePro/Models/Query/QueryPlanField.swift new file mode 100644 index 000000000..62a9ec0db --- /dev/null +++ b/TablePro/Models/Query/QueryPlanField.swift @@ -0,0 +1,170 @@ +// +// QueryPlanField.swift +// TablePro +// +// The named values a plan node reports, kept as numbers until the moment they are drawn. +// +// A metric that reaches the view as a string has already lost its locale: a cost of 52000000 +// renders as "52000000.0" beside a summary row that says "52,000,000" for the same number. +// + +import Foundation + +enum QueryPlanUnit: Hashable, Sendable { + /// A planner cost. Unitless, and only comparable between two plans from the same server. + case cost + /// A row or loop count. + case count + /// A duration the plan reported, already in milliseconds. + case milliseconds + /// A width in bytes. + case bytes +} + +enum QueryPlanMetric: String, CaseIterable, Hashable, Sendable { + case estimatedStartupCost + case estimatedTotalCost + case estimatedRows + case estimatedWidth + case actualStartupTime + case actualTotalTime + case actualRows + case actualLoops + + var title: String { + switch self { + case .estimatedStartupCost: return String(localized: "Estimated Startup Cost") + case .estimatedTotalCost: return String(localized: "Estimated Total Cost") + case .estimatedRows: return String(localized: "Estimated Rows") + case .estimatedWidth: return String(localized: "Estimated Width") + case .actualStartupTime: return String(localized: "Actual Startup Time") + case .actualTotalTime: return String(localized: "Actual Total Time") + case .actualRows: return String(localized: "Actual Rows") + case .actualLoops: return String(localized: "Actual Loops") + } + } + + var unit: QueryPlanUnit { + switch self { + case .estimatedStartupCost, .estimatedTotalCost: return .cost + case .estimatedRows, .actualRows, .actualLoops: return .count + case .estimatedWidth: return .bytes + case .actualStartupTime, .actualTotalTime: return .milliseconds + } + } + + func value(of node: QueryPlanNode) -> Double? { + switch self { + case .estimatedStartupCost: return node.estimatedStartupCost + case .estimatedTotalCost: return node.estimatedTotalCost + case .estimatedRows: return node.estimatedRows.map(Double.init) + case .estimatedWidth: return node.estimatedWidth.map(Double.init) + case .actualStartupTime: return node.actualStartupTime + case .actualTotalTime: return node.actualTotalTime + case .actualRows: return node.actualRows.map(Double.init) + case .actualLoops: return node.actualLoops.map(Double.init) + } + } +} + +/// Plan-wide numbers, which live on the plan rather than on any one node. +enum QueryPlanSummaryMetric: String, CaseIterable, Hashable, Sendable { + case totalCost + case estimatedRows + case planningTime + case executionTime + case nodeCount + + var title: String { + switch self { + case .totalCost: return String(localized: "Cost") + case .estimatedRows: return String(localized: "Estimated rows") + case .planningTime: return String(localized: "Planning time") + case .executionTime: return String(localized: "Execution time") + case .nodeCount: return String(localized: "Node count") + } + } + + var unit: QueryPlanUnit { + switch self { + case .totalCost: return .cost + case .estimatedRows, .nodeCount: return .count + case .planningTime, .executionTime: return .milliseconds + } + } +} + +enum QueryPlanField: Hashable, Sendable { + case metric(QueryPlanMetric) + case summary(QueryPlanSummaryMetric) + case property(String) + + var title: String { + switch self { + case .metric(let metric): return metric.title + case .summary(let metric): return metric.title + case .property(let key): return key + } + } + + var unit: QueryPlanUnit? { + switch self { + case .metric(let metric): return metric.unit + case .summary(let metric): return metric.unit + case .property: return nil + } + } + + var id: String { + switch self { + case .metric(let metric): return "metric.\(metric.rawValue)" + case .summary(let metric): return "summary.\(metric.rawValue)" + case .property(let key): return "property.\(key)" + } + } + + /// Metrics sort before properties, and metrics keep their declared order rather than an + /// alphabetical one, so a node reads startup cost then total cost the way the database prints + /// it. + var sortOrder: (Int, Int, String) { + switch self { + case .summary(let metric): + return (0, QueryPlanSummaryMetric.allCases.firstIndex(of: metric) ?? 0, metric.rawValue) + case .metric(let metric): + return (1, QueryPlanMetric.allCases.firstIndex(of: metric) ?? 0, metric.rawValue) + case .property(let key): + return (2, 0, key) + } + } +} + +enum QueryPlanFieldValue: Hashable, Sendable { + case number(Double) + case text(String) +} + +struct QueryPlanFieldChange: Identifiable, Hashable, Sendable { + let field: QueryPlanField + let before: QueryPlanFieldValue? + let after: QueryPlanFieldValue? + + var id: String { field.id } + + var hasChange: Bool { before != after } + + /// The signed difference, when both sides are numbers. A property that changed from one word to + /// another has no delta, only a before and an after. + var delta: Double? { + guard case .number(let before)? = before, case .number(let after)? = after else { return nil } + return after - before + } + + /// How much larger the current value is, as a multiple of the baseline. Nil when the baseline is + /// zero, because everything is infinitely larger than nothing and saying so helps nobody. + var ratio: Double? { + guard case .number(let before)? = before, case .number(let after)? = after, + before > 0, before.isFinite, after.isFinite + else { return nil } + return after / before + } +} diff --git a/TablePro/Models/Query/QueryPlanSnapshot.swift b/TablePro/Models/Query/QueryPlanSnapshot.swift new file mode 100644 index 000000000..72fe5b60d --- /dev/null +++ b/TablePro/Models/Query/QueryPlanSnapshot.swift @@ -0,0 +1,124 @@ +// +// QueryPlanSnapshot.swift +// TablePro +// +// A saved EXPLAIN plan, and the identity that decides which saved plans are comparable. +// +// Identity is the statement's fingerprint rather than its text. `SQLQueryFingerprint` already +// folds literals, whitespace, comments and identifier quoting the way `pg_stat_statements` does, +// and every history row already stores and indexes the same hash, so a reformatted or +// re-parameterized statement keeps its chain of earlier plans instead of starting a new one. +// + +import Foundation +import TableProPluginKit + +/// Where a plan was produced. Two plans from different databases describe different work even when +/// the statement is spelled the same way. +struct QueryPlanScope: Hashable, Sendable { + let connectionId: UUID + let databaseType: DatabaseType + let databaseName: String + let schemaName: String? +} + +/// The scope two plans have to share before comparing them means anything: the same statement +/// shape, on the same database, asked the same way. +struct QueryPlanIdentity: Hashable, Sendable { + let fingerprintHash: Int64 + let scope: QueryPlanScope + let variantKey: QueryPlanVariantKey + let format: ExplainPlanFormat +} + +/// Which flavour of EXPLAIN produced the plan. A plain `EXPLAIN` and an `EXPLAIN ANALYZE` describe +/// the same statement but report different things, so they are separate chains. +/// +/// Spelled out rather than hashed: a stored key a developer can read is one they can debug, and it +/// is what the baseline picker shows the user. +struct QueryPlanVariantKey: Hashable, Sendable, RawRepresentable { + /// Long enough for any real EXPLAIN preamble, short enough that a pathological statement cannot + /// grow the index entry without bound. + static let maximumLength = 200 + + let rawValue: String + + init(rawValue: String) { + self.rawValue = String(rawValue.prefix(Self.maximumLength)) + } + + /// A variant the driver declared. Its identifier is stable across releases, so it keys the + /// chain directly. + static func declared(_ variantId: String) -> QueryPlanVariantKey { + QueryPlanVariantKey(rawValue: "variant:\(variantId)") + } + + /// A statement the user typed themselves. The preamble is normalized to uppercase tokens so + /// `explain (analyze)` and `EXPLAIN (ANALYZE)` are one chain. + static func typed(preamble: String) -> QueryPlanVariantKey { + QueryPlanVariantKey(rawValue: "sql:\(SQLPreambleNormalizer.normalize(preamble))") + } + + /// The driver built the statement and told us nothing about it. + static let driverBuilt = QueryPlanVariantKey(rawValue: "driver") + + /// What the baseline picker shows beside a run. The prefix is machinery, not something to read. + var displayName: String { + if let value = rawValue.dropPrefixIfPresent("sql:"), !value.isEmpty { return value } + if let value = rawValue.dropPrefixIfPresent("variant:"), !value.isEmpty { return value } + return rawValue + } +} + +/// One stored plan, with the raw text loaded. +struct QueryPlanSnapshot: Identifiable, Hashable, Sendable { + let id: UUID + let identity: QueryPlanIdentity + let subjectSQL: String + let rawPlan: String + let executionTime: TimeInterval + let capturedAt: Date + let isPinned: Bool +} + +/// A row of the baseline list. Deliberately carries no plan text: a list of fifty runs would +/// otherwise pull fifty plans into memory to draw fifty dates. +struct QueryPlanSnapshotSummary: Identifiable, Hashable, Sendable { + let id: UUID + let subjectSQL: String + let executionTime: TimeInterval + let capturedAt: Date + let isPinned: Bool + let byteCount: Int +} + +/// What a finished EXPLAIN offers the store, before the store decides whether to keep it. +struct QueryPlanCapture: Sendable { + let id: UUID + let identity: QueryPlanIdentity + let subjectSQL: String + let rawPlan: String + let executionTime: TimeInterval + let capturedAt: Date + let historyId: UUID? + + var byteCount: Int { rawPlan.utf8.count } + + var isWithinPlanSizeLimit: Bool { byteCount <= QueryPlanStorageLimits.maximumPlanByteCount } +} + +enum QueryPlanStorageLimits { + /// A single plan larger than this is a dump rather than a plan, and storing it would cost more + /// than every other plan put together. + static let maximumPlanByteCount = 2_000_000 + + /// Ceiling on everything unpinned, enforced on the same cadence as query-history retention. + static let maximumTotalByteCount: Int64 = 100_000_000 +} + +private extension String { + func dropPrefixIfPresent(_ prefix: String) -> String? { + guard hasPrefix(prefix) else { return nil } + return String(dropFirst(prefix.count)) + } +} diff --git a/TablePro/Models/Query/QueryPlanValueFormatter.swift b/TablePro/Models/Query/QueryPlanValueFormatter.swift new file mode 100644 index 000000000..fc7863c08 --- /dev/null +++ b/TablePro/Models/Query/QueryPlanValueFormatter.swift @@ -0,0 +1,61 @@ +// +// QueryPlanValueFormatter.swift +// TablePro +// +// One spelling for every number the comparison draws, so a node row and the summary above it never +// render the same cost two different ways. +// + +import Foundation + +enum QueryPlanValueFormatter { + /// Shown where a plan reports no value at all, as opposed to reporting zero. + static let absent = "\u{2013}" + + static func string(_ value: QueryPlanFieldValue?, unit: QueryPlanUnit?) -> String { + guard let value else { return absent } + switch value { + case .text(let text): + return text + case .number(let number): + return string(number, unit: unit ?? .cost) + } + } + + static func string(_ value: Double, unit: QueryPlanUnit) -> String { + switch unit { + case .cost: + return value.formatted(.number.precision(.fractionLength(0 ... 2))) + case .count: + return value.formatted(.number.precision(.fractionLength(0))) + case .bytes: + return value.formatted(.number.precision(.fractionLength(0))) + case .milliseconds: + return Measurement(value: value, unit: UnitDuration.milliseconds) + .formatted(.measurement( + width: .abbreviated, + usage: .asProvided, + numberFormatStyle: .number.precision(.fractionLength(0 ... 3)) + )) + } + } + + /// The signed difference, with the multiple beside it when there is a baseline to be a multiple + /// of. Nil when nothing moved, so a caller can draw the row as unchanged rather than as a zero. + static func change(_ change: QueryPlanFieldChange) -> String? { + guard change.hasChange else { return nil } + guard let delta = change.delta, let unit = change.field.unit else { + return String(localized: "Changed") + } + let signed = signedString(delta, unit: unit) + guard let ratio = change.ratio else { return signed } + let percent = (ratio - 1).formatted(.percent.precision(.fractionLength(0 ... 1)).sign(strategy: .always())) + return "\(signed) (\(percent))" + } + + private static func signedString(_ value: Double, unit: QueryPlanUnit) -> String { + let magnitude = string(abs(value), unit: unit) + guard value != 0 else { return magnitude } + return value > 0 ? "+\(magnitude)" : "\u{2212}\(magnitude)" + } +} diff --git a/TablePro/Models/Query/ResultSet.swift b/TablePro/Models/Query/ResultSet.swift index cd4e6affe..75f02b323 100644 --- a/TablePro/Models/Query/ResultSet.swift +++ b/TablePro/Models/Query/ResultSet.swift @@ -55,6 +55,10 @@ final class ResultSet: Identifiable { var queryPlan: QueryPlan? var explainRawText: String? + /// Where this plan sits in the statement's saved history, so the plan pane can offer a + /// comparison without asking a coordinator anything. + var explainPlanContext: QueryPlanContext? + var isExplainResult: Bool { explainRawText != nil } var resultColumns: [String] { tableRows.columns } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 9a1be3aef..3d800a90c 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -603,6 +603,40 @@ } } }, + "%1$@ · %2$@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · %2$@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ · %2$@" + } + } + } + }, "%1$@ → %2$@" : { "localizations" : { "ko" : { @@ -1555,6 +1589,40 @@ } } }, + "%@ faster than the baseline" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기준보다 %@ 빠름" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "temel plandan %@ daha hızlı" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhanh hơn %@ so với bản gốc" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "比基准快 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "比基準快 %@" + } + } + } + }, "%@ is already assigned to \"%@\". Reassigning will remove it from that action." : { "extractionState" : "stale", "localizations" : { @@ -2234,6 +2302,40 @@ } } }, + "%@ slower than the baseline" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기준보다 %@ 느림" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "temel plandan %@ daha yavaş" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chậm hơn %@ so với bản gốc" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "比基准慢 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "比基準慢 %@" + } + } + } + }, "%@ slowest" : { "comment" : "Slowest single run, %@ is a duration", "localizations" : { @@ -2952,6 +3054,40 @@ } } }, + "%@x" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@배" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@x" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@x" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 倍" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 倍" + } + } + } + }, "%@×" : { "comment" : "Number of times a query ran, %@ is a count\nSlowdown multiple, %@ is a number", "localizations" : { @@ -11086,6 +11222,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, @@ -11122,6 +11292,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, @@ -11158,6 +11362,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" : { @@ -18053,6 +18291,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" : { @@ -21853,7 +22125,7 @@ "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đang vẽ %1$d trong số %2$@ hàng đã tải đầu tiên" + "value" : "Đang vẽ %1$@ trong số %2$@ hàng đã tải đầu tiên" } }, "zh-Hans" : { @@ -27347,8 +27619,32 @@ "localizations" : { "ko" : { "stringUnit" : { - "value" : "비교", - "state" : "translated" + "state" : "translated", + "value" : "비교" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karşılaştır" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "So sánh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "比较" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "比較" } } } @@ -27525,6 +27821,40 @@ } } }, + "Comparison Unavailable" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "비교할 수 없음" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karşılaştırma Kullanılamıyor" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể so sánh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法比较" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法比較" + } + } + } + }, "Comparison cancelled." : { "localizations" : { "ko" : { @@ -34502,6 +34832,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" : { @@ -37882,8 +38246,32 @@ "localizations" : { "ko" : { "stringUnit" : { - "value" : "정의", - "state" : "translated" + "state" : "translated", + "value" : "정의" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tanım" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Định nghĩa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "定义" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "定義" } } } @@ -46559,6 +46947,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" : "估算列數" + } + } + } + }, "Event" : { "localizations" : { "ko" : { @@ -47438,6 +47996,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" : { @@ -63259,6 +63851,40 @@ } } }, + "Keep this plan when history is cleaned up" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록을 정리할 때 이 플랜 유지" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geçmiş temizlenirken bu planı sakla" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giữ kế hoạch này khi dọn dẹp lịch sử" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清理历史记录时保留此计划" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "清理歷史記錄時保留此計劃" + } + } + } + }, "Kerberos Principal" : { "localizations" : { "ko" : { @@ -70270,6 +70896,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" : { @@ -75143,6 +75803,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" : { @@ -77529,6 +78223,40 @@ } } }, + "No measurable change" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "측정 가능한 변화 없음" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ölçülebilir bir değişiklik yok" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có thay đổi đo được" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有可测量的变化" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "沒有可測量的變化" + } + } + } + }, "No model selected" : { "extractionState" : "stale", "localizations" : { @@ -77599,6 +78327,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" : { @@ -78944,6 +79706,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" : "節點數" + } + } + } + }, "Non-UTF-8 file (%@). Saving may change the encoding." : { "localizations" : { "ko" : { @@ -85727,6 +86557,40 @@ } } }, + "Pin baseline" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기준 고정" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temel planı sabitle" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ghim bản gốc" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "固定基准" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "固定基準" + } + } + } + }, "Pink" : { "localizations" : { "ko" : { @@ -85899,6 +86763,40 @@ } } }, + "Plan Not Saved" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "플랜이 저장되지 않음" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plan Kaydedilmedi" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kế hoạch chưa được lưu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未保存计划" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未儲存計劃" + } + } + } + }, "Plan: %@" : { "localizations" : { "ko" : { @@ -85933,6 +86831,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" : { @@ -85967,6 +86899,74 @@ } } }, + "Plans are not saved for queries that carry parameters, because a database can print the values into its plan output." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "데이터베이스가 플랜 출력에 값을 그대로 표시할 수 있으므로, 매개변수를 사용하는 쿼리의 플랜은 저장하지 않습니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir veritabanı değerleri plan çıktısına yazdırabildiği için, parametre taşıyan sorguların planları kaydedilmez." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kế hoạch không được lưu cho truy vấn có tham số, vì cơ sở dữ liệu có thể in giá trị vào kết quả kế hoạch." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "数据库可能把参数值打印到计划输出中,因此带参数的查询不会保存计划。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "資料庫可能把參數值列印到計劃輸出中,因此帶參數的查詢不會儲存計劃。" + } + } + } + }, + "Plans are saved with query history. Resume history to start collecting them." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "플랜은 쿼리 기록과 함께 저장됩니다. 기록을 다시 시작하면 수집이 시작됩니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planlar sorgu geçmişiyle birlikte kaydedilir. Toplamaya başlamak için geçmişi sürdürün." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kế hoạch được lưu cùng lịch sử truy vấn. Tiếp tục lịch sử để bắt đầu thu thập." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计划随查询历史一起保存。恢复历史记录即可开始收集。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "計劃隨查詢歷史一起儲存。恢復歷史記錄即可開始收集。" + } + } + } + }, "Please select a column" : { "localizations" : { "ko" : { @@ -90463,6 +91463,40 @@ } } }, + "Query History Is Paused" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "쿼리 기록이 일시 중지됨" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sorgu Geçmişi Duraklatıldı" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lịch sử truy vấn đang tạm dừng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "查询历史已暂停" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "查詢歷史已暫停" + } + } + } + }, "Query History Is Unavailable" : { "comment" : "A message that indicates that the query history is unavailable.", "isCommentAutoGenerated" : true, @@ -99526,6 +100560,40 @@ } } }, + "Run this EXPLAIN again after a change to compare the two plans." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "변경한 뒤 이 EXPLAIN을 다시 실행하면 두 플랜을 비교할 수 있습니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İki planı karşılaştırmak için bir değişiklikten sonra bu EXPLAIN'i yeniden çalıştırın." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chạy lại EXPLAIN này sau khi thay đổi để so sánh hai kế hoạch." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更改后再次运行此 EXPLAIN,即可比较两个计划。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "更改後再次執行此 EXPLAIN,即可比較兩個計劃。" + } + } + } + }, "Running Threads" : { "localizations" : { "ko" : { @@ -101978,6 +103046,40 @@ } } }, + "Same plan shape, different numbers" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "플랜 구조는 같고 수치만 다름" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aynı plan yapısı, farklı sayılar" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cùng cấu trúc kế hoạch, khác số liệu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计划结构相同,数值不同" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "計劃結構相同,數值不同" + } + } + } + }, "Sanitize formula-like values" : { "extractionState" : "stale", "localizations" : { @@ -113484,6 +114586,40 @@ } } }, + "Stop keeping this plan when history is cleaned up" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록을 정리할 때 이 플랜을 더 이상 유지하지 않음" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geçmiş temizlenirken bu planı artık saklama" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không giữ kế hoạch này khi dọn dẹp lịch sử" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清理历史记录时不再保留此计划" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "清理歷史記錄時不再保留此計劃" + } + } + } + }, "Stop recording queries on this Mac" : { "localizations" : { "ko" : { @@ -117382,8 +118518,32 @@ "localizations" : { "ko" : { "stringUnit" : { - "value" : "대상", - "state" : "translated" + "state" : "translated", + "value" : "대상" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hedef" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đích" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目标" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "目標" } } } @@ -120189,6 +121349,40 @@ } } }, + "The plan shape changed" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "플랜 구조가 변경됨" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plan yapısı değişti" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cấu trúc kế hoạch đã thay đổi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计划结构已改变" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "計劃結構已改變" + } + } + } + }, "The plugin depends on a component that's missing or incompatible with this Mac." : { "localizations" : { "ko" : { @@ -120540,6 +121734,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 run was rolled back. %@ is unchanged." : { "localizations" : { "ko" : { @@ -123160,6 +124388,40 @@ } } }, + "This plan could not be read as a tree, so the two runs are compared as text." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 플랜을 트리로 읽을 수 없어 두 실행을 텍스트로 비교합니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu plan bir ağaç olarak okunamadı, bu yüzden iki çalıştırma metin olarak karşılaştırılıyor." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể đọc kế hoạch này dưới dạng cây, nên hai lần chạy được so sánh dưới dạng văn bản." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法将此计划读取为树,因此两次运行按文本比较。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法將此計劃讀取為樹,因此兩次執行按文字比較。" + } + } + } + }, "This plan could not be read as a tree. Showing the raw output." : { "comment" : "A message that appears when the query plan cannot be displayed as a tree.", "isCommentAutoGenerated" : true, @@ -123196,6 +124458,74 @@ } } }, + "This plan is no longer stored." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 플랜은 더 이상 저장되어 있지 않습니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu plan artık saklanmıyor." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kế hoạch này không còn được lưu." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此计划已不再存储。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此計劃已不再儲存。" + } + } + } + }, + "This plan is too large to save." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 플랜은 너무 커서 저장할 수 없습니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu plan kaydedilemeyecek kadar büyük." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kế hoạch này quá lớn để lưu." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此计划过大,无法保存。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此計劃過大,無法儲存。" + } + } + } + }, "This plugin is not in the registry, so it can't be updated automatically." : { "localizations" : { "ko" : { @@ -128293,6 +129623,40 @@ } } }, + "Unpin baseline" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기준 고정 해제" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temel plan sabitlemesini kaldır" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bỏ ghim bản gốc" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消固定基准" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消固定基準" + } + } + } + }, "Unsaved changes will be lost." : { "localizations" : { "ko" : { diff --git a/TablePro/ViewModels/QueryPlanComparisonModel.swift b/TablePro/ViewModels/QueryPlanComparisonModel.swift new file mode 100644 index 000000000..db8ad6ee0 --- /dev/null +++ b/TablePro/ViewModels/QueryPlanComparisonModel.swift @@ -0,0 +1,230 @@ +// +// QueryPlanComparisonModel.swift +// TablePro +// +// Drives the plan pane's Compare mode: which earlier runs exist, which one is the baseline, and +// what changed between it and the plan on screen. +// + +import Combine +import Foundation +import Observation +import TableProPluginKit + +/// What the pane can show for the selected baseline. +enum QueryPlanComparisonContent: Hashable, Sendable { + /// Both plans parsed, so the difference can be stated in terms of nodes and metrics. + case diff(QueryPlanDiff) + /// One of them did not parse. The plans are still comparable as text, which is what the + /// database gave us. + case rawText(baseline: [String], current: [String]) +} + +/// Why there is nothing to compare against yet. +enum QueryPlanComparisonEmptyReason: Hashable, Sendable { + case noEarlierRuns + case notSaved(QueryPlanCaptureSkipReason) + case capturePaused + + var title: String { + switch self { + case .noEarlierRuns: return String(localized: "No Earlier Plans") + case .notSaved: return String(localized: "Plan Not Saved") + case .capturePaused: return String(localized: "Query History Is Paused") + } + } + + var message: String { + switch self { + case .noEarlierRuns: + return String(localized: "Run this EXPLAIN again after a change to compare the two plans.") + case .notSaved(let reason): + return reason.explanation + case .capturePaused: + return String(localized: "Plans are saved with query history. Resume history to start collecting them.") + } + } + + var systemImage: String { + switch self { + case .noEarlierRuns: return "clock.arrow.circlepath" + case .notSaved: return "doc.badge.ellipsis" + case .capturePaused: return "pause.circle" + } + } +} + +@MainActor +@Observable +final class QueryPlanComparisonModel { + enum State: Hashable, Sendable { + case loading + case empty(QueryPlanComparisonEmptyReason) + case content(QueryPlanComparisonContent) + case unavailable(String) + } + + /// A plan longer than this is not read line by line by anybody, and rendering all of it costs + /// more than the answer is worth. Only the text fallback is bounded; a parsed plan is compared + /// as a tree and has no such problem. + nonisolated static let maximumComparedLineCount = 2_000 + + /// Enough runs to find the one before yesterday's deploy, few enough to stay a menu. + nonisolated static let baselineListLimit = 50 + + private(set) var baselines: [QueryPlanSnapshotSummary] = [] + private(set) var state: State = .loading + + var selectedBaselineId: UUID? { + didSet { + guard oldValue != selectedBaselineId else { return } + reloadComparison() + } + } + + private var context: QueryPlanContext? + private var currentPlan: QueryPlan? + private var currentRawText = "" + private let history: QueryPlanSnapshotReading + private let isCapturePaused: @MainActor () -> Bool + private var updateSubscription: AnyCancellable? + private var loadTask: Task? + private var comparisonTask: Task? + + init( + history: QueryPlanSnapshotReading = QueryHistoryManager.shared, + isCapturePaused: @escaping @MainActor () -> Bool = { QueryHistoryCaptureStore.isPaused } + ) { + self.history = history + self.isCapturePaused = isCapturePaused + } + + var selectedBaseline: QueryPlanSnapshotSummary? { + baselines.first { $0.id == selectedBaselineId } + } + + /// Called whenever the pane's result changes. Reloading on the context's identity rather than on + /// every render keeps a re-run of the same statement from throwing away the chosen baseline. + func activate(context: QueryPlanContext, plan: QueryPlan?, rawText: String) { + self.context = context + currentPlan = plan + currentRawText = rawText + startObserving(connectionId: context.identity.scope.connectionId) + reloadBaselines() + } + + func deactivate() { + updateSubscription?.cancel() + updateSubscription = nil + loadTask?.cancel() + comparisonTask?.cancel() + } + + func setPinned(_ isPinned: Bool, snapshotId: UUID) { + Task { [history] in + await history.setPlanSnapshotPinned(id: snapshotId, isPinned: isPinned) + reloadBaselines() + } + } + + // MARK: - Loading + + private func startObserving(connectionId: UUID) { + guard updateSubscription == nil else { return } + /// Scoped to this connection and debounced, the same shape the history drawer and the + /// insights tab use. Saving a grid full of edits records one entry per statement, and an + /// unfiltered, undebounced reload turns that burst into a burst of queries. + updateSubscription = AppEvents.shared.queryHistoryDidUpdate + .filter { $0 == nil || $0 == connectionId } + .debounce(for: .milliseconds(200), scheduler: RunLoop.main) + .sink { [weak self] _ in self?.reloadBaselines() } + } + + private func reloadBaselines() { + guard let context else { return } + loadTask?.cancel() + loadTask = Task { [history] in + guard await history.isStoreAvailable() else { + guard !Task.isCancelled else { return } + state = .unavailable(String(localized: "The query history store could not be opened.")) + return + } + let loaded = await history.planSnapshots( + matching: context.identity, + excluding: context.storedSnapshotId, + limit: Self.baselineListLimit + ) + guard !Task.isCancelled else { return } + baselines = loaded + if let selectedBaselineId, loaded.contains(where: { $0.id == selectedBaselineId }) { + reloadComparison() + return + } + selectedBaselineId = loaded.first?.id + if selectedBaselineId == nil { + state = .empty(emptyReason) + } + } + } + + private var emptyReason: QueryPlanComparisonEmptyReason { + if let reason = context?.skipReason { return .notSaved(reason) } + if isCapturePaused() { return .capturePaused } + return .noEarlierRuns + } + + private func reloadComparison() { + comparisonTask?.cancel() + guard let selectedBaselineId else { + state = .empty(emptyReason) + return + } + guard let context else { return } + + let plan = currentPlan + let rawText = currentRawText + let format = context.identity.format + comparisonTask = Task { [history] in + guard let baselineRaw = await history.planSnapshotRawText(id: selectedBaselineId) else { + guard !Task.isCancelled else { return } + state = .unavailable(String(localized: "This plan is no longer stored.")) + return + } + let content = await Self.makeContent( + baselineRawText: baselineRaw, + format: format, + currentPlan: plan, + currentRawText: rawText + ) + guard !Task.isCancelled, self.selectedBaselineId == selectedBaselineId else { return } + state = .content(content) + } + } + + /// Parsing and diffing are the only expensive part, and neither touches the model, so they run + /// off the main actor. `nonisolated async` is what moves them there; a detached task would + /// escape the model's isolation for no benefit. + nonisolated private static func makeContent( + baselineRawText: String, + format: ExplainPlanFormat, + currentPlan: QueryPlan?, + currentRawText: String + ) async -> QueryPlanComparisonContent { + guard let currentPlan, + let baselinePlan = ExplainPlanParserRegistry.plan(from: baselineRawText, format: format) + else { + return .rawText( + baseline: boundedLines(baselineRawText), + current: boundedLines(currentRawText) + ) + } + return .diff(QueryPlanDiff.compare(baseline: baselinePlan, current: currentPlan)) + } + + nonisolated private static func boundedLines(_ text: String) -> [String] { + let lines = SqlNormalizer.lines(text) + guard lines.count > maximumComparedLineCount else { return lines } + return Array(lines.prefix(maximumComparedLineCount)) + + [String(localized: "Output truncated for display")] + } +} diff --git a/TablePro/Views/Compare/StructureDefinitionDiffView.swift b/TablePro/Views/Compare/StructureDefinitionDiffView.swift index ef4552412..518791e2b 100644 --- a/TablePro/Views/Compare/StructureDefinitionDiffView.swift +++ b/TablePro/Views/Compare/StructureDefinitionDiffView.swift @@ -10,6 +10,9 @@ import SwiftUI internal struct StructureDefinitionDiffView: View { + internal var title = String(localized: "Definition") + internal var sourceLabel = String(localized: "Source") + internal var targetLabel = String(localized: "Target") internal let sourceLines: [String] internal let targetLines: [String] @@ -24,7 +27,7 @@ internal struct StructureDefinitionDiffView: View { internal var body: some View { VStack(alignment: .leading, spacing: 0) { HStack { - Text("Definition") + Text(title) .font(.subheadline.weight(.semibold)) Spacer() Picker("", selection: $isUnified) { @@ -48,8 +51,8 @@ internal struct StructureDefinitionDiffView: View { private var splitBody: some View { VStack(spacing: 0) { HStack(spacing: 0) { - columnHeader(String(localized: "Target")) - columnHeader(String(localized: "Source")) + columnHeader(targetLabel) + columnHeader(sourceLabel) } ForEach(Array(pairs.enumerated()), id: \.offset) { _, pair in HStack(spacing: 0) { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index fd0d22451..adefccf26 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -676,11 +676,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( @@ -702,11 +698,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) @@ -790,6 +782,15 @@ struct MainEditorContentView: View { } } + private func queryPlanResultView(for resultSet: ResultSet) -> QueryPlanResultView { + QueryPlanResultView( + rawText: resultSet.explainRawText ?? "", + executionTime: resultSet.executionTime, + plan: resultSet.queryPlan, + planContext: resultSet.explainPlanContext + ) + } + @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..34feddc40 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,26 @@ extension MainContentCoordinator { databaseName: operationDatabaseName(tabId: tabId), outcome: .succeeded(OperationSummary()) ) + /// The same builder and the same database and schema the history row uses, so a + /// plan captured here and one captured from a hand-typed EXPLAIN land in one + /// chain instead of two that never compare. + let historyId = UUID() + let captured = QueryPlanCaptureBuilder.make( + subjectSQL: request.subjectSQL, + rawPlan: rawText, + format: request.format, + variantKey: request.variantKey, + scope: QueryPlanScope( + connectionId: conn.id, + databaseType: conn.type, + databaseName: queryExecutionCoordinator.historyDatabaseName(tabId: tabId), + schemaName: queryExecutionCoordinator.historySchemaName(tabId: tabId) + ), + executionTime: fetchResult.executionTime, + capturedAt: Date(), + historyId: historyId, + queryParameters: nil + ) flushBufferToActiveResult(tabId: tabId, pinnedOnly: true) tabManager.mutate(tabId: tabId) { tab in tab.execution.executionTime = fetchResult.executionTime @@ -182,7 +206,8 @@ extension MainContentCoordinator { plan: plan, sql: request.sql, executionTime: fetchResult.executionTime, - anchor: anchor + anchor: anchor, + planContext: captured.context )] ) if tab.display.isResultsCollapsed { @@ -194,15 +219,17 @@ extension MainContentCoordinator { recordHistory( QueryHistoryRecordRequest( + id: historyId, query: request.sql, connectionId: conn.id, - databaseName: queryExecutionCoordinator.historyDatabaseName(tabId: tabId), + databaseName: captured.context.identity.scope.databaseName, databaseType: conn.type, - schemaName: queryExecutionCoordinator.historySchemaName(tabId: tabId), + schemaName: captured.context.identity.scope.schemaName, source: .explain, executionTime: fetchResult.executionTime, rowCount: fetchResult.rows.count, - wasSuccessful: true + wasSuccessful: true, + planCapture: captured.capture ) ) } diff --git a/TablePro/Views/QueryPlan/QueryPlanChangeStyle.swift b/TablePro/Views/QueryPlan/QueryPlanChangeStyle.swift new file mode 100644 index 000000000..b74d040b2 --- /dev/null +++ b/TablePro/Views/QueryPlan/QueryPlanChangeStyle.swift @@ -0,0 +1,81 @@ +// +// QueryPlanChangeStyle.swift +// TablePro +// +// How an added, removed or changed plan node is drawn. +// +// The glyph is drawn whatever the accessibility settings say, and the tint is what Differentiate +// Without Color removes. Colour is the redundant channel here, not the load-bearing one, which is +// what the HIG asks for: "Convey information with more than color alone." +// + +import SwiftUI + +struct QueryPlanChangeStyle { + let symbolName: String + let glyph: String + let label: String + let tint: Color + + init(_ kind: QueryPlanNodeChange.Kind) { + switch kind { + case .added: + symbolName = "plus.circle.fill" + glyph = "+" + label = String(localized: "Added") + tint = .green + case .removed: + symbolName = "minus.circle.fill" + glyph = "\u{2212}" + label = String(localized: "Removed") + tint = .red + case .changed: + symbolName = "pencil.circle.fill" + glyph = "~" + label = String(localized: "Changed") + tint = .orange + } + } +} + +extension QueryPlanVerdict { + var symbolName: String { + switch self { + case .slower: return "arrow.up.right.circle.fill" + case .faster: return "arrow.down.right.circle.fill" + case .shapeChanged: return "arrow.triangle.branch" + case .valuesChanged: return "equal.circle" + case .unchanged: return "checkmark.circle" + } + } + + var tint: Color { + switch self { + case .slower: return .red + case .faster: return .green + case .shapeChanged, .valuesChanged: return .orange + case .unchanged: return .secondary + } + } + + /// The one line the pane leads with. A reader wants to know whether the query got better before + /// they want to know which node changed. + var headline: String { + switch self { + case .slower(let ratio): + return String(format: String(localized: "%@ slower than the baseline"), Self.multiple(ratio)) + case .faster(let ratio): + return String(format: String(localized: "%@ faster than the baseline"), Self.multiple(ratio)) + case .shapeChanged: + return String(localized: "The plan shape changed") + case .valuesChanged: + return String(localized: "Same plan shape, different numbers") + case .unchanged: + return String(localized: "No measurable change") + } + } + + private static func multiple(_ ratio: Double) -> String { + String(format: String(localized: "%@x"), ratio.formatted(.number.precision(.fractionLength(0 ... 1)))) + } +} diff --git a/TablePro/Views/QueryPlan/QueryPlanComparisonView.swift b/TablePro/Views/QueryPlan/QueryPlanComparisonView.swift new file mode 100644 index 000000000..e15c0c76c --- /dev/null +++ b/TablePro/Views/QueryPlan/QueryPlanComparisonView.swift @@ -0,0 +1,229 @@ +// +// QueryPlanComparisonView.swift +// TablePro +// +// The plan pane's Compare mode: this run against an earlier one. +// +// A mode inside the pane rather than a sheet or a window. The HIG routes a prolonged, revisitable +// task away from modality ("For complex or prolonged user flows, consider alternatives to +// sheets"), and the whole point of comparing a plan is to change the query or the schema and run +// it again, which a modal sheet structurally forbids. Xcode's own comparison editor is the same +// shape: a mode, a revision picker, and next/previous change. +// + +import SwiftUI + +struct QueryPlanComparisonView: View { + let model: QueryPlanComparisonModel + + var body: some View { + switch model.state { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .empty(let reason): + ContentUnavailableView( + reason.title, + systemImage: reason.systemImage, + description: Text(reason.message) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("query-plan-comparison-empty") + + case .unavailable(let message): + ContentUnavailableView( + String(localized: "Comparison Unavailable"), + systemImage: "exclamationmark.triangle", + description: Text(message) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .content(.diff(let diff)): + QueryPlanDiffView(diff: diff) + + case .content(.rawText(let baseline, let current)): + QueryPlanRawComparisonView(baselineLines: baseline, currentLines: current) + } + } +} + +private struct QueryPlanDiffView: View { + let diff: QueryPlanDiff + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + QueryPlanVerdictBanner(verdict: diff.verdict) + QueryPlanSummaryGrid(changes: diff.summary) + Divider() + nodeChanges + } + .padding(16) + } + } + + @ViewBuilder + private var nodeChanges: some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "Node Changes")) + .font(.headline) + + if diff.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(diff.nodeChanges) { change in + QueryPlanNodeChangeRow(change: change) + Divider() + } + } + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("query-plan-comparison-changes") + } +} + +private struct QueryPlanVerdictBanner: View { + let verdict: QueryPlanVerdict + + var body: some View { + HStack(spacing: 8) { + Image(systemName: verdict.symbolName) + .foregroundStyle(verdict.tint) + .accessibilityHidden(true) + Text(verdict.headline) + .font(.headline) + Spacer(minLength: 0) + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("query-plan-comparison-verdict") + } +} + +private struct QueryPlanSummaryGrid: View { + let changes: [QueryPlanFieldChange] + + var body: 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) + + ForEach(changes) { change in + GridRow { + Text(change.field.title) + .font(.callout) + Text(QueryPlanValueFormatter.string(change.before, unit: change.field.unit)) + .foregroundStyle(.secondary) + Text(QueryPlanValueFormatter.string(change.after, unit: change.field.unit)) + Text(QueryPlanValueFormatter.change(change) ?? QueryPlanValueFormatter.absent) + .foregroundStyle(change.hasChange ? Color.primary : Color.secondary) + } + .accessibilityElement(children: .combine) + } + } + .monospacedDigit() + .accessibilityIdentifier("query-plan-comparison-summary") + } +} + +private struct QueryPlanNodeChangeRow: View { + let change: QueryPlanNodeChange + + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + + private var style: QueryPlanChangeStyle { QueryPlanChangeStyle(change.kind) } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: style.symbolName) + .foregroundStyle(differentiateWithoutColor ? Color.primary : style.tint) + .frame(width: 18) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(style.glyph) + .font(.caption.monospaced().weight(.bold)) + .accessibilityHidden(true) + Text(style.label) + .font(.caption.weight(.medium)) + .foregroundStyle(differentiateWithoutColor ? Color.secondary : style.tint) + Text(change.title) + .font(.callout.weight(.medium)) + } + + ForEach(change.fieldChanges) { field in + Text(fieldText(field)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + + Spacer(minLength: 0) + } + .padding(.vertical, 8) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(style.label): \(change.title)") + .accessibilityValue(change.fieldChanges.map(Self.fieldText).joined(separator: ", ")) + .accessibilityIdentifier("query-plan-comparison-change-\(change.kind.rawValue)") + } + + private func fieldText(_ field: QueryPlanFieldChange) -> String { + Self.fieldText(field) + } + + static func fieldText(_ field: QueryPlanFieldChange) -> String { + let before = QueryPlanValueFormatter.string(field.before, unit: field.field.unit) + let after = QueryPlanValueFormatter.string(field.after, unit: field.field.unit) + return "\(field.field.title): \(before) \u{2192} \(after)" + } +} + +/// Used when either plan could not be parsed. It goes through the same line diff the Compare & Sync +/// window uses for a definition, so a plan the app cannot read as a tree is still shown as a +/// difference rather than as two blobs the reader has to align by eye. +private struct QueryPlanRawComparisonView: View { + let baselineLines: [String] + let currentLines: [String] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 6) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(String(localized: "This plan could not be read as a tree, so the two runs are compared as text.")) + .font(.caption) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + .accessibilityElement(children: .combine) + + StructureDefinitionDiffView( + title: String(localized: "Plan"), + sourceLabel: String(localized: "Current"), + targetLabel: String(localized: "Baseline"), + sourceLines: currentLines, + targetLines: baselineLines + ) + } + .padding(16) + } + .accessibilityIdentifier("query-plan-comparison-raw") + } +} diff --git a/TablePro/Views/QueryPlan/QueryPlanResultView.swift b/TablePro/Views/QueryPlan/QueryPlanResultView.swift index 16467288b..7166f1f79 100644 --- a/TablePro/Views/QueryPlan/QueryPlanResultView.swift +++ b/TablePro/Views/QueryPlan/QueryPlanResultView.swift @@ -11,6 +11,7 @@ enum QueryPlanViewMode: String, CaseIterable, Identifiable { case diagram case tree case raw + case compare var id: String { rawValue } @@ -19,6 +20,7 @@ enum QueryPlanViewMode: String, CaseIterable, Identifiable { case .diagram: return String(localized: "Diagram") case .tree: return String(localized: "Tree") case .raw: return String(localized: "Raw") + case .compare: return String(localized: "Compare") } } } @@ -65,11 +67,13 @@ struct QueryPlanResultView: View { let rawText: String let executionTime: TimeInterval? let plan: QueryPlan? + let planContext: QueryPlanContext? @AppStorage(PreferenceKeys.queryPlanRawFontSize.name) private var fontSize: Double = 13 @State private var showCopyConfirmation = false @State private var copyResetTask: Task? @State private var viewMode: QueryPlanViewMode = .diagram + @State private var comparison = QueryPlanComparisonModel() /// Shared by the diagram and the outline, so switching view mode keeps the selected step. @State private var selectedNodeId: UUID? @@ -78,12 +82,40 @@ struct QueryPlanResultView: View { QueryPlanPresentation.resolve(plan: plan, rawText: rawText) } + init( + rawText: String, + executionTime: TimeInterval?, + plan: QueryPlan?, + planContext: QueryPlanContext? = nil + ) { + self.rawText = rawText + self.executionTime = executionTime + self.plan = plan + self.planContext = planContext + } + + /// Compare is offered only when there is something to compare: a plan the app could read, and a + /// run it knows the identity of. + private var availableModes: [QueryPlanViewMode] { + planContext == nil + ? QueryPlanViewMode.allCases.filter { $0 != .compare } + : QueryPlanViewMode.allCases + } + var body: some View { VStack(spacing: 0) { toolbar Divider() content } + .task(id: planContext) { + guard let planContext else { return } + comparison.activate(context: planContext, plan: plan, rawText: rawText) + } + .onChange(of: availableModes) { _, modes in + guard !modes.contains(viewMode) else { return } + viewMode = .diagram + } } @ViewBuilder @@ -111,6 +143,8 @@ struct QueryPlanResultView: View { QueryPlanTreeView(plan: plan, selectedNodeId: $selectedNodeId) case .raw: DDLTextView(ddl: rawText, fontSize: $fontSize) + case .compare: + QueryPlanComparisonView(model: comparison) } } } @@ -135,22 +169,28 @@ struct QueryPlanResultView: View { HStack(spacing: 12) { if presentation.plan != nil { Picker("", selection: $viewMode) { - ForEach(QueryPlanViewMode.allCases) { mode in + ForEach(availableModes) { mode in Text(mode.title).tag(mode) } } .pickerStyle(.segmented) .controlSize(.small) - .frame(width: 240) + .fixedSize() .labelsHidden() .accessibilityIdentifier("query-plan-mode-picker") } + if viewMode == .compare { + baselinePicker + } + if viewMode == .raw || presentation.plan == nil { fontSizeStepper } - timings + if viewMode != .compare { + timings + } Spacer() @@ -175,6 +215,52 @@ struct QueryPlanResultView: View { .background(Color(nsColor: .controlBackgroundColor)) } + /// Which earlier run this plan is measured against. It sits in the pane's own bar beside the + /// mode switch, where Xcode's comparison editor puts its revision chooser, so changing the + /// baseline never leaves the plan. + @ViewBuilder + private var baselinePicker: some View { + if comparison.baselines.isEmpty { + EmptyView() + } else { + Picker(String(localized: "Baseline"), selection: $comparison.selectedBaselineId) { + ForEach(comparison.baselines) { baseline in + if baseline.isPinned { + Label(baselineLabel(baseline), systemImage: "pin.fill").tag(Optional(baseline.id)) + } else { + Text(baselineLabel(baseline)).tag(Optional(baseline.id)) + } + } + } + .controlSize(.small) + .fixedSize() + .accessibilityIdentifier("query-plan-baseline-picker") + + if let selected = comparison.selectedBaseline { + Button { + comparison.setPinned(!selected.isPinned, snapshotId: selected.id) + } label: { + Image(systemName: selected.isPinned ? "pin.fill" : "pin") + } + .buttonStyle(.borderless) + .controlSize(.small) + .help(selected.isPinned + ? String(localized: "Stop keeping this plan when history is cleaned up") + : String(localized: "Keep this plan when history is cleaned up")) + .accessibilityLabel(selected.isPinned + ? String(localized: "Unpin baseline") + : String(localized: "Pin baseline")) + .accessibilityIdentifier("query-plan-baseline-pin") + } + } + } + + private func baselineLabel(_ baseline: QueryPlanSnapshotSummary) -> String { + let stamp = baseline.capturedAt.formatted(date: .abbreviated, time: .shortened) + let duration = QueryDurationFormatter.string(from: baseline.executionTime) + return String(format: String(localized: "%1$@ · %2$@"), stamp, duration) + } + private var fontSizeStepper: some View { HStack(spacing: 4) { Button { fontSize = max(10, fontSize - 1) } label: { diff --git a/TableProTests/Core/Services/Query/ExplainResultRouterTests.swift b/TableProTests/Core/Services/Query/ExplainResultRouterTests.swift index a994a68df..cf565bd5c 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,9 @@ struct ExplainResultRouterTests { declaredVariants: mysqlVariants ) #expect(routed?.rawText == "-> Limit: 5 row(s)\n -> Sort") + #expect(routed?.subjectSQL == "SELECT 1") + #expect(routed?.format == .mysqlComposite) + #expect(routed?.variantKey == .typed(preamble: "EXPLAIN ANALYZE")) } @Test("A multi-column plan the app can read routes to the viewer") @@ -45,6 +54,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?.variantKey == .declared("plan")) } /// MySQL declares an `EXPLAIN` variant, so prefix matching alone would drag its tabular @@ -112,4 +124,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 + )?.variantKey + } + + #expect(identifiers.count == 3) + #expect(Set(identifiers).count == 3) + #expect(identifiers[0] == .declared("explain")) + #expect(identifiers[1] == .typed(preamble: "EXPLAIN FORMAT = TREE")) + #expect(identifiers[2] == .typed(preamble: "EXPLAIN ANALYZE")) + } + + @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?.variantKey == spaced?.variantKey) + #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?.variantKey == .declared("explain-json")) + #expect(routed?.format == .mysqlComposite) + #expect(routed?.plan != nil) + } + + /// A pathological preamble must not grow the stored key, and therefore the index entry, without + /// bound. It is truncated rather than hashed, so what is stored stays readable. + @Test("A very long typed preamble is bounded") + func boundsTypedPreamble() throws { + let sql = "EXPLAIN " + String(repeating: "OPTION ", count: 1_000) + "SELECT 1" + let routed = try #require(routeMySQL(sql)) + + #expect(routed.variantKey.rawValue.count == QueryPlanVariantKey.maximumLength) + #expect(routed.variantKey.rawValue.hasPrefix("sql:EXPLAIN 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/QueryHistoryMigrationTests.swift b/TableProTests/Core/Storage/QueryHistoryMigrationTests.swift index 8aad9679b..34d619aca 100644 --- a/TableProTests/Core/Storage/QueryHistoryMigrationTests.swift +++ b/TableProTests/Core/Storage/QueryHistoryMigrationTests.swift @@ -10,6 +10,7 @@ import Foundation import SQLite3 @testable import TablePro +import TableProPluginKit import Testing @Suite("QueryHistoryStorage migration") @@ -106,6 +107,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 +277,87 @@ 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: "plan_snapshots") == [ + "id", "history_id", "fingerprint_hash", "subject_sql", "connection_id", + "database_name", "database_type", "schema_name", "variant_key", "format", + "raw_plan", "byte_count", "execution_time", "captured_at", "is_pinned" + ]) + #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 = 'plan_snapshots';" + ) == 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 (" + + "'plan_snapshots', 'idx_plan_snapshots_identity', 'idx_plan_snapshots_retention');" + ) == 3 + ) + } + + /// `history_id` is provenance, not ownership. History retention must leave the plan behind with + /// a null link rather than cascading it away, which is what a pinned baseline depends on. + @Test("deleting a history row nulls the plan link instead of deleting the plan") + func historyDeletionNullsThePlanLink() async { + let connectionId = UUID() + let historyId = UUID() + let url = makeLegacyDatabase(rows: [ + (historyId, "EXPLAIN SELECT 1", connectionId, Date(), nil) + ]) + + let storage = QueryHistoryStorage(databaseURL: url, removeDatabaseOnDeinit: true) + let capture = QueryPlanCapture( + id: UUID(), + identity: QueryPlanIdentity( + fingerprintHash: 1, + scope: QueryPlanScope( + connectionId: connectionId, + databaseType: .postgresql, + databaseName: "legacydb", + schemaName: nil + ), + variantKey: .declared("explain"), + format: .postgresJson + ), + subjectSQL: "SELECT 1", + rawPlan: "plan", + executionTime: 0.1, + capturedAt: Date(), + historyId: historyId + ) + #expect(await storage.recordPlanSnapshot(capture)) + #expect(await storage.delete(id: historyId)) + + #expect(scalarInt(in: url, sql: "SELECT COUNT(*) FROM plan_snapshots;") == 1) + #expect(scalarInt(in: url, sql: "SELECT COUNT(*) FROM plan_snapshots WHERE history_id IS NULL;") == 1) + } } diff --git a/TableProTests/Core/Storage/QueryPlanSnapshotStorageTests.swift b/TableProTests/Core/Storage/QueryPlanSnapshotStorageTests.swift new file mode 100644 index 000000000..6b0b2b7aa --- /dev/null +++ b/TableProTests/Core/Storage/QueryPlanSnapshotStorageTests.swift @@ -0,0 +1,237 @@ +// +// QueryPlanSnapshotStorageTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Saved query plans") +struct QueryPlanSnapshotStorageTests { + // MARK: - Identity + + /// The whole point of keying on the fingerprint: a user who reformats the statement, or runs it + /// with a different literal, still sees the plan they ran before the index. + @Test("A reformatted statement finds the earlier plan") + func reformattedStatementFindsEarlierPlan() async { + let store = TemporaryQueryHistoryStore() + let first = await store.record(subjectSQL: "SELECT * FROM users WHERE id = 1", rawPlan: "old") + let second = await store.identity(subjectSQL: "select *\n from users\n where id = 2") + + let found = await store.storage.planSnapshots(matching: second, excluding: nil, limit: 10) + #expect(found.map(\.id) == [first]) + } + + @Test("A different statement does not") + func differentStatementDoesNotMatch() async { + let store = TemporaryQueryHistoryStore() + _ = await store.record(subjectSQL: "SELECT * FROM users", rawPlan: "old") + let other = await store.identity(subjectSQL: "SELECT * FROM orders") + + let found = await store.storage.planSnapshots(matching: other, excluding: nil, limit: 10) + #expect(found.isEmpty) + } + + /// `EXPLAIN` and `EXPLAIN ANALYZE` describe the same statement but report different things, so + /// one must never be offered as a baseline for the other. + @Test("Switching EXPLAIN variant starts a separate chain") + func variantsAreSeparateChains() async { + let store = TemporaryQueryHistoryStore() + _ = await store.record(subjectSQL: "SELECT 1", rawPlan: "plain", variantKey: .declared("explain")) + let analyzed = await store.identity(subjectSQL: "SELECT 1", variantKey: .declared("analyze")) + + let found = await store.storage.planSnapshots(matching: analyzed, excluding: nil, limit: 10) + #expect(found.isEmpty) + } + + @Test("The run doing the asking is excluded from its own baselines") + func excludesTheAskingRun() async { + let store = TemporaryQueryHistoryStore() + let older = await store.record(subjectSQL: "SELECT 1", rawPlan: "older") + let newer = await store.record(subjectSQL: "SELECT 1", rawPlan: "newer") + let identity = await store.identity(subjectSQL: "SELECT 1") + + let found = await store.storage.planSnapshots(matching: identity, excluding: newer, limit: 10) + #expect(found.map(\.id) == [older]) + } + + @Test("Baselines come back newest first") + func baselinesAreNewestFirst() async { + let store = TemporaryQueryHistoryStore() + let older = await store.record(subjectSQL: "SELECT 1", rawPlan: "a", capturedAt: Date(timeIntervalSince1970: 100)) + let newer = await store.record(subjectSQL: "SELECT 1", rawPlan: "b", capturedAt: Date(timeIntervalSince1970: 200)) + let identity = await store.identity(subjectSQL: "SELECT 1") + + let found = await store.storage.planSnapshots(matching: identity, excluding: nil, limit: 10) + #expect(found.map(\.id) == [newer, older]) + } + + @Test("The plan text is loaded on demand, not with the list") + func rawTextLoadsOnDemand() async { + let store = TemporaryQueryHistoryStore() + let id = await store.record(subjectSQL: "SELECT 1", rawPlan: "the whole plan") + + #expect(await store.storage.planSnapshotRawText(id: id) == "the whole plan") + #expect(await store.storage.planSnapshotRawText(id: UUID()) == nil) + } + + // MARK: - Lifetime + + /// A plan is an artifact the user keeps, not a child of a history row. History retention must + /// not take it with it. + @Test("Deleting the originating history row keeps the plan") + func historyDeletionKeepsThePlan() async { + let store = TemporaryQueryHistoryStore() + let id = await store.record(subjectSQL: "SELECT 1", rawPlan: "kept") + let historyId = await store.lastHistoryId + + #expect(await store.storage.delete(id: historyId)) + #expect(await store.storage.planSnapshotRawText(id: id) == "kept") + } + + @Test("Clearing all history keeps the plans") + func clearingHistoryKeepsThePlans() async { + let store = TemporaryQueryHistoryStore() + let id = await store.record(subjectSQL: "SELECT 1", rawPlan: "kept") + + #expect(await store.storage.clear(matching: QueryHistoryFilter(scope: .all))) + #expect(await store.storage.planSnapshotRawText(id: id) == "kept") + } + + @Test("Pruning drops the oldest plans first") + func pruningDropsOldestFirst() async { + let store = TemporaryQueryHistoryStore() + let oldest = await store.record( + subjectSQL: "SELECT 1", rawPlan: String(repeating: "a", count: 1_000), + capturedAt: Date(timeIntervalSince1970: 100) + ) + let newest = await store.record( + subjectSQL: "SELECT 1", rawPlan: String(repeating: "b", count: 1_000), + capturedAt: Date(timeIntervalSince1970: 200) + ) + + #expect(await store.storage.prunePlanSnapshots(toByteLimit: 1_500)) + #expect(await store.storage.planSnapshotRawText(id: oldest) == nil) + #expect(await store.storage.planSnapshotRawText(id: newest) != nil) + } + + /// Pinning is the user saying the one thing retention exists to guess at. + @Test("A pinned plan survives pruning that would otherwise remove it") + func pinnedPlanSurvivesPruning() async { + let store = TemporaryQueryHistoryStore() + let pinned = await store.record( + subjectSQL: "SELECT 1", rawPlan: String(repeating: "a", count: 1_000), + capturedAt: Date(timeIntervalSince1970: 100) + ) + _ = await store.record( + subjectSQL: "SELECT 1", rawPlan: String(repeating: "b", count: 1_000), + capturedAt: Date(timeIntervalSince1970: 200) + ) + #expect(await store.storage.setPlanSnapshotPinned(id: pinned, isPinned: true)) + + _ = await store.storage.prunePlanSnapshots(toByteLimit: 1) + #expect(await store.storage.planSnapshotRawText(id: pinned) != nil) + } + + @Test("A plan over the per-plan cap is never written") + func oversizedPlanIsNeverWritten() async { + let store = TemporaryQueryHistoryStore() + let identity = await store.identity(subjectSQL: "SELECT 1") + let capture = QueryPlanCapture( + id: UUID(), + identity: identity, + subjectSQL: "SELECT 1", + rawPlan: String(repeating: "x", count: QueryPlanStorageLimits.maximumPlanByteCount + 1), + executionTime: 0, + capturedAt: Date(), + historyId: nil + ) + + #expect(await store.storage.recordPlanSnapshot(capture) == false) + #expect(await store.storage.planSnapshotUsage().snapshotCount == 0) + } + + @Test("Usage reports what the plans cost") + func usageReportsCost() async { + let store = TemporaryQueryHistoryStore() + _ = await store.record(subjectSQL: "SELECT 1", rawPlan: String(repeating: "a", count: 500)) + _ = await store.record(subjectSQL: "SELECT 2", rawPlan: String(repeating: "b", count: 300)) + + let usage = await store.storage.planSnapshotUsage() + #expect(usage.snapshotCount == 2) + #expect(usage.byteCount == 800) + } +} + +/// A store on a throwaway file, so every case starts from an empty database and nothing reaches the +/// developer's own history. +private final class TemporaryQueryHistoryStore { + let storage: QueryHistoryStorage + private let connectionId = UUID() + private(set) var lastHistoryId = UUID() + + init() { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("plan-snapshots-\(UUID().uuidString).db") + storage = QueryHistoryStorage(databaseURL: url, removeDatabaseOnDeinit: true) + } + + func identity( + subjectSQL: String, + variantKey: QueryPlanVariantKey = .declared("explain") + ) async -> QueryPlanIdentity { + QueryPlanIdentity( + fingerprintHash: SQLQueryFingerprint.hash(subjectSQL, databaseType: .postgresql), + scope: QueryPlanScope( + connectionId: connectionId, + databaseType: .postgresql, + databaseName: "app", + schemaName: "public" + ), + variantKey: variantKey, + format: .postgresJson + ) + } + + /// Writes the history row and the plan the way `QueryHistoryManager` does, so the foreign key + /// and the ordering are exercised rather than bypassed. + @discardableResult + func record( + subjectSQL: String, + rawPlan: String, + variantKey: QueryPlanVariantKey = .declared("explain"), + capturedAt: Date = Date() + ) async -> UUID { + let historyId = UUID() + lastHistoryId = historyId + let entry = QueryHistoryEntry( + id: historyId, + query: "EXPLAIN \(subjectSQL)", + connectionId: connectionId, + databaseName: "app", + databaseType: .postgresql, + schemaName: "public", + source: .explain, + executedAt: capturedAt, + executionTime: 0.1, + rowCount: 1, + wasSuccessful: true + ) + _ = await storage.record(entry) + + let snapshotId = UUID() + let capture = await QueryPlanCapture( + id: snapshotId, + identity: identity(subjectSQL: subjectSQL, variantKey: variantKey), + subjectSQL: subjectSQL, + rawPlan: rawPlan, + executionTime: 0.1, + capturedAt: capturedAt, + historyId: historyId + ) + _ = await storage.recordPlanSnapshot(capture) + return snapshotId + } +} 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/Localization/StringCatalogIntegrityTests.swift b/TableProTests/Localization/StringCatalogIntegrityTests.swift new file mode 100644 index 000000000..5bac04cc0 --- /dev/null +++ b/TableProTests/Localization/StringCatalogIntegrityTests.swift @@ -0,0 +1,552 @@ +// +// StringCatalogIntegrityTests.swift +// TableProTests +// + +import Foundation +import Testing + +/// A translation is data, so the compiler never reads it. A Turkish string that spells `%@` where the +/// source passes `%lld` still builds, ships, and then formats garbage or traps at runtime, and nobody +/// on the team reads Turkish closely enough to catch it in review. The catalogs carry more than 15,000 +/// translated units across four languages, and both grow every release. +/// +/// These are the rules the shipped catalogs already keep, measured across every unit in both of them +/// before this guard was written, so a failure here is a new defect rather than a pre-existing one. +/// Style choices that legitimately differ by language are deliberately not asserted: Chinese renders +/// terminal punctuation and ellipses full width, and 232 shipped translations pass two or more +/// arguments in plain `%@` order rather than positionally. +@Suite("String catalogs agree with their source strings") +struct StringCatalogIntegrityTests { + @Test("Every translation consumes the arguments its source passes") + func argumentsMatchSource() throws { + let offenders = try StringCatalog.loadAll().flatMap { catalog in + catalog.translatedUnits.compactMap { unit -> String? in + let expected = FormatSpecifier.arguments(in: unit.source, substitutions: unit.sourceSubstitutions) + let found = FormatSpecifier.parse(unit.value) + let foundArguments = FormatSpecifier.arguments(in: unit.value, substitutions: unit.valueSubstitutions) + guard !expected.isEmpty || !foundArguments.isEmpty else { return nil } + if let complaint = Self.mismatch(expected: expected, found: found, foundArguments: foundArguments) { + return "\(unit.description): \(complaint)" + } + return nil + } + } + + #expect( + offenders.isEmpty, + """ + A translation does not consume the same arguments as its source string. String(format:) \ + reads whatever the specifier names, so this formats the wrong value or crashes. + \(offenders.joined(separator: "\n")) + """ + ) + } + + @Test("No translation carries Apple's English-only inflection markup") + func inflectionMarkupStaysInTheSource() throws { + let offenders = try StringCatalog.loadAll().flatMap { catalog in + catalog.translatedUnits + .filter { $0.value.contains("^[") } + .map(\.description) + } + + #expect( + offenders.isEmpty, + """ + `^[noun](inflect: true)` is English grammar Foundation applies for the source language \ + only. Translations write the plain noun their own language needs. + \(offenders.joined(separator: "\n")) + """ + ) + } + + @Test("Shortcuts parameter tokens survive translation") + func interpolationTokensSurvive() throws { + let offenders = try StringCatalog.loadAll().flatMap { catalog in + catalog.translatedUnits.compactMap { unit -> String? in + let wanted = Self.interpolationTokens(in: unit.key) + let found = Self.interpolationTokens(in: unit.value) + guard wanted != found else { return nil } + return "\(unit.description): has \(found.joined(separator: " ")), source has \(wanted.joined(separator: " "))" + } + } + + #expect( + offenders.isEmpty, + """ + `${name}` in an App Intents parameter summary is a parameter reference, not a word. \ + Shortcuts drops the summary when the token no longer resolves. + \(offenders.joined(separator: "\n")) + """ + ) + } + + @Test("A command that ends in an ellipsis keeps one in every language") + func ellipsisSurvives() throws { + let offenders = try StringCatalog.loadAll().flatMap { catalog in + catalog.translatedUnits.compactMap { unit -> String? in + let sourceEnds = Self.endsWithEllipsis(unit.key) + guard sourceEnds != Self.endsWithEllipsis(unit.value) else { return nil } + return "\(unit.description): source \(sourceEnds ? "has" : "has no") trailing ellipsis" + } + } + + #expect( + offenders.isEmpty, + """ + The trailing ellipsis is the platform's promise that a command opens something before it \ + acts. Either spelling is fine, and Chinese uses the single character, but it has to be there. + \(offenders.joined(separator: "\n")) + """ + ) + } + + @Test("A source that ends in a sentence mark keeps one in every language") + func terminalPunctuationSurvives() throws { + let offenders = try StringCatalog.loadAll().flatMap { catalog in + catalog.translatedUnits.compactMap { unit -> String? in + guard !Self.endsWithEllipsis(unit.key) else { return nil } + guard let mark = Self.terminalMarks.first(where: { unit.key.hasSuffix($0.key) }) else { return nil } + guard !mark.value.contains(where: { unit.value.hasSuffix($0) }) else { return nil } + return "\(unit.description): source ends in \(mark.key)" + } + } + + #expect( + offenders.isEmpty, + """ + A label that drops the source's sentence mark reads as a different kind of string. \ + The full-width forms Chinese uses count. + \(offenders.joined(separator: "\n")) + """ + ) + } + + @Test("No translation introduces an em dash its source does not have") + func translationsAddNoEmDash() throws { + let offenders = try StringCatalog.loadAll().flatMap { catalog in + catalog.translatedUnits + .filter { $0.value.contains(Self.emDash) && !$0.key.contains(Self.emDash) } + .map(\.description) + } + + #expect( + offenders.isEmpty, + """ + CLAUDE.md bans the em dash from anything a user reads. Nine source strings still carry one \ + and their translations may mirror it; nothing else may introduce one. + \(offenders.joined(separator: "\n")) + """ + ) + } + + static func mismatch( + expected: [Int: String], + found: [FormatSpecifier], + foundArguments: [Int: String] + ) -> String? { + let indices = found.map(\.argumentIndex) + if indices.contains(where: { $0 == nil }), indices.contains(where: { $0 != nil }) { + return "mixes positional and plain specifiers" + } + guard Set(foundArguments.keys) == Set(expected.keys) else { + let want = expected.isEmpty + ? "no arguments" + : expected.keys.sorted().map(String.init).joined(separator: ",") + let got = foundArguments.isEmpty + ? "none" + : foundArguments.keys.sorted().map(String.init).joined(separator: ",") + return "consumes \(got) but the source passes \(want)" + } + for index in expected.keys.sorted() { + guard let want = expected[index], let got = foundArguments[index], want != got else { continue } + return "argument \(index) is %\(got) but the source passes %\(want)" + } + return nil + } + + static func interpolationTokens(in text: String) -> [String] { + var tokens: [String] = [] + var remainder = Substring(text) + while let start = remainder.range(of: "${") { + guard let end = remainder[start.upperBound...].firstIndex(of: "}") else { break } + tokens.append(String(remainder[start.lowerBound ... end])) + remainder = remainder[remainder.index(after: end)...] + } + return tokens.sorted() + } + + static func endsWithEllipsis(_ text: String) -> Bool { + text.hasSuffix("...") || text.hasSuffix("\u{2026}") + } + + static let terminalMarks: [String: [String]] = [ + ":": [":", "\u{FF1A}"], + "?": ["?", "\u{FF1F}"], + ".": [".", "\u{3002}"], + ] + + static let emDash = "\u{2014}" +} + +/// Foundation has no public parser for printf specifiers, so the guard carries its own. It reads the +/// grammar `String(format:)` accepts: an optional `n$` argument index, flags, width, precision, a +/// length modifier, and the conversion character. `%%` is a literal percent and consumes no argument. +struct FormatSpecifier: Equatable, Sendable { + let argumentIndex: Int? + let type: String + + static func parse(_ text: String) -> [FormatSpecifier] { + var specifiers: [FormatSpecifier] = [] + var characters = Array(text) + var cursor = 0 + while cursor < characters.count { + guard characters[cursor] == "%" else { + cursor += 1 + continue + } + cursor += 1 + guard cursor < characters.count else { break } + + /// `%#@name@` is a substitution placeholder, not a printf specifier. Read as printf it + /// looks like `%@` with a `#` flag, which is what made a plural translation report as + /// mixing positional and plain specifiers. + if characters[cursor] == "#", cursor + 1 < characters.count, characters[cursor + 1] == "@" { + var scan = cursor + 2 + while scan < characters.count, characters[scan] != "@" { + scan += 1 + } + guard scan < characters.count else { break } + cursor = scan + 1 + continue + } + + var index: Int? + var digits = "" + var lookahead = cursor + while lookahead < characters.count, characters[lookahead].isNumber { + digits.append(characters[lookahead]) + lookahead += 1 + } + if !digits.isEmpty, lookahead < characters.count, characters[lookahead] == "$" { + index = Int(digits) + cursor = lookahead + 1 + } + + while cursor < characters.count, "-+ #0".contains(characters[cursor]) { + cursor += 1 + } + while cursor < characters.count, characters[cursor].isNumber { + cursor += 1 + } + if cursor < characters.count, characters[cursor] == "." { + cursor += 1 + while cursor < characters.count, characters[cursor].isNumber { + cursor += 1 + } + } + + var length = "" + for candidate in ["ll", "hh", "l", "h", "z", "q"] where length.isEmpty { + let end = cursor + candidate.count + if end <= characters.count, String(characters[cursor ..< end]) == candidate { + length = candidate + cursor = end + } + } + + guard cursor < characters.count else { break } + let conversion = characters[cursor] + cursor += 1 + guard conversion != "%" else { continue } + guard "@diuUfFeEgGxXoscpaA".contains(conversion) else { continue } + specifiers.append(FormatSpecifier(argumentIndex: index, type: length + String(conversion))) + } + return specifiers + } + + /// The argument types a source string passes, in argument order. + static func argumentTypes(in source: String) -> [String] { + let specifiers = parse(source) + guard !specifiers.isEmpty else { return [] } + if specifiers.allSatisfy({ $0.argumentIndex != nil }) { + var byIndex: [Int: String] = [:] + for specifier in specifiers { + byIndex[specifier.argumentIndex ?? 0] = specifier.type + } + return byIndex.keys.sorted().compactMap { byIndex[$0] } + } + return specifiers.map(\.type) + } + + /// Which argument each side consumes, and as what. + /// + /// A `%#@name@` substitution is not a printf specifier and consumes no argument of its own; the + /// `substitutions` block beside it says which argument it stands for and how that argument is + /// spelled. Reading only the printf specifiers therefore under-counts a plural source by one + /// argument, and reports every translation that inlines the plural as consuming an argument the + /// source never passed. + static func arguments(in text: String, substitutions: [Int: String]) -> [Int: String] { + var arguments: [Int: String] = [:] + for (position, specifier) in parse(text).enumerated() { + arguments[specifier.argumentIndex ?? position + 1] = specifier.type + } + for (index, specifier) in substitutions { + arguments[index] = specifier + } + return arguments + } +} + +struct StringCatalog { + struct Unit { + let catalog: String + let language: String + let key: String + let source: String + let value: String + + /// `argNum` to format specifier, for the `%#@name@` substitutions each side declares. A + /// plural substitution consumes a real argument that no printf specifier spells out, so a + /// side that inlines `%4$d` and a side that writes `%#@points@` pass the same arguments. + let sourceSubstitutions: [Int: String] + let valueSubstitutions: [Int: String] + + var description: String { "\(catalog):\(language): \"\(key)\" -> \"\(value)\"" } + } + + let name: String + private let strings: [String: Entry] + private let sourceLanguage: String + + var translatedUnits: [Unit] { + strings.sorted { $0.key < $1.key }.flatMap { key, entry -> [Unit] in + let sourceLocalization = entry.localizations?[sourceLanguage] + let source = sourceLocalization?.stringUnit?.value ?? key + let sourceSubstitutions = sourceLocalization?.argumentSubstitutions ?? [:] + return (entry.localizations ?? [:]) + .sorted { $0.key < $1.key } + .filter { $0.key != sourceLanguage } + .flatMap { language, localization -> [Unit] in + let plain = localization.stringUnit.map { [$0] } ?? [] + let varied = (localization.variations ?? [:]).values + .flatMap(\.values) + .compactMap(\.stringUnit) + return (plain + varied) + .filter { $0.state == "translated" } + .map { + Unit( + catalog: name, + language: language, + key: key, + source: source, + value: $0.value, + sourceSubstitutions: sourceSubstitutions, + valueSubstitutions: localization.argumentSubstitutions + ) + } + } + } + } + + static func loadAll() throws -> [StringCatalog] { + let root = try repoRoot() + return try [ + ("TablePro", "TablePro/Resources/Localizable.xcstrings"), + ("TableProMobile", "TableProMobile/TableProMobile/Localizable.xcstrings"), + ].map { name, path in + let data = try Data(contentsOf: root.appendingPathComponent(path)) + let decoded = try JSONDecoder().decode(Payload.self, from: data) + return StringCatalog(name: name, strings: decoded.strings, sourceLanguage: decoded.sourceLanguage) + } + } + + private static func repoRoot() throws -> URL { + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0 ..< 12 { + if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { + return directory + } + directory = directory.deletingLastPathComponent() + } + throw CatalogError.repoRootNotFound + } + + private struct Payload: Decodable { + let sourceLanguage: String + let strings: [String: Entry] + } + + struct Entry: Decodable { + let localizations: [String: Localization]? + } + + struct Localization: Decodable { + let stringUnit: StringUnit? + let variations: [String: [String: Variation]]? + let substitutions: [String: Substitution]? + + var argumentSubstitutions: [Int: String] { + (substitutions ?? [:]).values.reduce(into: [:]) { result, substitution in + result[substitution.argNum] = substitution.formatSpecifier + } + } + } + + struct Substitution: Decodable { + let argNum: Int + let formatSpecifier: String + } + + struct Variation: Decodable { + let stringUnit: StringUnit? + } + + struct StringUnit: Decodable { + let state: String + let value: String + } + + enum CatalogError: Error { + case repoRootNotFound + } +} + +@Suite("Format specifier parsing") +struct FormatSpecifierTests { + @Test("A plain specifier carries no argument index") + func plainSpecifier() { + #expect(FormatSpecifier.parse("%@ rows") == [FormatSpecifier(argumentIndex: nil, type: "@")]) + } + + @Test("A length modifier is part of the type") + func lengthModifier() { + #expect(FormatSpecifier.parse("%lld of %d").map(\.type) == ["lld", "d"]) + } + + @Test("Width and precision are not the type") + func widthAndPrecision() { + #expect(FormatSpecifier.parse("%.3fms") == [FormatSpecifier(argumentIndex: nil, type: "f")]) + #expect(FormatSpecifier.parse("%-8.2f") == [FormatSpecifier(argumentIndex: nil, type: "f")]) + } + + @Test("A literal percent consumes no argument") + func literalPercent() { + #expect(FormatSpecifier.parse("100%% done").isEmpty) + #expect(FormatSpecifier.parse("%1$lld%% slower").map(\.argumentIndex) == [1]) + } + + @Test("Positional specifiers report their index") + func positionalSpecifiers() { + let parsed = FormatSpecifier.parse("%2$@ on %1$@") + #expect(parsed.map(\.argumentIndex) == [2, 1]) + } + + @Test("An argument used twice is one argument") + func repeatedArgument() { + #expect(FormatSpecifier.argumentTypes(in: "%1$@ owns %2$d items in %1$@") == ["@", "d"]) + } + + @Test("Argument types follow argument order, not written order") + func typesFollowArgumentOrder() { + #expect(FormatSpecifier.argumentTypes(in: "%2$lld of %1$@") == ["@", "lld"]) + } +} + +@Suite("String catalog rule checks") +struct StringCatalogRuleTests { + private static func complaint( + source: String, + sourceSubstitutions: [Int: String] = [:], + value: String, + valueSubstitutions: [Int: String] = [:] + ) -> String? { + StringCatalogIntegrityTests.mismatch( + expected: FormatSpecifier.arguments(in: source, substitutions: sourceSubstitutions), + found: FormatSpecifier.parse(value), + foundArguments: FormatSpecifier.arguments(in: value, substitutions: valueSubstitutions) + ) + } + + @Test("A reordered translation is fine when it stays positional") + func reorderedPositionalPasses() { + #expect(Self.complaint(source: "%1$@ on %2$@", value: "%2$@ üzerinde %1$@") == nil) + } + + @Test("A dropped argument is caught") + func droppedArgumentFails() { + #expect(Self.complaint(source: "%1$@ on %2$@", value: "%1$@") != nil) + } + + @Test("A retyped argument is caught") + func retypedArgumentFails() { + #expect(Self.complaint(source: "%lld rows", value: "%@ satır") != nil) + } + + @Test("Mixing positional and plain specifiers is caught") + func mixedStyleFails() { + #expect(Self.complaint(source: "%1$@ on %2$@", value: "%1$@ üzerinde %@") != nil) + } + + /// A plural source spells its count as `%#@points@` and declares the real argument beside it. + /// A translation that inlines `%4$d` passes the same four arguments, and one that keeps its own + /// substitution does too. Reading only the printf specifiers reported both as defects, which is + /// six false alarms on the shipped catalog. + @Test("A plural substitution counts as the argument it stands for") + func pluralSubstitutionCountsAsItsArgument() { + let source = "%1$@ chart of %2$@ by %3$@ with %#@points@" + let substitutions = [4: "d"] + + #expect(Self.complaint( + source: source, + sourceSubstitutions: substitutions, + value: "%3$@ ölçütüne göre %2$@ için %4$d noktalı %1$@ grafiği" + ) == nil) + #expect(Self.complaint( + source: source, + sourceSubstitutions: substitutions, + value: "%3$@ 기준 %2$@의 %1$@ 차트, %#@points@", + valueSubstitutions: substitutions + ) == nil) + } + + @Test("A translation that drops the plural argument is still caught") + func droppedPluralArgumentFails() { + #expect(Self.complaint( + source: "%1$@ with %#@points@", + sourceSubstitutions: [2: "d"], + value: "%1$@" + ) != nil) + } + + @Test("A translation that retypes the plural argument is still caught") + func retypedPluralArgumentFails() { + #expect(Self.complaint( + source: "%1$@ with %#@points@", + sourceSubstitutions: [2: "d"], + value: "%1$@ với %2$@" + ) != nil) + } + + @Test("Full-width punctuation counts as the sentence mark") + func fullWidthPunctuationCounts() { + let marks = StringCatalogIntegrityTests.terminalMarks + #expect(marks["."]?.contains(where: { "已导入。".hasSuffix($0) }) == true) + #expect(marks["?"]?.contains(where: { "確定嗎?".hasSuffix($0) }) == true) + } + + @Test("Either ellipsis spelling satisfies the ellipsis rule") + func eitherEllipsisSpellingPasses() { + #expect(StringCatalogIntegrityTests.endsWithEllipsis("Open Quickly...")) + #expect(StringCatalogIntegrityTests.endsWithEllipsis("快速打開…")) + #expect(!StringCatalogIntegrityTests.endsWithEllipsis("Open Quickly")) + } + + @Test("Interpolation tokens are compared as a set") + func interpolationTokensAreCompared() { + #expect(StringCatalogIntegrityTests.interpolationTokens(in: "Add a row to ${table}") == ["${table}"]) + #expect(StringCatalogIntegrityTests.interpolationTokens(in: "Thêm hàng vào ${table}") == ["${table}"]) + #expect(StringCatalogIntegrityTests.interpolationTokens(in: "no tokens").isEmpty) + } +} diff --git a/TableProTests/Models/Query/ExplainRequestTests.swift b/TableProTests/Models/Query/ExplainRequestTests.swift index a29fc81d0..a96be8a04 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.variantKey == .declared("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.variantKey == .declared("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.variantKey == .driverBuilt) + } + + @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,154 @@ struct ExplainRequestTests { let request = ExplainRequest.driverBuilt(sql: "DEBUG OBJECT key", databaseType: .redis) #expect(request.format == .plainText) } + + @Test("The result factory retains the run's plan-history provenance") + @MainActor + func resultFactoryRetainsPlanContext() { + let built = QueryPlanCaptureBuilder.make( + subjectSQL: "SELECT * FROM users", + rawPlan: "[]", + format: .postgresJson, + variantKey: .declared("analyze"), + scope: QueryPlanScope( + connectionId: UUID(), + databaseType: .postgresql, + databaseName: "app", + schemaName: "public" + ), + executionTime: 0.25, + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + historyId: UUID(), + queryParameters: nil + ) + let result = ExplainResultSetFactory.make( + rawText: "[]", + plan: nil, + sql: "EXPLAIN (ANALYZE, FORMAT JSON) SELECT * FROM users", + executionTime: 0.25, + planContext: built.context + ) + + #expect(result.explainPlanContext == built.context) + #expect(result.baseQuery == "EXPLAIN (ANALYZE, FORMAT JSON) SELECT * FROM users") + } + + /// A database is free to print bind values into its plan output, so a parameterized run keeps + /// its history row and stores no plan. The pane says so rather than showing an empty list. + @Test("A parameterized run stores no plan and explains why") + func parameterizedRunStoresNoPlan() { + let rawPlan = #"[{"Plan":{"Filter":"token = 'must-not-reach-history'"}}]"# + let arguments = ( + subjectSQL: "SELECT * FROM users WHERE token = :secret", + format: ExplainPlanFormat.postgresJson, + capturedAt: Date(timeIntervalSince1970: 1_700_000_000) + ) + + let unparameterized = QueryPlanCaptureBuilder.make( + subjectSQL: arguments.subjectSQL, + rawPlan: rawPlan, + format: arguments.format, + variantKey: .declared("explain"), + scope: QueryPlanScope( + connectionId: UUID(), + databaseType: .postgresql, + databaseName: "app", + schemaName: "public" + ), + executionTime: 0.1, + capturedAt: arguments.capturedAt, + historyId: UUID(), + queryParameters: nil + ) + #expect(unparameterized.capture?.rawPlan == rawPlan) + #expect(unparameterized.context.skipReason == nil) + #expect(unparameterized.context.isStored) + + let parameterized = QueryPlanCaptureBuilder.make( + subjectSQL: arguments.subjectSQL, + rawPlan: rawPlan, + format: arguments.format, + variantKey: .declared("explain"), + scope: QueryPlanScope( + connectionId: UUID(), + databaseType: .postgresql, + databaseName: "app", + schemaName: "public" + ), + executionTime: 0.1, + capturedAt: arguments.capturedAt, + historyId: UUID(), + queryParameters: [QueryParameter(name: "secret", value: "must-not-reach-history")] + ) + #expect(parameterized.capture == nil) + #expect(parameterized.context.skipReason == .parameterized) + #expect(!parameterized.context.isStored) + #expect(!parameterized.context.skipReason!.explanation.isEmpty) + } + + /// A plan bigger than the per-plan cap keeps the history row and reports why it was dropped. + @Test("An oversized plan is skipped with a reason") + func oversizedPlanIsSkipped() { + let built = QueryPlanCaptureBuilder.make( + subjectSQL: "SELECT 1", + rawPlan: String(repeating: "x", count: QueryPlanStorageLimits.maximumPlanByteCount + 1), + format: .postgresJson, + variantKey: .declared("explain"), + scope: QueryPlanScope( + connectionId: UUID(), + databaseType: .postgresql, + databaseName: "app", + schemaName: nil + ), + executionTime: 0.1, + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + historyId: UUID(), + queryParameters: nil + ) + #expect(built.capture == nil) + #expect(built.context.skipReason == .tooLarge) + } + + /// Both EXPLAIN paths have to reach one chain. They build the identity through the same builder, + /// so the same statement asked the same way hashes to the same identity whatever route it took. + @Test("Reformatting a statement keeps it in the same chain") + func reformattingKeepsOneChain() { + func identity(_ sql: String) -> QueryPlanIdentity { + QueryPlanCaptureBuilder.make( + subjectSQL: sql, + rawPlan: "[]", + format: .postgresJson, + variantKey: .declared("explain"), + scope: QueryPlanScope( + connectionId: connectionId, + databaseType: .postgresql, + databaseName: "app", + schemaName: "public" + ), + executionTime: 0.1, + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + historyId: UUID(), + queryParameters: nil + ).context.identity + } + + #expect(identity("SELECT * FROM users WHERE id = 1") + == identity("select *\n from users\n where id = 2")) + #expect(identity("SELECT * FROM users") != identity("SELECT * FROM orders")) + } + + private let connectionId = UUID() + + 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/QueryPlanDiffTests.swift b/TableProTests/Models/Query/QueryPlanDiffTests.swift new file mode 100644 index 000000000..6808f2f97 --- /dev/null +++ b/TableProTests/Models/Query/QueryPlanDiffTests.swift @@ -0,0 +1,271 @@ +// +// QueryPlanDiffTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Query plan comparison") +struct QueryPlanDiffTests { + // MARK: - Verdict + + @Test("Two runs of an unchanged plan report no measurable change") + func unchangedPlanReportsNoChange() { + let plan = QueryPlanFixture.plan(root: QueryPlanFixture.node(operation: "Seq Scan", relation: "users")) + let diff = QueryPlanDiff.compare(baseline: plan, current: plan) + + #expect(diff.verdict == .unchanged) + #expect(diff.nodeChanges.isEmpty) + #expect(!diff.hasChanges) + } + + /// Timing jitter between two identical runs is not a regression, and reporting it as one makes + /// the whole feature untrustworthy. + @Test("A timing difference inside the noise band is not a regression") + func smallTimingDifferenceIsNoise() { + let baseline = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Seq Scan", relation: "users"), + executionTime: 100 + ) + let current = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Seq Scan", relation: "users"), + executionTime: 105 + ) + + #expect(QueryPlanDiff.compare(baseline: baseline, current: current).verdict == .unchanged) + } + + @Test("A real slowdown is reported as a multiple of the baseline") + func slowdownIsReportedAsMultiple() throws { + let baseline = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Seq Scan", relation: "users"), + executionTime: 100 + ) + let current = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Seq Scan", relation: "users"), + executionTime: 400 + ) + + let verdict = QueryPlanDiff.compare(baseline: baseline, current: current).verdict + let ratio = try #require({ if case .slower(let ratio) = verdict { return ratio } else { return nil } }()) + #expect(abs(ratio - 4) < 0.001) + #expect(!verdict.headline.isEmpty) + } + + @Test("A real speed-up is reported as faster") + func speedUpIsReported() { + let baseline = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Seq Scan", relation: "users"), + executionTime: 400 + ) + let current = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Index Scan", relation: "users"), + executionTime: 100 + ) + + guard case .faster = QueryPlanDiff.compare(baseline: baseline, current: current).verdict else { + Issue.record("expected a faster verdict") + return + } + } + + @Test("A plan with no measured time falls back to shape and value verdicts") + func fallsBackToShapeVerdict() { + let baseline = QueryPlanFixture.plan(root: QueryPlanFixture.node(operation: "Seq Scan", relation: "users")) + let current = QueryPlanFixture.plan(root: QueryPlanFixture.node(operation: "Index Scan", relation: "users")) + + #expect(QueryPlanDiff.compare(baseline: baseline, current: current).verdict == .shapeChanged) + } + + // MARK: - Node matching + + /// The headline scenario: an index turns a sequential scan into an index scan. + @Test("Replacing a scan reports one removal and one addition") + func replacedScanIsOneRemovalAndOneAddition() { + let baseline = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Limit", children: [ + QueryPlanFixture.node(operation: "Seq Scan", relation: "users"), + ]) + ) + let current = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Limit", children: [ + QueryPlanFixture.node(operation: "Index Scan", relation: "users"), + ]) + ) + + let diff = QueryPlanDiff.compare(baseline: baseline, current: current) + #expect(diff.nodeChanges.filter { $0.kind == .removed }.map(\.operation) == ["Seq Scan"]) + #expect(diff.nodeChanges.filter { $0.kind == .added }.map(\.operation) == ["Index Scan"]) + } + + /// A node inserted between two that stayed must not report the whole tail as rewritten. This is + /// what a real sequence diff buys over pairing siblings by position. + @Test("Inserting a sibling leaves the others matched") + func insertedSiblingLeavesOthersMatched() { + let baseline = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Append", children: [ + QueryPlanFixture.node(operation: "Seq Scan", relation: "a"), + QueryPlanFixture.node(operation: "Seq Scan", relation: "c"), + ]) + ) + let current = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Append", children: [ + QueryPlanFixture.node(operation: "Seq Scan", relation: "a"), + QueryPlanFixture.node(operation: "Seq Scan", relation: "b"), + QueryPlanFixture.node(operation: "Seq Scan", relation: "c"), + ]) + ) + + let diff = QueryPlanDiff.compare(baseline: baseline, current: current) + #expect(diff.nodeChanges.count == 1) + #expect(diff.nodeChanges[0].kind == .added) + #expect(diff.nodeChanges[0].relation == "b") + } + + @Test("Two siblings of the same shape keep distinct identities") + func repeatedSiblingsKeepDistinctPaths() { + let plan = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Append", children: [ + QueryPlanFixture.node(operation: "Seq Scan", relation: "a"), + QueryPlanFixture.node(operation: "Seq Scan", relation: "a"), + ]) + ) + let current = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Append", children: [ + QueryPlanFixture.node(operation: "Seq Scan", relation: "a"), + ]) + ) + + let diff = QueryPlanDiff.compare(baseline: plan, current: current) + #expect(diff.nodeChanges.count == 1) + #expect(diff.nodeChanges[0].kind == .removed) + #expect(diff.nodeChanges[0].path.hasSuffix("#2")) + } + + @Test("A whole removed subtree reports every node in it") + func removedSubtreeReportsEveryNode() { + let baseline = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Append", children: [ + QueryPlanFixture.node(operation: "Hash Join", children: [ + QueryPlanFixture.node(operation: "Seq Scan", relation: "a"), + QueryPlanFixture.node(operation: "Seq Scan", relation: "b"), + ]), + ]) + ) + let current = QueryPlanFixture.plan(root: QueryPlanFixture.node(operation: "Append")) + + let diff = QueryPlanDiff.compare(baseline: baseline, current: current) + #expect(diff.nodeChanges.count == 3) + #expect(diff.nodeChanges.allSatisfy { $0.kind == .removed }) + } + + // MARK: - Field changes + + /// `QueryPlanLabels.visibleProperties` drops any value spelled `0` or `false`, which is right + /// for the node inspector and wrong here: a filter that stopped discarding rows is exactly the + /// improvement the reader came for, and hiding the zero reported it as the property vanishing. + @Test("A property that fell to zero reads as a value change, not a removal") + func propertyFallingToZeroIsNotARemoval() throws { + let baseline = QueryPlanFixture.plan( + root: QueryPlanFixture.node( + operation: "Seq Scan", + relation: "users", + properties: ["Rows Removed by Filter": "1000"] + ) + ) + let current = QueryPlanFixture.plan( + root: QueryPlanFixture.node( + operation: "Seq Scan", + relation: "users", + properties: ["Rows Removed by Filter": "0"] + ) + ) + + let diff = QueryPlanDiff.compare(baseline: baseline, current: current) + let change = try #require(diff.nodeChanges.first) + #expect(change.kind == .changed) + let field = try #require(change.fieldChanges.first { $0.field == .property("Rows Removed by Filter") }) + #expect(field.before == .text("1000")) + #expect(field.after == .text("0")) + } + + @Test("A metric change carries numbers rather than rendered strings") + func metricChangeCarriesNumbers() throws { + let baseline = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Seq Scan", relation: "users", estimatedTotalCost: 52_000_000) + ) + let current = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Seq Scan", relation: "users", estimatedTotalCost: 61_000_000) + ) + + let change = try #require(QueryPlanDiff.compare(baseline: baseline, current: current).nodeChanges.first) + let field = try #require(change.fieldChanges.first { $0.field == .metric(.estimatedTotalCost) }) + #expect(field.before == .number(52_000_000)) + #expect(field.after == .number(61_000_000)) + #expect(field.delta == 9_000_000) + } + + @Test("Noise properties stay out of the comparison") + func hiddenPropertiesAreIgnored() { + let baseline = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Seq Scan", relation: "users", properties: ["Parallel Aware": "false"]) + ) + let current = QueryPlanFixture.plan( + root: QueryPlanFixture.node(operation: "Seq Scan", relation: "users", properties: ["Parallel Aware": "true"]) + ) + + #expect(QueryPlanDiff.compare(baseline: baseline, current: current).nodeChanges.isEmpty) + } + + @Test("The summary reports plan-wide metrics even when nothing changed") + func summaryAlwaysReportsEveryMetric() { + let plan = QueryPlanFixture.plan(root: QueryPlanFixture.node(operation: "Seq Scan", relation: "users")) + let diff = QueryPlanDiff.compare(baseline: plan, current: plan) + + #expect(diff.summary.count == QueryPlanSummaryMetric.allCases.count) + #expect(diff.summary.allSatisfy { !$0.hasChange }) + } +} + +enum QueryPlanFixture { + static func node( + operation: String, + relation: String? = nil, + alias: String? = nil, + estimatedTotalCost: Double? = nil, + properties: [String: String] = [:], + children: [QueryPlanNode] = [] + ) -> QueryPlanNode { + QueryPlanNode( + operation: operation, + relation: relation, + schema: nil, + alias: alias, + estimatedStartupCost: nil, + estimatedTotalCost: estimatedTotalCost, + estimatedRows: nil, + estimatedWidth: nil, + actualStartupTime: nil, + actualTotalTime: nil, + actualRows: nil, + actualLoops: nil, + properties: properties, + children: children + ) + } + + static func plan( + root: QueryPlanNode, + planningTime: Double? = nil, + executionTime: Double? = nil + ) -> QueryPlan { + QueryPlan( + rootNode: root, + planningTime: planningTime, + executionTime: executionTime, + rawText: root.operation + ) + } +} diff --git a/TableProTests/Models/Query/QueryPlanValueFormatterTests.swift b/TableProTests/Models/Query/QueryPlanValueFormatterTests.swift new file mode 100644 index 000000000..cf9b0b5ab --- /dev/null +++ b/TableProTests/Models/Query/QueryPlanValueFormatterTests.swift @@ -0,0 +1,133 @@ +// +// QueryPlanValueFormatterTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Query plan value formatting") +struct QueryPlanValueFormatterTests { + /// A metric rendered with `String(describing:)` reaches the user as `52000000.0`, unlocalized + /// and ungrouped, directly under a summary that spells the same number `52,000,000`. + @Test("A large cost is grouped, not printed as a Swift Double") + func largeCostIsGrouped() { + let rendered = QueryPlanValueFormatter.string(.number(52_000_000), unit: .cost) + + #expect(!rendered.contains("e+")) + #expect(!rendered.hasSuffix(".0")) + #expect(rendered.contains(52_000_000.formatted(.number.precision(.fractionLength(0))))) + } + + @Test("A count carries no fraction") + func countHasNoFraction() { + #expect(QueryPlanValueFormatter.string(.number(1_234), unit: .count) + == 1_234.0.formatted(.number.precision(.fractionLength(0)))) + } + + @Test("A duration renders as a measurement rather than a bare number") + func durationRendersAsMeasurement() { + let rendered = QueryPlanValueFormatter.string(.number(1.2345), unit: .milliseconds) + #expect(rendered.contains("ms")) + } + + @Test("An absent value is not shown as zero") + func absentValueIsNotZero() { + #expect(QueryPlanValueFormatter.string(nil, unit: .count) == QueryPlanValueFormatter.absent) + } + + @Test("A property value is shown as the database spelled it") + func propertyValueIsVerbatim() { + #expect(QueryPlanValueFormatter.string(.text("Hash Right Join"), unit: nil) == "Hash Right Join") + } + + @Test("A change carries a sign and a percentage") + func changeCarriesSignAndPercentage() throws { + let change = QueryPlanFieldChange( + field: .summary(.totalCost), + before: .number(100), + after: .number(150) + ) + let rendered = try #require(QueryPlanValueFormatter.change(change)) + + #expect(rendered.hasPrefix("+")) + #expect(rendered.contains("%")) + } + + @Test("An unchanged value has no change text") + func unchangedValueHasNoChangeText() { + let change = QueryPlanFieldChange( + field: .summary(.totalCost), + before: .number(100), + after: .number(100) + ) + #expect(QueryPlanValueFormatter.change(change) == nil) + } + + /// Percent needs a baseline to be a percentage of, and everything is infinitely larger than + /// nothing. + @Test("A change from zero reports the difference without a percentage") + func changeFromZeroHasNoPercentage() throws { + let change = QueryPlanFieldChange( + field: .summary(.totalCost), + before: .number(0), + after: .number(10) + ) + let rendered = try #require(QueryPlanValueFormatter.change(change)) + #expect(!rendered.contains("%")) + } + + @Test("A text value that changed says so rather than inventing a delta") + func textChangeHasNoDelta() { + let change = QueryPlanFieldChange( + field: .property("Join Type"), + before: .text("Inner"), + after: .text("Left") + ) + #expect(QueryPlanValueFormatter.change(change) == String(localized: "Changed")) + } +} + +@Suite("EXPLAIN preamble normalization") +struct SQLPreambleNormalizerTests { + @Test("Case and spacing do not change the preamble") + func normalizesCaseAndSpacing() { + #expect(SQLPreambleNormalizer.normalize(" explain format = tree ") + == SQLPreambleNormalizer.normalize("EXPLAIN FORMAT=TREE")) + } + + @Test("Punctuation is kept, because it is part of the option") + func keepsPunctuation() { + #expect(SQLPreambleNormalizer.normalize("EXPLAIN (ANALYZE, BUFFERS)") + == "EXPLAIN ( ANALYZE , BUFFERS )") + } + + @Test("Different options stay different") + func differentOptionsStayDifferent() { + #expect(SQLPreambleNormalizer.normalize("EXPLAIN ANALYZE") + != SQLPreambleNormalizer.normalize("EXPLAIN")) + } +} + +@Suite("Plan variant keys") +struct QueryPlanVariantKeyTests { + @Test("A declared variant and a typed statement never collide") + func declaredAndTypedNeverCollide() { + #expect(QueryPlanVariantKey.declared("explain") != QueryPlanVariantKey.typed(preamble: "explain")) + } + + @Test("A key is bounded so a pathological preamble cannot grow the index entry") + func keyIsBounded() { + let key = QueryPlanVariantKey.typed(preamble: String(repeating: "OPTION ", count: 500)) + #expect(key.rawValue.count == QueryPlanVariantKey.maximumLength) + } + + /// The stored key stays readable, which is what makes it debuggable in a database browser and + /// showable in the baseline picker. + @Test("A key shows the options rather than a digest") + func keyIsReadable() { + #expect(QueryPlanVariantKey.typed(preamble: "EXPLAIN ANALYZE").displayName == "EXPLAIN ANALYZE") + #expect(QueryPlanVariantKey.declared("explain-json").displayName == "explain-json") + } +} diff --git a/TableProTests/Views/QueryPlan/QueryPlanPresentationTests.swift b/TableProTests/Views/QueryPlan/QueryPlanPresentationTests.swift index 3b727a2db..a31a81370 100644 --- a/TableProTests/Views/QueryPlan/QueryPlanPresentationTests.swift +++ b/TableProTests/Views/QueryPlan/QueryPlanPresentationTests.swift @@ -62,7 +62,8 @@ struct QueryPlanPresentationTests { for mode in QueryPlanViewMode.allCases { #expect(!mode.title.isEmpty) } - #expect(QueryPlanViewMode.allCases.count == 3) + #expect(QueryPlanViewMode.allCases.count == 4) + #expect(QueryPlanViewMode.allCases.contains(.compare)) } @Test("An explain result set is recognised by its raw text, not by a parsed plan") diff --git a/TableProUITests/QueryPlanResultUITests.swift b/TableProUITests/QueryPlanResultUITests.swift index c960e1151..11907d41d 100644 --- a/TableProUITests/QueryPlanResultUITests.swift +++ b/TableProUITests/QueryPlanResultUITests.swift @@ -34,7 +34,7 @@ final class QueryPlanResultUITests: UITestCase { let modePicker = app.radioGroups["query-plan-mode-picker"].firstMatch XCTAssertTrue( modePicker.waitToExist(timeout: 10), - "A parsed plan must offer the Diagram, Tree and Raw modes" + "A parsed plan must offer the Diagram, Tree, Raw and Compare modes" ) let canvas = app.descendants(matching: .any).matching(identifier: "query-plan-diagram").firstMatch @@ -69,6 +69,77 @@ final class QueryPlanResultUITests: UITestCase { app.typeKey(.escape, modifierFlags: []) } + /// Comparing a plan is a mode of the plan pane, not a sheet, so the editor behind it stays + /// usable and the comparison survives running the query again. The sample SQLite database makes + /// the plan change deterministic: creating an index turns a scan into a search. + func testComparingAPlanAgainstAnEarlierRun() throws { + let app = try launchWithSampleDatabase() + let subjectSQL = "SELECT * FROM Track WHERE Name = 'For Those About To Rock';" + + runExplainAction(subjectSQL, in: app) + let firstPlan = app.radioGroups["query-plan-mode-picker"].firstMatch + XCTAssertTrue(firstPlan.waitToExist(timeout: 20), "The first plan must finish before it can be a baseline") + + createPlanChangingIndex(in: app) + + runQuery("EXPLAIN QUERY PLAN \(subjectSQL)", in: app) + let modePicker = app.radioGroups["query-plan-mode-picker"].firstMatch + XCTAssertTrue(modePicker.waitToExist(timeout: 20), "The second plan must arrive") + + let compareMode = modePicker.radioButtons["Compare"] + XCTAssertTrue( + waitUntilHittable(compareMode, timeout: 10), + "A run with an earlier plan behind it must offer Compare as a mode, not a sheet" + ) + compareMode.click() + + let baselinePicker = app.popUpButtons["query-plan-baseline-picker"].firstMatch + XCTAssertTrue( + baselinePicker.waitToExist(timeout: 15), + "Compare mode must offer the earlier run as a baseline" + ) + + let verdict = app.descendants(matching: .any) + .matching(identifier: "query-plan-comparison-verdict").firstMatch + XCTAssertTrue( + verdict.waitToExist(timeout: 15), + "The comparison must lead with what happened, not with a table of numbers" + ) + + XCTAssertTrue( + waitForPredicate(timeout: 15) { + ["added", "removed", "changed"].contains { kind in + app.descendants(matching: .any) + .matching(identifier: "query-plan-comparison-change-\(kind)").firstMatch.exists + } + }, + "Creating the index must produce a visible plan-node change" + ) + + let evidence = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + evidence.name = "explain-plan-comparison" + evidence.lifetime = .keepAlways + add(evidence) + + /// The explicit Explain action and a hand-typed EXPLAIN have to land in one chain. This run + /// is the third of the same statement, so it must see both earlier ones. + runExplainAction(subjectSQL, in: app) + let laterPicker = app.radioGroups["query-plan-mode-picker"].firstMatch + XCTAssertTrue(laterPicker.waitToExist(timeout: 20)) + let laterCompare = laterPicker.radioButtons["Compare"] + XCTAssertTrue(waitUntilHittable(laterCompare, timeout: 10)) + laterCompare.click() + + let laterBaselines = app.popUpButtons["query-plan-baseline-picker"].firstMatch + XCTAssertTrue(laterBaselines.waitToExist(timeout: 15)) + laterBaselines.click() + XCTAssertTrue( + waitForPredicate(timeout: 10) { app.menuItems.count >= 2 }, + "A typed EXPLAIN and the Explain action must build one history, not two" + ) + app.typeKey(.escape, modifierFlags: []) + } + // MARK: - Helpers private func runQuery(_ sql: String, in app: XCUIApplication) { @@ -79,4 +150,40 @@ final class QueryPlanResultUITests: UITestCase { app.typeText(sql) app.typeKey(.return, modifierFlags: .command) } + + private func runExplainAction(_ sql: String, in app: XCUIApplication) { + app.typeKey("t", modifierFlags: .command) + let queryEditor = editorTextView(in: app) + XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) + queryEditor.click() + app.typeText(sql) + + let explainButton = app.buttons["Explain"].firstMatch + XCTAssertTrue(waitUntilHittable(explainButton, timeout: 10)) + explainButton.click() + } + + private func createPlanChangingIndex(in app: XCUIApplication) { + app.typeKey("t", modifierFlags: .command) + let queryEditor = editorTextView(in: app) + XCTAssertTrue(queryEditor.waitToExist(timeout: 10)) + queryEditor.click() + app.typeText( + "CREATE INDEX plan_history_track_name ON Track(Name);\n" + + "SELECT name FROM sqlite_master WHERE name = 'plan_history_track_name';" + ) + let menuBar = app.menuBars.firstMatch + menuBar.menuBarItems["Query"].click() + menuBar.menuItems["Execute All Statements"].click() + + let verificationResult = app.staticTexts["result-status-readout"].firstMatch + XCTAssertTrue( + verificationResult.waitToExist(timeout: 20), + "The test index must exist before the second plan runs" + ) + XCTAssertTrue( + app.staticTexts["plan_history_track_name"].firstMatch.waitToExist(timeout: 10), + "The verification query must find the test index" + ) + } } diff --git a/docs/features/explain-visualization.mdx b/docs/features/explain-visualization.mdx index 2f058657e..cc59892a4 100644 --- a/docs/features/explain-visualization.mdx +++ b/docs/features/explain-visualization.mdx @@ -77,6 +77,30 @@ An expandable outline with **Operation**, **Cost**, **Rows**, and **Actual Time* The original EXPLAIN output as text, with a copy button and a font size stepper. Planning and execution times, where the plan reports them, sit beside the view switcher in every mode. +## Compare + +A fourth mode beside Diagram, Tree and Raw. Pick an earlier run from the **Baseline** menu and the pane reports what changed between it and the plan on screen. + +It leads with the verdict: whether the query got faster or slower, whether the plan shape changed, or whether nothing measurable moved. Below that come cost, estimated rows, planning time, execution time and node count, then the plan steps that were added, removed or changed. A timing difference under 15% counts as noise, so two runs of an unchanged plan report no change rather than a list of near-identical numbers. + +Compare is a mode, not a sheet, so the editor stays usable. Change the query or add an index, run it again, and the comparison follows the new plan. + +If either plan cannot be read as a tree, the two runs are compared as text instead. + +### Which runs can be compared + +A run appears as a baseline when it matches the current one on statement shape, connection, database, schema, EXPLAIN variant and output format. + +Statement shape means the query normalized the way `pg_stat_statements` normalizes one: literals, comments, whitespace and identifier quoting stop mattering. Reformatting a query, or running it with a different `WHERE` value, keeps its earlier plans. Changing which tables or columns it touches does not, and neither does switching between `EXPLAIN` and `EXPLAIN ANALYZE`, which report different things. + +### What is kept + +Plans are saved alongside [Query History](/features/query-history), and pausing history stops saving them. They are not deleted with it: clearing or pruning history leaves the plans in place, because a baseline you kept is worth more than the row that produced it. + +Click the pin beside the baseline menu to keep a plan permanently. Unpinned plans are pruned oldest first once they pass 100 MB in total, on the same schedule as history cleanup. + +A plan over 2 MB is not saved, and neither is the plan of a query that carries parameters, because a database can print the parameter values into its plan output. In both cases the run keeps its history entry, and Compare says which of the two happened. + ## Node details Selecting a node in either view fills the detail panel: diff --git a/docs/features/query-history.mdx b/docs/features/query-history.mdx index 753f209f0..b18374f41 100644 --- a/docs/features/query-history.mdx +++ b/docs/features/query-history.mdx @@ -50,6 +50,12 @@ Search matches partial words, so `cust` finds `customers`. Several words must al **My Queries**, the default, is Editor and Explain. Add **Table Browsing** to see what the app sent while you clicked around a table, **Structure Changes** to review what altered a schema and when. +## Saved EXPLAIN plans + +A successful EXPLAIN also saves its plan, so a later run of the same query can be compared against it. See [EXPLAIN Visualization](/features/explain-visualization#compare). + +Pausing history stops saving plans. Clearing, pruning or deleting history does not delete them: a saved plan outlives the run that produced it, and pinning one exempts it from cleanup entirely. Unpinned plans are pruned oldest first once they pass 100 MB in total. + ## Working with entries | Action | How |