Skip to content
Draft
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
11 changes: 11 additions & 0 deletions Plugins/TableProPluginKit/PluginDatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable {
func applyQueryTimeout(_ seconds: Int) async throws
var serverVersion: String? { get }
var parameterStyle: ParameterStyle { get }
func resolveQueryCompletionProfile(
databaseTypeId: String,
base: QueryCompletionProfile
) async throws -> QueryCompletionProfile

var requiresBackslashEscapingInLiterals: Bool { get }

Expand Down Expand Up @@ -270,6 +274,13 @@ public extension PluginDatabaseDriver {

var parameterStyle: ParameterStyle { .questionMark }

func resolveQueryCompletionProfile(
databaseTypeId: String,
base: QueryCompletionProfile
) async throws -> QueryCompletionProfile {
base
}

var requiresBackslashEscapingInLiterals: Bool { false }

func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { nil }
Expand Down
42 changes: 42 additions & 0 deletions Plugins/TableProPluginKit/QueryCompletionProfile.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Foundation

public enum QueryCompletionTokenCasingPolicy: String, Sendable {
case preserveTypedToken
case uppercaseKeywordsAndFunctions
case lowercaseKeywordsAndFunctions
case canonicalGrammarCasing
}

public struct QueryCompletionProfile: Sendable {
public let resolvedDialect: SQLDialectDescriptor?
public let statementCompletions: [CompletionEntry]
public let tokenCasingPolicy: QueryCompletionTokenCasingPolicy
public let revision: String

public static let defaultRevision = "base"

@_disfavoredOverload
public init(
resolvedDialect: SQLDialectDescriptor?,
statementCompletions: [CompletionEntry]
) {
self.init(
resolvedDialect: resolvedDialect,
statementCompletions: statementCompletions,
tokenCasingPolicy: .preserveTypedToken,
revision: Self.defaultRevision
)
}

public init(
resolvedDialect: SQLDialectDescriptor?,
statementCompletions: [CompletionEntry],
tokenCasingPolicy: QueryCompletionTokenCasingPolicy = .preserveTypedToken,
revision: String = QueryCompletionProfile.defaultRevision
) {
self.resolvedDialect = resolvedDialect
self.statementCompletions = statementCompletions
self.tokenCasingPolicy = tokenCasingPolicy
self.revision = revision
}
}
131 changes: 131 additions & 0 deletions TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import Combine
import Foundation
import Observation
import TableProPluginKit

@MainActor
@Observable
final class QueryCompletionProfileRegistry {
struct CacheKey: Hashable {
let scope: DatabaseScope
let databaseType: DatabaseType
let serverVersion: String?
}

static let shared = QueryCompletionProfileRegistry()

private var profiles: [CacheKey: QueryCompletionProfile] = [:]
private var inFlight: [CacheKey: Task<QueryCompletionProfile, Never>] = [:]
private var generations: [CacheKey: Int] = [:]
private(set) var revisions: [DatabaseScope: Int] = [:]
@ObservationIgnored private var cancellables: Set<AnyCancellable> = []

init() {
AppCommands.shared.refreshData
.sink { [weak self] request in
guard let self else { return }
if let scope = request.scope {
self.invalidate(scope: scope)
} else {
self.invalidate(connectionId: request.connectionId)
}
}
.store(in: &cancellables)
}

func revision(for scope: DatabaseScope) -> Int {
revisions[scope, default: 0]
}

func profile(
for scope: DatabaseScope,
databaseType: DatabaseType,
driver: DatabaseDriver
) async -> QueryCompletionProfile {
let base = baseProfile(for: databaseType, serverVersion: driver.serverVersion)
return await resolve(
scope: scope,
databaseType: databaseType,
serverVersion: driver.serverVersion,
base: base
) {
try await driver.resolveQueryCompletionProfile(
databaseTypeId: databaseType.rawValue,
base: base
)
}
}

func resolve(
scope: DatabaseScope,
databaseType: DatabaseType,
serverVersion: String?,
base: QueryCompletionProfile,
resolver: @escaping () async throws -> QueryCompletionProfile
) async -> QueryCompletionProfile {
let key = CacheKey(scope: scope, databaseType: databaseType, serverVersion: serverVersion)
if let profile = profiles[key] {
return profile
}
if let task = inFlight[key] {
return await task.value
}
let generation = generations[key, default: 0]
let task = Task { @MainActor in
(try? await resolver()) ?? base
}
inFlight[key] = task
let profile = await task.value
if generations[key, default: 0] == generation {
inFlight.removeValue(forKey: key)
profiles[key] = profile
}
return profile
}

func invalidate(scope: DatabaseScope) {
revisions[scope, default: 0] &+= 1
let profileKeys = profiles.keys.filter { $0.scope == scope }
let taskKeys = inFlight.keys.filter { $0.scope == scope }
for key in profileKeys {
generations[key, default: 0] &+= 1
}
for key in taskKeys {
generations[key, default: 0] &+= 1
inFlight[key]?.cancel()
inFlight.removeValue(forKey: key)
}
profiles = profiles.filter { $0.key.scope != scope }
}

func invalidate(connectionId: UUID) {
let scopes = Set(profiles.keys.map(\.scope) + inFlight.keys.map(\.scope))
.filter { $0.connectionId == connectionId }
for scope in scopes {
revisions[scope, default: 0] &+= 1
}
let profileKeys = profiles.keys.filter { $0.scope.connectionId == connectionId }
let taskKeys = inFlight.keys.filter { $0.scope.connectionId == connectionId }
for key in profileKeys {
generations[key, default: 0] &+= 1
}
for key in taskKeys {
generations[key, default: 0] &+= 1
inFlight[key]?.cancel()
inFlight.removeValue(forKey: key)
}
profiles = profiles.filter { $0.key.scope.connectionId != connectionId }
}

private func baseProfile(
for databaseType: DatabaseType,
serverVersion: String?
) -> QueryCompletionProfile {
QueryCompletionProfile(
resolvedDialect: PluginManager.shared.sqlDialect(for: databaseType),
statementCompletions: PluginManager.shared.statementCompletions(for: databaseType),
tokenCasingPolicy: .preserveTypedToken,
revision: [databaseType.rawValue, serverVersion ?? "unknown", "base"].joined(separator: ":")
)
}
}
12 changes: 12 additions & 0 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ protocol DatabaseDriver: AnyObject, Sendable {
/// Apply query execution timeout (seconds, 0 = no limit)
func applyQueryTimeout(_ seconds: Int) async throws

func resolveQueryCompletionProfile(
databaseTypeId: String,
base: QueryCompletionProfile
) async throws -> QueryCompletionProfile

// MARK: - Query Execution

/// Execute a SQL query and return results
Expand Down Expand Up @@ -247,6 +252,13 @@ extension DatabaseDriver {
try await connect()
}

func resolveQueryCompletionProfile(
databaseTypeId: String,
base: QueryCompletionProfile
) async throws -> QueryCompletionProfile {
base
}

var queryBuildingPluginDriver: (any PluginDatabaseDriver)? { nil }

func beginTransaction(mode: PluginTransactionAccessMode) async throws {
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Database/DatabaseManager+Metadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Foundation
/// no connection-only overload: a connection reaches many databases, so resolving the
/// database from ambient session state is how a tab's read lands on another database.
@MainActor
protocol ScopedMetadataProviding: AnyObject {
protocol ScopedMetadataProviding: AnyObject, Sendable {
func withMetadataDriver<T: Sendable>(
scope: DatabaseScope,
workload: MetadataConnectionPool.Workload,
Expand Down
1 change: 1 addition & 0 deletions TablePro/Core/Database/DatabaseManager+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ extension DatabaseManager {
await DatabaseTreeMetadataService.shared.handleDisconnect(connectionId: sessionId)

SchemaProviderRegistry.shared.clear(for: sessionId)
QueryCompletionProfileRegistry.shared.invalidate(connectionId: sessionId)
ExternalSchemaTracker.shared.reset(connectionId: sessionId)

SharedSidebarState.removeConnection(sessionId)
Expand Down
10 changes: 10 additions & 0 deletions TablePro/Core/Plugins/PluginDriverAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,16 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
try await pluginDriver.applyQueryTimeout(seconds)
}

func resolveQueryCompletionProfile(
databaseTypeId: String,
base: QueryCompletionProfile
) async throws -> QueryCompletionProfile {
try await pluginDriver.resolveQueryCompletionProfile(
databaseTypeId: databaseTypeId,
base: base
)
}

// MARK: - Query Execution

func execute(query: String) async throws -> QueryResult {
Expand Down
14 changes: 8 additions & 6 deletions TablePro/Core/Plugins/PluginManager+Registration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,7 @@ extension PluginManager {
}

func sqlDialect(for databaseType: DatabaseType) -> SQLDialectDescriptor? {
PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)?
.editor.sqlDialect
metadataSnapshot(for: databaseType)?.editor.sqlDialect
}

/// How this engine can express case-insensitive matching. SQL engines answer from their
Expand All @@ -225,8 +224,7 @@ extension PluginManager {
}

func statementCompletions(for databaseType: DatabaseType) -> [CompletionEntry] {
PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)?
.editor.statementCompletions ?? []
metadataSnapshot(for: databaseType)?.editor.statementCompletions ?? []
}

func additionalConnectionFields(for databaseType: DatabaseType) -> [ConnectionField] {
Expand Down Expand Up @@ -317,8 +315,12 @@ extension PluginManager {
}

func editorLanguage(for databaseType: DatabaseType) -> EditorLanguage {
PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)?
.editorLanguage ?? .sql
metadataSnapshot(for: databaseType)?.editorLanguage ?? .sql
}

private func metadataSnapshot(for databaseType: DatabaseType) -> PluginMetadataSnapshot? {
PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.rawValue)
?? PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)
}

func queryLanguageName(for databaseType: DatabaseType) -> String {
Expand Down
Loading
Loading