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

- AI Explain, Optimize, and Fix Error now answer with a walkthrough in the chat panel: a before/after SQL diff you can switch between unified and split, numbered steps anchored to the lines they change, and a follow-up prompt on any step. Optimize and Fix add an Apply to Editor button that asks before replacing your query. (#1945)
- Create a connection from a project folder. Pick one from the welcome screen or File > Open Project Folder..., and TablePro reads the database settings it finds in `.env` files, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, and `appsettings.json`. A project that uses more than one engine gets a row for each. Review what it found, pick one, and the connection form opens filled in. Nothing is saved or connected until you save it. (#1959)
- Compare structure or data between two connections and generate the SQL script that brings the target in line with the source. Differences show in a tree you can group by object type or by operation, with the source and target definitions side by side. The script is editable before it runs, statements are ordered so parent tables come before the tables that reference them, and anything that would drop data is listed but held back until you allow it for that run. A read-only connection cannot be chosen as the target. Requires a Starter license. (#721)

### Changed

Expand Down
40 changes: 2 additions & 38 deletions Plugins/SQLExportPlugin/SQLExportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,44 +184,8 @@
_ tables: [PluginExportTable],
fkMap: [String: [PluginForeignKeyInfo]]
) -> [PluginExportTable] {
let nameSet = Set(tables.map { $0.name })
var indegree: [String: Int] = [:]
var children: [String: Set<String>] = [:]
for table in tables { indegree[table.name] = 0 }

for table in tables {
let fks = fkMap[table.name] ?? []
var seenParents: Set<String> = []
for fk in fks where fk.referencedTable != table.name {
guard nameSet.contains(fk.referencedTable),
!seenParents.contains(fk.referencedTable) else { continue }
seenParents.insert(fk.referencedTable)
children[fk.referencedTable, default: []].insert(table.name)
indegree[table.name, default: 0] += 1
}
}

let byName = Dictionary(uniqueKeysWithValues: tables.map { ($0.name, $0) })
var queue = tables.map { $0.name }.filter { (indegree[$0] ?? 0) == 0 }.sorted()
var ordered: [String] = []
while !queue.isEmpty {
let head = queue.removeFirst()
ordered.append(head)
for child in (children[head] ?? []).sorted() {
indegree[child] = (indegree[child] ?? 0) - 1
if indegree[child] == 0 {
queue.append(child)
}
}
}

if ordered.count < tables.count {
let remaining = tables.map { $0.name }
.filter { name in !ordered.contains(name) }
.sorted()
ordered.append(contentsOf: remaining)
}

let byName = Dictionary(tables.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first })
let ordered = ForeignKeyTopologicalSort.orderedNames(tables.map { $0.name }, foreignKeysByTable: fkMap)
return ordered.compactMap { byName[$0] }
}

Expand Down Expand Up @@ -460,7 +424,7 @@
switch element {
case .header(let header):
columns = header.columns
columnTypeNames = header.columnTypeNames ?? []

Check warning on line 427 in Plugins/SQLExportPlugin/SQLExportPlugin.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

left side of nil coalescing operator '??' has non-optional type '[String]', so the right side is never used
case .rows(let rows):
for row in rows {
rowBatch.append(row)
Expand Down
41 changes: 41 additions & 0 deletions Plugins/TableProPluginKit/ForeignKeyTopologicalSort.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation

public enum ForeignKeyTopologicalSort {
public static func orderedNames(
_ names: [String],
foreignKeysByTable: [String: [PluginForeignKeyInfo]]
) -> [String] {
let nameSet = Set(names)
var indegree: [String: Int] = [:]
var children: [String: Set<String>] = [:]
for name in names { indegree[name] = 0 }

for name in names {
var seenParents: Set<String> = []
for fk in foreignKeysByTable[name] ?? [] where fk.referencedTable != name {
guard nameSet.contains(fk.referencedTable),
!seenParents.contains(fk.referencedTable) else { continue }
seenParents.insert(fk.referencedTable)
children[fk.referencedTable, default: []].insert(name)
indegree[name, default: 0] += 1
}
}

var queue = names.filter { (indegree[$0] ?? 0) == 0 }.sorted()
var ordered: [String] = []
while !queue.isEmpty {
let head = queue.removeFirst()
ordered.append(head)
for child in (children[head] ?? []).sorted() {
indegree[child] = (indegree[child] ?? 0) - 1
if indegree[child] == 0 {
queue.append(child)
}
}
}

guard ordered.count < names.count else { return ordered }
let placed = Set(ordered)
return ordered + names.filter { !placed.contains($0) }.sorted()
}
}
2 changes: 2 additions & 0 deletions Plugins/TableProPluginKit/PluginCapabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,6 @@ public struct PluginCapabilities: OptionSet, Sendable {
public static let batchExecute = PluginCapabilities(rawValue: 1 << 10)
public static let transactions = PluginCapabilities(rawValue: 1 << 11)
public static let userManagement = PluginCapabilities(rawValue: 1 << 12)
public static let schemaCompare = PluginCapabilities(rawValue: 1 << 13)
public static let dataCompare = PluginCapabilities(rawValue: 1 << 14)
}
14 changes: 14 additions & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,20 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}

func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
if CompareSyncRunRegistry.shared.isApplying {
let alert = NSAlert()
alert.messageText = String(localized: "A sync is still running")
alert.informativeText = String(
format: String(localized: "Quitting stops the run against %@. Statements that already ran stay applied."),
CompareSyncRunRegistry.shared.applyingTargetNames.joined(separator: ", ")
)
alert.alertStyle = .critical
alert.addButton(withTitle: String(localized: "Keep Running"))
alert.addButton(withTitle: String(localized: "Stop and Quit"))
alert.buttons[1].hasDestructiveAction = true
guard alert.runModal() == .alertSecondButtonReturn else { return .terminateCancel }
}

let hasUnsaved = MainContentCoordinator.hasAnyUnsavedChanges()
if hasUnsaved {
let alert = NSAlert()
Expand Down
78 changes: 78 additions & 0 deletions TablePro/Core/Compare/CompareSyncEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// CompareSyncEndpoint.swift
// TablePro
//
// One side of a comparison. The source never changes; the target is written to.
//

import Foundation
import TableProPluginKit

internal struct CompareSyncEndpoint: Hashable, Identifiable {
internal let connectionId: UUID
internal let displayName: String
internal let databaseType: DatabaseType
internal let database: String?
internal let schema: String?
internal let safeModeLevel: SafeModeLevel
internal let color: ConnectionColor

internal var id: String {
"\(connectionId.uuidString)-\(database ?? "")-\(schema ?? "")"
}

internal var canBeWrittenTo: Bool {
safeModeLevel != .readOnly
}

internal var ineligibleAsTargetReason: String? {
guard !canBeWrittenTo else { return nil }
return String(localized: "Read-Only. Choose a different connection to write changes to.")
}

internal var qualifiedDescription: String {
var parts = [displayName]
if let database, !database.isEmpty { parts.append(database) }
if let schema, !schema.isEmpty { parts.append(schema) }
return parts.joined(separator: " / ")
}
}

internal extension CompareSyncEndpoint {
static func candidates(from connections: [DatabaseConnection]) -> [CompareSyncEndpoint] {
connections.map { connection in
CompareSyncEndpoint(
connectionId: connection.id,
displayName: connection.name,
databaseType: connection.type,
database: connection.database,
schema: nil,
safeModeLevel: connection.safeModeLevel,
color: connection.color
)
}
}
}

internal enum CompareSyncEligibility {
static func refusalReason(
for driver: any PluginDatabaseDriver,
mode: CompareSyncMode,
endpointName: String
) -> String? {
let required: PluginCapabilities = mode == .structure ? .schemaCompare : .dataCompare
guard !driver.capabilities.contains(required) else { return nil }
Comment on lines +63 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Opt supported drivers into compare capabilities

This eligibility check rejects every real database driver because the commit defines the two capability bits but never adds either bit to any PluginDatabaseDriver.capabilities implementation. I checked all driver capability declarations under Plugins; consequently both structure and data comparisons always stop with the unsupported message before doing any work.

Useful? React with 👍 / 👎.

switch mode {
case .structure:
return String(
format: String(localized: "%@ does not report structure metadata that can be compared."),
endpointName
)
case .data:
return String(
format: String(localized: "%@ does not support reading rows in key order, which data compare needs."),
endpointName
)
}
}
}
44 changes: 44 additions & 0 deletions TablePro/Core/Compare/CompareSyncEngineFamily.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// CompareSyncEngineFamily.swift
// TablePro
//
// Which pairs of database types may generate a structure sync script.
// Column data types are driver-native strings, so generating DDL for one engine
// from another engine's metadata is unsound. Comparison stays available across
// engines as an informational read; only script generation is gated.
//

import Foundation

internal enum CompareSyncEngineFamily {
internal static func canGenerateStructureScript(from source: DatabaseType, to target: DatabaseType) -> Bool {
guard source != target else { return true }
return sameFamily(source, target)
}

internal static func sameFamily(_ lhs: DatabaseType, _ rhs: DatabaseType) -> Bool {
guard lhs != rhs else { return true }
let key = [lhs.rawValue, rhs.rawValue].sorted().joined(separator: "\u{1F}")
return compatiblePairKeys.contains(key)
}

private static let compatiblePairKeys: Set<String> = {
let pairs: [[DatabaseType]] = [[.mysql, .mariadb]]
return Set(pairs.map { $0.map { $0.rawValue }.sorted().joined(separator: "\u{1F}") })
}()

internal static func structureScriptRefusal(from source: DatabaseType, to target: DatabaseType) -> String {
String(
format: String(localized: "Structure sync needs matching database types. %@ and %@ can be compared, but no script is generated."),
source.rawValue, target.rawValue
)
}

internal static func crossEngineDataWarning(from source: DatabaseType, to target: DatabaseType) -> String? {
guard source != target else { return nil }
return String(
format: String(localized: "Syncing data from %@ to %@. Value formatting can differ between engines; review the script before applying."),
source.rawValue, target.rawValue
)
}
}
Loading
Loading