Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Show Previous Window Tab** and **Show Next Window Tab** for window tabs, with no default shortcut.
- SQLite 3.53.4 built into the SQLite and libSQL drivers in place of the macOS copy.
- One-time reset of Open Quickly's Recent query history, and of its objects on connections that switch databases.
- Other-schema tables for Open Quickly and the sidebar filter read in one query on SQL Server.
- Other-schema tables for Open Quickly and the sidebar filter read in one query on DuckDB files.
- Tables and views from every schema in the MCP `search_schema` tool when no schema is named. (#3048)

### Removed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import Foundation

public enum MSSQLTableListingScope: Sendable, Equatable {
case schema(String)
case allSchemas
}

public enum MSSQLSchemaQueries {
public static func escape(_ value: String) -> String {
MSSQLStringLiteral.escaped(value)
Expand Down Expand Up @@ -137,25 +142,47 @@ public enum MSSQLSchemaQueries {

public static let databases = "SELECT name FROM sys.databases ORDER BY name"

public static let schemas = """
/// Unordered, because SQL Server rejects an `ORDER BY` in a subquery and the all-schema table
/// listing filters by this query.
internal static let listedSchemaNames = """
SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA
WHERE SCHEMA_NAME NOT IN (
'information_schema','sys','db_owner','db_accessadmin',
'db_securityadmin','db_ddladmin','db_backupoperator',
'db_datareader','db_datawriter','db_denydatareader',
'db_denydatawriter','guest'
)
ORDER BY SCHEMA_NAME
"""

public static let schemas = listedSchemaNames + "\nORDER BY SCHEMA_NAME"

public static func tables(schema: String) -> String {
let s = MSSQLStringLiteral.quoted(schema)
tables(in: .schema(schema))
}

/// The same listing over one schema or over every schema `schemas` returns. The second filters
/// by that query itself rather than dropping the schema predicate, so a table is listed here
/// exactly when its schema is listed there, and it projects each row's schema as a third column.
public static func tables(in scope: MSSQLTableListingScope) -> String {
let schemaFilter: String
let schemaColumn: String
let orderBy: String
switch scope {
case .schema(let schema):
schemaFilter = "t.TABLE_SCHEMA = \(MSSQLStringLiteral.quoted(schema))"
schemaColumn = ""
orderBy = "t.TABLE_NAME"
case .allSchemas:
schemaFilter = "t.TABLE_SCHEMA IN (\n\(listedSchemaNames)\n)"
schemaColumn = ", t.TABLE_SCHEMA"
orderBy = "t.TABLE_SCHEMA, t.TABLE_NAME"
}
return """
SELECT t.TABLE_NAME, t.TABLE_TYPE
SELECT t.TABLE_NAME, t.TABLE_TYPE\(schemaColumn)
FROM INFORMATION_SCHEMA.TABLES t
WHERE t.TABLE_SCHEMA = \(s)
WHERE \(schemaFilter)
AND t.TABLE_TYPE IN ('BASE TABLE', 'VIEW')
ORDER BY t.TABLE_NAME
ORDER BY \(orderBy)
"""
}

Expand Down Expand Up @@ -235,10 +262,12 @@ public enum MSSQLSchemaQueries {
public struct MSSQLTableRow: Sendable, Equatable {
public let name: String
public let isView: Bool
public let schema: String?

public init(name: String, isView: Bool) {
public init(name: String, isView: Bool, schema: String? = nil) {
self.name = name
self.isView = isView
self.schema = schema
}
}

Expand Down Expand Up @@ -340,7 +369,7 @@ public extension MSSQLSchemaQueries {
static func parseTableRow(_ row: [String?]) -> MSSQLTableRow? {
guard let name = row[safe: 0] ?? nil else { return nil }
let typeRaw = (row[safe: 1] ?? nil) ?? "BASE TABLE"
return MSSQLTableRow(name: name, isView: typeRaw == "VIEW")
return MSSQLTableRow(name: name, isView: typeRaw == "VIEW", schema: row[safe: 2] ?? nil)
}

static func parseColumnRow(_ row: [String?]) -> MSSQLColumnRow? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,40 @@ final class MSSQLSchemaQueriesTests: XCTestCase {
XCTAssertTrue(sql.contains("'VIEW'"))
}

func testOneSchemaListingIsFilteredByTheSchemaAlone() {
let sql = MSSQLSchemaQueries.tables(in: .schema("sales"))
XCTAssertEqual(sql, MSSQLSchemaQueries.tables(schema: "sales"))
XCTAssertTrue(sql.contains("WHERE t.TABLE_SCHEMA = N'sales'"))
XCTAssertTrue(sql.hasPrefix("SELECT t.TABLE_NAME, t.TABLE_TYPE\n"))
XCTAssertTrue(sql.hasSuffix("ORDER BY t.TABLE_NAME"))
XCTAssertFalse(sql.contains("INFORMATION_SCHEMA.SCHEMATA"))
}

/// The all-schema listing is filtered by the schema list query itself, so a table is listed exactly when its schema
/// is one `fetchSchemas()` returns. SQL Server rejects an `ORDER BY` inside that subquery, which is why the list is
/// split from its ordering.
func testAllSchemaListingIsFilteredByTheSchemaListQuery() {
let sql = MSSQLSchemaQueries.tables(in: .allSchemas)
XCTAssertTrue(sql.contains("WHERE t.TABLE_SCHEMA IN (\n\(MSSQLSchemaQueries.listedSchemaNames)\n)"))
XCTAssertFalse(sql.contains("ORDER BY SCHEMA_NAME"))
XCTAssertTrue(sql.hasPrefix("SELECT t.TABLE_NAME, t.TABLE_TYPE, t.TABLE_SCHEMA\n"))
XCTAssertTrue(sql.contains("AND t.TABLE_TYPE IN ('BASE TABLE', 'VIEW')"))
XCTAssertTrue(sql.hasSuffix("ORDER BY t.TABLE_SCHEMA, t.TABLE_NAME"))
}

func testSchemaListIsTheListedSchemasInOrder() {
XCTAssertEqual(MSSQLSchemaQueries.schemas, MSSQLSchemaQueries.listedSchemaNames + "\nORDER BY SCHEMA_NAME")
XCTAssertFalse(MSSQLSchemaQueries.listedSchemaNames.contains("ORDER BY"))
}

func testParseTableRowReadsTheSchemaColumnWhenPresent() {
XCTAssertEqual(
MSSQLSchemaQueries.parseTableRow(["orders", "BASE TABLE", "sales"]),
MSSQLTableRow(name: "orders", isView: false, schema: "sales")
)
XCTAssertNil(MSSQLSchemaQueries.parseTableRow(["orders", "BASE TABLE"])?.schema)
}

func testColumnsQueryIncludesIdentityAndPrimaryKey() {
let sql = MSSQLSchemaQueries.columns(schema: "dbo", table: "Users")
XCTAssertTrue(sql.contains("IsIdentity"))
Expand Down
26 changes: 20 additions & 6 deletions Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -582,16 +582,30 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
// MARK: - Schema Operations

func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
let schemaName = resolveSchema(schema)
let result = try await executeParameterized(
query: DuckDBSchemaQueries.listTables,
parameters: [.text(try requireCatalog()), .text(schemaName)]
query: DuckDBSchemaQueries.listTables(in: .schema),
parameters: [.text(try requireCatalog()), .text(resolveSchema(schema))]
)
return result.rows.compactMap { row in
return Self.tableInfos(from: result)
}

/// A remote catalog answers its schema list best-effort, falling back to `main` when it cannot,
/// and one query filtered by that list has no such fallback, so it keeps the per-schema listing.
func fetchTablesInAllSchemas() async throws -> [PluginTableInfo]? {
guard remoteAlias == nil else { return nil }
let result = try await executeParameterized(
query: DuckDBSchemaQueries.listTables(in: .allSchemas),
parameters: [.text(try requireCatalog())]
)
return Self.tableInfos(from: result)
}

private static func tableInfos(from result: PluginQueryResult) -> [PluginTableInfo] {
result.rows.compactMap { row in
guard let name = row[safe: 0]?.asText else { return nil }
let typeString = (row[safe: 1]?.asText) ?? "BASE TABLE"
let tableType = typeString.uppercased().contains("VIEW") ? "VIEW" : "TABLE"
return PluginTableInfo(name: name, type: tableType)
return PluginTableInfo(name: name, type: tableType, schema: row[safe: 2]?.asText)
}
}

Expand Down Expand Up @@ -1155,7 +1169,7 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
} else if character == ")" {
depth -= 1
if depth == 0 { break }
} else if character == "," , depth == 1 {
} else if character == ",", depth == 1 {
keys.append(current)
current = ""
continue
Expand Down
52 changes: 38 additions & 14 deletions Plugins/DuckDBDriverPlugin/DuckDBSchemaQueries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@

import Foundation

enum DuckDBTableListingScope: Sendable, Equatable {
case schema
case allSchemas
}

/// DuckDB's namespace is `catalog.schema.table`, and the `duckdb_*` table functions span
/// every attached catalog. A predicate on the schema alone therefore matches same-named
/// schemas in other catalogs: with a second database attached, `WHERE schema_name = 'main'`
Expand Down Expand Up @@ -54,20 +59,39 @@ enum DuckDBSchemaQueries {
ORDER BY schema_name
"""

static let listTables = """
SELECT table_name, 'BASE TABLE' AS table_type
FROM duckdb_tables()
WHERE database_name = $1
AND schema_name = $2
AND internal = false
UNION ALL
SELECT view_name, 'VIEW'
FROM duckdb_views()
WHERE database_name = $1
AND schema_name = $2
AND internal = false
ORDER BY 1
"""
/// One schema's objects, bound to the catalog and the schema, or every schema's, bound to the
/// catalog alone. The second filters by `listSchemas` itself rather than dropping the schema
/// predicate, so an object is listed here exactly when its schema is listed there, and it
/// projects each row's schema as a third column.
static func listTables(in scope: DuckDBTableListingScope) -> String {
let schemaFilter: String
let schemaColumn: String
let orderBy: String
switch scope {
case .schema:
schemaFilter = "schema_name = $2"
schemaColumn = ""
orderBy = "ORDER BY 1"
case .allSchemas:
schemaFilter = "schema_name IN (\n\(listSchemas)\n)"
schemaColumn = ", schema_name"
orderBy = "ORDER BY 3, 1"
}
return """
SELECT table_name, 'BASE TABLE' AS table_type\(schemaColumn)
FROM duckdb_tables()
WHERE database_name = $1
AND \(schemaFilter)
AND internal = false
UNION ALL
SELECT view_name, 'VIEW'\(schemaColumn)
FROM duckdb_views()
WHERE database_name = $1
AND \(schemaFilter)
AND internal = false
\(orderBy)
"""
}

static let columnsForTable = """
SELECT column_name, data_type, is_nullable, column_default, column_index
Expand Down
44 changes: 19 additions & 25 deletions Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,25 @@ extension MSSQLPluginDriver {

func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
let resolved = effectiveSchema(schema)
let schemaLiteral = MSSQLStringLiteral.quoted(resolved)
let sql = """
SELECT t.TABLE_NAME, t.TABLE_TYPE
FROM INFORMATION_SCHEMA.TABLES t
WHERE t.TABLE_SCHEMA = \(schemaLiteral)
AND t.TABLE_TYPE IN ('BASE TABLE', 'VIEW')
ORDER BY t.TABLE_NAME
"""
let result = try await execute(query: sql)
return try await listTables(in: .schema(resolved), schemaFallback: resolved)
}

func fetchTablesInAllSchemas() async throws -> [PluginTableInfo]? {
try await listTables(in: .allSchemas, schemaFallback: nil)
}

private func listTables(
in scope: MSSQLTableListingScope,
schemaFallback: String?
) async throws -> [PluginTableInfo] {
let result = try await execute(query: MSSQLSchemaQueries.tables(in: scope))
return result.rows.compactMap { row -> PluginTableInfo? in
guard let name = row[safe: 0]?.asText else { return nil }
let rawType = row[safe: 1]?.asText
let tableType = (rawType == "VIEW") ? "VIEW" : "TABLE"
return PluginTableInfo(name: name, type: tableType, schema: resolved)
guard let table = MSSQLSchemaQueries.parseTableRow(row.map(\.asText)) else { return nil }
return PluginTableInfo(
name: table.name,
type: table.isView ? "VIEW" : "TABLE",
schema: table.schema ?? schemaFallback
)
}
}

Expand Down Expand Up @@ -545,17 +550,7 @@ extension MSSQLPluginDriver {
}

func fetchSchemas() async throws -> [String] {
let sql = """
SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA
WHERE SCHEMA_NAME NOT IN (
'information_schema','sys','db_owner','db_accessadmin',
'db_securityadmin','db_ddladmin','db_backupoperator',
'db_datareader','db_datawriter','db_denydatareader',
'db_denydatawriter','guest'
)
ORDER BY SCHEMA_NAME
"""
let result = try await execute(query: sql)
let result = try await execute(query: MSSQLSchemaQueries.schemas)
return result.rows.compactMap { $0.first?.asText }
}

Expand Down Expand Up @@ -619,5 +614,4 @@ extension MSSQLPluginDriver {
ORDER BY t.name
"""
}

}
30 changes: 26 additions & 4 deletions TableProTests/Plugins/DuckDBSchemaQueriesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import Testing
struct DuckDBSchemaQueriesTests {
private static let catalogScopedQueries: [(name: String, sql: String)] = [
("listSchemas", DuckDBSchemaQueries.listSchemas),
("listTables", DuckDBSchemaQueries.listTables),
("listTables(in: .schema)", DuckDBSchemaQueries.listTables(in: .schema)),
("listTables(in: .allSchemas)", DuckDBSchemaQueries.listTables(in: .allSchemas)),
("columnsForTable", DuckDBSchemaQueries.columnsForTable),
("columnsForSchema", DuckDBSchemaQueries.columnsForSchema),
("primaryKeyColumnsForSchema", DuckDBSchemaQueries.primaryKeyColumnsForSchema),
Expand Down Expand Up @@ -116,9 +117,12 @@ struct DuckDBSchemaQueriesTests {

// MARK: - Object listing

@Test("The table list reports views alongside tables and hides internal objects in both")
func tableListIncludesViews() {
let sql = DuckDBSchemaQueries.listTables
@Test(
"The table list reports views alongside tables and hides internal objects in both",
arguments: [DuckDBTableListingScope.schema, .allSchemas]
)
func tableListIncludesViews(scope: DuckDBTableListingScope) {
let sql = DuckDBSchemaQueries.listTables(in: scope)
#expect(sql.contains("duckdb_tables()"))
#expect(sql.contains("duckdb_views()"))
#expect(sql.contains("'BASE TABLE'"))
Expand All @@ -129,6 +133,24 @@ struct DuckDBSchemaQueriesTests {
)
}

@Test("The one-schema listing binds the schema and projects no schema column")
func oneSchemaListingBindsTheSchema() {
let sql = DuckDBSchemaQueries.listTables(in: .schema)
#expect(sql.components(separatedBy: "schema_name = $2").count == 3)
#expect(!sql.contains(DuckDBSchemaQueries.listSchemas))
#expect(!sql.contains(", schema_name"))
#expect(sql.hasSuffix("ORDER BY 1"))
}

@Test("Every arm of the all-schema listing is filtered by the schema list query itself")
func allSchemaListingFiltersByTheSchemaList() {
let sql = DuckDBSchemaQueries.listTables(in: .allSchemas)
#expect(sql.components(separatedBy: "schema_name IN (\n\(DuckDBSchemaQueries.listSchemas)\n)").count == 3)
#expect(!sql.contains("$2"))
#expect(sql.components(separatedBy: ", schema_name\n").count == 3)
#expect(sql.hasSuffix("ORDER BY 3, 1"))
}

// MARK: - Keys

@Test("Primary key columns are unnested so a composite key yields one row per column")
Expand Down
9 changes: 8 additions & 1 deletion docs/features/open-quickly.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,14 @@ Type a dot to search by where a table lives:
| `shop.attendance.timesheet` | The table, when `shop` is the database the connection is browsing |
| `"my.schema".orders` | `orders` in a schema whose name holds a dot. Backticks and square brackets quote too |

The browsed schema's tables are listed as the panel opens, and the rest arrive after one catalog query on PostgreSQL or one query per schema on other engines. Until then the panel reads **Loading…** rather than reporting no results.
The browsed schema's tables are listed as the panel opens. Until the rest arrive the panel reads **Loading…** rather than reporting no results.

| Engine | The other schemas' tables arrive after |
|---|---|
| PostgreSQL, PGlite | One query |
| SQL Server | One query |
| DuckDB | One query, or one per schema on a [remote Quack connection](/databases/duckdb#remote-quack) |
| Every other engine | One query per schema |

## Across connections

Expand Down
Loading
Loading