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
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import Foundation

public enum TrinoIntrospectionSQL {
// `.literal` matches code units; the default skips a quote that a combining mark follows.
public static func quoteIdentifier(_ name: String) -> String {
"\"" + name.replacingOccurrences(of: "\"", with: "\"\"") + "\""
"\"" + name.replacingOccurrences(of: "\"", with: "\"\"", options: .literal) + "\""
}

public static func quoteLiteral(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "''") + "'"
"'" + value.replacingOccurrences(of: "'", with: "''", options: .literal) + "'"
}

// `IN ()` does not parse.
private static func membership(_ column: String, in values: [String]) -> String {
guard !values.isEmpty else { return "false" }
return "\(column) IN (\(values.map(quoteLiteral).joined(separator: ", ")))"
}

public static func qualifiedName(catalog: String?, schema: String?, table: String) -> String {
Expand Down Expand Up @@ -36,6 +43,13 @@ public enum TrinoIntrospectionSQL {
"""
}

public static func listTables(catalog: String, inSchemas schemas: [String]) -> String {
"""
SELECT table_name, table_type, table_schema FROM \(quoteIdentifier(catalog)).information_schema.tables \
WHERE \(membership("table_schema", in: schemas)) ORDER BY table_schema, table_name
"""
}

public static func listColumns(catalog: String, schema: String, table: String) -> String {
"""
SELECT column_name, data_type, is_nullable, column_default, comment, ordinal_position \
Expand Down Expand Up @@ -83,6 +97,25 @@ public enum TrinoIntrospectionSQL {
}
}

public static func listMaterializedViews(
catalog: String,
inSchemas schemas: [String],
flavor: TrinoServerFlavor
) -> String {
switch flavor {
case .trino:
return """
SELECT name, schema_name FROM system.metadata.materialized_views \
WHERE catalog_name = \(quoteLiteral(catalog)) AND \(membership("schema_name", in: schemas))
"""
case .presto:
return """
SELECT table_name, table_schema FROM \(quoteIdentifier(catalog)).information_schema.materialized_views \
WHERE \(membership("table_schema", in: schemas))
"""
}
}

/// One relation's `table_type`, which says `VIEW` for a view. A materialized view is not told
/// apart here: ``materializedView(catalog:schema:name:flavor:)`` answers for it.
public static func relationType(catalog: String, schema: String, table: String) -> String {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import TableProTrinoCore
import Testing

struct TrinoIntrospectionSQLTests {
@Test("A schema set is listed in one query that names each schema and projects it")
func schemaSetListing() {
let statement = TrinoIntrospectionSQL.listTables(catalog: "hive", inSchemas: ["sales", "o'brien"])
#expect(statement.contains("FROM \"hive\".information_schema.tables"))
#expect(statement.contains("WHERE table_schema IN ('sales', 'o''brien')"))
#expect(statement.contains("SELECT table_name, table_type, table_schema"))
#expect(TrinoIntrospectionSQL.listTables(catalog: "hive", inSchemas: []).contains("WHERE false"))
}

@Test("A schema set's materialized views come from one query that projects the schema")
func schemaSetMaterializedViews() {
let statement = TrinoIntrospectionSQL.listMaterializedViews(
catalog: "iceberg", inSchemas: ["a", "b"], flavor: .trino
)
#expect(statement.contains("SELECT name, schema_name FROM system.metadata.materialized_views"))
#expect(statement.contains("catalog_name = 'iceberg' AND schema_name IN ('a', 'b')"))
}

@Test("On Presto a schema set's materialized views come from information_schema in one query")
func prestoSchemaSetMaterializedViews() {
let statement = TrinoIntrospectionSQL.listMaterializedViews(
catalog: "hive", inSchemas: ["a", "o'brien"], flavor: .presto
)
#expect(statement.contains(#"SELECT table_name, table_schema FROM "hive".information_schema.materialized_views"#))
#expect(statement.contains("WHERE table_schema IN ('a', 'o''brien')"))
}

@Test("A quote that a combining mark follows is still doubled")
func combiningMarkCannotHideAQuote() {
#expect(TrinoIntrospectionSQL.quoteLiteral("x'\u{301} OR true--") == "'x''\u{301} OR true--'")
#expect(TrinoIntrospectionSQL.quoteIdentifier("a\"\u{301}") == "\"a\"\"\u{301}\"")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ public struct OracleTableRow: Sendable, Equatable {
/// count.
public let isPartitioned: Bool
public let partitionCount: Int?
// Nil from a one-schema listing, which does not project the owner.
public let schema: String?

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

Expand Down Expand Up @@ -188,6 +191,29 @@ public enum OracleSchemaQueries {
"""
}

// ORA-01795: an IN list holds at most 1,000 expressions.
public static let maxOwnersPerTableListing = 1_000

// Names the owners so the read skips SYS and the other Oracle-maintained schemas the host drops.
public static func tables(inSchemas schemas: [String]) -> [String] {
stride(from: 0, to: schemas.count, by: maxOwnersPerTableListing).map { start in
let chunk = schemas[start..<min(start + maxOwnersPerTableListing, schemas.count)]
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,
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))
ORDER BY 5, 1
"""
}
}

/// 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.
Expand Down Expand Up @@ -476,7 +502,8 @@ public enum OracleSchemaQueries {
name: name,
isView: row[safe: 1]?.stringValue == "VIEW",
isPartitioned: row[safe: 2]?.stringValue == "Y",
partitionCount: row[safe: 3]?.stringValue.flatMap(Int.init)
partitionCount: row[safe: 3]?.stringValue.flatMap(Int.init),
schema: row[safe: 4]?.stringValue
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ struct OracleDictionaryQualificationTests {
("serverVersion", OracleSchemaQueries.serverVersion),
("users", OracleSchemaQueries.users),
("tables", OracleSchemaQueries.tables(schema: "HR")),
("tables in schemas", OracleSchemaQueries.tables(inSchemas: ["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))),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,40 @@ struct OracleSchemaQueriesTests {
#expect(OracleSchemaQueries.foreignKeys(schema: owner, table: "T").contains("ac.OWNER = 'O''BRIEN'"))
}

@Test func testQuoteFollowedByACombiningMarkIsEscaped() {
let owner = "O'\u{301} OR 1=1 --"
#expect(OracleSchemaQueries.escapeLiteral(owner) == "O''\u{301} OR 1=1 --")
#expect(OracleSchemaQueries.quoteIdentifier("A\"\u{301}") == "\"A\"\"\u{301}\"")
}

@Test func testSchemaSetListingNamesEveryOwnerAndProjectsIt() throws {
let statements = OracleSchemaQueries.tables(inSchemas: ["HR", "O'BRIEN"])
let sql = try #require(statements.first)
#expect(statements.count == 1)
#expect(sql.contains("WHERE t.owner IN ('HR', 'O''BRIEN')"))
#expect(sql.contains("FROM SYS.ALL_VIEWS WHERE owner IN ('HR', 'O''BRIEN')"))
#expect(sql.contains("SELECT view_name, 'VIEW', 'N', NULL, owner"))
#expect(sql.contains("ORDER BY 5, 1"))
}

@Test func testSchemaSetListingSplitsPastTheInListLimit() {
let owners = (0..<2_001).map { "U\($0)" }
let statements = OracleSchemaQueries.tables(inSchemas: owners)
#expect(statements.count == 3)
#expect(statements[0].contains("'U999')") && !statements[0].contains("'U1000'"))
#expect(statements[1].contains("IN ('U1000', ") && statements[1].contains("'U1999')"))
#expect(statements[2].contains("IN ('U2000')"))
#expect(OracleSchemaQueries.tables(inSchemas: []).isEmpty)
}

@Test func testParseTableRowReadsTheOwnerOfASchemaSetRow() {
let row = OracleSchemaQueries.parseTableRow([
.string("ORDERS"), .string("BASE TABLE"), .string("N"), .null, .string("SALES")
])
#expect(row == OracleTableRow(name: "ORDERS", isView: false, schema: "SALES"))
#expect(OracleSchemaQueries.parseTableRow([.string("ORDERS"), .string("BASE TABLE")])?.schema == nil)
}

@Test func testTableNameIsEscaped() {
let sql = OracleSchemaQueries.columns(schema: "HR", table: "IT'S", release: release23ai)
#expect(sql.contains("c.TABLE_NAME = 'IT''S'"))
Expand Down
28 changes: 28 additions & 0 deletions apple/Plugins/DamengDriverPlugin/DamengPluginDriver+Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,34 @@ extension DamengPluginDriver {
}
}

func fetchTables(inSchemas schemas: [String]) async throws -> [PluginTableInfo]? {
// DM8 follows Oracle here, so each statement stays under Oracle's 1,000-expression IN limit.
let chunkSize = 1_000

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard-coded chunkSize = 1_000 with each owner bound twice means 2,000 bind parameters per statement, unverified against DM's cap. A failure falls back to per-schema queries, losing the gain on large catalogs. Also duplicates OracleSchemaQueries.maxOwnersPerTableListing.

var tables: [PluginTableInfo] = []
for start in stride(from: 0, to: schemas.count, by: chunkSize) {
let owners = Array(schemas[start..<min(start + chunkSize, schemas.count)])
let placeholders = Array(repeating: "?", count: owners.count).joined(separator: ", ")
let result = try await executeParameterized(
query: """
SELECT TABLE_NAME, 'TABLE' AS TABLE_TYPE, OWNER
FROM SYS.ALL_TABLES
WHERE OWNER IN (\(placeholders))
UNION ALL
SELECT VIEW_NAME, 'VIEW', OWNER
FROM SYS.ALL_VIEWS
WHERE OWNER IN (\(placeholders))
ORDER BY 3, 1
""",
parameters: (owners + owners).map { .text($0) }
)
tables += result.rows.compactMap { row in
guard let name = row[safe: 0]?.asText, let owner = row[safe: 2]?.asText else { return nil }
return PluginTableInfo(name: name, type: row[safe: 1]?.asText ?? "TABLE", schema: owner)
}
}
return tables
}

func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] {
let owner = effectiveSchema(schema)
let result = try await executeParameterized(
Expand Down
7 changes: 7 additions & 0 deletions apple/Plugins/HanaDriverPlugin/HanaCatalogMapping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,13 @@ enum HanaCatalogMapping {
}
}

static func tablesInSchemas(from rows: [[PluginCellValue]]) -> [PluginTableInfo] {
rows.compactMap { row in
guard let schema = row[safe: 3]?.asText else { return nil }
return tables(from: [row], schema: schema).first
}
}

static func columns(from rows: [[PluginCellValue]]) -> [HanaCatalogColumn] {
rows.compactMap(HanaCatalogColumn.init(row:))
}
Expand Down
15 changes: 15 additions & 0 deletions apple/Plugins/HanaDriverPlugin/HanaCatalogQueries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ enum HanaCatalogQueries {
"""
}

static func tables(inSchemas schemas: [String]) -> String {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tables(inSchemas:) builds IN () for an empty list, which does not parse. Only the caller's guard prevents it. Trino and Postgres use a membership(...) helper that emits false for an empty list.

let owners = schemas.map(HanaSQL.quoteLiteral).joined(separator: ", ")
return """
SELECT TABLE_NAME, 'TABLE' AS OBJECT_TYPE, COMMENTS, SCHEMA_NAME
FROM SYS.TABLES
WHERE SCHEMA_NAME IN (\(owners))
AND IS_USER_DEFINED_TYPE = 'FALSE'
UNION ALL
SELECT VIEW_NAME, 'VIEW', COMMENTS, SCHEMA_NAME
FROM SYS.VIEWS
WHERE SCHEMA_NAME IN (\(owners))
ORDER BY 4, 1
"""
}

static func columns(schema: String, table: String?) -> String {
let owner = HanaSQL.quoteLiteral(schema)
let tableFilter = table.map { "\n AND TABLE_NAME = \(HanaSQL.quoteLiteral($0))" } ?? ""
Expand Down
6 changes: 6 additions & 0 deletions apple/Plugins/HanaDriverPlugin/HanaPluginDriver+Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ extension HanaPluginDriver {
return HanaCatalogMapping.tables(from: result.rows, schema: owner)
}

func fetchTables(inSchemas schemas: [String]) async throws -> [PluginTableInfo]? {
guard !schemas.isEmpty else { return [] }
let result = try await execute(query: HanaCatalogQueries.tables(inSchemas: schemas))
return HanaCatalogMapping.tablesInSchemas(from: result.rows)
}

func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] {
try await catalogColumns(table: table, schema: effectiveSchema(schema)).map(\.pluginColumn)
}
Expand Down
26 changes: 19 additions & 7 deletions apple/Plugins/OracleDriverPlugin/OraclePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -487,14 +487,26 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable {

func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
let result = try await rawQuery(OracleSchemaQueries.tables(schema: effectiveSchema(schema)))
return result.rows.compactMap(OracleSchemaQueries.parseTableRow).map { row in
PluginTableInfo(
name: row.name,
type: row.isView ? "VIEW" : (row.isPartitioned ? "PARTITIONED TABLE" : "TABLE"),
comment: nil,
partitionCount: row.partitionCount
)
return result.rows.compactMap(OracleSchemaQueries.parseTableRow).map(Self.tableInfo)
}

func fetchTables(inSchemas schemas: [String]) async throws -> [PluginTableInfo]? {
var tables: [PluginTableInfo] = []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chunks run sequentially and the result is only ordered within each 1,000-owner chunk, not globally by owner/name as the query's ORDER BY implies.

for query in OracleSchemaQueries.tables(inSchemas: schemas) {
let result = try await rawQuery(query)
tables += result.rows.compactMap(OracleSchemaQueries.parseTableRow).map(Self.tableInfo)
}
return tables
}

private static func tableInfo(_ row: OracleTableRow) -> PluginTableInfo {
PluginTableInfo(
name: row.name,
type: row.isView ? "VIEW" : (row.isPartitioned ? "PARTITIONED TABLE" : "TABLE"),
schema: row.schema,
comment: nil,
partitionCount: row.partitionCount
)
}

func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] {
Expand Down
22 changes: 8 additions & 14 deletions apple/Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,20 +61,14 @@ final class CockroachPluginDriver: LibPQBackedDriver, @unchecked Sendable {
// MARK: - Schema

func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
let resolvedSchema = schema ?? core.currentSchema
let query = """
SELECT table_name, table_type
FROM information_schema.tables
WHERE table_schema = \(PostgreSQLObjectQueries.quoteLiteral(resolvedSchema))
ORDER BY table_name
"""
let result = try await execute(query: query)
return result.rows.compactMap { row -> PluginTableInfo? in
guard let name = row[0].asText else { return nil }
let typeStr = (row[1].asText ?? "BASE TABLE").uppercased()
let type = typeStr.contains("VIEW") ? "VIEW" : "TABLE"
return PluginTableInfo(name: name, type: type)
}
let result = try await execute(query: CockroachRelationSQL.tablesQuery(schema: schema ?? core.currentSchema))
return result.rows.compactMap { CockroachRelationSQL.table(fromRow: $0.map(\.asText)) }
}

func fetchTables(inSchemas schemas: [String]) async throws -> [PluginTableInfo]? {
guard !schemas.isEmpty else { return [] }
let result = try await execute(query: CockroachRelationSQL.tablesQuery(inSchemas: schemas))
return result.rows.compactMap { CockroachRelationSQL.table(fromRow: $0.map(\.asText)) }
}

func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] {
Expand Down
24 changes: 24 additions & 0 deletions apple/Plugins/PostgreSQLDriverPlugin/CockroachRelationSQL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,30 @@ import TableProPluginKit
/// `schema` is non-optional on purpose: the `?? currentSchema` fallback stays at the call site,
/// where the driver knows its own session, so no caller can reach these builders without one.
public enum CockroachRelationSQL {
public static func tablesQuery(schema: String) -> String {
"""
SELECT table_name, table_type
FROM information_schema.tables
WHERE table_schema = \(PostgreSQLObjectQueries.quoteLiteral(schema))
ORDER BY table_name
"""
}

public static func tablesQuery(inSchemas schemas: [String]) -> String {
"""
SELECT table_name, table_type, table_schema
FROM information_schema.tables
WHERE \(PostgreSQLObjectQueries.membership("table_schema", in: schemas))
ORDER BY table_schema, table_name
"""
}

public static func table(fromRow row: [String?]) -> PluginTableInfo? {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

table(fromRow:) is a near-copy of RedshiftTableCatalog.table(fromListingRow:) and has already drifted (Cockroach uppercases the type before contains("VIEW"), Redshift does not).

guard let name = row[safe: 0] ?? nil else { return nil }
let listedType = ((row[safe: 1] ?? nil) ?? "BASE TABLE").uppercased()
return PluginTableInfo(name: name, type: listedType.contains("VIEW") ? "VIEW" : "TABLE", schema: row[safe: 2] ?? nil)
}

public static func showCreateTable(table: String, schema: String) -> String {
"SHOW CREATE TABLE \(PostgreSQLObjectQueries.qualifiedName(schema: schema, name: table))"
}
Expand Down
Loading
Loading