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/2] 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/2] 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.