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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Garbled or double-encoded non-ASCII text on iOS with PostgreSQL databases not encoded in UTF-8.
- Garbled non-ASCII text when restoring a PostgreSQL SQL export into a database not encoded in UTF-8.
- Display As formats and foreign key labels lost on a rename, and kept with column layouts after deleting a connection.
- Foreign key value picker failing to read a referenced table in another MySQL database. (#2768)
- MySQL table reached through a foreign key keeping its own filters, column layout, highlight rules and Display As formats. (#2768)
- Referenced database shown as a schema in a MySQL tab title and in the foreign key picker's header. (#2768)
- Composite foreign keys listing mismatched column pairs on iOS, CockroachDB and Redshift.
- Foreign keys missing on iOS for a PostgreSQL role that does not own the table.
- Redshift foreign keys from other schemas shown on a table.
Expand All @@ -207,7 +210,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Edit View Definition** in the Database menu opening a same-named view from the browsed schema. (#2726)
- **Edit View Definition** enabled in the Database menu on a read-only connection. (#2726)
- Enum types and sequences written in front of a PostgreSQL view's DDL. (#2726)
- Display As formats lost on a rename, and kept with column layouts after deleting a connection.
- Garbled ClickHouse text whenever another value in the same result held binary data.
- Carriage returns, quotes, NUL bytes and Enum type names shown with backslash escapes on ClickHouse.
- Edits and deletes matching no row on ClickHouse tables with a binary value in the row.
Expand Down
17 changes: 11 additions & 6 deletions TablePro/Core/ObjectCopy/ObjectCopyNamespace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,22 @@ internal enum ObjectCopyNamespace {
/// What this engine calls the namespace of the objects at `endpoint`.
///
/// A schema where the engine has schemas, the database where it has databases but no schemas,
/// and nothing at all where it has neither. Derived from the two capabilities the plugin
/// registry already publishes rather than from a list of engine names, so a driver added later
/// answers without being enumerated here.
/// and nothing at all where it has neither. The choice itself is `EngineNamespaceSlot`, shared
/// with every other caller that has to make it, so a capability change cannot move one of them
/// and leave the other behind.
internal static func name(
for endpoint: DatabaseEndpoint,
supportsSchemas: Bool,
supportsDatabases: Bool
) -> String? {
if supportsSchemas { return endpoint.schema?.nilIfEmpty }
guard supportsDatabases else { return nil }
return endpoint.database.nilIfEmpty
switch EngineNamespaceSlot(supportsSchemas: supportsSchemas, supportsDatabases: supportsDatabases) {
case .schema:
return endpoint.schema?.nilIfEmpty
case .database:
return endpoint.database.nilIfEmpty
case .unqualified:
return nil
}
}

/// Whether two endpoints put their objects in the same namespace, which is what decides
Expand Down
41 changes: 41 additions & 0 deletions TablePro/Core/Plugins/EngineNamespaceSlot.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// EngineNamespaceSlot.swift
// TablePro
//
// Which container an engine qualifies its object names with.
//
// Not the same question as "which container is selected". MySQL and MariaDB have no schemas at
// all, yet `information_schema` reports the database in every column named for a schema, so a
// foreign key, a routine or a trigger comes back qualified by the database name. A value read out
// of a schema-named column therefore belongs in the database slot on those engines, and reading it
// as a schema gives the same object two identities.
//
// One answer, shared by everything that has to make the choice, so a capability change cannot move
// one of them and leave the other behind.
//

import Foundation

internal enum EngineNamespaceSlot {
case schema
case database
case unqualified

internal init(supportsSchemas: Bool, supportsDatabases: Bool) {
if supportsSchemas {
self = .schema
return
}
self = supportsDatabases ? .database : .unqualified
}
}

@MainActor
internal extension EngineNamespaceSlot {
init(databaseType: DatabaseType) {
self.init(
supportsSchemas: PluginManager.shared.supportsSchemaSwitching(for: databaseType),
supportsDatabases: PluginManager.shared.supportsDatabaseSwitching(for: databaseType)
)
}
}
44 changes: 31 additions & 13 deletions TablePro/Core/Services/Query/ForeignKeyLookupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@ enum ForeignKeyLookupService {
/// A metadata read, so it goes through `withMetadataDriver` like every other one.
static func referencedColumns(
in origin: DatabaseScope,
databaseType: DatabaseType,
reference: ForeignKeyInfo
) async throws -> [ForeignKeyLookupColumn] {
let scope = targetScope(from: origin, reference: reference)
let scope = targetScope(from: origin, databaseType: databaseType, reference: reference)
let table = reference.referencedTable
let schema = reference.referencedSchema
let schema = scope.schema
let columns = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in
try await driver.fetchColumns(table: table, schema: schema)
}
Expand Down Expand Up @@ -61,8 +62,12 @@ enum ForeignKeyLookupService {
guard let dialect = PluginManager.shared.sqlDialect(for: databaseType) else {
throw LookupFailure.noDialect
}
let scope = targetScope(from: origin, reference: reference)
let scope = targetScope(from: origin, databaseType: databaseType, reference: reference)
let table = reference.referencedTable
/// Routed to the referenced table's own container and named in full as well. The qualifier
/// costs nothing and a pooled session can still be moved out from under the read: startup
/// commands run after the pool connects, so a connection carrying `USE other` answers from
/// `other` however the scope was resolved.
let schema = reference.referencedSchema

return try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in
Expand Down Expand Up @@ -99,18 +104,31 @@ enum ForeignKeyLookupService {
/// ambient browse state: a tab stays on the database it opened, while the sidebar and other
/// windows move, and resolving the database from session state is how a tab's read lands on
/// another database.
nonisolated static func targetScope(from origin: DatabaseScope, reference: ForeignKeyInfo) -> DatabaseScope {
guard let schema = reference.referencedSchema, !schema.isEmpty else { return origin }
return DatabaseScope(connectionId: origin.connectionId, database: origin.database, schema: schema)
///
/// The read is routed to the referenced table's own container rather than left to a qualifier
/// alone, because `fetchColumns` reaches the catalog through helpers of its own that take no
/// schema: MySQL's generated-column read names `activeDatabaseName` directly, so a qualified
/// `SHOW FULL COLUMNS` would still collect generation expressions from the wrong database.
static func targetScope(
from origin: DatabaseScope,
databaseType: DatabaseType,
reference: ForeignKeyInfo
) -> DatabaseScope {
ForeignKeyTargetScope.resolve(
origin: origin, referencedSchema: reference.referencedSchema, databaseType: databaseType
)
}

nonisolated static func tableScope(from origin: DatabaseScope, reference: ForeignKeyInfo) -> TableScope {
let scope = targetScope(from: origin, reference: reference)
return TableScope(
connectionId: scope.connectionId,
database: scope.database,
schema: scope.schema,
table: reference.referencedTable
static func tableScope(
from origin: DatabaseScope,
databaseType: DatabaseType,
reference: ForeignKeyInfo
) -> TableScope {
ForeignKeyTargetScope.tableScope(
origin: origin,
referencedSchema: reference.referencedSchema,
referencedTable: reference.referencedTable,
databaseType: databaseType
)
}

Expand Down
87 changes: 87 additions & 0 deletions TablePro/Core/Services/Query/ForeignKeyTargetScope.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//
// ForeignKeyTargetScope.swift
// TablePro
//
// Where a foreign key points, in the vocabulary the rest of the app keys on.
//

import Foundation
import TableProPluginKit

/// The referenced table's own scope, resolved once and used for every question about it: which
/// connection to route the lookup through, which key its remembered settings hang on, and which
/// database and schema a tab opened on it carries.
///
/// `ForeignKeyInfo.referencedSchema` is whatever the engine's catalog puts in its schema column,
/// which on an engine with no schema layer is a database name. Every other per-table setting builds
/// its `TableScope` from the tab's own schema, which is nil there, so writing the raw value into the
/// schema slot gave one table two keys and left its foreign key label behind on a rename. The same
/// value reached tab identity, where it split a table reached through a key from the same table
/// opened from the sidebar.
internal enum ForeignKeyTargetScope {
internal static func resolve(
origin: DatabaseScope,
referencedSchema: String?,
slot: EngineNamespaceSlot
) -> DatabaseScope {
guard let referenced = referencedSchema?.nilIfEmpty else { return origin }
switch slot {
case .schema:
return DatabaseScope(
connectionId: origin.connectionId, database: origin.database, schema: referenced
)
case .database:
return DatabaseScope(
connectionId: origin.connectionId, database: referenced, schema: nil
)
case .unqualified:
return DatabaseScope(
connectionId: origin.connectionId, database: origin.database, schema: nil
)
}
}

internal static func tableScope(
origin: DatabaseScope,
referencedSchema: String?,
referencedTable: String,
slot: EngineNamespaceSlot
) -> TableScope {
let scope = resolve(origin: origin, referencedSchema: referencedSchema, slot: slot)
return TableScope(
connectionId: scope.connectionId,
database: scope.database.nilIfEmpty,
schema: scope.schema,
table: referencedTable
)
}
}

@MainActor
internal extension ForeignKeyTargetScope {
static func resolve(
origin: DatabaseScope,
referencedSchema: String?,
databaseType: DatabaseType
) -> DatabaseScope {
resolve(
origin: origin,
referencedSchema: referencedSchema,
slot: EngineNamespaceSlot(databaseType: databaseType)
)
}

static func tableScope(
origin: DatabaseScope,
referencedSchema: String?,
referencedTable: String,
databaseType: DatabaseType
) -> TableScope {
tableScope(
origin: origin,
referencedSchema: referencedSchema,
referencedTable: referencedTable,
slot: EngineNamespaceSlot(databaseType: databaseType)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,20 @@ extension MainContentCoordinator {
return
}

let currentDatabase = sourceScope.database
let targetSchema = fkInfo.referencedSchema.flatMap { $0.isEmpty ? nil : $0 } ?? sourceScope.schema
/// The referenced table's own database and schema, not the raw catalog value. An engine
/// with no schema layer names the referenced database in the slot its catalog calls a
/// schema, and carrying that through gave a table reached by a key a different identity
/// from the same table opened from the sidebar: its own filters, its own column layout, no
/// tab to reuse, and a rename that never found it.
let target = ForeignKeyTargetScope.resolve(
origin: sourceScope, referencedSchema: fkInfo.referencedSchema, databaseType: connection.type
)
let targetDatabase = target.database
let targetSchema = target.schema

if !openInNewTab,
let current = tabManager.selectedTab,
matchesFKTarget(current, table: referencedTable, database: currentDatabase, schema: targetSchema) {
matchesFKTarget(current, table: referencedTable, database: targetDatabase, schema: targetSchema) {
/// Re-filtering the tab in place is a jump like any other, so it goes on the history.
/// Clicking the reference the tab is already showing is not, and recording it would
/// stack identical entries a reader has to press Back through.
Expand All @@ -70,7 +78,7 @@ extension MainContentCoordinator {
replaceSelectedTabWithFKTarget(
referencedTable: referencedTable,
filter: filter,
databaseName: currentDatabase,
databaseName: targetDatabase,
schemaName: targetSchema
)
return
Expand All @@ -79,7 +87,7 @@ extension MainContentCoordinator {
if !openInNewTab,
let existing = openFKTargetTab(
table: referencedTable,
database: currentDatabase,
database: targetDatabase,
schema: targetSchema,
filter: filter
) {
Expand All @@ -91,7 +99,7 @@ extension MainContentCoordinator {
let payload = makeFKReferencePayload(
filter: filter,
referencedTable: referencedTable,
databaseName: currentDatabase,
databaseName: targetDatabase,
schemaName: targetSchema
)
openTabInNewWindow(payload)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,14 @@ extension MainContentCoordinator {
)
}

/// `database` names the target when the caller knows it, which a foreign key does and the
/// sidebar does not: a reference can point into another database, and taking the browse cursor
/// there opens a tab on whichever one the sidebar happens to be showing.
@discardableResult
func openTableTab(
_ tableName: String,
schema: String? = nil,
database: String? = nil,
showStructure: Bool = false,
isView: Bool = false,
objectType: TableInfo.TableType? = nil,
Expand All @@ -63,7 +67,7 @@ extension MainContentCoordinator {
}
currentDatabase = String(tableName.dropFirst(2))
} else {
currentDatabase = browseDatabaseName
currentDatabase = database?.nilIfEmpty ?? browseDatabaseName
}

let resolvedSchema = DatabaseManager.shared.resolvedSchemaName(schema, for: connectionId)
Expand Down
35 changes: 25 additions & 10 deletions TablePro/Views/Results/ForeignKeyPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,27 @@ struct ForeignKeyPickerView: View {

// MARK: - Header

/// An engine with schemas qualifies by schema as it always has. One without has no schema to
/// show, and naming the referenced database only says something when it is not the database the
/// reader is already looking at: every MySQL reference carries one, so spelling it out
/// unconditionally put `shop.users` in front of someone browsing `shop`.
private var referencedTableDisplay: String {
guard let schema = fkInfo.referencedSchema, !schema.isEmpty else { return fkInfo.referencedTable }
return "\(schema).\(fkInfo.referencedTable)"
let target = targetScope
if let schema = target.schema {
return "\(schema).\(fkInfo.referencedTable)"
}
guard target.database != scope.database, !target.database.isEmpty else {
return fkInfo.referencedTable
}
return "\(target.database).\(fkInfo.referencedTable)"
}

private var targetScope: DatabaseScope {
ForeignKeyLookupService.targetScope(from: scope, databaseType: databaseType, reference: fkInfo)
}

private var referencedTableScope: TableScope {
ForeignKeyLookupService.tableScope(from: scope, databaseType: databaseType, reference: fkInfo)
}

private var header: some View {
Expand Down Expand Up @@ -218,10 +236,7 @@ struct ForeignKeyPickerView: View {
get: { labelColumnName },
set: { newValue in
labelColumnName = newValue
ForeignKeyLabelColumnStore.shared.setLabelColumn(
newValue,
for: ForeignKeyLookupService.tableScope(from: scope, reference: fkInfo)
)
ForeignKeyLabelColumnStore.shared.setLabelColumn(newValue, for: referencedTableScope)
}
)
}
Expand Down Expand Up @@ -276,11 +291,11 @@ struct ForeignKeyPickerView: View {

private func loadColumns() async {
do {
let fetched = try await ForeignKeyLookupService.referencedColumns(in: scope, reference: fkInfo)
guard !Task.isCancelled else { return }
let stored = ForeignKeyLabelColumnStore.shared.labelColumn(
for: ForeignKeyLookupService.tableScope(from: scope, reference: fkInfo)
let fetched = try await ForeignKeyLookupService.referencedColumns(
in: scope, databaseType: databaseType, reference: fkInfo
)
guard !Task.isCancelled else { return }
let stored = ForeignKeyLabelColumnStore.shared.labelColumn(for: referencedTableScope)
labelColumnName = ForeignKeyLabelColumn.resolve(
columns: fetched,
keyColumn: fkInfo.referencedColumn,
Expand Down
Loading
Loading