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/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index f156fccc9..15e0ef186 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 } @@ -279,6 +283,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/Autocomplete/QueryCompletionService.swift b/TablePro/Core/Autocomplete/QueryCompletionService.swift index 86578cde8..1730dbf79 100644 --- a/TablePro/Core/Autocomplete/QueryCompletionService.swift +++ b/TablePro/Core/Autocomplete/QueryCompletionService.swift @@ -21,14 +21,22 @@ protocol QueryCompletionService: AnyObject { @MainActor enum QueryCompletionServiceFactory { - static func make(schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?) -> QueryCompletionService { + static func make( + schemaProvider: SQLSchemaProvider?, + databaseType: DatabaseType?, + profile: QueryCompletionProfile? = nil + ) -> QueryCompletionService { let language = databaseType.map { PluginManager.shared.editorLanguage(for: $0) } ?? .sql switch language { case .javascript: return MongoCompletionService(schemaProvider: schemaProvider, databaseType: databaseType) default: - return SQLCompletionService(schemaProvider: schemaProvider, databaseType: databaseType) + return SQLCompletionService( + schemaProvider: schemaProvider, + databaseType: databaseType, + profile: profile + ) } } } diff --git a/TablePro/Core/Autocomplete/SQLCompletionService.swift b/TablePro/Core/Autocomplete/SQLCompletionService.swift index 308f4ec5a..cf68f571d 100644 --- a/TablePro/Core/Autocomplete/SQLCompletionService.swift +++ b/TablePro/Core/Autocomplete/SQLCompletionService.swift @@ -8,9 +8,16 @@ final class SQLCompletionService: QueryCompletionService { private static let windowRadius = 5_000 - init(schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?) { - let dialect = databaseType.flatMap { PluginManager.shared.sqlDialect(for: $0) } - let statementCompletions = databaseType.flatMap { PluginManager.shared.statementCompletions(for: $0) } ?? [] + init( + schemaProvider: SQLSchemaProvider?, + databaseType: DatabaseType?, + profile: QueryCompletionProfile? = nil + ) { + let dialect = profile?.resolvedDialect + ?? databaseType.flatMap { PluginManager.shared.sqlDialect(for: $0) } + let statementCompletions = profile?.statementCompletions + ?? databaseType.flatMap { PluginManager.shared.statementCompletions(for: $0) } + ?? [] self.engine = CompletionEngine( schemaProvider: schemaProvider, databaseType: databaseType, diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 43165f725..c6a0b63c9 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 @@ -264,6 +269,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 0b9b4cf43..5da565198 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -431,6 +431,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 9b6595128..55644a050 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -136,6 +136,16 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor 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 aecd495f6..2a153f227 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 bfa4f64ef..bc1dc21a4 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,53 @@ 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 metadataProvider = metadataDriverProvider let source = SQLSchemaProvider.ColumnMetadataSource( fetchColumns: { table, schema in - try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connectionId) { driver in + try await metadataProvider.withMetadataDriver(scope: scope) { driver in if let schema { return try await driver.fetchColumns(table: table, schema: schema) } @@ -67,12 +92,12 @@ final class SchemaProviderRegistry { } }, fetchAllColumns: { - try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connectionId, workload: .bulk) { driver in + try await metadataProvider.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 metadataProvider.withMetadataDriver(scope: scope) { driver in try await driver.fetchTables(schema: schema) } }, @@ -83,10 +108,37 @@ final class SchemaProviderRegistry { } ) 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) @@ -101,7 +153,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 { @@ -110,21 +162,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 025d23e87..a2801e99a 100644 --- a/TablePro/Core/Services/Query/SchemaRefreshService.swift +++ b/TablePro/Core/Services/Query/SchemaRefreshService.swift @@ -153,22 +153,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/QueryCompletionAdapter.swift b/TablePro/Views/Editor/QueryCompletionAdapter.swift index 5e247acf3..50fe8971a 100644 --- a/TablePro/Views/Editor/QueryCompletionAdapter.swift +++ b/TablePro/Views/Editor/QueryCompletionAdapter.swift @@ -11,6 +11,7 @@ import CodeEditSourceEditor import CodeEditTextView import os import SwiftUI +import TableProPluginKit @MainActor final class QueryCompletionAdapter: CodeSuggestionDelegate { @@ -37,8 +38,16 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { self.service = QueryCompletionServiceFactory.make(schemaProvider: schemaProvider, databaseType: databaseType) } - func configure(schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?) { - service = QueryCompletionServiceFactory.make(schemaProvider: schemaProvider, databaseType: databaseType) + func configure( + schemaProvider: SQLSchemaProvider?, + databaseType: DatabaseType?, + profile: QueryCompletionProfile? = nil + ) { + service = QueryCompletionServiceFactory.make( + schemaProvider: schemaProvider, + databaseType: databaseType, + profile: profile + ) service.updateFavoriteKeywords(favoriteKeywords) session = nil clearRefilterState() diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index fa771350d..197386122 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -21,6 +21,8 @@ struct QueryEditorView: View { var onExecuteAllStatements: (() -> Void)? var schemaProvider: SQLSchemaProvider? var databaseType: DatabaseType? + var databaseScope: DatabaseScope? + var serverVersion: String? var connectionId: UUID? var connectionAIPolicy: AIConnectionPolicy? var tabID: UUID? @@ -67,6 +69,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/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index f68dda4e1..cc7b54d67 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -21,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? @@ -107,6 +109,9 @@ struct SQLEditorView: View { completionAdapter.configure(schemaProvider: schemaProvider, databaseType: databaseType) setupFavoritesObserver() } + .task(id: completionProfileRequest) { + await resolveCompletionProfile() + } .onChange(of: colorScheme) { editorConfiguration = Self.makeConfiguration() } @@ -137,6 +142,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() { @@ -202,6 +234,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 1704c02b8..db69a6663 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -372,6 +372,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) VerticalCollapsibleSplitView( isBottomCollapsed: Binding( get: { tab.display.isResultsCollapsed }, @@ -399,8 +400,10 @@ struct MainEditorContentView: View { onExecute: { coordinator.runQuery() }, onExecuteWithoutLimit: { coordinator.runQuery(bypassRowLimit: true) }, onExecuteAllStatements: { coordinator.runAllStatements() }, - 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") + } +} 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. 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.