diff --git a/CHANGELOG.md b/CHANGELOG.md index 4952f80cc..34c407ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Autocomplete in a query tab follows that tab's own database and schema instead of the object browser's, and caches suggestions per database. + ### Fixed +- Case-insensitive filters and completion on Redshift use its own dialect, so they no longer fall back to PostgreSQL's ASCII-only ILIKE. - Fixed a crash on macOS 26 and later when the editor redrew a diagnostic underline or search highlight whose text had been edited away. - The XLSX, MQL and SQL Import plugins linked to a documentation page that did not exist. They now point at Import & Export. diff --git a/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..ddea3b8be --- /dev/null +++ b/TablePro/Core/Autocomplete/QueryCompletionProfileRegistry.swift @@ -0,0 +1,135 @@ +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] + } + + /// The metadata driver is leased inside the resolver rather than around this call, so a + /// cached profile costs nothing. An engine that cannot pool serves metadata from the + /// session driver, and leasing that for a profile the cache already holds would queue + /// behind, and ahead of, the statements the user is running. + func profile( + for scope: DatabaseScope, + databaseType: DatabaseType, + serverVersion: String?, + metadataProvider: any ScopedMetadataProviding = DatabaseManager.shared + ) async -> QueryCompletionProfile { + let base = baseProfile(for: databaseType, serverVersion: serverVersion) + return await resolve( + scope: scope, + databaseType: databaseType, + serverVersion: serverVersion, + base: base + ) { + try await metadataProvider.withMetadataDriver(scope: scope) { driver in + try await driver.resolveQueryCompletionProfile( + databaseTypeId: databaseType.rawValue, + base: base + ) + } + } + } + + func resolve( + scope: DatabaseScope, + databaseType: DatabaseType, + serverVersion: String?, + base: QueryCompletionProfile, + resolver: @Sendable @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 { + (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 + discardEntries { $0 == scope } + } + + func invalidate(connectionId: UUID) { + for scope in cachedScopes(of: connectionId) { + revisions[scope, default: 0] &+= 1 + } + discardEntries { $0.connectionId == connectionId } + } + + private func cachedScopes(of connectionId: UUID) -> Set { + Set(profiles.keys.map(\.scope) + inFlight.keys.map(\.scope)) + .filter { $0.connectionId == connectionId } + } + + /// The generation bump is what fences a resolution that is already running: it completes + /// against a key whose generation moved, so it discards its own result instead of writing + /// a profile the refresh was meant to replace. + private func discardEntries(matching matches: (DatabaseScope) -> Bool) { + for key in profiles.keys where matches(key.scope) { + generations[key, default: 0] &+= 1 + } + for key in Array(inFlight.keys) where matches(key.scope) { + generations[key, default: 0] &+= 1 + inFlight.removeValue(forKey: key)?.cancel() + } + profiles = profiles.filter { !matches($0.key.scope) } + } + + 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 5c56315c7..1a5353a80 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 @@ -275,6 +280,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..1fe2288dc 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, @@ -28,7 +28,8 @@ extension ScopedMetadataProviding { } /// For reads that belong to the sidebar rather than to a tab: the object list, the - /// database and quick switchers, autocomplete, and the AI schema context. + /// database and quick switchers, and the AI schema context. Completion is not one of + /// them any more: a query tab completes against its own scope. func withBrowseMetadataDriver( connectionId: UUID, workload: MetadataConnectionPool.Workload = .interactive, diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 365af8f7b..258e83b51 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -466,6 +466,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 7f04ac509..663c8e5a7 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -152,6 +152,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 1a2a04137..2e0371396 100644 --- a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift +++ b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift @@ -2,8 +2,8 @@ // SchemaProviderRegistry.swift // TablePro // -// Manages shared SQLSchemaProvider instances across connections. -// Ref-counted with grace period removal to avoid redundant schema loads. +// Manages shared SQLSchemaProvider instances, one per DatabaseScope. +// Ref-counted per connection with grace period removal to avoid redundant schema loads. // import Combine @@ -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,28 @@ 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() } + func provider(for scope: DatabaseScope) -> SQLSchemaProvider? { + providers[scope] } - func provider(for connectionId: UUID) -> SQLSchemaProvider? { - providers[connectionId] - } - - func getOrCreate(for connectionId: UUID) -> SQLSchemaProvider { + 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,26 +67,53 @@ 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) } }, sampleFieldPaths: { table, limit in - try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connectionId) { driver in + try await metadataProvider.withMetadataDriver(scope: scope) { driver in try await driver.sampleFieldPaths(table: table, limit: limit) } } ) let provider = SQLSchemaProvider(metadataSource: source) - providers[connectionId] = provider + providers[scope] = provider + return provider + } + + /// Creates the provider for `scope` if it does not exist yet and loads its object list once. + /// A provider that already holds tables is left as it is, because a tab that reappears has + /// not changed database; `refresh(request:)` is the reload path. + func prepare(for scope: DatabaseScope) async -> SQLSchemaProvider { + let provider = getOrCreate(for: scope) + let isLoaded = await provider.isSchemaLoaded() + guard !isLoaded else { return provider } + try? await metadataDriverProvider.withMetadataDriver(scope: scope) { driver in + await provider.loadSchema(using: driver) + } return provider } + func refresh(request: DataRefreshRequest) { + let matchingProviders = providers.filter { scope, _ in + scope.connectionId == request.connectionId && request.reaches(tabScope: 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 +128,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 +137,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 5d535eeb9..22067ecae 100644 --- a/TablePro/Core/Services/Query/SchemaRefreshService.swift +++ b/TablePro/Core/Services/Query/SchemaRefreshService.swift @@ -154,22 +154,18 @@ final class SchemaRefreshService { ) return } - guard let provider = providerRegistry.provider(for: connectionId) else { - Self.logger.debug( - "[schema] autocomplete sync skipped, no provider connId=\(connectionId, privacy: .public)" - ) - return - } - guard let browseDatabase = metadataDriverProvider.browseScope(for: connectionId)?.database else { + guard let browseScope = metadataDriverProvider.browseScope(for: connectionId) else { Self.logger.debug( "[schema] autocomplete sync skipped, no browse scope connId=\(connectionId, privacy: .public)" ) return } + let provider = providerRegistry.getOrCreate(for: browseScope) + let browseDatabase = browseScope.database let tables = schemaService.allLoadedTables(for: connectionId) let schemas = schemaService.schemas(for: connectionId) 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 3c3d55877..c550d22f0 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 79d2b634d..cd686aefe 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? @@ -71,6 +73,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 7f0f88c86..cf5fe459a 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? @@ -123,6 +125,9 @@ struct SQLEditorView: View { completionAdapter.configure(schemaProvider: schemaProvider, databaseType: databaseType) setupFavoritesObserver() } + .task(id: completionProfileRequest) { + await resolveCompletionProfile() + } .onChange(of: colorScheme) { editorConfiguration = Self.makeConfiguration() } @@ -153,6 +158,31 @@ 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 = await QueryCompletionProfileRegistry.shared.profile( + for: request.scope, + databaseType: request.databaseType, + serverVersion: request.serverVersion + ) + guard !Task.isCancelled else { return } + completionAdapter.configure( + schemaProvider: schemaProvider, + databaseType: databaseType, + profile: profile + ) + } + // MARK: - Favorites private func setupFavoritesObserver() { @@ -219,6 +249,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/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index ae8137fdd..6fbbb6549 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -345,7 +345,11 @@ struct FilterPanelView: View { fieldPaths = [] return } - let provider = SchemaProviderRegistry.shared.getOrCreate(for: coordinator.connection.id) + guard let scope = coordinator.selectedTabScope else { + fieldPaths = [] + return + } + let provider = SchemaProviderRegistry.shared.getOrCreate(for: scope) let paths = await provider.fieldPaths(for: tableName) guard !Task.isCancelled else { return } fieldPaths = paths @@ -357,11 +361,14 @@ struct FilterPanelView: View { } private func refreshRawSQLCompletionProvider() { - guard isSQLDialect, let tableName = coordinator.currentTableName else { + guard isSQLDialect, + let tableName = coordinator.currentTableName, + let scope = coordinator.selectedTabScope + else { rawSQLCompletionProvider = nil return } - let schemaProvider = SchemaProviderRegistry.shared.getOrCreate(for: coordinator.connection.id) + let schemaProvider = SchemaProviderRegistry.shared.getOrCreate(for: scope) rawSQLCompletionProvider = RawSQLFilterCompletionProvider( schemaProvider: schemaProvider, databaseType: databaseType, diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index d321dd2cc..a5803bcae 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -368,6 +368,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 }, @@ -395,8 +396,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, @@ -450,6 +453,10 @@ struct MainEditorContentView: View { .onAppear { coordinator.clearRestoredCursor(for: tab.id) } + .task(id: queryScope) { + guard let queryScope else { return } + _ = await SchemaProviderRegistry.shared.prepare(for: queryScope) + } } private func reloadFileForTab(tabId: UUID, url: URL) { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index 404bfcd77..2a2eb1f6b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -15,6 +15,8 @@ extension MainContentCoordinator { return } let browseSchema = services.databaseManager.session(for: connectionId)?.browseSchema + let switcherScope = browseScope + ?? DatabaseScope(connectionId: connectionId, database: connection.database, schema: nil) let openTables = Set( tabManager.tabs .filter { $0.tabType == .table } @@ -28,7 +30,7 @@ extension MainContentCoordinator { } ) let panelView = QuickSwitcherPanelView( - schemaProvider: SchemaProviderRegistry.shared.getOrCreate(for: connectionId), + schemaProvider: SchemaProviderRegistry.shared.getOrCreate(for: switcherScope), connectionId: connectionId, databaseType: connection.type, openTables: openTables, diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 248132cb9..537c67652 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -610,7 +610,6 @@ final class MainContentCoordinator { ) self.persistence = TabPersistenceCoordinator(connectionId: connection.id) - _ = services.schemaProviderRegistry.getOrCreate(for: connection.id) ConnectionDataCache.shared(for: connection.id).ensureLoaded() changeManager.undoManagerProvider = { [weak self] in self?.contentWindow?.undoManager } changeManager.onUndoApplied = { [weak self] result in self?.handleUndoResult(result) } diff --git a/TableProTests/Core/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/Plugins/PluginManagerEditorMetadataTests.swift b/TableProTests/Core/Plugins/PluginManagerEditorMetadataTests.swift new file mode 100644 index 000000000..e604f8a40 --- /dev/null +++ b/TableProTests/Core/Plugins/PluginManagerEditorMetadataTests.swift @@ -0,0 +1,47 @@ +// +// PluginManagerEditorMetadataTests.swift +// TableProTests +// +// A variant type (Redshift, CockroachDB, PGlite on the PostgreSQL plugin) has its own +// curated snapshot, so the editor must read the variant's entry. Looking it up by plugin +// type instead handed a Redshift tab PostgreSQL's dialect, which is where its ASCII-only +// ILIKE came from. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("PluginManager editor metadata") +@MainActor +struct PluginManagerEditorMetadataTests { + @Test("a variant type resolves its own dialect rather than the primary plugin's") + func variantDialectWinsOverPrimary() throws { + let redshift = try #require(PluginManager.shared.sqlDialect(for: .redshift)) + let postgresql = try #require(PluginManager.shared.sqlDialect(for: .postgresql)) + + #expect(redshift.caseSensitivityStyle == .caseFoldFunction) + #expect(postgresql.caseSensitivityStyle == .ilikeOperator) + #expect(PluginManager.shared.caseSensitivityStyle(for: .redshift) == .caseFoldFunction) + } + + @Test("a variant type resolves its own identifier quoting") + func variantQuotingComesFromItsOwnEntry() throws { + let mariadb = try #require(PluginManager.shared.sqlDialect(for: .mariadb)) + #expect(mariadb.identifierQuote == "`") + } + + @Test("a type with no snapshot of its own falls back to its plugin type") + func unknownVariantFallsBackToPluginSnapshot() throws { + let aliasTypeId = "PluginManagerEditorMetadataTestsFork" + let alias = DatabaseType(rawValue: aliasTypeId) + #expect(PluginMetadataRegistry.shared.snapshot(forTypeId: aliasTypeId) == nil) + + PluginMetadataRegistry.shared.registerTypeAlias(aliasTypeId, primaryTypeId: "MySQL") + + let dialect = try #require(PluginManager.shared.sqlDialect(for: alias)) + #expect(dialect.identifierQuote == "`") + #expect(PluginManager.shared.editorLanguage(for: alias) == .sql) + } +} diff --git a/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift b/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift index 6903132af..fb38772d5 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 { @@ -78,7 +100,7 @@ struct SchemaRefreshServiceTests { _ = await (first, second, third) #expect(driver.fetchTablesCallCount == 1) - #expect(provider.acquisitionCount == 1) + #expect(provider.requestedWorkloads.filter { $0 == .bulk }.count == 1) #expect(schemaService.state(for: connection.id) == .loaded(driver.tablesToReturn)) } @@ -94,12 +116,12 @@ struct SchemaRefreshServiceTests { await service.refresh(connection: connection) - #expect(provider.requestedScopes.count == 1) let scope = try #require(provider.requestedScopes.first) #expect(scope.connectionId == connection.id) #expect(scope.database == "inventory") #expect(scope.schema == "dbo") - #expect(provider.requestedWorkloads == [.bulk]) + #expect(Set(provider.requestedScopes) == [scope]) + #expect(provider.requestedWorkloads.first == .bulk) } @Test("an empty browse database is server scoped, so the refresh still runs") @@ -116,6 +138,7 @@ struct SchemaRefreshServiceTests { let scope = try #require(provider.requestedScopes.first) #expect(scope.isServerScoped) #expect(driver.fetchTablesCallCount == 1) + #expect(provider.requestedWorkloads.filter { $0 == .bulk }.count == 1) #expect(schemaService.state(for: connection.id) == .loaded(driver.tablesToReturn)) } @@ -147,9 +170,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, @@ -162,14 +186,37 @@ struct SchemaRefreshServiceTests { #expect(names.sorted() == ["customers", "orders"]) } + @Test("the sync creates the browse scope's provider when no tab has one yet") + func autocompleteSyncCreatesTheBrowseScopeProvider() async throws { + let driver = MockDatabaseDriver() + driver.tablesToReturn = [TableInfo(name: "orders", type: .table, rowCount: 0, schema: nil)] + let provider = FakeScopedMetadataProvider(driver: driver) + let registry = SchemaProviderRegistry(metadataDriverProvider: provider) + let connection = TestFixtures.makeConnection() + let service = makeService( + schemaService: SchemaService(), + provider: provider, + providerRegistry: registry + ) + + await service.refresh(connection: connection) + + let scope = DatabaseScope(connectionId: connection.id, database: "testdb", schema: nil) + let schemaProvider = try #require(registry.provider(for: scope)) + let names = await schemaProvider.getTables().map(\.name) + #expect(names == ["orders"]) + #expect(driver.fetchTablesCallCount == 1) + } + @Test("no browse scope leaves the autocomplete provider untouched instead of clearing it") func autocompleteSyncWithoutABrowseScopeKeepsTheCachedTables() async { let driver = MockDatabaseDriver() 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 +277,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/Core/Services/SchemaProviderRegistryTests.swift b/TableProTests/Core/Services/SchemaProviderRegistryTests.swift index ef15e94b5..dbee4c9d4 100644 --- a/TableProTests/Core/Services/SchemaProviderRegistryTests.swift +++ b/TableProTests/Core/Services/SchemaProviderRegistryTests.swift @@ -11,96 +11,114 @@ import Testing @Suite("SchemaProviderRegistry") @MainActor struct SchemaProviderRegistryTests { - @Test("getOrCreate returns new provider for unknown connectionId") + private func scope( + _ connectionId: UUID = UUID(), + database: String = "shop", + schema: String? = nil + ) -> DatabaseScope { + DatabaseScope(connectionId: connectionId, database: database, schema: schema) + } + + @Test("getOrCreate returns new provider for unknown scope") func getOrCreateNewProvider() { let registry = SchemaProviderRegistry() - let id = UUID() - let provider = registry.getOrCreate(for: id) - #expect(registry.provider(for: id) === provider) + let scoped = scope() + let provider = registry.getOrCreate(for: scoped) + #expect(registry.provider(for: scoped) === provider) } - @Test("getOrCreate returns same provider for same connectionId") + @Test("getOrCreate returns same provider for same scope") func getOrCreateReturnsSameProvider() { let registry = SchemaProviderRegistry() - let id = UUID() - let p1 = registry.getOrCreate(for: id) - let p2 = registry.getOrCreate(for: id) - #expect(p1 === p2) + let scoped = scope() + #expect(registry.getOrCreate(for: scoped) === registry.getOrCreate(for: scoped)) } - @Test("provider(for:) returns nil for unknown connectionId") + @Test("provider(for:) returns nil for unknown scope") func providerForUnknownReturnsNil() { let registry = SchemaProviderRegistry() - #expect(registry.provider(for: UUID()) == nil) + #expect(registry.provider(for: scope()) == nil) } - @Test("provider(for:) returns provider after getOrCreate") - func providerForKnownReturnsProvider() { + @Test("scopes on one connection get their own providers") + func scopesOnOneConnectionAreIndependent() { let registry = SchemaProviderRegistry() - let id = UUID() - let created = registry.getOrCreate(for: id) - #expect(registry.provider(for: id) === created) + let connectionId = UUID() + let sales = scope(connectionId, database: "shop", schema: "sales") + let audit = scope(connectionId, database: "shop", schema: "audit") + let warehouse = scope(connectionId, database: "warehouse") + let salesProvider = registry.getOrCreate(for: sales) + let auditProvider = registry.getOrCreate(for: audit) + let warehouseProvider = registry.getOrCreate(for: warehouse) + #expect(salesProvider !== auditProvider) + #expect(salesProvider !== warehouseProvider) + #expect(auditProvider !== warehouseProvider) } @Test("retain increments refcount, prevents purge") func retainPreventsRemoval() { let registry = SchemaProviderRegistry() - let id = UUID() - _ = registry.getOrCreate(for: id) - registry.retain(for: id) + let scoped = scope() + _ = registry.getOrCreate(for: scoped) + registry.retain(for: scoped.connectionId) registry.purgeUnused() - #expect(registry.provider(for: id) != nil) + #expect(registry.provider(for: scoped) != nil) } @Test("release decrements refcount to zero, schedules deferred removal") func releaseSchedulesDeferredRemoval() { let registry = SchemaProviderRegistry() - let id = UUID() - _ = registry.getOrCreate(for: id) - registry.retain(for: id) - registry.release(for: id) - #expect(registry.provider(for: id) != nil) + let scoped = scope() + _ = registry.getOrCreate(for: scoped) + registry.retain(for: scoped.connectionId) + registry.release(for: scoped.connectionId) + #expect(registry.provider(for: scoped) != nil) } - @Test("clear removes provider, refcount, and pending removal") + @Test("clear removes every scope of the connection, its refcount and pending removal") func clearRemovesEverything() { let registry = SchemaProviderRegistry() - let id = UUID() - _ = registry.getOrCreate(for: id) - registry.retain(for: id) - registry.clear(for: id) - #expect(registry.provider(for: id) == nil) + let connectionId = UUID() + let shop = scope(connectionId, database: "shop") + let warehouse = scope(connectionId, database: "warehouse") + _ = registry.getOrCreate(for: shop) + _ = registry.getOrCreate(for: warehouse) + registry.retain(for: connectionId) + registry.clear(for: connectionId) + #expect(registry.provider(for: shop) == nil) + #expect(registry.provider(for: warehouse) == nil) } @Test("purgeUnused removes orphaned providers with zero refcount and no pending task") func purgeRemovesOrphans() { let registry = SchemaProviderRegistry() - let id = UUID() - _ = registry.getOrCreate(for: id) + let scoped = scope() + _ = registry.getOrCreate(for: scoped) registry.purgeUnused() - #expect(registry.provider(for: id) == nil) + #expect(registry.provider(for: scoped) == nil) } @Test("purgeUnused does not remove providers with pending removal task") func purgeKeepsProvidersWithPendingTask() { let registry = SchemaProviderRegistry() - let id = UUID() - _ = registry.getOrCreate(for: id) - registry.retain(for: id) - registry.release(for: id) + let scoped = scope() + _ = registry.getOrCreate(for: scoped) + registry.retain(for: scoped.connectionId) + registry.release(for: scoped.connectionId) registry.purgeUnused() - #expect(registry.provider(for: id) != nil) + #expect(registry.provider(for: scoped) != nil) } @Test("multiple connections are independent") func multipleConnectionsIndependent() { let registry = SchemaProviderRegistry() - let id1 = UUID(), id2 = UUID() - let p1 = registry.getOrCreate(for: id1) - let p2 = registry.getOrCreate(for: id2) - #expect(p1 !== p2) - registry.clear(for: id1) - #expect(registry.provider(for: id1) == nil) - #expect(registry.provider(for: id2) != nil) + let first = scope() + let second = scope() + let firstProvider = registry.getOrCreate(for: first) + let secondProvider = registry.getOrCreate(for: second) + #expect(firstProvider !== secondProvider) + registry.clear(for: first.connectionId) + #expect(registry.provider(for: first) == nil) + #expect(registry.provider(for: second) != nil) } } diff --git a/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift b/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift new file mode 100644 index 000000000..6dba3a06a --- /dev/null +++ b/TableProTests/Views/Editor/QueryCompletionProfileRegistryTests.swift @@ -0,0 +1,229 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +private final class LeaseCountingMetadataProvider: ScopedMetadataProviding { + private let driver: MockDatabaseDriver + private let scope: DatabaseScope + private(set) var leaseCount = 0 + + init(driver: MockDatabaseDriver, browseScope: DatabaseScope) { + self.driver = driver + self.scope = browseScope + } + + func withMetadataDriver( + scope: DatabaseScope, + workload: MetadataConnectionPool.Workload, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + leaseCount += 1 + return try await body(driver) + } + + func browseScope(for connectionId: UUID) -> DatabaseScope? { scope } +} + +@Suite("Query completion profile registry") +@MainActor +struct QueryCompletionProfileRegistryTests { + actor Counter { + private(set) var value = 0 + + func increment() { + value += 1 + } + } + + nonisolated private static func base(revision: String = "base") -> QueryCompletionProfile { + QueryCompletionProfile( + resolvedDialect: nil, + statementCompletions: [CompletionEntry(label: "SELECT", insertText: "SELECT")], + tokenCasingPolicy: .preserveTypedToken, + revision: revision + ) + } + + @Test("a cached profile is served without leasing a metadata driver") + func cachedProfileSkipsTheDriverLease() async { + let registry = QueryCompletionProfileRegistry() + let connectionId = UUID() + let scope = DatabaseScope(connectionId: connectionId, database: "shop", schema: nil) + let metadataProvider = LeaseCountingMetadataProvider( + driver: MockDatabaseDriver(), + browseScope: scope + ) + + _ = await registry.profile( + for: scope, + databaseType: .postgresql, + serverVersion: "15.2", + metadataProvider: metadataProvider + ) + _ = await registry.profile( + for: scope, + databaseType: .postgresql, + serverVersion: "15.2", + metadataProvider: metadataProvider + ) + + #expect(metadataProvider.leaseCount == 1) + + registry.invalidate(scope: scope) + _ = await registry.profile( + for: scope, + databaseType: .postgresql, + serverVersion: "15.2", + metadataProvider: metadataProvider + ) + + #expect(metadataProvider.leaseCount == 2) + } + + @Test("cache keys include scope, database type, and server version") + func cacheKeyIncludesEveryRuntimeDimension() async { + let registry = QueryCompletionProfileRegistry() + let connectionId = UUID() + let firstScope = DatabaseScope(connectionId: connectionId, database: "first", schema: "public") + let secondScope = DatabaseScope(connectionId: connectionId, database: "second", schema: "public") + let resolutions = Counter() + + _ = await registry.resolve( + scope: firstScope, + databaseType: .postgresql, + serverVersion: "15.2", + base: Self.base() + ) { + await resolutions.increment() + return Self.base(revision: "first") + } + _ = await registry.resolve( + scope: firstScope, + databaseType: .postgresql, + serverVersion: "15.2", + base: Self.base() + ) { + await resolutions.increment() + return Self.base(revision: "cached") + } + _ = await registry.resolve( + scope: secondScope, + databaseType: .postgresql, + serverVersion: "15.2", + base: Self.base() + ) { + await resolutions.increment() + return Self.base(revision: "second") + } + _ = await registry.resolve( + scope: firstScope, + databaseType: .cockroachdb, + serverVersion: "15.2", + base: Self.base() + ) { + await resolutions.increment() + return Self.base(revision: "type") + } + _ = await registry.resolve( + scope: firstScope, + databaseType: .postgresql, + serverVersion: "16.1", + base: Self.base() + ) { + await resolutions.increment() + return Self.base(revision: "version") + } + + #expect(await resolutions.value == 4) + } + + @Test("resolution errors return and cache the conservative base profile") + func resolutionFailureReturnsBase() async { + let registry = QueryCompletionProfileRegistry() + let scope = DatabaseScope(connectionId: UUID(), database: "shop", schema: nil) + let conservative = Self.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) + let resolutions = Counter() + + async let first = registry.resolve( + scope: scope, + databaseType: .mysql, + serverVersion: "8.0", + base: Self.base() + ) { + await resolutions.increment() + await Task.yield() + return Self.base(revision: "resolved") + } + async let second = registry.resolve( + scope: scope, + databaseType: .mysql, + serverVersion: "8.0", + base: Self.base() + ) { + await resolutions.increment() + return Self.base(revision: "duplicate") + } + + let revisions = await [first.revision, second.revision] + #expect(await resolutions.value == 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: Self.base() + ) { + try? await Task.sleep(nanoseconds: 10_000_000) + return Self.base(revision: "old") + } + await Task.yield() + registry.invalidate(scope: scope) + let current = await registry.resolve( + scope: scope, + databaseType: .mysql, + serverVersion: "8.0", + base: Self.base() + ) { + Self.base(revision: "current") + } + _ = await old + let cached = await registry.resolve( + scope: scope, + databaseType: .mysql, + serverVersion: "8.0", + base: Self.base() + ) { + Self.base(revision: "unexpected") + } + + #expect(current.revision == "current") + #expect(cached.revision == "current") + } +} diff --git a/docs/features/autocomplete.mdx b/docs/features/autocomplete.mdx index c014892ec..254cad400 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` (**Database > 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..f1f51e2f2 --- /dev/null +++ b/plans/20260812-sql-completion-singlestore-worktrees/phase-01-foundation.md @@ -0,0 +1,101 @@ +# 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 runs in CI (no Xcode toolchain on the dev machine) +- Priority: P1 +- Gate: phải merge vào `main` trước khi mở Wave 2 + +## 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 + +- [x] Tạo `QueryCompletionProfile` +- [x] Thêm API resolve profile vào PluginKit +- [x] Dựng registry + conservative fallback +- [x] Scope cache theo query tab +- [x] Truyền `scope/serverVersion/profileRevision` tới editor +- [x] Đổi precedence metadata lookup +- [x] Viết test 2-tab và fallback version +- [ ] Chạy build/lint/ABI check (CI, không chạy được local) + +## Việc đã bổ sung sau review + +- Bỏ overload `getOrCreate(for connectionId:)`: mọi call site phải nói rõ scope. Filter panel dùng scope của tab đang chọn, quick switcher dùng browse scope. +- `sampleFieldPaths` trong provider đã scope-hóa; trước đó vẫn đọc browse scope nên MongoDB field path lấy sai database. +- `prepare(for:)` là đường nạp schema duy nhất và chỉ nạp một lần; editor pane gọi nó qua `.task(id: queryScope)` thay vì fire-and-forget trong getter. +- `syncAutocompleteProvider` tạo provider cho browse scope nếu chưa có, để danh sách object của sidebar không bị bỏ rơi. +- `QueryCompletionProfileRegistry.profile` lease metadata driver bên trong resolver, nên cache hit không còn chiếm session driver. +- Cập nhật `SchemaProviderRegistryTests` (đang gọi API cũ nên test target không build) và thêm test precedence metadata lookup. + +## Done when + +- 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..578633804 --- /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, đã review và bổ sung; chờ CI xanh trước khi merge. Blocker cho toàn bộ Wave 2. +2. [Phase 02 - Parallel Profiles](./phase-02-parallel-profiles.md) + Status: pending. Chỉ bắt đầu sau khi Phase 01 merge vào `main`. +3. [Phase 03 - Docs & Acceptance](./phase-03-docs-acceptance.md) + 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.