diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac0b3477..355bb1e3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - PostgreSQL array columns of a simple type, including arrays of an enum, get a list editor in the data grid. One row per element, with reordering, add and remove, and NULL per element. An empty array and a NULL column stay separate values. Enum arrays pick from the labels the type declares. Arrays of `jsonb`, `bytea` or composite types, and multi-dimensional values, keep the plain text editor. +- Query History Insights for Starter licenses, with most-run, slowest and week-over-week regression views for the current connection. (#2107) ### Fixed diff --git a/TablePro/Core/Storage/QueryHistoryManager.swift b/TablePro/Core/Storage/QueryHistoryManager.swift index ef48d35ea..d038a3633 100644 --- a/TablePro/Core/Storage/QueryHistoryManager.swift +++ b/TablePro/Core/Storage/QueryHistoryManager.swift @@ -107,6 +107,18 @@ final class QueryHistoryManager { return await storage.fetchHistory(searchText: text) } + func fetchInsights( + connectionId: UUID, + referenceDate: Date = Date(), + limit: Int = 5 + ) async -> QueryHistoryInsightSnapshot { + await storage.fetchInsights( + connectionId: connectionId, + referenceDate: referenceDate, + limit: limit + ) + } + func deleteHistory(id: UUID) async -> Bool { let success = await storage.deleteHistory(id: id) if success { diff --git a/TablePro/Core/Storage/QueryHistoryStorage.swift b/TablePro/Core/Storage/QueryHistoryStorage.swift index 428840630..465d6c8bf 100644 --- a/TablePro/Core/Storage/QueryHistoryStorage.swift +++ b/TablePro/Core/Storage/QueryHistoryStorage.swift @@ -184,6 +184,7 @@ actor QueryHistoryStorage { let historyIndexes = [ "CREATE INDEX IF NOT EXISTS idx_history_connection ON history(connection_id);", "CREATE INDEX IF NOT EXISTS idx_history_executed_at ON history(executed_at DESC);", + "CREATE INDEX IF NOT EXISTS idx_history_connection_executed_at ON history(connection_id, executed_at DESC);", ] execute(historyTable) @@ -407,6 +408,95 @@ actor QueryHistoryStorage { return entries } + func fetchInsights( + connectionId: UUID, + referenceDate: Date = Date(), + limit: Int = 5 + ) -> QueryHistoryInsightSnapshot { + guard limit > 0 else { return .empty } + + let referenceTimestamp = referenceDate.timeIntervalSince1970 + let recentStart = referenceTimestamp - QueryHistoryInsightPolicy.comparisonWindow + let previousStart = recentStart - QueryHistoryInsightPolicy.comparisonWindow + guard referenceTimestamp.isFinite, recentStart.isFinite, previousStart.isFinite else { return .empty } + + let sql = """ + SELECT query, + database_name, + COUNT(*), + SUM(CASE WHEN was_successful = 1 + AND execution_time BETWEEN 0 AND 1.7976931348623157e308 THEN 1 ELSE 0 END), + AVG(CASE WHEN was_successful = 1 + AND execution_time BETWEEN 0 AND 1.7976931348623157e308 THEN execution_time END), + MAX(CASE WHEN was_successful = 1 + AND execution_time BETWEEN 0 AND 1.7976931348623157e308 THEN execution_time END), + MAX(executed_at), + SUM(CASE WHEN was_successful = 1 + AND execution_time BETWEEN 0 AND 1.7976931348623157e308 + AND executed_at >= ? AND executed_at < ? THEN 1 ELSE 0 END), + AVG(CASE WHEN was_successful = 1 + AND execution_time BETWEEN 0 AND 1.7976931348623157e308 + AND executed_at >= ? AND executed_at < ? THEN execution_time END), + SUM(CASE WHEN was_successful = 1 + AND execution_time BETWEEN 0 AND 1.7976931348623157e308 + AND executed_at >= ? AND executed_at < ? THEN 1 ELSE 0 END), + AVG(CASE WHEN was_successful = 1 + AND execution_time BETWEEN 0 AND 1.7976931348623157e308 + AND executed_at >= ? AND executed_at < ? THEN execution_time END) + FROM history + WHERE connection_id = ? + AND executed_at < ? + AND length(trim(query, char(9) || char(10) || char(11) || char(12) || char(13) || ' ')) > 0 + GROUP BY query, database_name; + """ + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + return .empty + } + defer { sqlite3_finalize(statement) } + + sqlite3_bind_double(statement, 1, recentStart) + sqlite3_bind_double(statement, 2, referenceTimestamp) + sqlite3_bind_double(statement, 3, recentStart) + sqlite3_bind_double(statement, 4, referenceTimestamp) + sqlite3_bind_double(statement, 5, previousStart) + sqlite3_bind_double(statement, 6, recentStart) + sqlite3_bind_double(statement, 7, previousStart) + sqlite3_bind_double(statement, 8, recentStart) + + let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(statement, 9, connectionId.uuidString, -1, SQLITE_TRANSIENT) + sqlite3_bind_double(statement, 10, referenceTimestamp) + + var insights: [QueryHistoryInsight] = [] + var stepResult = sqlite3_step(statement) + while stepResult == SQLITE_ROW { + if let insight = parseInsight(from: statement, connectionId: connectionId) { + insights.append(insight) + } + stepResult = sqlite3_step(statement) + } + guard stepResult == SQLITE_DONE else { return .empty } + + let resultLimit = min(limit, QueryHistoryInsightPolicy.maximumResultLimit) + let mostRun = insights.sorted(by: frequencySort).prefix(resultLimit) + let slowest = insights + .filter { $0.successfulExecutionCount > 0 } + .sorted(by: latencySort) + .prefix(resultLimit) + let regressions = insights + .filter(isMeaningfulRegression) + .sorted(by: regressionSort) + .prefix(resultLimit) + + return QueryHistoryInsightSnapshot( + mostRun: Array(mostRun), + slowest: Array(slowest), + regressions: Array(regressions) + ) + } + func deleteHistory(id: UUID) -> Bool { let idString = id.uuidString let sql = "DELETE FROM history WHERE id = ?;" @@ -556,4 +646,92 @@ actor QueryHistoryStorage { parameterValues: parameterValues ) } + + private func parseInsight( + from statement: OpaquePointer?, + connectionId: UUID + ) -> QueryHistoryInsight? { + guard let statement, + let query = sqlite3_column_text(statement, 0).map({ String(cString: $0) }), + let databaseName = sqlite3_column_text(statement, 1).map({ String(cString: $0) }) + else { + return nil + } + + let lastExecutedTimestamp = sqlite3_column_double(statement, 6) + guard lastExecutedTimestamp.isFinite else { return nil } + + return QueryHistoryInsight( + connectionId: connectionId, + databaseName: databaseName, + query: query, + executionCount: Int(sqlite3_column_int64(statement, 2)), + successfulExecutionCount: Int(sqlite3_column_int64(statement, 3)), + averageExecutionTime: nonnegativeFiniteColumn(statement, index: 4), + maximumExecutionTime: nonnegativeFiniteColumn(statement, index: 5), + lastExecutedAt: Date(timeIntervalSince1970: lastExecutedTimestamp), + recentExecutionCount: Int(sqlite3_column_int64(statement, 7)), + recentAverageExecutionTime: nonnegativeFiniteColumn(statement, index: 8), + previousExecutionCount: Int(sqlite3_column_int64(statement, 9)), + previousAverageExecutionTime: nonnegativeFiniteColumn(statement, index: 10) + ) + } + + private func nonnegativeFiniteColumn(_ statement: OpaquePointer, index: Int32) -> TimeInterval { + guard sqlite3_column_type(statement, index) != SQLITE_NULL else { return 0 } + let value = sqlite3_column_double(statement, index) + return value.isFinite && value >= 0 ? value : 0 + } + + private func frequencySort(_ lhs: QueryHistoryInsight, _ rhs: QueryHistoryInsight) -> Bool { + if lhs.executionCount != rhs.executionCount { + return lhs.executionCount > rhs.executionCount + } + if lhs.lastExecutedAt != rhs.lastExecutedAt { + return lhs.lastExecutedAt > rhs.lastExecutedAt + } + return insightTieBreak(lhs, rhs) + } + + private func latencySort(_ lhs: QueryHistoryInsight, _ rhs: QueryHistoryInsight) -> Bool { + if lhs.averageExecutionTime != rhs.averageExecutionTime { + return lhs.averageExecutionTime > rhs.averageExecutionTime + } + if lhs.successfulExecutionCount != rhs.successfulExecutionCount { + return lhs.successfulExecutionCount > rhs.successfulExecutionCount + } + return insightTieBreak(lhs, rhs) + } + + private func regressionSort(_ lhs: QueryHistoryInsight, _ rhs: QueryHistoryInsight) -> Bool { + if lhs.slowdownRatio != rhs.slowdownRatio { + return lhs.slowdownRatio > rhs.slowdownRatio + } + let lhsIncrease = lhs.recentAverageExecutionTime - lhs.previousAverageExecutionTime + let rhsIncrease = rhs.recentAverageExecutionTime - rhs.previousAverageExecutionTime + if lhsIncrease != rhsIncrease { + return lhsIncrease > rhsIncrease + } + return insightTieBreak(lhs, rhs) + } + + private func insightTieBreak(_ lhs: QueryHistoryInsight, _ rhs: QueryHistoryInsight) -> Bool { + if lhs.databaseName != rhs.databaseName { + return lhs.databaseName < rhs.databaseName + } + return lhs.query < rhs.query + } + + private func isMeaningfulRegression(_ insight: QueryHistoryInsight) -> Bool { + guard insight.recentExecutionCount >= QueryHistoryInsightPolicy.minimumRegressionSamples, + insight.previousExecutionCount >= QueryHistoryInsightPolicy.minimumRegressionSamples, + insight.previousAverageExecutionTime > 0, + insight.slowdownRatio >= QueryHistoryInsightPolicy.minimumSlowdownRatio + else { + return false + } + + return insight.recentAverageExecutionTime - insight.previousAverageExecutionTime + >= QueryHistoryInsightPolicy.minimumSlowdownDuration + } } diff --git a/TablePro/Models/Query/QueryHistoryInsights.swift b/TablePro/Models/Query/QueryHistoryInsights.swift new file mode 100644 index 000000000..1f57176d3 --- /dev/null +++ b/TablePro/Models/Query/QueryHistoryInsights.swift @@ -0,0 +1,100 @@ +import Foundation + +struct QueryHistoryInsight: Hashable, Identifiable { + struct ID: Hashable { + let connectionId: UUID + let databaseName: String + let query: String + } + + let connectionId: UUID + let databaseName: String + let query: String + let executionCount: Int + let successfulExecutionCount: Int + let averageExecutionTime: TimeInterval + let maximumExecutionTime: TimeInterval + let lastExecutedAt: Date + let recentExecutionCount: Int + let recentAverageExecutionTime: TimeInterval + let previousExecutionCount: Int + let previousAverageExecutionTime: TimeInterval + + var id: ID { + ID(connectionId: connectionId, databaseName: databaseName, query: query) + } + + var slowdownRatio: Double { + guard previousAverageExecutionTime > 0, + previousAverageExecutionTime.isFinite, + recentAverageExecutionTime >= 0, + recentAverageExecutionTime.isFinite + else { + return 0 + } + let ratio = recentAverageExecutionTime / previousAverageExecutionTime + return ratio.isFinite ? ratio : .greatestFiniteMagnitude + } + + var slowdownPercentage: Int { + let percentage = (slowdownRatio - 1) * 100 + guard percentage > 0 else { return 0 } + guard percentage.isFinite else { return Int(Int32.max) } + return Int(min(percentage.rounded(), Double(Int32.max))) + } +} + +struct QueryHistoryInsightSnapshot: Equatable { + static let empty = QueryHistoryInsightSnapshot(mostRun: [], slowest: [], regressions: []) + + let mostRun: [QueryHistoryInsight] + let slowest: [QueryHistoryInsight] + let regressions: [QueryHistoryInsight] + + var isEmpty: Bool { + mostRun.isEmpty && slowest.isEmpty && regressions.isEmpty + } + + func insights(in category: QueryHistoryInsightCategory) -> [QueryHistoryInsight] { + switch category { + case .mostRun: + return mostRun + case .slowest: + return slowest + case .regression: + return regressions + } + } + + func insight(for selection: QueryHistoryInsightSelection) -> QueryHistoryInsight? { + insights(in: selection.category).first { $0.id == selection.insightId } + } +} + +enum QueryHistoryInsightCategory: Hashable { + case mostRun + case slowest + case regression +} + +struct QueryHistoryInsightSelection: Hashable { + let category: QueryHistoryInsightCategory + let insightId: QueryHistoryInsight.ID + + init(category: QueryHistoryInsightCategory, insightId: QueryHistoryInsight.ID) { + self.category = category + self.insightId = insightId + } + + init(category: QueryHistoryInsightCategory, insight: QueryHistoryInsight) { + self.init(category: category, insightId: insight.id) + } +} + +enum QueryHistoryInsightPolicy { + static let comparisonWindow: TimeInterval = 7 * 24 * 60 * 60 + static let minimumRegressionSamples = 3 + static let minimumSlowdownRatio = 1.25 + static let minimumSlowdownDuration: TimeInterval = 0.05 + static let maximumResultLimit = 50 +} diff --git a/TablePro/Models/Settings/ProFeature.swift b/TablePro/Models/Settings/ProFeature.swift index 8c44a0aaa..16dfecc53 100644 --- a/TablePro/Models/Settings/ProFeature.swift +++ b/TablePro/Models/Settings/ProFeature.swift @@ -13,6 +13,7 @@ internal enum ProFeature: String, CaseIterable { case encryptedExport case envVarReferences case linkedFolders + case queryHistoryInsights case teamCatalog case teamLibrary @@ -26,6 +27,8 @@ internal enum ProFeature: String, CaseIterable { return String(localized: "Environment Variables") case .linkedFolders: return String(localized: "Linked Folders") + case .queryHistoryInsights: + return String(localized: "Query History Insights") case .teamCatalog: return String(localized: "Team Catalog") case .teamLibrary: @@ -43,6 +46,8 @@ internal enum ProFeature: String, CaseIterable { return "dollarsign.square" case .linkedFolders: return "folder.badge.gearshape" + case .queryHistoryInsights: + return "chart.line.uptrend.xyaxis" case .teamCatalog: return "person.2.fill" case .teamLibrary: @@ -60,6 +65,8 @@ internal enum ProFeature: String, CaseIterable { return String(localized: "Use environment variables in connection fields.") case .linkedFolders: return String(localized: "Watch shared folders for connection files.") + case .queryHistoryInsights: + return String(localized: "See your most-run, slowest, and regressing queries on this connection.") case .teamCatalog: return String(localized: "Publish connections to a shared folder your team reads from. Passwords are never included.") case .teamLibrary: @@ -70,7 +77,7 @@ internal enum ProFeature: String, CaseIterable { /// The lowest license tier that unlocks this feature. var requiredTier: LicenseTier { switch self { - case .iCloudSync, .encryptedExport, .envVarReferences, .linkedFolders: + case .iCloudSync, .encryptedExport, .envVarReferences, .linkedFolders, .queryHistoryInsights: return .starter case .teamCatalog, .teamLibrary: return .team diff --git a/TablePro/Views/Editor/HistoryPanelView.swift b/TablePro/Views/Editor/HistoryPanelView.swift index 452e2bcd4..58d991d32 100644 --- a/TablePro/Views/Editor/HistoryPanelView.swift +++ b/TablePro/Views/Editor/HistoryPanelView.swift @@ -16,6 +16,7 @@ struct HistoryPanelView: View { let connectionId: UUID // MARK: - State + @State private var mode: HistoryPanelMode = .history @State private var selectedEntryID: UUID? @State private var searchText = "" @State private var dateFilter: UIDateFilter = .all @@ -39,12 +40,25 @@ struct HistoryPanelView: View { // MARK: - Body var body: some View { - HSplitView { - historyList - .frame(minWidth: 200, idealWidth: 250) + VStack(spacing: 0) { + Picker(String(localized: "History View"), selection: $mode) { + Text("History").tag(HistoryPanelMode.history) + Text("Insights").tag(HistoryPanelMode.insights) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 220) + .padding(8) + .accessibilityIdentifier("history-panel-mode-picker") - queryPreview - .frame(minWidth: 300) + Divider() + + switch mode { + case .history: + historyContent + case .insights: + QueryHistoryInsightsView(connectionId: connectionId) + } } .onAppear { restoreFilterState() @@ -65,6 +79,23 @@ struct HistoryPanelView: View { } } +private enum HistoryPanelMode: Hashable { + case history + case insights +} + +private extension HistoryPanelView { + var historyContent: some View { + HSplitView { + historyList + .frame(minWidth: 200, idealWidth: 250) + + queryPreview + .frame(minWidth: 300) + } + } +} + // MARK: - History List (Left Pane) private extension HistoryPanelView { diff --git a/TablePro/Views/Editor/QueryHistoryInsightsView.swift b/TablePro/Views/Editor/QueryHistoryInsightsView.swift new file mode 100644 index 000000000..36ff9183b --- /dev/null +++ b/TablePro/Views/Editor/QueryHistoryInsightsView.swift @@ -0,0 +1,396 @@ +import SwiftUI + +struct QueryHistoryInsightsView: View { + let connectionId: UUID + + var body: some View { + QueryHistoryInsightsPanel( + snapshot: snapshot, + selection: $selection, + hasLoaded: hasLoaded, + loadInEditor: { actions?.loadQueryIntoEditor($0) } + ) + .requiresPro(.queryHistoryInsights) + .task(id: connectionId) { + pendingReload?.cancel() + if loadedConnectionId != connectionId { + snapshot = .empty + selection = nil + hasLoaded = false + loadedConnectionId = connectionId + } + await load() + } + .onReceive(AppEvents.shared.queryHistoryDidUpdate) { updatedConnectionId in + guard updatedConnectionId == nil || updatedConnectionId == connectionId else { + return + } + scheduleReload() + } + .onDisappear { + pendingReload?.cancel() + } + .accessibilityIdentifier("query-history-insights-view") + } + + private static let reloadCoalescingDelay = Duration.milliseconds(250) + + @State private var snapshot = QueryHistoryInsightSnapshot.empty + @State private var selection: QueryHistoryInsightSelection? + @State private var hasLoaded = false + @State private var loadedConnectionId: UUID? + @State private var pendingReload: Task? + @FocusedValue(\.commandActions) private var actions + + @MainActor + private func scheduleReload() { + pendingReload?.cancel() + pendingReload = Task { @MainActor in + try? await Task.sleep(for: Self.reloadCoalescingDelay) + guard !Task.isCancelled else { return } + await load() + } + } + + @MainActor + private func load() async { + let loadedSnapshot = await QueryHistoryManager.shared.fetchInsights(connectionId: connectionId) + guard !Task.isCancelled, loadedConnectionId == connectionId else { + return + } + + snapshot = loadedSnapshot + hasLoaded = true + if let selection, loadedSnapshot.insight(for: selection) == nil { + self.selection = nil + } + } +} + +private struct QueryHistoryInsightsPanel: View { + let snapshot: QueryHistoryInsightSnapshot + @Binding var selection: QueryHistoryInsightSelection? + + let hasLoaded: Bool + let loadInEditor: (String) -> Void + + var body: some View { + if !hasLoaded { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if snapshot.isEmpty { + ContentUnavailableView( + String(localized: "No Query Insights Yet"), + systemImage: "chart.line.uptrend.xyaxis", + description: Text(String(localized: "Run queries on this connection to build local insights.")) + ) + } else { + HSplitView { + insightList + .frame(minWidth: 260, idealWidth: 320) + + insightDetail + .frame(minWidth: 300) + } + } + } + + private var insightList: some View { + List(selection: $selection) { + insightSection( + title: String(localized: "Most Run"), + emptyMessage: String(localized: "No queries recorded."), + category: .mostRun, + insights: snapshot.mostRun + ) + insightSection( + title: String(localized: "Slowest"), + emptyMessage: String(localized: "No successful queries recorded."), + category: .slowest, + insights: snapshot.slowest + ) + insightSection( + title: String(localized: "Slower Than Last Week"), + emptyMessage: String(localized: "No meaningful regressions detected."), + category: .regression, + insights: snapshot.regressions + ) + } + .listStyle(.sidebar) + .environment(\.defaultMinListRowHeight, 48) + .onCopyCommand { + copySelectedQuery() + return [] + } + .accessibilityIdentifier("query-history-insights-list") + } + + private func copySelectedQuery() { + guard let selection, let insight = snapshot.insight(for: selection) else { return } + ClipboardService.shared.writeText(insight.query) + } + + @ViewBuilder + private var insightDetail: some View { + if let selection, let insight = snapshot.insight(for: selection) { + VStack(spacing: 0) { + HighlightedSQLTextView( + sql: insight.query.hasSuffix(";") + ? insight.query + : insight.query + ";", + databaseType: insight.query.trimmingCharacters(in: .whitespaces) + .hasPrefix("db.") ? .mongodb : .mysql + ) + .background(Color(nsColor: ThemeEngine.shared.colors.editor.background)) + + Divider() + + QueryHistoryInsightMetadata(category: selection.category, insight: insight) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + + Divider() + + HStack { + Button(String(localized: "Copy Query")) { + ClipboardService.shared.writeText(insight.query) + } + .controlSize(.small) + + Spacer() + + Button(String(localized: "Load in Editor")) { + loadInEditor(insight.query) + } + .controlSize(.small) + .keyboardShortcut(.defaultAction) + } + .padding(12) + } + } else { + ContentUnavailableView( + String(localized: "Select an Insight"), + systemImage: "chart.line.uptrend.xyaxis", + description: Text(String(localized: "Choose a query to inspect its execution history.")) + ) + } + } + + @ViewBuilder + private func rowMenu(for insight: QueryHistoryInsight) -> some View { + Button { + ClipboardService.shared.writeText(insight.query) + } label: { + Label(String(localized: "Copy Query"), systemImage: "doc.on.doc") + } + + Button { + loadInEditor(insight.query) + } label: { + Label(String(localized: "Load in Editor"), systemImage: "square.and.pencil") + } + } + + private func insightSection( + title: String, + emptyMessage: String, + category: QueryHistoryInsightCategory, + insights: [QueryHistoryInsight] + ) -> some View { + Section(title) { + if insights.isEmpty { + Text(emptyMessage) + .font(.callout) + .foregroundStyle(.tertiary) + } else { + ForEach(insights) { insight in + QueryHistoryInsightRow(category: category, insight: insight) + .tag(QueryHistoryInsightSelection(category: category, insight: insight)) + .contextMenu { rowMenu(for: insight) } + } + } + } + } +} + +private struct QueryHistoryInsightRow: View { + let category: QueryHistoryInsightCategory + let insight: QueryHistoryInsight + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(insight.query) + .font(.system(.callout, design: .monospaced)) + .lineLimit(1) + + HStack(spacing: 8) { + Text(insight.databaseName) + .lineLimit(1) + + Spacer(minLength: 8) + + Text(metric) + .monospacedDigit() + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 3) + .accessibilityElement(children: .combine) + } + + private var metric: String { + switch category { + case .mostRun: + QueryHistoryInsightFormatting.runCount(insight.executionCount) + case .slowest: + QueryHistoryInsightFormatting.duration(insight.averageExecutionTime) + case .regression: + String(format: String(localized: "+%d%%"), insight.slowdownPercentage) + } + } +} + +private struct QueryHistoryInsightMetadata: View { + let category: QueryHistoryInsightCategory + let insight: QueryHistoryInsight + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(insight.databaseName) + .font(.subheadline.weight(.medium)) + + Text(primaryMetric) + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(secondaryMetric) + .font(.subheadline) + .foregroundStyle(.tertiary) + } + } + + private var primaryMetric: String { + switch category { + case .mostRun: + guard insight.successfulExecutionCount > 0 else { + return String( + format: String(localized: "%@, no successful runs"), + QueryHistoryInsightFormatting.runCount(insight.executionCount) + ) + } + return String( + format: String(localized: "%@, %@ average"), + QueryHistoryInsightFormatting.runCount(insight.executionCount), + QueryHistoryInsightFormatting.duration(insight.averageExecutionTime) + ) + case .slowest: + return String( + format: String(localized: "%@ average, %@ maximum"), + QueryHistoryInsightFormatting.duration(insight.averageExecutionTime), + QueryHistoryInsightFormatting.duration(insight.maximumExecutionTime) + ) + case .regression: + return String( + format: String(localized: "%@ recent, %@ previous"), + QueryHistoryInsightFormatting.duration(insight.recentAverageExecutionTime), + QueryHistoryInsightFormatting.duration(insight.previousAverageExecutionTime) + ) + } + } + + private var secondaryMetric: String { + switch category { + case .mostRun, .slowest: + let executedAt = insight.lastExecutedAt.formatted(date: .abbreviated, time: .shortened) + return String(format: String(localized: "Last run: %@"), executedAt) + case .regression: + return String( + format: String(localized: "%@ recent, %@ previous"), + QueryHistoryInsightFormatting.runCount(insight.recentExecutionCount), + QueryHistoryInsightFormatting.runCount(insight.previousExecutionCount) + ) + } + } +} + +private enum QueryHistoryInsightFormatting { + static func duration(_ duration: TimeInterval) -> String { + if duration < 1 { + return String(format: String(localized: "%.0f ms"), duration * 1_000) + } + return String(format: String(localized: "%.2f s"), duration) + } + + static func runCount(_ count: Int) -> String { + if count == 1 { + return String(localized: "1 run") + } + return String(format: String(localized: "%d runs"), count) + } +} + +#if DEBUG +private struct QueryHistoryInsightsViewPreview: View { + init() { + let connectionId = UUID() + let frequent = QueryHistoryInsight( + connectionId: connectionId, + databaseName: "analytics", + query: "SELECT country, COUNT(*) FROM customers GROUP BY country", + executionCount: 42, + successfulExecutionCount: 42, + averageExecutionTime: 0.18, + maximumExecutionTime: 0.42, + lastExecutedAt: Date(), + recentExecutionCount: 8, + recentAverageExecutionTime: 0.18, + previousExecutionCount: 7, + previousAverageExecutionTime: 0.16 + ) + let regression = QueryHistoryInsight( + connectionId: connectionId, + databaseName: "analytics", + query: "SELECT * FROM events WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'", + executionCount: 18, + successfulExecutionCount: 18, + averageExecutionTime: 1.84, + maximumExecutionTime: 3.12, + lastExecutedAt: Date(), + recentExecutionCount: 9, + recentAverageExecutionTime: 2.4, + previousExecutionCount: 9, + previousAverageExecutionTime: 1.28 + ) + let snapshot = QueryHistoryInsightSnapshot( + mostRun: [frequent, regression], + slowest: [regression, frequent], + regressions: [regression] + ) + self.snapshot = snapshot + _selection = State( + initialValue: QueryHistoryInsightSelection(category: .regression, insight: regression) + ) + } + + var body: some View { + QueryHistoryInsightsPanel( + snapshot: snapshot, + selection: $selection, + hasLoaded: true, + loadInEditor: { _ in } + ) + .frame(width: 760, height: 440) + .background(Color(nsColor: .windowBackgroundColor)) + .environment(\.controlActiveState, .active) + } + + @State private var selection: QueryHistoryInsightSelection? + + private let snapshot: QueryHistoryInsightSnapshot +} + +#Preview { + QueryHistoryInsightsViewPreview() +} +#endif diff --git a/TableProTests/Core/Storage/QueryHistoryInsightsTests.swift b/TableProTests/Core/Storage/QueryHistoryInsightsTests.swift new file mode 100644 index 000000000..8d6093b26 --- /dev/null +++ b/TableProTests/Core/Storage/QueryHistoryInsightsTests.swift @@ -0,0 +1,206 @@ +import Foundation +@testable import TablePro +import Testing + +@Suite("QueryHistoryInsights") +struct QueryHistoryInsightsTests { + @Test("Empty history returns an empty snapshot") + func emptyHistoryReturnsEmptySnapshot() async { + let snapshot = await storage.fetchInsights(connectionId: connectionId, referenceDate: referenceDate) + #expect(snapshot == .empty) + } + + @Test("Frequency includes failures while latency uses successful runs") + func frequencyAndLatencyUseCorrectPopulations() async throws { + await add(query: "SELECT popular", offset: -100, duration: 0.2) + await add(query: "SELECT popular", offset: -90, duration: 0.4) + await add(query: "SELECT popular", offset: -80, duration: 20, successful: false) + await add(query: "SELECT slower", offset: -70, duration: 1.2) + + let snapshot = await storage.fetchInsights(connectionId: connectionId, referenceDate: referenceDate) + let popular = try #require(snapshot.mostRun.first { $0.query == "SELECT popular" }) + let slower = try #require(snapshot.slowest.first) + + #expect(popular.executionCount == 3) + #expect(popular.successfulExecutionCount == 2) + #expect(abs(popular.averageExecutionTime - 0.3) < 0.000_001) + #expect(popular.maximumExecutionTime == 0.4) + #expect(slower.query == "SELECT slower") + #expect(slower.averageExecutionTime == 1.2) + } + + @Test("Insights stay within one connection and one database") + func connectionAndDatabaseScopesStaySeparate() async { + let otherConnectionId = UUID() + await add(query: "SELECT scoped", databaseName: "primary", offset: -100, duration: 0.2) + await add(query: "SELECT scoped", databaseName: "analytics", offset: -90, duration: 0.3) + await add( + query: "SELECT scoped", + databaseName: "primary", + offset: -80, + duration: 10, + connectionId: otherConnectionId + ) + + let snapshot = await storage.fetchInsights(connectionId: connectionId, referenceDate: referenceDate) + let scoped = snapshot.mostRun.filter { $0.query == "SELECT scoped" } + + #expect(scoped.count == 2) + #expect(Set(scoped.map(\.databaseName)) == ["primary", "analytics"]) + #expect(scoped.allSatisfy { $0.connectionId == connectionId && $0.executionCount == 1 }) + } + + @Test("Regression requires both sample windows and meaningful slowdown") + func regressionPolicyFiltersNoise() async throws { + for offset in [-1_000_000.0, -950_000, -900_000] { + await add(query: "SELECT regressed", offset: offset, duration: 0.2) + await add(query: "SELECT small_delta", offset: offset, duration: 0.1) + await add(query: "SELECT small_ratio", offset: offset, duration: 1.0) + } + for offset in [-300_000.0, -200_000, -100_000] { + await add(query: "SELECT regressed", offset: offset, duration: 0.4) + await add(query: "SELECT small_delta", offset: offset, duration: 0.13) + await add(query: "SELECT small_ratio", offset: offset, duration: 1.2) + } + for offset in [-1_000_000.0, -900_000] { + await add(query: "SELECT undersampled", offset: offset, duration: 0.1) + } + for offset in [-300_000.0, -100_000] { + await add(query: "SELECT undersampled", offset: offset, duration: 1.0) + } + await add(query: "SELECT regressed", offset: -50_000, duration: 100, successful: false) + + let snapshot = await storage.fetchInsights(connectionId: connectionId, referenceDate: referenceDate) + let regression = try #require(snapshot.regressions.first) + + #expect(snapshot.regressions.count == 1) + #expect(regression.query == "SELECT regressed") + #expect(regression.recentExecutionCount == 3) + #expect(regression.previousExecutionCount == 3) + #expect(regression.slowdownPercentage == 100) + } + + @Test("Comparison windows are half open and future entries are excluded") + func comparisonBoundariesAreStable() async throws { + let window = QueryHistoryInsightPolicy.comparisonWindow + await add(query: "SELECT boundary", offset: -(2 * window), duration: 0.2) + await add(query: "SELECT boundary", offset: -window, duration: 0.4) + await add(query: "SELECT boundary", offset: -1, duration: 0.4) + await add(query: "SELECT boundary", offset: 0, duration: 50) + await add(query: "SELECT boundary", offset: 1, duration: 50) + + let snapshot = await storage.fetchInsights(connectionId: connectionId, referenceDate: referenceDate) + let boundary = try #require(snapshot.mostRun.first { $0.query == "SELECT boundary" }) + + #expect(boundary.executionCount == 3) + #expect(boundary.previousExecutionCount == 1) + #expect(boundary.recentExecutionCount == 2) + } + + @Test("Blank queries and invalid durations do not create latency insights") + func unusableRowsStayOutOfLatencyInsights() async { + await add(query: " \n", offset: -10, duration: 10) + await add(query: "SELECT negative", offset: -9, duration: -1) + await add(query: "SELECT infinite", offset: -8, duration: .infinity) + await add(query: "SELECT valid", offset: -8, duration: 0.2) + + let snapshot = await storage.fetchInsights(connectionId: connectionId, referenceDate: referenceDate) + + #expect(snapshot.mostRun.contains { $0.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } == false) + #expect(snapshot.mostRun.contains { $0.query == "SELECT negative" }) + #expect(snapshot.slowest.contains { $0.query == "SELECT negative" } == false) + #expect(snapshot.mostRun.contains { $0.query == "SELECT infinite" }) + #expect(snapshot.slowest.contains { $0.query == "SELECT infinite" } == false) + #expect(snapshot.slowest.map(\.query) == ["SELECT valid"]) + } + + @Test("Bound parameters keep query text from changing the history schema") + func queryTextCannotChangeHistorySchema() async { + let hostileQuery = "SELECT 'x'); DROP TABLE history; --" + await add(query: hostileQuery, offset: -10, duration: 0.1) + + let firstSnapshot = await storage.fetchInsights(connectionId: connectionId, referenceDate: referenceDate) + await add(query: "SELECT still_here", offset: -5, duration: 0.2) + let entries = await storage.fetchHistory(connectionId: connectionId) + + #expect(firstSnapshot.mostRun.contains { $0.query == hostileQuery }) + #expect(entries.count == 2) + } + + @Test("Limits reject nonpositive values and cap oversized requests") + func resultLimitIsBounded() async { + for index in 0..<55 { + await add(query: "SELECT limit_\(index)", offset: -Double(index + 1), duration: Double(index + 1)) + } + + let rejected = await storage.fetchInsights( + connectionId: connectionId, + referenceDate: referenceDate, + limit: -1 + ) + let capped = await storage.fetchInsights( + connectionId: connectionId, + referenceDate: referenceDate, + limit: .max + ) + + #expect(rejected == .empty) + #expect(capped.mostRun.count == QueryHistoryInsightPolicy.maximumResultLimit) + #expect(capped.slowest.count == QueryHistoryInsightPolicy.maximumResultLimit) + } + + @Test("An invalid reference date returns no insights") + func invalidReferenceDateReturnsEmptySnapshot() async { + let snapshot = await storage.fetchInsights( + connectionId: connectionId, + referenceDate: Date(timeIntervalSince1970: .infinity) + ) + #expect(snapshot == .empty) + } + + @Test("Extreme latency ratios stay representable") + func extremeLatencyRatioStaysRepresentable() { + let insight = QueryHistoryInsight( + connectionId: connectionId, + databaseName: "analytics", + query: "SELECT extreme", + executionCount: 6, + successfulExecutionCount: 6, + averageExecutionTime: 1, + maximumExecutionTime: 1, + lastExecutedAt: referenceDate, + recentExecutionCount: 3, + recentAverageExecutionTime: .greatestFiniteMagnitude, + previousExecutionCount: 3, + previousAverageExecutionTime: .leastNonzeroMagnitude + ) + + #expect(insight.slowdownRatio == .greatestFiniteMagnitude) + #expect(insight.slowdownPercentage == Int(Int32.max)) + } + + private let storage = QueryHistoryStorageTests.makeIsolatedStorage() + private let connectionId = UUID() + private let referenceDate = Date(timeIntervalSince1970: 2_000_000_000) + + private func add( + query: String, + databaseName: String = "analytics", + offset: TimeInterval, + duration: TimeInterval, + successful: Bool = true, + connectionId: UUID? = nil + ) async { + let entry = QueryHistoryEntry( + query: query, + connectionId: connectionId ?? self.connectionId, + databaseName: databaseName, + executedAt: referenceDate.addingTimeInterval(offset), + executionTime: duration, + rowCount: 1, + wasSuccessful: successful, + errorMessage: successful ? nil : "failed" + ) + #expect(await storage.addHistory(entry)) + } +} diff --git a/TableProTests/Models/LicenseTierTests.swift b/TableProTests/Models/LicenseTierTests.swift index 343ef0b68..3b0d4670c 100644 --- a/TableProTests/Models/LicenseTierTests.swift +++ b/TableProTests/Models/LicenseTierTests.swift @@ -132,6 +132,7 @@ struct LicenseTierTests { #expect(ProFeature.encryptedExport.requiredTier == .starter) #expect(ProFeature.envVarReferences.requiredTier == .starter) #expect(ProFeature.linkedFolders.requiredTier == .starter) + #expect(ProFeature.queryHistoryInsights.requiredTier == .starter) #expect(ProFeature.teamCatalog.requiredTier == .team) } } diff --git a/TableProTests/Views/History/HistoryPanelSelectionTests.swift b/TableProTests/Views/History/HistoryPanelSelectionTests.swift index f81a152e3..1a06fb9f6 100644 --- a/TableProTests/Views/History/HistoryPanelSelectionTests.swift +++ b/TableProTests/Views/History/HistoryPanelSelectionTests.swift @@ -36,3 +36,93 @@ struct HistoryPanelSelectionTests { #expect(HistoryPanelView.selectionIndex(afterDeleting: -1, remainingCount: 4) == nil) } } + +@Suite("Query History Insights selection refresh") +struct QueryHistoryInsightsSelectionTests { + @Test("A selection identifies a query without capturing its metrics") + func selectionIdentityIgnoresMetrics() { + let original = makeInsight(executionCount: 3, averageExecutionTime: 0.2) + let updated = makeInsight(executionCount: 4, averageExecutionTime: 0.3) + + let selection = QueryHistoryInsightSelection(category: .mostRun, insight: original) + let afterReload = QueryHistoryInsightSelection(category: .mostRun, insight: updated) + + #expect(selection == afterReload) + #expect(selection.hashValue == afterReload.hashValue) + } + + @Test("The same query in two categories is two distinct selections") + func selectionIsScopedToItsCategory() { + let insight = makeInsight(executionCount: 3, averageExecutionTime: 0.2) + + let mostRun = QueryHistoryInsightSelection(category: .mostRun, insight: insight) + let slowest = QueryHistoryInsightSelection(category: .slowest, insight: insight) + + #expect(mostRun != slowest) + } + + @Test("Resolving a selection reads the metrics of the newest snapshot") + func resolvingSelectionAdoptsRefreshedMetrics() throws { + let original = makeInsight(executionCount: 3, averageExecutionTime: 0.2) + let updated = makeInsight(executionCount: 4, averageExecutionTime: 0.3) + let selection = QueryHistoryInsightSelection(category: .mostRun, insight: original) + let snapshot = QueryHistoryInsightSnapshot(mostRun: [updated], slowest: [], regressions: []) + + let resolved = try #require(snapshot.insight(for: selection)) + + #expect(resolved.executionCount == 4) + #expect(resolved.averageExecutionTime == 0.3) + } + + @Test("A selection missing from its original category no longer resolves") + func selectionMissingFromCategoryDoesNotResolve() { + let insight = makeInsight(executionCount: 3, averageExecutionTime: 0.2) + let selection = QueryHistoryInsightSelection(category: .mostRun, insight: insight) + let snapshot = QueryHistoryInsightSnapshot(mostRun: [], slowest: [insight], regressions: []) + + #expect(snapshot.insight(for: selection) == nil) + } + + @Test("Each category resolves against its own list") + func categoriesResolveAgainstTheirOwnList() throws { + let frequent = makeInsight(executionCount: 9, averageExecutionTime: 0.1, query: "SELECT frequent") + let slow = makeInsight(executionCount: 2, averageExecutionTime: 4.5, query: "SELECT slow") + let regressed = makeInsight(executionCount: 6, averageExecutionTime: 1.0, query: "SELECT regressed") + let snapshot = QueryHistoryInsightSnapshot( + mostRun: [frequent], + slowest: [slow], + regressions: [regressed] + ) + + #expect(snapshot.insights(in: .mostRun) == [frequent]) + #expect(snapshot.insights(in: .slowest) == [slow]) + #expect(snapshot.insights(in: .regression) == [regressed]) + + let slowestSelection = QueryHistoryInsightSelection(category: .slowest, insight: slow) + let resolvedSlowest = try #require(snapshot.insight(for: slowestSelection)) + #expect(resolvedSlowest.query == "SELECT slow") + } + + private func makeInsight( + executionCount: Int, + averageExecutionTime: TimeInterval, + query: String = "SELECT 1" + ) -> QueryHistoryInsight { + QueryHistoryInsight( + connectionId: connectionId, + databaseName: "analytics", + query: query, + executionCount: executionCount, + successfulExecutionCount: executionCount, + averageExecutionTime: averageExecutionTime, + maximumExecutionTime: averageExecutionTime, + lastExecutedAt: Date(timeIntervalSince1970: Double(executionCount)), + recentExecutionCount: 0, + recentAverageExecutionTime: 0, + previousExecutionCount: 0, + previousAverageExecutionTime: 0 + ) + } + + private let connectionId = UUID() +} diff --git a/TableProUITests/QueryHistoryInsightsUITests.swift b/TableProUITests/QueryHistoryInsightsUITests.swift new file mode 100644 index 000000000..ae236375b --- /dev/null +++ b/TableProUITests/QueryHistoryInsightsUITests.swift @@ -0,0 +1,33 @@ +import XCTest + +final class QueryHistoryInsightsUITests: XCTestCase { + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + XCUIApplication().terminate() + } + + func testInsightsModeIsVisible() { + let app = XCUIApplication() + app.launchEnvironment["TABLEPRO_UI_TESTING"] = "1" + app.launch() + + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitForExistence(timeout: 10)) + menuBar.menuBarItems["Help"].click() + let openSample = menuBar.menuItems["Open Sample Database"] + XCTAssertTrue(openSample.waitForExistence(timeout: 5)) + openSample.click() + + XCTAssertTrue(app.textViews.firstMatch.waitForExistence(timeout: 15)) + app.typeKey("y", modifierFlags: .command) + + let modePicker = app.segmentedControls["history-panel-mode-picker"] + XCTAssertTrue(modePicker.waitForExistence(timeout: 10)) + modePicker.buttons["Insights"].click() + + XCTAssertTrue(app.otherElements["query-history-insights-view"].waitForExistence(timeout: 5)) + } +} diff --git a/docs/features/query-history.mdx b/docs/features/query-history.mdx index bc7d84d9d..bdf3fec33 100644 --- a/docs/features/query-history.mdx +++ b/docs/features/query-history.mdx @@ -20,6 +20,20 @@ Narrow the list with the date picker (Today, This Week, This Month, All Time) or Recent queries also appear in the [Quick Switcher](/features/quick-switcher). +## Insights + +Choose **Insights** at the top of the panel to review query activity for the current connection. This Starter feature shows: + + + Query History Insights panel + + +- **Most Run**: queries with the highest execution count. +- **Slowest**: queries with the highest average successful execution time. +- **Slower Than Last Week**: queries whose average time rose by at least 25% and 50 ms, with at least three successful runs in each seven-day period. + +Insights are calculated from the local history database. Query text and timing data never leave the Mac. Failed runs count toward **Most Run**, but are excluded from latency calculations. Select an insight to inspect its SQL, copy it, or load it into the current editor. + ## Working With Entries | Action | How | diff --git a/docs/images/query-history-insights.png b/docs/images/query-history-insights.png new file mode 100644 index 000000000..6e6848832 Binary files /dev/null and b/docs/images/query-history-insights.png differ