From c229804c33e7c702b653244af86d6e50c65a5d0e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 13 Jun 2026 16:31:00 +0700 Subject: [PATCH 1/2] fix(plugin-postgresql): complete autocomplete for tables and columns in non-active schemas (#1668) --- CHANGELOG.md | 2 + .../LibPQDriverCore.swift | 4 - .../PostgreSQLPluginDriver+Columns.swift | 117 +++++------------- .../PostgreSQLPluginDriver.swift | 34 +++-- .../PostgreSQLSchemaQueries.swift | 57 +++++++++ .../RedshiftPluginDriver.swift | 89 ++++--------- .../RedshiftSchemaQueries.swift | 58 +++++++++ .../RedshiftSchemaQueries.swift | 1 + .../Plugins/PostgreSQLColumnQueryTests.swift | 111 +++++++++++++++++ 9 files changed, 302 insertions(+), 171 deletions(-) create mode 100644 Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift create mode 120000 TableProTests/PluginTestSources/RedshiftSchemaQueries.swift create mode 100644 TableProTests/Plugins/PostgreSQLColumnQueryTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bc5f8e5f..a924b0375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refresh (Cmd+R) now acts only on the focused window's connection, instead of also reloading views and clearing autocomplete caches for every other open connection. - Holding Cmd+R no longer queues a backlog of refreshes that kept running after the key was released; refresh fires once per key press, and rapid presses collapse into a single reload. - Switching PostgreSQL schemas now sets the search path to just the selected schema instead of also keeping "public" on it. Unqualified references to objects in "public", such as extension functions, need a "public." prefix while another schema is selected. (#1662) +- The inspector panel can now be resized freely by dragging its divider instead of being capped at a fixed width. ### Fixed +- PostgreSQL and Redshift autocomplete now completes tables and columns from schemas other than the one selected in the sidebar, so a schema-qualified query like `SELECT * FROM s2.orders` suggests `s2`'s columns. (#1668) - Favorite keywords now work again: favorites scoped to a deleted connection were silently kept in storage but hidden everywhere, so typing their keyword did nothing. Deleting a connection now also deletes its saved queries, their folders, and per-table filters, the delete confirmation says so, and favorites already orphaned by an earlier delete are cleaned up at launch. - Keyword autocomplete and SQL keyword suggestions now work in editors without a database connection, and favorites appear in the completion popup immediately instead of after a short delay. - Typing a favorite's keyword in the Quick Switcher now finds the saved query instead of ranking it below name matches. diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift index 66ce6d7b2..27feb02e0 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift @@ -230,8 +230,4 @@ extension LibPQBackedDriver { func escapeLiteral(_ str: String) -> String { escapeStringLiteral(str) } - - var escapedSchema: String { - escapeLiteral(core.currentSchema) - } } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift index b9edc97c5..562d8da9d 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift @@ -8,51 +8,17 @@ import TableProPluginKit extension PostgreSQLPluginDriver { func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { - let safeSchema = escapeStringLiteral(currentSchema ?? "public") + let safeSchema = escapeStringLiteral(schema ?? currentSchema ?? "public") let safeTable = escapeStringLiteral(table) let enumMap = try await fetchEnumLabelMap(schema: safeSchema) - let caps = versionedCapabilities - let identityProjection = caps.hasIdentityColumns ? "a.attidentity" : "NULL::text" - let generatedProjection = caps.hasGeneratedColumns ? "a.attgenerated" : "NULL::text" - let attributeJoin = (caps.hasIdentityColumns || caps.hasGeneratedColumns) ? """ - LEFT JOIN pg_catalog.pg_attribute a - ON a.attrelid = st.relid - AND a.attname = c.column_name - AND NOT a.attisdropped - """ : "" - let query = """ - SELECT - c.column_name, - c.data_type, - c.is_nullable, - c.column_default, - c.collation_name, - pgd.description, - c.udt_name, - CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk, - \(identityProjection), - \(generatedProjection) - FROM information_schema.columns c - LEFT JOIN pg_catalog.pg_statio_all_tables st - ON st.schemaname = c.table_schema - AND st.relname = c.table_name - LEFT JOIN pg_catalog.pg_description pgd - ON pgd.objoid = st.relid - AND pgd.objsubid = c.ordinal_position - \(attributeJoin) - LEFT JOIN ( - SELECT DISTINCT kcu.column_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - WHERE tc.constraint_type = 'PRIMARY KEY' - AND tc.table_schema = '\(safeSchema)' - AND tc.table_name = '\(safeTable)' - ) pk ON c.column_name = pk.column_name - WHERE c.table_schema = '\(safeSchema)' AND c.table_name = '\(safeTable)' - ORDER BY c.ordinal_position - """ + let projections = columnProjections() + let query = PostgreSQLSchemaQueries.columnsQuery( + schemaLiteral: safeSchema, + tableLiteral: safeTable, + identityProjection: projections.identity, + generatedProjection: projections.generated, + attributeJoin: projections.attributeJoin + ) let result = try await execute(query: query) return result.rows.compactMap { row in mapPgColumnRow(row, tableNameOffset: 0, enumLabelsByType: enumMap) @@ -60,50 +26,16 @@ extension PostgreSQLPluginDriver { } func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { - let safeSchema = escapeStringLiteral(currentSchema ?? "public") + let safeSchema = escapeStringLiteral(schema ?? currentSchema ?? "public") let enumMap = try await fetchEnumLabelMap(schema: safeSchema) - let caps = versionedCapabilities - let identityProjection = caps.hasIdentityColumns ? "a.attidentity" : "NULL::text" - let generatedProjection = caps.hasGeneratedColumns ? "a.attgenerated" : "NULL::text" - let attributeJoin = (caps.hasIdentityColumns || caps.hasGeneratedColumns) ? """ - LEFT JOIN pg_catalog.pg_attribute a - ON a.attrelid = st.relid - AND a.attname = c.column_name - AND NOT a.attisdropped - """ : "" - let query = """ - SELECT - c.table_name, - c.column_name, - c.data_type, - c.is_nullable, - c.column_default, - c.collation_name, - pgd.description, - c.udt_name, - CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk, - \(identityProjection), - \(generatedProjection) - FROM information_schema.columns c - LEFT JOIN pg_catalog.pg_statio_all_tables st - ON st.schemaname = c.table_schema - AND st.relname = c.table_name - LEFT JOIN pg_catalog.pg_description pgd - ON pgd.objoid = st.relid - AND pgd.objsubid = c.ordinal_position - \(attributeJoin) - LEFT JOIN ( - SELECT DISTINCT kcu.table_name, kcu.column_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - WHERE tc.constraint_type = 'PRIMARY KEY' - AND tc.table_schema = '\(safeSchema)' - ) pk ON c.table_name = pk.table_name AND c.column_name = pk.column_name - WHERE c.table_schema = '\(safeSchema)' - ORDER BY c.table_name, c.ordinal_position - """ + let projections = columnProjections() + let query = PostgreSQLSchemaQueries.columnsQuery( + schemaLiteral: safeSchema, + tableLiteral: nil, + identityProjection: projections.identity, + generatedProjection: projections.generated, + attributeJoin: projections.attributeJoin + ) let result = try await execute(query: query) var allColumns: [String: [PluginColumnInfo]] = [:] for row in result.rows { @@ -115,6 +47,19 @@ extension PostgreSQLPluginDriver { return allColumns } + private func columnProjections() -> (identity: String, generated: String, attributeJoin: String) { + let caps = versionedCapabilities + let identity = caps.hasIdentityColumns ? "a.attidentity" : "NULL::text" + let generated = caps.hasGeneratedColumns ? "a.attgenerated" : "NULL::text" + let attributeJoin = (caps.hasIdentityColumns || caps.hasGeneratedColumns) ? """ + LEFT JOIN pg_catalog.pg_attribute a + ON a.attrelid = st.relid + AND a.attname = c.column_name + AND NOT a.attisdropped + """ : "" + return (identity, generated, attributeJoin) + } + fileprivate func fetchEnumLabelMap(schema: String) async throws -> [String: [String]] { let query = """ SELECT t.typname, e.enumlabel diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index a4a98b917..3781aacf7 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -227,6 +227,7 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { + let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) let query = """ SELECT con.conname, @@ -261,7 +262,7 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { ON ref_col.attrelid = con.confrelid AND ref_col.attnum = cols.ref_attnum WHERE con.contype = 'f' AND src_cl.relname = '\(escapeLiteral(table))' - AND src_ns.nspname = '\(escapedSchema)' + AND src_ns.nspname = '\(schemaLiteral)' ORDER BY con.conname, cols.ord """ let result = try await execute(query: query) @@ -282,11 +283,12 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { onUpdate: row[6].asText ?? "NO ACTION" ) } - Self.logger.info("[fk] postgres fetchForeignKeys schema=\(self.core.currentSchema, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(foreignKeys.count)") + Self.logger.info("[fk] postgres fetchForeignKeys schema=\(schema ?? self.core.currentSchema, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(foreignKeys.count)") return foreignKeys } func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { + let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) let query = """ SELECT src_cl.relname AS table_name, @@ -321,7 +323,7 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { JOIN pg_catalog.pg_attribute ref_col ON ref_col.attrelid = con.confrelid AND ref_col.attnum = cols.ref_attnum WHERE con.contype = 'f' - AND src_ns.nspname = '\(escapedSchema)' + AND src_ns.nspname = '\(schemaLiteral)' ORDER BY src_cl.relname, con.conname, cols.ord """ let result = try await execute(query: query) @@ -364,6 +366,8 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchTableDDL(table: String, schema: String?) async throws -> String { let safeTable = escapeLiteral(table) + let resolvedSchema = schema ?? core.currentSchema + let schemaLiteral = escapeLiteral(resolvedSchema) let quotedTable = quoteIdentifier(table) let caps = versionedCapabilities @@ -410,7 +414,7 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum WHERE c.relname = '\(safeTable)' - AND n.nspname = '\(escapedSchema)' + AND n.nspname = '\(schemaLiteral)' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum @@ -423,7 +427,7 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { JOIN pg_class c ON c.oid = con.conrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relname = '\(safeTable)' - AND n.nspname = '\(escapedSchema)' + AND n.nspname = '\(schemaLiteral)' AND con.contype IN ('p', 'u', 'c') ORDER BY CASE con.contype WHEN 'p' THEN 0 WHEN 'u' THEN 1 WHEN 'c' THEN 2 END @@ -433,13 +437,13 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { SELECT indexdef FROM pg_indexes WHERE tablename = '\(safeTable)' - AND schemaname = '\(escapedSchema)' + AND schemaname = '\(schemaLiteral)' AND indexname NOT IN ( SELECT conname FROM pg_constraint JOIN pg_class ON pg_class.oid = conrelid JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace WHERE pg_class.relname = '\(safeTable)' - AND pg_namespace.nspname = '\(escapedSchema)' + AND pg_namespace.nspname = '\(schemaLiteral)' ) ORDER BY indexname """ @@ -459,7 +463,7 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { var parts = columnDefs parts.append(contentsOf: constraints) - let quotedSchema = quoteIdentifier(core.currentSchema) + let quotedSchema = quoteIdentifier(resolvedSchema) let ddl = "CREATE TABLE \(quotedSchema).\(quotedTable) (\n " + parts.joined(separator: ",\n ") + "\n);" @@ -470,11 +474,12 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchViewDefinition(view: String, schema: String?) async throws -> String { + let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) let query = """ SELECT 'CREATE OR REPLACE VIEW ' || quote_ident(schemaname) || '.' || quote_ident(viewname) || ' AS ' || E'\\n' || definition AS ddl FROM pg_views WHERE viewname = '\(escapeLiteral(view))' - AND schemaname = '\(escapedSchema)' + AND schemaname = '\(schemaLiteral)' """ let result = try await execute(query: query) guard let firstRow = result.rows.first, let ddl = firstRow[0].asText else { @@ -484,6 +489,7 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) let query = """ SELECT pg_total_relation_size(c.oid) AS total_size, @@ -494,7 +500,7 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relname = '\(escapeLiteral(table))' - AND n.nspname = '\(escapedSchema)' + AND n.nspname = '\(schemaLiteral)' """ let result = try await execute(query: query) guard let row = result.rows.first else { @@ -574,6 +580,7 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchDependentTypes(table: String, schema: String?) async throws -> [(name: String, labels: [String])] { let safeTable = escapeLiteral(table) + let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) let query = """ SELECT DISTINCT t.typname, array_agg(e.enumlabel ORDER BY e.enumsortorder) @@ -583,7 +590,7 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { JOIN pg_type t ON t.oid = a.atttypid JOIN pg_enum e ON e.enumtypid = t.oid WHERE c.relname = '\(safeTable)' - AND n.nspname = '\(escapedSchema)' + AND n.nspname = '\(schemaLiteral)' AND a.attnum > 0 AND NOT a.attisdropped GROUP BY t.typname @@ -602,6 +609,8 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchDependentSequences(table: String, schema: String?) async throws -> [(name: String, ddl: String)] { guard includesSequencesCatalog() else { return [] } let safeTable = escapeLiteral(table) + let schemaName = schema ?? core.currentSchema + let schemaLiteral = escapeLiteral(schemaName) let query = """ SELECT s.sequencename, s.start_value, @@ -616,11 +625,10 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { JOIN pg_sequences s ON s.schemaname = n.nspname AND pg_get_expr(ad.adbin, ad.adrelid) LIKE '%' || quote_ident(s.sequencename) || '%' WHERE c.relname = '\(safeTable)' - AND n.nspname = '\(escapedSchema)' + AND n.nspname = '\(schemaLiteral)' AND pg_get_expr(ad.adbin, ad.adrelid) LIKE '%nextval%' """ let result = try await execute(query: query) - let schemaName = schema ?? core.currentSchema return result.rows.compactMap { row -> (name: String, ddl: String)? in guard let seqName = row[0].asText else { return nil } let startVal = row[1].asText ?? "1" diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift index 108df6924..c262b5226 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift @@ -114,4 +114,61 @@ enum PostgreSQLSchemaQueries { let quotedIdentifier = "\"\(schema.replacingOccurrences(of: "\"", with: "\"\""))\"" return "SET search_path TO \(quotedIdentifier)" } + + /// Column introspection for one schema. Passing `tableLiteral` restricts the + /// result to a single table; passing `nil` returns every table's columns and + /// prefixes each row with `table_name`. `schemaLiteral` is the only schema + /// source, so the caller resolves the target schema (qualified reference, + /// then current schema) before escaping and passing it here. The identity, + /// generated, and attribute-join fragments come from the connected server's + /// versioned capabilities. + static func columnsQuery( + schemaLiteral: String, + tableLiteral: String?, + identityProjection: String, + generatedProjection: String, + attributeJoin: String + ) -> String { + let includesTableName = tableLiteral == nil + let selectPrefix = includesTableName ? "c.table_name,\n" : "" + let pkSelect = includesTableName ? "kcu.table_name, kcu.column_name" : "kcu.column_name" + let pkTableFilter = tableLiteral.map { "\n AND tc.table_name = '\($0)'" } ?? "" + let pkJoin = includesTableName + ? "c.table_name = pk.table_name AND c.column_name = pk.column_name" + : "c.column_name = pk.column_name" + let mainTableFilter = tableLiteral.map { " AND c.table_name = '\($0)'" } ?? "" + let orderBy = includesTableName ? "c.table_name, c.ordinal_position" : "c.ordinal_position" + return """ + SELECT + \(selectPrefix)c.column_name, + c.data_type, + c.is_nullable, + c.column_default, + c.collation_name, + pgd.description, + c.udt_name, + CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk, + \(identityProjection), + \(generatedProjection) + FROM information_schema.columns c + LEFT JOIN pg_catalog.pg_statio_all_tables st + ON st.schemaname = c.table_schema + AND st.relname = c.table_name + LEFT JOIN pg_catalog.pg_description pgd + ON pgd.objoid = st.relid + AND pgd.objsubid = c.ordinal_position + \(attributeJoin) + LEFT JOIN ( + SELECT DISTINCT \(pkSelect) + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = '\(schemaLiteral)'\(pkTableFilter) + ) pk ON \(pkJoin) + WHERE c.table_schema = '\(schemaLiteral)'\(mainTableFilter) + ORDER BY \(orderBy) + """ + } } diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift index 258132df6..95689a093 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift @@ -41,10 +41,11 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { // MARK: - Schema func fetchTables(schema: String?) async throws -> [PluginTableInfo] { + let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) let query = """ SELECT table_name, table_type FROM information_schema.tables - WHERE table_schema = '\(escapedSchema)' + WHERE table_schema = '\(schemaLiteral)' ORDER BY table_name """ let result = try await execute(query: query) @@ -57,37 +58,11 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { - let safeTable = escapeLiteral(table) - let query = """ - SELECT - c.column_name, - c.data_type, - c.is_nullable, - c.column_default, - c.collation_name, - pgd.description, - c.udt_name, - CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk - FROM information_schema.columns c - LEFT JOIN pg_catalog.pg_class cls - ON cls.relname = c.table_name - AND cls.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema) - LEFT JOIN pg_catalog.pg_description pgd - ON pgd.objoid = cls.oid - AND pgd.objsubid = c.ordinal_position - LEFT JOIN ( - SELECT DISTINCT kcu.column_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - WHERE tc.constraint_type = 'PRIMARY KEY' - AND tc.table_schema = '\(escapedSchema)' - AND tc.table_name = '\(safeTable)' - ) pk ON c.column_name = pk.column_name - WHERE c.table_schema = '\(escapedSchema)' AND c.table_name = '\(safeTable)' - ORDER BY c.ordinal_position - """ + let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) + let query = RedshiftSchemaQueries.columnsQuery( + schemaLiteral: schemaLiteral, + tableLiteral: escapeLiteral(table) + ) let result = try await execute(query: query) return result.rows.compactMap { row -> PluginColumnInfo? in guard row.count >= 4, @@ -131,36 +106,8 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { - let query = """ - SELECT - c.table_name, - c.column_name, - c.data_type, - c.is_nullable, - c.column_default, - c.collation_name, - pgd.description, - c.udt_name, - CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk - FROM information_schema.columns c - LEFT JOIN pg_catalog.pg_class cls - ON cls.relname = c.table_name - AND cls.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema) - LEFT JOIN pg_catalog.pg_description pgd - ON pgd.objoid = cls.oid - AND pgd.objsubid = c.ordinal_position - LEFT JOIN ( - SELECT DISTINCT kcu.table_name, kcu.column_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - WHERE tc.constraint_type = 'PRIMARY KEY' - AND tc.table_schema = '\(escapedSchema)' - ) pk ON c.table_name = pk.table_name AND c.column_name = pk.column_name - WHERE c.table_schema = '\(escapedSchema)' - ORDER BY c.table_name, c.ordinal_position - """ + let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) + let query = RedshiftSchemaQueries.columnsQuery(schemaLiteral: schemaLiteral, tableLiteral: nil) let result = try await execute(query: query) var allColumns: [String: [PluginColumnInfo]] = [:] for row in result.rows { @@ -209,6 +156,7 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { let safeTable = escapeLiteral(table) + let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) let query = """ SELECT "column", @@ -216,7 +164,7 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { distkey, sortkey FROM pg_table_def - WHERE schemaname = '\(escapedSchema)' + WHERE schemaname = '\(schemaLiteral)' AND tablename = '\(safeTable)' AND (distkey = true OR sortkey != 0) ORDER BY sortkey @@ -285,11 +233,12 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? { let safeTable = escapeLiteral(table) + let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) let query = """ SELECT tbl_rows FROM svv_table_info WHERE "table" = '\(safeTable)' - AND schema = '\(escapedSchema)' + AND schema = '\(schemaLiteral)' """ let result = try await execute(query: query) guard let firstRow = result.rows.first, let value = firstRow[0].asText, let count = Int(value) else { return nil } @@ -298,8 +247,10 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchTableDDL(table: String, schema: String?) async throws -> String { let safeTable = escapeLiteral(table) + let resolvedSchema = schema ?? core.currentSchema + let schemaLiteral = escapeLiteral(resolvedSchema) let quotedTable = quoteIdentifier(table) - let quotedSchema = quoteIdentifier(core.currentSchema) + let quotedSchema = quoteIdentifier(resolvedSchema) do { let showResult = try await execute(query: "SHOW TABLE \(quotedSchema).\(quotedTable)") @@ -320,7 +271,7 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum WHERE c.relname = '\(safeTable)' - AND n.nspname = '\(escapedSchema)' + AND n.nspname = '\(schemaLiteral)' AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum @@ -357,11 +308,12 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchViewDefinition(view: String, schema: String?) async throws -> String { let safeView = escapeLiteral(view) + let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) let query = """ SELECT 'CREATE OR REPLACE VIEW ' || quote_ident(schemaname) || '.' || quote_ident(viewname) || ' AS ' || E'\\n' || definition AS ddl FROM pg_views WHERE viewname = '\(safeView)' - AND schemaname = '\(escapedSchema)' + AND schemaname = '\(schemaLiteral)' """ let result = try await execute(query: query) guard let firstRow = result.rows.first, let ddl = firstRow[0].asText else { @@ -372,6 +324,7 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { let safeTable = escapeLiteral(table) + let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) let query = """ SELECT tbl_rows, @@ -381,7 +334,7 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { stats_off FROM svv_table_info WHERE "table" = '\(safeTable)' - AND schema = '\(escapedSchema)' + AND schema = '\(schemaLiteral)' """ let result = try await execute(query: query) guard let row = result.rows.first else { diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift new file mode 100644 index 000000000..98e97adba --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift @@ -0,0 +1,58 @@ +// +// RedshiftSchemaQueries.swift +// PostgreSQLDriverPlugin +// +// Static SQL for Redshift column introspection. Extracted so the queries can +// be exercised by unit tests via TableProTests/PluginTestSources without the +// libpq C bridge. +// + +import Foundation + +enum RedshiftSchemaQueries { + /// Column introspection for one schema. Passing `tableLiteral` restricts the + /// result to a single table; passing `nil` returns every table's columns and + /// prefixes each row with `table_name`. `schemaLiteral` is the only schema + /// source, so the caller resolves the target schema (qualified reference, + /// then current schema) before escaping and passing it here. + static func columnsQuery(schemaLiteral: String, tableLiteral: String?) -> String { + let includesTableName = tableLiteral == nil + let selectPrefix = includesTableName ? "c.table_name,\n" : "" + let pkSelect = includesTableName ? "kcu.table_name, kcu.column_name" : "kcu.column_name" + let pkTableFilter = tableLiteral.map { "\n AND tc.table_name = '\($0)'" } ?? "" + let pkJoin = includesTableName + ? "c.table_name = pk.table_name AND c.column_name = pk.column_name" + : "c.column_name = pk.column_name" + let mainTableFilter = tableLiteral.map { " AND c.table_name = '\($0)'" } ?? "" + let orderBy = includesTableName ? "c.table_name, c.ordinal_position" : "c.ordinal_position" + return """ + SELECT + \(selectPrefix)c.column_name, + c.data_type, + c.is_nullable, + c.column_default, + c.collation_name, + pgd.description, + c.udt_name, + CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk + FROM information_schema.columns c + LEFT JOIN pg_catalog.pg_class cls + ON cls.relname = c.table_name + AND cls.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema) + LEFT JOIN pg_catalog.pg_description pgd + ON pgd.objoid = cls.oid + AND pgd.objsubid = c.ordinal_position + LEFT JOIN ( + SELECT DISTINCT \(pkSelect) + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = '\(schemaLiteral)'\(pkTableFilter) + ) pk ON \(pkJoin) + WHERE c.table_schema = '\(schemaLiteral)'\(mainTableFilter) + ORDER BY \(orderBy) + """ + } +} diff --git a/TableProTests/PluginTestSources/RedshiftSchemaQueries.swift b/TableProTests/PluginTestSources/RedshiftSchemaQueries.swift new file mode 120000 index 000000000..76b637ce0 --- /dev/null +++ b/TableProTests/PluginTestSources/RedshiftSchemaQueries.swift @@ -0,0 +1 @@ +../../Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift \ No newline at end of file diff --git a/TableProTests/Plugins/PostgreSQLColumnQueryTests.swift b/TableProTests/Plugins/PostgreSQLColumnQueryTests.swift new file mode 100644 index 000000000..0e234c3bc --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLColumnQueryTests.swift @@ -0,0 +1,111 @@ +// +// PostgreSQLColumnQueryTests.swift +// TableProTests +// +// Tests for the PostgreSQL and Redshift column introspection query builders +// (compiled via symlink from PostgreSQLDriverPlugin). Regression cover for +// autocomplete that ignored the requested schema and always queried the +// active schema, so columns of a schema-qualified table like `s2.orders` +// never resolved. +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("PostgreSQLSchemaQueries.columnsQuery") +struct PostgreSQLColumnsQueryTests { + private func singleTable(schema: String, table: String) -> String { + PostgreSQLSchemaQueries.columnsQuery( + schemaLiteral: schema, + tableLiteral: table, + identityProjection: "a.attidentity", + generatedProjection: "a.attgenerated", + attributeJoin: "LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = st.relid" + ) + } + + private func allTables(schema: String) -> String { + PostgreSQLSchemaQueries.columnsQuery( + schemaLiteral: schema, + tableLiteral: nil, + identityProjection: "NULL::text", + generatedProjection: "NULL::text", + attributeJoin: "" + ) + } + + @Test("single-table query filters on the requested schema and table") + func singleTableFiltersOnRequestedSchema() { + let query = singleTable(schema: "s2", table: "orders") + #expect(query.contains("WHERE c.table_schema = 's2' AND c.table_name = 'orders'")) + #expect(query.contains("AND tc.table_schema = 's2'")) + #expect(query.contains("AND tc.table_name = 'orders'")) + } + + @Test("a non-active schema is not ignored", arguments: ["s2", "analytics", "public"]) + func nonActiveSchemaThreadsThrough(schema: String) { + let query = singleTable(schema: schema, table: "orders") + #expect(query.contains("c.table_schema = '\(schema)'")) + } + + @Test("queries for different schemas differ in the schema literal") + func differentSchemasProduceDifferentQueries() { + #expect(singleTable(schema: "s1", table: "t") != singleTable(schema: "s2", table: "t")) + } + + @Test("single-table query omits the table_name column and orders by ordinal") + func singleTableOmitsTableNameColumn() { + let query = singleTable(schema: "s2", table: "orders") + #expect(!query.contains("c.table_name,")) + #expect(query.contains("ORDER BY c.ordinal_position")) + #expect(query.contains("pk ON c.column_name = pk.column_name")) + } + + @Test("all-tables query selects table_name, drops the table filter, and orders by table") + func allTablesProjectsTableName() { + let query = allTables(schema: "s2") + #expect(query.contains("c.table_name,")) + #expect(query.contains("WHERE c.table_schema = 's2'")) + #expect(!query.contains("c.table_name = '")) + #expect(query.contains("ORDER BY c.table_name, c.ordinal_position")) + #expect(query.contains("pk ON c.table_name = pk.table_name AND c.column_name = pk.column_name")) + } + + @Test("version-dependent projections are interpolated verbatim") + func projectionsInterpolated() { + let query = singleTable(schema: "s2", table: "orders") + #expect(query.contains("a.attidentity")) + #expect(query.contains("a.attgenerated")) + #expect(query.contains("LEFT JOIN pg_catalog.pg_attribute a ON a.attrelid = st.relid")) + } +} + +@Suite("RedshiftSchemaQueries.columnsQuery") +struct RedshiftColumnsQueryTests { + @Test("single-table query filters on the requested schema and table") + func singleTableFiltersOnRequestedSchema() { + let query = RedshiftSchemaQueries.columnsQuery(schemaLiteral: "s2", tableLiteral: "orders") + #expect(query.contains("WHERE c.table_schema = 's2' AND c.table_name = 'orders'")) + #expect(query.contains("AND tc.table_schema = 's2'")) + #expect(query.contains("AND tc.table_name = 'orders'")) + #expect(!query.contains("c.table_name,")) + #expect(query.contains("ORDER BY c.ordinal_position")) + } + + @Test("a non-active schema is not ignored", arguments: ["s2", "analytics", "public"]) + func nonActiveSchemaThreadsThrough(schema: String) { + let query = RedshiftSchemaQueries.columnsQuery(schemaLiteral: schema, tableLiteral: "orders") + #expect(query.contains("c.table_schema = '\(schema)'")) + } + + @Test("all-tables query selects table_name, drops the table filter, and orders by table") + func allTablesProjectsTableName() { + let query = RedshiftSchemaQueries.columnsQuery(schemaLiteral: "s2", tableLiteral: nil) + #expect(query.contains("c.table_name,")) + #expect(query.contains("WHERE c.table_schema = 's2'")) + #expect(!query.contains("c.table_name = '")) + #expect(query.contains("ORDER BY c.table_name, c.ordinal_position")) + #expect(query.contains("pk ON c.table_name = pk.table_name AND c.column_name = pk.column_name")) + } +} From fbe989315c2e8f9937cb809bddbdd1b3d866c2c1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 13 Jun 2026 19:05:54 +0700 Subject: [PATCH 2/2] refactor(plugin-postgresql): share column-query shape between PostgreSQL and Redshift builders (#1668) --- .../ColumnQueryShape.swift | 39 +++++++++++++++++++ .../PostgreSQLPluginDriver+Columns.swift | 4 +- .../PostgreSQLSchemaQueries.swift | 22 ++++------- .../RedshiftSchemaQueries.swift | 22 ++++------- .../PluginTestSources/ColumnQueryShape.swift | 1 + 5 files changed, 56 insertions(+), 32 deletions(-) create mode 100644 Plugins/PostgreSQLDriverPlugin/ColumnQueryShape.swift create mode 120000 TableProTests/PluginTestSources/ColumnQueryShape.swift diff --git a/Plugins/PostgreSQLDriverPlugin/ColumnQueryShape.swift b/Plugins/PostgreSQLDriverPlugin/ColumnQueryShape.swift new file mode 100644 index 000000000..b0d7f4349 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/ColumnQueryShape.swift @@ -0,0 +1,39 @@ +// +// ColumnQueryShape.swift +// PostgreSQLDriverPlugin +// +// Shared single-table vs all-tables fragments for the PostgreSQL-family column +// introspection queries. Extracted so the PostgreSQL and Redshift builders +// describe only what differs (joins, projections) instead of repeating the +// shape logic. Compiled into the test target via TableProTests/PluginTestSources. +// + +import Foundation + +enum ColumnQueryShape { + struct Fragments { + let selectPrefix: String + let pkSelect: String + let pkTableFilter: String + let pkJoin: String + let mainTableFilter: String + let orderBy: String + } + + /// Fragments for a column query scoped to one schema. Passing `tableLiteral` + /// restricts the query to a single table; passing `nil` returns every table's + /// columns, prefixes each row with `table_name`, and orders by table. + static func fragments(tableLiteral: String?) -> Fragments { + let includesTableName = tableLiteral == nil + return Fragments( + selectPrefix: includesTableName ? "c.table_name,\n" : "", + pkSelect: includesTableName ? "kcu.table_name, kcu.column_name" : "kcu.column_name", + pkTableFilter: tableLiteral.map { "\n AND tc.table_name = '\($0)'" } ?? "", + pkJoin: includesTableName + ? "c.table_name = pk.table_name AND c.column_name = pk.column_name" + : "c.column_name = pk.column_name", + mainTableFilter: tableLiteral.map { " AND c.table_name = '\($0)'" } ?? "", + orderBy: includesTableName ? "c.table_name, c.ordinal_position" : "c.ordinal_position" + ) + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift index 562d8da9d..8342a92a2 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift @@ -8,7 +8,7 @@ import TableProPluginKit extension PostgreSQLPluginDriver { func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { - let safeSchema = escapeStringLiteral(schema ?? currentSchema ?? "public") + let safeSchema = escapeStringLiteral(schema ?? core.currentSchema) let safeTable = escapeStringLiteral(table) let enumMap = try await fetchEnumLabelMap(schema: safeSchema) let projections = columnProjections() @@ -26,7 +26,7 @@ extension PostgreSQLPluginDriver { } func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { - let safeSchema = escapeStringLiteral(schema ?? currentSchema ?? "public") + let safeSchema = escapeStringLiteral(schema ?? core.currentSchema) let enumMap = try await fetchEnumLabelMap(schema: safeSchema) let projections = columnProjections() let query = PostgreSQLSchemaQueries.columnsQuery( diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift index c262b5226..5e1be9490 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift @@ -129,18 +129,10 @@ enum PostgreSQLSchemaQueries { generatedProjection: String, attributeJoin: String ) -> String { - let includesTableName = tableLiteral == nil - let selectPrefix = includesTableName ? "c.table_name,\n" : "" - let pkSelect = includesTableName ? "kcu.table_name, kcu.column_name" : "kcu.column_name" - let pkTableFilter = tableLiteral.map { "\n AND tc.table_name = '\($0)'" } ?? "" - let pkJoin = includesTableName - ? "c.table_name = pk.table_name AND c.column_name = pk.column_name" - : "c.column_name = pk.column_name" - let mainTableFilter = tableLiteral.map { " AND c.table_name = '\($0)'" } ?? "" - let orderBy = includesTableName ? "c.table_name, c.ordinal_position" : "c.ordinal_position" + let shape = ColumnQueryShape.fragments(tableLiteral: tableLiteral) return """ SELECT - \(selectPrefix)c.column_name, + \(shape.selectPrefix)c.column_name, c.data_type, c.is_nullable, c.column_default, @@ -159,16 +151,16 @@ enum PostgreSQLSchemaQueries { AND pgd.objsubid = c.ordinal_position \(attributeJoin) LEFT JOIN ( - SELECT DISTINCT \(pkSelect) + SELECT DISTINCT \(shape.pkSelect) FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema WHERE tc.constraint_type = 'PRIMARY KEY' - AND tc.table_schema = '\(schemaLiteral)'\(pkTableFilter) - ) pk ON \(pkJoin) - WHERE c.table_schema = '\(schemaLiteral)'\(mainTableFilter) - ORDER BY \(orderBy) + AND tc.table_schema = '\(schemaLiteral)'\(shape.pkTableFilter) + ) pk ON \(shape.pkJoin) + WHERE c.table_schema = '\(schemaLiteral)'\(shape.mainTableFilter) + ORDER BY \(shape.orderBy) """ } } diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift index 98e97adba..29d6cf52f 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift @@ -16,18 +16,10 @@ enum RedshiftSchemaQueries { /// source, so the caller resolves the target schema (qualified reference, /// then current schema) before escaping and passing it here. static func columnsQuery(schemaLiteral: String, tableLiteral: String?) -> String { - let includesTableName = tableLiteral == nil - let selectPrefix = includesTableName ? "c.table_name,\n" : "" - let pkSelect = includesTableName ? "kcu.table_name, kcu.column_name" : "kcu.column_name" - let pkTableFilter = tableLiteral.map { "\n AND tc.table_name = '\($0)'" } ?? "" - let pkJoin = includesTableName - ? "c.table_name = pk.table_name AND c.column_name = pk.column_name" - : "c.column_name = pk.column_name" - let mainTableFilter = tableLiteral.map { " AND c.table_name = '\($0)'" } ?? "" - let orderBy = includesTableName ? "c.table_name, c.ordinal_position" : "c.ordinal_position" + let shape = ColumnQueryShape.fragments(tableLiteral: tableLiteral) return """ SELECT - \(selectPrefix)c.column_name, + \(shape.selectPrefix)c.column_name, c.data_type, c.is_nullable, c.column_default, @@ -43,16 +35,16 @@ enum RedshiftSchemaQueries { ON pgd.objoid = cls.oid AND pgd.objsubid = c.ordinal_position LEFT JOIN ( - SELECT DISTINCT \(pkSelect) + SELECT DISTINCT \(shape.pkSelect) FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema WHERE tc.constraint_type = 'PRIMARY KEY' - AND tc.table_schema = '\(schemaLiteral)'\(pkTableFilter) - ) pk ON \(pkJoin) - WHERE c.table_schema = '\(schemaLiteral)'\(mainTableFilter) - ORDER BY \(orderBy) + AND tc.table_schema = '\(schemaLiteral)'\(shape.pkTableFilter) + ) pk ON \(shape.pkJoin) + WHERE c.table_schema = '\(schemaLiteral)'\(shape.mainTableFilter) + ORDER BY \(shape.orderBy) """ } } diff --git a/TableProTests/PluginTestSources/ColumnQueryShape.swift b/TableProTests/PluginTestSources/ColumnQueryShape.swift new file mode 120000 index 000000000..6cab54ee5 --- /dev/null +++ b/TableProTests/PluginTestSources/ColumnQueryShape.swift @@ -0,0 +1 @@ +../../Plugins/PostgreSQLDriverPlugin/ColumnQueryShape.swift \ No newline at end of file