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 @@ -30,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Format Query can now be undone with Cmd+Z; the formatting is applied as a single editor edit instead of clearing the undo history. (#1645)
- Format Query now formats only the selected text when a selection is active, and the full query when nothing is selected. (#1656)
- Foreign key jump arrows no longer disappear after sorting, filtering, or paginating a table, and a failed foreign key lookup is retried on the next load instead of hiding the arrows for the whole session.
- PostgreSQL foreign keys are now read from the system catalogs, so FK jump arrows appear even when the connected role does not own the referenced tables.
- Sorting a query result no longer overwrites the SQL editor text or the contents of an opened `.sql` file; the sort runs as a separate query and the editor keeps what you wrote. (#1645)
- iCloud Sync between the iPhone and Mac apps: the iOS app now uses the Production CloudKit environment, so a development build no longer syncs into a separate database the Mac never reads.
- Exports no longer fail mid-table on servers that enforce a statement time limit; the export session disables the limit and restores it afterwards, the same way mysqldump does. (#1633)
Expand Down
4 changes: 3 additions & 1 deletion Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {

let result = try await execute(query: query)

return result.rows.compactMap { row in
let foreignKeys: [PluginForeignKeyInfo] = result.rows.compactMap { row in
guard let name = row[safe: 0]?.asText,
let column = row[safe: 1]?.asText,
let refTable = row[safe: 2]?.asText,
Expand All @@ -378,6 +378,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
onUpdate: (row[safe: 6]?.asText) ?? "NO ACTION"
)
}
Self.logger.info("[fk] mysql fetchForeignKeys db=\(dbName, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(foreignKeys.count)")
return foreignKeys
}

func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] {
Expand Down
114 changes: 71 additions & 43 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -229,30 +229,43 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] {
let query = """
SELECT
tc.constraint_name,
kcu.column_name,
ccu.table_name AS referenced_table,
ccu.column_name AS referenced_column,
ccu.table_schema AS referenced_schema,
rc.delete_rule,
rc.update_rule
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
JOIN information_schema.referential_constraints rc
ON tc.constraint_name = rc.constraint_name
AND tc.constraint_schema = rc.constraint_schema
JOIN information_schema.constraint_column_usage ccu
ON rc.unique_constraint_name = ccu.constraint_name
AND rc.unique_constraint_schema = ccu.constraint_schema
WHERE tc.table_name = '\(escapeLiteral(table))'
AND tc.table_schema = '\(escapedSchema)'
AND tc.constraint_type = 'FOREIGN KEY'
ORDER BY tc.constraint_name
con.conname,
src_col.attname,
ref_cl.relname AS referenced_table,
ref_col.attname AS referenced_column,
ref_ns.nspname AS referenced_schema,
CASE con.confdeltype
WHEN 'c' THEN 'CASCADE'
WHEN 'n' THEN 'SET NULL'
WHEN 'd' THEN 'SET DEFAULT'
WHEN 'r' THEN 'RESTRICT'
ELSE 'NO ACTION'
END AS delete_rule,
CASE con.confupdtype
WHEN 'c' THEN 'CASCADE'
WHEN 'n' THEN 'SET NULL'
WHEN 'd' THEN 'SET DEFAULT'
WHEN 'r' THEN 'RESTRICT'
ELSE 'NO ACTION'
END AS update_rule
FROM pg_catalog.pg_constraint con
JOIN pg_catalog.pg_class src_cl ON src_cl.oid = con.conrelid
JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src_cl.relnamespace
JOIN pg_catalog.pg_class ref_cl ON ref_cl.oid = con.confrelid
JOIN pg_catalog.pg_namespace ref_ns ON ref_ns.oid = ref_cl.relnamespace
CROSS JOIN LATERAL unnest(con.conkey, con.confkey)
WITH ORDINALITY AS cols(src_attnum, ref_attnum, ord)
Comment on lines +256 to +257

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep foreign-key lookups working on PostgreSQL 9.3

On PostgreSQL servers older than 9.4, this new WITH ORDINALITY/multi-array unnest syntax is a parse error, so FK metadata fetches fail and the grid never marks foreign keys as fetched. The plugin still has explicit compatibility gates for older PostgreSQL versions (for example hasMaterializedViewsCatalog at 9.3 and the guarded array_position use), so this replaces a broadly compatible information_schema query with one that breaks those supported connections; add a version-gated fallback or avoid the 9.4-only syntax here and in the matching fetchAllForeignKeys query.

Useful? React with 👍 / 👎.

JOIN pg_catalog.pg_attribute src_col
ON src_col.attrelid = con.conrelid AND src_col.attnum = cols.src_attnum
JOIN pg_catalog.pg_attribute ref_col
ON ref_col.attrelid = con.confrelid AND ref_col.attnum = cols.ref_attnum
WHERE con.contype = 'f'
AND src_cl.relname = '\(escapeLiteral(table))'
AND src_ns.nspname = '\(escapedSchema)'
ORDER BY con.conname, cols.ord
"""
let result = try await execute(query: query)
return result.rows.compactMap { row -> PluginForeignKeyInfo? in
let foreignKeys: [PluginForeignKeyInfo] = result.rows.compactMap { row -> PluginForeignKeyInfo? in
guard row.count >= 7,
let name = row[0].asText,
let column = row[1].asText,
Expand All @@ -269,32 +282,47 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
onUpdate: row[6].asText ?? "NO ACTION"
)
}
Self.logger.info("[fk] postgres fetchForeignKeys schema=\(self.core.currentSchema, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(foreignKeys.count)")
return foreignKeys
}

func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] {
let query = """
SELECT
tc.table_name,
tc.constraint_name,
kcu.column_name,
ccu.table_name AS referenced_table,
ccu.column_name AS referenced_column,
ccu.table_schema AS referenced_schema,
rc.delete_rule,
rc.update_rule
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
JOIN information_schema.referential_constraints rc
ON tc.constraint_name = rc.constraint_name
AND tc.constraint_schema = rc.constraint_schema
JOIN information_schema.constraint_column_usage ccu
ON rc.unique_constraint_name = ccu.constraint_name
AND rc.unique_constraint_schema = ccu.constraint_schema
WHERE tc.table_schema = '\(escapedSchema)'
AND tc.constraint_type = 'FOREIGN KEY'
ORDER BY tc.table_name, tc.constraint_name
src_cl.relname AS table_name,
con.conname,
src_col.attname,
ref_cl.relname AS referenced_table,
ref_col.attname AS referenced_column,
ref_ns.nspname AS referenced_schema,
CASE con.confdeltype
WHEN 'c' THEN 'CASCADE'
WHEN 'n' THEN 'SET NULL'
WHEN 'd' THEN 'SET DEFAULT'
WHEN 'r' THEN 'RESTRICT'
ELSE 'NO ACTION'
END AS delete_rule,
CASE con.confupdtype
WHEN 'c' THEN 'CASCADE'
WHEN 'n' THEN 'SET NULL'
WHEN 'd' THEN 'SET DEFAULT'
WHEN 'r' THEN 'RESTRICT'
ELSE 'NO ACTION'
END AS update_rule
FROM pg_catalog.pg_constraint con
JOIN pg_catalog.pg_class src_cl ON src_cl.oid = con.conrelid
JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src_cl.relnamespace
JOIN pg_catalog.pg_class ref_cl ON ref_cl.oid = con.confrelid
JOIN pg_catalog.pg_namespace ref_ns ON ref_ns.oid = ref_cl.relnamespace
CROSS JOIN LATERAL unnest(con.conkey, con.confkey)
WITH ORDINALITY AS cols(src_attnum, ref_attnum, ord)
JOIN pg_catalog.pg_attribute src_col
ON src_col.attrelid = con.conrelid AND src_col.attnum = cols.src_attnum
JOIN pg_catalog.pg_attribute ref_col
ON ref_col.attrelid = con.confrelid AND ref_col.attnum = cols.ref_attnum
WHERE con.contype = 'f'
AND src_ns.nspname = '\(escapedSchema)'
ORDER BY src_cl.relname, con.conname, cols.ord
"""
let result = try await execute(query: query)
var grouped: [String: [PluginForeignKeyInfo]] = [:]
Expand Down
129 changes: 78 additions & 51 deletions TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ extension QueryExecutionCoordinator {
QueryExecutor.resolveRowCap(sql: sql, tabType: tabType, databaseType: parent.connection.type)
}

func parseSchemaMetadata(_ schema: SchemaResult) -> ParsedSchemaMetadata {
func parseSchemaMetadata(_ schema: FetchedTableSchema) -> ParsedSchemaMetadata {
QueryExecutor.parseSchemaMetadata(schema)
}

Expand All @@ -25,21 +25,21 @@ extension QueryExecutionCoordinator {
}
let tab = parent.tabManager.tabs[idx]
let tableRows = parent.tabSessionRegistry.tableRows(for: tab.id)
guard tab.tableContext.tableName == tableName,
!tableRows.columnDefaults.isEmpty,
!tab.tableContext.primaryKeyColumns.isEmpty else {
return false
}
let enumSetColumnNames: [String] = tableRows.columns.enumerated().compactMap { i, name in
guard i < tableRows.columnTypes.count,
tableRows.columnTypes[i].isEnumType || tableRows.columnTypes[i].isSetType else { return nil }
return name
}
if !enumSetColumnNames.isEmpty,
!enumSetColumnNames.allSatisfy({ tableRows.columnEnumValues[$0] != nil }) {
return false
}
return true
let enumsReady = enumSetColumnNames.allSatisfy { tableRows.columnEnumValues[$0] != nil }
let cached = tab.tableContext.tableName == tableName
&& !tableRows.columnDefaults.isEmpty
&& !tab.tableContext.primaryKeyColumns.isEmpty
&& tableRows.foreignKeysFetched
&& enumsReady
helpersLogger.info(
"[fk] cache check table=\(tableName, privacy: .public) defaults=\(tableRows.columnDefaults.count) pks=\(tab.tableContext.primaryKeyColumns.count) fkFetched=\(tableRows.foreignKeysFetched) fks=\(tableRows.columnForeignKeys.count) enumsReady=\(enumsReady) cached=\(cached)"
)
return cached
}

func applyPhase1Result( // swiftlint:disable:this function_parameter_count
Expand Down Expand Up @@ -85,10 +85,13 @@ extension QueryExecutionCoordinator {
}
}

var foreignKeysFetched = false

if let metadata {
columnDefaults = metadata.columnDefaults
columnForeignKeys = metadata.columnForeignKeys
columnForeignKeys = metadata.columnForeignKeys ?? [:]
columnNullable = metadata.columnNullable
foreignKeysFetched = metadata.columnForeignKeys != nil
for (col, vals) in metadata.columnEnumValues {
columnEnumValues[col] = vals
}
Expand All @@ -97,6 +100,7 @@ extension QueryExecutionCoordinator {
columnDefaults = existing.columnDefaults
columnForeignKeys = existing.columnForeignKeys
columnNullable = existing.columnNullable
foreignKeysFetched = existing.foreignKeysFetched
for (col, vals) in existing.columnEnumValues where columnEnumValues[col] == nil {
columnEnumValues[col] = vals
}
Expand All @@ -109,7 +113,8 @@ extension QueryExecutionCoordinator {
columnDefaults: columnDefaults,
columnForeignKeys: columnForeignKeys,
columnEnumValues: columnEnumValues,
columnNullable: columnNullable
columnNullable: columnNullable,
foreignKeysFetched: foreignKeysFetched
)
parent.setActiveTableRows(newTableRows, for: existingTabId)

Expand Down Expand Up @@ -239,67 +244,87 @@ extension QueryExecutionCoordinator {
tabId: UUID,
capturedGeneration: Int,
connectionType: DatabaseType,
schemaTask: Task<SchemaResult, Error>?
schemaTask: Task<FetchedTableSchema, Error>?
) {
let isNonSQL = PluginManager.shared.editorLanguage(for: connectionType) != .sql
Task(priority: .utility) { [weak self, parent] in
guard let self else { return }
guard !parent.isTearingDown else { return }

let schema = try? await schemaTask?.value
if schemaTask != nil, schema == nil {
helpersLogger.error("[fk] phase2 schema fetch failed or cancelled table=\(tableName, privacy: .public)")
}

await MainActor.run { [weak self] in
guard let self else { return }
guard capturedGeneration == parent.queryGeneration else { return }
if let schema {
applyPhase2Metadata(parsed: QueryExecutor.parseSchemaMetadata(schema), tabId: tabId)
applySchemaMetadata(schema, tabId: tabId, tableName: tableName)
}
if capturedGeneration == parent.queryGeneration {
resolveRowCount(
tableName: tableName,
tabId: tabId,
capturedGeneration: capturedGeneration,
connectionType: connectionType
)
}
resolveRowCount(
tableName: tableName,
tabId: tabId,
capturedGeneration: capturedGeneration,
connectionType: connectionType
)
}

guard !isNonSQL, let schema else { return }

let columnEnumValues = await parent.fetchEnumValues(
columnInfo: schema.columnInfo,
columnInfo: schema.columns,
tableName: tableName,
connectionType: connectionType
)
guard !columnEnumValues.isEmpty else { return }

guard !columnEnumValues.isEmpty else {
return
}
await MainActor.run { [weak self] in
guard let self else { return }
guard capturedGeneration == parent.queryGeneration else { return }
guard !Task.isCancelled else { return }
guard parent.tabManager.tabs.contains(where: { $0.id == tabId }) else { return }
let existing = parent.tabSessionRegistry.tableRows(for: tabId)
let hasNewValues = columnEnumValues.contains { key, value in
existing.columnEnumValues[key] != value
}
if hasNewValues {
parent.mutateActiveTableRows(for: tabId) { rows in
for (col, vals) in columnEnumValues {
rows.columnEnumValues[col] = vals
}
return .columnsReplaced
}
parent.tabManager.mutate(tabId: tabId) { $0.metadataVersion += 1 }
if let activeIdx = parent.tabManager.selectedTabIndex,
activeIdx < parent.tabManager.tabs.count,
parent.tabManager.tabs[activeIdx].id == tabId {
parent.dataTabDelegate?.tableViewCoordinator?.refreshForeignKeyColumns()
}
}
guard let self, !Task.isCancelled else { return }
applyEnumValues(columnEnumValues, tabId: tabId, tableName: tableName)
}
}
}

private func tabShowsTable(_ tabId: UUID, _ tableName: String) -> Bool {
parent.tabManager.tabs.contains { $0.id == tabId && $0.tableContext.tableName == tableName }
}

private func isActiveTab(_ tabId: UUID) -> Bool {
guard let activeIdx = parent.tabManager.selectedTabIndex,
activeIdx < parent.tabManager.tabs.count else { return false }
return parent.tabManager.tabs[activeIdx].id == tabId
}

private func applySchemaMetadata(_ schema: FetchedTableSchema, tabId: UUID, tableName: String) {
guard tabShowsTable(tabId, tableName) else {
helpersLogger.info("[fk] phase2 apply skipped, tab closed or table changed table=\(tableName, privacy: .public)")
return
}
applyPhase2Metadata(parsed: QueryExecutor.parseSchemaMetadata(schema), tabId: tabId)
}

private func applyEnumValues(_ values: [String: [String]], tabId: UUID, tableName: String) {
guard tabShowsTable(tabId, tableName) else { return }
let existing = parent.tabSessionRegistry.tableRows(for: tabId)
let hasNewValues = values.contains { key, value in
existing.columnEnumValues[key] != value
}
guard hasNewValues else { return }

parent.mutateActiveTableRows(for: tabId) { rows in
for (col, vals) in values {
rows.columnEnumValues[col] = vals
}
return .columnsReplaced
}
parent.tabManager.mutate(tabId: tabId) { $0.metadataVersion += 1 }
if isActiveTab(tabId) {
parent.dataTabDelegate?.tableViewCoordinator?.refreshForeignKeyColumns()
}
}

private func applyPhase2Metadata(parsed: ParsedSchemaMetadata, tabId: UUID) {
guard parent.tabManager.tabs.contains(where: { $0.id == tabId }) else { return }

Expand Down Expand Up @@ -327,11 +352,13 @@ extension QueryExecutionCoordinator {
parent.changeManager.setPrimaryKeyColumns(parsed.primaryKeyColumns)
}

if let activeIdx = parent.tabManager.selectedTabIndex,
activeIdx < parent.tabManager.tabs.count,
parent.tabManager.tabs[activeIdx].id == tabId {
let refreshed = isActiveTab(tabId)
if refreshed {
parent.dataTabDelegate?.tableViewCoordinator?.refreshForeignKeyColumns()
}
helpersLogger.info(
"[fk] phase2 applied tab=\(tabId, privacy: .public) fks=\(parsed.columnForeignKeys?.count ?? -1) defaults=\(parsed.columnDefaults.count) activeTabRefreshed=\(refreshed)"
)
}

func launchPhase2Count(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ extension QueryExecutionCoordinator {
parent.currentQueryTask = Task { [weak self, parent] in
guard let self else { return }

let schemaTask: Task<SchemaResult, Error>?
let schemaTask: Task<FetchedTableSchema, Error>?
if needsMetadataFetch, let tableName {
schemaTask = Task { try await QueryExecutor.fetchTableSchema(connectionId: connId, tableName: tableName) }
} else {
Expand Down
Loading
Loading