Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Explain Analyze running write statements on Read-Only connections and skipping the Alert and Safe Mode confirmation.
- Remote deletions of connections, groups, tags, SSH profiles and table favorites applied with their sync category off.
- **Local only** connections taking edits and deletions made on another device.
- Favorites tab missing starred tables from other schemas, and every starred table on Oracle, Snowflake, BigQuery and Trino.
- Export dialog offering a SQL row scope on MongoDB, Redis and other engines without SQL.
- CSV, XLSX and MQL exports ignoring a table's row filter, row limit and column choice.
- MQL export rounding 64-bit integers past 2^53 and restoring whole doubles and small 64-bit integers as 32-bit ones.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,25 @@ struct SyncRecordMapperTests {
let decoded = try #require(SyncRecordMapper.toConnection(record))
#expect(decoded.safeModeLevel == .confirmWrites)
}

@Test("An unrecognized wire value keeps the legacy read-only restriction")
func unknownWireValuePreservesReadOnly() throws {
let record = makeRawRecord(safeModeLevelRaw: "someFutureLevel", isReadOnly: true)
let decoded = try #require(SyncRecordMapper.toConnection(record))
#expect(decoded.safeModeLevel == .readOnly)
}

@Test("A rename preserves an unrecognized wire value and requires confirmation")
func renamePreservesUnknownWireValue() throws {
let record = makeRawRecord(safeModeLevelRaw: "someFutureLevel")
var connection = try #require(SyncRecordMapper.toConnection(record))
connection.name = "Renamed"

SyncRecordMapper.updateRecord(record, with: connection)

#expect(record["safeModeLevel"] as? String == "someFutureLevel")
let decoded = try #require(SyncRecordMapper.toConnection(record))
#expect(decoded.name == "Renamed")
#expect(decoded.safeModeLevel == .confirmWrites)
}
}
9 changes: 9 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -99531,6 +99531,9 @@
}
}
}
},
"No Favorites in This %@" : {

},
"No favorites match the selected environment." : {
"localizations" : {
Expand Down Expand Up @@ -162274,6 +162277,9 @@
},
"The table's primary key is not loaded, so the row cannot be written." : {

},
"The tables you starred here no longer exist." : {

},
"the target" : {
"localizations" : {
Expand Down Expand Up @@ -181005,6 +181011,9 @@
}
}
}
},
"Your favorite tables are in other %@. Switch to one to see them here." : {

},
"Your license has expired" : {
"localizations" : {
Expand Down
13 changes: 11 additions & 2 deletions TablePro/ViewModels/ERDiagramViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,31 @@ final class ERDiagramViewModel: ObservableObject {
services.databaseManager.resolvedScope(database: databaseName, schema: schemaName, for: connectionId)
}

private static let noSchemaMarker = "default"
nonisolated private static let noSchemaMarker = "default"

/// `schemaKey` is the diagram's identity, written as `database.schema` with
/// `noSchemaMarker` standing in for an engine that has no schemas. It is also the only
/// record of the schema a diagram tab was opened on, because `addERDiagramTab` writes a
/// database into the tab's table context but never a schema. Stripping the database
/// prefix rather than splitting on the separator keeps a database name that contains a
/// dot intact.
static func resolveSchemaName(fromSchemaKey schemaKey: String, databaseName: String) -> String? {
nonisolated static func resolveSchemaName(fromSchemaKey schemaKey: String, databaseName: String) -> String? {
let prefix = databaseName + "."
guard !databaseName.isEmpty, schemaKey.hasPrefix(prefix) else { return nil }
let schema = String(schemaKey.dropFirst(prefix.count))
guard !schema.isEmpty, schema != noSchemaMarker else { return nil }
return schema
}

nonisolated static func schemaKey(databaseName: String, schema: String?) -> String {
"\(databaseName).\(schema ?? noSchemaMarker)"
}

nonisolated static func schemaKeyPreserves(_ schema: String, databaseName: String) -> Bool {
resolveSchemaName(fromSchemaKey: schemaKey(databaseName: databaseName, schema: schema), databaseName: databaseName)
== schema
}

// MARK: - State

enum LoadState: Equatable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ extension FavoritesSidebarViewModel {
FavoriteTablesStorage.shared.favorites(for: connectionId).sorted { $0.name < $1.name }
}

internal func removeTableFavorite(_ table: TableInfo, database: String?) {
internal func removeTableFavorite(_ entry: FavoriteTablesStorage.FavoriteEntry) {
FavoriteTablesStorage.shared.removeFavorite(
name: table.name, schema: table.schema, database: database, connectionId: connectionId
name: entry.name, schema: entry.schema, database: entry.database, connectionId: entry.connectionId
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import AppKit
import Foundation

extension MainContentCoordinator {
/// Open (or focus) an ER Diagram tab for the current database/schema.
/// Open (or focus) an ER Diagram tab for the current database and `schema`, or the browsed
/// schema when none is named.
///
/// Resolution order:
/// 1. If another window for this connection already hosts an ER Diagram
Expand All @@ -11,10 +12,10 @@ extension MainContentCoordinator {
/// tabs yet), add the ER Diagram tab locally.
/// 3. Otherwise open a new native window tab so the current tab's content
/// (unsaved queries, filters, etc.) is preserved.
func showERDiagram() {
func showERDiagram(schema: String? = nil) {
let dbName = browseDatabaseName
let schemaName = DatabaseManager.shared.session(for: connectionId)?.browseSchema
let schemaKey = "\(dbName).\(schemaName ?? "default")"
let schemaName = schema ?? DatabaseManager.shared.session(for: connectionId)?.browseSchema
let schemaKey = ERDiagramViewModel.schemaKey(databaseName: dbName, schema: schemaName)

if let existing = Self.coordinator(forConnection: connectionId, tabMatching: {
$0.tabType == .erDiagram && $0.display.erDiagramSchemaKey == schemaKey
Expand Down
209 changes: 209 additions & 0 deletions TablePro/Views/Sidebar/FavoriteTableCatalogReader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
//
// FavoriteTableCatalogReader.swift
// TablePro
//

import Combine
import Foundation
import TableProPluginKit

internal struct FavoriteTableLoadRequest: Equatable {
internal let database: String?
internal let schemas: Set<String>

internal static let none = FavoriteTableLoadRequest(database: nil, schemas: [])
}

internal struct FavoriteTableRead: Equatable {
internal let resolution: FavoriteTableResolution
internal let loadRequest: FavoriteTableLoadRequest
}

@MainActor
internal struct FavoriteTableCatalogReader {
internal let connectionId: UUID
internal let grouping: GroupingStrategy
internal let isConnected: Bool
internal let schemaService: SchemaService
internal let treeService: DatabaseTreeMetadataService

internal init(
connectionId: UUID,
grouping: GroupingStrategy,
isConnected: Bool,
schemaService: SchemaService = .shared,
treeService: DatabaseTreeMetadataService = .shared
) {
self.connectionId = connectionId
self.grouping = grouping
self.isConnected = isConnected
self.schemaService = schemaService
self.treeService = treeService
}

internal func read(
_ entries: [FavoriteTablesStorage.FavoriteEntry],
scope: FavoriteTableBrowseScope,
search: SidebarSearch
) -> FavoriteTableRead {
let catalog = catalog(for: entries, scope: scope)
return FavoriteTableRead(
resolution: FavoriteTableResolver.resolve(entries, scope: scope, catalog: catalog, search: search),
loadRequest: loadRequest(for: entries, scope: scope, catalog: catalog)
)
}

internal func catalog(
for entries: [FavoriteTablesStorage.FavoriteEntry],
scope: FavoriteTableBrowseScope
) -> FavoriteTableCatalog {
let browsed = entries.filter(scope.contains)
guard !browsed.isEmpty else { return .empty }
let names = Set(browsed.map(\.name))
let schemas = Set(browsed.map { $0.schema ?? "" })
var sources: [FavoriteTableCatalog.Source] = []
if let loadedScope = schemaServiceScope(browsing: scope.database) {
sources += flatListSource(loadedScope: loadedScope, names: names)
sources += perSchemaSources(schemas: schemas, names: names)
}
sources += treeSources(database: scope.database, schemas: schemas, names: names)
sources += listingSource(database: scope.database, names: names)
return FavoriteTableCatalog(sources: sources)
}

internal func load(_ request: FavoriteTableLoadRequest) {
for schema in request.schemas.sorted() {
Task { await load(schema: schema, database: request.database) }
}
}

internal func rowForOpening(
_ entry: FavoriteTablesStorage.FavoriteEntry,
scope: FavoriteTableBrowseScope
) async -> FavoriteTableRow? {
if let row = row(for: entry, scope: scope), row.isVerified { return row }
await schemaService.waitForRefresh(connectionId: connectionId)
if let row = row(for: entry, scope: scope), row.isVerified { return row }
await loadList(for: entry, scope: scope)
return row(for: entry, scope: scope)
}

private func row(
for entry: FavoriteTablesStorage.FavoriteEntry,
scope: FavoriteTableBrowseScope
) -> FavoriteTableRow? {
FavoriteTableResolver.resolve(
[entry],
scope: scope,
catalog: catalog(for: [entry], scope: scope),
search: SidebarSearch("")
).rows.first
}

private func loadList(for entry: FavoriteTablesStorage.FavoriteEntry, scope: FavoriteTableBrowseScope) async {
guard isConnected, scope.listsTablesPerSchema, scope.contains(entry),
let schema = entry.schema?.nilIfEmpty,
schemaServiceScope(browsing: scope.database) != nil else { return }
if grouping == .hierarchicalSchema {
await schemaService.loadSchemaObjects(connectionId: connectionId, schema: schema, database: scope.database)
return
}
let database = scope.database ?? ""
await treeService.loadTables(connectionId: connectionId, database: database, schema: schema)
let key = DatabaseTreeMetadataService.ObjectsKey(connectionId: connectionId, database: database, schema: schema)
for await states in treeService.$tablesState.values {
guard case .loading = states[key] else { return }
}
}

private func load(schema: String, database: String?) async {
guard canStartLoad(schema: schema, database: database) else { return }
if grouping == .hierarchicalSchema {
await schemaService.loadSchemaObjects(connectionId: connectionId, schema: schema, database: database)
} else {
await treeService.loadTables(connectionId: connectionId, database: database ?? "", schema: schema)
}
}

private func loadRequest(
for entries: [FavoriteTablesStorage.FavoriteEntry],
scope: FavoriteTableBrowseScope,
catalog: FavoriteTableCatalog
) -> FavoriteTableLoadRequest {
guard isConnected, schemaServiceScope(browsing: scope.database) != nil else { return .none }
let schemas = FavoriteTableResolver.schemasNeedingLoad(entries, scope: scope, catalog: catalog)
.filter { canStartLoad(schema: $0, database: scope.database) }
guard !schemas.isEmpty else { return .none }
return FavoriteTableLoadRequest(database: scope.database, schemas: schemas)
}

private func canStartLoad(schema: String, database: String?) -> Bool {
if grouping == .hierarchicalSchema {
return schemaService.schemaObjectsNeedFetch(for: connectionId, schema: schema)
}
guard case .idle = treeService.tablesLoadState(
connectionId: connectionId, database: database ?? "", schema: schema
) else { return false }
return true
}

private func schemaServiceScope(browsing database: String?) -> DatabaseScope? {
guard let loadedScope = schemaService.loadedScope(for: connectionId),
loadedScope.database.nilIfEmpty == database else { return nil }
return loadedScope
}

private func flatListSource(loadedScope: DatabaseScope, names: Set<String>) -> [FavoriteTableCatalog.Source] {
guard grouping != .hierarchicalSchema,
case .loaded(let tables) = schemaService.state(for: connectionId) else { return [] }
let coverage: FavoriteTableCatalog.Source.Coverage = grouping == .bySchema
? .schemas(Set(tables.map { $0.schema ?? "" } + [loadedScope.schema].compactMap { $0 }))
: .everySchema
return [FavoriteTableCatalog.Source(
tables: tables.filter { names.contains($0.name) },
coverage: coverage,
isCurrent: !schemaService.isRefreshing(connectionId: connectionId)
)]
}

private func perSchemaSources(schemas: Set<String>, names: Set<String>) -> [FavoriteTableCatalog.Source] {
schemas.filter { !$0.isEmpty }.compactMap { schema in
guard case .loaded(let tables) = schemaService.schemaState(for: connectionId, schema: schema) else {
return nil
}
return FavoriteTableCatalog.Source(
tables: tables.filter { names.contains($0.name) },
coverage: .schemas([schema]),
isCurrent: schemaService.isSchemaCurrent(for: connectionId, schema: schema)
)
}
}

private func treeSources(
database: String?,
schemas: Set<String>,
names: Set<String>
) -> [FavoriteTableCatalog.Source] {
schemas.compactMap { schema in
let state = treeService.tablesLoadState(
connectionId: connectionId, database: database ?? "", schema: schema.nilIfEmpty
)
guard case .loaded(let tables) = state else { return nil }
return FavoriteTableCatalog.Source(
tables: tables.filter { names.contains($0.name) },
coverage: .schemas([schema]),
isCurrent: true
)
}
}

private func listingSource(database: String?, names: Set<String>) -> [FavoriteTableCatalog.Source] {
let state = treeService.allSchemaTablesLoadState(connectionId: connectionId, database: database ?? "")
guard case .loaded(let listing) = state else { return [] }
return [FavoriteTableCatalog.Source(
tables: listing.tables.filter { names.contains($0.name) },
coverage: .schemas(Set(listing.tables.map { $0.schema ?? "" })),
isCurrent: false
)]
}
}
Loading
Loading