Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions TablePro/Core/Storage/QueryHistoryManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
178 changes: 178 additions & 0 deletions TablePro/Core/Storage/QueryHistoryStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 = ?;"
Expand Down Expand Up @@ -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
}
}
100 changes: 100 additions & 0 deletions TablePro/Models/Query/QueryHistoryInsights.swift
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 8 additions & 1 deletion TablePro/Models/Settings/ProFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ internal enum ProFeature: String, CaseIterable {
case encryptedExport
case envVarReferences
case linkedFolders
case queryHistoryInsights
case teamCatalog
case teamLibrary

Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand Down
Loading
Loading