Skip to content

Commit 561cecf

Browse files
authored
fix(ios): list PostgreSQL materialized views and read indexes with the plugin's catalog queries (#3074)
1 parent 96e4d49 commit 561cecf

37 files changed

Lines changed: 1640 additions & 471 deletions

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
366366
- MySQL and MariaDB column defaults on iPhone and iPad missing for DEFAULT NULL, and string defaults shown unquoted.
367367
- Structure and Create Table SQL Preview disagreeing with Save on the schema, primary key name or a SQLite foreign key.
368368
- Row import creating its new table in another schema than its rows, and PGlite primary key changes failing to save.
369+
- PostgreSQL materialized views missing on iPhone and iPad, and wrong index columns, types and predicates in Structure.
370+
- Truncate and Drop Table offered on PostgreSQL foreign tables on iPhone and iPad.
369371

370372
### Security
371373

‎Packages/TableProCore/Sources/TableProModels/QueryResult.swift‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ public struct TableInfo: Hashable, Sendable, Identifiable {
7878
case table
7979
case view
8080
case materializedView
81+
case foreignTable
8182
case systemTable
8283
case externalTable
8384
case sequence
@@ -92,7 +93,7 @@ public struct TableInfo: Hashable, Sendable, Identifiable {
9293
/// which is what happened to a MariaDB sequence.
9394
public var listSection: ListSection {
9495
switch self {
95-
case .table, .systemTable, .externalTable, .sequence: return .tables
96+
case .table, .foreignTable, .systemTable, .externalTable, .sequence: return .tables
9697
case .view, .materializedView: return .views
9798
}
9899
}
@@ -102,7 +103,7 @@ public struct TableInfo: Hashable, Sendable, Identifiable {
102103
public var allowsTruncate: Bool {
103104
switch self {
104105
case .table: return true
105-
case .view, .materializedView, .systemTable, .externalTable, .sequence: return false
106+
case .view, .materializedView, .foreignTable, .systemTable, .externalTable, .sequence: return false
106107
}
107108
}
108109

@@ -115,7 +116,7 @@ public struct TableInfo: Hashable, Sendable, Identifiable {
115116
public var allowsDrop: Bool {
116117
switch self {
117118
case .table, .sequence: return true
118-
case .view, .materializedView, .systemTable, .externalTable: return false
119+
case .view, .materializedView, .foreignTable, .systemTable, .externalTable: return false
119120
}
120121
}
121122

@@ -127,7 +128,7 @@ public struct TableInfo: Hashable, Sendable, Identifiable {
127128
/// why the Mac app withholds row editing for one and this must too.
128129
public var allowsRowEditing: Bool {
129130
switch self {
130-
case .table, .systemTable: return true
131+
case .table, .foreignTable, .systemTable: return true
131132
case .view, .materializedView, .externalTable, .sequence: return false
132133
}
133134
}
@@ -154,19 +155,25 @@ public struct IndexInfo: Sendable {
154155
public let isUnique: Bool
155156
public let isPrimary: Bool
156157
public let type: String
158+
public let includedColumns: [String]
159+
public let whereClause: String?
157160

158161
public init(
159162
name: String,
160163
columns: [String],
161164
isUnique: Bool = false,
162165
isPrimary: Bool = false,
163-
type: String = "BTREE"
166+
type: String = "BTREE",
167+
includedColumns: [String] = [],
168+
whereClause: String? = nil
164169
) {
165170
self.name = name
166171
self.columns = columns
167172
self.isUnique = isUnique
168173
self.isPrimary = isPrimary
169174
self.type = type
175+
self.includedColumns = includedColumns
176+
self.whereClause = whereClause
170177
}
171178
}
172179

@@ -260,6 +267,8 @@ public extension TableInfo {
260267
kind = .view
261268
case "MATERIALIZED VIEW":
262269
kind = .materializedView
270+
case "FOREIGN TABLE", "FOREIGN":
271+
kind = .foreignTable
263272
case "SYSTEM TABLE":
264273
kind = .systemTable
265274
case "EXTERNAL TABLE":
@@ -300,7 +309,9 @@ public extension IndexInfo {
300309
columns: plugin.columns,
301310
isUnique: plugin.isUnique,
302311
isPrimary: plugin.isPrimary,
303-
type: plugin.type
312+
type: plugin.type,
313+
includedColumns: plugin.includedColumns ?? [],
314+
whereClause: plugin.whereClause
304315
)
305316
}
306317
}

‎Packages/TableProCore/Tests/TableProModelsTests/QueryResultMappingTests.swift‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,19 @@ struct QueryResultMappingTests {
7272
#expect(!external.type.allowsRowEditing)
7373
}
7474

75+
@Test("A foreign table keeps its kind, in either spelling, and offers no Truncate or Drop Table")
76+
func mapPluginForeignTable() {
77+
let listed = TableInfo(from: PluginTableInfo(name: "ft", type: "FOREIGN TABLE"))
78+
let informationSchema = TableInfo(from: PluginTableInfo(name: "ft", type: "FOREIGN"))
79+
80+
#expect(listed.type == .foreignTable)
81+
#expect(informationSchema.type == .foreignTable)
82+
#expect(!listed.type.allowsTruncate)
83+
#expect(!listed.type.allowsDrop)
84+
#expect(listed.type.allowsRowEditing)
85+
#expect(listed.type.listSection == .tables)
86+
}
87+
7588
@Test("Maps PluginColumnInfo to ColumnInfo")
7689
func mapPluginColumnInfo() {
7790
let plugin = PluginColumnInfo(
@@ -104,6 +117,27 @@ struct QueryResultMappingTests {
104117
#expect(index.columns == ["email"])
105118
#expect(index.isUnique)
106119
#expect(!index.isPrimary)
120+
#expect(index.includedColumns.isEmpty)
121+
#expect(index.whereClause == nil)
122+
}
123+
124+
@Test("An index keeps its INCLUDE columns and its predicate apart from its key")
125+
func mapPluginIndexInfoIncludeAndPredicate() {
126+
let plugin = PluginIndexInfo(
127+
name: "t_include_partial",
128+
columns: ["a", "lower(email)"],
129+
isUnique: true,
130+
type: "BTREE",
131+
whereClause: "(a > 0)",
132+
expressions: ["lower(email)"],
133+
includedColumns: ["b"],
134+
ddlMethodAndKeys: nil,
135+
ddlWhereClause: nil
136+
)
137+
let index = IndexInfo(from: plugin)
138+
#expect(index.columns == ["a", "lower(email)"])
139+
#expect(index.includedColumns == ["b"])
140+
#expect(index.whereClause == "(a > 0)")
107141
}
108142

109143
@Test("Maps PluginForeignKeyInfo to ForeignKeyInfo")

‎Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogBoolean.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import Foundation
77

8-
enum PostgreSQLCatalogBoolean {
8+
nonisolated enum PostgreSQLCatalogBoolean {
99
private static let trueSpellings: Set<String> = ["t", "true", "yes", "on", "1"]
1010

1111
static func isTrue(_ text: String?) -> Bool {

‎Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import Foundation
77

8-
struct PostgreSQLCatalogPresence: Sendable, Equatable {
8+
nonisolated struct PostgreSQLCatalogPresence: Sendable, Equatable {
99
let hasMaterializedViews: Bool
1010
let hasForeignTables: Bool
1111
let hasSequences: Bool

‎Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Foundation
77
import os
88
import TableProPluginKit
99

10-
enum PostgreSQLIndexQueries {
10+
nonisolated enum PostgreSQLIndexQueries {
1111
private static let logger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category: "IndexQueries")
1212

1313
/// One row per index, with its key parts in key order.
@@ -152,12 +152,12 @@ enum PostgreSQLIndexQueries {
152152
}
153153
}
154154

155-
struct PostgreSQLCatalogIndexDDL: Equatable {
155+
nonisolated struct PostgreSQLCatalogIndexDDL: Equatable {
156156
let methodAndKeys: String?
157157
let whereClause: String?
158158
}
159159

160-
enum PostgreSQLIndexRow {
160+
nonisolated enum PostgreSQLIndexRow {
161161
static func index(
162162
from row: [PluginCellValue],
163163
ddl: [String: [String: PostgreSQLCatalogIndexDDL]]
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import Foundation
2+
3+
nonisolated enum PostgreSQLMaterializedViewColumnSource {
4+
static let columnName = "mva.attname"
5+
6+
static let ordinalPosition = "mva.attnum"
7+
8+
static let dataType = """
9+
CASE WHEN mvt.typtype = 'd'
10+
THEN CASE WHEN mvbt.typelem <> 0 AND mvbt.typlen = -1 THEN 'ARRAY'
11+
WHEN mvbtn.nspname = 'pg_catalog' THEN pg_catalog.format_type(mvt.typbasetype, NULL)
12+
ELSE 'USER-DEFINED' END
13+
ELSE CASE WHEN mvt.typelem <> 0 AND mvt.typlen = -1 THEN 'ARRAY'
14+
WHEN mvtn.nspname = 'pg_catalog' THEN pg_catalog.format_type(mva.atttypid, NULL)
15+
ELSE 'USER-DEFINED' END
16+
END
17+
"""
18+
19+
static let isNullable = "CASE WHEN mva.attnotnull OR (mvt.typtype = 'd' AND mvt.typnotnull) THEN 'NO' ELSE 'YES' END"
20+
21+
static let characterMaximumLength = """
22+
information_schema._pg_char_max_length(\
23+
information_schema._pg_truetypid(mva.*, mvt.*), \
24+
information_schema._pg_truetypmod(mva.*, mvt.*))
25+
"""
26+
27+
static func relation(schemaLiteral: String, table: String?) -> String {
28+
let tableFilter = table.map { "\n AND mvc.relname = \(PostgreSQLObjectQueries.quoteLiteral($0))" } ?? ""
29+
return """
30+
FROM pg_catalog.pg_class mvc
31+
JOIN pg_catalog.pg_namespace mvn ON mvn.oid = mvc.relnamespace
32+
JOIN pg_catalog.pg_attribute mva
33+
ON mva.attrelid = mvc.oid
34+
AND mva.attnum > 0
35+
AND NOT mva.attisdropped
36+
JOIN pg_catalog.pg_type mvt ON mvt.oid = mva.atttypid
37+
JOIN pg_catalog.pg_namespace mvtn ON mvtn.oid = mvt.typnamespace
38+
LEFT JOIN pg_catalog.pg_type mvbt
39+
ON mvt.typtype = 'd'
40+
AND mvbt.oid = mvt.typbasetype
41+
LEFT JOIN pg_catalog.pg_namespace mvbtn ON mvbtn.oid = mvbt.typnamespace
42+
LEFT JOIN pg_catalog.pg_collation mvco ON mvco.oid = mva.attcollation
43+
LEFT JOIN pg_catalog.pg_namespace mvcon ON mvcon.oid = mvco.collnamespace
44+
WHERE mvc.relkind = 'm'
45+
AND mvn.nspname = \(schemaLiteral)\(tableFilter)
46+
AND NOT pg_catalog.pg_is_other_temp_schema(mvn.oid)
47+
AND (pg_catalog.pg_has_role(mvc.relowner, 'USAGE')
48+
OR pg_catalog.has_column_privilege(mvc.oid, mva.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'))
49+
"""
50+
}
51+
}

‎Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift‎

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
176176

177177
private func listTables(in listing: PostgreSQLTableListingScope) async throws -> [PluginTableInfo] {
178178
func query(_ attempt: PostgreSQLTableListingAttempt) -> String {
179-
PostgreSQLSchemaQueries.fetchTables(
179+
PostgreSQLTableListing.query(
180180
in: listing,
181181
includeMaterializedViews: attempt.includeOptionalCatalogs && includesMaterializedViews(),
182182
includeForeignTables: attempt.includeOptionalCatalogs && includesForeignTables(),
@@ -198,27 +198,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
198198
}
199199

200200
guard let result else { return [] }
201-
return result.rows.compactMap { row -> PluginTableInfo? in
202-
guard let name = row[0].asText else { return nil }
203-
let typeStr = row[1].asText ?? "BASE TABLE"
204-
let type: String
205-
switch typeStr {
206-
case "PARTITIONED TABLE": type = "PARTITIONED TABLE"
207-
case "MATERIALIZED VIEW": type = "MATERIALIZED VIEW"
208-
case "FOREIGN TABLE": type = "FOREIGN TABLE"
209-
case "VIEW": type = "VIEW"
210-
default: type = "TABLE"
211-
}
212-
let comment = row[safe: 2]?.asText?.nilIfEmpty
213-
let partitionCount = row[safe: 3]?.asText.flatMap(Int.init)
214-
return PluginTableInfo(
215-
name: name,
216-
type: type,
217-
schema: row[safe: 4]?.asText,
218-
comment: comment,
219-
partitionCount: partitionCount
220-
)
221-
}
201+
return result.rows.compactMap { PostgreSQLTableListing.table(fromRow: $0.map(\.asText)) }
222202
}
223203

224204
func fetchPartitions(table: String, schema: String?) async throws -> [PluginTableInfo] {

0 commit comments

Comments
 (0)