Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
fae307b
fix(ssh): expand tilde in agent socket and identityAgent paths
datlechin Apr 30, 2026
48b9e0e
fix(storage): persist group deletions before firing sync notification
datlechin Apr 30, 2026
3a096bc
fix(sql): throw when database dialect cannot be resolved
datlechin Apr 30, 2026
0b1a39b
docs: add changelog entries for ssh, storage, dialect fixes
datlechin Apr 30, 2026
d57fb83
test: delete dead LIMIT-1 codegen test methods
datlechin Apr 30, 2026
1a5a986
test: update stale icon and SF Symbol expectations
datlechin Apr 30, 2026
77cc6f9
test: add MSSQL plugin stub for unit test bundle
datlechin Apr 30, 2026
be69ff2
test: disable parallel execution in TablePro scheme
datlechin Apr 30, 2026
7a15e36
revert: re-enable parallel test execution
datlechin Apr 30, 2026
efd56ee
refactor(storage): inject UserDefaults and dependencies into GroupSto…
datlechin Apr 30, 2026
c1ac5c9
refactor(storage): inject UserDefaults into AppSettingsStorage
datlechin Apr 30, 2026
e49e3b5
refactor(storage): inject file URL and dependencies into ConnectionSt…
datlechin Apr 30, 2026
9894916
refactor(sync): inject UserDefaults into SyncMetadataStorage
datlechin Apr 30, 2026
849008d
refactor(storage): inject database URL into QueryHistoryStorage
datlechin Apr 30, 2026
b3b3c63
refactor(database): inject storage and plugin manager into DatabaseMa…
datlechin Apr 30, 2026
577bc3e
refactor(plugins): inject plugin search URLs and UserDefaults into Pl…
datlechin Apr 30, 2026
e1088a3
test: rewrite storage tests to use isolated instances
datlechin Apr 30, 2026
9398451
docs: add changelog entry for Apple-pattern singleton refactor
datlechin Apr 30, 2026
dbf11ef
refactor(storage): inject database URL into SQLFavoriteStorage
datlechin Apr 30, 2026
a9960e9
refactor(sync): inject metadata storage into SyncChangeTracker
datlechin Apr 30, 2026
783236c
test: rewrite SQLFavoriteStorage and sync-aware tests with injected i…
datlechin Apr 30, 2026
57197a3
refactor(storage): drop dead test-detection branch from QueryHistoryS…
datlechin Apr 30, 2026
83aaf13
docs: add changelog entry for SQLFavoriteStorage and SyncChangeTracke…
datlechin Apr 30, 2026
0baaaa9
fix(database): expose injected dependencies as internal for cross-fil…
datlechin Apr 30, 2026
5a04dd8
fix(storage): persist connection deletions before firing sync notific…
datlechin Apr 30, 2026
cc4040a
docs: add changelog entry for connection delete fix and tighten stora…
datlechin Apr 30, 2026
b2df379
docs: correct sync delete ordering invariant
datlechin Apr 30, 2026
fc9e0de
style(plugins): add explicit internal access modifier to userPluginsDir
datlechin Apr 30, 2026
a3af3df
refactor(storage): convert SQLFavoriteStorage to actor
datlechin Apr 30, 2026
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Native Search Field focus regression when clearing text
- PostgreSQL Create Database failed with `new collation incompatible with template database` on glibc-initialized servers (#927). Encodings, collations, and the `template1` defaults are now read from the server. `LC_CTYPE` mirrors `LC_COLLATE`, and `TEMPLATE template0` is added automatically when the chosen collation differs from `template1.datcollate`.
- Redshift Create Database emitted PostgreSQL `LC_COLLATE` syntax which is invalid Redshift grammar. Now emits `COLLATE { CASE_SENSITIVE | CASE_INSENSITIVE }`.
- Expand tilde in SSH agent socket and `IdentityAgent` paths so 1Password and similar agents work when configured with `~/...` paths.
- Persist group deletions before firing the sync notification, fixing a race that could re-upload deleted groups via iCloud.
- Refuse to generate SQL when the database dialect cannot be resolved, instead of silently emitting unquoted identifiers.

## [0.36.0] - 2026-04-27

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5ABCC5A62F43856700EAF3FC"
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/ChangeTracking/DataChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ final class DataChangeManager: ChangeManaging {
)
}

