diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift index 9eedea5a25..564db216ca 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift @@ -244,7 +244,8 @@ extension TextViewController { if let findViewController, findViewController.viewModel.isShowingFindPanel, textViewHasFocus || findPanelHasFocus, - findViewController.findPanel.handleKeyDown(event) == nil { + let findPanel = findViewController.findPanel, + findPanel.handleKeyDown(event) == nil { return nil } diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController+Toggle.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController+Toggle.swift index 28afcaf556..504634c73a 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController+Toggle.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController+Toggle.swift @@ -35,8 +35,8 @@ extension FindViewController { viewModel.isShowingFindPanel = true // Smooth out the animation by placing the find panel just outside the correct position before animating. - findPanel.isHidden = false - findPanelVerticalConstraint.constant = resolvedTopPadding - viewModel.panelHeight + installFindPanel().isHidden = false + findPanelVerticalConstraint?.constant = resolvedTopPadding - viewModel.panelHeight view.layoutSubtreeIfNeeded() @@ -65,13 +65,13 @@ extension FindViewController { /// - Sets the text view to be the first responder. func hideFindPanel(animated: Bool = true) { viewModel.isShowingFindPanel = false - _ = findPanel.resignFirstResponder() + _ = findPanel?.resignFirstResponder() conditionalAnimated(animated) { viewModel.target?.findPanelWillHide(panelHeight: viewModel.panelHeight) setFindPanelConstraintHide() } onComplete: { [weak self] in - self?.findPanel.isHidden = true + self?.findPanel?.isHidden = true self?.viewModel.isFocused = false } @@ -120,7 +120,7 @@ extension FindViewController { /// Sets the find panel constraint to show the find panel. /// Can be animated using implicit animation. func setFindPanelConstraintShow() { - // Update the find panel's top to be equal to the view's top. + guard let findPanelVerticalConstraint else { return } findPanelVerticalConstraint.constant = resolvedTopPadding findPanelVerticalConstraint.isActive = true } @@ -128,8 +128,7 @@ extension FindViewController { /// Sets the find panel constraint to hide the find panel. /// Can be animated using implicit animation. func setFindPanelConstraintHide() { - // Update the find panel's top anchor to be equal to it's negative height, hiding it above the view. - + guard let findPanelVerticalConstraint else { return } // SwiftUI hates us. It refuses to move views outside of the safe are if they don't have the `.ignoresSafeArea` // modifier, but with that modifier on it refuses to allow it to be animated outside the safe area. // The only way I found to fix it was to multiply the height by 3 here. diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController.swift index 1e303bee1c..918a38153a 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController.swift @@ -23,8 +23,10 @@ final class FindViewController: NSViewController { } var childView: NSView - var findPanel: FindPanelHostingView - var findPanelVerticalConstraint: NSLayoutConstraint! + // Built on first show, as NSTextFinder builds its find bar: a hidden hosting view is still laid out, and + // SwiftUI inserting its controls forces a layout of the whole editor around them. + private(set) var findPanel: FindPanelHostingView? + private(set) var findPanelVerticalConstraint: NSLayoutConstraint? /// The 'real' top padding amount. /// Is equal to ``topPadding`` if set, or the view's top safe area inset if not. @@ -35,7 +37,6 @@ final class FindViewController: NSViewController { init(target: FindPanelTarget, childView: NSView) { viewModel = FindPanelViewModel(target: target) self.childView = childView - findPanel = FindPanelHostingView(viewModel: viewModel) super.init(nibName: nil, bundle: nil) viewModel.dismiss = { [weak self] in self?.hideFindPanel() @@ -63,21 +64,8 @@ final class FindViewController: NSViewController { /// scopes its hover tracking and the ribbons' accessibility elements. view.clipsToBounds = true view.addSubview(childView) - // Ordered above the content so the panel wins hit-testing as well as drawing. - // `zPosition` alone only affects drawing, which left clicks falling through to the - // text view underneath. - view.addSubview(findPanel, positioned: .above, relativeTo: childView) - findPanel.wantsLayer = true - - findPanelVerticalConstraint = findPanel.topAnchor.constraint(equalTo: view.topAnchor) NSLayoutConstraint.activate([ - // Constrain find panel - findPanelVerticalConstraint, - findPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor), - findPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor), - - // Constrain child view childView.topAnchor.constraint(equalTo: view.topAnchor), childView.bottomAnchor.constraint(equalTo: view.bottomAnchor), childView.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -87,12 +75,33 @@ final class FindViewController: NSViewController { override func viewWillAppear() { super.viewWillAppear() - if viewModel.isShowingFindPanel { // Update constraints for initial state - findPanel.isHidden = false - setFindPanelConstraintShow() - } else { - findPanel.isHidden = true - setFindPanelConstraintHide() + guard viewModel.isShowingFindPanel else { return } + installFindPanel().isHidden = false + setFindPanelConstraintShow() + } + + @discardableResult + func installFindPanel() -> FindPanelHostingView { + if let findPanel { return findPanel } + let panel = FindPanelHostingView(viewModel: viewModel) + // Ordered above the content so the panel wins hit-testing as well as drawing. + // `zPosition` alone only affects drawing, which left clicks falling through to the + // text view underneath. + view.addSubview(panel, positioned: .above, relativeTo: childView) + panel.wantsLayer = true + let top = panel.topAnchor.constraint(equalTo: view.topAnchor) + NSLayoutConstraint.activate([ + top, + panel.leadingAnchor.constraint(equalTo: view.leadingAnchor), + panel.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + findPanel = panel + findPanelVerticalConstraint = top + setFindPanelConstraintHide() + // The panel's onChange searches a term typed into it; one set before it existed has not been searched. + if !viewModel.findText.isEmpty { + viewModel.find() } + return panel } } diff --git a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/EditorKeyMonitorCompositionTests.swift b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/EditorKeyMonitorCompositionTests.swift index c72348e357..d6e7d9b734 100644 --- a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/EditorKeyMonitorCompositionTests.swift +++ b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/EditorKeyMonitorCompositionTests.swift @@ -180,7 +180,7 @@ internal struct EditorKeyMonitorCompositionTests { Mock.beginComposition("le", in: editor.textView) let escape = try #require(Mock.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window)) - #expect(finder.findPanel.handleKeyDown(escape) === escape) + #expect(try #require(finder.findPanel).handleKeyDown(escape) === escape) #expect(finder.viewModel.isShowingFindPanel) } @@ -202,7 +202,7 @@ internal struct EditorKeyMonitorCompositionTests { try #require(fieldEditor.hasMarkedText()) let escape = try #require(Mock.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window)) - #expect(finder.findPanel.handleKeyDown(escape) === escape) + #expect(try #require(finder.findPanel).handleKeyDown(escape) === escape) #expect(finder.viewModel.isShowingFindPanel) } @@ -215,7 +215,7 @@ internal struct EditorKeyMonitorCompositionTests { _ = window.makeFirstResponder(editor.textView) let escape = try #require(Mock.keyDown(keyCode: kVK_Escape, characters: "\u{1b}", in: window)) - #expect(finder.findPanel.handleKeyDown(escape) == nil) + #expect(try #require(finder.findPanel).handleKeyDown(escape) == nil) #expect(finder.viewModel.isShowingFindPanel == false) } diff --git a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewControllerClippingTests.swift b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewControllerClippingTests.swift index ff0ce8bd3e..4982e38335 100644 --- a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewControllerClippingTests.swift +++ b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewControllerClippingTests.swift @@ -131,13 +131,14 @@ struct TextViewControllerClippingTests { @Test("The find panel sliding in from above shows only inside the editor") func findPanelSlidesInsideTheEditor() throws { let find = try #require(controller.findViewController) - find.findPanel.isHidden = false - find.findPanelVerticalConstraint.constant = find.resolvedTopPadding - find.viewModel.panelHeight / 2 + let panel = find.installFindPanel() + panel.isHidden = false + try #require(find.findPanelVerticalConstraint).constant = find.resolvedTopPadding - find.viewModel.panelHeight / 2 find.view.layoutSubtreeIfNeeded() - let panelInWindow = find.findPanel.convert(find.findPanel.bounds, to: nil) + let panelInWindow = panel.convert(panel.bounds, to: nil) #expect(panelInWindow.maxY > editorInWindow.maxY, "the panel starts its slide above the editor") - #expect(contains(editorInWindow, visibleRectInWindow(find.findPanel))) + #expect(contains(editorInWindow, visibleRectInWindow(panel))) } /// The band's red, loosely: the cached drawing lands in a color space that shifts the fill. diff --git a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewControllerTests.swift b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewControllerTests.swift index 96f564bcbb..ff714958bf 100644 --- a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewControllerTests.swift +++ b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewControllerTests.swift @@ -146,7 +146,7 @@ final class TextViewControllerTests: XCTestCase { scrollView.contentInsets, NSEdgeInsets(top: 10 + findModel.viewModel.panelHeight, left: 0, bottom: 10, right: 0) ) - XCTAssertEqual(controller.findViewController?.findPanelVerticalConstraint.constant, 0) + XCTAssertEqual(controller.findViewController?.findPanelVerticalConstraint?.constant, 0) XCTAssertEqual(controller.gutterView.frame.origin.y, -10 - findModel.viewModel.panelHeight) } diff --git a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/FindPanelTests.swift b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/FindPanelTests.swift index de6b2947ee..4896224029 100644 --- a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/FindPanelTests.swift +++ b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/FindPanelTests.swift @@ -56,6 +56,33 @@ struct FindPanelTests { #expect(model.panelHeight == 54) } + @Test("The find panel is built on its first show, not with the editor, and kept after it hides") + func findPanelIsBuiltOnFirstShow() { + #expect(viewController.findPanel == nil) + #expect(!viewController.view.subviews.contains { $0 is FindPanelHostingView }) + + viewController.showFindPanel(animated: false) + let panel = viewController.findPanel + #expect(panel?.superview === viewController.view) + #expect(panel?.isHidden == false) + + viewController.hideFindPanel(animated: false) + #expect(viewController.findPanel === panel) + #expect(panel?.isHidden == true) + } + + @Test("A term set before the panel was first shown is searched when it opens") + func termSetBeforeFirstShowIsSearched() { + let target = MockPanelTarget(text: "SELECT 1; SELECT 2") + let controller = FindViewController(target: target, childView: NSView()) + controller.loadView() + controller.viewModel.findText = "SELECT" + + controller.showFindPanel(animated: false) + + #expect(controller.viewModel.matchCount == 2) + } + @Test func findPanelShowsOnCommandF() async throws { // Show find panel viewController.showFindPanel() diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleSchemaQueries.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleSchemaQueries.swift index f190d8ac0d..bb155b48a7 100644 --- a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleSchemaQueries.swift +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleSchemaQueries.swift @@ -168,22 +168,17 @@ public enum OracleSchemaQueries { "ALTER SESSION SET CURRENT_SCHEMA = \(quoteIdentifier(schema))" } - /// `ALL_PART_TABLES` carries the partition count without reading a LONG column, which this - /// driver cannot decode. Its `INTERVAL` column is the flag for interval partitioning, where the - /// count is a placeholder for a range the server extends on demand rather than a number of - /// partitions that exist, so those report no count instead of a fabricated one. - /// - /// Being partitioned is projected separately from the count for exactly that reason: an - /// interval-partitioned table has partitions and no count to state, and reading the missing - /// count as "not partitioned" would take its partitions out of the tree. + // The count comes from `partitionCounts(owners:)`: joining `ALL_PART_TABLES` here made every hard + // parse of a 100-schema listing cost 0.5 to 2.4 s of server CPU, against 0.1 to 0.3 s without it. + // Being partitioned is projected apart from the count, since an interval-partitioned table has + // partitions and no count to state. public static func tables(schema: String) -> String { let owner = escapeLiteral(schema) return """ SELECT t.table_name, 'BASE TABLE' AS table_type, - CASE WHEN pt.table_name IS NULL THEN 'N' ELSE 'Y' END AS is_partitioned, - CASE WHEN pt.interval IS NULL THEN pt.partition_count END AS partition_count + CASE WHEN t.partitioned = 'YES' THEN 'Y' ELSE 'N' END AS is_partitioned, + NULL AS partition_count FROM \(OracleDictionary.allTables) t - LEFT JOIN \(OracleDictionary.allPartTables) pt ON pt.owner = t.owner AND pt.table_name = t.table_name WHERE t.owner = '\(owner)' UNION ALL SELECT view_name, 'VIEW', 'N', NULL FROM \(OracleDictionary.allViews) WHERE owner = '\(owner)' @@ -201,11 +196,10 @@ public enum OracleSchemaQueries { let owners = chunk.map { "'\(escapeLiteral($0))'" }.joined(separator: ", ") return """ SELECT t.table_name, 'BASE TABLE' AS table_type, - CASE WHEN pt.table_name IS NULL THEN 'N' ELSE 'Y' END AS is_partitioned, - CASE WHEN pt.interval IS NULL THEN pt.partition_count END AS partition_count, + CASE WHEN t.partitioned = 'YES' THEN 'Y' ELSE 'N' END AS is_partitioned, + NULL AS partition_count, t.owner FROM \(OracleDictionary.allTables) t - LEFT JOIN \(OracleDictionary.allPartTables) pt ON pt.owner = t.owner AND pt.table_name = t.table_name WHERE t.owner IN (\(owners)) UNION ALL SELECT view_name, 'VIEW', 'N', NULL, owner FROM \(OracleDictionary.allViews) WHERE owner IN (\(owners)) @@ -214,6 +208,47 @@ public enum OracleSchemaQueries { } } + // `ALL_PART_TABLES` states the count without reading a LONG column, which this driver cannot + // decode. An interval-partitioned table's count is a placeholder for the range the server extends + // on demand, so it reports none. + public static func partitionCounts(owners: [String]) -> [String] { + stride(from: 0, to: owners.count, by: maxOwnersPerTableListing).map { start in + let chunk = owners[start.. [String] { + let owners = rows.lazy.filter(\.isPartitioned).compactMap { $0.schema ?? defaultOwner } + return Array(Set(owners)).sorted() + } + + public static func applyingPartitionCounts( + _ countRows: [[OracleRawCell]], + to rows: [OracleTableRow], + defaultOwner: String? + ) -> [OracleTableRow] { + var counts: [String: [String: Int]] = [:] + for row in countRows { + guard let owner = row[safe: 0]?.stringValue, + let table = row[safe: 1]?.stringValue, + let count = row[safe: 2]?.stringValue.flatMap(Int.init) else { continue } + counts[owner, default: [:]][table] = count + } + return rows.map { row in + guard row.isPartitioned, + let owner = row.schema ?? defaultOwner, + let count = counts[owner]?[row.name] else { return row } + return OracleTableRow(name: row.name, isView: row.isView, isPartitioned: true, partitionCount: count, schema: row.schema) + } + } + /// One partitioned table's partitions. `HIGH_VALUE` is deliberately absent: it is a LONG /// column, the datatype this driver already avoids reading because OracleNIO cannot decode it, /// so an Oracle partition states its position rather than its bound. diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDictionaryQualificationTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDictionaryQualificationTests.swift index e885dc028b..f4273c2b97 100644 --- a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDictionaryQualificationTests.swift +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleDictionaryQualificationTests.swift @@ -33,6 +33,7 @@ struct OracleDictionaryQualificationTests { ("users", OracleSchemaQueries.users), ("tables", OracleSchemaQueries.tables(schema: "HR")), ("tables in schemas", OracleSchemaQueries.tables(inSchemas: ["HR", "SALES"]).joined(separator: "\n")), + ("partition counts", OracleSchemaQueries.partitionCounts(owners: ["HR", "SALES"]).joined(separator: "\n")), ("partitions", OracleSchemaQueries.partitions(schema: "HR", table: "T")), ("subpartitions", OracleSchemaQueries.subpartitions(schema: "HR", table: "T")), ("columns 11g", OracleSchemaQueries.columns(schema: "HR", table: "T", release: OracleServerRelease(major: 11))), diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSchemaQueriesTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSchemaQueriesTests.swift index 713700aece..c51fc6f6f2 100644 --- a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSchemaQueriesTests.swift +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSchemaQueriesTests.swift @@ -90,11 +90,61 @@ struct OracleSchemaQueriesTests { #expect(interval?.partitionCount == nil) } - @Test func testTableListingReadsPartitioningFromAllPartTables() { - let sql = OracleSchemaQueries.tables(schema: "HR") - #expect(sql.contains("LEFT JOIN SYS.ALL_PART_TABLES pt")) - #expect(sql.contains("CASE WHEN pt.table_name IS NULL THEN 'N' ELSE 'Y' END")) - #expect(sql.contains("CASE WHEN pt.interval IS NULL THEN pt.partition_count END")) + @Test func testTableListingsReadPartitioningFromAllTablesWithoutJoiningAllPartTables() { + let listings = [OracleSchemaQueries.tables(schema: "HR")] + OracleSchemaQueries.tables(inSchemas: ["HR", "SALES"]) + for sql in listings { + #expect(!sql.contains("ALL_PART_TABLES")) + #expect(sql.contains("CASE WHEN t.partitioned = 'YES' THEN 'Y' ELSE 'N' END AS is_partitioned")) + #expect(sql.contains("NULL AS partition_count")) + } + } + + @Test func testPartitionCountsNameEveryOwnerAndOmitAnIntervalCount() throws { + let statements = OracleSchemaQueries.partitionCounts(owners: ["HR", "O'BRIEN"]) + let sql = try #require(statements.first) + #expect(statements.count == 1) + #expect(sql.contains("FROM SYS.ALL_PART_TABLES")) + #expect(sql.contains("WHERE owner IN ('HR', 'O''BRIEN')")) + #expect(sql.contains("CASE WHEN interval IS NULL THEN partition_count END")) + #expect(OracleSchemaQueries.partitionCounts(owners: []).isEmpty) + #expect(OracleSchemaQueries.partitionCounts(owners: (0..<1_001).map { "U\($0)" }).count == 2) + } + + @Test func testOnlyOwnersWithAPartitionedTableAreAskedForCounts() { + let rows = [ + OracleTableRow(name: "ORDERS", isView: false, isPartitioned: true, schema: "SALES"), + OracleTableRow(name: "EVENTS", isView: false, isPartitioned: true, schema: "SALES"), + OracleTableRow(name: "EMPLOYEES", isView: false, schema: "HR"), + OracleTableRow(name: "AUDIT", isView: false, isPartitioned: true) + ] + #expect(OracleSchemaQueries.ownersWithPartitionedTables(in: rows, defaultOwner: "APP") == ["APP", "SALES"]) + #expect(OracleSchemaQueries.ownersWithPartitionedTables(in: rows, defaultOwner: nil) == ["SALES"]) + #expect(OracleSchemaQueries.ownersWithPartitionedTables(in: [rows[2]], defaultOwner: "HR").isEmpty) + } + + @Test func testPartitionCountsLandOnTheirOwnersTables() { + let rows = [ + OracleTableRow(name: "ORDERS", isView: false, isPartitioned: true, schema: "SALES"), + OracleTableRow(name: "ORDERS", isView: false, isPartitioned: true, schema: "ARCHIVE"), + OracleTableRow(name: "EVENTS", isView: false, isPartitioned: true, schema: "SALES"), + OracleTableRow(name: "EMPLOYEES", isView: false, schema: "SALES") + ] + let counts: [[OracleRawCell]] = [ + [.string("SALES"), .string("ORDERS"), .string("12")], + [.string("ARCHIVE"), .string("ORDERS"), .string("4")], + [.string("SALES"), .string("EVENTS"), .null], + [.string("SALES"), .string("EMPLOYEES"), .string("3")] + ] + let merged = OracleSchemaQueries.applyingPartitionCounts(counts, to: rows, defaultOwner: nil) + #expect(merged.map(\.partitionCount) == [12, 4, nil, nil]) + #expect(merged[2].isPartitioned) + } + + @Test func testOneSchemaListingTakesItsCountsUnderTheDefaultOwner() { + let rows = [OracleTableRow(name: "ORDERS", isView: false, isPartitioned: true)] + let counts: [[OracleRawCell]] = [[.string("HR"), .string("ORDERS"), .string("8")]] + let merged = OracleSchemaQueries.applyingPartitionCounts(counts, to: rows, defaultOwner: "HR") + #expect(merged == [OracleTableRow(name: "ORDERS", isView: false, isPartitioned: true, partitionCount: 8)]) } /// `HIGH_VALUE` is a LONG column OracleNIO cannot decode, so it must never be selected. diff --git a/apple/Plugins/OracleDriverPlugin/OraclePlugin.swift b/apple/Plugins/OracleDriverPlugin/OraclePlugin.swift index 26d751856d..38057bb611 100644 --- a/apple/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/apple/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -500,17 +500,35 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Schema Operations func fetchTables(schema: String?) async throws -> [PluginTableInfo] { - let result = try await rawQuery(OracleSchemaQueries.tables(schema: effectiveSchema(schema))) - return result.rows.compactMap(OracleSchemaQueries.parseTableRow).map(Self.tableInfo) + let owner = effectiveSchema(schema) + let result = try await rawQuery(OracleSchemaQueries.tables(schema: owner)) + let rows = result.rows.compactMap(OracleSchemaQueries.parseTableRow) + return await withPartitionCounts(rows, defaultOwner: owner).map(Self.tableInfo) } func fetchTables(inSchemas schemas: [String]) async throws -> [PluginTableInfo]? { - var tables: [PluginTableInfo] = [] + var rows: [OracleTableRow] = [] for query in OracleSchemaQueries.tables(inSchemas: schemas) { let result = try await rawQuery(query) - tables += result.rows.compactMap(OracleSchemaQueries.parseTableRow).map(Self.tableInfo) + rows += result.rows.compactMap(OracleSchemaQueries.parseTableRow) + } + return await withPartitionCounts(rows, defaultOwner: nil).map(Self.tableInfo) + } + + // The count only labels a row, so a failed read leaves the listing without counts rather than failing it. + private func withPartitionCounts(_ rows: [OracleTableRow], defaultOwner: String?) async -> [OracleTableRow] { + let owners = OracleSchemaQueries.ownersWithPartitionedTables(in: rows, defaultOwner: defaultOwner) + guard !owners.isEmpty else { return rows } + var countRows: [[OracleRawCell]] = [] + do { + for query in OracleSchemaQueries.partitionCounts(owners: owners) { + countRows += try await rawQuery(query).rows + } + } catch { + Self.logger.warning("Partition counts could not be read: \(LogRedaction.publicDescription(of: error), privacy: .public)") + return rows } - return tables + return OracleSchemaQueries.applyingPartitionCounts(countRows, to: rows, defaultOwner: defaultOwner) } private static func tableInfo(_ row: OracleTableRow) -> PluginTableInfo { diff --git a/apple/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift b/apple/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift index 9a10b118e0..018b3f3f6e 100644 --- a/apple/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift +++ b/apple/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift @@ -49,7 +49,8 @@ internal enum SidebarObjectListPresentation: Equatable { hasActiveFilter: Bool, hasAnyMatch: Bool, hasOutlastedGrace: Bool = true, - needsDatabaseSelection: Bool = false + needsDatabaseSelection: Bool = false, + awaitsSearchListing: Bool = false ) -> SidebarObjectListPresentation { switch state { case .idle, .loading: @@ -61,6 +62,8 @@ internal enum SidebarObjectListPresentation: Equatable { return .noDatabaseSelected } if hasActiveFilter, !hasAnyMatch { + // The other schemas' tables are still on their way, so no match yet is not a miss. + guard !awaitsSearchListing else { return hasOutlastedGrace ? .loading : .preparing } return .noMatch } return .list diff --git a/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift b/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift index a946110541..dc065991a3 100644 --- a/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/apple/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -131,11 +131,13 @@ internal final class QuickSwitcherViewModel: ObservableObject { /// The tables of schemas other than the browsed one arrive after the rest of the catalog, so a /// search that finds nothing yet is still loading rather than a miss. var isLoadingTables: Bool { - isLoadingBrowseCatalog || isListingInFlight + isLoadingBrowseCatalog || isListingInFlight || isSharedListingLoading } @Published private var isLoadingBrowseCatalog = false @Published private var isListingInFlight = false + // A listing the sidebar or another reader started is one this panel never requests itself. + @Published private var isSharedListingLoading = false @Published private(set) var isLoadingCrossConnections = false @Published private(set) var isLoadingCrossConnectionQueries = false @Published private(set) var crossConnectionQueryContentRevision = 0 @@ -463,6 +465,7 @@ internal final class QuickSwitcherViewModel: ObservableObject { } private func refreshListingDemand() { + if sharedListingIsLoading() { isSharedListingLoading = true } guard hasLoadedBrowseCatalog, let tableSource, tableSource.listsTablesPerSchema, let database = tableSource.database, Self.wantsAllSchemaListing( @@ -1170,6 +1173,18 @@ extension QuickSwitcherViewModel { pendingTableMerge = false if refreshesListing { refreshListingDemand() } if mergesTables { await rebuildTableItemsIfNeeded() } + // Lowered only after the rebuild, so the rows a landed listing brings are in before the spinner goes. + if isSharedListingLoading, !sharedListingIsLoading() { isSharedListingLoading = false } + } + + private func sharedListingIsLoading() -> Bool { + guard let tableSource, tableSource.listsTablesPerSchema, let database = tableSource.database else { return false } + if case .loading = DatabaseTreeMetadataService.shared.allSchemaTablesLoadState( + connectionId: connectionId, database: database + ) { + return true + } + return false } // A call that finds a build of the same inputs running waits for that one instead of starting another. diff --git a/apple/TablePro/ViewModels/SidebarViewModel.swift b/apple/TablePro/ViewModels/SidebarViewModel.swift index fbb94820db..11b4a9322e 100644 --- a/apple/TablePro/ViewModels/SidebarViewModel.swift +++ b/apple/TablePro/ViewModels/SidebarViewModel.swift @@ -110,7 +110,8 @@ final class SidebarViewModel: ObservableObject { didSet { invalidateFilterCaches() if filterQuery.isEmpty { listingMatchesMemo.removeAll() } - listingDemand.reset() + // Once per search, not per keystroke: a failed or partly unlisted listing was read again on every refinement. + if oldValue.isEmpty { listingDemand.reset() } loadAllSchemaTablesForSearch() } } diff --git a/apple/TablePro/Views/Sidebar/SidebarView.swift b/apple/TablePro/Views/Sidebar/SidebarView.swift index 3cf187c3d2..08a7436198 100644 --- a/apple/TablePro/Views/Sidebar/SidebarView.swift +++ b/apple/TablePro/Views/Sidebar/SidebarView.swift @@ -342,10 +342,22 @@ struct SidebarView: View { hasActiveFilter: !viewModel.filterQuery.isEmpty, hasAnyMatch: hasAnyMatch, hasOutlastedGrace: showsSchemaProgress, - needsDatabaseSelection: needsDatabaseSelection + needsDatabaseSelection: needsDatabaseSelection, + awaitsSearchListing: awaitsSearchListing ) } + private var awaitsSearchListing: Bool { + guard rootShape == .flat, !viewModel.filterQuery.isEmpty, let database = activeDatabase, + DatabaseTreeMetadataService.listsTablesPerSchema(groupingStrategy) else { return false } + switch treeMetadata.allSchemaTablesLoadState(connectionId: connectionId, database: database) { + case .loading: return true + // A search asks for it at once, but never while disconnected. + case .idle: return databaseManager.session(for: connectionId)?.reportedStatus.isConnected == true + case .loaded, .failed: return false + } + } + private var needsDatabaseSelection: Bool { viewModel.databaseType.browsingRequiresSelectedDatabase && activeDatabase == nil } diff --git a/apple/TableProTests/Core/Services/Query/SidebarSchemaFilterTests.swift b/apple/TableProTests/Core/Services/Query/SidebarSchemaFilterTests.swift index 0b34a2c6da..893e0f434e 100644 --- a/apple/TableProTests/Core/Services/Query/SidebarSchemaFilterTests.swift +++ b/apple/TableProTests/Core/Services/Query/SidebarSchemaFilterTests.swift @@ -106,6 +106,38 @@ struct SidebarSchemaFilterTests { await disconnect(connection) } + @Test("A failed listing is asked for once per search, not again on every refinement") + func failedListingIsAskedForOncePerSearch() async throws { + let (connection, driver) = connect { driver in + driver.allSchemaTablesError = DatabaseError.queryFailed("listing timed out") + } + let viewModel = SidebarViewModel( + selectedTables: .constant([]), + pendingTruncates: .constant([]), + pendingDeletes: .constant([]), + tableOperationOptions: .constant([:]), + databaseType: .pglite, + connectionId: connection.id + ) + + for query in ["t", "ti", "tim"] { + viewModel.searchText = query + try await waitUntil { viewModel.filterQuery == query } + try await waitUntil { + if case .failed = service.allSchemaTablesLoadState(connectionId: connection.id, database: "shop") { return true } + return false + } + } + #expect(driver.fetchTablesInAllSchemasCallCount == 1) + + viewModel.searchText = "" + try await waitUntil { viewModel.filterQuery.isEmpty } + viewModel.searchText = "o" + try await waitUntil { driver.fetchTablesInAllSchemasCallCount == 2 } + viewModel.searchText = "" + await disconnect(connection) + } + @Test("Sidebar search and Open Quickly share one listing for one filter, read schema by schema") func sidebarAndOpenQuicklyReadOnce() async throws { let (connection, driver) = connect { driver in diff --git a/apple/TableProTests/Models/SidebarObjectListPresentationTests.swift b/apple/TableProTests/Models/SidebarObjectListPresentationTests.swift index 506df62779..8fd0d08fa6 100644 --- a/apple/TableProTests/Models/SidebarObjectListPresentationTests.swift +++ b/apple/TableProTests/Models/SidebarObjectListPresentationTests.swift @@ -13,17 +13,29 @@ struct SidebarObjectListPresentationTests { hasActiveFilter: Bool = false, hasAnyMatch: Bool = true, hasOutlastedGrace: Bool = true, - needsDatabaseSelection: Bool = false + needsDatabaseSelection: Bool = false, + awaitsSearchListing: Bool = false ) -> SidebarObjectListPresentation { SidebarObjectListPresentation.resolve( state: state, hasActiveFilter: hasActiveFilter, hasAnyMatch: hasAnyMatch, hasOutlastedGrace: hasOutlastedGrace, - needsDatabaseSelection: needsDatabaseSelection + needsDatabaseSelection: needsDatabaseSelection, + awaitsSearchListing: awaitsSearchListing ) } + @Test("A search with no match yet waits for the other schemas' listing instead of reporting a miss") + func searchAwaitingTheListingIsNotAMiss() { + #expect(resolve(.loaded([table("users")]), hasActiveFilter: true, hasAnyMatch: false, awaitsSearchListing: true) == .loading) + #expect(resolve( + .loaded([table("users")]), hasActiveFilter: true, hasAnyMatch: false, hasOutlastedGrace: false, awaitsSearchListing: true + ) == .preparing) + #expect(resolve(.loaded([table("users")]), hasActiveFilter: true, hasAnyMatch: false) == .noMatch) + #expect(resolve(.loaded([table("users")]), hasActiveFilter: true, hasAnyMatch: true, awaitsSearchListing: true) == .list) + } + // MARK: - No database selected /// A MySQL connection with no database named lists nothing, and a bare "No items" under Tables diff --git a/apple/TableProTests/ViewModels/QuickSwitcherListingDemandTests.swift b/apple/TableProTests/ViewModels/QuickSwitcherListingDemandTests.swift index 13d82cec8e..e682364017 100644 --- a/apple/TableProTests/ViewModels/QuickSwitcherListingDemandTests.swift +++ b/apple/TableProTests/ViewModels/QuickSwitcherListingDemandTests.swift @@ -130,6 +130,34 @@ struct QuickSwitcherListingDemandTests { await disconnect(connection) } + @Test("A listing another reader started counts as loading, so a search waits for it instead of reporting a miss") + func listingAnotherReaderStartedCountsAsLoading() async throws { + let (connection, driver) = connect() + let switcher = makeSwitcher(connection) + await switcher.loadItems(databaseType: .pglite, browseSchema: "public") + await switcher.flushPendingFilter() + + driver.pausesNextAllSchemaTablesFetch = true + var sidebarDemand = AllSchemaTablesDemand() + sidebarDemand.requestIfNeeded( + connectionId: connection.id, + database: "shop", + selection: SidebarSchemaFilter().selection(connectionId: connection.id, database: "shop"), + isConnected: true, + service: .shared + ) + try await waitUntil { driver.fetchTablesInAllSchemasCallCount == 1 } + switcher.searchText = "time" + + try await waitUntil { switcher.isLoadingTables } + driver.resumeAllSchemaTablesFetch() + try await waitUntil { !switcher.isLoadingTables } + await switcher.flushPendingFilter() + #expect(switcher.flatItems.contains { $0.name == "timesheet" }) + #expect(driver.fetchTablesInAllSchemasCallCount == 1) + await disconnect(connection) + } + @Test("The Tables scope lists every schema's tables, so it reads them on open") func tablesScopeReadsOnOpen() async throws { let (connection, driver) = connect() diff --git a/apple/changelog.d/3831.fixed.1.md b/apple/changelog.d/3831.fixed.1.md new file mode 100644 index 0000000000..54746fe1a0 --- /dev/null +++ b/apple/changelog.d/3831.fixed.1.md @@ -0,0 +1 @@ +On Oracle with many schemas, the first sidebar search and Open Quickly waited up to two seconds for the table list. diff --git a/apple/changelog.d/3831.fixed.2.md b/apple/changelog.d/3831.fixed.2.md new file mode 100644 index 0000000000..ed45e36288 --- /dev/null +++ b/apple/changelog.d/3831.fixed.2.md @@ -0,0 +1 @@ +Open Quickly said No results for a table in another schema while the sidebar was still loading the tables. diff --git a/apple/changelog.d/3831.fixed.3.md b/apple/changelog.d/3831.fixed.3.md new file mode 100644 index 0000000000..38969101c4 --- /dev/null +++ b/apple/changelog.d/3831.fixed.3.md @@ -0,0 +1 @@ +The sidebar's List layout said No Results while the other schemas' tables were still loading. diff --git a/apple/changelog.d/3831.fixed.4.md b/apple/changelog.d/3831.fixed.4.md new file mode 100644 index 0000000000..69d3cd2ec6 --- /dev/null +++ b/apple/changelog.d/3831.fixed.4.md @@ -0,0 +1 @@ +A sidebar search read a table list that had failed to load again on every keystroke. diff --git a/apple/changelog.d/3831.fixed.md b/apple/changelog.d/3831.fixed.md new file mode 100644 index 0000000000..550352c43c --- /dev/null +++ b/apple/changelog.d/3831.fixed.md @@ -0,0 +1 @@ +Opening a connection or a query tab paused while every editor built a find bar nobody had opened yet.