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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 17 additions & 6 deletions Packages/TableProCore/Sources/TableProModels/QueryResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ public struct TableInfo: Hashable, Sendable, Identifiable {
case table
case view
case materializedView
case foreignTable
case systemTable
case externalTable
case sequence
Expand All @@ -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
}
}
Expand All @@ -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
}
}

Expand All @@ -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
}
}

Expand All @@ -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
}
}
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import Foundation

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

static func isTrue(_ text: String?) -> Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import Foundation

struct PostgreSQLCatalogPresence: Sendable, Equatable {
nonisolated struct PostgreSQLCatalogPresence: Sendable, Equatable {
let hasMaterializedViews: Bool
let hasForeignTables: Bool
let hasSequences: Bool
Expand Down
6 changes: 3 additions & 3 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLIndexQueries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]]
Expand Down
Original file line number Diff line number Diff line change
@@ -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'))
"""
}
}
24 changes: 2 additions & 22 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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] {
Expand Down
Loading
Loading