let generator = SQLStatementGenerator(
let generator = try SQLStatementGenerator(
tableName: tableName,
columns: columns,
primaryKeyColumns: primaryKeyColumns,
Expand Down
9 changes: 7 additions & 2 deletions TablePro/Core/ChangeTracking/SQLStatementGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,18 @@ struct SQLStatementGenerator {
parameterStyle: ParameterStyle? = nil,
dialect: SQLDialectDescriptor? = nil,
quoteIdentifier: ((String) -> String)? = nil
) {
) throws {
self.tableName = tableName
self.columns = columns
self.primaryKeyColumns = primaryKeyColumns
self.databaseType = databaseType
self.parameterStyle = parameterStyle ?? Self.defaultParameterStyle(for: databaseType)
self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(dialect)
if let quoteIdentifier {
self.quoteIdentifierFn = quoteIdentifier
} else {
let resolvedDialect = try resolveSQLDialect(for: databaseType, explicit: dialect)
self.quoteIdentifierFn = quoteIdentifierFromDialect(resolvedDialect)
}
}

private static func defaultParameterStyle(for databaseType: DatabaseType) -> ParameterStyle {
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Core/SSH/LibSSH2TunnelFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -508,9 +508,9 @@ internal enum LibSSH2TunnelFactory {
// Resolve agent socket: UI config > SSH config IdentityAgent > system default
let socketPath: String?
if !config.agentSocketPath.isEmpty {
socketPath = config.agentSocketPath
socketPath = SSHPathUtilities.expandTilde(config.agentSocketPath)
} else if let agentPath = configEntry?.identityAgent, !agentPath.isEmpty {
socketPath = agentPath
socketPath = SSHPathUtilities.expandTilde(agentPath)
} else {
socketPath = nil
}
Expand Down
31 changes: 19 additions & 12 deletions TablePro/Core/Services/Infrastructure/SessionStateFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
//

import Foundation
import os

private let sessionStateLogger = Logger(subsystem: "com.TablePro", category: "SessionStateFactory")

@MainActor
enum SessionStateFactory {
Expand Down Expand Up @@ -75,18 +78,22 @@ enum SessionStateFactory {
case .table:
toolbarSt.isTableTab = true
if let tableName = payload.tableName {
if payload.isPreview {
tabMgr.addPreviewTableTab(
tableName: tableName,
databaseType: connection.type,
databaseName: payload.databaseName ?? connection.database
)
} else {
tabMgr.addTableTab(
tableName: tableName,
databaseType: connection.type,
databaseName: payload.databaseName ?? connection.database
)
do {
if payload.isPreview {
try tabMgr.addPreviewTableTab(
tableName: tableName,
databaseType: connection.type,
databaseName: payload.databaseName ?? connection.database
)
} else {
try tabMgr.addTableTab(
tableName: tableName,
databaseType: connection.type,
databaseName: payload.databaseName ?? connection.database
)
}
} catch {
sessionStateLogger.error("create tab for table failed: \(error.localizedDescription, privacy: .public)")
}
if let index = tabMgr.selectedTabIndex {
tabMgr.tabs[index].tableContext.isView = payload.isView
Expand Down
6 changes: 3 additions & 3 deletions TablePro/Core/Storage/GroupStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,13 @@ final class GroupStorage {
let descendantIds = collectAllDescendantGroupIds(groupId: group.id, groups: groups)
let allIdsToDelete = descendantIds.union([group.id])

groups.removeAll { allIdsToDelete.contains($0.id) }
saveGroups(groups)

for deletedId in allIdsToDelete {
SyncChangeTracker.shared.markDeleted(.group, id: deletedId.uuidString)
}

groups.removeAll { allIdsToDelete.contains($0.id) }
saveGroups(groups)

let storage = ConnectionStorage.shared
var connections = storage.loadConnections()
var changed = false
Expand Down
33 changes: 27 additions & 6 deletions TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,25 @@
// DialectQuoteHelper.swift
// TablePro
//
// Builds an identifier-quoting closure from a SQL dialect descriptor.
//

import Foundation
import TableProPluginKit

/// Build an identifier-quoting closure from a dialect descriptor.
/// NoSQL databases (nil dialect) use identity (return name as-is).
func quoteIdentifierFromDialect(_ dialect: SQLDialectDescriptor?) -> (String) -> String {
guard let dialect else { return { $0 } }
enum SQLDialectError: Error, LocalizedError {
case dialectUnavailable(typeId: String)

var errorDescription: String? {
switch self {
case .dialectUnavailable(let typeId):
return String(
format: String(localized: "SQL dialect for %@ is not available. The plugin may not be installed or loaded."),
typeId
)
}
}
}

func quoteIdentifierFromDialect(_ dialect: SQLDialectDescriptor) -> (String) -> String {
let q = dialect.identifierQuote
if q == "[" {
return { name in
Expand All @@ -24,3 +33,15 @@ func quoteIdentifierFromDialect(_ dialect: SQLDialectDescriptor?) -> (String) ->
return "\(q)\(escaped)\(q)"
}
}

func resolveSQLDialect(
for databaseType: DatabaseType,
explicit: SQLDialectDescriptor? = nil
) throws -> SQLDialectDescriptor {
if let explicit { return explicit }
if let dialect = PluginMetadataRegistry.shared
.snapshot(forTypeId: databaseType.pluginTypeId)?.editor.sqlDialect {
return dialect
}
throw SQLDialectError.dialectUnavailable(typeId: databaseType.pluginTypeId)
}
20 changes: 13 additions & 7 deletions TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,27 @@ internal struct SQLRowToStatementConverter {
dialect: SQLDialectDescriptor? = nil,
quoteIdentifier: ((String) -> String)? = nil,
escapeStringLiteral: ((String) -> String)? = nil
) {
) throws {
self.tableName = tableName
self.columns = columns
self.primaryKeyColumn = primaryKeyColumn
self.databaseType = databaseType
self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(dialect)
self.escapeStringFn = escapeStringLiteral ?? Self.defaultEscapeFunction(dialect: dialect)

if let quoteIdentifier, let escapeStringLiteral {
self.quoteIdentifierFn = quoteIdentifier
self.escapeStringFn = escapeStringLiteral
return
}

let resolvedDialect = try resolveSQLDialect(for: databaseType, explicit: dialect)
self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(resolvedDialect)
self.escapeStringFn = escapeStringLiteral ?? Self.defaultEscapeFunction(dialect: resolvedDialect)
}

private static let maxRows = 50_000

/// Fallback escape function when no plugin driver is available.
/// Dialects with `requiresBackslashEscaping` get backslash escaping; others use ANSI SQL.
private static func defaultEscapeFunction(dialect: SQLDialectDescriptor?) -> (String) -> String {
if dialect?.requiresBackslashEscaping == true {
private static func defaultEscapeFunction(dialect: SQLDialectDescriptor) -> (String) -> String {
if dialect.requiresBackslashEscaping {
return { value in
var result = value
result = result.replacingOccurrences(of: "\\", with: "\\\\")
Expand Down
5 changes: 3 additions & 2 deletions TablePro/Models/Query/QueryTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,7 @@ struct QueryTab: Identifiable, Equatable {
databaseType: DatabaseType,
schemaName: String? = nil,
quoteIdentifier: ((String) -> String)? = nil
) -> String {
let quote = quoteIdentifier ?? quoteIdentifierFromDialect(PluginManager.shared.sqlDialect(for: databaseType))
) throws -> String {
let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize

if let pluginDriver = PluginManager.shared.queryBuildingDriver(for: databaseType),
Expand All @@ -112,6 +111,8 @@ struct QueryTab: Identifiable, Equatable {
case .bash:
return "SCAN 0 MATCH * COUNT \(pageSize)"
default:
let dialect = try resolveSQLDialect(for: databaseType)
let quote = quoteIdentifier ?? quoteIdentifierFromDialect(dialect)
let qualifiedName: String
if let schema = schemaName, !schema.isEmpty {
qualifiedName = "\(quote(schema)).\(quote(tableName))"
Expand Down
12 changes: 6 additions & 6 deletions TablePro/Models/Query/QueryTabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ final class QueryTabManager {
databaseType: DatabaseType = .mysql,
databaseName: String = "",
quoteIdentifier: ((String) -> String)? = nil
) {
) throws {
if let existingTab = tabs.first(where: {
$0.tabType == .table && $0.tableContext.tableName == tableName && $0.tableContext.databaseName == databaseName
}) {
Expand All @@ -112,7 +112,7 @@ final class QueryTabManager {
}

let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize
let query = QueryTab.buildBaseTableQuery(
let query = try QueryTab.buildBaseTableQuery(
tableName: tableName, databaseType: databaseType, quoteIdentifier: quoteIdentifier
)
var newTab = QueryTab(
Expand Down Expand Up @@ -180,9 +180,9 @@ final class QueryTabManager {
databaseType: DatabaseType = .mysql,
databaseName: String = "",
quoteIdentifier: ((String) -> String)? = nil
) {
) throws {
let pageSize = AppSettingsManager.shared.dataGrid.defaultPageSize
let query = QueryTab.buildBaseTableQuery(
let query = try QueryTab.buildBaseTableQuery(
tableName: tableName, databaseType: databaseType, quoteIdentifier: quoteIdentifier
)
var newTab = QueryTab(
Expand All @@ -207,14 +207,14 @@ final class QueryTabManager {
isView: Bool = false, databaseName: String = "",
schemaName: String? = nil, isPreview: Bool = false,
quoteIdentifier: ((String) -> String)? = nil
) -> Bool {
) throws -> Bool {
guard let selectedId = selectedTabId,
let selectedIndex = tabs.firstIndex(where: { $0.id == selectedId })
else {
return false
}

let query = QueryTab.buildBaseTableQuery(
let query = try QueryTab.buildBaseTableQuery(
tableName: tableName,
databaseType: databaseType,
schemaName: schemaName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,19 @@ extension MainContentCoordinator {
return
}

let needsQuery = tabManager.replaceTabContent(
tableName: referencedTable,
databaseType: connection.type,
isView: false,
databaseName: currentDatabase,
schemaName: targetSchema
)
let needsQuery: Bool
do {
needsQuery = try tabManager.replaceTabContent(
tableName: referencedTable,
databaseType: connection.type,
isView: false,
databaseName: currentDatabase,
schemaName: targetSchema
)
} catch {
fkNavigationLogger.error("navigateToFKReference replaceTabContent failed: \(error.localizedDescription, privacy: .public)")
return
}

if needsQuery, let (tab, tabIndex) = tabManager.selectedTabAndIndex {
setActiveTableRows(TableRows(), for: tab.id)
Expand Down
Loading
Loading