From 80e9900cf55fd23e2bb788b6ca63ea8beb45626c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Wed, 12 Aug 2026 20:08:57 +0700 Subject: [PATCH 1/6] refactor(editor): scope query completion by database --- .../PluginDatabaseDriver.swift | 11 ++ .../QueryCompletionProfile.swift | 42 +++++ .../QueryCompletionProfileRegistry.swift | 131 ++++++++++++++ TablePro/Core/Database/DatabaseDriver.swift | 12 ++ .../Database/DatabaseManager+Metadata.swift | 2 +- .../Database/DatabaseManager+Sessions.swift | 1 + .../Core/Plugins/PluginDriverAdapter.swift | 10 ++ .../Plugins/PluginManager+Registration.swift | 14 +- .../Query/SchemaProviderRegistry.swift | 83 +++++++-- .../Services/Query/SchemaRefreshService.swift | 11 +- TablePro/Views/Editor/QueryEditorView.swift | 4 + .../Views/Editor/SQLCompletionAdapter.swift | 25 ++- TablePro/Views/Editor/SQLEditorView.swift | 40 +++++ .../Main/Child/MainEditorContentView.swift | 5 +- ...erAdapterQueryCompletionProfileTests.swift | 109 ++++++++++++ .../Plugins/PluginKitABIResilienceTests.swift | 14 ++ .../Query/SchemaRefreshServiceTests.swift | 63 ++++++- .../QueryCompletionProfileRegistryTests.swift | 162 ++++++++++++++++++ 18 files changed, 701 insertions(+), 38 deletions(-) create mode 100644 Plugins/TableProPluginKit/QueryCompletionProfile.swift create mode 100644 TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift create mode 100644 TableProTests/Core/Plugins/PluginDriverAdapterQueryCompletionProfileTests.swift create mode 100644 TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 17d5048a1..cefe3316d 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -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 } @@ -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 } diff --git a/Plugins/TableProPluginKit/QueryCompletionProfile.swift b/Plugins/TableProPluginKit/QueryCompletionProfile.swift new file mode 100644 index 000000000..7146bd1ce --- /dev/null +++ b/Plugins/TableProPluginKit/QueryCompletionProfile.swift @@ -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 + } +} diff --git a/TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift b/TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift new file mode 100644 index 000000000..e45310b22 --- /dev/null +++ b/TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift @@ -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] = [:] + private var generations: [CacheKey: Int] = [:] + private(set) var revisions: [DatabaseScope: Int] = [:] + @ObservationIgnored private var cancellables: Set = [] + + 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: ":") + ) + } +} diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 14b3e4431..a78b1f074 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -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 @@ -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 { diff --git a/TablePro/Core/Database/DatabaseManager+Metadata.swift b/TablePro/Core/Database/DatabaseManager+Metadata.swift index 552feb30c..5d773c5f3 100644 --- a/TablePro/Core/Database/DatabaseManager+Metadata.swift +++ b/TablePro/Core/Database/DatabaseManager+Metadata.swift @@ -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( scope: DatabaseScope, workload: MetadataConnectionPool.Workload, diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 4f46eceae..b638f8da9 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -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) diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 794f9e7af..0bf262667 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -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 { diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index 09729e9dc..3bb0e7f95 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -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 @@ -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] { @@ -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 { diff --git a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift index e0b4c94a2..af677f22e 100644 --- a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift +++ b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift @@ -16,18 +16,21 @@ final class SchemaProviderRegistry { static let shared = SchemaProviderRegistry() - private var providers: [UUID: SQLSchemaProvider] = [:] + private var providers: [DatabaseScope: SQLSchemaProvider] = [:] private var refCounts: [UUID: Int] = [:] private var removalTasks: [UUID: Task] = [:] private var cancellables: Set = [] + private let metadataDriverProvider: any ScopedMetadataProviding #if DEBUG /// Test-only init for `@testable` tests in DEBUG builds; release builds must use `.shared`. - internal init() { + internal init(metadataDriverProvider: any ScopedMetadataProviding = DatabaseManager.shared) { + self.metadataDriverProvider = metadataDriverProvider subscribeToRefreshSignal() } #else - private init() { + private init(metadataDriverProvider: any ScopedMetadataProviding = DatabaseManager.shared) { + self.metadataDriverProvider = metadataDriverProvider subscribeToRefreshSignal() } #endif @@ -35,31 +38,52 @@ final class SchemaProviderRegistry { private func subscribeToRefreshSignal() { AppCommands.shared.refreshData .sink { [weak self] request in - self?.invalidateColumnCache(for: request.connectionId) + self?.refresh(request: request) } .store(in: &cancellables) } func invalidateColumnCache(for connectionId: UUID) { - guard let provider = providers[connectionId] else { return } - Task { await provider.clearColumnCache() } + let matchingProviders = providers.compactMap { scope, provider in + scope.connectionId == connectionId ? provider : nil + } + for provider in matchingProviders { + Task { await provider.clearColumnCache() } + } } func provider(for connectionId: UUID) -> SQLSchemaProvider? { - providers[connectionId] + if let scope = metadataDriverProvider.browseScope(for: connectionId), let provider = provider(for: scope) { + return provider + } + let fallback = DatabaseScope(connectionId: connectionId, database: "", schema: nil) + return provider(for: fallback) } func getOrCreate(for connectionId: UUID) -> SQLSchemaProvider { + guard let scope = metadataDriverProvider.browseScope(for: connectionId) else { + let fallback = DatabaseScope(connectionId: connectionId, database: "", schema: nil) + return getOrCreate(for: fallback) + } + return getOrCreate(for: scope) + } + + func provider(for scope: DatabaseScope) -> SQLSchemaProvider? { + providers[scope] + } + + func getOrCreate(for scope: DatabaseScope) -> SQLSchemaProvider { + let connectionId = scope.connectionId if let removalTask = removalTasks[connectionId] { removalTask.cancel() removalTasks.removeValue(forKey: connectionId) } - if let existing = providers[connectionId] { + if let existing = providers[scope] { return existing } let source = SQLSchemaProvider.ColumnMetadataSource( fetchColumns: { table, schema in - try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connectionId) { driver in + try await metadataDriverProvider.withMetadataDriver(scope: scope) { driver in if let schema { return try await driver.fetchColumns(table: table, schema: schema) } @@ -67,21 +91,48 @@ final class SchemaProviderRegistry { } }, fetchAllColumns: { - try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connectionId, workload: .bulk) { driver in + try await metadataDriverProvider.withMetadataDriver(scope: scope, workload: .bulk) { driver in try await driver.fetchAllColumns() } }, fetchSchemaTables: { schema in - try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connectionId) { driver in + try await metadataDriverProvider.withMetadataDriver(scope: scope) { driver in try await driver.fetchTables(schema: schema) } } ) let provider = SQLSchemaProvider(metadataSource: source) - providers[connectionId] = provider + providers[scope] = provider + Task { + try? await metadataDriverProvider.withMetadataDriver(scope: scope) { driver in + await provider.loadSchema(using: driver) + } + } return provider } + func prepare(for scope: DatabaseScope) async -> SQLSchemaProvider { + let provider = getOrCreate(for: scope) + try? await metadataDriverProvider.withMetadataDriver(scope: scope) { driver in + await provider.loadSchema(using: driver) + } + return provider + } + + func refresh(request: DataRefreshRequest) { + let matchingProviders = providers.filter { scope, _ in + scope.connectionId == request.connectionId && (request.scope == nil || request.scope == scope) + } + for (scope, provider) in matchingProviders { + Task { + try? await metadataDriverProvider.withMetadataDriver(scope: scope) { driver in + await provider.clearColumnCache() + await provider.loadSchema(using: driver) + } + } + } + } + func retain(for connectionId: UUID) { removalTasks[connectionId]?.cancel() removalTasks.removeValue(forKey: connectionId) @@ -96,7 +147,7 @@ final class SchemaProviderRegistry { removalTasks[connectionId] = Task { [weak self] in try? await Task.sleep(nanoseconds: 5_000_000_000) guard let self, !Task.isCancelled else { return } - self.providers.removeValue(forKey: connectionId) + self.providers = self.providers.filter { $0.key.connectionId != connectionId } self.removalTasks.removeValue(forKey: connectionId) } } else { @@ -105,21 +156,21 @@ final class SchemaProviderRegistry { } func clear(for connectionId: UUID) { - providers.removeValue(forKey: connectionId) + providers = providers.filter { $0.key.connectionId != connectionId } refCounts.removeValue(forKey: connectionId) removalTasks[connectionId]?.cancel() removalTasks.removeValue(forKey: connectionId) } func purgeUnused() { - let orphanedIds = providers.keys.filter { connectionId in + let orphanedIds = Set(providers.keys.map(\.connectionId)).filter { connectionId in let count = refCounts[connectionId] ?? 0 let hasPendingRemoval = removalTasks[connectionId] != nil return count <= 0 && !hasPendingRemoval } for connectionId in orphanedIds { Self.logger.info("Purging orphaned schema provider for connection \(connectionId)") - providers.removeValue(forKey: connectionId) + providers = providers.filter { $0.key.connectionId != connectionId } refCounts.removeValue(forKey: connectionId) } } diff --git a/TablePro/Core/Services/Query/SchemaRefreshService.swift b/TablePro/Core/Services/Query/SchemaRefreshService.swift index 8301de797..350230a68 100644 --- a/TablePro/Core/Services/Query/SchemaRefreshService.swift +++ b/TablePro/Core/Services/Query/SchemaRefreshService.swift @@ -81,22 +81,23 @@ final class SchemaRefreshService { ) return } - guard let provider = providerRegistry.provider(for: connectionId) else { + guard let browseScope = metadataDriverProvider.browseScope(for: connectionId) else { Self.logger.debug( - "[schema] autocomplete sync skipped, no provider connId=\(connectionId, privacy: .public)" + "[schema] autocomplete sync skipped, no browse scope connId=\(connectionId, privacy: .public)" ) return } - guard let browseDatabase = metadataDriverProvider.browseScope(for: connectionId)?.database else { + guard let provider = providerRegistry.provider(for: browseScope) else { Self.logger.debug( - "[schema] autocomplete sync skipped, no browse scope connId=\(connectionId, privacy: .public)" + "[schema] autocomplete sync skipped, no provider connId=\(connectionId, privacy: .public)" ) return } + let browseDatabase = browseScope.database let tables = schemaService.allLoadedTables(for: connectionId) let schemas = schemaService.schemas(for: connectionId) do { - try await metadataDriverProvider.withBrowseMetadataDriver(connectionId: connectionId) { driver in + try await metadataDriverProvider.withMetadataDriver(scope: browseScope) { driver in await provider.resetForDatabase(browseDatabase, tables: tables, driver: driver) await provider.setNamespaces(schemas: schemas, databases: [browseDatabase]) } diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index 7536ce6e4..e7074888a 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -20,6 +20,8 @@ struct QueryEditorView: View { var onExecuteWithoutLimit: (() -> Void)? var schemaProvider: SQLSchemaProvider? var databaseType: DatabaseType? + var databaseScope: DatabaseScope? + var serverVersion: String? var connectionId: UUID? var connectionAIPolicy: AIConnectionPolicy? var tabID: UUID? @@ -64,6 +66,8 @@ struct QueryEditorView: View { cursorPositions: $cursorPositions, schemaProvider: schemaProvider, databaseType: databaseType, + databaseScope: databaseScope, + serverVersion: serverVersion, connectionId: connectionId, connectionAIPolicy: connectionAIPolicy, tabID: tabID, diff --git a/TablePro/Views/Editor/SQLCompletionAdapter.swift b/TablePro/Views/Editor/SQLCompletionAdapter.swift index 64edc1189..e9483d4ac 100644 --- a/TablePro/Views/Editor/SQLCompletionAdapter.swift +++ b/TablePro/Views/Editor/SQLCompletionAdapter.swift @@ -10,6 +10,7 @@ import CodeEditSourceEditor import CodeEditTextView import os import SwiftUI +import TableProPluginKit /// Adapts the existing CompletionEngine to CodeEditSourceEditor's suggestion system @MainActor @@ -27,6 +28,7 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { } private var completionEngine: CompletionEngine + private(set) var profileRevision = QueryCompletionProfile.defaultRevision private var favoriteKeywords: [String: (name: String, query: String)] = [:] private var session: CompletionSession? private let debounceNanoseconds: UInt64 = 50_000_000 @@ -45,8 +47,17 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { } /// Rebuild the completion engine for the current connection (nil schema still yields keyword completion) - func configure(schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?) { - completionEngine = Self.makeEngine(schemaProvider: schemaProvider, databaseType: databaseType) + func configure( + schemaProvider: SQLSchemaProvider?, + databaseType: DatabaseType?, + profile: QueryCompletionProfile? = nil + ) { + profileRevision = profile?.revision ?? QueryCompletionProfile.defaultRevision + completionEngine = Self.makeEngine( + schemaProvider: schemaProvider, + databaseType: databaseType, + profile: profile + ) completionEngine.updateFavoriteKeywords(favoriteKeywords) } @@ -58,10 +69,14 @@ final class SQLCompletionAdapter: CodeSuggestionDelegate { private static func makeEngine( schemaProvider: SQLSchemaProvider?, - databaseType: DatabaseType? + databaseType: DatabaseType?, + profile: QueryCompletionProfile? = nil ) -> CompletionEngine { - let dialect = databaseType.flatMap { PluginManager.shared.sqlDialect(for: $0) } - let completions = databaseType.flatMap { PluginManager.shared.statementCompletions(for: $0) } ?? [] + let dialect = profile?.resolvedDialect + ?? databaseType.flatMap { PluginManager.shared.sqlDialect(for: $0) } + let completions = profile?.statementCompletions + ?? databaseType.flatMap { PluginManager.shared.statementCompletions(for: $0) } + ?? [] return CompletionEngine( schemaProvider: schemaProvider, databaseType: databaseType, dialect: dialect, statementCompletions: completions diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index e1dacba26..a1fd27d37 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -11,6 +11,7 @@ import CodeEditSourceEditor import CodeEditTextView import Combine import SwiftUI +import TableProPluginKit // MARK: - SQLEditorView @@ -20,6 +21,8 @@ struct SQLEditorView: View { @Binding var cursorPositions: [CursorPosition] var schemaProvider: SQLSchemaProvider? var databaseType: DatabaseType? + var databaseScope: DatabaseScope? + var serverVersion: String? var connectionId: UUID? var connectionAIPolicy: AIConnectionPolicy? var tabID: UUID? @@ -90,6 +93,9 @@ struct SQLEditorView: View { completionAdapter.configure(schemaProvider: schemaProvider, databaseType: databaseType) setupFavoritesObserver() } + .task(id: completionProfileRequest) { + await resolveCompletionProfile() + } .onChange(of: colorScheme) { editorConfiguration = Self.makeConfiguration() } @@ -124,6 +130,33 @@ struct SQLEditorView: View { setupFavoritesObserver() } + private var completionProfileRequest: CompletionProfileRequest? { + guard let databaseScope, let databaseType else { return nil } + return CompletionProfileRequest( + scope: databaseScope, + databaseType: databaseType, + serverVersion: serverVersion, + profileRevision: QueryCompletionProfileRegistry.shared.revision(for: databaseScope) + ) + } + + private func resolveCompletionProfile() async { + guard let request = completionProfileRequest else { return } + let profile = try? await DatabaseManager.shared.withMetadataDriver(scope: request.scope) { driver in + await QueryCompletionProfileRegistry.shared.profile( + for: request.scope, + databaseType: request.databaseType, + driver: driver + ) + } + guard !Task.isCancelled, let profile else { return } + completionAdapter.configure( + schemaProvider: schemaProvider, + databaseType: databaseType, + profile: profile + ) + } + // MARK: - Favorites private func setupFavoritesObserver() { @@ -190,6 +223,13 @@ struct SQLEditorView: View { } } +private struct CompletionProfileRequest: Hashable { + let scope: DatabaseScope + let databaseType: DatabaseType + let serverVersion: String? + let profileRevision: Int +} + // MARK: - Preview #Preview { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index bf5958322..88d2439e9 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -339,6 +339,7 @@ struct MainEditorContentView: View { private func queryTabContent(tab: QueryTab) -> some View { @Bindable var bindableCoordinator = coordinator let claimFocus = coordinator.tabManager.pendingFocusTabId == tab.id + let queryScope = coordinator.scope(for: tab) QuerySplitView( isBottomCollapsed: Binding( get: { tab.display.isResultsCollapsed }, @@ -365,8 +366,10 @@ struct MainEditorContentView: View { isParameterPanelVisible: parameterVisibilityBinding(for: tab), onExecute: { coordinator.runQuery() }, onExecuteWithoutLimit: { coordinator.runQuery(bypassRowLimit: true) }, - schemaProvider: SchemaProviderRegistry.shared.getOrCreate(for: coordinator.connection.id), + schemaProvider: queryScope.map { SchemaProviderRegistry.shared.getOrCreate(for: $0) }, databaseType: coordinator.connection.type, + databaseScope: queryScope, + serverVersion: DatabaseManager.shared.driver(for: coordinator.connection.id)?.serverVersion, connectionId: coordinator.connection.id, connectionAIPolicy: coordinator.connection.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, tabID: tab.id, diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterQueryCompletionProfileTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterQueryCompletionProfileTests.swift new file mode 100644 index 000000000..5f2991a8f --- /dev/null +++ b/TableProTests/Core/Plugins/PluginDriverAdapterQueryCompletionProfileTests.swift @@ -0,0 +1,109 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private class QueryCompletionProfileDriverBase { + func connect() async throws {} + func disconnect() {} + + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +private final class DefaultQueryCompletionProfileDriver: QueryCompletionProfileDriverBase, PluginDatabaseDriver, + @unchecked Sendable {} + +private final class OverrideQueryCompletionProfileDriver: QueryCompletionProfileDriverBase, PluginDatabaseDriver, + @unchecked Sendable { + private(set) var receivedTypeIds: [String] = [] + + func resolveQueryCompletionProfile( + databaseTypeId: String, + base: QueryCompletionProfile + ) async throws -> QueryCompletionProfile { + receivedTypeIds.append(databaseTypeId) + return QueryCompletionProfile( + resolvedDialect: base.resolvedDialect, + statementCompletions: base.statementCompletions + [CompletionEntry(label: "TOP", insertText: "TOP")], + tokenCasingPolicy: .lowercaseKeywordsAndFunctions, + revision: "override-\(databaseTypeId)" + ) + } +} + +@Suite("PluginDriverAdapter query completion profile") +struct PluginDriverAdapterQueryCompletionProfileTests { + private func connection() -> DatabaseConnection { + DatabaseConnection( + name: "Completion Profile Test", + host: "127.0.0.1", + port: 1_433, + database: "test", + username: "sa", + type: .mssql + ) + } + + @Test("The adapter forwards completion profile resolution to the plugin driver") + func forwardsResolutionOverride() async throws { + let driver = OverrideQueryCompletionProfileDriver() + let adapter = PluginDriverAdapter(connection: connection(), pluginDriver: driver) + let base = QueryCompletionProfile( + resolvedDialect: nil, + statementCompletions: [CompletionEntry(label: "SELECT", insertText: "SELECT")], + tokenCasingPolicy: .uppercaseKeywordsAndFunctions, + revision: "base" + ) + + let resolved = try await adapter.resolveQueryCompletionProfile( + databaseTypeId: "SQL Server", + base: base + ) + + #expect(driver.receivedTypeIds == ["SQL Server"]) + #expect(resolved.statementCompletions.map(\.label) == ["SELECT", "TOP"]) + #expect(resolved.tokenCasingPolicy == .lowercaseKeywordsAndFunctions) + #expect(resolved.revision == "override-SQL Server") + } + + @Test("The adapter keeps the base profile when the plugin relies on the default implementation") + func preservesBaseProfileByDefault() async throws { + let adapter = PluginDriverAdapter( + connection: connection(), + pluginDriver: DefaultQueryCompletionProfileDriver() + ) + let base = QueryCompletionProfile( + resolvedDialect: nil, + statementCompletions: [CompletionEntry(label: "SELECT", insertText: "SELECT")], + tokenCasingPolicy: .uppercaseKeywordsAndFunctions, + revision: "base" + ) + + let resolved = try await adapter.resolveQueryCompletionProfile( + databaseTypeId: "SQL Server", + base: base + ) + + #expect(resolved.resolvedDialect == nil) + #expect(resolved.statementCompletions.map(\.label) == ["SELECT"]) + #expect(resolved.tokenCasingPolicy == .uppercaseKeywordsAndFunctions) + #expect(resolved.revision == "base") + } +} diff --git a/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift b/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift index a9e4912eb..1b54acb2f 100644 --- a/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift +++ b/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift @@ -44,5 +44,19 @@ struct PluginKitABIResilienceTests { #expect(try await driver.fetchSchemas().isEmpty) #expect(try await driver.fetchExternalSchemaNames().isEmpty) #expect(try await driver.fetchApproximateRowCount(table: "users", schema: nil) == nil) + let base = QueryCompletionProfile( + resolvedDialect: nil, + statementCompletions: [CompletionEntry(label: "SELECT", insertText: "SELECT")], + tokenCasingPolicy: .uppercaseKeywordsAndFunctions, + revision: "fixture" + ) + let resolved = try await driver.resolveQueryCompletionProfile( + databaseTypeId: "SQL Server", + base: base + ) + #expect(resolved.resolvedDialect == nil) + #expect(resolved.statementCompletions.map(\.label) == ["SELECT"]) + #expect(resolved.tokenCasingPolicy == .uppercaseKeywordsAndFunctions) + #expect(resolved.revision == "fixture") } } diff --git a/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift b/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift index 6903132af..efed2cf5a 100644 --- a/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift +++ b/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift @@ -47,6 +47,28 @@ private final class FakeScopedMetadataProvider: ScopedMetadataProviding { } } +@MainActor +private final class ScopeRoutingMetadataProvider: ScopedMetadataProviding { + let drivers: [DatabaseScope: MockDatabaseDriver] + private(set) var requestedScopes: [DatabaseScope] = [] + + init(drivers: [DatabaseScope: MockDatabaseDriver]) { + self.drivers = drivers + } + + func withMetadataDriver( + scope: DatabaseScope, + workload: MetadataConnectionPool.Workload, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + requestedScopes.append(scope) + guard let driver = drivers[scope] else { throw DatabaseError.notConnected } + return try await body(driver) + } + + func browseScope(for connectionId: UUID) -> DatabaseScope? { nil } +} + @Suite("SchemaRefreshService") @MainActor struct SchemaRefreshServiceTests { @@ -147,9 +169,10 @@ struct SchemaRefreshServiceTests { TableInfo(name: "customers", type: .table, rowCount: 0, schema: nil) ] let provider = FakeScopedMetadataProvider(driver: driver) - let registry = SchemaProviderRegistry() + let registry = SchemaProviderRegistry(metadataDriverProvider: provider) let connection = TestFixtures.makeConnection() - let schemaProvider = registry.getOrCreate(for: connection.id) + let scope = DatabaseScope(connectionId: connection.id, database: "testdb", schema: nil) + let schemaProvider = registry.getOrCreate(for: scope) let service = makeService( schemaService: SchemaService(), provider: provider, @@ -167,9 +190,10 @@ struct SchemaRefreshServiceTests { let driver = MockDatabaseDriver() driver.tablesToReturn = [TableInfo(name: "orders", type: .table, rowCount: 0, schema: nil)] let provider = FakeScopedMetadataProvider(driver: driver) - let registry = SchemaProviderRegistry() + let registry = SchemaProviderRegistry(metadataDriverProvider: provider) let connection = TestFixtures.makeConnection() - let schemaProvider = registry.getOrCreate(for: connection.id) + let scope = DatabaseScope(connectionId: connection.id, database: "testdb", schema: nil) + let schemaProvider = registry.getOrCreate(for: scope) let service = makeService( schemaService: SchemaService(), provider: provider, @@ -230,4 +254,35 @@ struct SchemaRefreshServiceTests { } #expect(isFailed) } + + @Test("query tabs on one connection keep schema providers isolated by full scope") + func queryTabProvidersAreIsolatedByScope() async { + let connectionId = UUID() + let salesScope = DatabaseScope(connectionId: connectionId, database: "shop", schema: "sales") + let auditScope = DatabaseScope(connectionId: connectionId, database: "shop", schema: "audit") + let salesDriver = MockDatabaseDriver() + salesDriver.tablesToReturn = [ + TableInfo(name: "orders", type: .table, rowCount: 0, schema: "sales") + ] + let auditDriver = MockDatabaseDriver() + auditDriver.tablesToReturn = [ + TableInfo(name: "events", type: .table, rowCount: 0, schema: "audit") + ] + let metadataProvider = ScopeRoutingMetadataProvider( + drivers: [salesScope: salesDriver, auditScope: auditDriver] + ) + let registry = SchemaProviderRegistry(metadataDriverProvider: metadataProvider) + + let salesProvider = await registry.prepare(for: salesScope) + let auditProvider = await registry.prepare(for: auditScope) + + let salesNames = await salesProvider.getTables().map(\.name) + let auditNames = await auditProvider.getTables().map(\.name) + #expect(salesProvider !== auditProvider) + #expect(salesNames == ["orders"]) + #expect(auditNames == ["events"]) + #expect(registry.provider(for: salesScope) === salesProvider) + #expect(registry.provider(for: auditScope) === auditProvider) + #expect(Set(metadataProvider.requestedScopes) == [salesScope, auditScope]) + } } diff --git a/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift b/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift new file mode 100644 index 000000000..c6c468aaf --- /dev/null +++ b/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift @@ -0,0 +1,162 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Query completion profile registry") +@MainActor +struct QueryCompletionProfileRegistryTests { + private func base(revision: String = "base") -> QueryCompletionProfile { + QueryCompletionProfile( + resolvedDialect: nil, + statementCompletions: [CompletionEntry(label: "SELECT", insertText: "SELECT")], + tokenCasingPolicy: .preserveTypedToken, + revision: revision + ) + } + + @Test("cache keys include scope, database type, and server version") + func cacheKeyIncludesEveryRuntimeDimension() async { + let registry = QueryCompletionProfileRegistry() + let connectionId = UUID() + let firstScope = DatabaseScope(connectionId: connectionId, database: "first", schema: "public") + let secondScope = DatabaseScope(connectionId: connectionId, database: "second", schema: "public") + var resolutions = 0 + + _ = await registry.resolve( + scope: firstScope, + databaseType: .postgresql, + serverVersion: "15.2", + base: base() + ) { + resolutions += 1 + return base(revision: "first") + } + _ = await registry.resolve( + scope: firstScope, + databaseType: .postgresql, + serverVersion: "15.2", + base: base() + ) { + resolutions += 1 + return base(revision: "cached") + } + _ = await registry.resolve( + scope: secondScope, + databaseType: .postgresql, + serverVersion: "15.2", + base: base() + ) { + resolutions += 1 + return base(revision: "second") + } + _ = await registry.resolve( + scope: firstScope, + databaseType: .cockroachdb, + serverVersion: "15.2", + base: base() + ) { + resolutions += 1 + return base(revision: "type") + } + _ = await registry.resolve( + scope: firstScope, + databaseType: .postgresql, + serverVersion: "16.1", + base: base() + ) { + resolutions += 1 + return base(revision: "version") + } + + #expect(resolutions == 4) + } + + @Test("resolution errors return and cache the conservative base profile") + func resolutionFailureReturnsBase() async { + let registry = QueryCompletionProfileRegistry() + let scope = DatabaseScope(connectionId: UUID(), database: "shop", schema: nil) + let conservative = base(revision: "unknown-base") + + let resolved = await registry.resolve( + scope: scope, + databaseType: .mysql, + serverVersion: nil, + base: conservative + ) { + throw DatabaseError.connectionFailed("catalog denied") + } + + #expect(resolved.revision == "unknown-base") + #expect(resolved.statementCompletions.map(\.label) == ["SELECT"]) + } + + @Test("concurrent requests for one key join one resolution") + func concurrentRequestsJoinOneResolution() async { + let registry = QueryCompletionProfileRegistry() + let scope = DatabaseScope(connectionId: UUID(), database: "shop", schema: nil) + var resolutions = 0 + + async let first = registry.resolve( + scope: scope, + databaseType: .mysql, + serverVersion: "8.0", + base: base() + ) { + resolutions += 1 + await Task.yield() + return base(revision: "resolved") + } + async let second = registry.resolve( + scope: scope, + databaseType: .mysql, + serverVersion: "8.0", + base: base() + ) { + resolutions += 1 + return base(revision: "duplicate") + } + + let revisions = await [first.revision, second.revision] + #expect(resolutions == 1) + #expect(revisions == ["resolved", "resolved"]) + } + + @Test("invalidation prevents an old resolution from replacing the next generation") + func invalidationFencesOldResolution() async { + let registry = QueryCompletionProfileRegistry() + let scope = DatabaseScope(connectionId: UUID(), database: "shop", schema: nil) + + async let old = registry.resolve( + scope: scope, + databaseType: .mysql, + serverVersion: "8.0", + base: base() + ) { + try? await Task.sleep(nanoseconds: 10_000_000) + return base(revision: "old") + } + await Task.yield() + registry.invalidate(scope: scope) + let current = await registry.resolve( + scope: scope, + databaseType: .mysql, + serverVersion: "8.0", + base: base() + ) { + base(revision: "current") + } + _ = await old + let cached = await registry.resolve( + scope: scope, + databaseType: .mysql, + serverVersion: "8.0", + base: base() + ) { + base(revision: "unexpected") + } + + #expect(current.revision == "current") + #expect(cached.revision == "current") + } +} From e0e58059bd0754bbbff8d83ae8754dba5731d690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Wed, 12 Aug 2026 20:10:41 +0700 Subject: [PATCH 2/6] docs(plans): track query completion worktrees --- .../phase-01-foundation.md | 92 +++++++++++++++++++ .../phase-02-parallel-profiles.md | 83 +++++++++++++++++ .../phase-03-docs-acceptance.md | 82 +++++++++++++++++ .../plan.md | 67 ++++++++++++++ 4 files changed, 324 insertions(+) create mode 100644 plans/20260812-sql-completion-singlestore-worktrees/phase-01-foundation.md create mode 100644 plans/20260812-sql-completion-singlestore-worktrees/phase-02-parallel-profiles.md create mode 100644 plans/20260812-sql-completion-singlestore-worktrees/phase-03-docs-acceptance.md create mode 100644 plans/20260812-sql-completion-singlestore-worktrees/plan.md diff --git a/plans/20260812-sql-completion-singlestore-worktrees/phase-01-foundation.md b/plans/20260812-sql-completion-singlestore-worktrees/phase-01-foundation.md new file mode 100644 index 000000000..9267e17d0 --- /dev/null +++ b/plans/20260812-sql-completion-singlestore-worktrees/phase-01-foundation.md @@ -0,0 +1,92 @@ +# Phase 01: Foundation + +## Overview + +- Wave: 1 +- Branch: `codex/refactor/query-completion-foundation` +- Worktree: `/Users/hangvalong/Code/projects/worktrees/TablePro-codex-refactor-query-completion-foundation` +- Status: implemented, verification blocked by local Xcode toolchain +- Priority: P1 +- Gate: phải merge vào `main` trước khi mở Wave 2 + +## Scope và ownership độc quyền + +- `QueryCompletionProfile` và API PluginKit additive. +- Schema-provider cache theo `DatabaseScope`. +- Completion-profile registry và conservative fallback. +- Truyền `scope`, `serverVersion`, `profileRevision` tới editor. +- Sửa lookup metadata ưu tiên database type thực trước primary plugin type. +- Test hai query tab cùng connection nhưng khác database/schema. + +## Data flow + +1. Query tab cung cấp `DatabaseScope` + connection context. +2. Driver resolve profile từ `databaseTypeId`, base profile, runtime server version. +3. Registry chọn profile phù hợp hoặc fallback bảo thủ. +4. Cache lưu theo `DatabaseScope + DatabaseType + serverVersion`. +5. Editor nhận `scope`, `serverVersion`, `profileRevision` để lấy completion đúng tab. +6. Metadata lookup ưu tiên database type thực, rồi mới fallback plugin type. + +## Implementation steps + +1. Thêm model `QueryCompletionProfile` chứa resolved dialect, statement completions, token-casing policy. +2. Mở rộng `PluginDatabaseDriver` bằng `resolveQueryCompletionProfile(databaseTypeId:base:) async throws`, có default implementation additive. +3. Thêm registry profile với conservative fallback khi thiếu version/catalog. +4. Refactor cache schema provider để khóa theo `DatabaseScope`, tránh rò completion giữa query tabs. +5. Truyền `DatabaseScope`, `serverVersion`, `profileRevision` tới editor/completion pipeline. +6. Sửa metadata lookup dùng database type thực trước, không lệ thuộc primary plugin type. +7. Viết test cho hai query tab cùng connection nhưng khác database/schema và cho fallback version không xác định. + +## Dependencies + +- Không phụ thuộc Wave 2. +- Là blocker cho toàn bộ 4 branch Wave 2. + +## Risks + +- High: cache scope sai làm rò schema giữa tabs. + Mitigation: key cache bằng scope đầy đủ; thêm integration test 2-tab. +- High: API PluginKit additive nhưng vô tình phá ABI. + Mitigation: giữ initializer/public symbol cũ; chạy ABI check. +- Medium: metadata lookup đổi precedence gây regression plugin cũ. + Mitigation: fallback plugin type khi database type thật không đủ metadata. + +## Backwards compatibility + +- Driver chưa override API mới vẫn dùng profile gốc. +- Unknown version/catalog vẫn chạy bằng SQL baseline bảo thủ. +- Không đổi shortcut editor hay public initializer hiện có. + +## Tests + +- Unit: profile resolution, registry fallback, cache key by scope. +- Integration: 2 query tabs cùng connection nhưng khác database/schema. +- Verification: `AllPlugins` build, `swiftlint lint --strict`, PluginKit ABI check nếu có chạm PluginKit. + +## Rollback + +- Revert riêng foundation branch trước khi tạo Wave 2. +- Nếu merge rồi mới lỗi: revert commit foundation trên `main`; các branch Wave 2 phải rebase lại từ `main` đã rollback. + +## TODO + +- [ ] Tạo `QueryCompletionProfile` +- [ ] Thêm API resolve profile vào PluginKit +- [ ] Dựng registry + conservative fallback +- [ ] Scope cache theo query tab +- [ ] Truyền `scope/serverVersion/profileRevision` tới editor +- [ ] Đổi precedence metadata lookup +- [ ] Viết test 2-tab và fallback version +- [ ] Chạy build/lint/ABI check + +## Done when + +- Foundation mergeable độc lập. +- Không leak completion giữa hai tab khác database/schema. +- API mới additive, không phá ABI/public initializer. +- Có test và verification pass. + +## Notes + +- Không commit nếu chưa có approval explicit. +- Không mở Wave 2 trước khi foundation vào `main`. diff --git a/plans/20260812-sql-completion-singlestore-worktrees/phase-02-parallel-profiles.md b/plans/20260812-sql-completion-singlestore-worktrees/phase-02-parallel-profiles.md new file mode 100644 index 000000000..4d70c280e --- /dev/null +++ b/plans/20260812-sql-completion-singlestore-worktrees/phase-02-parallel-profiles.md @@ -0,0 +1,83 @@ +# Phase 02: Parallel Profiles + +## Overview + +- Wave: 2 +- Base branch cho cả 4 worktree: `main` sau khi foundation merge +- Status: pending +- Priority: P1 +- Rule: file ownership tách tuyệt đối, PR vào `main`, merge theo bất kỳ thứ tự nào + +## Parallel branches và ownership + +1. `codex/feat/singlestore-support` + Worktree: `/Users/hangvalong/Code/projects/worktrees/TablePro-codex-feat-singlestore-support` + Ownership: MySQL/MariaDB/SingleStore driver, `DatabaseType.singlestore`, curated metadata/icon/`singlestore://`, probe `@@memsql_version`, Helios + self-managed, TLS Verify Identity mặc định, text protocol parameter binding + MariaDB C client escaping, disable unsupported capabilities, support chính thức 8.9/9.0, version khác dùng profile bảo thủ. +2. `codex/feat/bundled-query-profiles` + Worktree: `/Users/hangvalong/Code/projects/worktrees/TablePro-codex-feat-bundled-query-profiles` + Ownership: PostgreSQL, Redshift, CockroachDB, PGlite, SQLite, ClickHouse, runtime catalog khi có, curated version gates khi catalog thiếu; không sửa MySQL plugin hoặc editor casing. +3. `codex/feat/registry-query-profiles` + Worktree: `/Users/hangvalong/Code/projects/worktrees/TablePro-codex-feat-registry-query-profiles` + Ownership: SQL Server, Oracle, DuckDB, Cassandra/ScyllaDB, Cloudflare D1, DynamoDB PartiQL, BigQuery, libSQL/Turso, Snowflake, Beancount, SurrealQL, Teradata, Trino; version profile, permission fallback, plugin-specific tests; loại trừ MongoDB, Elasticsearch, Redis, etcd. +4. `codex/feat/sql-function-uppercase` + Worktree: `/Users/hangvalong/Code/projects/worktrees/TablePro-codex-feat-sql-function-uppercase` + Ownership: auto-uppercase + formatter, completion insert text cho keyword/built-in, rename setting thành `Auto-uppercase keywords and functions`, không đổi table/column/UDF/string/comment/quoted identifier, grammar case-sensitive giữ canonical casing. + +## Data flows + +- Foundation registry nhận `databaseType + serverVersion + scope`, rồi branch profile tương ứng bổ sung completion sets/version gates. +- Runtime catalog nếu server có sẽ augment curated profile; nếu không có hoặc permission thiếu thì fallback curated baseline. +- SingleStore branch nhận handshake/version probe, resolve database type riêng, rồi đi qua MySQL wire transport hiện có. +- Uppercase branch chỉ tác động token insert/casing policy cho keyword và built-in, không chạm identifier resolution. + +## Dependencies + +- Hard blocker: Phase 01 đã merge vào `main`. +- Soft coordination: mỗi branch chỉ đọc interface chung từ foundation; không sửa file ngoài ownership. +- Wave 3 blocked bởi cả 4 branch đã merge. + +## Risks + +- High: overlap file giữa SingleStore và profile registry/bundled profiles. + Mitigation: giữ ownership theo plugin/dialect rõ ràng; nếu có shared registry file thì chỉ thêm entry phần mình, merge tuần tự và rebase trước test. +- High: SingleStore tự nhận MySQL/MariaDB sai path, làm metadata/query lệch. + Mitigation: probe `@@memsql_version`; unknown result fallback profile bảo thủ, không tự nhận full MySQL feature set. +- Medium: curated version gates drift với runtime catalog. + Mitigation: ưu tiên runtime catalog khi server cung cấp; curated chỉ bù chỗ thiếu. +- Medium: uppercase branch phá casing grammar nhạy chữ hoa thường. + Mitigation: giữ canonical casing cho grammar case-sensitive; test quoted/string/comment/UDF. + +## Branch test matrix + +- Chung cho mỗi PR: targeted tests, `AllPlugins` build, `swiftlint lint --strict`, rebase `main` trước test. +- SingleStore: metadata, query thường, parameterized query, Helios/self-managed, TLS default, unsupported capabilities off, fallback cho version ngoài 8.9/9.0. +- Bundled profiles: version gates cho PostgreSQL-family, SQLite, ClickHouse; runtime catalog vs curated fallback. +- Registry profiles: permission fallback, per-plugin version profile, dialect exclusions không khai báo SQL dialect phù hợp. +- Uppercase: insert text keyword/built-in, formatter, setting label/default off, không đổi identifiers/string/comment/quoted identifiers. + +## Rollback + +- Revert từng PR độc lập trên `main` vì phạm vi file ownership tách. +- Nếu branch sửa shared registry entry và gây regression: revert PR đó rồi rebase các PR chưa merge. + +## TODO + +- [ ] Merge foundation vào `main` +- [ ] Cập nhật `main` local +- [ ] Tạo 4 worktree Wave 2 từ `main` +- [ ] Giữ ownership file không chồng lấn +- [ ] Viết test theo từng branch +- [ ] Rebase từng branch lên `main` mới nhất trước test +- [ ] Chạy build/lint/targeted tests cho từng PR + +## Done when + +- 4 PR mergeable độc lập, không conflict ownership. +- Không feature nào giả định server version mới nhất khi version không parse được. +- SingleStore hoạt động như database type riêng trên MySQL wire transport. +- Uppercase chỉ tác động keyword + built-in functions khi setting bật. + +## Notes + +- Không commit nếu chưa có approval explicit. +- Không sửa generated `.xcodeproj`, secrets, build artifacts. diff --git a/plans/20260812-sql-completion-singlestore-worktrees/phase-03-docs-acceptance.md b/plans/20260812-sql-completion-singlestore-worktrees/phase-03-docs-acceptance.md new file mode 100644 index 000000000..17afa02a6 --- /dev/null +++ b/plans/20260812-sql-completion-singlestore-worktrees/phase-03-docs-acceptance.md @@ -0,0 +1,82 @@ +# Phase 03: Docs & Acceptance + +## Overview + +- Wave: 3 +- Branch: `codex/docs/query-completion-singlestore` +- Worktree: `/Users/hangvalong/Code/projects/worktrees/TablePro-codex-docs-query-completion-singlestore` +- Status: pending +- Priority: P2 +- Gate: chỉ bắt đầu sau khi 4 PR Wave 2 đã merge vào `main` + +## Scope và ownership + +- README, public `docs/`, changelog, và phần driver inventory được phép sửa trong `CLAUDE.md`. +- Trang SingleStore, TLS, URL scheme, version support, compatibility limits. +- Đồng bộ README với danh sách database hiện đang thiếu. +- Sửa conflict giữa ví dụ formatter và test function casing. +- Không bulk-update `.docs/` vì đây là case-study lịch sử. +- Chạy acceptance suite và full build cuối. + +## Data flow + +1. Tổng hợp behavior thực tế từ foundation + 4 PR Wave 2 đã merge. +2. Đồng bộ docs public theo capability thật, version support thật, compatibility limits thật. +3. Chạy acceptance/full build trên `main`. +4. Nếu acceptance fail: trả lỗi về branch gây regression, không sửa lan sang docs ngoài scope. + +## Acceptance checklist + +- Hai tab khác database không gợi ý lẫn bảng/cột. +- Feature mới hơn server version không xuất hiện. +- Version không parse được dùng conservative fallback. +- SingleStore Helios và self-managed chạy metadata, query thường, parameterized query. +- `Cmd+T`, `Cmd+Enter`, `Cmd+Shift+Enter`, `Cmd+Option+Enter` không regression. +- Keyword và built-in function được viết hoa khi setting bật; identifiers giữ nguyên. + +## Risks + +- High: docs mô tả capability rộng hơn implementation thật. + Mitigation: chỉ document behavior đã pass acceptance. +- Medium: chỉnh README/changelog chạm vùng project-owned ngoài scope. + Mitigation: giới hạn đúng README, `docs/`, changelog, driver inventory section được phép. +- Medium: formatter example và casing test xung đột. + Mitigation: fix example theo canonical behavior đã test pass. + +## Backwards compatibility + +- Docs phải nêu rõ SingleStore là database độc lập, chỉ tương thích MySQL protocol và một phần SQL. +- Nêu support chính thức 8.9/9.0; version khác dùng profile bảo thủ. +- Nêu rõ compatibility limits: foreign-key enforcement, trigger editing, `LIKE ... ESCAPE` không hỗ trợ. + +## Verification + +- Chạy acceptance suite cuối. +- Chạy full build cuối trên `main` đã chứa đủ 5 PR code. +- Đảm bảo không có regression shortcut editor và casing. + +## Rollback + +- Revert docs branch nếu chỉ sai tài liệu. +- Nếu acceptance phát hiện regression code: không patch chéo docs branch; mở fix riêng trên branch code tương ứng rồi rerun acceptance. + +## TODO + +- [ ] Chờ 4 PR Wave 2 merge +- [ ] Tạo worktree docs từ `main` mới nhất +- [ ] Cập nhật README/public docs/changelog/driver inventory được phép +- [ ] Viết trang SingleStore + TLS + URL scheme + version support + limits +- [ ] Đồng bộ ví dụ formatter với behavior function casing +- [ ] Chạy acceptance suite +- [ ] Chạy full build cuối + +## Done when + +- Docs phản ánh đúng behavior đã merge. +- Acceptance suite và full build pass. +- Không sửa `.docs/` hàng loạt, không vượt scope ownership. + +## Notes + +- Không commit nếu chưa có approval explicit. +- Không sửa repository `CLAUDE.md` ngoài phần driver inventory đã được ownership cho phép. diff --git a/plans/20260812-sql-completion-singlestore-worktrees/plan.md b/plans/20260812-sql-completion-singlestore-worktrees/plan.md new file mode 100644 index 000000000..4d5316c0a --- /dev/null +++ b/plans/20260812-sql-completion-singlestore-worktrees/plan.md @@ -0,0 +1,67 @@ +--- +title: "SQL completion và SingleStore theo worktree" +description: "Kế hoạch 3 wave để tách foundation, các profile song song và docs/acceptance." +status: in_progress +priority: P1 +effort: 5d +branch: codex/refactor/query-completion-foundation +tags: [sql, completion, singlestore, worktree, plugin-kit] +created: 2026-08-12 +--- + +# Kế hoạch tổng + +Mục tiêu: sửa SQL completion theo `DatabaseScope` từng query tab, thêm completion profile theo engine/version, tách `SingleStore` thành database type riêng nhưng tái dùng MySQL transport, mở rộng auto-uppercase cho keyword + built-in function, và chốt docs/acceptance cuối. + +## Phases + +1. [Phase 01 - Foundation](./phase-01-foundation.md) + Status: implemented, awaiting executable Xcode verification and commit approval. Blocker cho toàn bộ Wave 2. +2. [Phase 02 - Parallel Profiles](./phase-02-parallel-profiles.md) + Status: pending. Chỉ bắt đầu sau khi Phase 01 merge vào `main`. +3. [Phase 03 - Docs & Acceptance](./phase-03-docs-acceptance.md) + Status: pending. Chỉ bắt đầu sau khi cả 4 PR Wave 2 merge. + +## Dependency graph + +- Wave 1 branch `codex/refactor/query-completion-foundation` phải merge trước. +- Sau merge: cập nhật `main`, tạo 4 worktree Wave 2 cùng base từ `main`. +- Wave 2 có thể merge theo bất kỳ thứ tự nào nếu giữ đúng ownership file. +- Wave 3 branch `codex/docs/query-completion-singlestore` chỉ mở sau khi 4 PR Wave 2 đã vào `main`. + +## Shared interfaces + +- Thêm `QueryCompletionProfile`: resolved dialect, statement completions, token-casing policy. +- Thêm `resolveQueryCompletionProfile(databaseTypeId:base:) async throws` vào `PluginDatabaseDriver`; default trả profile gốc. +- Giữ nguyên public initializer hiện có. +- Không bump PluginKit nếu ABI check không phát hiện symbol bị xóa. +- Cache profile theo `DatabaseScope + DatabaseType + serverVersion`. +- Khi version/catalog không xác định: chỉ dùng baseline đã xác minh, không giả định server mới nhất. + +## Test matrix + +- Unit: profile resolution, conservative fallback, metadata lookup theo database type thật, token casing. +- Integration: hai query tab cùng connection khác database/schema; version gates; catalog permission fallback. +- End-to-end/acceptance: SingleStore metadata/query/parameterized query; shortcut execution không regression; uppercase chỉ tác động keyword/built-in. + +## Rollback + +- Wave 1 rollback độc lập bằng revert branch foundation trước khi mở Wave 2. +- Mỗi branch Wave 2 rollback độc lập vì ownership tách file; không trộn commit cross-branch. +- Wave 3 rollback chỉ ảnh hưởng docs/test harness; không rollback code tính năng trừ khi acceptance phát hiện regression. + +## Success criteria + +- Hai tab khác database không gợi ý lẫn bảng/cột. +- Feature mới hơn server version không xuất hiện. +- Version parse lỗi dùng conservative fallback. +- SingleStore Helios và self-managed chạy metadata, query thường, parameterized query. +- `Cmd+T`, `Cmd+Enter`, `Cmd+Shift+Enter`, `Cmd+Option+Enter` không regression. +- Setting bật thì keyword + built-in function viết hoa; identifiers giữ nguyên. + +## Guardrails + +- Không commit nếu chưa có approval explicit. +- Không sửa file ngoài ownership branch tương ứng. +- Mỗi PR phải rebase `main`, chạy targeted tests, `AllPlugins` build, `swiftlint lint --strict`. +- Foundation branch phải chạy thêm PluginKit ABI check nếu có chạm PluginKit. From 4b23a9640b6349e8fc110652b0d472ae82caf350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Nam=20Long?= <26674308+J2TeamNNL@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:22:48 +0700 Subject: [PATCH 3/6] docs(changelog): note per-database query completion scope --- CHANGELOG.md | 1 + docs/features/autocomplete.mdx | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ef5e8d0c..77942e385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Autocomplete in a query tab follows that tab's own database and schema. A tab you rebound to another database used to keep suggesting the tables of the database the object browser was pointing at, so the names it offered belonged to a database the query was not running against. Suggestions are also cached per database, and refreshing or disconnecting drops the cache. - The iOS Shortcuts documentation says which app build each action needs and where to find the full action list, so a Mac release note no longer reads as though the iPhone app already has them. - `Cmd+F` on a table tab used to toggle the filter panel, which meant it closed the panel when it was already open and never searched anything. The filter panel keeps `Cmd+Option+F` and its funnel button in the status bar. - Find Next and Find Previous work on the data grid when its find bar is open, instead of staying dimmed on a table tab. diff --git a/docs/features/autocomplete.mdx b/docs/features/autocomplete.mdx index 98ab8d163..f964de9cd 100644 --- a/docs/features/autocomplete.mdx +++ b/docs/features/autocomplete.mdx @@ -191,6 +191,8 @@ SELECT * FROM ANALYTICS_PROD.DBT_MARTS.ORDERS o WHERE o.| -- columns ## Schema Cache +Each query tab completes against its own database and schema. A tab you rebind to another database suggests that database's tables, not the one the object browser is pointing at. Schemas and dialects are cached per database, so two tabs on the same connection but different databases keep separate caches. + On connection, TablePro fetches table names and loads columns in the background. The column cache holds up to 50 tables with LRU eviction. There is no time-based expiry: cached columns stay until you switch databases or refresh the connection. A failed schema load waits 30 seconds before retrying, to avoid hammering the server. After external schema changes (migrations, CLI work), press `Cmd+R` (**Query > Refresh**) to reload it. Right-clicking the sidebar's Tables header and choosing **Refresh** does the same. From b7d62a70d9f183fe5ceb2805eafdc87e65ed2259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Fri, 21 Aug 2026 22:07:31 +0700 Subject: [PATCH 4/6] fix(editor): make the completion profile resolver Sendable and drop two unused registry lookups --- .../QueryCompletionProfileRegistry.swift | 4 +- .../Query/SchemaProviderRegistry.swift | 17 ----- .../QueryCompletionProfileRegistryTests.swift | 74 ++++++++++--------- 3 files changed, 43 insertions(+), 52 deletions(-) diff --git a/TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift b/TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift index e45310b22..cf914e9b1 100644 --- a/TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift +++ b/TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift @@ -61,7 +61,7 @@ final class QueryCompletionProfileRegistry { databaseType: DatabaseType, serverVersion: String?, base: QueryCompletionProfile, - resolver: @escaping () async throws -> QueryCompletionProfile + resolver: @Sendable @escaping () async throws -> QueryCompletionProfile ) async -> QueryCompletionProfile { let key = CacheKey(scope: scope, databaseType: databaseType, serverVersion: serverVersion) if let profile = profiles[key] { @@ -71,7 +71,7 @@ final class QueryCompletionProfileRegistry { return await task.value } let generation = generations[key, default: 0] - let task = Task { @MainActor in + let task = Task { (try? await resolver()) ?? base } inFlight[key] = task diff --git a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift index 2418bb5e4..0ae550588 100644 --- a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift +++ b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift @@ -43,23 +43,6 @@ final class SchemaProviderRegistry { .store(in: &cancellables) } - func invalidateColumnCache(for connectionId: UUID) { - let matchingProviders = providers.compactMap { scope, provider in - scope.connectionId == connectionId ? provider : nil - } - for provider in matchingProviders { - Task { await provider.clearColumnCache() } - } - } - - func provider(for connectionId: UUID) -> SQLSchemaProvider? { - if let scope = metadataDriverProvider.browseScope(for: connectionId), let provider = provider(for: scope) { - return provider - } - let fallback = DatabaseScope(connectionId: connectionId, database: "", schema: nil) - return provider(for: fallback) - } - func getOrCreate(for connectionId: UUID) -> SQLSchemaProvider { guard let scope = metadataDriverProvider.browseScope(for: connectionId) else { let fallback = DatabaseScope(connectionId: connectionId, database: "", schema: nil) diff --git a/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift b/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift index c6c468aaf..71fa08d77 100644 --- a/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift +++ b/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift @@ -6,7 +6,15 @@ import Testing @Suite("Query completion profile registry") @MainActor struct QueryCompletionProfileRegistryTests { - private func base(revision: String = "base") -> QueryCompletionProfile { + actor Counter { + private(set) var value = 0 + + func increment() { + value += 1 + } + } + + nonisolated private static func base(revision: String = "base") -> QueryCompletionProfile { QueryCompletionProfile( resolvedDialect: nil, statementCompletions: [CompletionEntry(label: "SELECT", insertText: "SELECT")], @@ -21,62 +29,62 @@ struct QueryCompletionProfileRegistryTests { let connectionId = UUID() let firstScope = DatabaseScope(connectionId: connectionId, database: "first", schema: "public") let secondScope = DatabaseScope(connectionId: connectionId, database: "second", schema: "public") - var resolutions = 0 + let resolutions = Counter() _ = await registry.resolve( scope: firstScope, databaseType: .postgresql, serverVersion: "15.2", - base: base() + base: Self.base() ) { - resolutions += 1 - return base(revision: "first") + await resolutions.increment() + return Self.base(revision: "first") } _ = await registry.resolve( scope: firstScope, databaseType: .postgresql, serverVersion: "15.2", - base: base() + base: Self.base() ) { - resolutions += 1 - return base(revision: "cached") + await resolutions.increment() + return Self.base(revision: "cached") } _ = await registry.resolve( scope: secondScope, databaseType: .postgresql, serverVersion: "15.2", - base: base() + base: Self.base() ) { - resolutions += 1 - return base(revision: "second") + await resolutions.increment() + return Self.base(revision: "second") } _ = await registry.resolve( scope: firstScope, databaseType: .cockroachdb, serverVersion: "15.2", - base: base() + base: Self.base() ) { - resolutions += 1 - return base(revision: "type") + await resolutions.increment() + return Self.base(revision: "type") } _ = await registry.resolve( scope: firstScope, databaseType: .postgresql, serverVersion: "16.1", - base: base() + base: Self.base() ) { - resolutions += 1 - return base(revision: "version") + await resolutions.increment() + return Self.base(revision: "version") } - #expect(resolutions == 4) + #expect(await resolutions.value == 4) } @Test("resolution errors return and cache the conservative base profile") func resolutionFailureReturnsBase() async { let registry = QueryCompletionProfileRegistry() let scope = DatabaseScope(connectionId: UUID(), database: "shop", schema: nil) - let conservative = base(revision: "unknown-base") + let conservative = Self.base(revision: "unknown-base") let resolved = await registry.resolve( scope: scope, @@ -95,30 +103,30 @@ struct QueryCompletionProfileRegistryTests { func concurrentRequestsJoinOneResolution() async { let registry = QueryCompletionProfileRegistry() let scope = DatabaseScope(connectionId: UUID(), database: "shop", schema: nil) - var resolutions = 0 + let resolutions = Counter() async let first = registry.resolve( scope: scope, databaseType: .mysql, serverVersion: "8.0", - base: base() + base: Self.base() ) { - resolutions += 1 + await resolutions.increment() await Task.yield() - return base(revision: "resolved") + return Self.base(revision: "resolved") } async let second = registry.resolve( scope: scope, databaseType: .mysql, serverVersion: "8.0", - base: base() + base: Self.base() ) { - resolutions += 1 - return base(revision: "duplicate") + await resolutions.increment() + return Self.base(revision: "duplicate") } let revisions = await [first.revision, second.revision] - #expect(resolutions == 1) + #expect(await resolutions.value == 1) #expect(revisions == ["resolved", "resolved"]) } @@ -131,10 +139,10 @@ struct QueryCompletionProfileRegistryTests { scope: scope, databaseType: .mysql, serverVersion: "8.0", - base: base() + base: Self.base() ) { try? await Task.sleep(nanoseconds: 10_000_000) - return base(revision: "old") + return Self.base(revision: "old") } await Task.yield() registry.invalidate(scope: scope) @@ -142,18 +150,18 @@ struct QueryCompletionProfileRegistryTests { scope: scope, databaseType: .mysql, serverVersion: "8.0", - base: base() + base: Self.base() ) { - base(revision: "current") + Self.base(revision: "current") } _ = await old let cached = await registry.resolve( scope: scope, databaseType: .mysql, serverVersion: "8.0", - base: base() + base: Self.base() ) { - base(revision: "unexpected") + Self.base(revision: "unexpected") } #expect(current.revision == "current") From 2154c19687d4fc76ee78cad8fcee45a019ae1806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Fri, 21 Aug 2026 22:40:09 +0700 Subject: [PATCH 5/6] refactor(editor): key every schema provider lookup by database scope --- .../QueryCompletionProfileRegistry.swift | 60 +++++----- .../Database/DatabaseManager+Metadata.swift | 3 +- .../Query/SchemaProviderRegistry.swift | 26 ++--- .../Services/Query/SchemaRefreshService.swift | 7 +- TablePro/Views/Editor/SQLEditorView.swift | 14 +-- TablePro/Views/Filter/FilterPanelView.swift | 13 ++- .../Main/Child/MainEditorContentView.swift | 4 + ...MainContentCoordinator+QuickSwitcher.swift | 4 +- .../Views/Main/MainContentCoordinator.swift | 1 - .../Query/SchemaRefreshServiceTests.swift | 29 ++++- .../SchemaProviderRegistryTests.swift | 110 ++++++++++-------- .../QueryCompletionProfileRegistryTests.swift | 59 ++++++++++ .../phase-01-foundation.md | 27 +++-- .../plan.md | 2 +- 14 files changed, 235 insertions(+), 124 deletions(-) diff --git a/TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift b/TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift index cf914e9b1..ddea3b8be 100644 --- a/TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift +++ b/TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift @@ -37,22 +37,29 @@ final class QueryCompletionProfileRegistry { revisions[scope, default: 0] } + /// The metadata driver is leased inside the resolver rather than around this call, so a + /// cached profile costs nothing. An engine that cannot pool serves metadata from the + /// session driver, and leasing that for a profile the cache already holds would queue + /// behind, and ahead of, the statements the user is running. func profile( for scope: DatabaseScope, databaseType: DatabaseType, - driver: DatabaseDriver + serverVersion: String?, + metadataProvider: any ScopedMetadataProviding = DatabaseManager.shared ) async -> QueryCompletionProfile { - let base = baseProfile(for: databaseType, serverVersion: driver.serverVersion) + let base = baseProfile(for: databaseType, serverVersion: serverVersion) return await resolve( scope: scope, databaseType: databaseType, - serverVersion: driver.serverVersion, + serverVersion: serverVersion, base: base ) { - try await driver.resolveQueryCompletionProfile( - databaseTypeId: databaseType.rawValue, - base: base - ) + try await metadataProvider.withMetadataDriver(scope: scope) { driver in + try await driver.resolveQueryCompletionProfile( + databaseTypeId: databaseType.rawValue, + base: base + ) + } } } @@ -85,36 +92,33 @@ final class QueryCompletionProfileRegistry { 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 } + discardEntries { $0 == scope } } func invalidate(connectionId: UUID) { - let scopes = Set(profiles.keys.map(\.scope) + inFlight.keys.map(\.scope)) - .filter { $0.connectionId == connectionId } - for scope in scopes { + for scope in cachedScopes(of: connectionId) { 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 { + discardEntries { $0.connectionId == connectionId } + } + + private func cachedScopes(of connectionId: UUID) -> Set { + Set(profiles.keys.map(\.scope) + inFlight.keys.map(\.scope)) + .filter { $0.connectionId == connectionId } + } + + /// The generation bump is what fences a resolution that is already running: it completes + /// against a key whose generation moved, so it discards its own result instead of writing + /// a profile the refresh was meant to replace. + private func discardEntries(matching matches: (DatabaseScope) -> Bool) { + for key in profiles.keys where matches(key.scope) { generations[key, default: 0] &+= 1 } - for key in taskKeys { + for key in Array(inFlight.keys) where matches(key.scope) { generations[key, default: 0] &+= 1 - inFlight[key]?.cancel() - inFlight.removeValue(forKey: key) + inFlight.removeValue(forKey: key)?.cancel() } - profiles = profiles.filter { $0.key.scope.connectionId != connectionId } + profiles = profiles.filter { !matches($0.key.scope) } } private func baseProfile( diff --git a/TablePro/Core/Database/DatabaseManager+Metadata.swift b/TablePro/Core/Database/DatabaseManager+Metadata.swift index 5d773c5f3..1fe2288dc 100644 --- a/TablePro/Core/Database/DatabaseManager+Metadata.swift +++ b/TablePro/Core/Database/DatabaseManager+Metadata.swift @@ -28,7 +28,8 @@ extension ScopedMetadataProviding { } /// For reads that belong to the sidebar rather than to a tab: the object list, the - /// database and quick switchers, autocomplete, and the AI schema context. + /// database and quick switchers, and the AI schema context. Completion is not one of + /// them any more: a query tab completes against its own scope. func withBrowseMetadataDriver( connectionId: UUID, workload: MetadataConnectionPool.Workload = .interactive, diff --git a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift index 0ae550588..2e0371396 100644 --- a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift +++ b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift @@ -2,8 +2,8 @@ // SchemaProviderRegistry.swift // TablePro // -// Manages shared SQLSchemaProvider instances across connections. -// Ref-counted with grace period removal to avoid redundant schema loads. +// Manages shared SQLSchemaProvider instances, one per DatabaseScope. +// Ref-counted per connection with grace period removal to avoid redundant schema loads. // import Combine @@ -43,14 +43,6 @@ final class SchemaProviderRegistry { .store(in: &cancellables) } - func getOrCreate(for connectionId: UUID) -> SQLSchemaProvider { - guard let scope = metadataDriverProvider.browseScope(for: connectionId) else { - let fallback = DatabaseScope(connectionId: connectionId, database: "", schema: nil) - return getOrCreate(for: fallback) - } - return getOrCreate(for: scope) - } - func provider(for scope: DatabaseScope) -> SQLSchemaProvider? { providers[scope] } @@ -85,23 +77,23 @@ final class SchemaProviderRegistry { } }, sampleFieldPaths: { table, limit in - try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connectionId) { driver in + try await metadataProvider.withMetadataDriver(scope: scope) { driver in try await driver.sampleFieldPaths(table: table, limit: limit) } } ) let provider = SQLSchemaProvider(metadataSource: source) providers[scope] = provider - Task { - try? await metadataDriverProvider.withMetadataDriver(scope: scope) { driver in - await provider.loadSchema(using: driver) - } - } return provider } + /// Creates the provider for `scope` if it does not exist yet and loads its object list once. + /// A provider that already holds tables is left as it is, because a tab that reappears has + /// not changed database; `refresh(request:)` is the reload path. func prepare(for scope: DatabaseScope) async -> SQLSchemaProvider { let provider = getOrCreate(for: scope) + let isLoaded = await provider.isSchemaLoaded() + guard !isLoaded else { return provider } try? await metadataDriverProvider.withMetadataDriver(scope: scope) { driver in await provider.loadSchema(using: driver) } @@ -110,7 +102,7 @@ final class SchemaProviderRegistry { func refresh(request: DataRefreshRequest) { let matchingProviders = providers.filter { scope, _ in - scope.connectionId == request.connectionId && (request.scope == nil || request.scope == scope) + scope.connectionId == request.connectionId && request.reaches(tabScope: scope) } for (scope, provider) in matchingProviders { Task { diff --git a/TablePro/Core/Services/Query/SchemaRefreshService.swift b/TablePro/Core/Services/Query/SchemaRefreshService.swift index f82a83177..22067ecae 100644 --- a/TablePro/Core/Services/Query/SchemaRefreshService.swift +++ b/TablePro/Core/Services/Query/SchemaRefreshService.swift @@ -160,12 +160,7 @@ final class SchemaRefreshService { ) return } - guard let provider = providerRegistry.provider(for: browseScope) else { - Self.logger.debug( - "[schema] autocomplete sync skipped, no provider connId=\(connectionId, privacy: .public)" - ) - return - } + let provider = providerRegistry.getOrCreate(for: browseScope) let browseDatabase = browseScope.database let tables = schemaService.allLoadedTables(for: connectionId) let schemas = schemaService.schemas(for: connectionId) diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index c3156f1d0..cf5fe459a 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -170,14 +170,12 @@ struct SQLEditorView: View { private func resolveCompletionProfile() async { guard let request = completionProfileRequest else { return } - let profile = try? await DatabaseManager.shared.withMetadataDriver(scope: request.scope) { driver in - await QueryCompletionProfileRegistry.shared.profile( - for: request.scope, - databaseType: request.databaseType, - driver: driver - ) - } - guard !Task.isCancelled, let profile else { return } + let profile = await QueryCompletionProfileRegistry.shared.profile( + for: request.scope, + databaseType: request.databaseType, + serverVersion: request.serverVersion + ) + guard !Task.isCancelled else { return } completionAdapter.configure( schemaProvider: schemaProvider, databaseType: databaseType, diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index ae8137fdd..6fbbb6549 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -345,7 +345,11 @@ struct FilterPanelView: View { fieldPaths = [] return } - let provider = SchemaProviderRegistry.shared.getOrCreate(for: coordinator.connection.id) + guard let scope = coordinator.selectedTabScope else { + fieldPaths = [] + return + } + let provider = SchemaProviderRegistry.shared.getOrCreate(for: scope) let paths = await provider.fieldPaths(for: tableName) guard !Task.isCancelled else { return } fieldPaths = paths @@ -357,11 +361,14 @@ struct FilterPanelView: View { } private func refreshRawSQLCompletionProvider() { - guard isSQLDialect, let tableName = coordinator.currentTableName else { + guard isSQLDialect, + let tableName = coordinator.currentTableName, + let scope = coordinator.selectedTabScope + else { rawSQLCompletionProvider = nil return } - let schemaProvider = SchemaProviderRegistry.shared.getOrCreate(for: coordinator.connection.id) + let schemaProvider = SchemaProviderRegistry.shared.getOrCreate(for: scope) rawSQLCompletionProvider = RawSQLFilterCompletionProvider( schemaProvider: schemaProvider, databaseType: databaseType, diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index ebf338fcc..a5803bcae 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -453,6 +453,10 @@ struct MainEditorContentView: View { .onAppear { coordinator.clearRestoredCursor(for: tab.id) } + .task(id: queryScope) { + guard let queryScope else { return } + _ = await SchemaProviderRegistry.shared.prepare(for: queryScope) + } } private func reloadFileForTab(tabId: UUID, url: URL) { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index 404bfcd77..2a2eb1f6b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -15,6 +15,8 @@ extension MainContentCoordinator { return } let browseSchema = services.databaseManager.session(for: connectionId)?.browseSchema + let switcherScope = browseScope + ?? DatabaseScope(connectionId: connectionId, database: connection.database, schema: nil) let openTables = Set( tabManager.tabs .filter { $0.tabType == .table } @@ -28,7 +30,7 @@ extension MainContentCoordinator { } ) let panelView = QuickSwitcherPanelView( - schemaProvider: SchemaProviderRegistry.shared.getOrCreate(for: connectionId), + schemaProvider: SchemaProviderRegistry.shared.getOrCreate(for: switcherScope), connectionId: connectionId, databaseType: connection.type, openTables: openTables, diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 248132cb9..537c67652 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -610,7 +610,6 @@ final class MainContentCoordinator { ) self.persistence = TabPersistenceCoordinator(connectionId: connection.id) - _ = services.schemaProviderRegistry.getOrCreate(for: connection.id) ConnectionDataCache.shared(for: connection.id).ensureLoaded() changeManager.undoManagerProvider = { [weak self] in self?.contentWindow?.undoManager } changeManager.onUndoApplied = { [weak self] result in self?.handleUndoResult(result) } diff --git a/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift b/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift index efed2cf5a..fb38772d5 100644 --- a/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift +++ b/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift @@ -100,7 +100,7 @@ struct SchemaRefreshServiceTests { _ = await (first, second, third) #expect(driver.fetchTablesCallCount == 1) - #expect(provider.acquisitionCount == 1) + #expect(provider.requestedWorkloads.filter { $0 == .bulk }.count == 1) #expect(schemaService.state(for: connection.id) == .loaded(driver.tablesToReturn)) } @@ -116,12 +116,12 @@ struct SchemaRefreshServiceTests { await service.refresh(connection: connection) - #expect(provider.requestedScopes.count == 1) let scope = try #require(provider.requestedScopes.first) #expect(scope.connectionId == connection.id) #expect(scope.database == "inventory") #expect(scope.schema == "dbo") - #expect(provider.requestedWorkloads == [.bulk]) + #expect(Set(provider.requestedScopes) == [scope]) + #expect(provider.requestedWorkloads.first == .bulk) } @Test("an empty browse database is server scoped, so the refresh still runs") @@ -138,6 +138,7 @@ struct SchemaRefreshServiceTests { let scope = try #require(provider.requestedScopes.first) #expect(scope.isServerScoped) #expect(driver.fetchTablesCallCount == 1) + #expect(provider.requestedWorkloads.filter { $0 == .bulk }.count == 1) #expect(schemaService.state(for: connection.id) == .loaded(driver.tablesToReturn)) } @@ -185,6 +186,28 @@ struct SchemaRefreshServiceTests { #expect(names.sorted() == ["customers", "orders"]) } + @Test("the sync creates the browse scope's provider when no tab has one yet") + func autocompleteSyncCreatesTheBrowseScopeProvider() async throws { + let driver = MockDatabaseDriver() + driver.tablesToReturn = [TableInfo(name: "orders", type: .table, rowCount: 0, schema: nil)] + let provider = FakeScopedMetadataProvider(driver: driver) + let registry = SchemaProviderRegistry(metadataDriverProvider: provider) + let connection = TestFixtures.makeConnection() + let service = makeService( + schemaService: SchemaService(), + provider: provider, + providerRegistry: registry + ) + + await service.refresh(connection: connection) + + let scope = DatabaseScope(connectionId: connection.id, database: "testdb", schema: nil) + let schemaProvider = try #require(registry.provider(for: scope)) + let names = await schemaProvider.getTables().map(\.name) + #expect(names == ["orders"]) + #expect(driver.fetchTablesCallCount == 1) + } + @Test("no browse scope leaves the autocomplete provider untouched instead of clearing it") func autocompleteSyncWithoutABrowseScopeKeepsTheCachedTables() async { let driver = MockDatabaseDriver() diff --git a/TableProTests/Core/Services/SchemaProviderRegistryTests.swift b/TableProTests/Core/Services/SchemaProviderRegistryTests.swift index ef15e94b5..dbee4c9d4 100644 --- a/TableProTests/Core/Services/SchemaProviderRegistryTests.swift +++ b/TableProTests/Core/Services/SchemaProviderRegistryTests.swift @@ -11,96 +11,114 @@ import Testing @Suite("SchemaProviderRegistry") @MainActor struct SchemaProviderRegistryTests { - @Test("getOrCreate returns new provider for unknown connectionId") + private func scope( + _ connectionId: UUID = UUID(), + database: String = "shop", + schema: String? = nil + ) -> DatabaseScope { + DatabaseScope(connectionId: connectionId, database: database, schema: schema) + } + + @Test("getOrCreate returns new provider for unknown scope") func getOrCreateNewProvider() { let registry = SchemaProviderRegistry() - let id = UUID() - let provider = registry.getOrCreate(for: id) - #expect(registry.provider(for: id) === provider) + let scoped = scope() + let provider = registry.getOrCreate(for: scoped) + #expect(registry.provider(for: scoped) === provider) } - @Test("getOrCreate returns same provider for same connectionId") + @Test("getOrCreate returns same provider for same scope") func getOrCreateReturnsSameProvider() { let registry = SchemaProviderRegistry() - let id = UUID() - let p1 = registry.getOrCreate(for: id) - let p2 = registry.getOrCreate(for: id) - #expect(p1 === p2) + let scoped = scope() + #expect(registry.getOrCreate(for: scoped) === registry.getOrCreate(for: scoped)) } - @Test("provider(for:) returns nil for unknown connectionId") + @Test("provider(for:) returns nil for unknown scope") func providerForUnknownReturnsNil() { let registry = SchemaProviderRegistry() - #expect(registry.provider(for: UUID()) == nil) + #expect(registry.provider(for: scope()) == nil) } - @Test("provider(for:) returns provider after getOrCreate") - func providerForKnownReturnsProvider() { + @Test("scopes on one connection get their own providers") + func scopesOnOneConnectionAreIndependent() { let registry = SchemaProviderRegistry() - let id = UUID() - let created = registry.getOrCreate(for: id) - #expect(registry.provider(for: id) === created) + let connectionId = UUID() + let sales = scope(connectionId, database: "shop", schema: "sales") + let audit = scope(connectionId, database: "shop", schema: "audit") + let warehouse = scope(connectionId, database: "warehouse") + let salesProvider = registry.getOrCreate(for: sales) + let auditProvider = registry.getOrCreate(for: audit) + let warehouseProvider = registry.getOrCreate(for: warehouse) + #expect(salesProvider !== auditProvider) + #expect(salesProvider !== warehouseProvider) + #expect(auditProvider !== warehouseProvider) } @Test("retain increments refcount, prevents purge") func retainPreventsRemoval() { let registry = SchemaProviderRegistry() - let id = UUID() - _ = registry.getOrCreate(for: id) - registry.retain(for: id) + let scoped = scope() + _ = registry.getOrCreate(for: scoped) + registry.retain(for: scoped.connectionId) registry.purgeUnused() - #expect(registry.provider(for: id) != nil) + #expect(registry.provider(for: scoped) != nil) } @Test("release decrements refcount to zero, schedules deferred removal") func releaseSchedulesDeferredRemoval() { let registry = SchemaProviderRegistry() - let id = UUID() - _ = registry.getOrCreate(for: id) - registry.retain(for: id) - registry.release(for: id) - #expect(registry.provider(for: id) != nil) + let scoped = scope() + _ = registry.getOrCreate(for: scoped) + registry.retain(for: scoped.connectionId) + registry.release(for: scoped.connectionId) + #expect(registry.provider(for: scoped) != nil) } - @Test("clear removes provider, refcount, and pending removal") + @Test("clear removes every scope of the connection, its refcount and pending removal") func clearRemovesEverything() { let registry = SchemaProviderRegistry() - let id = UUID() - _ = registry.getOrCreate(for: id) - registry.retain(for: id) - registry.clear(for: id) - #expect(registry.provider(for: id) == nil) + let connectionId = UUID() + let shop = scope(connectionId, database: "shop") + let warehouse = scope(connectionId, database: "warehouse") + _ = registry.getOrCreate(for: shop) + _ = registry.getOrCreate(for: warehouse) + registry.retain(for: connectionId) + registry.clear(for: connectionId) + #expect(registry.provider(for: shop) == nil) + #expect(registry.provider(for: warehouse) == nil) } @Test("purgeUnused removes orphaned providers with zero refcount and no pending task") func purgeRemovesOrphans() { let registry = SchemaProviderRegistry() - let id = UUID() - _ = registry.getOrCreate(for: id) + let scoped = scope() + _ = registry.getOrCreate(for: scoped) registry.purgeUnused() - #expect(registry.provider(for: id) == nil) + #expect(registry.provider(for: scoped) == nil) } @Test("purgeUnused does not remove providers with pending removal task") func purgeKeepsProvidersWithPendingTask() { let registry = SchemaProviderRegistry() - let id = UUID() - _ = registry.getOrCreate(for: id) - registry.retain(for: id) - registry.release(for: id) + let scoped = scope() + _ = registry.getOrCreate(for: scoped) + registry.retain(for: scoped.connectionId) + registry.release(for: scoped.connectionId) registry.purgeUnused() - #expect(registry.provider(for: id) != nil) + #expect(registry.provider(for: scoped) != nil) } @Test("multiple connections are independent") func multipleConnectionsIndependent() { let registry = SchemaProviderRegistry() - let id1 = UUID(), id2 = UUID() - let p1 = registry.getOrCreate(for: id1) - let p2 = registry.getOrCreate(for: id2) - #expect(p1 !== p2) - registry.clear(for: id1) - #expect(registry.provider(for: id1) == nil) - #expect(registry.provider(for: id2) != nil) + let first = scope() + let second = scope() + let firstProvider = registry.getOrCreate(for: first) + let secondProvider = registry.getOrCreate(for: second) + #expect(firstProvider !== secondProvider) + registry.clear(for: first.connectionId) + #expect(registry.provider(for: first) == nil) + #expect(registry.provider(for: second) != nil) } } diff --git a/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift b/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift index 71fa08d77..6dba3a06a 100644 --- a/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift +++ b/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift @@ -3,6 +3,29 @@ import Foundation import TableProPluginKit import Testing +@MainActor +private final class LeaseCountingMetadataProvider: ScopedMetadataProviding { + private let driver: MockDatabaseDriver + private let scope: DatabaseScope + private(set) var leaseCount = 0 + + init(driver: MockDatabaseDriver, browseScope: DatabaseScope) { + self.driver = driver + self.scope = browseScope + } + + func withMetadataDriver( + scope: DatabaseScope, + workload: MetadataConnectionPool.Workload, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + leaseCount += 1 + return try await body(driver) + } + + func browseScope(for connectionId: UUID) -> DatabaseScope? { scope } +} + @Suite("Query completion profile registry") @MainActor struct QueryCompletionProfileRegistryTests { @@ -23,6 +46,42 @@ struct QueryCompletionProfileRegistryTests { ) } + @Test("a cached profile is served without leasing a metadata driver") + func cachedProfileSkipsTheDriverLease() async { + let registry = QueryCompletionProfileRegistry() + let connectionId = UUID() + let scope = DatabaseScope(connectionId: connectionId, database: "shop", schema: nil) + let metadataProvider = LeaseCountingMetadataProvider( + driver: MockDatabaseDriver(), + browseScope: scope + ) + + _ = await registry.profile( + for: scope, + databaseType: .postgresql, + serverVersion: "15.2", + metadataProvider: metadataProvider + ) + _ = await registry.profile( + for: scope, + databaseType: .postgresql, + serverVersion: "15.2", + metadataProvider: metadataProvider + ) + + #expect(metadataProvider.leaseCount == 1) + + registry.invalidate(scope: scope) + _ = await registry.profile( + for: scope, + databaseType: .postgresql, + serverVersion: "15.2", + metadataProvider: metadataProvider + ) + + #expect(metadataProvider.leaseCount == 2) + } + @Test("cache keys include scope, database type, and server version") func cacheKeyIncludesEveryRuntimeDimension() async { let registry = QueryCompletionProfileRegistry() diff --git a/plans/20260812-sql-completion-singlestore-worktrees/phase-01-foundation.md b/plans/20260812-sql-completion-singlestore-worktrees/phase-01-foundation.md index 9267e17d0..f1f51e2f2 100644 --- a/plans/20260812-sql-completion-singlestore-worktrees/phase-01-foundation.md +++ b/plans/20260812-sql-completion-singlestore-worktrees/phase-01-foundation.md @@ -5,7 +5,7 @@ - Wave: 1 - Branch: `codex/refactor/query-completion-foundation` - Worktree: `/Users/hangvalong/Code/projects/worktrees/TablePro-codex-refactor-query-completion-foundation` -- Status: implemented, verification blocked by local Xcode toolchain +- Status: implemented, verification runs in CI (no Xcode toolchain on the dev machine) - Priority: P1 - Gate: phải merge vào `main` trước khi mở Wave 2 @@ -70,14 +70,23 @@ ## TODO -- [ ] Tạo `QueryCompletionProfile` -- [ ] Thêm API resolve profile vào PluginKit -- [ ] Dựng registry + conservative fallback -- [ ] Scope cache theo query tab -- [ ] Truyền `scope/serverVersion/profileRevision` tới editor -- [ ] Đổi precedence metadata lookup -- [ ] Viết test 2-tab và fallback version -- [ ] Chạy build/lint/ABI check +- [x] Tạo `QueryCompletionProfile` +- [x] Thêm API resolve profile vào PluginKit +- [x] Dựng registry + conservative fallback +- [x] Scope cache theo query tab +- [x] Truyền `scope/serverVersion/profileRevision` tới editor +- [x] Đổi precedence metadata lookup +- [x] Viết test 2-tab và fallback version +- [ ] Chạy build/lint/ABI check (CI, không chạy được local) + +## Việc đã bổ sung sau review + +- Bỏ overload `getOrCreate(for connectionId:)`: mọi call site phải nói rõ scope. Filter panel dùng scope của tab đang chọn, quick switcher dùng browse scope. +- `sampleFieldPaths` trong provider đã scope-hóa; trước đó vẫn đọc browse scope nên MongoDB field path lấy sai database. +- `prepare(for:)` là đường nạp schema duy nhất và chỉ nạp một lần; editor pane gọi nó qua `.task(id: queryScope)` thay vì fire-and-forget trong getter. +- `syncAutocompleteProvider` tạo provider cho browse scope nếu chưa có, để danh sách object của sidebar không bị bỏ rơi. +- `QueryCompletionProfileRegistry.profile` lease metadata driver bên trong resolver, nên cache hit không còn chiếm session driver. +- Cập nhật `SchemaProviderRegistryTests` (đang gọi API cũ nên test target không build) và thêm test precedence metadata lookup. ## Done when diff --git a/plans/20260812-sql-completion-singlestore-worktrees/plan.md b/plans/20260812-sql-completion-singlestore-worktrees/plan.md index 4d5316c0a..578633804 100644 --- a/plans/20260812-sql-completion-singlestore-worktrees/plan.md +++ b/plans/20260812-sql-completion-singlestore-worktrees/plan.md @@ -16,7 +16,7 @@ Mục tiêu: sửa SQL completion theo `DatabaseScope` từng query tab, thêm c ## Phases 1. [Phase 01 - Foundation](./phase-01-foundation.md) - Status: implemented, awaiting executable Xcode verification and commit approval. Blocker cho toàn bộ Wave 2. + Status: implemented, đã review và bổ sung; chờ CI xanh trước khi merge. Blocker cho toàn bộ Wave 2. 2. [Phase 02 - Parallel Profiles](./phase-02-parallel-profiles.md) Status: pending. Chỉ bắt đầu sau khi Phase 01 merge vào `main`. 3. [Phase 03 - Docs & Acceptance](./phase-03-docs-acceptance.md) From 22c06e021c9711ca4a191a00dfcc112353d2c5eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Fri, 21 Aug 2026 22:40:13 +0700 Subject: [PATCH 6/6] test(plugins): cover a variant type resolving its own editor metadata --- CHANGELOG.md | 1 + .../PluginManagerEditorMetadataTests.swift | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 TableProTests/Core/Plugins/PluginManagerEditorMetadataTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 04487939f..34c407ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Case-insensitive filters and completion on Redshift use its own dialect, so they no longer fall back to PostgreSQL's ASCII-only ILIKE. - Fixed a crash on macOS 26 and later when the editor redrew a diagnostic underline or search highlight whose text had been edited away. - The XLSX, MQL and SQL Import plugins linked to a documentation page that did not exist. They now point at Import & Export. diff --git a/TableProTests/Core/Plugins/PluginManagerEditorMetadataTests.swift b/TableProTests/Core/Plugins/PluginManagerEditorMetadataTests.swift new file mode 100644 index 000000000..e604f8a40 --- /dev/null +++ b/TableProTests/Core/Plugins/PluginManagerEditorMetadataTests.swift @@ -0,0 +1,47 @@ +// +// PluginManagerEditorMetadataTests.swift +// TableProTests +// +// A variant type (Redshift, CockroachDB, PGlite on the PostgreSQL plugin) has its own +// curated snapshot, so the editor must read the variant's entry. Looking it up by plugin +// type instead handed a Redshift tab PostgreSQL's dialect, which is where its ASCII-only +// ILIKE came from. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("PluginManager editor metadata") +@MainActor +struct PluginManagerEditorMetadataTests { + @Test("a variant type resolves its own dialect rather than the primary plugin's") + func variantDialectWinsOverPrimary() throws { + let redshift = try #require(PluginManager.shared.sqlDialect(for: .redshift)) + let postgresql = try #require(PluginManager.shared.sqlDialect(for: .postgresql)) + + #expect(redshift.caseSensitivityStyle == .caseFoldFunction) + #expect(postgresql.caseSensitivityStyle == .ilikeOperator) + #expect(PluginManager.shared.caseSensitivityStyle(for: .redshift) == .caseFoldFunction) + } + + @Test("a variant type resolves its own identifier quoting") + func variantQuotingComesFromItsOwnEntry() throws { + let mariadb = try #require(PluginManager.shared.sqlDialect(for: .mariadb)) + #expect(mariadb.identifierQuote == "`") + } + + @Test("a type with no snapshot of its own falls back to its plugin type") + func unknownVariantFallsBackToPluginSnapshot() throws { + let aliasTypeId = "PluginManagerEditorMetadataTestsFork" + let alias = DatabaseType(rawValue: aliasTypeId) + #expect(PluginMetadataRegistry.shared.snapshot(forTypeId: aliasTypeId) == nil) + + PluginMetadataRegistry.shared.registerTypeAlias(aliasTypeId, primaryTypeId: "MySQL") + + let dialect = try #require(PluginManager.shared.sqlDialect(for: alias)) + #expect(dialect.identifierQuote == "`") + #expect(PluginManager.shared.editorLanguage(for: alias) == .sql) + } +}