Skip to content

Commit 1243ec2

Browse files
authored
feat(sidebar): find tables in every schema from Open Quickly and the sidebar filter (#3060)
* feat(sidebar): find tables in every schema from Open Quickly and the sidebar filter * fix(sidebar): keep cross-schema search correct across failed reads, database switches and dotted names * fix(sidebar): keep cross-schema listings fresh across reconnects and database switches * fix(sidebar): open only matching sections while filtering, and list a schema alphabetically in Open Quickly * docs(sidebar): screenshots of other-schema results in Open Quickly and the sidebar filter --------- Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
1 parent 9d3815a commit 1243ec2

46 files changed

Lines changed: 3071 additions & 205 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3838
- **File > Session**, with the agent session commands and the assistant's conversation commands.
3939
- Eight more rebindable commands in **Settings > Keyboard**, among them the sidebar's lists and the session commands.
4040
- **Global** on a saved query folder's menu, for a folder every connection shows.
41+
- Tables from every schema in Open Quickly and the sidebar filter, and `schema.table` searches in both. (#3048)
4142
- Recent-tab switching on Control-Tab, with a list of the window's tabs while Control is held. (#2524)
4243
- **Extensions** for SQLite and local libSQL connections, loading sqlite-vec, SpatiaLite and other libraries on connect. (#2502)
4344

@@ -74,6 +75,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7475

7576
### Fixed
7677

78+
- Tables in an expanded Oracle or Snowflake schema missing from Open Quickly until the next refresh.
79+
- Schemas missing from Open Quickly on every reopen after one failed to load.
80+
- Unexpanded schemas hidden by the sidebar filter in the Tree layout.
81+
- Empty object sections opened as "No items" under every match while filtering the sidebar tree.
82+
- **Drop View** offered in Recent for a sequence or materialized view opened from Open Quickly.
7783
- Unresponsive app and a dropped keystroke when typing in the row inspector's JSON field. (#3051)
7884
- Raw Oracle driver error in the schema switch failure dialog. (#3053)
7985
- Oracle health check closing a connection a statement was still running on. (#3053)

Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,17 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
166166
// MARK: - Schema
167167

168168
func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
169-
let schemaName = schema ?? core.currentSchema
169+
try await listTables(in: .schema(schema ?? core.currentSchema))
170+
}
171+
172+
func fetchTablesInAllSchemas() async throws -> [PluginTableInfo]? {
173+
try await listTables(in: .allSchemas)
174+
}
175+
176+
private func listTables(in listing: PostgreSQLTableListingScope) async throws -> [PluginTableInfo] {
170177
func query(_ attempt: PostgreSQLTableListingAttempt) -> String {
171178
PostgreSQLSchemaQueries.fetchTables(
172-
schema: schemaName,
179+
in: listing,
173180
includeMaterializedViews: attempt.includeOptionalCatalogs && includesMaterializedViews(),
174181
includeForeignTables: attempt.includeOptionalCatalogs && includesForeignTables(),
175182
includeComments: attempt.includeComments,
@@ -203,7 +210,13 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
203210
}
204211
let comment = row[safe: 2]?.asText?.nilIfEmpty
205212
let partitionCount = row[safe: 3]?.asText.flatMap(Int.init)
206-
return PluginTableInfo(name: name, type: type, comment: comment, partitionCount: partitionCount)
213+
return PluginTableInfo(
214+
name: name,
215+
type: type,
216+
schema: row[safe: 4]?.asText,
217+
comment: comment,
218+
partitionCount: partitionCount
219+
)
207220
}
208221
}
209222

Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ enum PostgreSQLSchemaProbe: Equatable {
1515
case failed
1616
}
1717

18+
enum PostgreSQLTableListingScope: Sendable, Equatable {
19+
case schema(String)
20+
case allSchemas
21+
}
22+
1823
enum PostgreSQLSchemaQueries {
1924
/// Returns the first schema on the effective search path, or SQL NULL
2025
/// when the path is empty (neither `$user` nor `public` exists).
@@ -110,7 +115,38 @@ enum PostgreSQLSchemaQueries {
110115
includeComments: Bool = true,
111116
includePartitionAwareness: Bool = true
112117
) -> String {
113-
let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema)
118+
fetchTables(
119+
in: .schema(schema),
120+
includeMaterializedViews: includeMaterializedViews,
121+
includeForeignTables: includeForeignTables,
122+
includeComments: includeComments,
123+
includePartitionAwareness: includePartitionAwareness
124+
)
125+
}
126+
127+
/// The same listing over one schema or over every schema `listSchemas` returns. The second
128+
/// filters by that query itself rather than restating its predicate, so a table is listed here
129+
/// exactly when its schema is listed there, and projects each row's schema, which the
130+
/// one-schema listing leaves to the caller.
131+
static func fetchTables(
132+
in listing: PostgreSQLTableListingScope,
133+
includeMaterializedViews: Bool,
134+
includeForeignTables: Bool,
135+
includeComments: Bool = true,
136+
includePartitionAwareness: Bool = true
137+
) -> String {
138+
func schemaFilter(_ column: String) -> String {
139+
switch listing {
140+
case .schema(let schema):
141+
return "\(column) = \(PostgreSQLObjectQueries.quoteLiteral(schema))"
142+
case .allSchemas:
143+
return "\(column) IN (\n\(listSchemas)\n)"
144+
}
145+
}
146+
func schemaColumn(_ column: String) -> String {
147+
listing == .allSchemas ? ",\n \(column) AS schema_name" : ""
148+
}
149+
let orderBy = listing == .allSchemas ? "ORDER BY schema_name, table_name" : "ORDER BY table_name"
114150
func commentColumn(_ oidExpression: String) -> String {
115151
includeComments ? "obj_description(\(oidExpression), 'pg_class')" : "NULL::text"
116152
}
@@ -140,9 +176,9 @@ enum PostgreSQLSchemaQueries {
140176
"""
141177
SELECT t.table_name, \(tableTypeColumn) AS table_type,
142178
\(commentColumn("pc.oid")) AS table_comment,
143-
\(partitionCountColumn) AS partition_count
179+
\(partitionCountColumn) AS partition_count\(schemaColumn("t.table_schema"))
144180
FROM information_schema.tables t\(classJoin)
145-
WHERE t.table_schema = \(schemaLiteral)
181+
WHERE \(schemaFilter("t.table_schema"))
146182
AND t.table_type IN ('BASE TABLE', 'VIEW')\(partitionFilter)
147183
"""
148184
]
@@ -157,9 +193,9 @@ enum PostgreSQLSchemaQueries {
157193
"""
158194
SELECT m.matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type,
159195
\(commentColumn("mc.oid")) AS table_comment,
160-
NULL::bigint AS partition_count
196+
NULL::bigint AS partition_count\(schemaColumn("m.schemaname"))
161197
FROM pg_matviews m\(matviewJoin)
162-
WHERE m.schemaname = \(schemaLiteral)
198+
WHERE \(schemaFilter("m.schemaname"))
163199
"""
164200
)
165201
}
@@ -172,16 +208,16 @@ enum PostgreSQLSchemaQueries {
172208
"""
173209
SELECT c.relname AS table_name, 'FOREIGN TABLE' AS table_type,
174210
\(commentColumn("c.oid")) AS table_comment,
175-
NULL::bigint AS partition_count
211+
NULL::bigint AS partition_count\(schemaColumn("n.nspname"))
176212
FROM pg_foreign_table ft
177213
JOIN pg_class c ON c.oid = ft.ftrelid
178214
JOIN pg_namespace n ON n.oid = c.relnamespace
179-
WHERE n.nspname = \(schemaLiteral)\(foreignPartitionFilter)
215+
WHERE \(schemaFilter("n.nspname"))\(foreignPartitionFilter)
180216
"""
181217
)
182218
}
183219

184-
return unions.joined(separator: "\nUNION ALL\n") + "\nORDER BY table_name"
220+
return unions.joined(separator: "\nUNION ALL\n") + "\n" + orderBy
185221
}
186222

187223
/// The predicate that keeps a partition out of a flat listing. A foreign

Plugins/TableProPluginKit/PluginDatabaseDriver.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable {
9393
func executeBoundedQuery(query: String, rowCap: Int) async throws -> PluginQueryResult?
9494

9595
func fetchTables(schema: String?) async throws -> [PluginTableInfo]
96+
97+
/// What `fetchTables(schema:)` lists for every schema `fetchSchemas()` lists, in one call, each
98+
/// row carrying its own schema. Nil means the engine has no single call for it, and the host
99+
/// asks each schema in turn instead.
100+
func fetchTablesInAllSchemas() async throws -> [PluginTableInfo]?
96101
func fetchPartitions(table: String, schema: String?) async throws -> [PluginTableInfo]
97102

98103
/// The same partitions as `fetchPartitions`, with the bound, the ordinal position and the row
@@ -641,6 +646,8 @@ public extension PluginDatabaseDriver {
641646

642647
func fetchSchemas() async throws -> [String] { [] }
643648

649+
func fetchTablesInAllSchemas() async throws -> [PluginTableInfo]? { nil }
650+
644651
/// Schemas whose objects live in a catalog outside the database itself, such
645652
/// as Redshift external schemas backed by Glue, Hive, or a federated source.
646653
/// Engines without that concept keep the empty default.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
//
2+
// CatalogFreshness.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
/// Which cached lists a catalog change has overtaken, for a cache that refetches when it is next
9+
/// read rather than the moment the catalog changes.
10+
///
11+
/// A change moves its key's revision, and a fetch carries the revision it started under. A fetch
12+
/// that was already running when the change landed still delivers its rows, but it cannot make
13+
/// the key current again, so the next read fetches once more. A key whose fetch failed was never
14+
/// committed, which leaves it stale and retried on the next read too.
15+
struct CatalogFreshness<Key: Hashable> {
16+
private var revisions: [Key: Int] = [:]
17+
private var committed: [Key: Int] = [:]
18+
19+
func revision(for key: Key) -> Int {
20+
revisions[key, default: 0]
21+
}
22+
23+
func isCurrent(_ key: Key) -> Bool {
24+
committed[key] == revision(for: key)
25+
}
26+
27+
mutating func markChanged(_ key: Key) {
28+
revisions[key, default: 0] &+= 1
29+
}
30+
31+
/// False when a fetch that started later has already committed, so an older fetch finishing
32+
/// last cannot put its rows back over newer ones.
33+
mutating func commit(_ revision: Int, for key: Key) -> Bool {
34+
if let current = committed[key], current > revision { return false }
35+
committed[key] = revision
36+
return true
37+
}
38+
39+
mutating func removeAll(where shouldRemove: (Key) -> Bool) {
40+
revisions = revisions.filter { !shouldRemove($0.key) }
41+
committed = committed.filter { !shouldRemove($0.key) }
42+
}
43+
}

TablePro/Core/Database/BackupScopeLoader.swift

Lines changed: 17 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -92,32 +92,26 @@ enum BackupScopeLoader {
9292
}
9393
}
9494

95+
/// A schema that could not be listed fails the whole list, as it did when each schema was read
96+
/// in turn: a picker that silently lacks a schema would back up less than the user chose.
9597
@MainActor
9698
private static func schemaQualifiedObjects(scope: DatabaseScope) async throws -> [NativeDumpObject] {
97-
let schemas = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in
98-
try await driver.fetchSchemas()
99-
}
100-
var objects: [NativeDumpObject] = []
101-
for schema in schemas {
102-
let qualified = DatabaseScope(
103-
connectionId: scope.connectionId, database: scope.database, schema: schema
104-
)
105-
let tables = try await DatabaseManager.shared.withMetadataDriver(
106-
scope: qualified, workload: .bulk
107-
) { driver in
108-
try await driver.fetchTables(schema: schema)
99+
let listing = try await CatalogTableListing.tables(in: scope, excludingSchemas: [])
100+
guard listing.unlistedSchemas.isEmpty else { throw BackupScopeLoadError.schemasNotListed }
101+
return listing.tables
102+
.filter(\.type.isBackupSelectable)
103+
.compactMap { table in
104+
guard let schema = table.schema else { return nil }
105+
return NativeDumpObject(
106+
name: table.name,
107+
schema: schema,
108+
isPartitionedParent: table.type == .partitionedTable
109+
)
109110
}
110-
objects += tables
111-
.filter(\.type.isBackupSelectable)
112-
.map {
113-
NativeDumpObject(
114-
name: $0.name,
115-
schema: schema,
116-
isPartitionedParent: $0.type == .partitionedTable
117-
)
118-
}
119-
}
120-
return objects
111+
}
112+
113+
private enum BackupScopeLoadError: Error {
114+
case schemasNotListed
121115
}
122116

123117
/// Everything the dump tool has to be told about to reproduce the chosen objects.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
//
2+
// CatalogTableListing.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import os
8+
9+
/// Every table one database holds, across all of its schemas.
10+
///
11+
/// An engine that answers `fetchTablesInAllSchemas()` is asked once. Any other is asked schema by
12+
/// schema, and each of those reads queues on the metadata lane by itself, so a sidebar expansion
13+
/// that arrives in the middle waits behind one schema rather than behind all of them. Every read
14+
/// goes through the one scope the caller names: a scope per schema would open a pooled connection
15+
/// per schema.
16+
@MainActor
17+
internal enum CatalogTableListing {
18+
/// A schema whose own read failed is named rather than dropped. Read as empty, it would tell a
19+
/// search that nothing in it matches, and hide exactly the table the search was looking for.
20+
internal struct Result: Sendable, Equatable {
21+
internal let tables: [TableInfo]
22+
internal let unlistedSchemas: Set<String>
23+
24+
/// This listing with another read of some of its unlisted schemas folded in. A schema the
25+
/// read listed replaces what was known of it; one it still could not list keeps its rows
26+
/// and stays unlisted.
27+
internal func merging(_ retry: Result, retried schemas: Set<String>) -> Result {
28+
let listedNow = schemas.subtracting(retry.unlistedSchemas)
29+
let kept = tables.filter { table in
30+
guard let schema = table.schema else { return true }
31+
return !listedNow.contains(schema)
32+
}
33+
return Result(
34+
tables: kept + retry.tables,
35+
unlistedSchemas: unlistedSchemas.subtracting(schemas).union(retry.unlistedSchemas)
36+
)
37+
}
38+
39+
/// A refresh that could not read a schema says nothing new about it, so the rows an earlier
40+
/// listing had for that schema are carried over rather than dropped.
41+
internal func keepingRows(from previous: Result?) -> Result {
42+
guard let previous, !unlistedSchemas.isEmpty else { return self }
43+
let carried = previous.tables.filter { table in
44+
guard let schema = table.schema else { return false }
45+
return unlistedSchemas.contains(schema)
46+
}
47+
return Result(tables: tables + carried, unlistedSchemas: unlistedSchemas)
48+
}
49+
}
50+
51+
private static let logger = Logger(subsystem: "com.TablePro", category: "CatalogTableListing")
52+
53+
internal static func tables(
54+
in scope: DatabaseScope,
55+
excludingSchemas excluded: Set<String>,
56+
metadata: ScopedMetadataProviding = DatabaseManager.shared
57+
) async throws -> Result {
58+
let listed = try await metadata.withMetadataDriver(scope: scope, workload: .bulk) { driver in
59+
try await driver.fetchTablesInAllSchemas()
60+
}
61+
if let listed {
62+
let tables = listed.filter { table in
63+
guard let schema = table.schema else { return true }
64+
return !excluded.contains(schema)
65+
}
66+
return Result(tables: tables, unlistedSchemas: [])
67+
}
68+
let schemas = try await metadata.withMetadataDriver(scope: scope, workload: .bulk) { driver in
69+
try await driver.fetchSchemas()
70+
}
71+
return try await tables(inSchemas: schemas.filter { !excluded.contains($0) }, scope: scope, metadata: metadata)
72+
}
73+
74+
/// The named schemas one by one, which is also how a listing asks again for the schemas it
75+
/// could not read the first time.
76+
///
77+
/// Only a failure that belongs to one schema is recorded against it. A lost connection fails
78+
/// every schema the same way, and recording that as a listing of nothing would read as a
79+
/// database with no tables, so it fails the whole read instead, as does every schema failing.
80+
internal static func tables(
81+
inSchemas schemas: [String],
82+
scope: DatabaseScope,
83+
metadata: ScopedMetadataProviding = DatabaseManager.shared
84+
) async throws -> Result {
85+
var tables: [TableInfo] = []
86+
var unlisted: Set<String> = []
87+
var lastError: Error?
88+
for schema in schemas {
89+
try Task.checkCancellation()
90+
do {
91+
tables += try await metadata.withMetadataDriver(scope: scope, workload: .bulk) { driver in
92+
try await driver.fetchTables(schema: schema)
93+
}
94+
} catch is CancellationError {
95+
throw CancellationError()
96+
} catch let error as DatabaseError {
97+
throw error
98+
} catch {
99+
logger.warning(
100+
"[catalog] schema not listed schema=\(schema, privacy: .private(mask: .hash)) error=\(error.publicLogShape, privacy: .public)"
101+
)
102+
unlisted.insert(schema)
103+
lastError = error
104+
}
105+
}
106+
if let lastError, !schemas.isEmpty, unlisted.count == schemas.count {
107+
throw lastError
108+
}
109+
return Result(tables: tables, unlistedSchemas: unlisted)
110+
}
111+
}

TablePro/Core/Database/DatabaseDriver.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ protocol DatabaseDriver: AnyObject, Sendable {
9999

100100
func fetchTables(schema: String?) async throws -> [TableInfo]
101101

102+
/// Every schema's tables in one call, or nil when the engine has no such call and the caller
103+
/// has to ask each schema itself. `CatalogTableListing` is the caller that does.
104+
func fetchTablesInAllSchemas() async throws -> [TableInfo]?
105+
102106
/// Fetch the direct partitions of one partitioned table, with each one's bound, position and
103107
/// row estimate. A partition is not a table on every engine, so this cannot answer `TableInfo`:
104108
/// a MySQL or Oracle partition name is unique only within its own table.
@@ -704,6 +708,8 @@ extension DatabaseDriver {
704708
try await fetchTables()
705709
}
706710

711+
func fetchTablesInAllSchemas() async throws -> [TableInfo]? { nil }
712+
707713
func fetchRoutines(schema: String?) async throws -> [RoutineInfo] { [] }
708714

709715
func fetchRoutineDDL(_ routine: RoutineInfo) async throws -> String {

0 commit comments

Comments
 (0)