diff --git a/CHANGELOG.md b/CHANGELOG.md index b117e79893..0255c84ec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -366,6 +366,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - MySQL and MariaDB column defaults on iPhone and iPad missing for DEFAULT NULL, and string defaults shown unquoted. - Structure and Create Table SQL Preview disagreeing with Save on the schema, primary key name or a SQLite foreign key. - Row import creating its new table in another schema than its rows, and PGlite primary key changes failing to save. +- PostgreSQL materialized views missing on iPhone and iPad, and wrong index columns, types and predicates in Structure. +- Truncate and Drop Table offered on PostgreSQL foreign tables on iPhone and iPad. ### Security diff --git a/Packages/TableProCore/Sources/TableProModels/QueryResult.swift b/Packages/TableProCore/Sources/TableProModels/QueryResult.swift index 1e5cefa040..afb32d4cda 100644 --- a/Packages/TableProCore/Sources/TableProModels/QueryResult.swift +++ b/Packages/TableProCore/Sources/TableProModels/QueryResult.swift @@ -78,6 +78,7 @@ public struct TableInfo: Hashable, Sendable, Identifiable { case table case view case materializedView + case foreignTable case systemTable case externalTable case sequence @@ -92,7 +93,7 @@ public struct TableInfo: Hashable, Sendable, Identifiable { /// which is what happened to a MariaDB sequence. public var listSection: ListSection { switch self { - case .table, .systemTable, .externalTable, .sequence: return .tables + case .table, .foreignTable, .systemTable, .externalTable, .sequence: return .tables case .view, .materializedView: return .views } } @@ -102,7 +103,7 @@ public struct TableInfo: Hashable, Sendable, Identifiable { public var allowsTruncate: Bool { switch self { case .table: return true - case .view, .materializedView, .systemTable, .externalTable, .sequence: return false + case .view, .materializedView, .foreignTable, .systemTable, .externalTable, .sequence: return false } } @@ -115,7 +116,7 @@ public struct TableInfo: Hashable, Sendable, Identifiable { public var allowsDrop: Bool { switch self { case .table, .sequence: return true - case .view, .materializedView, .systemTable, .externalTable: return false + case .view, .materializedView, .foreignTable, .systemTable, .externalTable: return false } } @@ -127,7 +128,7 @@ public struct TableInfo: Hashable, Sendable, Identifiable { /// why the Mac app withholds row editing for one and this must too. public var allowsRowEditing: Bool { switch self { - case .table, .systemTable: return true + case .table, .foreignTable, .systemTable: return true case .view, .materializedView, .externalTable, .sequence: return false } } @@ -154,19 +155,25 @@ public struct IndexInfo: Sendable { public let isUnique: Bool public let isPrimary: Bool public let type: String + public let includedColumns: [String] + public let whereClause: String? public init( name: String, columns: [String], isUnique: Bool = false, isPrimary: Bool = false, - type: String = "BTREE" + type: String = "BTREE", + includedColumns: [String] = [], + whereClause: String? = nil ) { self.name = name self.columns = columns self.isUnique = isUnique self.isPrimary = isPrimary self.type = type + self.includedColumns = includedColumns + self.whereClause = whereClause } } @@ -260,6 +267,8 @@ public extension TableInfo { kind = .view case "MATERIALIZED VIEW": kind = .materializedView + case "FOREIGN TABLE", "FOREIGN": + kind = .foreignTable case "SYSTEM TABLE": kind = .systemTable case "EXTERNAL TABLE": @@ -300,7 +309,9 @@ public extension IndexInfo { columns: plugin.columns, isUnique: plugin.isUnique, isPrimary: plugin.isPrimary, - type: plugin.type + type: plugin.type, + includedColumns: plugin.includedColumns ?? [], + whereClause: plugin.whereClause ) } } diff --git a/Packages/TableProCore/Tests/TableProModelsTests/QueryResultMappingTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/QueryResultMappingTests.swift index 4eba28a603..9e97dcd6e7 100644 --- a/Packages/TableProCore/Tests/TableProModelsTests/QueryResultMappingTests.swift +++ b/Packages/TableProCore/Tests/TableProModelsTests/QueryResultMappingTests.swift @@ -72,6 +72,19 @@ struct QueryResultMappingTests { #expect(!external.type.allowsRowEditing) } + @Test("A foreign table keeps its kind, in either spelling, and offers no Truncate or Drop Table") + func mapPluginForeignTable() { + let listed = TableInfo(from: PluginTableInfo(name: "ft", type: "FOREIGN TABLE")) + let informationSchema = TableInfo(from: PluginTableInfo(name: "ft", type: "FOREIGN")) + + #expect(listed.type == .foreignTable) + #expect(informationSchema.type == .foreignTable) + #expect(!listed.type.allowsTruncate) + #expect(!listed.type.allowsDrop) + #expect(listed.type.allowsRowEditing) + #expect(listed.type.listSection == .tables) + } + @Test("Maps PluginColumnInfo to ColumnInfo") func mapPluginColumnInfo() { let plugin = PluginColumnInfo( @@ -104,6 +117,27 @@ struct QueryResultMappingTests { #expect(index.columns == ["email"]) #expect(index.isUnique) #expect(!index.isPrimary) + #expect(index.includedColumns.isEmpty) + #expect(index.whereClause == nil) + } + + @Test("An index keeps its INCLUDE columns and its predicate apart from its key") + func mapPluginIndexInfoIncludeAndPredicate() { + let plugin = PluginIndexInfo( + name: "t_include_partial", + columns: ["a", "lower(email)"], + isUnique: true, + type: "BTREE", + whereClause: "(a > 0)", + expressions: ["lower(email)"], + includedColumns: ["b"], + ddlMethodAndKeys: nil, + ddlWhereClause: nil + ) + let index = IndexInfo(from: plugin) + #expect(index.columns == ["a", "lower(email)"]) + #expect(index.includedColumns == ["b"]) + #expect(index.whereClause == "(a > 0)") } @Test("Maps PluginForeignKeyInfo to ForeignKeyInfo") diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogBoolean.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogBoolean.swift index 4e1ed30ef8..91e1fe035a 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogBoolean.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogBoolean.swift @@ -5,7 +5,7 @@ import Foundation -enum PostgreSQLCatalogBoolean { +nonisolated enum PostgreSQLCatalogBoolean { private static let trueSpellings: Set = ["t", "true", "yes", "on", "1"] static func isTrue(_ text: String?) -> Bool { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift index 5af177f302..5112fac1a2 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift @@ -5,7 +5,7 @@ import Foundation -struct PostgreSQLCatalogPresence: Sendable, Equatable { +nonisolated struct PostgreSQLCatalogPresence: Sendable, Equatable { let hasMaterializedViews: Bool let hasForeignTables: Bool let hasSequences: Bool diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift index 2a42c6d12e..65a4c73624 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift @@ -7,7 +7,7 @@ import Foundation import os import TableProPluginKit -enum PostgreSQLIndexQueries { +nonisolated enum PostgreSQLIndexQueries { private static let logger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category: "IndexQueries") /// One row per index, with its key parts in key order. @@ -152,12 +152,12 @@ enum PostgreSQLIndexQueries { } } -struct PostgreSQLCatalogIndexDDL: Equatable { +nonisolated struct PostgreSQLCatalogIndexDDL: Equatable { let methodAndKeys: String? let whereClause: String? } -enum PostgreSQLIndexRow { +nonisolated enum PostgreSQLIndexRow { static func index( from row: [PluginCellValue], ddl: [String: [String: PostgreSQLCatalogIndexDDL]] diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLMaterializedViewColumnSource.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLMaterializedViewColumnSource.swift new file mode 100644 index 0000000000..11cd6df2e5 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLMaterializedViewColumnSource.swift @@ -0,0 +1,51 @@ +import Foundation + +nonisolated enum PostgreSQLMaterializedViewColumnSource { + static let columnName = "mva.attname" + + static let ordinalPosition = "mva.attnum" + + static let dataType = """ + CASE WHEN mvt.typtype = 'd' + THEN CASE WHEN mvbt.typelem <> 0 AND mvbt.typlen = -1 THEN 'ARRAY' + WHEN mvbtn.nspname = 'pg_catalog' THEN pg_catalog.format_type(mvt.typbasetype, NULL) + ELSE 'USER-DEFINED' END + ELSE CASE WHEN mvt.typelem <> 0 AND mvt.typlen = -1 THEN 'ARRAY' + WHEN mvtn.nspname = 'pg_catalog' THEN pg_catalog.format_type(mva.atttypid, NULL) + ELSE 'USER-DEFINED' END + END + """ + + static let isNullable = "CASE WHEN mva.attnotnull OR (mvt.typtype = 'd' AND mvt.typnotnull) THEN 'NO' ELSE 'YES' END" + + static let characterMaximumLength = """ + information_schema._pg_char_max_length(\ + information_schema._pg_truetypid(mva.*, mvt.*), \ + information_schema._pg_truetypmod(mva.*, mvt.*)) + """ + + static func relation(schemaLiteral: String, table: String?) -> String { + let tableFilter = table.map { "\n AND mvc.relname = \(PostgreSQLObjectQueries.quoteLiteral($0))" } ?? "" + return """ + FROM pg_catalog.pg_class mvc + JOIN pg_catalog.pg_namespace mvn ON mvn.oid = mvc.relnamespace + JOIN pg_catalog.pg_attribute mva + ON mva.attrelid = mvc.oid + AND mva.attnum > 0 + AND NOT mva.attisdropped + JOIN pg_catalog.pg_type mvt ON mvt.oid = mva.atttypid + JOIN pg_catalog.pg_namespace mvtn ON mvtn.oid = mvt.typnamespace + LEFT JOIN pg_catalog.pg_type mvbt + ON mvt.typtype = 'd' + AND mvbt.oid = mvt.typbasetype + LEFT JOIN pg_catalog.pg_namespace mvbtn ON mvbtn.oid = mvbt.typnamespace + LEFT JOIN pg_catalog.pg_collation mvco ON mvco.oid = mva.attcollation + LEFT JOIN pg_catalog.pg_namespace mvcon ON mvcon.oid = mvco.collnamespace + WHERE mvc.relkind = 'm' + AND mvn.nspname = \(schemaLiteral)\(tableFilter) + AND NOT pg_catalog.pg_is_other_temp_schema(mvn.oid) + AND (pg_catalog.pg_has_role(mvc.relowner, 'USAGE') + OR pg_catalog.has_column_privilege(mvc.oid, mva.attnum, 'SELECT, INSERT, UPDATE, REFERENCES')) + """ + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 50571046ce..b8ebf698af 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -176,7 +176,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { private func listTables(in listing: PostgreSQLTableListingScope) async throws -> [PluginTableInfo] { func query(_ attempt: PostgreSQLTableListingAttempt) -> String { - PostgreSQLSchemaQueries.fetchTables( + PostgreSQLTableListing.query( in: listing, includeMaterializedViews: attempt.includeOptionalCatalogs && includesMaterializedViews(), includeForeignTables: attempt.includeOptionalCatalogs && includesForeignTables(), @@ -198,27 +198,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { } guard let result else { return [] } - return result.rows.compactMap { row -> PluginTableInfo? in - guard let name = row[0].asText else { return nil } - let typeStr = row[1].asText ?? "BASE TABLE" - let type: String - switch typeStr { - case "PARTITIONED TABLE": type = "PARTITIONED TABLE" - case "MATERIALIZED VIEW": type = "MATERIALIZED VIEW" - case "FOREIGN TABLE": type = "FOREIGN TABLE" - case "VIEW": type = "VIEW" - default: type = "TABLE" - } - let comment = row[safe: 2]?.asText?.nilIfEmpty - let partitionCount = row[safe: 3]?.asText.flatMap(Int.init) - return PluginTableInfo( - name: name, - type: type, - schema: row[safe: 4]?.asText, - comment: comment, - partitionCount: partitionCount - ) - } + return result.rows.compactMap { PostgreSQLTableListing.table(fromRow: $0.map(\.asText)) } } func fetchPartitions(table: String, schema: String?) async throws -> [PluginTableInfo] { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift index 4cb314d931..32ccb2453d 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift @@ -15,11 +15,6 @@ enum PostgreSQLSchemaProbe: Equatable { case failed } -enum PostgreSQLTableListingScope: Sendable, Equatable { - case schema(String) - case allSchemas -} - enum PostgreSQLSchemaQueries { /// Returns the first schema on the effective search path, or SQL NULL /// when the path is empty (neither `$user` nor `public` exists). @@ -55,12 +50,7 @@ enum PostgreSQLSchemaQueries { /// literally; without an `ESCAPE` clause, `_` would be SQL LIKE's /// single-char wildcard and `'pg_%'` would also exclude legitimate user /// schemas such as `pgboss`, `pgcrypto`, or `pgvector`. - static let listSchemas = """ - SELECT schema_name FROM information_schema.schemata - WHERE schema_name NOT LIKE 'pg!_%' ESCAPE '!' - AND schema_name <> 'information_schema' - ORDER BY schema_name - """ + static let listSchemas = PostgreSQLTableListing.visibleSchemas /// Redshift variant: queries `pg_namespace` directly and additionally /// requires the connected role to hold `USAGE` on the schema. @@ -72,175 +62,6 @@ enum PostgreSQLSchemaQueries { ORDER BY nspname """ - /// Lists tables and views, optionally including materialized views and - /// foreign tables. The optional unions reference `pg_matviews` and - /// `pg_foreign_table`, which some PostgreSQL-compatible engines do not - /// implement; the caller passes `false` when those catalogs are absent so - /// the whole query does not fail with `relation does not exist`. - /// - /// `includeComments` projects each table's comment via `obj_description` - /// over the relation's oid. Engines that lack that function fail the whole - /// listing, so the caller passes `false` to fall back to a comment-free - /// listing. - /// - /// `includePartitionAwareness` labels a declarative partition parent as - /// `PARTITIONED TABLE`, counts its partitions, and drops its partition - /// children, which `information_schema.tables` reports as plain - /// `BASE TABLE` rows indistinguishable from the parent. The test is - /// `pg_inherits` joined to the parent's `relkind`, not - /// `pg_class.relispartition`: `relispartition` only exists from PostgreSQL - /// 10, and referencing a missing column fails at parse time, which would - /// break the listing outright on older servers. Comparing `relkind` against - /// `'p'`/`'I'` is a value test on a column present since PostgreSQL 8, so it - /// parses everywhere and simply matches nothing before declarative - /// partitioning existed. Rows still come from `information_schema.tables`, - /// which keeps its privilege filtering; the catalog joins only label, count - /// and exclude rows it already returned. The caller passes `false` for - /// engines without these catalogs. - /// - /// A child is dropped only when its parent is itself listed. Keying the - /// exclusion on the child alone hid a partition whose parent the role cannot - /// read: granting `SELECT` on one partition and nothing on its parent left - /// the whole schema listing empty while that partition was perfectly - /// readable. The visibility test reuses `information_schema.tables` rather - /// than restating its privilege predicate, so the two cannot drift. - /// - /// Legacy `INHERITS` children stay listed on purpose. Their parent is an - /// ordinary table (`relkind = 'r'`), and they are independently useful - /// tables rather than an implementation detail of one parent. - static func fetchTables( - schema: String, - includeMaterializedViews: Bool, - includeForeignTables: Bool, - includeComments: Bool = true, - includePartitionAwareness: Bool = true - ) -> String { - fetchTables( - in: .schema(schema), - includeMaterializedViews: includeMaterializedViews, - includeForeignTables: includeForeignTables, - includeComments: includeComments, - includePartitionAwareness: includePartitionAwareness - ) - } - - /// The same listing over one schema or over every schema `listSchemas` returns. The second - /// filters by that query itself rather than restating its predicate, so a table is listed here - /// exactly when its schema is listed there, and projects each row's schema, which the - /// one-schema listing leaves to the caller. - static func fetchTables( - in listing: PostgreSQLTableListingScope, - includeMaterializedViews: Bool, - includeForeignTables: Bool, - includeComments: Bool = true, - includePartitionAwareness: Bool = true - ) -> String { - func schemaFilter(_ column: String) -> String { - switch listing { - case .schema(let schema): - return "\(column) = \(PostgreSQLObjectQueries.quoteLiteral(schema))" - case .allSchemas: - return "\(column) IN (\n\(listSchemas)\n)" - } - } - func schemaColumn(_ column: String) -> String { - listing == .allSchemas ? ",\n \(column) AS schema_name" : "" - } - let orderBy = listing == .allSchemas ? "ORDER BY schema_name, table_name" : "ORDER BY table_name" - func commentColumn(_ oidExpression: String) -> String { - includeComments ? "obj_description(\(oidExpression), 'pg_class')" : "NULL::text" - } - - let classJoin = (includeComments || includePartitionAwareness) ? """ - - LEFT JOIN pg_catalog.pg_namespace pn ON pn.nspname = t.table_schema - LEFT JOIN pg_catalog.pg_class pc ON pc.relnamespace = pn.oid AND pc.relname = t.table_name - """ : "" - - let tableTypeColumn = includePartitionAwareness - ? "CASE WHEN pc.relkind = 'p' THEN 'PARTITIONED TABLE' ELSE t.table_type END" - : "t.table_type" - - let partitionCountColumn = includePartitionAwareness ? """ - CASE WHEN pc.relkind = 'p' THEN ( - SELECT count(*) - FROM pg_catalog.pg_inherits ci - WHERE ci.inhparent = pc.oid) END - """ : "NULL::bigint" - - let partitionFilter = includePartitionAwareness - ? "\n " + partitionChildExclusion(childOidExpression: "pc.oid") - : "" - - var unions: [String] = [ - """ - SELECT t.table_name, \(tableTypeColumn) AS table_type, - \(commentColumn("pc.oid")) AS table_comment, - \(partitionCountColumn) AS partition_count\(schemaColumn("t.table_schema")) - FROM information_schema.tables t\(classJoin) - WHERE \(schemaFilter("t.table_schema")) - AND t.table_type IN ('BASE TABLE', 'VIEW')\(partitionFilter) - """ - ] - - if includeMaterializedViews { - let matviewJoin = includeComments ? """ - - LEFT JOIN pg_catalog.pg_namespace mn ON mn.nspname = m.schemaname - LEFT JOIN pg_catalog.pg_class mc ON mc.relnamespace = mn.oid AND mc.relname = m.matviewname - """ : "" - unions.append( - """ - SELECT m.matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type, - \(commentColumn("mc.oid")) AS table_comment, - NULL::bigint AS partition_count\(schemaColumn("m.schemaname")) - FROM pg_matviews m\(matviewJoin) - WHERE \(schemaFilter("m.schemaname")) - """ - ) - } - - if includeForeignTables { - let foreignPartitionFilter = includePartitionAwareness - ? "\n " + partitionChildExclusion(childOidExpression: "c.oid") - : "" - unions.append( - """ - SELECT c.relname AS table_name, 'FOREIGN TABLE' AS table_type, - \(commentColumn("c.oid")) AS table_comment, - NULL::bigint AS partition_count\(schemaColumn("n.nspname")) - FROM pg_foreign_table ft - JOIN pg_class c ON c.oid = ft.ftrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE \(schemaFilter("n.nspname"))\(foreignPartitionFilter) - """ - ) - } - - return unions.joined(separator: "\nUNION ALL\n") + "\n" + orderBy - } - - /// The predicate that keeps a partition out of a flat listing. A foreign - /// table can be a partition from PostgreSQL 11, so the foreign-table union - /// arm needs it as much as the base arm does: without it one relation was - /// listed flat under Foreign Tables and nested under its parent at once. - private static func partitionChildExclusion(childOidExpression: String) -> String { - """ - AND NOT EXISTS ( - SELECT 1 - FROM pg_catalog.pg_inherits i - JOIN pg_catalog.pg_class parent ON parent.oid = i.inhparent - JOIN pg_catalog.pg_namespace parentns ON parentns.oid = parent.relnamespace - WHERE i.inhrelid = \(childOidExpression) - AND parent.relkind IN ('p', 'I') - AND EXISTS ( - SELECT 1 - FROM information_schema.tables pt - WHERE pt.table_schema = parentns.nspname - AND pt.table_name = parent.relname)) - """ - } - /// Lists one partitioned table's direct partitions with each one's own /// schema and bound, ordered so the DEFAULT partition sorts last. A child /// that is itself subpartitioned comes back with `relkind = 'p'` so it can @@ -251,7 +72,7 @@ enum PostgreSQLSchemaQueries { /// public.orders` is legal, and stamping the parent's schema on the row /// pointed every statement built from it at a different relation. /// - /// `relpartbound` exists only from PostgreSQL 10, so unlike `fetchTables` + /// `relpartbound` exists only from PostgreSQL 10, so unlike `PostgreSQLTableListing.query` /// this query cannot be issued against an older server. The caller gates it /// on `PostgreSQLCapabilities.hasDeclarativePartitioning`. static func fetchPartitions(schema: String, table: String) -> String { @@ -640,22 +461,15 @@ enum PostgreSQLSchemaQueries { capabilities: PostgreSQLCapabilities, includesTableName: Bool ) -> String { + let source = PostgreSQLMaterializedViewColumnSource.self let tableNameProjection = includesTableName ? "mvc.relname AS table_name,\n " : "" - let tableFilter = table.map { "\n AND mvc.relname = \(PostgreSQLObjectQueries.quoteLiteral($0))" } ?? "" let identityProjection = capabilities.hasIdentityColumns ? "mva.attidentity" : "NULL::text" let generatedProjection = capabilities.hasGeneratedColumns ? "mva.attgenerated" : "NULL::text" return """ SELECT - \(tableNameProjection)mva.attname AS column_name, - CASE WHEN mvt.typtype = 'd' - THEN CASE WHEN mvbt.typelem <> 0 AND mvbt.typlen = -1 THEN 'ARRAY' - WHEN mvbtn.nspname = 'pg_catalog' THEN pg_catalog.format_type(mvt.typbasetype, NULL) - ELSE 'USER-DEFINED' END - ELSE CASE WHEN mvt.typelem <> 0 AND mvt.typlen = -1 THEN 'ARRAY' - WHEN mvtn.nspname = 'pg_catalog' THEN pg_catalog.format_type(mva.atttypid, NULL) - ELSE 'USER-DEFINED' END - END AS data_type, - CASE WHEN mva.attnotnull OR (mvt.typtype = 'd' AND mvt.typnotnull) THEN 'NO' ELSE 'YES' END AS is_nullable, + \(tableNameProjection)\(source.columnName) AS column_name, + \(source.dataType) AS data_type, + \(source.isNullable) AS is_nullable, NULL::text AS column_default, CASE WHEN mvcon.nspname <> 'pg_catalog' OR mvco.collname <> 'default' THEN mvco.collname END AS collation_name, pg_catalog.col_description(mvc.oid, mva.attnum) AS column_comment, @@ -667,26 +481,8 @@ enum PostgreSQLSchemaQueries { NULL::text AS generation_expression, \(declaredType(attribute: "mva")) AS declared_type, CASE WHEN mvt.typtype = 'd' THEN mvt.typname END AS domain_name, - mva.attnum AS ordinal_position - FROM pg_catalog.pg_class mvc - JOIN pg_catalog.pg_namespace mvn ON mvn.oid = mvc.relnamespace - JOIN pg_catalog.pg_attribute mva - ON mva.attrelid = mvc.oid - AND mva.attnum > 0 - AND NOT mva.attisdropped - JOIN pg_catalog.pg_type mvt ON mvt.oid = mva.atttypid - JOIN pg_catalog.pg_namespace mvtn ON mvtn.oid = mvt.typnamespace - LEFT JOIN pg_catalog.pg_type mvbt - ON mvt.typtype = 'd' - AND mvbt.oid = mvt.typbasetype - LEFT JOIN pg_catalog.pg_namespace mvbtn ON mvbtn.oid = mvbt.typnamespace - LEFT JOIN pg_catalog.pg_collation mvco ON mvco.oid = mva.attcollation - LEFT JOIN pg_catalog.pg_namespace mvcon ON mvcon.oid = mvco.collnamespace - WHERE mvc.relkind = 'm' - AND mvn.nspname = \(schemaLiteral)\(tableFilter) - AND NOT pg_catalog.pg_is_other_temp_schema(mvn.oid) - AND (pg_catalog.pg_has_role(mvc.relowner, 'USAGE') - OR pg_catalog.has_column_privilege(mvc.oid, mva.attnum, 'SELECT, INSERT, UPDATE, REFERENCES')) + \(source.ordinalPosition) AS ordinal_position + \(source.relation(schemaLiteral: schemaLiteral, table: table)) """ } } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLTableListing.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLTableListing.swift new file mode 100644 index 0000000000..0ef45cc94b --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLTableListing.swift @@ -0,0 +1,213 @@ +import Foundation +import TableProPluginKit + +nonisolated enum PostgreSQLTableListingScope: Sendable, Equatable { + case schema(String) + case allSchemas +} + +nonisolated enum PostgreSQLTableListing { + /// Lists user-visible schemas, excluding PostgreSQL's built-in `pg_*` + /// namespaces and `information_schema`. + /// + /// The underscore in the `LIKE` pattern is escaped so it is matched + /// literally; without an `ESCAPE` clause, `_` would be SQL LIKE's + /// single-char wildcard and `'pg_%'` would also exclude legitimate user + /// schemas such as `pgboss`, `pgcrypto`, or `pgvector`. + static let visibleSchemas = """ + SELECT schema_name FROM information_schema.schemata + WHERE schema_name NOT LIKE 'pg!_%' ESCAPE '!' + AND schema_name <> 'information_schema' + ORDER BY schema_name + """ + + /// Lists tables and views, optionally including materialized views and + /// foreign tables. The optional unions reference `pg_matviews` and + /// `pg_foreign_table`, which some PostgreSQL-compatible engines do not + /// implement; the caller passes `false` when those catalogs are absent so + /// the whole query does not fail with `relation does not exist`. + /// + /// `includeComments` projects each table's comment via `obj_description` + /// over the relation's oid. Engines that lack that function fail the whole + /// listing, so the caller passes `false` to fall back to a comment-free + /// listing. + /// + /// `includePartitionAwareness` labels a declarative partition parent as + /// `PARTITIONED TABLE`, counts its partitions, and drops its partition + /// children, which `information_schema.tables` reports as plain + /// `BASE TABLE` rows indistinguishable from the parent. The test is + /// `pg_inherits` joined to the parent's `relkind`, not + /// `pg_class.relispartition`: `relispartition` only exists from PostgreSQL + /// 10, and referencing a missing column fails at parse time, which would + /// break the listing outright on older servers. Comparing `relkind` against + /// `'p'`/`'I'` is a value test on a column present since PostgreSQL 8, so it + /// parses everywhere and simply matches nothing before declarative + /// partitioning existed. Rows still come from `information_schema.tables`, + /// which keeps its privilege filtering; the catalog joins only label, count + /// and exclude rows it already returned. The caller passes `false` for + /// engines without these catalogs. + /// + /// A child is dropped only when its parent is itself listed. Keying the + /// exclusion on the child alone hid a partition whose parent the role cannot + /// read: granting `SELECT` on one partition and nothing on its parent left + /// the whole schema listing empty while that partition was perfectly + /// readable. The visibility test reuses `information_schema.tables` rather + /// than restating its privilege predicate, so the two cannot drift. + /// + /// Legacy `INHERITS` children stay listed on purpose. Their parent is an + /// ordinary table (`relkind = 'r'`), and they are independently useful + /// tables rather than an implementation detail of one parent. + static func query( + schema: String, + includeMaterializedViews: Bool, + includeForeignTables: Bool, + includeComments: Bool = true, + includePartitionAwareness: Bool = true + ) -> String { + query( + in: .schema(schema), + includeMaterializedViews: includeMaterializedViews, + includeForeignTables: includeForeignTables, + includeComments: includeComments, + includePartitionAwareness: includePartitionAwareness + ) + } + + /// The same listing over one schema or over every schema `visibleSchemas` returns. The second + /// filters by that query itself rather than restating its predicate, so a table is listed here + /// exactly when its schema is listed there, and projects each row's schema, which the + /// one-schema listing leaves to the caller. + static func query( + in listing: PostgreSQLTableListingScope, + includeMaterializedViews: Bool, + includeForeignTables: Bool, + includeComments: Bool = true, + includePartitionAwareness: Bool = true + ) -> String { + func schemaFilter(_ column: String) -> String { + switch listing { + case .schema(let schema): + return "\(column) = \(PostgreSQLObjectQueries.quoteLiteral(schema))" + case .allSchemas: + return "\(column) IN (\n\(visibleSchemas)\n)" + } + } + func schemaColumn(_ column: String) -> String { + listing == .allSchemas ? ",\n \(column) AS schema_name" : "" + } + let orderBy = listing == .allSchemas ? "ORDER BY schema_name, table_name" : "ORDER BY table_name" + func commentColumn(_ oidExpression: String) -> String { + includeComments ? "obj_description(\(oidExpression), 'pg_class')" : "NULL::text" + } + + let classJoin = (includeComments || includePartitionAwareness) ? """ + + LEFT JOIN pg_catalog.pg_namespace pn ON pn.nspname = t.table_schema + LEFT JOIN pg_catalog.pg_class pc ON pc.relnamespace = pn.oid AND pc.relname = t.table_name + """ : "" + + let tableTypeColumn = includePartitionAwareness + ? "CASE WHEN pc.relkind = 'p' THEN 'PARTITIONED TABLE' ELSE t.table_type END" + : "t.table_type" + + let partitionCountColumn = includePartitionAwareness ? """ + CASE WHEN pc.relkind = 'p' THEN ( + SELECT count(*) + FROM pg_catalog.pg_inherits ci + WHERE ci.inhparent = pc.oid) END + """ : "NULL::bigint" + + let partitionFilter = includePartitionAwareness + ? "\n " + partitionChildExclusion(childOidExpression: "pc.oid") + : "" + + var unions: [String] = [ + """ + SELECT t.table_name, \(tableTypeColumn) AS table_type, + \(commentColumn("pc.oid")) AS table_comment, + \(partitionCountColumn) AS partition_count\(schemaColumn("t.table_schema")) + FROM information_schema.tables t\(classJoin) + WHERE \(schemaFilter("t.table_schema")) + AND t.table_type IN ('BASE TABLE', 'VIEW')\(partitionFilter) + """ + ] + + if includeMaterializedViews { + let matviewJoin = includeComments ? """ + + LEFT JOIN pg_catalog.pg_namespace mn ON mn.nspname = m.schemaname + LEFT JOIN pg_catalog.pg_class mc ON mc.relnamespace = mn.oid AND mc.relname = m.matviewname + """ : "" + unions.append( + """ + SELECT m.matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type, + \(commentColumn("mc.oid")) AS table_comment, + NULL::bigint AS partition_count\(schemaColumn("m.schemaname")) + FROM pg_matviews m\(matviewJoin) + WHERE \(schemaFilter("m.schemaname")) + """ + ) + } + + if includeForeignTables { + let foreignPartitionFilter = includePartitionAwareness + ? "\n " + partitionChildExclusion(childOidExpression: "c.oid") + : "" + unions.append( + """ + SELECT c.relname AS table_name, 'FOREIGN TABLE' AS table_type, + \(commentColumn("c.oid")) AS table_comment, + NULL::bigint AS partition_count\(schemaColumn("n.nspname")) + FROM pg_foreign_table ft + JOIN pg_class c ON c.oid = ft.ftrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE \(schemaFilter("n.nspname"))\(foreignPartitionFilter) + """ + ) + } + + return unions.joined(separator: "\nUNION ALL\n") + "\n" + orderBy + } + + static func table(fromRow row: [String?]) -> PluginTableInfo? { + guard let name = row[safe: 0] ?? nil else { return nil } + return PluginTableInfo( + name: name, + type: relationType(listed: row[safe: 1] ?? nil), + schema: row[safe: 4] ?? nil, + comment: (row[safe: 2] ?? nil)?.nilIfEmpty, + partitionCount: (row[safe: 3] ?? nil).flatMap(Int.init) + ) + } + + private static func relationType(listed: String?) -> String { + switch listed { + case "PARTITIONED TABLE": return "PARTITIONED TABLE" + case "MATERIALIZED VIEW": return "MATERIALIZED VIEW" + case "FOREIGN TABLE": return "FOREIGN TABLE" + case "VIEW": return "VIEW" + default: return "TABLE" + } + } + + /// The predicate that keeps a partition out of a flat listing. A foreign + /// table can be a partition from PostgreSQL 11, so the foreign-table union + /// arm needs it as much as the base arm does: without it one relation was + /// listed flat under Foreign Tables and nested under its parent at once. + private static func partitionChildExclusion(childOidExpression: String) -> String { + """ + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_inherits i + JOIN pg_catalog.pg_class parent ON parent.oid = i.inhparent + JOIN pg_catalog.pg_namespace parentns ON parentns.oid = parent.relnamespace + WHERE i.inhrelid = \(childOidExpression) + AND parent.relkind IN ('p', 'I') + AND EXISTS ( + SELECT 1 + FROM information_schema.tables pt + WHERE pt.table_schema = parentns.nspname + AND pt.table_name = parent.relname)) + """ + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLTextArray.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLTextArray.swift index 90b946e985..b16b62635d 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLTextArray.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLTextArray.swift @@ -6,7 +6,7 @@ import Foundation import TableProPluginKit -enum PostgreSQLTextArray { +nonisolated enum PostgreSQLTextArray { static func elements(_ text: String?) -> [String?] { guard let text, let parsed = PostgresArrayLiteralCodec.parse(text) else { return [] } return parsed.map { element in diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift index 9222f54230..2c253299f1 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift @@ -74,20 +74,8 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchTables(schema: String?) async throws -> [PluginTableInfo] { let resolvedSchema = schema ?? core.currentSchema - let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(resolvedSchema) - let query = """ - SELECT table_name, table_type - FROM information_schema.tables - WHERE table_schema = \(schemaLiteral) - ORDER BY table_name - """ - let result = try await execute(query: query) - let localTables = result.rows.compactMap { row -> PluginTableInfo? in - guard let name = row[0].asText else { return nil } - let typeStr = row[1].asText ?? "BASE TABLE" - let type = typeStr.contains("VIEW") ? "VIEW" : "TABLE" - return PluginTableInfo(name: name, type: type) - } + let result = try await execute(query: RedshiftTableCatalog.listingQuery(schema: resolvedSchema)) + let localTables = result.rows.compactMap { RedshiftTableCatalog.table(fromListingRow: $0.map(\.asText)) } guard isExternalSchema(resolvedSchema) else { return localTables } @@ -306,40 +294,9 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { - let tableLiteral = PostgreSQLObjectQueries.quoteLiteral(table) - let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema ?? core.currentSchema) - let query = """ - SELECT - "column", - type, - distkey, - sortkey - FROM pg_table_def - WHERE schemaname = \(schemaLiteral) - AND tablename = \(tableLiteral) - AND (distkey = true OR sortkey != 0) - ORDER BY sortkey - """ + let query = RedshiftTableCatalog.keysQuery(schema: schema ?? core.currentSchema, table: table) let result = try await execute(query: query) - - var distkeyCols: [String] = [] - var sortkeyCols: [String] = [] - for row in result.rows { - guard let colName = row[0].asText else { continue } - let isDistkey = PostgreSQLCatalogBoolean.isTrue(row[2].asText) - let sortKeyVal = Int(row[3].asText ?? "0") ?? 0 - if isDistkey { distkeyCols.append(colName) } - if sortKeyVal != 0 { sortkeyCols.append(colName) } - } - - var indexes: [PluginIndexInfo] = [] - if !distkeyCols.isEmpty { - indexes.append(PluginIndexInfo(name: "DISTKEY", columns: distkeyCols, type: "DISTKEY")) - } - if !sortkeyCols.isEmpty { - indexes.append(PluginIndexInfo(name: "SORTKEY", columns: sortkeyCols, type: "SORTKEY")) - } - return indexes + return RedshiftTableCatalog.keys(fromRows: result.rows.map { $0.map(\.asText) }) } var tableDDLIncludesForeignKeys: Bool { true } diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftTableCatalog.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftTableCatalog.swift new file mode 100644 index 0000000000..6f88f76d7a --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftTableCatalog.swift @@ -0,0 +1,58 @@ +import Foundation +import TableProPluginKit + +nonisolated enum RedshiftTableCatalog { + static func listingQuery(schema: String) -> String { + """ + SELECT table_name, table_type + FROM information_schema.tables + WHERE table_schema = \(PostgreSQLObjectQueries.quoteLiteral(schema)) + ORDER BY table_name + """ + } + + static func table(fromListingRow row: [String?]) -> PluginTableInfo? { + guard let name = row[safe: 0] ?? nil else { return nil } + let listedType = (row[safe: 1] ?? nil) ?? "BASE TABLE" + return PluginTableInfo(name: name, type: listedType.contains("VIEW") ? "VIEW" : "TABLE") + } + + static func keysQuery(schema: String, table: String) -> String { + """ + SELECT + "column", + type, + distkey, + sortkey + FROM pg_table_def + WHERE schemaname = \(PostgreSQLObjectQueries.quoteLiteral(schema)) + AND tablename = \(PostgreSQLObjectQueries.quoteLiteral(table)) + AND (distkey = true OR sortkey != 0) + ORDER BY sortkey + """ + } + + static func keys(fromRows rows: [[String?]]) -> [PluginIndexInfo] { + var distkeyColumns: [String] = [] + var sortkeyColumns: [String] = [] + for row in rows { + guard let column = row[safe: 0] ?? nil else { continue } + if PostgreSQLCatalogBoolean.isTrue(row[safe: 2] ?? nil) { + distkeyColumns.append(column) + } + let sortKeyPosition = (row[safe: 3] ?? nil).flatMap { Int($0) } ?? 0 + if sortKeyPosition != 0 { + sortkeyColumns.append(column) + } + } + + var keys: [PluginIndexInfo] = [] + if !distkeyColumns.isEmpty { + keys.append(PluginIndexInfo(name: "DISTKEY", columns: distkeyColumns, type: "DISTKEY")) + } + if !sortkeyColumns.isEmpty { + keys.append(PluginIndexInfo(name: "SORTKEY", columns: sortkeyColumns, type: "SORTKEY")) + } + return keys + } +} diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLColumnReadSupport.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLColumnReadSupport.swift new file mode 100644 index 0000000000..34a587082e --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLColumnReadSupport.swift @@ -0,0 +1,32 @@ +import Foundation + +nonisolated struct PostgreSQLColumnReadShape: Equatable, Sendable { + let includesIdentityColumns: Bool + let includesMaterializedViews: Bool +} + +nonisolated struct PostgreSQLColumnReadSupport: Equatable, Sendable { + var identityColumns: Bool? + var materializedViewColumns: Bool? + + func attempts(materializedViewsPresent: Bool) -> [PostgreSQLColumnReadShape] { + let identityOptions = identityColumns == false ? [false] : [true, false] + let readsMaterializedViews = materializedViewsPresent && materializedViewColumns != false + let materializedViewOptions = readsMaterializedViews ? [true, false] : [false] + return materializedViewOptions.flatMap { includesMaterializedViews in + identityOptions.map { includesIdentityColumns in + PostgreSQLColumnReadShape( + includesIdentityColumns: includesIdentityColumns, + includesMaterializedViews: includesMaterializedViews + ) + } + } + } + + func learning(from shape: PostgreSQLColumnReadShape, materializedViewsPresent: Bool) -> PostgreSQLColumnReadSupport { + PostgreSQLColumnReadSupport( + identityColumns: shape.includesIdentityColumns, + materializedViewColumns: materializedViewsPresent ? shape.includesMaterializedViews : materializedViewColumns + ) + } +} diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver+Catalog.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver+Catalog.swift new file mode 100644 index 0000000000..94ab9be2a9 --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver+Catalog.swift @@ -0,0 +1,199 @@ +import Foundation +import os +import TableProDatabase +import TableProModels +import TableProPluginKit + +nonisolated extension PostgreSQLDriver { + func fetchTables(schema: String?) async throws -> [TableInfo] { + let query = Self.tablesQuery( + schema: schema ?? effectiveSchema, + databaseType: databaseType, + presence: catalogPresence + ) + let result = try await execute(query: query) + return Self.tables(fromRows: result.rows, databaseType: databaseType) + } + + func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { + let schemaName = schema ?? effectiveSchema + let materializedViewsPresent = catalogPresence.hasMaterializedViews + let attempts = columnReadSupport.attempts(materializedViewsPresent: materializedViewsPresent) + for shape in attempts.dropLast() { + try Task.checkCancellation() + do { + return try await readColumns(table: table, schema: schemaName, shape: shape) + } catch is CancellationError { + throw CancellationError() + } catch { + Self.logger.debug( + "Column read failed with identity=\(shape.includesIdentityColumns, privacy: .public) matviews=\(shape.includesMaterializedViews, privacy: .public): \(error.localizedDescription, privacy: .private)" + ) + } + } + guard let leastCapable = attempts.last else { return [] } + try Task.checkCancellation() + return try await readColumns(table: table, schema: schemaName, shape: leastCapable) + } + + private func readColumns(table: String, schema: String, shape: PostgreSQLColumnReadShape) async throws -> [ColumnInfo] { + let result = try await execute(query: Self.columnsQuery(schema: schema, table: table, shape: shape)) + columnReadSupport = columnReadSupport.learning( + from: shape, + materializedViewsPresent: catalogPresence.hasMaterializedViews + ) + return Self.columns(fromRows: result.rows) + } + + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { + let query = Self.indexesQuery( + schema: schema ?? effectiveSchema, + table: table, + databaseType: databaseType, + serverVersionNumber: serverVersionNumber + ) + let result = try await execute(query: query) + return Self.indexes(fromRows: result.rows, databaseType: databaseType) + } + + static func presence(probedRows rows: [[String?]]?) -> PostgreSQLCatalogPresence { + PostgreSQLCatalogPresence(relationNames: rows?.compactMap { $0.first ?? nil } ?? []) + } + + static func tablesQuery(schema: String, databaseType: DatabaseType, presence: PostgreSQLCatalogPresence) -> String { + guard databaseType != .redshift else { + return RedshiftTableCatalog.listingQuery(schema: schema) + } + return PostgreSQLTableListing.query( + schema: schema, + includeMaterializedViews: presence.hasMaterializedViews, + includeForeignTables: presence.hasForeignTables, + includeComments: false, + includePartitionAwareness: false + ) + } + + static func tables(fromRows rows: [[String?]], databaseType: DatabaseType) -> [TableInfo] { + rows.compactMap { row in + let listed = databaseType == .redshift + ? RedshiftTableCatalog.table(fromListingRow: row) + : PostgreSQLTableListing.table(fromRow: row) + return listed.map { TableInfo(from: $0) } + } + } + + static func columnsQuery(schema: String, table: String, shape: PostgreSQLColumnReadShape) -> String { + let schemaLiteral = PostgreSQLObjectQueries.quoteLiteral(schema) + let tableLiteral = PostgreSQLObjectQueries.quoteLiteral(table) + let identityColumns = shape.includesIdentityColumns ? ["is_identity", "is_generated"] : [] + let identityProjection = identityColumns.map { ",\n c.\($0) AS \($0)" }.joined() + var arms = [ + """ + SELECT + c.column_name AS column_name, + c.data_type AS data_type, + c.is_nullable AS is_nullable, + c.column_default AS column_default, + c.character_maximum_length AS character_maximum_length, + CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk\(identityProjection), + c.ordinal_position AS ordinal_position + FROM information_schema.columns c + LEFT JOIN ( + SELECT 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 = \(schemaLiteral) + AND tc.table_name = \(tableLiteral) + ) pk ON c.column_name = pk.column_name + WHERE c.table_schema = \(schemaLiteral) AND c.table_name = \(tableLiteral) + """ + ] + if shape.includesMaterializedViews { + arms.append(materializedViewColumnsArm(schemaLiteral: schemaLiteral, table: table, shape: shape)) + } + let outerColumns = [ + "column_name", "data_type", "is_nullable", "column_default", "character_maximum_length", "is_pk" + ] + identityColumns + return """ + SELECT + \(outerColumns.map { "cols.\($0)" }.joined(separator: ",\n ")) + FROM ( + \(arms.joined(separator: "\nUNION ALL\n")) + ) cols + ORDER BY cols.ordinal_position + """ + } + + private static func materializedViewColumnsArm( + schemaLiteral: String, + table: String, + shape: PostgreSQLColumnReadShape + ) -> String { + let source = PostgreSQLMaterializedViewColumnSource.self + let identityProjection = shape.includesIdentityColumns + ? ",\n 'NO' AS is_identity,\n 'NEVER' AS is_generated" + : "" + return """ + SELECT + \(source.columnName) AS column_name, + \(source.dataType) AS data_type, + \(source.isNullable) AS is_nullable, + NULL::text AS column_default, + \(source.characterMaximumLength) AS character_maximum_length, + 'NO' AS is_pk\(identityProjection), + \(source.ordinalPosition) AS ordinal_position + \(source.relation(schemaLiteral: schemaLiteral, table: table)) + """ + } + + static func columns(fromRows rows: [[String?]]) -> [ColumnInfo] { + rows.enumerated().compactMap { index, row in + guard row.count >= 6, let name = row[0], let dataType = row[1] else { return nil } + return ColumnInfo( + name: name, + typeName: dataType, + isPrimaryKey: row[5] == "YES", + isNullable: row[2]?.uppercased() == "YES", + defaultValue: row[3], + comment: nil, + characterMaxLength: row[4].flatMap { Int($0) }, + ordinalPosition: index, + isAutoIncrement: ColumnMetadataRules.postgresIsAutoIncrement( + isIdentity: row.count > 6 ? row[6] : nil, columnDefault: row[3] + ), + isGenerated: ColumnMetadataRules.postgresIsGenerated( + isGenerated: row.count > 7 ? row[7] : nil + ) + ) + } + } + + static func indexesQuery( + schema: String, + table: String, + databaseType: DatabaseType, + serverVersionNumber: Int32 + ) -> String { + guard databaseType != .redshift else { + return RedshiftTableCatalog.keysQuery(schema: schema, table: table) + } + return PostgreSQLIndexQueries.indexList( + schema: schema, + table: table, + capabilities: .assumingModernWhenUnknown(serverVersionNumber) + ) + } + + static func indexes(fromRows rows: [[String?]], databaseType: DatabaseType) -> [IndexInfo] { + guard databaseType != .redshift else { + return RedshiftTableCatalog.keys(fromRows: rows).map { IndexInfo(from: $0) } + } + return rows.compactMap { row in + PostgreSQLIndexRow.index(from: row.map(PluginCellValue.fromOptional), ddl: [:]) + .map { IndexInfo(from: $0.index) } + } + } +} diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift index e78c79e8f5..b49d39e05a 100644 --- a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift @@ -6,6 +6,8 @@ import TableProModels import TableProPluginKit nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { + static let logger = Logger(subsystem: "com.TablePro", category: "PostgreSQLDriver") + private let actor = PostgreSQLActor() private let host: String private let port: Int @@ -13,6 +15,7 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { private let password: String private let database: String private let ssl: DriverSSLConfiguration + let databaseType: DatabaseType var supportsSchemas: Bool { true } var supportsTransactions: Bool { true } @@ -21,18 +24,28 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { nonisolated(unsafe) private(set) var currentSchema: String? = "public" nonisolated(unsafe) private(set) var serverVersion: String? nonisolated(unsafe) private(set) var serverVersionNumber: Int32 = 0 + nonisolated(unsafe) private(set) var catalogPresence = PostgreSQLDriver.presence(probedRows: nil) - nonisolated(unsafe) private var reportsIdentityColumns: Bool? + nonisolated(unsafe) var columnReadSupport = PostgreSQLColumnReadSupport() - private var effectiveSchema: String { currentSchema ?? "public" } + var effectiveSchema: String { currentSchema ?? "public" } - init(host: String, port: Int, user: String, password: String, database: String, ssl: DriverSSLConfiguration = .disabled) { + init( + host: String, + port: Int, + user: String, + password: String, + database: String, + ssl: DriverSSLConfiguration = .disabled, + databaseType: DatabaseType = .postgresql + ) { self.host = host self.port = port self.user = user self.password = password self.database = database self.ssl = ssl + self.databaseType = databaseType } // MARK: - Connection @@ -43,9 +56,24 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { _ = try? await actor.execute("SET standard_conforming_strings = on") serverVersion = await actor.serverVersion() serverVersionNumber = await actor.serverVersionNumber() + catalogPresence = await probeCatalogPresence() + columnReadSupport = PostgreSQLColumnReadSupport() await adoptServerSchema() } + private func probeCatalogPresence() async -> PostgreSQLCatalogPresence { + guard databaseType != .redshift else { return Self.presence(probedRows: nil) } + do { + let rows = try await actor.execute(PostgreSQLCatalogPresence.probeQuery).rows + return Self.presence(probedRows: rows) + } catch { + Self.logger.warning( + "Catalog presence probe failed; listing without the optional catalogs: \(error.localizedDescription, privacy: .private)" + ) + return Self.presence(probedRows: nil) + } + } + private func adoptServerSchema() async { guard let schema = try? await actor.execute("SELECT current_schema()").rows.first?.first ?? nil, !schema.isEmpty else { return } @@ -149,145 +177,6 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { // MARK: - Schema - func fetchTables(schema: String?) async throws -> [TableInfo] { - let schemaName = schema ?? effectiveSchema - let safe = schemaName.replacingOccurrences(of: "'", with: "''") - let raw = try await actor.execute(""" - SELECT table_name, table_type - FROM information_schema.tables - WHERE table_schema = '\(safe)' - ORDER BY table_name - """) - - return raw.rows.compactMap { row in - guard row.count >= 2, let name = row[0] else { return nil } - let typeStr = row[1]?.uppercased() ?? "TABLE" - let kind: TableInfo.TableKind - switch typeStr { - case "VIEW": kind = .view - case "SYSTEM TABLE": kind = .systemTable - default: kind = .table - } - return TableInfo(name: name, type: kind, rowCount: nil, dataSize: nil, comment: nil) - } - } - - func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { - let schemaName = schema ?? effectiveSchema - let safeTbl = table.replacingOccurrences(of: "'", with: "''") - let safeSchema = schemaName.replacingOccurrences(of: "'", with: "''") - - let result: RawPGResult - if reportsIdentityColumns == false { - result = try await actor.execute(columnsQuery(schema: safeSchema, table: safeTbl, identity: false)) - } else { - do { - result = try await actor.execute(columnsQuery(schema: safeSchema, table: safeTbl, identity: true)) - reportsIdentityColumns = true - } catch is CancellationError { - throw CancellationError() - } catch { - reportsIdentityColumns = false - result = try await actor.execute(columnsQuery(schema: safeSchema, table: safeTbl, identity: false)) - } - } - - return result.rows.enumerated().compactMap { index, row in - guard row.count >= 6, let name = row[0], let dataType = row[1] else { return nil } - let maxLen = row[4].flatMap { Int($0) } - return ColumnInfo( - name: name, - typeName: dataType, - isPrimaryKey: row[5] == "YES", - isNullable: row[2]?.uppercased() == "YES", - defaultValue: row[3], - comment: nil, - characterMaxLength: maxLen, - ordinalPosition: index, - isAutoIncrement: ColumnMetadataRules.postgresIsAutoIncrement( - isIdentity: row.count > 6 ? row[6] : nil, columnDefault: row[3] - ), - isGenerated: ColumnMetadataRules.postgresIsGenerated( - isGenerated: row.count > 7 ? row[7] : nil - ) - ) - } - } - - private func columnsQuery(schema: String, table: String, identity: Bool) -> String { - let identityColumns = identity ? ",\n c.is_identity,\n c.is_generated" : "" - return """ - SELECT - c.column_name, - c.data_type, - c.is_nullable, - c.column_default, - c.character_maximum_length, - CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk\(identityColumns) - FROM information_schema.columns c - LEFT JOIN ( - SELECT 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 = '\(schema)' - AND tc.table_name = '\(table)' - ) pk ON c.column_name = pk.column_name - WHERE c.table_schema = '\(schema)' AND c.table_name = '\(table)' - ORDER BY c.ordinal_position - """ - } - - func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { - let schemaName = schema ?? effectiveSchema - let safeTbl = table.replacingOccurrences(of: "'", with: "''") - let safeSchema = schemaName.replacingOccurrences(of: "'", with: "''") - - let raw = try await actor.execute(""" - SELECT - i.relname AS index_name, - ix.indisunique, - ix.indisprimary, - a.attname AS column_name - FROM pg_index ix - JOIN pg_class t ON t.oid = ix.indrelid - JOIN pg_class i ON i.oid = ix.indexrelid - JOIN pg_namespace n ON n.oid = t.relnamespace - JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) - WHERE n.nspname = '\(safeSchema)' AND t.relname = '\(safeTbl)' - ORDER BY i.relname, a.attnum - """) - - var indexMap: [String: (isUnique: Bool, isPrimary: Bool, columns: [String])] = [:] - var order: [String] = [] - - for row in raw.rows { - guard row.count >= 4, let indexName = row[0], let colName = row[3] else { continue } - if indexMap[indexName] == nil { - indexMap[indexName] = ( - isUnique: row[1] == "t", - isPrimary: row[2] == "t", - columns: [] - ) - order.append(indexName) - } - indexMap[indexName]?.columns.append(colName) - } - - return order.compactMap { name in - guard let entry = indexMap[name] else { return nil } - return IndexInfo( - name: name, - columns: entry.columns, - isUnique: entry.isUnique, - isPrimary: entry.isPrimary, - type: "BTREE" - ) - } - } - func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { let raw = try await actor.execute( Self.foreignKeysQuery( diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index 06c185e0f5..119b78a36d 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -6375,6 +6375,9 @@ } } } + }, + "External Table" : { + }, "Failed" : { "localizations" : { @@ -7026,6 +7029,9 @@ } } } + }, + "Foreign Table" : { + }, "Get Started" : { "extractionState" : "stale", @@ -9001,6 +9007,9 @@ } } } + }, + "Materialized View" : { + }, "Microsoft Entra ID Sign-In Required" : { "localizations" : { @@ -14060,6 +14069,9 @@ } } } + }, + "Sequence" : { + }, "Server" : { "localizations" : { @@ -15218,6 +15230,9 @@ } } } + }, + "System Table" : { + }, "TLS handshake failed: %@" : { "extractionState" : "stale", diff --git a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift index bcded68946..0b3f5a77f6 100644 --- a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift +++ b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -103,7 +103,8 @@ nonisolated final class IOSDriverFactory: DriverFactory { user: connection.username, password: password ?? "", database: connection.database, - ssl: try ssl(for: connection) + ssl: try ssl(for: connection), + databaseType: connection.type ) case .redis: let dbIndex = RedisDatabaseIndex.resolve( diff --git a/TableProMobile/TableProMobile/Views/Components/TableKindPresentation.swift b/TableProMobile/TableProMobile/Views/Components/TableKindPresentation.swift new file mode 100644 index 0000000000..da8207bdda --- /dev/null +++ b/TableProMobile/TableProMobile/Views/Components/TableKindPresentation.swift @@ -0,0 +1,28 @@ +import Foundation +import TableProModels + +nonisolated enum TableKindPresentation { + static func systemImage(for kind: TableInfo.TableKind) -> String { + switch kind { + case .table: return "tablecells" + case .view: return "eye" + case .materializedView: return "square.stack.3d.up" + case .foreignTable: return "link" + case .systemTable: return "tablecells.badge.ellipsis" + case .externalTable: return "externaldrive.connected.to.line.below" + case .sequence: return "number" + } + } + + static func accessibilityKind(for kind: TableInfo.TableKind) -> String { + switch kind { + case .table: return String(localized: "Table") + case .view: return String(localized: "View") + case .materializedView: return String(localized: "Materialized View") + case .foreignTable: return String(localized: "Foreign Table") + case .systemTable: return String(localized: "System Table") + case .externalTable: return String(localized: "External Table") + case .sequence: return String(localized: "Sequence") + } + } +} diff --git a/TableProMobile/TableProMobile/Views/StructureView.swift b/TableProMobile/TableProMobile/Views/StructureView.swift index 8ebeb278f3..4fd504722d 100644 --- a/TableProMobile/TableProMobile/Views/StructureView.swift +++ b/TableProMobile/TableProMobile/Views/StructureView.swift @@ -152,6 +152,18 @@ struct StructureView: View { Text(index.columns.joined(separator: ", ")) .font(.caption) .foregroundStyle(.secondary) + + if !index.includedColumns.isEmpty { + Text(verbatim: "INCLUDE (\(index.includedColumns.joined(separator: ", ")))") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let whereClause = index.whereClause, !whereClause.isEmpty { + Text(verbatim: "WHERE \(whereClause)") + .font(.caption) + .foregroundStyle(.secondary) + } } .padding(.vertical, 2) } diff --git a/TableProMobile/TableProMobile/Views/TableListView.swift b/TableProMobile/TableProMobile/Views/TableListView.swift index 534e2936f8..697fb8480a 100644 --- a/TableProMobile/TableProMobile/Views/TableListView.swift +++ b/TableProMobile/TableProMobile/Views/TableListView.swift @@ -215,11 +215,9 @@ struct TableListView: View { private struct TableRow: View { let table: TableInfo - private var isView: Bool { table.type.listSection == .views } - var body: some View { RowItemLabel(title: table.name) { - Image(systemName: isView ? "eye" : "tablecells") + Image(systemName: TableKindPresentation.systemImage(for: table.type)) .foregroundStyle(.secondary) .frame(width: 24) } trailing: { @@ -233,7 +231,7 @@ private struct TableRow: View { } private var accessibilityLabel: Text { - let kind = isView ? String(localized: "View") : String(localized: "Table") + let kind = TableKindPresentation.accessibilityKind(for: table.type) if let rowCount = table.rowCount { return Text("\(kind), \(table.name), \(rowCount) rows") } diff --git a/TableProMobile/TableProMobileTests/Drivers/MySQLTableListingTests.swift b/TableProMobile/TableProMobileTests/Drivers/MySQLTableListingTests.swift index 878f434b4c..daecd22d31 100644 --- a/TableProMobile/TableProMobileTests/Drivers/MySQLTableListingTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/MySQLTableListingTests.swift @@ -93,7 +93,7 @@ struct TableKindListBehaviourTests { /// happened to a MariaDB sequence. Every kind lands in a section now. @Test("every kind lands in a section") func everyKindHasASection() { - let tables: Set = [.table, .systemTable, .externalTable, .sequence] + let tables: Set = [.table, .foreignTable, .systemTable, .externalTable, .sequence] for kind in TableInfo.TableKind.allCases { let expected: TableInfo.TableKind.ListSection = tables.contains(kind) ? .tables : .views #expect(kind.listSection == expected, "\(kind.rawValue) landed in the wrong section") @@ -115,7 +115,7 @@ struct TableKindListBehaviourTests { /// 4.4.2.1. A system table is the catalog's own and stays editable, as it is on Mac. @Test("row editing follows the kind") func rowEditingFollowsTheKind() { - let editable: Set = [.table, .systemTable] + let editable: Set = [.table, .foreignTable, .systemTable] for kind in TableInfo.TableKind.allCases { #expect(kind.allowsRowEditing == editable.contains(kind), "\(kind.rawValue) is wrongly editable") } diff --git a/TableProMobile/TableProMobileTests/Drivers/PostgreSQLCatalogQueryTests.swift b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLCatalogQueryTests.swift new file mode 100644 index 0000000000..8a38efbe22 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLCatalogQueryTests.swift @@ -0,0 +1,338 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import TableProPluginKit +import Testing + +@Suite("PostgreSQL catalog reads on iOS") +struct PostgreSQLCatalogQueryTests { + private static let allCatalogs = PostgreSQLDriver.presence( + probedRows: [["pg_matviews"], ["pg_foreign_table"], ["pg_sequences"]] + ) + + private static let indexRowsFromPostgreSQL17: [[String?]] = [ + ["t", "t_pkey", "{id}", "t", "t", "btree", nil, "{}", "{}"], + ["t", "t_brin", "{id}", "f", "f", "brin", nil, "{}", "{}"], + ["t", "t_expr_only", "{lower(email)}", "f", "f", "btree", nil, "{lower(email)}", "{}"], + ["t", "t_gin", "{tags}", "f", "f", "gin", nil, "{}", "{}"], + ["t", "t_hash", "{email}", "f", "f", "hash", nil, "{}", "{}"], + ["t", "t_include", "{a}", "f", "f", "btree", nil, "{}", "{b}"], + ["t", "t_mixed", "{tenant_id,lower(email)}", "f", "f", "btree", nil, "{lower(email)}", "{}"], + ["t", "t_order", "{b,a}", "f", "f", "btree", nil, "{}", "{}"], + ["t", "t_partial", "{a}", "t", "f", "btree", "(a > 0)", "{}", "{}"] + ] + + @Test("a materialized view is listed as one") + func materializedViewKind() { + let rows: [[String?]] = [ + ["mv", "MATERIALIZED VIEW", nil, nil], + ["t", "BASE TABLE", nil, nil], + ["v", "VIEW", nil, nil] + ] + + let tables = PostgreSQLDriver.tables(fromRows: rows, databaseType: .postgresql) + + #expect(tables.map(\.name) == ["mv", "t", "v"]) + #expect(tables.map(\.type) == [.materializedView, .table, .view]) + #expect(tables.first?.type.listSection == .views) + } + + @Test("a foreign table is listed as one, with no Truncate or Drop Table the server refuses") + func foreignTableKind() { + let tables = PostgreSQLDriver.tables(fromRows: [["ft", "FOREIGN TABLE", nil, nil]], databaseType: .postgresql) + + #expect(tables.map(\.type) == [.foreignTable]) + #expect(tables.first?.type.listSection == .tables) + #expect(tables.first?.type.allowsDrop == false) + #expect(tables.first?.type.allowsTruncate == false) + } + + @Test("the listing reads pg_matviews and pg_foreign_table only when the probe found them") + func optionalCatalogsFollowTheProbe() { + let withCatalogs = PostgreSQLDriver.tablesQuery( + schema: "public", databaseType: .postgresql, presence: Self.allCatalogs + ) + let withoutCatalogs = PostgreSQLDriver.tablesQuery( + schema: "public", + databaseType: .postgresql, + presence: PostgreSQLDriver.presence(probedRows: [["pg_sequences"]]) + ) + + #expect(withCatalogs.contains("FROM pg_matviews m")) + #expect(withCatalogs.contains("FROM pg_foreign_table ft")) + #expect(!withoutCatalogs.contains("pg_matviews")) + #expect(!withoutCatalogs.contains("pg_foreign_table")) + } + + @Test("a failed catalog probe lists without the optional catalogs") + func failedProbeMeansAbsent() { + let presence = PostgreSQLDriver.presence(probedRows: nil) + + #expect(!presence.hasMaterializedViews) + #expect(!presence.hasForeignTables) + let query = PostgreSQLDriver.tablesQuery(schema: "public", databaseType: .postgresql, presence: presence) + #expect(!query.contains("pg_matviews")) + #expect(!query.contains("pg_foreign_table")) + #expect(query.contains("FROM information_schema.tables t")) + } + + @Test("the iOS listing reads no comments and keeps partitions listed flat") + func noCommentsOrPartitionAwareness() { + let query = PostgreSQLDriver.tablesQuery(schema: "public", databaseType: .postgresql, presence: Self.allCatalogs) + + #expect(!query.contains("obj_description")) + #expect(!query.contains("pg_inherits")) + } + + @Test("Redshift keeps the listing the macOS Redshift driver runs") + func redshiftListing() { + let query = PostgreSQLDriver.tablesQuery(schema: "public", databaseType: .redshift, presence: Self.allCatalogs) + let tables = PostgreSQLDriver.tables( + fromRows: [["orders", "BASE TABLE"], ["recent", "VIEW"]], + databaseType: .redshift + ) + + #expect(query == RedshiftTableCatalog.listingQuery(schema: "public")) + #expect(!query.contains("pg_matviews")) + #expect(tables.map(\.type) == [.table, .view]) + } + + @Test("index rows read from PostgreSQL 17.11 keep expression keys, key order, INCLUDE columns, predicates and methods") + func indexRowsDecode() throws { + let indexes = PostgreSQLDriver.indexes(fromRows: Self.indexRowsFromPostgreSQL17, databaseType: .postgresql) + let byName = Dictionary(uniqueKeysWithValues: indexes.map { ($0.name, $0) }) + + #expect(indexes.count == 9) + let primary = try #require(byName["t_pkey"]) + let include = try #require(byName["t_include"]) + let partial = try #require(byName["t_partial"]) + #expect(primary.isPrimary) + #expect(byName["t_expr_only"]?.columns == ["lower(email)"]) + #expect(byName["t_mixed"]?.columns == ["tenant_id", "lower(email)"]) + #expect(include.columns == ["a"]) + #expect(include.includedColumns == ["b"]) + #expect(byName["t_order"]?.columns == ["b", "a"]) + #expect(partial.whereClause == "(a > 0)") + #expect(partial.isUnique) + #expect(byName["t_gin"]?.type == "GIN") + #expect(byName["t_hash"]?.type == "HASH") + #expect(byName["t_brin"]?.type == "BRIN") + #expect(byName["t_order"]?.type == "BTREE") + } + + @Test("INCLUDE columns are told apart by indnkeyatts from PostgreSQL 11, and on a server that reports no version") + func indexKeyCountFollowsTheServerVersion() { + func query(_ version: Int32) -> String { + PostgreSQLDriver.indexesQuery( + schema: "public", table: "t", databaseType: .postgresql, serverVersionNumber: version + ) + } + + #expect(query(170_011).contains("generate_series(1, ix.indnkeyatts)")) + #expect(query(110_000).contains("generate_series(1, ix.indnkeyatts)")) + #expect(query(0).contains("generate_series(1, ix.indnkeyatts)")) + #expect(!query(100_000).contains("indnkeyatts")) + #expect(query(100_000).contains("generate_series(1, ix.indnatts)")) + } + + @Test("the index read is scoped to the schema and table, quoted") + func indexQueryQuotes() { + let query = PostgreSQLDriver.indexesQuery( + schema: "o'brien", table: "it's", databaseType: .postgresql, serverVersionNumber: 170_011 + ) + + #expect(query.contains("n.nspname = 'o''brien'")) + #expect(query.contains("t.relname = 'it''s'")) + } + + @Test("a Redshift server reads its distribution and sort keys and never the pg_index read") + func redshiftKeys() { + let query = PostgreSQLDriver.indexesQuery( + schema: "public", table: "orders", databaseType: .redshift, serverVersionNumber: 80_002 + ) + let keys = PostgreSQLDriver.indexes( + fromRows: [["id", "integer", "true", "1"], ["created_at", "timestamp", "false", "2"]], + databaseType: .redshift + ) + + #expect(query == RedshiftTableCatalog.keysQuery(schema: "public", table: "orders")) + #expect(!query.contains("generate_series")) + #expect(!query.contains("pg_index")) + #expect(keys.map(\.name) == ["DISTKEY", "SORTKEY"]) + #expect(keys.last?.columns == ["id", "created_at"]) + } + + @Test("the column read adds the materialized view arm only when asked, inside a derived table") + func materializedViewArm() { + let withArm = PostgreSQLDriver.columnsQuery( + schema: "public", + table: "mv", + shape: PostgreSQLColumnReadShape(includesIdentityColumns: true, includesMaterializedViews: true) + ) + let withoutArm = PostgreSQLDriver.columnsQuery( + schema: "public", + table: "mv", + shape: PostgreSQLColumnReadShape(includesIdentityColumns: true, includesMaterializedViews: false) + ) + + #expect(withArm.contains("mvc.relkind = 'm'")) + #expect(withArm.contains("AND mvc.relname = 'mv'")) + #expect(!withoutArm.contains("relkind = 'm'")) + #expect(Self.arms(of: withArm).count == 2) + #expect(Self.arms(of: withoutArm).count == 1) + #expect(withArm.hasSuffix(") cols\nORDER BY cols.ordinal_position")) + } + + @Test( + "every arm projects the same columns in the same order, and ordinal_position stays out of the result", + arguments: [true, false] + ) + func armsAgree(includesIdentityColumns: Bool) { + let query = PostgreSQLDriver.columnsQuery( + schema: "public", + table: "mv", + shape: PostgreSQLColumnReadShape( + includesIdentityColumns: includesIdentityColumns, + includesMaterializedViews: true + ) + ) + let identity = includesIdentityColumns ? ["is_identity", "is_generated"] : [] + let result = ["column_name", "data_type", "is_nullable", "column_default", "character_maximum_length", "is_pk"] + + identity + + for arm in Self.arms(of: query) { + #expect(Self.aliases(in: arm) == result + ["ordinal_position"]) + } + #expect(Self.outerProjection(of: query) == result.map { "cols.\($0)" }) + } + + @Test("the column read quotes the schema and table in both arms") + func columnQueryQuotes() { + let query = PostgreSQLDriver.columnsQuery( + schema: "o'brien", + table: "it's", + shape: PostgreSQLColumnReadShape(includesIdentityColumns: false, includesMaterializedViews: true) + ) + + #expect(query.contains("c.table_schema = 'o''brien' AND c.table_name = 'it''s'")) + #expect(query.contains("mvn.nspname = 'o''brien'")) + #expect(query.contains("AND mvc.relname = 'it''s'")) + } + + @Test("materialized view rows decode like table rows") + func materializedViewColumnRows() { + let columns = PostgreSQLDriver.columns(fromRows: [ + ["id", "integer", "YES", nil, nil, "NO", "NO", "NEVER"], + ["email", "text", "NO", nil, nil, "NO", "NO", "NEVER"], + ["v", "character varying", "YES", nil, "50", "NO", "NO", "NEVER"] + ]) + + #expect(columns.map(\.name) == ["id", "email", "v"]) + #expect(columns.map(\.isNullable) == [true, false, true]) + #expect(columns.map(\.characterMaxLength) == [nil, nil, 50]) + #expect(columns.allSatisfy { !$0.isPrimaryKey && !$0.isAutoIncrement && !$0.isGenerated }) + } + + @Test("Redshift routes to the PostgreSQL driver with its own type") + func redshiftRoutesWithItsType() throws { + let redshift = DatabaseConnection(name: "r", type: .redshift, host: "127.0.0.1", port: 5_439) + let postgres = DatabaseConnection(name: "p", type: .postgresql, host: "127.0.0.1", port: 5_432) + + let redshiftDriver = try #require( + try IOSDriverFactory().createDriver(for: redshift, password: nil) as? PostgreSQLDriver + ) + let postgresDriver = try #require( + try IOSDriverFactory().createDriver(for: postgres, password: nil) as? PostgreSQLDriver + ) + + #expect(redshiftDriver.databaseType == .redshift) + #expect(postgresDriver.databaseType == .postgresql) + } + + private static func arms(of query: String) -> [String] { + guard let start = query.range(of: "FROM (\n"), let end = query.range(of: "\n) cols") else { return [] } + return query[start.upperBound.. [String] { + let projection = arm.components(separatedBy: "\nFROM ").first ?? "" + let pattern = /\sAS ([a-z_]+)/ + return projection.matches(of: pattern).map { String($0.output.1) } + } + + private static func outerProjection(of query: String) -> [String] { + let projection = query.components(separatedBy: "\nFROM (\n").first ?? "" + return projection + .replacingOccurrences(of: "SELECT", with: "") + .components(separatedBy: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + } +} + +@Suite("PostgreSQL column read fallbacks on iOS") +struct PostgreSQLColumnReadSupportTests { + private typealias Shape = PostgreSQLColumnReadShape + + @Test("without materialized views the read tries identity columns, then none") + func withoutMaterializedViews() { + let attempts = PostgreSQLColumnReadSupport().attempts(materializedViewsPresent: false) + + #expect(attempts == [ + Shape(includesIdentityColumns: true, includesMaterializedViews: false), + Shape(includesIdentityColumns: false, includesMaterializedViews: false) + ]) + } + + @Test("with materialized views the identity columns are dropped before the materialized view arm") + func withMaterializedViews() { + let attempts = PostgreSQLColumnReadSupport().attempts(materializedViewsPresent: true) + + #expect(attempts == [ + Shape(includesIdentityColumns: true, includesMaterializedViews: true), + Shape(includesIdentityColumns: false, includesMaterializedViews: true), + Shape(includesIdentityColumns: true, includesMaterializedViews: false), + Shape(includesIdentityColumns: false, includesMaterializedViews: false) + ]) + } + + @Test("a read that only worked without the materialized view arm keeps the identity columns") + func materializedViewArmFailureKeepsIdentity() { + let learned = PostgreSQLColumnReadSupport().learning( + from: Shape(includesIdentityColumns: true, includesMaterializedViews: false), + materializedViewsPresent: true + ) + + #expect(learned.identityColumns == true) + #expect(learned.materializedViewColumns == false) + #expect(learned.attempts(materializedViewsPresent: true) == [ + Shape(includesIdentityColumns: true, includesMaterializedViews: false), + Shape(includesIdentityColumns: false, includesMaterializedViews: false) + ]) + } + + @Test("a read that only worked without identity columns keeps the materialized view arm") + func identityFailureKeepsMaterializedViews() { + let learned = PostgreSQLColumnReadSupport().learning( + from: Shape(includesIdentityColumns: false, includesMaterializedViews: true), + materializedViewsPresent: true + ) + + #expect(learned.identityColumns == false) + #expect(learned.materializedViewColumns == true) + #expect(learned.attempts(materializedViewsPresent: true) == [ + Shape(includesIdentityColumns: false, includesMaterializedViews: true), + Shape(includesIdentityColumns: false, includesMaterializedViews: false) + ]) + } + + @Test("a server without materialized views learns nothing about the arm") + func noMaterializedViewsLearnsNothingAboutTheArm() { + let learned = PostgreSQLColumnReadSupport().learning( + from: Shape(includesIdentityColumns: true, includesMaterializedViews: false), + materializedViewsPresent: false + ) + + #expect(learned.identityColumns == true) + #expect(learned.materializedViewColumns == nil) + } +} diff --git a/TableProMobile/TableProMobileTests/Drivers/PostgreSQLDriverCatalogTests.swift b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLDriverCatalogTests.swift new file mode 100644 index 0000000000..339a4233c1 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLDriverCatalogTests.swift @@ -0,0 +1,141 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import XCTest + +final class PostgreSQLDriverCatalogTests: XCTestCase { + private static let schema = "ios_pg_catalog_test" + private static let foreignDataWrapper = "ios_pg_catalog_test_fdw" + + private var driver: PostgreSQLDriver? + + private static func loadTestConfig() -> [String: String]? { + let env = ProcessInfo.processInfo.environment + if env["POSTGRES_TEST_HOST"] != nil { + return env + } + let fallbackPath = env["POSTGRES_TEST_CONFIG_PATH"] ?? "/tmp/postgres-test.json" + guard let data = FileManager.default.contents(atPath: fallbackPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] + else { return nil } + return json + } + + override func setUp() async throws { + guard let config = Self.loadTestConfig() else { + throw XCTSkip("POSTGRES_TEST_HOST not set and /tmp/postgres-test.json not found, skipping integration tests") + } + let driver = PostgreSQLDriver( + host: config["POSTGRES_TEST_HOST"] ?? "127.0.0.1", + port: Int(config["POSTGRES_TEST_PORT"] ?? "5432") ?? 5_432, + user: config["POSTGRES_TEST_USER"] ?? "postgres", + password: config["POSTGRES_TEST_PASSWORD"] ?? "", + database: config["POSTGRES_TEST_DATABASE"] ?? "postgres" + ) + try await driver.connect() + self.driver = driver + for statement in Self.fixture { + _ = try await driver.execute(query: statement) + } + } + + override func tearDown() async throws { + if let driver { + _ = try? await driver.execute(query: "DROP SCHEMA IF EXISTS \(Self.schema) CASCADE") + _ = try? await driver.execute(query: "DROP FOREIGN DATA WRAPPER IF EXISTS \(Self.foreignDataWrapper) CASCADE") + try await driver.disconnect() + } + driver = nil + } + + private static let fixture = [ + "DROP SCHEMA IF EXISTS \(schema) CASCADE", + "CREATE SCHEMA \(schema)", + "CREATE TABLE \(schema).t (id int PRIMARY KEY, a int, b text, email text, tenant_id int, n int GENERATED BY DEFAULT AS IDENTITY)", + "CREATE INDEX t_mixed ON \(schema).t (tenant_id, lower(email))", + "CREATE INDEX t_include ON \(schema).t (a) INCLUDE (b)", + "CREATE INDEX t_order ON \(schema).t (b, a)", + "CREATE UNIQUE INDEX t_partial ON \(schema).t (a) WHERE a > 0", + "CREATE INDEX t_hash ON \(schema).t USING hash (email)", + "CREATE VIEW \(schema).v AS SELECT id FROM \(schema).t", + "CREATE MATERIALIZED VIEW \(schema).mv AS SELECT id AS mv_id, lower(email) AS mv_expr, b::varchar(20) AS label FROM \(schema).t", + "CREATE UNIQUE INDEX mv_id_key ON \(schema).mv (mv_id)", + "CREATE INDEX mv_expr_idx ON \(schema).mv (lower(mv_expr))" + ] + + private static let foreignTableFixture = [ + "DROP FOREIGN DATA WRAPPER IF EXISTS \(foreignDataWrapper) CASCADE", + "CREATE FOREIGN DATA WRAPPER \(foreignDataWrapper)", + "CREATE SERVER ios_pg_catalog_test_server FOREIGN DATA WRAPPER \(foreignDataWrapper)", + "CREATE FOREIGN TABLE \(schema).ft (a int) SERVER ios_pg_catalog_test_server" + ] + + func testMaterializedViewIsListedAsOne() async throws { + let driver = try XCTUnwrap(driver) + + let tables = try await driver.fetchTables(schema: Self.schema) + + XCTAssertEqual(tables.map(\.name), ["mv", "t", "v"]) + XCTAssertEqual(tables.map(\.type), [.materializedView, .table, .view]) + } + + func testForeignTableIsListedAsOne() async throws { + let driver = try XCTUnwrap(driver) + do { + for statement in Self.foreignTableFixture { + _ = try await driver.execute(query: statement) + } + } catch { + throw XCTSkip("Creating a foreign data wrapper needs a superuser: \(error.localizedDescription)") + } + + let tables = try await driver.fetchTables(schema: Self.schema) + + XCTAssertEqual(tables.map(\.name), ["ft", "mv", "t", "v"]) + XCTAssertEqual(tables.first?.type, .foreignTable) + XCTAssertEqual(tables.first?.type.allowsDrop, false) + } + + func testIndexesKeepKeyOrderExpressionsIncludeColumnsAndPredicates() async throws { + let driver = try XCTUnwrap(driver) + + let indexes = try await driver.fetchIndexes(table: "t", schema: Self.schema) + let byName = Dictionary(uniqueKeysWithValues: indexes.map { ($0.name, $0) }) + + XCTAssertEqual(indexes.first?.name, "t_pkey") + XCTAssertEqual(byName["t_mixed"]?.columns, ["tenant_id", "lower(email)"]) + XCTAssertEqual(byName["t_include"]?.columns, ["a"]) + XCTAssertEqual(byName["t_include"]?.includedColumns, ["b"]) + XCTAssertEqual(byName["t_order"]?.columns, ["b", "a"]) + XCTAssertEqual(byName["t_partial"]?.whereClause, "(a > 0)") + XCTAssertEqual(byName["t_hash"]?.type, "HASH") + } + + func testMaterializedViewHasColumnsAndIndexes() async throws { + let driver = try XCTUnwrap(driver) + + let columns = try await driver.fetchColumns(table: "mv", schema: Self.schema) + let indexes = try await driver.fetchIndexes(table: "mv", schema: Self.schema) + + XCTAssertEqual(columns.map(\.name), ["mv_id", "mv_expr", "label"]) + XCTAssertEqual(columns.map(\.typeName), ["integer", "text", "character varying"]) + XCTAssertEqual(columns.last?.characterMaxLength, 20) + XCTAssertEqual(Set(indexes.map(\.name)), ["mv_id_key", "mv_expr_idx"]) + XCTAssertEqual(indexes.first { $0.name == "mv_expr_idx" }?.columns, ["lower(mv_expr)"]) + XCTAssertEqual( + driver.columnReadSupport, + PostgreSQLColumnReadSupport(identityColumns: true, materializedViewColumns: true) + ) + } + + func testTableColumnsKeepTheirPrimaryKeyAndIdentity() async throws { + let driver = try XCTUnwrap(driver) + + let columns = try await driver.fetchColumns(table: "t", schema: Self.schema) + + XCTAssertEqual(columns.map(\.name), ["id", "a", "b", "email", "tenant_id", "n"]) + XCTAssertEqual(columns.first?.isPrimaryKey, true) + XCTAssertEqual(columns.first?.isNullable, false) + XCTAssertEqual(columns.last?.isAutoIncrement, true) + } +} diff --git a/TableProMobile/TableProMobileTests/Views/TableKindPresentationTests.swift b/TableProMobile/TableProMobileTests/Views/TableKindPresentationTests.swift new file mode 100644 index 0000000000..d8e1454f83 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Views/TableKindPresentationTests.swift @@ -0,0 +1,44 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing +import UIKit + +@Suite("Table kind presentation") +struct TableKindPresentationTests { + @Test("a materialized view has its own symbol and spoken kind, apart from a view's") + func materializedViewIsNotAView() { + #expect(TableKindPresentation.systemImage(for: .materializedView) == "square.stack.3d.up") + #expect( + TableKindPresentation.systemImage(for: .materializedView) + != TableKindPresentation.systemImage(for: .view) + ) + #expect( + TableKindPresentation.accessibilityKind(for: .materializedView) + != TableKindPresentation.accessibilityKind(for: .view) + ) + } + + @Test("a foreign table has the Mac's link symbol and its own spoken kind, apart from a table's") + func foreignTableIsNotATable() { + #expect(TableKindPresentation.systemImage(for: .foreignTable) == "link") + #expect( + TableKindPresentation.accessibilityKind(for: .foreignTable) + != TableKindPresentation.accessibilityKind(for: .table) + ) + } + + @Test("every kind names a symbol the system has and a spoken kind of its own", arguments: TableInfo.TableKind.allCases) + func everyKindIsPresented(kind: TableInfo.TableKind) { + let symbol = TableKindPresentation.systemImage(for: kind) + #expect(UIImage(systemName: symbol) != nil, "\(symbol)") + #expect(!TableKindPresentation.accessibilityKind(for: kind).isEmpty) + } + + @Test("no two kinds share a symbol or a spoken kind") + func kindsAreDistinct() { + let kinds = TableInfo.TableKind.allCases + #expect(Set(kinds.map { TableKindPresentation.systemImage(for: $0) }).count == kinds.count) + #expect(Set(kinds.map { TableKindPresentation.accessibilityKind(for: $0) }).count == kinds.count) + } +} diff --git a/TableProMobile/project.yml b/TableProMobile/project.yml index a840cd6a48..955ae14892 100644 --- a/TableProMobile/project.yml +++ b/TableProMobile/project.yml @@ -74,10 +74,18 @@ targets: - ../Plugins/MySQLDriverPlugin/MySQLLiteralSQL.swift - ../Plugins/MySQLDriverPlugin/MySQLServerVersion.swift - ../Plugins/MySQLDriverPlugin/OceanBaseColumnDefaults.swift - # Foreign key catalog read the iOS PostgreSQL driver shares with the macOS plugin, plus the - # single owner of the plugin's literal quoting that it builds its SQL with, and the version - # gate that owner projects against. + # Catalog reads the iOS PostgreSQL driver shares with the macOS plugin: the table listing, + # the materialized view columns, the index list and the Redshift table and key reads, with + # the catalog probe that gates them. Plus the single owner of the plugin's literal quoting + # that it builds its SQL with, and the version gate that owner projects against. + - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogBoolean.swift - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogForeignKeys.swift + - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift + - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift + - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLMaterializedViewColumnSource.swift + - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLTableListing.swift + - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLTextArray.swift + - ../Plugins/PostgreSQLDriverPlugin/RedshiftTableCatalog.swift - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift # libpq COPY handling the iOS PostgreSQL driver shares with the macOS plugin. diff --git a/TableProTests/Plugins/PostgreSQLCatalogCompatibilityTests.swift b/TableProTests/Plugins/PostgreSQLCatalogCompatibilityTests.swift index db8b1fbf10..57cc3e1732 100644 --- a/TableProTests/Plugins/PostgreSQLCatalogCompatibilityTests.swift +++ b/TableProTests/Plugins/PostgreSQLCatalogCompatibilityTests.swift @@ -12,11 +12,11 @@ import Foundation import TableProPluginKit import Testing -@Suite("PostgreSQLSchemaQueries.fetchTables") +@Suite("PostgreSQLTableListing.query") struct PostgreSQLFetchTablesQueryTests { @Test("Always selects base tables and views from information_schema") func alwaysIncludesBaseTables() { - let query = PostgreSQLSchemaQueries.fetchTables( + let query = PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: true, includeForeignTables: true @@ -26,7 +26,7 @@ struct PostgreSQLFetchTablesQueryTests { @Test("Omits the pg_matviews union when materialized views are unavailable") func omitsMatviewsWhenAbsent() { - let query = PostgreSQLSchemaQueries.fetchTables( + let query = PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: false, includeForeignTables: true @@ -36,7 +36,7 @@ struct PostgreSQLFetchTablesQueryTests { @Test("Includes the pg_matviews union when materialized views are available") func includesMatviewsWhenPresent() { - let query = PostgreSQLSchemaQueries.fetchTables( + let query = PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: true, includeForeignTables: false @@ -46,7 +46,7 @@ struct PostgreSQLFetchTablesQueryTests { @Test("Omits the pg_foreign_table union when foreign tables are unavailable") func omitsForeignTablesWhenAbsent() { - let query = PostgreSQLSchemaQueries.fetchTables( + let query = PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: true, includeForeignTables: false @@ -56,7 +56,7 @@ struct PostgreSQLFetchTablesQueryTests { @Test("With no optional catalogs, only the base query remains") func baseOnlyWhenNoOptionalCatalogs() { - let query = PostgreSQLSchemaQueries.fetchTables( + let query = PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: false, includeForeignTables: false diff --git a/TableProTests/Plugins/PostgreSQLCatalogSQLPinTests.swift b/TableProTests/Plugins/PostgreSQLCatalogSQLPinTests.swift new file mode 100644 index 0000000000..ecc459eced --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLCatalogSQLPinTests.swift @@ -0,0 +1,284 @@ +import Foundation +import TableProPluginKit +import Testing + +@Suite("PostgreSQL catalog SQL shared with iOS") +struct PostgreSQLCatalogSQLPinTests { + @Test("The listing iOS runs, with the optional catalogs and without comments or partitions") + func iOSListing() { + let query = PostgreSQLTableListing.query( + schema: "public", + includeMaterializedViews: true, + includeForeignTables: true, + includeComments: false, + includePartitionAwareness: false + ) + #expect(query == Self.iOSListing) + } + + @Test("The listing macOS runs first, with every arm") + func fullListing() { + let query = PostgreSQLTableListing.query( + schema: "public", + includeMaterializedViews: true, + includeForeignTables: true + ) + #expect(query == Self.fullListing) + } + + @Test("The column read with its materialized view arm") + func columnsWithMaterializedViews() { + let query = PostgreSQLSchemaQueries.columnsQuery( + schema: "public", + table: "orders", + capabilities: PostgreSQLCapabilities(serverVersion: 170_011), + includeMaterializedViews: true + ) + #expect(query == Self.columnsWithMaterializedViews) + } + + @Test("The Redshift listing and key reads") + func redshiftReads() { + #expect(RedshiftTableCatalog.listingQuery(schema: "public") == Self.redshiftListing) + #expect(RedshiftTableCatalog.keysQuery(schema: "public", table: "orders") == Self.redshiftKeys) + } + + private static let iOSListing = """ + SELECT t.table_name, t.table_type AS table_type, + NULL::text AS table_comment, + NULL::bigint AS partition_count + FROM information_schema.tables t + WHERE t.table_schema = 'public' + AND t.table_type IN ('BASE TABLE', 'VIEW') + UNION ALL + SELECT m.matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type, + NULL::text AS table_comment, + NULL::bigint AS partition_count + FROM pg_matviews m + WHERE m.schemaname = 'public' + UNION ALL + SELECT c.relname AS table_name, 'FOREIGN TABLE' AS table_type, + NULL::text AS table_comment, + NULL::bigint AS partition_count + FROM pg_foreign_table ft + JOIN pg_class c ON c.oid = ft.ftrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + ORDER BY table_name + """ + + private static let fullListing = """ + SELECT t.table_name, CASE WHEN pc.relkind = 'p' THEN 'PARTITIONED TABLE' ELSE t.table_type END AS table_type, + obj_description(pc.oid, 'pg_class') AS table_comment, + CASE WHEN pc.relkind = 'p' THEN ( + SELECT count(*) + FROM pg_catalog.pg_inherits ci + WHERE ci.inhparent = pc.oid) END AS partition_count + FROM information_schema.tables t + LEFT JOIN pg_catalog.pg_namespace pn ON pn.nspname = t.table_schema + LEFT JOIN pg_catalog.pg_class pc ON pc.relnamespace = pn.oid AND pc.relname = t.table_name + WHERE t.table_schema = 'public' + AND t.table_type IN ('BASE TABLE', 'VIEW') + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_inherits i + JOIN pg_catalog.pg_class parent ON parent.oid = i.inhparent + JOIN pg_catalog.pg_namespace parentns ON parentns.oid = parent.relnamespace + WHERE i.inhrelid = pc.oid + AND parent.relkind IN ('p', 'I') + AND EXISTS ( + SELECT 1 + FROM information_schema.tables pt + WHERE pt.table_schema = parentns.nspname + AND pt.table_name = parent.relname)) + UNION ALL + SELECT m.matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type, + obj_description(mc.oid, 'pg_class') AS table_comment, + NULL::bigint AS partition_count + FROM pg_matviews m + LEFT JOIN pg_catalog.pg_namespace mn ON mn.nspname = m.schemaname + LEFT JOIN pg_catalog.pg_class mc ON mc.relnamespace = mn.oid AND mc.relname = m.matviewname + WHERE m.schemaname = 'public' + UNION ALL + SELECT c.relname AS table_name, 'FOREIGN TABLE' AS table_type, + obj_description(c.oid, 'pg_class') AS table_comment, + NULL::bigint AS partition_count + FROM pg_foreign_table ft + JOIN pg_class c ON c.oid = ft.ftrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_inherits i + JOIN pg_catalog.pg_class parent ON parent.oid = i.inhparent + JOIN pg_catalog.pg_namespace parentns ON parentns.oid = parent.relnamespace + WHERE i.inhrelid = c.oid + AND parent.relkind IN ('p', 'I') + AND EXISTS ( + SELECT 1 + FROM information_schema.tables pt + WHERE pt.table_schema = parentns.nspname + AND pt.table_name = parent.relname)) + ORDER BY table_name + """ + + private static let columnsWithMaterializedViews = """ + SELECT + cols.column_name, + cols.data_type, + cols.is_nullable, + cols.column_default, + cols.collation_name, + cols.column_comment, + cols.udt_name, + cols.is_pk, + cols.identity_kind, + cols.generated_kind, + cols.udt_schema, + cols.generation_expression, + cols.declared_type, + cols.domain_name + FROM ( + SELECT + c.column_name AS column_name, + c.data_type AS data_type, + c.is_nullable AS is_nullable, + c.column_default AS column_default, + c.collation_name AS collation_name, + pg_catalog.col_description(rel.oid, c.ordinal_position) AS column_comment, + c.udt_name AS udt_name, + CASE WHEN pk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END AS is_pk, + a.attidentity AS identity_kind, + a.attgenerated AS generated_kind, + c.udt_schema AS udt_schema, + c.generation_expression AS generation_expression, + CASE WHEN EXISTS ( + SELECT 1 + FROM pg_catalog.pg_type dt + JOIN pg_catalog.pg_namespace dtn ON dtn.oid = dt.typnamespace + WHERE dt.oid = a.atttypid + AND dtn.nspname <> 'pg_catalog' + AND pg_catalog.pg_type_is_visible(dt.oid) + AND EXISTS ( + SELECT 1 + FROM pg_catalog.pg_depend dd + WHERE dd.classid = 'pg_catalog.pg_type'::pg_catalog.regclass + AND dd.deptype = 'e' + AND dd.objid = CASE WHEN dt.typlen = -1 AND dt.typelem <> 0 + THEN dt.typelem ELSE dt.oid END)) + THEN (SELECT pg_catalog.quote_ident(dtn.nspname) || '.' + FROM pg_catalog.pg_type dt + JOIN pg_catalog.pg_namespace dtn ON dtn.oid = dt.typnamespace + WHERE dt.oid = a.atttypid) + ELSE '' END + || pg_catalog.format_type(a.atttypid, a.atttypmod) AS declared_type, + c.domain_name AS domain_name, + c.ordinal_position AS ordinal_position + FROM information_schema.columns c + LEFT JOIN pg_catalog.pg_namespace relns + ON relns.nspname = c.table_schema + LEFT JOIN pg_catalog.pg_class rel + ON rel.relnamespace = relns.oid + AND rel.relname = c.table_name + LEFT JOIN pg_catalog.pg_attribute a + ON a.attrelid = rel.oid + AND a.attnum = 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 + AND tc.table_name = kcu.table_name + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = 'public' + AND tc.table_name = 'orders' + ) pk ON c.column_name = pk.column_name + WHERE c.table_schema = 'public' AND c.table_name = 'orders' + UNION ALL + SELECT + mva.attname AS column_name, + CASE WHEN mvt.typtype = 'd' + THEN CASE WHEN mvbt.typelem <> 0 AND mvbt.typlen = -1 THEN 'ARRAY' + WHEN mvbtn.nspname = 'pg_catalog' THEN pg_catalog.format_type(mvt.typbasetype, NULL) + ELSE 'USER-DEFINED' END + ELSE CASE WHEN mvt.typelem <> 0 AND mvt.typlen = -1 THEN 'ARRAY' + WHEN mvtn.nspname = 'pg_catalog' THEN pg_catalog.format_type(mva.atttypid, NULL) + ELSE 'USER-DEFINED' END + END AS data_type, + CASE WHEN mva.attnotnull OR (mvt.typtype = 'd' AND mvt.typnotnull) THEN 'NO' ELSE 'YES' END AS is_nullable, + NULL::text AS column_default, + CASE WHEN mvcon.nspname <> 'pg_catalog' OR mvco.collname <> 'default' THEN mvco.collname END AS collation_name, + pg_catalog.col_description(mvc.oid, mva.attnum) AS column_comment, + COALESCE(mvbt.typname, mvt.typname) AS udt_name, + 'NO' AS is_pk, + mva.attidentity AS identity_kind, + mva.attgenerated AS generated_kind, + COALESCE(mvbtn.nspname, mvtn.nspname) AS udt_schema, + NULL::text AS generation_expression, + CASE WHEN EXISTS ( + SELECT 1 + FROM pg_catalog.pg_type dt + JOIN pg_catalog.pg_namespace dtn ON dtn.oid = dt.typnamespace + WHERE dt.oid = mva.atttypid + AND dtn.nspname <> 'pg_catalog' + AND pg_catalog.pg_type_is_visible(dt.oid) + AND EXISTS ( + SELECT 1 + FROM pg_catalog.pg_depend dd + WHERE dd.classid = 'pg_catalog.pg_type'::pg_catalog.regclass + AND dd.deptype = 'e' + AND dd.objid = CASE WHEN dt.typlen = -1 AND dt.typelem <> 0 + THEN dt.typelem ELSE dt.oid END)) + THEN (SELECT pg_catalog.quote_ident(dtn.nspname) || '.' + FROM pg_catalog.pg_type dt + JOIN pg_catalog.pg_namespace dtn ON dtn.oid = dt.typnamespace + WHERE dt.oid = mva.atttypid) + ELSE '' END + || pg_catalog.format_type(mva.atttypid, mva.atttypmod) AS declared_type, + CASE WHEN mvt.typtype = 'd' THEN mvt.typname END AS domain_name, + mva.attnum AS ordinal_position + FROM pg_catalog.pg_class mvc + JOIN pg_catalog.pg_namespace mvn ON mvn.oid = mvc.relnamespace + JOIN pg_catalog.pg_attribute mva + ON mva.attrelid = mvc.oid + AND mva.attnum > 0 + AND NOT mva.attisdropped + JOIN pg_catalog.pg_type mvt ON mvt.oid = mva.atttypid + JOIN pg_catalog.pg_namespace mvtn ON mvtn.oid = mvt.typnamespace + LEFT JOIN pg_catalog.pg_type mvbt + ON mvt.typtype = 'd' + AND mvbt.oid = mvt.typbasetype + LEFT JOIN pg_catalog.pg_namespace mvbtn ON mvbtn.oid = mvbt.typnamespace + LEFT JOIN pg_catalog.pg_collation mvco ON mvco.oid = mva.attcollation + LEFT JOIN pg_catalog.pg_namespace mvcon ON mvcon.oid = mvco.collnamespace + WHERE mvc.relkind = 'm' + AND mvn.nspname = 'public' + AND mvc.relname = 'orders' + AND NOT pg_catalog.pg_is_other_temp_schema(mvn.oid) + AND (pg_catalog.pg_has_role(mvc.relowner, 'USAGE') + OR pg_catalog.has_column_privilege(mvc.oid, mva.attnum, 'SELECT, INSERT, UPDATE, REFERENCES')) + ) cols + ORDER BY cols.ordinal_position + """ + + private static let redshiftListing = """ + SELECT table_name, table_type + FROM information_schema.tables + WHERE table_schema = 'public' + ORDER BY table_name + """ + + private static let redshiftKeys = """ + SELECT + "column", + type, + distkey, + sortkey + FROM pg_table_def + WHERE schemaname = 'public' + AND tablename = 'orders' + AND (distkey = true OR sortkey != 0) + ORDER BY sortkey + """ +} diff --git a/TableProTests/Plugins/PostgreSQLFetchTablesAllSchemasTests.swift b/TableProTests/Plugins/PostgreSQLFetchTablesAllSchemasTests.swift index abdc219826..ee76fd705c 100644 --- a/TableProTests/Plugins/PostgreSQLFetchTablesAllSchemasTests.swift +++ b/TableProTests/Plugins/PostgreSQLFetchTablesAllSchemasTests.swift @@ -2,7 +2,7 @@ import Foundation import TableProPluginKit import Testing -@Suite("PostgreSQLSchemaQueries.fetchTables across every schema") +@Suite("PostgreSQLTableListing.query across every schema") struct PostgreSQLFetchTablesAllSchemasTests { private static let attempts = PostgreSQLTableListingLadder.degradableAttempts + [PostgreSQLTableListingLadder.leastCapableAttempt] @@ -11,7 +11,7 @@ struct PostgreSQLFetchTablesAllSchemasTests { _ listing: PostgreSQLTableListingScope, _ attempt: PostgreSQLTableListingAttempt = PostgreSQLTableListingLadder.degradableAttempts[0] ) -> String { - PostgreSQLSchemaQueries.fetchTables( + PostgreSQLTableListing.query( in: listing, includeMaterializedViews: attempt.includeOptionalCatalogs, includeForeignTables: attempt.includeOptionalCatalogs, diff --git a/TableProTests/Plugins/PostgreSQLFetchTablesCommentTests.swift b/TableProTests/Plugins/PostgreSQLFetchTablesCommentTests.swift index 2c19101357..f81b2fa8bb 100644 --- a/TableProTests/Plugins/PostgreSQLFetchTablesCommentTests.swift +++ b/TableProTests/Plugins/PostgreSQLFetchTablesCommentTests.swift @@ -2,11 +2,11 @@ import Foundation import TableProPluginKit import Testing -@Suite("PostgreSQLSchemaQueries.fetchTables comments") +@Suite("PostgreSQLTableListing.query comments") struct PostgreSQLFetchTablesCommentTests { @Test("Base query selects the table comment via obj_description") func baseQuerySelectsComment() { - let query = PostgreSQLSchemaQueries.fetchTables( + let query = PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: false, includeForeignTables: false @@ -19,7 +19,7 @@ struct PostgreSQLFetchTablesCommentTests { func noRungUsesToRegclass() { let attempts = PostgreSQLTableListingLadder.degradableAttempts + [PostgreSQLTableListingLadder.leastCapableAttempt] for attempt in attempts { - let query = PostgreSQLSchemaQueries.fetchTables( + let query = PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: attempt.includeOptionalCatalogs, includeForeignTables: attempt.includeOptionalCatalogs, @@ -32,7 +32,7 @@ struct PostgreSQLFetchTablesCommentTests { @Test("Comments without partition awareness still join pg_class for the relation oid") func commentsWithoutPartitionsKeepTheClassJoin() { - let query = PostgreSQLSchemaQueries.fetchTables( + let query = PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: false, includeForeignTables: false, @@ -45,7 +45,7 @@ struct PostgreSQLFetchTablesCommentTests { @Test("A materialized view's comment comes from its own relation oid") func matviewCommentUsesItsOid() { - let query = PostgreSQLSchemaQueries.fetchTables( + let query = PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: true, includeForeignTables: false @@ -56,7 +56,7 @@ struct PostgreSQLFetchTablesCommentTests { @Test("Fully degraded query does not reference pg_class/pg_namespace so the portability fallback stays minimal") func fallbackQueryStaysPortable() { - let query = PostgreSQLSchemaQueries.fetchTables( + let query = PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: false, includeForeignTables: false, @@ -70,7 +70,7 @@ struct PostgreSQLFetchTablesCommentTests { @Test("Every union branch projects a comment column so columns stay aligned") func allBranchesProjectComment() { - let query = PostgreSQLSchemaQueries.fetchTables( + let query = PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: true, includeForeignTables: true @@ -82,7 +82,7 @@ struct PostgreSQLFetchTablesCommentTests { @Test("Comment-free fallback omits obj_description but keeps the aligned comment column") func commentFreeFallbackOmitsObjDescription() { - let query = PostgreSQLSchemaQueries.fetchTables( + let query = PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: true, includeForeignTables: true, diff --git a/TableProTests/Plugins/PostgreSQLLegacyCatalogQueryTests.swift b/TableProTests/Plugins/PostgreSQLLegacyCatalogQueryTests.swift index 9339b63806..2f67b859db 100644 --- a/TableProTests/Plugins/PostgreSQLLegacyCatalogQueryTests.swift +++ b/TableProTests/Plugins/PostgreSQLLegacyCatalogQueryTests.swift @@ -40,7 +40,7 @@ struct PostgreSQLLegacyCatalogQueryTests { PostgreSQLPrincipalQueries.tableGrants(role: "r"), PostgreSQLPrincipalQueries.columnGrants(role: "r"), PostgreSQLSequenceQueries.sequenceList(schema: "public", dependentOnTable: "orders", source: .sequenceParameters), - PostgreSQLSchemaQueries.fetchTables(schema: "public", includeMaterializedViews: true, includeForeignTables: true), + PostgreSQLTableListing.query(schema: "public", includeMaterializedViews: true, includeForeignTables: true), PostgreSQLViewDefinition.catalogQuery(name: "v", schema: "public") ] for sql in queries { @@ -55,7 +55,7 @@ struct PostgreSQLLegacyCatalogQueryTests { PostgreSQLObjectQueries.triggerList(schema: hostile, table: hostile), PostgreSQLObjectQueries.routineList(schema: hostile, capabilities: Self.legacy), PostgreSQLSchemaQueries.checkConstraintsQuery(schema: hostile, table: hostile), - PostgreSQLSchemaQueries.fetchTables( + PostgreSQLTableListing.query( schema: hostile, includeMaterializedViews: true, includeForeignTables: true ) ] diff --git a/TableProTests/Plugins/PostgreSQLLiteralQuotingTests.swift b/TableProTests/Plugins/PostgreSQLLiteralQuotingTests.swift index 308680033c..b768cdfb3a 100644 --- a/TableProTests/Plugins/PostgreSQLLiteralQuotingTests.swift +++ b/TableProTests/Plugins/PostgreSQLLiteralQuotingTests.swift @@ -26,7 +26,7 @@ struct PostgreSQLLiteralQuotingTests { /// which is what makes that safe. private static func statements(schema: String, table: String) -> [String] { [ - PostgreSQLSchemaQueries.fetchTables( + PostgreSQLTableListing.query( schema: schema, includeMaterializedViews: true, includeForeignTables: true ), PostgreSQLSchemaQueries.fetchPartitions(schema: schema, table: table), diff --git a/TableProTests/Plugins/PostgreSQLPartitionFilterTests.swift b/TableProTests/Plugins/PostgreSQLPartitionFilterTests.swift index a6bb27612b..e2f5bbeb88 100644 --- a/TableProTests/Plugins/PostgreSQLPartitionFilterTests.swift +++ b/TableProTests/Plugins/PostgreSQLPartitionFilterTests.swift @@ -5,7 +5,7 @@ import Testing @Suite("PostgreSQLSchemaQueries partition awareness") struct PostgreSQLPartitionFilterTests { private func awareQuery() -> String { - PostgreSQLSchemaQueries.fetchTables( + PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: false, includeForeignTables: false @@ -41,7 +41,7 @@ struct PostgreSQLPartitionFilterTests { @Test("Partition awareness degrades independently of the optional catalogs") func partitionAwarenessDegradesIndependently() { - let query = PostgreSQLSchemaQueries.fetchTables( + let query = PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: true, includeForeignTables: true, @@ -55,7 +55,7 @@ struct PostgreSQLPartitionFilterTests { @Test("Every union branch still projects four aligned columns when partition aware") func unionBranchesStayAligned() { - let query = PostgreSQLSchemaQueries.fetchTables( + let query = PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: true, includeForeignTables: true @@ -79,7 +79,7 @@ struct PostgreSQLPartitionFilterTests { @Test("The foreign-table branch excludes partitions the same way the base branch does") func foreignTableBranchExcludesPartitions() { - let query = PostgreSQLSchemaQueries.fetchTables( + let query = PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: false, includeForeignTables: true @@ -98,7 +98,7 @@ struct PostgreSQLPartitionFilterTests { @Test("Dropping partition awareness drops the count with it and keeps the columns aligned") func unawareListingStillProjectsFourColumns() { - let query = PostgreSQLSchemaQueries.fetchTables( + let query = PostgreSQLTableListing.query( schema: "public", includeMaterializedViews: true, includeForeignTables: true, diff --git a/TableProTests/Plugins/PostgreSQLTableListingTests.swift b/TableProTests/Plugins/PostgreSQLTableListingTests.swift new file mode 100644 index 0000000000..b083012ffa --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLTableListingTests.swift @@ -0,0 +1,34 @@ +import Foundation +import TableProPluginKit +import Testing + +@Suite("PostgreSQL table listing rows") +struct PostgreSQLTableListingTests { + @Test("each listed relation type keeps its kind, and any other type reads as a table") + func relationTypes() { + let rows: [[String?]] = [ + ["mv", "MATERIALIZED VIEW", nil, nil], + ["p", "PARTITIONED TABLE", "partitioned", "3"], + ["ft", "FOREIGN TABLE", nil, nil], + ["v", "VIEW", nil, nil], + ["t", "BASE TABLE", "", nil], + ["tmp", "LOCAL TEMPORARY", nil, nil], + ["missing", nil, nil, nil] + ] + + let tables = rows.compactMap { PostgreSQLTableListing.table(fromRow: $0) } + + #expect(tables.map(\.name) == ["mv", "p", "ft", "v", "t", "tmp", "missing"]) + #expect(tables.map(\.type) == [ + "MATERIALIZED VIEW", "PARTITIONED TABLE", "FOREIGN TABLE", "VIEW", "TABLE", "TABLE", "TABLE" + ]) + #expect(tables.map(\.comment) == [nil, "partitioned", nil, nil, nil, nil, nil]) + #expect(tables.map(\.partitionCount) == [nil, 3, nil, nil, nil, nil, nil]) + } + + @Test("a row without a name is dropped") + func namelessRowIsDropped() { + #expect(PostgreSQLTableListing.table(fromRow: [nil, "VIEW"]) == nil) + #expect(PostgreSQLTableListing.table(fromRow: []) == nil) + } +} diff --git a/TableProTests/Plugins/RedshiftTableCatalogTests.swift b/TableProTests/Plugins/RedshiftTableCatalogTests.swift new file mode 100644 index 0000000000..d6dc6181c6 --- /dev/null +++ b/TableProTests/Plugins/RedshiftTableCatalogTests.swift @@ -0,0 +1,39 @@ +import Foundation +import TableProPluginKit +import Testing + +@Suite("Redshift table catalog rows") +struct RedshiftTableCatalogTests { + @Test("any listed type naming a view is a view, everything else a table") + func listingTypes() { + let rows: [[String?]] = [["orders", "BASE TABLE"], ["recent", "VIEW"], ["catalog", "SYSTEM VIEW"], ["bare", nil]] + + let tables = rows.compactMap { RedshiftTableCatalog.table(fromListingRow: $0) } + + #expect(tables.map(\.name) == ["orders", "recent", "catalog", "bare"]) + #expect(tables.map(\.type) == ["TABLE", "VIEW", "VIEW", "TABLE"]) + #expect(RedshiftTableCatalog.table(fromListingRow: [nil, "VIEW"]) == nil) + } + + @Test("a distribution key and the sort key columns in sort order") + func distributionAndSortKeys() { + let rows: [[String?]] = [ + ["id", "integer", "true", "1"], + ["created_at", "timestamp", "false", "2"], + ["region", "varchar(16)", "f", "-1"], + ["ignored", "integer", "f", "not a number"] + ] + + let keys = RedshiftTableCatalog.keys(fromRows: rows) + + #expect(keys.map(\.name) == ["DISTKEY", "SORTKEY"]) + #expect(keys.map(\.type) == ["DISTKEY", "SORTKEY"]) + #expect(keys.first?.columns == ["id"]) + #expect(keys.last?.columns == ["id", "created_at", "region"]) + } + + @Test("a table with neither key has no rows to report") + func noKeys() { + #expect(RedshiftTableCatalog.keys(fromRows: []).isEmpty) + } +} diff --git a/docs/ios/index.mdx b/docs/ios/index.mdx index 7d0ca666e3..246fe4ce0e 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -104,13 +104,15 @@ On iPhone Duo the system draws the toolbar and the tab bar down the side of the ### Browsing -A row in the list previews four of its fields, and eight where the display is wide enough. Search the table list by name. Page a table at 50, 100, 200, or 500 rows, jump to a page number, sort by a column, search text columns, and stack filters with AND or OR. A foreign key value previews the row it points at. **Table Structure** lists columns, indexes, and foreign keys, read only. +A row in the list previews four of its fields, and eight where the display is wide enough. Search the table list by name. A PostgreSQL materialized view sits under **Views** and opens read only. Page a table at 50, 100, 200, or 500 rows, jump to a page number, sort by a column, search text columns, and stack filters with AND or OR. A foreign key value previews the row it points at. + +**Table Structure** lists columns, indexes, and foreign keys, read only. An index shows its key columns and expressions in key order, then any `INCLUDE` columns and a partial index's `WHERE` clause. On Redshift the index list holds the DISTKEY and SORTKEY columns. On Redis the list holds the current database's keys. A key opens by type: a string's value, a hash's fields, a list's elements by index, a set's members, a sorted set's members and scores, a stream's entries. There is no search, sort, filter or **Table Structure** for a key. ### Editing -Tap a row to open it full screen, page between rows, edit values, toggle one to `NULL`, and save. While you edit, **Cancel** takes the place of the back button and asks before it throws away a changed value, and so does **Connections** in another tab. Inserting and deleting rows, and truncating or dropping a table, are here too. Editing needs a primary key. +Tap a row to open it full screen, page between rows, edit values, toggle one to `NULL`, and save. While you edit, **Cancel** takes the place of the back button and asks before it throws away a changed value, and so does **Connections** in another tab. Inserting and deleting rows, and truncating or dropping a table, are here too; a PostgreSQL foreign table offers neither Truncate nor Drop. Editing needs a primary key. A new row starts with every column on **DEFAULT**, which leaves that column out of the `INSERT` so the database fills it in. The badge beside a field switches it between **DEFAULT**, **NULL** and a typed value, and **NULL** is offered on nullable columns only. Generated columns are never written, and an auto-increment key stays on **DEFAULT** until you type one. diff --git a/project.yml b/project.yml index afee24ce8d..ba1a157426 100644 --- a/project.yml +++ b/project.yml @@ -587,6 +587,7 @@ targets: - Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexClauses.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLMaintenance.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLMaterializedViewColumnSource.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLPartitionBound.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLPrincipalQueries.swift @@ -598,6 +599,7 @@ targets: - Plugins/PostgreSQLDriverPlugin/PostgreSQLSequenceQueries.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLSequenceReference.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLSystemDatabases.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLTableListing.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLTableListingLadder.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLTextArray.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLTransactionStatement.swift @@ -605,6 +607,7 @@ targets: - Plugins/PostgreSQLDriverPlugin/PostgreSQLVersionedStatements.swift - Plugins/PostgreSQLDriverPlugin/RedshiftExternalSchemaQueries.swift - Plugins/PostgreSQLDriverPlugin/RedshiftSchemaQueries.swift + - Plugins/PostgreSQLDriverPlugin/RedshiftTableCatalog.swift - Plugins/KafkaDriverPlugin/KafkaApiKey.swift - Plugins/KafkaDriverPlugin/KafkaBrowseEngine.swift - Plugins/KafkaDriverPlugin/KafkaCluster.swift