diff --git a/CHANGELOG.md b/CHANGELOG.md index fa1b6db6e..4be492027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Compare & Sync between two databases, comparing tables, views, procedures, functions and triggers, or row data. Starter license. (#721) - Triggers as a sidebar section, listed per database and schema alongside Procedures and Functions. (#2383) - Read-only source viewer for procedures, functions and triggers, with Copy, Export and Open in Editor. (#2383) - Procedures, functions and triggers on MSSQL, Oracle, SQLite, ClickHouse, DuckDB, Snowflake, BigQuery, Cassandra, LibSQL, Cloudflare D1, Teradata and Dameng. (#2383) @@ -40,6 +41,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Duplicate routine rows in the flat sidebar taking the selection back to the first of them. (#2383) - MySQL triggers losing their definer, `WHEN` clause and ordering in the Structure tab. - Oracle triggers showing a header with no body in the Structure tab. +- Crash exporting two same-named tables from different schemas to SQL. (#1968) +- SQL export writing one schema's rows into another schema's table of the same name. (#1968) +- SQL export leaving out columns and foreign keys for every schema after the first. (#1968) ## [0.67.1] - 2026-08-22 diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift index 511ae9f5f..724c6536a 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift @@ -58,6 +58,7 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send .multiSchema, .cancelQuery, .materializedViews, + .dataCompare, ] } diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 616f3223c..c786e196c 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -163,6 +163,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { .alterTableDDL, .cancelQuery, .materializedViews, + .dataCompare, ] } diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift index b6be1d1af..2feb3c450 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -45,6 +45,8 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable .foreignKeyToggle, .truncateTable, .cancelQuery, + .schemaCompare, + .dataCompare, ] } diff --git a/Plugins/DamengDriverPlugin/DamengPlugin.swift b/Plugins/DamengDriverPlugin/DamengPlugin.swift index c9f62954d..6ca76bb33 100644 --- a/Plugins/DamengDriverPlugin/DamengPlugin.swift +++ b/Plugins/DamengDriverPlugin/DamengPlugin.swift @@ -150,7 +150,7 @@ final class DamengPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } var capabilities: PluginCapabilities { - [.parameterizedQueries, .transactions, .alterTableDDL, .multiSchema] + [.parameterizedQueries, .transactions, .alterTableDDL, .multiSchema, .schemaCompare, .dataCompare] } var supportsSchemas: Bool { true } diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 66e2b22ae..5eafabcfc 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -199,6 +199,8 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { .alterTableDDL, .multiSchema, .cancelQuery, + .schemaCompare, + .dataCompare, ] } diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift index 6f0add357..01496b8d9 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift @@ -51,6 +51,8 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { .foreignKeyToggle, .truncateTable, .cancelQuery, + .schemaCompare, + .dataCompare, ] if isLocalMode { base.insert(.transactions) diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 79dc7274a..928043248 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -239,6 +239,8 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { .multiSchema, .cancelQuery, .batchExecute, + .schemaCompare, + .dataCompare, ] } diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index f91e1dd6b..daf401c41 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -42,6 +42,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { .storedProcedures, .userFunctions, .userManagement, + .schemaCompare, + .dataCompare, ] } diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 6ae4d3ec7..092296cf5 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -278,6 +278,8 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { .transactions, .alterTableDDL, .multiSchema, + .schemaCompare, + .dataCompare, ] } diff --git a/Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift index 3638cc65d..8ab2086bf 100644 --- a/Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift @@ -24,6 +24,8 @@ final class CockroachPluginDriver: LibPQBackedDriver, @unchecked Sendable { .cancelQuery, .batchExecute, .materializedViews, + .schemaCompare, + .dataCompare, ] } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 50f782155..0f18aac13 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -37,7 +37,9 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { .foreignTables, .storedProcedures, .userFunctions, - .userManagement + .userManagement, + .schemaCompare, + .dataCompare ] } diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift index 0dc622d2a..83587a7a0 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift @@ -26,6 +26,8 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { .multiSchema, .cancelQuery, .batchExecute, + .schemaCompare, + .dataCompare, ] } diff --git a/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/Plugins/SQLExportPlugin/SQLExportPlugin.swift index 09ac1d705..c1592299a 100644 --- a/Plugins/SQLExportPlugin/SQLExportPlugin.swift +++ b/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -35,6 +35,14 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send var ddlFailures: [String] = [] var metadataWarnings: [String] = [] + /// A dump refers to its tables unqualified whenever every selected table lives in one + /// container, which is what makes it restorable into any database. Qualifying became necessary + /// only once an export could span two containers holding the same table name: unqualified there + /// means one schema's rows land in the other's table. The CREATE statements come back from the + /// driver verbatim and cannot be qualified without rewriting engine DDL, so a spanning export + /// says so rather than shipping a dump whose three phases disagree. + var exportSpansContainers = false + private static let logger = Logger(subsystem: "com.TablePro", category: "SQLExportPlugin") required init() { loadSettings() } @@ -68,6 +76,7 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send ) async throws -> ExportFormatResult { ddlFailures = [] metadataWarnings = [] + exportSpansContainers = false let actualDestination: URL let gzipTempURL: URL? @@ -92,10 +101,10 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send do { try writeHeader(to: fileHandle, dataSource: dataSource) - let databaseName = tables.first?.databaseName ?? "" - let columnsByTable = await prefetchColumns(databaseName: databaseName, dataSource: dataSource) - let fkMap = await prefetchForeignKeys(databaseName: databaseName, dataSource: dataSource) + let columnsByTable = await prefetchColumns(tables: tables, dataSource: dataSource) + let fkMap = await prefetchForeignKeys(tables: tables, dataSource: dataSource) let sortedTables = topologicallySort(tables, fkMap: fkMap) + noteContainerSpan(of: sortedTables) try writeDropPhase(sortedTables: sortedTables, dataSource: dataSource, to: fileHandle) try await writeDependentTypesAndSequences( @@ -153,77 +162,83 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send try fileHandle.write(contentsOf: "-- Database Type: \(dataSource.databaseTypeId)\n\n".toUTF8Data()) } + private struct ExportGroup { + let databaseName: String + let container: String? + } + + private func exportGroups(in tables: [PluginExportTable]) -> [ExportGroup] { + var seen: Set = [] + return tables + .filter { seen.insert($0.databaseName).inserted } + .map { ExportGroup(databaseName: $0.databaseName, container: $0.containerName) } + } + + private func node(for table: PluginExportTable) -> ForeignKeyTopologicalSort.Table { + ForeignKeyTopologicalSort.Table(name: table.name, schema: table.containerName) + } + + private func metadataKey(_ tableName: String, in group: ExportGroup) -> String { + ForeignKeyTopologicalSort.Table(name: tableName, schema: group.container).identifier + } + private func prefetchForeignKeys( - databaseName: String, + tables: [PluginExportTable], dataSource: any PluginExportDataSource ) async -> [String: [PluginForeignKeyInfo]] { - do { - return try await dataSource.fetchAllForeignKeys(databaseName: databaseName) - } catch { - Self.logger.warning("Failed to fetch foreign keys: \(error.localizedDescription)") + var merged: [String: [PluginForeignKeyInfo]] = [:] + var anyGroupFailed = false + for group in exportGroups(in: tables) { + do { + let fetched = try await dataSource.fetchAllForeignKeys(databaseName: group.databaseName) + for (tableName, foreignKeys) in fetched { + merged[metadataKey(tableName, in: group)] = foreignKeys + } + } catch { + Self.logger.warning("Failed to fetch foreign keys: \(error.localizedDescription)") + anyGroupFailed = true + } + } + if anyGroupFailed { metadataWarnings.append( "Could not fetch foreign keys; FK constraints may be missing from the export.") - return [:] } + return merged } private func prefetchColumns( - databaseName: String, + tables: [PluginExportTable], dataSource: any PluginExportDataSource ) async -> [String: [PluginColumnInfo]] { - do { - return try await dataSource.fetchAllColumns(databaseName: databaseName) - } catch { - Self.logger.warning("Failed to fetch columns: \(error.localizedDescription)") + var merged: [String: [PluginColumnInfo]] = [:] + var anyGroupFailed = false + for group in exportGroups(in: tables) { + do { + let fetched = try await dataSource.fetchAllColumns(databaseName: group.databaseName) + for (tableName, columns) in fetched { + merged[metadataKey(tableName, in: group)] = columns + } + } catch { + Self.logger.warning("Failed to fetch columns: \(error.localizedDescription)") + anyGroupFailed = true + } + } + if anyGroupFailed { metadataWarnings.append( "Could not fetch column metadata; identity columns and generated columns may not round-trip correctly.") - return [:] } + return merged } private func topologicallySort( _ tables: [PluginExportTable], fkMap: [String: [PluginForeignKeyInfo]] ) -> [PluginExportTable] { - let nameSet = Set(tables.map { $0.name }) - var indegree: [String: Int] = [:] - var children: [String: Set] = [:] - for table in tables { indegree[table.name] = 0 } - - for table in tables { - let fks = fkMap[table.name] ?? [] - var seenParents: Set = [] - 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) - } - - return ordered.compactMap { byName[$0] } + let byIdentifier = Dictionary( + tables.map { (node(for: $0).identifier, $0) }, + uniquingKeysWith: { first, _ in first }) + let ordered = ForeignKeyTopologicalSort.ordered(tables.map { node(for: $0) }, foreignKeysByTable: fkMap) + return ordered.compactMap { byIdentifier[$0.identifier] } } private func writeDropPhase( @@ -234,7 +249,8 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send let dropTargets = sortedTables.reversed().filter { optionValue($0, at: 1) } guard !dropTargets.isEmpty else { return } for table in dropTargets { - let tableRef = dataSource.quoteIdentifier(table.name) + let tableRef = qualifiedRef( + schema: table.databaseName, table: table.name, dataSource: dataSource) let keyword = dropStatementKeyword(for: table.tableType) try fileHandle.write(contentsOf: "\(keyword) IF EXISTS \(tableRef) CASCADE;\n".toUTF8Data()) } @@ -330,7 +346,7 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send try progress.checkCancellation() try await writeTableData( table: table, - columnInfo: columnsByTable[table.name] ?? [], + columnInfo: columnsByTable[node(for: table).identifier] ?? [], dataSource: dataSource, to: fileHandle, progress: progress) @@ -346,7 +362,7 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send ) throws { var emittedAnything = false for table in sortedTables where optionValue(table, at: 0) { - let fks = fkMap[table.name] ?? [] + let fks = fkMap[node(for: table).identifier] ?? [] let grouped = groupForeignKeysByConstraint(fks) for group in grouped { let alter = renderAddConstraintFK(table: table, group: group, dataSource: dataSource) @@ -356,7 +372,7 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send } for table in sortedTables where optionValue(table, at: 2) && table.tableType != "view" { - let columns = columnsByTable[table.name] ?? [] + let columns = columnsByTable[node(for: table).identifier] ?? [] for column in columns where column.isIdentity { let setval = renderIdentitySetval( table: table, columnName: column.name, dataSource: dataSource) @@ -400,13 +416,24 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send return orderedNames.compactMap { groups[$0] } } + private func noteContainerSpan(of tables: [PluginExportTable]) { + let containers = Set(tables.map { $0.containerName ?? "" }) + exportSpansContainers = containers.count > 1 + guard exportSpansContainers else { return } + metadataWarnings.append( + "Warning: this export spans \(containers.count) databases or schemas. Table references are " + + "qualified, but CREATE TABLE comes from the server unqualified, so restore it into the " + + "matching database or schema." + ) + } + private func qualifiedRef( schema: String, table: String, dataSource: any PluginExportDataSource ) -> String { let quotedTable = dataSource.quoteIdentifier(table) - guard !schema.isEmpty else { return quotedTable } + guard exportSpansContainers, !schema.isEmpty else { return quotedTable } return "\(dataSource.quoteIdentifier(schema)).\(quotedTable)" } @@ -453,6 +480,8 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send let generatedColumnNames = Set(columnInfo.filter { $0.isGenerated }.map { $0.name }) let usesOverridingSystemValue = columnInfo.contains { $0.identityKind == .always } + let tableRef = qualifiedRef( + schema: table.databaseName, table: table.name, dataSource: dataSource) let stream = dataSource.streamRows(table: table.name, databaseName: table.databaseName) for try await element in stream { @@ -467,7 +496,7 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send rowBatch.append(row) if rowBatch.count >= batchSize { try writeInsertStatements( - tableName: table.name, + tableRef: tableRef, columns: columns, columnTypeNames: columnTypeNames, rows: rowBatch, @@ -487,7 +516,7 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send if !rowBatch.isEmpty { try writeInsertStatements( - tableName: table.name, + tableRef: tableRef, columns: columns, columnTypeNames: columnTypeNames, rows: rowBatch, @@ -507,7 +536,7 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send } private func writeInsertStatements( - tableName: String, + tableRef: String, columns: [String], columnTypeNames: [String], rows: [[PluginCellValue]], @@ -523,7 +552,6 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send } guard !includedColumnIndices.isEmpty else { return } - let tableRef = dataSource.quoteIdentifier(tableName) let quotedColumns = includedColumnIndices .map { dataSource.quoteIdentifier(columns[$0]) } .joined(separator: ", ") diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index ff5ed0e7f..11e4e97fa 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -551,6 +551,8 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { .truncateTable, .cancelQuery, .batchExecute, + .schemaCompare, + .dataCompare, ] } diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift index 8af541d4e..17adb1c24 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver.swift @@ -28,7 +28,16 @@ final class SnowflakePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } var capabilities: PluginCapabilities { - [.multiSchema, .transactions, .truncateTable, .cancelQuery, .parameterizedQueries, .alterTableDDL] + [ + .multiSchema, + .transactions, + .truncateTable, + .cancelQuery, + .parameterizedQueries, + .alterTableDDL, + .schemaCompare, + .dataCompare, + ] } func cancelQuery() throws { diff --git a/Plugins/TableProPluginKit/ForeignKeyTopologicalSort.swift b/Plugins/TableProPluginKit/ForeignKeyTopologicalSort.swift new file mode 100644 index 000000000..f77beb8bb --- /dev/null +++ b/Plugins/TableProPluginKit/ForeignKeyTopologicalSort.swift @@ -0,0 +1,81 @@ +import Foundation + +public enum ForeignKeyTopologicalSort { + /// One node of the dependency graph. Two tables that share a name in different schemas are + /// two nodes, so no ordering can collapse them into one. + public struct Table: Hashable, Sendable { + public let name: String + public let schema: String? + + public init(name: String, schema: String? = nil) { + self.name = name + self.schema = (schema?.isEmpty ?? true) ? nil : schema + } + + public var identifier: String { + guard let schema else { return name } + return "\(schema).\(name)" + } + } + + /// Orders `tables` so a parent precedes every child that references it. Identity is + /// `Table.identifier` throughout: `foreignKeysByTable` is keyed by it, and a foreign key + /// that names no `referencedSchema` points inside the referencing table's own schema. + /// A dependency cycle falls back to the tables the traversal could not place, in identifier + /// order, so the result holds every distinct input table exactly once. + public static func ordered( + _ tables: [Table], + foreignKeysByTable: [String: [PluginForeignKeyInfo]], + childrenFirst: Bool = false + ) -> [Table] { + let nodes = distinct(tables) + guard nodes.count > 1 else { return nodes } + + let byIdentifier = Dictionary(nodes.map { ($0.identifier, $0) }, uniquingKeysWith: { first, _ in first }) + var indegree: [String: Int] = [:] + var children: [String: Set] = [:] + for node in nodes { indegree[node.identifier] = 0 } + + for node in nodes { + let identifier = node.identifier + var seenParents: Set = [] + for foreignKey in foreignKeysByTable[identifier] ?? [] { + let parent = Table( + name: foreignKey.referencedTable, + schema: foreignKey.referencedSchema ?? node.schema + ).identifier + guard parent != identifier, + byIdentifier[parent] != nil, + seenParents.insert(parent).inserted else { continue } + children[parent, default: []].insert(identifier) + indegree[identifier, default: 0] += 1 + } + } + + var queue = nodes.map { $0.identifier }.filter { (indegree[$0] ?? 0) == 0 }.sorted() + var placed: [String] = [] + while !queue.isEmpty { + let head = queue.removeFirst() + placed.append(head) + for child in (children[head] ?? []).sorted() { + indegree[child] = (indegree[child] ?? 0) - 1 + if indegree[child] == 0 { + queue.append(child) + } + } + } + + if placed.count < nodes.count { + let settled = Set(placed) + placed += nodes.map { $0.identifier }.filter { !settled.contains($0) }.sorted() + } + + let resolved = placed.compactMap { byIdentifier[$0] } + return childrenFirst ? resolved.reversed() : resolved + } + + private static func distinct(_ tables: [Table]) -> [Table] { + var seen: Set = [] + return tables.filter { seen.insert($0.identifier).inserted } + } +} diff --git a/Plugins/TableProPluginKit/PluginCapabilities.swift b/Plugins/TableProPluginKit/PluginCapabilities.swift index 99ce00baa..8283d295f 100644 --- a/Plugins/TableProPluginKit/PluginCapabilities.swift +++ b/Plugins/TableProPluginKit/PluginCapabilities.swift @@ -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) } diff --git a/Plugins/TableProPluginKit/PluginExportTypes.swift b/Plugins/TableProPluginKit/PluginExportTypes.swift index 69d4e255c..4ac92995b 100644 --- a/Plugins/TableProPluginKit/PluginExportTypes.swift +++ b/Plugins/TableProPluginKit/PluginExportTypes.swift @@ -8,12 +8,29 @@ import Foundation public struct PluginExportTable: Sendable { public let name: String public let databaseName: String + public let schema: String? public let tableType: String public let optionValues: [Bool] + public init( + name: String, + databaseName: String, + tableType: String, + optionValues: [Bool] = [], + schema: String? + ) { + self.name = name + self.databaseName = databaseName + self.schema = schema + self.tableType = tableType + self.optionValues = optionValues + } + + @_disfavoredOverload public init(name: String, databaseName: String, tableType: String, optionValues: [Bool] = []) { self.name = name self.databaseName = databaseName + self.schema = nil self.tableType = tableType self.optionValues = optionValues } @@ -21,6 +38,15 @@ public struct PluginExportTable: Sendable { public var qualifiedName: String { databaseName.isEmpty ? name : "\(databaseName).\(name)" } + + /// The container this table was grouped under: the export tree's group name where it named + /// one, and the driver's own schema where it did not. Two tables in one export carry the + /// same value only when they really sit together, so this is what qualifies a bare name. + public var containerName: String? { + guard databaseName.isEmpty else { return databaseName } + guard let schema, !schema.isEmpty else { return nil } + return schema + } } public struct PluginExportOptionColumn: Sendable, Identifiable { diff --git a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift index e809de191..b82a8e859 100644 --- a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift +++ b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift @@ -125,7 +125,7 @@ final class TeradataPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var supportsTransactions: Bool { true } var capabilities: PluginCapabilities { - [.cancelQuery, .transactions, .alterTableDDL] + [.cancelQuery, .transactions, .alterTableDDL, .dataCompare] } init(config: DriverConnectionConfig) { diff --git a/Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift b/Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift index 7a5fc59d3..152c642ec 100644 --- a/Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift +++ b/Plugins/TrinoDriverPlugin/TrinoPluginDriver.swift @@ -24,7 +24,7 @@ final class TrinoPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } var capabilities: PluginCapabilities { - [.multiSchema, .cancelQuery, .materializedViews] + [.multiSchema, .cancelQuery, .materializedViews, .dataCompare] } func cacheColumnTypes(_ types: [String: String], key: String) { diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index ca7074d2f..b3eefca19 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -79,6 +79,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { WindowOpener.shared.setConnectionFormPresenter { ConnectionFormWindowController.present($0) } WindowOpener.shared.setIntegrationsActivityPresenter { IntegrationsActivityWindowController.present() } WindowOpener.shared.setSettingsPresenter { SettingsWindowController.present(pane: $0) } + WindowOpener.shared.setCompareSyncPresenter { CompareSyncWindowController.present(prefillSource: $0) } KeyRepeatFilter.shared.install() let syncSettings = AppSettingsStorage.shared.loadSync() let passwordSyncExpected = syncSettings.enabled && syncSettings.syncConnections && syncSettings.syncPasswords @@ -151,6 +152,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 { /// Quitting can be asked for from outside the app, so this alert has to come forward on diff --git a/TablePro/Core/Compare/CompareMetadataService.swift b/TablePro/Core/Compare/CompareMetadataService.swift new file mode 100644 index 000000000..4c2810749 --- /dev/null +++ b/TablePro/Core/Compare/CompareMetadataService.swift @@ -0,0 +1,292 @@ +// +// CompareMetadataService.swift +// TablePro +// +// Every driver call the comparison makes. +// +// It exists because the session used to reach `DatabaseManager.shared.driver(for:)` +// directly, which hands back the connection's single live interactive driver. +// That driver carries mutable position, so a read taken without +// `SessionDriverGate` can interleave with a tab's query or land on whichever +// database that tab last switched to, and it skips `metadataRoute`, which is +// what keeps an embedded engine from being handed a second, empty instance. +// `withMetadataDriver(scope:)` is the one route that applies both. +// +// The closure hands back the app-level `DatabaseDriver`; the comparison needs +// the plugin transfer types, so the downcast happens inside the gated closure, +// the same shape `DatabaseManager+Schema` already uses. +// + +import Foundation +import os +import TableProPluginKit + +/// One table's raw metadata, in transfer types so it can cross the gated closure. Conversion to +/// `TableStructureSnapshot` happens on the caller's side, where the editable definition types live. +internal struct TableStructureRead: Sendable { + internal let table: PluginTableInfo + internal let columns: [PluginColumnInfo] + internal let indexes: [PluginIndexInfo] + internal let foreignKeys: [PluginForeignKeyInfo] + internal let metadata: PluginTableMetadata? + internal let failure: String? + + internal var snapshot: TableStructureSnapshot? { + guard failure == nil else { return nil } + return TableStructureSnapshot.from( + table: table, columns: columns, indexes: indexes, foreignKeys: foreignKeys, metadata: metadata + ) + } +} + +internal struct RoutineSourceRead: Sendable { + internal let name: String + internal let kind: CompareObjectKind + internal let schema: String? + internal let signature: String? + internal let source: String +} + +@MainActor +internal struct CompareMetadataService { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "CompareMetadataService") + + private let manager: DatabaseManager + + internal init(manager: DatabaseManager = .shared) { + self.manager = manager + } + + // MARK: - Scope discovery + + internal func databases(for connection: DatabaseConnection) async throws -> [String] { + try await manager.ensureConnected(connection) + let scope = DatabaseScope(connectionId: connection.id, database: connection.database ?? "", schema: nil) + return try await manager.withMetadataDriver(scope: scope) { driver in + try await driver.fetchDatabases() + } + } + + internal func schemas(for endpoint: CompareSyncEndpoint, connection: DatabaseConnection) async throws -> [String] { + try await manager.ensureConnected(connection) + return try await manager.withMetadataDriver(scope: endpoint.scope) { driver in + try await driver.fetchSchemas() + } + } + + // MARK: - Capability + + internal func refusalReason( + for endpoint: CompareSyncEndpoint, + connection: DatabaseConnection, + mode: CompareSyncMode + ) async throws -> String? { + try await manager.ensureConnected(connection) + let name = endpoint.qualifiedDescription + return try await manager.withMetadataDriver(scope: endpoint.scope) { driver in + guard let plugin = Self.pluginDriver(from: driver) else { + return String( + format: String(localized: "%@ cannot be compared, because its driver does not expose metadata."), + name + ) + } + return CompareSyncEligibility.refusalReason(for: plugin, mode: mode, endpointName: name) + } + } + + // MARK: - Structure + + /// One object's failure is that object's, not the comparison's. A single unreadable table used + /// to abort the whole run, which is why `TableDiffResult.comparisonError` was read by the UI and + /// written by nothing. + internal func tableReads( + for endpoint: CompareSyncEndpoint, + connection: DatabaseConnection, + includeViews: Bool + ) async throws -> [TableStructureRead] { + try await manager.ensureConnected(connection) + let schema = endpoint.schema + let concurrency = Self.metadataConcurrency(for: endpoint.databaseType) + + return try await manager.withMetadataDriver(scope: endpoint.scope) { driver in + guard let plugin = Self.pluginDriver(from: driver) else { return [] } + let tables = try await plugin.fetchTables(schema: schema).filter { table in + let kind = CompareTableKindClassifier.kind(of: table) + return kind == .table || includeViews + } + + return try await Self.map(tables, concurrency: concurrency) { table in + await Self.read(table: table, schema: table.schema ?? schema, using: plugin) + } + } + } + + /// `fetchRoutines` supersedes the old per-kind pair and carries `identity`, which is what + /// `fetchRoutineDDL` needs to address an overloaded routine again. A routine whose DDL cannot + /// be read is still listed, with an empty definition, so it shows as present rather than + /// vanishing from the comparison. + internal func routineReads( + for endpoint: CompareSyncEndpoint, + connection: DatabaseConnection + ) async throws -> [RoutineSourceRead] { + try await manager.ensureConnected(connection) + let schema = endpoint.schema + return try await manager.withMetadataDriver(scope: endpoint.scope) { driver in + guard let plugin = Self.pluginDriver(from: driver) else { return [] } + let routines = (try? await plugin.fetchRoutines(schema: schema)) ?? [] + var reads: [RoutineSourceRead] = [] + for routine in routines { + try Task.checkCancellation() + var source = routine.definition ?? "" + if source.isEmpty { + source = (try? await plugin.fetchRoutineDDL(routine)) ?? "" + } + reads.append(RoutineSourceRead( + name: routine.name, + kind: routine.kind == .procedure ? .procedure : .function, + schema: routine.schema ?? schema, + signature: routine.argumentSignature, + source: source + )) + } + return reads + } + } + + /// There is no schema-wide trigger fetch on the driver protocol, so the tables the structure + /// read already listed are the ones asked. A trigger on a table that is not in scope is not in + /// scope either. + internal func triggerReads( + for endpoint: CompareSyncEndpoint, + connection: DatabaseConnection, + tables: [String] + ) async throws -> [RoutineSourceRead] { + try await manager.ensureConnected(connection) + let schema = endpoint.schema + return try await manager.withMetadataDriver(scope: endpoint.scope) { driver in + guard let plugin = Self.pluginDriver(from: driver) else { return [] } + var reads: [RoutineSourceRead] = [] + for table in tables { + try Task.checkCancellation() + guard let triggers = try? await plugin.fetchTriggers(table: table, schema: schema) else { continue } + reads += triggers.map { trigger in + RoutineSourceRead( + name: trigger.name, + kind: .trigger, + schema: trigger.schema ?? schema, + signature: trigger.table ?? table, + source: trigger.definition ?? trigger.statement + ) + } + } + return reads + } + } + + internal func viewDefinitions( + for endpoint: CompareSyncEndpoint, + connection: DatabaseConnection, + views: [PluginTableInfo] + ) async throws -> [RoutineSourceRead] { + try await manager.ensureConnected(connection) + let schema = endpoint.schema + return try await manager.withMetadataDriver(scope: endpoint.scope) { driver in + guard let plugin = Self.pluginDriver(from: driver) else { return [] } + var reads: [RoutineSourceRead] = [] + for view in views { + try Task.checkCancellation() + let definition = try? await plugin.fetchViewDefinition( + view: view.name, schema: view.schema ?? schema + ) + let source = definition ?? "" + reads.append(RoutineSourceRead( + name: view.name, + kind: CompareTableKindClassifier.kind(of: view), + schema: view.schema ?? schema, + signature: nil, + source: source + )) + } + return reads + } + } + + // MARK: - Helpers + + nonisolated private static func read( + table: PluginTableInfo, + schema: String?, + using plugin: any PluginDatabaseDriver + ) async -> TableStructureRead { + do { + let columns = try await plugin.fetchColumns(table: table.name, schema: schema) + let indexes = (try? await plugin.fetchIndexes(table: table.name, schema: schema)) ?? [] + let foreignKeys = (try? await plugin.fetchForeignKeys(table: table.name, schema: schema)) ?? [] + let metadata = try? await plugin.fetchTableMetadata(table: table.name, schema: schema) + return TableStructureRead( + table: table, columns: columns, indexes: indexes, + foreignKeys: foreignKeys, metadata: metadata, failure: nil + ) + } catch { + Self.logger.warning( + "Structure read failed for \(table.name, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + return TableStructureRead( + table: table, columns: [], indexes: [], foreignKeys: [], + metadata: nil, failure: error.localizedDescription + ) + } + } + + /// A driver that cannot be pooled reaches a different database on a second connection, so its + /// reads stay serial. Everything else fans out, because the old code paid one round trip per + /// table for columns, indexes, foreign keys and metadata in strict sequence. + nonisolated private static func metadataConcurrency(for databaseType: DatabaseType) -> Int { + databaseType.supportsConnectionPooling ? 4 : 1 + } + + nonisolated private static func map( + _ elements: [Element], + concurrency: Int, + _ transform: @escaping @Sendable (Element) async -> Result + ) async throws -> [Result] { + guard concurrency > 1, elements.count > 1 else { + var results: [Result] = [] + for element in elements { + try Task.checkCancellation() + results.append(await transform(element)) + } + return results + } + + return try await withThrowingTaskGroup(of: (Int, Result).self) { group in + var results = [Result?](repeating: nil, count: elements.count) + var next = 0 + var running = 0 + + while next < elements.count && running < concurrency { + let index = next + group.addTask { (index, await transform(elements[index])) } + next += 1 + running += 1 + } + + while let (index, result) = try await group.next() { + results[index] = result + running -= 1 + try Task.checkCancellation() + if next < elements.count { + let queued = next + group.addTask { (queued, await transform(elements[queued])) } + next += 1 + running += 1 + } + } + return results.compactMap { $0 } + } + } + + nonisolated internal static func pluginDriver(from driver: DatabaseDriver) -> (any PluginDatabaseDriver)? { + (driver as? PluginDriverAdapter)?.schemaPluginDriver + } +} diff --git a/TablePro/Core/Compare/CompareObjectKind.swift b/TablePro/Core/Compare/CompareObjectKind.swift new file mode 100644 index 000000000..88e4a811c --- /dev/null +++ b/TablePro/Core/Compare/CompareObjectKind.swift @@ -0,0 +1,71 @@ +// +// CompareObjectKind.swift +// TablePro +// +// What a compared object is. `fetchTables` reports views alongside tables, and +// nothing filtered on the kind, so a view reached the table paths and produced +// CREATE TABLE for a view or INSERT against one. +// +// The rule is subtractive rather than an allow-list: PostgreSQL reports +// PARTITIONED TABLE for an ordinary, directly queryable table, so matching the +// literal string TABLE would silently drop it from every comparison. +// + +import Foundation +import TableProPluginKit + +internal enum CompareObjectKind: String, Codable, Hashable, Sendable, CaseIterable { + case table + case view + case materializedView + case procedure + case function + case trigger + case sequence + + internal var displayName: String { + switch self { + case .table: return String(localized: "Table") + case .view: return String(localized: "View") + case .materializedView: return String(localized: "Materialized View") + case .procedure: return String(localized: "Procedure") + case .function: return String(localized: "Function") + case .trigger: return String(localized: "Trigger") + case .sequence: return String(localized: "Sequence") + } + } + + /// Only a table holds rows a data comparison can walk. + internal var carriesRows: Bool { + self == .table + } + + /// Objects whose definition is a body of SQL text rather than a set of columns. + internal var isSourceDefined: Bool { + switch self { + case .procedure, .function, .trigger, .view, .materializedView: return true + case .table, .sequence: return false + } + } +} + +internal enum CompareTableKindClassifier { + /// `fetchTables` mixes tables and views, and each driver spells the kind its own way. + /// Anything not recognised as a view is treated as a table, because a driver that reports + /// an unfamiliar table-like kind should still be compared rather than silently dropped. + internal static func kind(of table: PluginTableInfo) -> CompareObjectKind { + let normalized = table.type + .uppercased() + .replacingOccurrences(of: "_", with: " ") + .trimmingCharacters(in: .whitespaces) + if normalized.contains("MATERIALIZED") { return .materializedView } + if normalized.contains("VIEW") { return .view } + return .table + } + + /// A foreign table is a proxy for data on another server. Comparing its structure is + /// meaningful; writing rows through it is not, so it is excluded from data compare. + internal static func isForeign(_ table: PluginTableInfo) -> Bool { + table.type.uppercased().contains("FOREIGN") + } +} diff --git a/TablePro/Core/Compare/CompareObjectResult.swift b/TablePro/Core/Compare/CompareObjectResult.swift new file mode 100644 index 000000000..cde3c9037 --- /dev/null +++ b/TablePro/Core/Compare/CompareObjectResult.swift @@ -0,0 +1,167 @@ +// +// CompareObjectResult.swift +// TablePro +// +// One row of a comparison, whatever kind of object it is. +// +// A table is compared as parsed metadata and yields a `SchemaChange` list. A +// view, procedure, function or trigger has no such structure: its definition is +// a body of SQL, so it is compared as normalised text and the only honest +// answers are create, replace and drop. Both arrive here as one type, so the +// results list, the selection model and the script builder do not each need to +// know which engine produced a row. +// + +import Foundation + +internal struct CompareObjectIdentity: Hashable, Sendable { + internal let kind: CompareObjectKind + internal let schema: String? + internal let name: String + + /// Distinguishes two routines that share a name, which PostgreSQL and Oracle both allow. + /// Nil for everything that cannot be overloaded. + internal let signature: String? + + internal init(kind: CompareObjectKind, schema: String?, name: String, signature: String? = nil) { + self.kind = kind + self.schema = schema + self.name = name + self.signature = signature + } + + internal var qualifiedName: String { + guard let schema, !schema.isEmpty else { return name } + return "\(schema).\(name)" + } + + internal var displayName: String { + guard let signature, !signature.isEmpty else { return qualifiedName } + return "\(qualifiedName)\(signature)" + } + + internal var id: String { + "\(kind.rawValue)|\(schema ?? "")|\(name)|\(signature ?? "")" + } +} + +internal struct CompareObjectResult: Identifiable, Hashable, Sendable { + internal let identity: CompareObjectIdentity + internal let status: TableDiffStatus + internal let changes: [SchemaChange] + internal let sourceDefinition: [String] + internal let targetDefinition: [String] + internal let notes: [String] + internal let comparisonError: String? + + internal init( + identity: CompareObjectIdentity, + status: TableDiffStatus, + changes: [SchemaChange] = [], + sourceDefinition: [String] = [], + targetDefinition: [String] = [], + notes: [String] = [], + comparisonError: String? = nil + ) { + self.identity = identity + self.status = status + self.changes = changes + self.sourceDefinition = sourceDefinition + self.targetDefinition = targetDefinition + self.notes = notes + self.comparisonError = comparisonError + } + + internal var id: String { identity.id } + + internal var isComparable: Bool { comparisonError == nil } + + internal var suggestedAction: TableSyncAction { + guard comparisonError == nil else { return .skip } + switch status { + case .onlyInSource: return .create + case .onlyInTarget: return .drop + case .differs: return .alter + case .identical: return .skip + } + } + + internal var availableActions: [TableSyncAction] { + guard comparisonError == nil else { return [.skip] } + switch status { + case .onlyInSource: return [.skip, .create] + case .onlyInTarget: return [.skip, .drop] + case .differs: return [.skip, .alter] + case .identical: return [.skip] + } + } +} + +internal extension CompareObjectResult { + /// Table results come from `StructureDiffEngine`, which stays table-shaped and keeps its tests. + static func from( + _ result: TableDiffResult, + sourceDefinition: [String] = [], + targetDefinition: [String] = [] + ) -> CompareObjectResult { + CompareObjectResult( + identity: CompareObjectIdentity(kind: .table, schema: result.schema, name: result.tableName), + status: result.status, + changes: result.changes, + sourceDefinition: sourceDefinition, + targetDefinition: targetDefinition, + notes: result.notes, + comparisonError: result.comparisonError + ) + } +} + +internal struct CompareReport: Sendable { + internal let results: [CompareObjectResult] + + internal init(results: [CompareObjectResult]) { + self.results = results.sorted { lhs, rhs in + guard lhs.identity.kind == rhs.identity.kind else { + return Self.kindRank(lhs.identity.kind) < Self.kindRank(rhs.identity.kind) + } + return lhs.identity.displayName.localizedStandardCompare(rhs.identity.displayName) == .orderedAscending + } + } + + internal var comparable: [CompareObjectResult] { + results.filter { $0.isComparable } + } + + internal var uncomparable: [CompareObjectResult] { + results.filter { !$0.isComparable } + } + + internal func count(of status: TableDiffStatus) -> Int { + comparable.filter { $0.status == status }.count + } + + internal var differenceCount: Int { + comparable.filter { $0.status != .identical }.count + } + + internal var presentKinds: [CompareObjectKind] { + var seen: [CompareObjectKind] = [] + for result in results where !seen.contains(result.identity.kind) { + seen.append(result.identity.kind) + } + return seen + } + + /// Dependency order, so a script that both drops and creates does not trip over itself. + private static func kindRank(_ kind: CompareObjectKind) -> Int { + switch kind { + case .table: return 0 + case .sequence: return 1 + case .view: return 2 + case .materializedView: return 3 + case .function: return 4 + case .procedure: return 5 + case .trigger: return 6 + } + } +} diff --git a/TablePro/Core/Compare/CompareRowService.swift b/TablePro/Core/Compare/CompareRowService.swift new file mode 100644 index 000000000..20a18abf0 --- /dev/null +++ b/TablePro/Core/Compare/CompareRowService.swift @@ -0,0 +1,191 @@ +// +// CompareRowService.swift +// TablePro +// +// Reads both sides' rows for a data comparison. +// +// A merge join needs both streams open at once, so the two scoped-driver +// closures nest. That is safe only when the two scopes reach two drivers. +// `SessionDriverGate` is not reentrant, and a connection's shared driver holds +// one database position, so two scopes that both route to the session driver +// on the same connection cannot be open together: nesting them would block +// forever, and even if it did not, pinning the driver to the second scope +// would move the first out from under its own stream. `refusalReason` is that +// check, made before anything opens. +// + +import Foundation +import TableProPluginKit + +@MainActor +internal struct CompareRowService { + private let manager: DatabaseManager + + internal init(manager: DatabaseManager = .shared) { + self.manager = manager + } + + internal func concurrentReadRefusal( + source: CompareSyncEndpoint, + target: CompareSyncEndpoint + ) -> String? { + guard source.connectionId == target.connectionId else { return nil } + let routes = [manager.metadataRoute(for: source.scope), manager.metadataRoute(for: target.scope)] + guard routes.contains(where: { $0 != .pooled }) else { return nil } + return String( + format: String( + localized: "%@ cannot compare two of its own databases at once, because it reads both through one connection. Use a second connection for the target." + ), + source.databaseType.rawValue + ) + } + + internal func compare( + plan: DataComparePlan, + source: CompareSyncEndpoint, + sourceConnection: DatabaseConnection, + target: CompareSyncEndpoint, + targetConnection: DatabaseConnection, + options: DataCompareOptions + ) async throws -> DataDiffSummary { + try await withBothSides( + source: source, sourceConnection: sourceConnection, + target: target, targetConnection: targetConnection, + plan: plan, options: options + ) { engine, sourceProvider, targetProvider in + try await engine.compare(source: sourceProvider, target: targetProvider) + } + } + + /// Runs the walk a second time, building statements as entries arrive rather than reading the + /// review pane's capped entry list. Building from that list emitted 5,000 statements for a + /// 12,000 row difference and reported the run as complete. + internal func buildStatements( + plan: DataComparePlan, + source: CompareSyncEndpoint, + sourceConnection: DatabaseConnection, + target: CompareSyncEndpoint, + targetConnection: DatabaseConnection, + options: DataCompareOptions, + excludedKeys: Set + ) async throws -> DataSyncStatements { + let targetType = target.databaseType + let table = plan.table + let schema = plan.schema + let writeColumns = plan.writeColumns + + return try await withBothSides( + source: source, sourceConnection: sourceConnection, + target: target, targetConnection: targetConnection, + plan: plan, options: options + ) { engine, sourceProvider, targetProvider, targetDriver in + let builder = DataSyncScriptBuilder( + targetDriver: targetDriver, targetDatabaseType: targetType, options: options + ) + let collector = SyncStatementCollector() + _ = try await engine.compare(source: sourceProvider, target: targetProvider) { entry in + guard !excludedKeys.contains(entry.keyIdentity) else { return } + collector.append(entry, table: table, schema: schema, writeColumns: writeColumns, builder: builder) + } + return collector.statements + } + } + + // MARK: - Plumbing + + private func withBothSides( + source: CompareSyncEndpoint, + sourceConnection: DatabaseConnection, + target: CompareSyncEndpoint, + targetConnection: DatabaseConnection, + plan: DataComparePlan, + options: DataCompareOptions, + _ body: @escaping @Sendable ( + DataDiffEngine, StreamingRowProvider, StreamingRowProvider, any PluginDatabaseDriver + ) async throws -> T + ) async throws -> T { + if let refusal = concurrentReadRefusal(source: source, target: target) { + throw CompareSyncError.unsupportedOperation(refusal) + } + try await manager.ensureConnected(sourceConnection) + try await manager.ensureConnected(targetConnection) + + var scopedOptions = options + scopedOptions.keyColumns = plan.keyColumns + let engine = DataDiffEngine( + options: scopedOptions, + columns: plan.comparisonColumnNames, + keyDescriptors: plan.keyDescriptors + ) + let sourceSchema = source.schema ?? plan.schema + let targetSchema = plan.targetSchema + let readColumns = plan.readColumns + let keyColumns = plan.keyColumns + let table = plan.table + + return try await manager.withMetadataDriver(scope: source.scope) { sourceDriver in + guard let sourcePlugin = CompareMetadataService.pluginDriver(from: sourceDriver) else { + throw CompareSyncError.unsupportedOperation( + String(localized: "The source driver cannot stream rows for a comparison.") + ) + } + return try await DatabaseManager.shared.withMetadataDriver(scope: target.scope) { targetDriver in + guard let targetPlugin = CompareMetadataService.pluginDriver(from: targetDriver) else { + throw CompareSyncError.unsupportedOperation( + String(localized: "The target driver cannot stream rows for a comparison.") + ) + } + let sourceQuery = KeyOrderedQuery.build( + table: table, schema: sourceSchema, columns: readColumns, + keyColumns: keyColumns, driver: sourcePlugin + ) + let targetQuery = KeyOrderedQuery.build( + table: table, schema: targetSchema, columns: readColumns, + keyColumns: keyColumns, driver: targetPlugin + ) + let sourceProvider = StreamingRowProvider( + stream: sourcePlugin.streamRows(query: sourceQuery), columns: readColumns + ) + let targetProvider = StreamingRowProvider( + stream: targetPlugin.streamRows(query: targetQuery), columns: readColumns + ) + return try await body(engine, sourceProvider, targetProvider, targetPlugin) + } + } + } + + private func withBothSides( + source: CompareSyncEndpoint, + sourceConnection: DatabaseConnection, + target: CompareSyncEndpoint, + targetConnection: DatabaseConnection, + plan: DataComparePlan, + options: DataCompareOptions, + _ body: @escaping @Sendable (DataDiffEngine, StreamingRowProvider, StreamingRowProvider) async throws -> T + ) async throws -> T { + try await withBothSides( + source: source, sourceConnection: sourceConnection, + target: target, targetConnection: targetConnection, + plan: plan, options: options + ) { engine, sourceProvider, targetProvider, _ in + try await body(engine, sourceProvider, targetProvider) + } + } +} + +/// The merge join hands entries out one at a time from inside a `@Sendable` closure, so the +/// accumulation cannot be a captured `var`. A reference box keeps it simple without reaching for +/// an actor the single-threaded walk does not need. +private final class SyncStatementCollector: @unchecked Sendable { + private(set) var statements = DataSyncStatements() + + func append( + _ entry: RowDiffEntry, + table: String, + schema: String?, + writeColumns: [String], + builder: DataSyncScriptBuilder + ) { + builder.append(entry, table: table, schema: schema, writeColumns: writeColumns, into: &statements) + } +} diff --git a/TablePro/Core/Compare/CompareRunner+Data.swift b/TablePro/Core/Compare/CompareRunner+Data.swift new file mode 100644 index 000000000..f4948e1c7 --- /dev/null +++ b/TablePro/Core/Compare/CompareRunner+Data.swift @@ -0,0 +1,171 @@ +// +// CompareRunner+Data.swift +// TablePro +// +// The data half: building the per-table plans, running the merge join for each, +// and turning the result into DML. +// +// Statements are ordered across tables, not just within one. A flat +// inserts-then-updates-then-deletes per table, emitted in alphabetical order, +// puts a child row's INSERT before its parent's and the server refuses it. So +// every table's inserts run parent-first, then the updates, then the deletes +// child-first, which is the only ordering that satisfies a foreign key in both +// directions. +// + +import Foundation +import TableProPluginKit + +internal extension CompareRunner { + func runDataCompare(_ context: Context) async throws { + if let refusal = rowService.concurrentReadRefusal(source: context.source, target: context.target) { + throw CompareSyncError.unsupportedOperation(refusal) + } + + var plans = try await buildPlans(context) + if !session.pendingSelection.isEmpty { + for index in plans.indices { + plans[index].isEnabled = session.pendingSelection.contains(plans[index].id) + } + session.pendingSelection = [] + } + + for index in plans.indices where plans[index].isEnabled && plans[index].isComparable { + try Task.checkCancellation() + do { + plans[index].summary = try await rowService.compare( + plan: plans[index], + source: context.source, + sourceConnection: context.sourceConnection, + target: context.target, + targetConnection: context.targetConnection, + options: session.dataOptions + ) + } catch is CancellationError { + throw CancellationError() + } catch { + plans[index].unavailableReason = error.localizedDescription + } + } + + session.dataPlans = plans + session.selectedPlanId = plans.first { $0.isEnabled && $0.isComparable }?.id ?? plans.first?.id + session.detailPane = .rows + session.invalidateScript() + + /// Plans start unchecked so a first Compare cannot stream every row of every table, which + /// means a first run legitimately reads nothing. Recording that as "0 differences" invited + /// the reader to conclude the two databases matched. + let comparedAny = plans.contains { $0.isEnabled && $0.isComparable && $0.summary != nil } + guard comparedAny else { + session.lastAction = .none + session.informationalMessage = String( + localized: "No tables were compared. Choose the tables to compare, then press Compare." + ) + return + } + session.lastAction = .compared(Date(), differences: session.dataDifferenceTotal) + } + + func dataStatements(_ context: Context) async throws -> [SyncStatement] { + var byTable: [(plan: DataComparePlan, statements: DataSyncStatements)] = [] + + for plan in session.dataPlans where plan.isEnabled && plan.isComparable { + try Task.checkCancellation() + let statements = try await rowService.buildStatements( + plan: plan, + source: context.source, + sourceConnection: context.sourceConnection, + target: context.target, + targetConnection: context.targetConnection, + options: session.dataOptions, + excludedKeys: plan.excludedRowKeys + ) + guard !statements.isEmpty else { continue } + byTable.append((plan, statements)) + } + guard !byTable.isEmpty else { return [] } + + /// `session.sourceSnapshots` is filled by the structure path only, so reading it here left + /// the graph empty and the ordering fell back to alphabetical: `order_items` before + /// `orders`, which is exactly the foreign key failure this ordering exists to prevent. + /// The data path records its own snapshots when it builds the plans. + let foreignKeys = CompareRunner.foreignKeyMap(from: session.sourceSnapshots) + let nodes = byTable.map { ForeignKeyTopologicalSort.Table(name: $0.plan.table, schema: $0.plan.schema) } + let parentFirst = ForeignKeyTopologicalSort + .ordered(nodes, foreignKeysByTable: foreignKeys, childrenFirst: false) + .map { $0.identifier } + let ordered = Dictionary(byTable.map { ($0.plan.id, $0.statements) }, uniquingKeysWith: { first, _ in first }) + + var result: [SyncStatement] = [] + for name in parentFirst { + result += ordered[name]?.inserts ?? [] + } + for name in parentFirst { + result += ordered[name]?.updates ?? [] + } + for name in parentFirst.reversed() { + result += ordered[name]?.deletes ?? [] + } + return result + } + + // MARK: - Plans + + private func buildPlans(_ context: Context) async throws -> [DataComparePlan] { + let sourceReads = try await metadataService.tableReads( + for: context.source, connection: context.sourceConnection, includeViews: false + ) + try Task.checkCancellation() + let targetReads = try await metadataService.tableReads( + for: context.target, connection: context.targetConnection, includeViews: false + ) + try Task.checkCancellation() + + /// Keyed on schema and name, not name alone: two schemas of one database can hold the same + /// table, and pairing on the bare name took the shared column set from the wrong + /// counterpart while reading rows from the right one. + let options = session.structureOptions + let targetByKey = Dictionary( + targetReads.map { (options.matchKey(name: $0.table.name, schema: $0.table.schema), $0) }, + uniquingKeysWith: { first, _ in first } + ) + let previous = Dictionary( + session.dataPlans.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first } + ) + + session.sourceSnapshots = Dictionary( + sourceReads.compactMap { $0.snapshot }.map { ($0.qualifiedName, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + var plans: [DataComparePlan] = [] + for read in sourceReads { + guard read.failure == nil else { continue } + let pairKey = options.matchKey(name: read.table.name, schema: read.table.schema) + guard let counterpart = targetByKey[pairKey], counterpart.failure == nil else { continue } + let targetNames = Set(counterpart.columns.map { $0.name.lowercased() }) + let shared = read.columns.filter { targetNames.contains($0.name.lowercased()) } + let schema = read.table.schema ?? context.source.schema + let identifier = schema.map { "\($0).\(read.table.name)" } ?? read.table.name + let carried = previous[identifier] + + var plan = DataComparePlan( + table: read.table.name, + schema: schema, + targetSchema: counterpart.table.schema ?? context.target.schema, + columns: shared.map { $0.name }, + columnDescriptors: shared.map { + KeyColumnDescriptor(name: $0.name, dataType: $0.dataType, collation: $0.collation) + }, + generatedColumns: Set(shared.filter { $0.isGenerated }.map { $0.name.lowercased() }), + keyColumns: carried?.keyColumns ?? shared.filter { $0.isPrimaryKey }.map { $0.name }, + isEnabled: carried?.isEnabled ?? false, + excludedRowKeys: carried?.excludedRowKeys ?? [] + ) + plan.unavailableReason = DataComparePlan.unavailableReason(for: plan) + plans.append(plan) + } + return plans.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending } + } +} diff --git a/TablePro/Core/Compare/CompareRunner.swift b/TablePro/Core/Compare/CompareRunner.swift new file mode 100644 index 000000000..e1c98011d --- /dev/null +++ b/TablePro/Core/Compare/CompareRunner.swift @@ -0,0 +1,385 @@ +// +// CompareRunner.swift +// TablePro +// +// Runs a comparison, builds its script and applies it. +// +// It owns the I/O so the session does not. Every read goes through +// `CompareMetadataService` / `CompareRowService`, which route through +// `DatabaseManager.withMetadataDriver(scope:)`; nothing here reaches +// `DatabaseManager.driver(for:)`, which hands back the connection's live +// interactive driver without the gate that keeps it on one database. +// + +import Foundation +import os +import TableProPluginKit + +@MainActor +internal struct CompareRunner { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "CompareRunner") + + internal let session: CompareSyncSession + internal let metadataService = CompareMetadataService() + internal let rowService = CompareRowService() + + internal init(session: CompareSyncSession) { + self.session = session + } + + // MARK: - Entry points + + internal func compare() { + guard session.canCompare else { return } + session.cancelRunningWork() + session.errorMessage = nil + session.informationalMessage = nil + + session.runTask = Task { [session] in + session.activity = .connecting + defer { session.activity = .idle } + do { + let context = try resolveContext() + if let refusal = try await capabilityRefusal(context) { + session.errorMessage = refusal + return + } + session.activity = .comparing + switch session.mode { + case .structure: + try await runStructureCompare(context) + case .data: + try await runDataCompare(context) + } + session.informationalMessage = session.crossEngineNotice + } catch is CancellationError { + session.informationalMessage = String(localized: "Comparison cancelled.") + } catch { + session.errorMessage = error.localizedDescription + } + } + } + + internal func buildScript() { + guard session.canBuildScript else { return } + session.cancelRunningWork() + session.errorMessage = nil + + session.runTask = Task { [session] in + session.activity = .comparing + defer { session.activity = .idle } + do { + let context = try resolveContext() + let built: [SyncStatement] + switch session.mode { + case .structure: + built = try await structureStatements(context) + case .data: + built = try await dataStatements(context) + } + guard !built.isEmpty else { + session.errorMessage = String(localized: "Nothing is selected to apply.") + return + } + session.statements = built + session.detailPane = .script + } catch is CancellationError { + session.informationalMessage = String(localized: "Script generation cancelled.") + } catch { + session.errorMessage = error.localizedDescription + } + } + } + + internal func apply() { + guard session.canApply, let target = session.target else { return } + session.cancelRunningWork() + session.errorMessage = nil + + let runProgress = Progress(totalUnitCount: Int64(session.statements.count)) + session.progress = runProgress + + session.runTask = Task { [session] in + session.activity = .applying + CompareSyncRunRegistry.shared.markApplying(session, target: target.qualifiedDescription) + defer { + session.activity = .idle + session.progress = nil + CompareSyncRunRegistry.shared.clear(session) + } + + do { + /// Resolved for its validation: it throws when a connection has gone away, which + /// must stop the run before anything is written. + _ = try resolveContext() + let statements = session.statements + let settings = session.executionSettings + let mode = session.mode + let result = try await DatabaseManager.shared.withMetadataDriver(scope: target.scope) { driver in + guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { + throw CompareSyncError.unsupportedOperation( + String(localized: "The target driver cannot run a sync script.") + ) + } + return try await CompareSyncExecutor().apply( + statements: statements, + mode: mode, + settings: settings, + target: target, + driver: plugin, + progress: runProgress + ) + } + /// Set from the result, not before the run. Setting it up front meant a declined + /// authorization or a driver that could not run the script still flipped the status + /// strip to "written", next to text that still read "Nothing has been written." + session.hasWrittenToTarget = session.hasWrittenToTarget || result.executedCount > 0 + session.runResult = result + session.lastAction = .applied( + Date(), target: target.qualifiedDescription, statements: result.executedCount + ) + /// The script just ran, so it describes work the target has already had. Leaving it + /// armed left Apply enabled on a stale plan, one click from running the same + /// CREATE/ALTER/DELETE a second time. + session.markAppliedAndStale() + } catch { + session.errorMessage = error.localizedDescription + } + } + } + + // MARK: - Context + + internal struct Context { + internal let source: CompareSyncEndpoint + internal let target: CompareSyncEndpoint + internal let sourceConnection: DatabaseConnection + internal let targetConnection: DatabaseConnection + } + + internal func resolveContext() throws -> Context { + guard let source = session.source, let target = session.target else { + throw CompareSyncError.unsupportedOperation(String(localized: "Choose a source and a target first.")) + } + let connections = ConnectionStorage.shared.loadConnections() + guard let sourceConnection = connections.first(where: { $0.id == source.connectionId }) else { + throw CompareSyncError.unsupportedOperation(missingConnection(source)) + } + guard let targetConnection = connections.first(where: { $0.id == target.connectionId }) else { + throw CompareSyncError.unsupportedOperation(missingConnection(target)) + } + return Context( + source: source, target: target, + sourceConnection: sourceConnection, targetConnection: targetConnection + ) + } + + private func missingConnection(_ endpoint: CompareSyncEndpoint) -> String { + String( + format: String(localized: "%@ is no longer a saved connection."), + endpoint.connectionName + ) + } + + private func capabilityRefusal(_ context: Context) async throws -> String? { + if let refusal = try await metadataService.refusalReason( + for: context.source, connection: context.sourceConnection, mode: session.mode + ) { + return refusal + } + return try await metadataService.refusalReason( + for: context.target, connection: context.targetConnection, mode: session.mode + ) + } + + // MARK: - Structure + + private func runStructureCompare(_ context: Context) async throws { + let wantsViews = session.includedKinds.contains(.view) + || session.includedKinds.contains(.materializedView) + + let sourceReads = try await metadataService.tableReads( + for: context.source, connection: context.sourceConnection, includeViews: wantsViews + ) + try Task.checkCancellation() + let targetReads = try await metadataService.tableReads( + for: context.target, connection: context.targetConnection, includeViews: wantsViews + ) + try Task.checkCancellation() + + let sourceTables = sourceReads.filter { CompareTableKindClassifier.kind(of: $0.table) == .table } + let targetTables = targetReads.filter { CompareTableKindClassifier.kind(of: $0.table) == .table } + + let sourceSnapshots = sourceTables.compactMap { $0.snapshot } + let targetSnapshots = targetTables.compactMap { $0.snapshot } + + let engine = StructureDiffEngine(options: session.structureOptions) + let tableReport = engine.compare(source: sourceSnapshots, target: targetSnapshots) + + session.sourceSnapshots = Dictionary( + sourceSnapshots.map { ($0.qualifiedName, $0) }, uniquingKeysWith: { first, _ in first } + ) + session.targetSnapshots = Dictionary( + targetSnapshots.map { ($0.qualifiedName, $0) }, uniquingKeysWith: { first, _ in first } + ) + + var results = tableReport.results.map { result -> CompareObjectResult in + CompareObjectResult.from( + result, + sourceDefinition: session.sourceSnapshots[result.id].map(TableDefinitionRenderer.lines) ?? [], + targetDefinition: session.targetSnapshots[result.id].map(TableDefinitionRenderer.lines) ?? [] + ) + } + results += unreadableResults(sourceTables, targetTables) + results += try await sourceDefinedResults(context, sourceReads: sourceReads, targetReads: targetReads) + + let report = CompareReport(results: results) + session.report = report + session.actions = [:] + for result in report.comparable where session.pendingSelection.contains(result.id) { + session.actions[result.id] = result.suggestedAction + } + session.pendingSelection = [] + session.invalidateScript() + session.selectedObjectId = session.visibleResults.first?.id + session.lastAction = .compared(Date(), differences: report.differenceCount) + } + + /// A table whose metadata could not be read is listed with its reason rather than aborting the + /// comparison, which is what `CompareObjectResult.comparisonError` is for. Before this, one + /// unreadable table threw and the user saw no results at all. + private func unreadableResults( + _ sourceTables: [TableStructureRead], + _ targetTables: [TableStructureRead] + ) -> [CompareObjectResult] { + var seen: Set = [] + var results: [CompareObjectResult] = [] + for read in sourceTables + targetTables { + guard let failure = read.failure else { continue } + let identity = CompareObjectIdentity(kind: .table, schema: read.table.schema, name: read.table.name) + guard seen.insert(identity.id).inserted else { continue } + results.append(CompareObjectResult( + identity: identity, status: .differs, comparisonError: failure + )) + } + return results + } + + private func sourceDefinedResults( + _ context: Context, + sourceReads: [TableStructureRead], + targetReads: [TableStructureRead] + ) async throws -> [CompareObjectResult] { + var results: [CompareObjectResult] = [] + + if session.includedKinds.contains(.view) || session.includedKinds.contains(.materializedView) { + let sourceViews = sourceReads.map(\.table).filter { CompareTableKindClassifier.kind(of: $0) != .table } + let targetViews = targetReads.map(\.table).filter { CompareTableKindClassifier.kind(of: $0) != .table } + let sourceDefinitions = try await metadataService.viewDefinitions( + for: context.source, connection: context.sourceConnection, views: sourceViews + ) + let targetDefinitions = try await metadataService.viewDefinitions( + for: context.target, connection: context.targetConnection, views: targetViews + ) + results += SourceObjectDiffEngine(options: session.structureOptions) + .compare(source: sourceDefinitions, target: targetDefinitions) + } + + if session.includedKinds.contains(.procedure) || session.includedKinds.contains(.function) { + let sourceRoutines = try await metadataService.routineReads( + for: context.source, connection: context.sourceConnection + ) + let targetRoutines = try await metadataService.routineReads( + for: context.target, connection: context.targetConnection + ) + results += SourceObjectDiffEngine(options: session.structureOptions) + .compare(source: sourceRoutines, target: targetRoutines) + .filter { session.includedKinds.contains($0.identity.kind) } + } + + if session.includedKinds.contains(.trigger) { + let sourceTriggers = try await metadataService.triggerReads( + for: context.source, + connection: context.sourceConnection, + tables: sourceReads.map(\.table.name) + ) + let targetTriggers = try await metadataService.triggerReads( + for: context.target, + connection: context.targetConnection, + tables: targetReads.map(\.table.name) + ) + results += SourceObjectDiffEngine(options: session.structureOptions) + .compare(source: sourceTriggers, target: targetTriggers) + } + + return results + } + + private func structureStatements(_ context: Context) async throws -> [SyncStatement] { + guard let report = session.report else { return [] } + let snapshots = session.sourceSnapshots + let selected = report.comparable.filter { session.action(for: $0) != .skip } + let tableOperations = selected.compactMap { result -> SchemaSyncOperation? in + guard result.identity.kind == .table else { return nil } + switch session.action(for: result) { + case .skip: return nil + case .create: + guard let snapshot = snapshots[result.identity.qualifiedName] else { return nil } + return .createTable(snapshot) + case .drop: + return .dropTable(name: result.identity.name, schema: result.identity.schema) + case .alter: + guard !result.changes.isEmpty else { return nil } + return .alterTable( + name: result.identity.name, schema: result.identity.schema, changes: result.changes + ) + } + } + /// The action is resolved before the closure, because the closure crosses an isolation + /// boundary and the session is main-actor state. + let sourceDefined = selected + .filter { $0.identity.kind != .table } + .map { (result: $0, action: session.action(for: $0)) } + let foreignKeys = Self.foreignKeyMap(from: snapshots) + + return try await DatabaseManager.shared.withMetadataDriver(scope: context.target.scope) { driver in + guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { + throw CompareSyncError.unsupportedOperation( + String(localized: "The target driver cannot generate a sync script.") + ) + } + var statements = try SchemaSyncScriptBuilder(targetDriver: plugin) + .build(operations: tableOperations, foreignKeysByTable: foreignKeys) + let sourceBuilder = SourceObjectSyncBuilder(targetDriver: plugin) + for entry in sourceDefined { + statements += sourceBuilder.build(for: entry.result, action: entry.action) + } + return statements + } + } + + internal static func foreignKeyMap( + from snapshots: [String: TableStructureSnapshot] + ) -> [String: [PluginForeignKeyInfo]] { + var map: [String: [PluginForeignKeyInfo]] = [:] + for snapshot in snapshots.values { + let dependencies = snapshot.foreignKeys.compactMap { foreignKey -> PluginForeignKeyInfo? in + guard let column = foreignKey.columns.first, + let referencedColumn = foreignKey.referencedColumns.first else { return nil } + let referencedSchema = foreignKey.referencedSchema ?? snapshot.schema + /// The sort qualifies `referencedTable` with `referencedSchema` itself, so + /// pre-qualifying here produced `public.public.orders` and dropped every edge. + return PluginForeignKeyInfo( + name: foreignKey.name, + column: column, + referencedTable: foreignKey.referencedTable, + referencedColumn: referencedColumn, + referencedSchema: referencedSchema + ) + } + guard !dependencies.isEmpty else { continue } + map[snapshot.qualifiedName] = dependencies + } + return map + } +} diff --git a/TablePro/Core/Compare/CompareSQLLiteral.swift b/TablePro/Core/Compare/CompareSQLLiteral.swift new file mode 100644 index 000000000..b07e9d911 --- /dev/null +++ b/TablePro/Core/Compare/CompareSQLLiteral.swift @@ -0,0 +1,71 @@ +// +// CompareSQLLiteral.swift +// TablePro +// +// How a compared value is written back as a literal. +// +// The PluginKit default renders binary as `X'89504E47'`, which is a bit-string +// literal. MySQL, MariaDB, SQLite and ClickHouse accept it; PostgreSQL rejects +// it with "column is of type bytea but expression is of type bit", SQL Server +// wants `0x...` and Oracle wants `HEXTORAW`. No shipped driver overrides +// `sqlLiteral(for:)`, so the spelling is decided here, per engine, rather than +// by adding a requirement every plugin would have to be re-released to answer. +// + +import Foundation +import TableProPluginKit + +internal enum CompareSQLLiteral { + internal static func literal( + for value: PluginCellValue, + databaseType: DatabaseType, + driver: any PluginDatabaseDriver + ) -> String { + guard case .bytes(let data) = value else { + return driver.sqlLiteral(for: value) + } + return binaryLiteral(for: data, databaseType: databaseType) + ?? driver.sqlLiteral(for: value) + } + + internal static func binaryLiteral(for data: Data, databaseType: DatabaseType) -> String? { + let hex = data.map { String(format: "%02X", $0) }.joined() + switch binaryStyle(for: databaseType) { + case .bitString: + return "X'\(hex)'" + case .postgresBytea: + return "'\\x\(hex.lowercased())'::bytea" + case .zeroX: + return "0x\(hex)" + case .hexToRaw: + return "HEXTORAW('\(hex)')" + case .unknown: + return nil + } + } + + internal enum BinaryStyle { + case bitString + case postgresBytea + case zeroX + case hexToRaw + case unknown + } + + /// Curated per type, the same shape `CompareSyncEngineFamily` uses. A type this does not + /// name falls back to the driver's own spelling rather than guessing at one. + internal static func binaryStyle(for databaseType: DatabaseType) -> BinaryStyle { + switch databaseType { + case .mysql, .mariadb, .sqlite, .clickhouse, .duckdb, .libsql, .turso, .cloudflareD1: + return .bitString + case .postgresql, .cockroachdb, .redshift, .pglite: + return .postgresBytea + case .mssql: + return .zeroX + case .oracle: + return .hexToRaw + default: + return .unknown + } + } +} diff --git a/TablePro/Core/Compare/CompareSyncEndpoint.swift b/TablePro/Core/Compare/CompareSyncEndpoint.swift new file mode 100644 index 000000000..fdae989bb --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncEndpoint.swift @@ -0,0 +1,160 @@ +// +// CompareSyncEndpoint.swift +// TablePro +// +// One side of a comparison. The source never changes; the target is written to. +// +// An endpoint is a `DatabaseScope`, not a connection id. A connection reaches +// many databases, so identifying a side by connection alone made two databases +// on one server impossible to compare and left the schema unset, which is how +// the reads went out unqualified while the writes went out qualified. +// + +import Foundation +import TableProPluginKit + +internal struct CompareSyncEndpoint: Hashable, Identifiable, Sendable { + internal let scope: DatabaseScope + internal let connectionName: String + internal let databaseType: DatabaseType + internal let safeModeLevel: SafeModeLevel + internal let color: ConnectionColor + + /// What a person reads where the database is named. A file-based engine's database *is* an + /// absolute path, so spelling it out put `/Users/…/Library/Application Support/…/Chinook.sqlite` + /// in a toolbar popup, a window subtitle and a status strip at once, truncated in all three. + /// Resolved once at construction, because the rule needs `PluginManager`, which is main-actor + /// bound, and this type crosses into the executor actor. + internal let databaseLabel: String + + internal init( + scope: DatabaseScope, + connectionName: String, + databaseType: DatabaseType, + safeModeLevel: SafeModeLevel, + color: ConnectionColor, + databaseLabel: String? = nil + ) { + self.scope = scope + self.connectionName = connectionName + self.databaseType = databaseType + self.safeModeLevel = safeModeLevel + self.color = color + self.databaseLabel = databaseLabel ?? scope.database + } + + internal var id: String { + "\(scope.connectionId.uuidString)|\(scope.database)|\(scope.schema ?? "")" + } + + internal var connectionId: UUID { scope.connectionId } + internal var database: String { scope.database } + internal var schema: String? { scope.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 = [connectionName] + if !databaseLabel.isEmpty, databaseLabel != connectionName { parts.append(databaseLabel) } + if let schema = scope.schema, !schema.isEmpty { parts.append(schema) } + return parts.joined(separator: " / ") + } + + /// The unshortened scope, for a tooltip. A file path is worth having somewhere, just not in + /// three chrome surfaces at once. + internal var fullDescription: String { + var parts = [connectionName] + if !scope.database.isEmpty { parts.append(scope.database) } + if let schema = scope.schema, !schema.isEmpty { parts.append(schema) } + return parts.joined(separator: " / ") + } + + /// The name shown once the connection is already named elsewhere, so a picker does not repeat it. + internal var scopeDescription: String { + guard !databaseLabel.isEmpty else { return String(localized: "Server") } + guard let schema = scope.schema, !schema.isEmpty else { return databaseLabel } + return "\(databaseLabel).\(schema)" + } + + internal func withDatabase(_ database: String, label: String? = nil) -> CompareSyncEndpoint { + CompareSyncEndpoint( + scope: DatabaseScope(connectionId: scope.connectionId, database: database, schema: nil), + connectionName: connectionName, + databaseType: databaseType, + safeModeLevel: safeModeLevel, + color: color, + databaseLabel: label ?? database + ) + } + + internal func withSchema(_ schema: String?) -> CompareSyncEndpoint { + CompareSyncEndpoint( + scope: DatabaseScope(connectionId: scope.connectionId, database: scope.database, schema: schema), + connectionName: connectionName, + databaseType: databaseType, + safeModeLevel: safeModeLevel, + color: color, + databaseLabel: databaseLabel + ) + } +} + +@MainActor +internal extension CompareSyncEndpoint { + static func from( + connection: DatabaseConnection, + database: String? = nil, + schema: String? = nil + ) -> CompareSyncEndpoint { + let resolved = database ?? connection.database ?? "" + return CompareSyncEndpoint( + scope: DatabaseScope(connectionId: connection.id, database: resolved, schema: schema), + connectionName: connection.name, + databaseType: connection.type, + safeModeLevel: connection.safeModeLevel, + color: connection.color, + databaseLabel: label(for: resolved, type: connection.type) + ) + } + + static func candidates(from connections: [DatabaseConnection]) -> [CompareSyncEndpoint] { + connections.map { from(connection: $0) } + } + + /// The rule `ConnectionToolbarState` already applies to the main window's scope chip: a + /// file-based engine's database is a path, and only its last component is worth showing. + static func label(for database: String, type: DatabaseType) -> String { + guard PluginManager.shared.connectionMode(for: type) == .fileBased else { return database } + return (database as NSString).lastPathComponent + } +} + +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 } + 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 + ) + } + } +} diff --git a/TablePro/Core/Compare/CompareSyncEngineFamily.swift b/TablePro/Core/Compare/CompareSyncEngineFamily.swift new file mode 100644 index 000000000..7ca98f894 --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncEngineFamily.swift @@ -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 = { + 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 + ) + } +} diff --git a/TablePro/Core/Compare/CompareSyncExecutor.swift b/TablePro/Core/Compare/CompareSyncExecutor.swift new file mode 100644 index 000000000..ee6ff17ce --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncExecutor.swift @@ -0,0 +1,256 @@ +// +// CompareSyncExecutor.swift +// TablePro +// +// Applies a generated sync script to the target connection. +// Authorization happens once for the whole run through ExecutionGate, and the +// statement loop stays inside that call so the task-local receipt remains bound +// for every statement. Cancellation is cooperative between statements: a driver +// already blocked in a C call cannot be interrupted. +// + +import Foundation +import os +import TableProPluginKit + +internal enum CompareSyncMode: String, Codable, Hashable, Sendable, CaseIterable { + case structure + case data + + internal var displayName: String { + switch self { + case .structure: return String(localized: "Structure") + case .data: return String(localized: "Data") + } + } +} + +internal struct CompareSyncExecutionSettings { + internal var errorHandling: ImportErrorHandling = .stopAndRollback + internal var wrapInTransaction = true + internal var allowedHazardStatementIds: Set = [] + + internal func canRun(_ statement: SyncStatement) -> Bool { + guard statement.isRefusedByDefault else { return true } + return allowedHazardStatementIds.contains(statement.id) + } + + /// A structure sync asks a different question from a data sync. Every engine here supports a + /// transaction over DML, but MySQL, MariaDB and Oracle commit implicitly on every DDL + /// statement, so wrapping a structure script in one produces a ROLLBACK that undoes nothing + /// while the run reports "The target is unchanged." `supportsTransactionalDDL` is the flag + /// that distinguishes them, and the driver already publishes it. + internal func usesTransaction(for mode: CompareSyncMode, driver: any PluginDatabaseDriver) -> Bool { + let supported = mode == .structure ? driver.supportsTransactionalDDL : driver.supportsTransactions + return wrapInTransaction && supported && errorHandling != .skipAndContinue + } +} + +internal struct SyncStatementOutcome: Identifiable { + internal let id: UUID + internal let statement: SyncStatement + internal let error: String? + internal let wasSkipped: Bool + + internal var succeeded: Bool { + error == nil && !wasSkipped + } +} + +internal struct CompareSyncRunResult { + internal let outcomes: [SyncStatementOutcome] + internal let rolledBack: Bool + internal let cancelled: Bool + + /// Set when the statements ran but the transaction could not be committed, which leaves the + /// target in whatever state the engine decided rather than in either of the two the user + /// expects. + internal let commitFailure: String? + + internal init( + outcomes: [SyncStatementOutcome], + rolledBack: Bool, + cancelled: Bool, + commitFailure: String? = nil + ) { + self.outcomes = outcomes + self.rolledBack = rolledBack + self.cancelled = cancelled + self.commitFailure = commitFailure + } + + internal var executedCount: Int { + outcomes.filter { $0.succeeded }.count + } + + internal var failedCount: Int { + outcomes.filter { $0.error != nil }.count + } + + internal var heldBackCount: Int { + outcomes.filter { $0.wasSkipped }.count + } +} + +internal actor CompareSyncExecutor { + private static let logger = Logger(subsystem: "com.TablePro", category: "CompareSyncExecutor") + + private let gate: ExecutionGate + + internal init(gate: ExecutionGate = ExecutionGateProvider.shared) { + self.gate = gate + } + + internal func apply( + statements: [SyncStatement], + mode: CompareSyncMode, + settings: CompareSyncExecutionSettings, + target: CompareSyncEndpoint, + driver: any PluginDatabaseDriver, + progress: Progress + ) async throws -> CompareSyncRunResult { + let runnable = statements.filter { settings.canRun($0) } + let heldBack = statements.filter { !settings.canRun($0) } + + guard !runnable.isEmpty else { + return CompareSyncRunResult( + outcomes: heldBack.map { SyncStatementOutcome(id: $0.id, statement: $0, error: nil, wasSkipped: true) }, + rolledBack: false, + cancelled: false + ) + } + + let request = OperationRequest( + connectionId: target.connectionId, + databaseType: target.databaseType, + sql: Self.digest(of: runnable), + kind: Self.kind(for: mode, statements: runnable, databaseType: target.databaseType), + caller: .userInterface, + capabilities: [.mayWrite, .mayRunDestructive, .mayRunMultiStatement, .confirmationPreCleared], + operationDescription: String( + format: String(localized: "Apply %@ sync to %@"), + mode.displayName, target.qualifiedDescription + ) + ) + + progress.totalUnitCount = Int64(runnable.count) + progress.completedUnitCount = 0 + progress.isCancellable = true + + let activity = ProcessInfo.processInfo.beginActivity( + options: [.userInitiated, .idleSystemSleepDisabled, .suddenTerminationDisabled], + reason: "Applying database sync" + ) + defer { ProcessInfo.processInfo.endActivity(activity) } + + return try await gate.authorizing(request) { + try await self.run( + runnable: runnable, + heldBack: heldBack, + mode: mode, + settings: settings, + driver: driver, + progress: progress + ) + } + } + + private func run( + runnable: [SyncStatement], + heldBack: [SyncStatement], + mode: CompareSyncMode, + settings: CompareSyncExecutionSettings, + driver: any PluginDatabaseDriver, + progress: Progress + ) async throws -> CompareSyncRunResult { + let usesTransaction = settings.usesTransaction(for: mode, driver: driver) + if usesTransaction { + try await driver.beginTransaction() + } + + var outcomes = heldBack.map { + SyncStatementOutcome(id: $0.id, statement: $0, error: nil, wasSkipped: true) + } + var completed: Int64 = 0 + var stopped = false + var cancelled = false + + for statement in runnable { + if progress.isCancelled || Task.isCancelled { + cancelled = true + break + } + do { + _ = try await driver.execute(query: statement.sql) + outcomes.append(SyncStatementOutcome( + id: statement.id, statement: statement, error: nil, wasSkipped: false + )) + } catch { + Self.logger.error("Sync statement failed: \(error.localizedDescription, privacy: .public)") + outcomes.append(SyncStatementOutcome( + id: statement.id, statement: statement, + error: error.localizedDescription, wasSkipped: false + )) + if settings.errorHandling != .skipAndContinue { + stopped = true + break + } + } + completed += 1 + if completed % Self.progressBatchSize == 0 || completed == Int64(runnable.count) { + progress.completedUnitCount = completed + } + } + progress.completedUnitCount = completed + + let shouldRollback = usesTransaction + && (cancelled || (stopped && settings.errorHandling == .stopAndRollback)) + var commitFailure: String? + if usesTransaction { + if shouldRollback { + try? await driver.rollbackTransaction() + } else { + /// A commit that throws used to propagate past the whole run, so the result was + /// discarded and the user got an error with no record of which statements had + /// already executed. The failure belongs in the result, not instead of it. + do { + try await driver.commitTransaction() + } catch { + Self.logger.error("Sync commit failed: \(error.localizedDescription, privacy: .public)") + commitFailure = error.localizedDescription + } + } + } + + return CompareSyncRunResult( + outcomes: outcomes, + rolledBack: shouldRollback, + cancelled: cancelled, + commitFailure: commitFailure + ) + } + + private static let progressBatchSize: Int64 = 25 + + private static func kind( + for mode: CompareSyncMode, + statements: [SyncStatement], + databaseType: DatabaseType + ) -> OperationKind { + guard mode == .data else { return .schemaMutation } + return OperationKind.worst(of: statements.map { $0.sql }, databaseType: databaseType) + } + + private static func digest(of statements: [SyncStatement]) -> String { + var digest = "" + var length = 0 + for statement in statements { + guard length < Self.digestCharacterLimit else { break } + digest += statement.sql + "\n" + length += (statement.sql as NSString).length + 1 + } + return digest + } + + private static let digestCharacterLimit = 10_000 +} diff --git a/TablePro/Core/Compare/CompareSyncLauncher.swift b/TablePro/Core/Compare/CompareSyncLauncher.swift new file mode 100644 index 000000000..0292191c8 --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncLauncher.swift @@ -0,0 +1,32 @@ +// +// CompareSyncLauncher.swift +// TablePro +// +// Single entry point for opening Compare & Sync, so the license gate is +// enforced in one place no matter which menu or context menu was used. +// + +import AppKit +import Foundation + +@MainActor +internal enum CompareSyncLauncher { + internal static func open(prefillSource connectionId: UUID? = nil) { + guard LicenseManager.shared.isFeatureAvailable(.compareSync) else { + presentUpgradeAlert() + return + } + WindowOpener.shared.openCompareSync(prefillSource: connectionId) + } + + private static func presentUpgradeAlert() { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = String(localized: "Compare & Sync requires a license") + alert.informativeText = ProFeature.compareSync.featureDescription + alert.addButton(withTitle: String(localized: "View Account")) + alert.addButton(withTitle: String(localized: "Cancel")) + guard alert.runModal() == .alertFirstButtonReturn else { return } + WindowOpener.shared.openSettings(tab: .account) + } +} diff --git a/TablePro/Core/Compare/CompareSyncProfileStorage.swift b/TablePro/Core/Compare/CompareSyncProfileStorage.swift new file mode 100644 index 000000000..bd8b72580 --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncProfileStorage.swift @@ -0,0 +1,182 @@ +// +// CompareSyncProfileStorage.swift +// TablePro +// +// Named comparison setups, keyed by source scope, target scope, and mode. +// +// The key used to be the pair of connection ids alone, which cannot tell two +// databases on one server apart. A profile saved under the old key is read back +// and adopted onto whatever database each connection currently points at, which +// is the same place it was saved from, so nothing is silently retargeted and +// nothing is thrown away. +// + +import Foundation +import os + +internal struct CompareSyncProfile: Codable, Hashable, Identifiable { + internal var id = UUID() + internal var name: String + internal var source: DatabaseScope + internal var target: DatabaseScope + internal var mode: CompareSyncMode + internal var includedKinds: Set + internal var structureOptions: StructureCompareOptions + internal var dataOptions: DataCompareOptions + internal var selectedObjects: [String] + + internal init( + id: UUID = UUID(), + name: String, + source: DatabaseScope, + target: DatabaseScope, + mode: CompareSyncMode, + includedKinds: Set = [.table], + structureOptions: StructureCompareOptions, + dataOptions: DataCompareOptions, + selectedObjects: [String] + ) { + self.id = id + self.name = name + self.source = source + self.target = target + self.mode = mode + self.includedKinds = includedKinds + self.structureOptions = structureOptions + self.dataOptions = dataOptions + self.selectedObjects = selectedObjects + } + + internal static func storageKey(source: DatabaseScope, target: DatabaseScope, mode: CompareSyncMode) -> String { + "\(key(for: source))|\(key(for: target))|\(mode.rawValue)" + } + + internal var storageKey: String { + Self.storageKey(source: source, target: target, mode: mode) + } + + private static func key(for scope: DatabaseScope) -> String { + "\(scope.connectionId.uuidString)/\(scope.database)/\(scope.schema ?? "")" + } +} + +extension DatabaseScope: Codable { + private enum CodingKeys: String, CodingKey { + case connectionId + case database + case schema + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + connectionId: try container.decode(UUID.self, forKey: .connectionId), + database: try container.decodeIfPresent(String.self, forKey: .database) ?? "", + schema: try container.decodeIfPresent(String.self, forKey: .schema) + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(connectionId, forKey: .connectionId) + try container.encode(database, forKey: .database) + try container.encodeIfPresent(schema, forKey: .schema) + } +} + +@MainActor +internal final class CompareSyncProfileStorage { + internal static let shared = CompareSyncProfileStorage() + + private static let logger = Logger(subsystem: "com.TablePro", category: "CompareSyncProfileStorage") + private static let defaultsKey = "compareSyncProfiles" + + private let defaults: UserDefaults + + internal init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + internal func allProfiles() -> [CompareSyncProfile] { + guard let data = defaults.data(forKey: Self.defaultsKey) else { return [] } + do { + return try JSONDecoder().decode([CompareSyncProfile].self, from: data) + } catch { + Self.logger.error("Failed to decode profiles: \(error.localizedDescription, privacy: .public)") + return migrateLegacyProfiles(from: data) + } + } + + internal func profiles(source: DatabaseScope, target: DatabaseScope, mode: CompareSyncMode) -> [CompareSyncProfile] { + let key = CompareSyncProfile.storageKey(source: source, target: target, mode: mode) + return allProfiles().filter { $0.storageKey == key } + } + + internal func save(_ profile: CompareSyncProfile) { + var profiles = allProfiles() + if let index = profiles.firstIndex(where: { $0.id == profile.id }) { + profiles[index] = profile + } else { + profiles.append(profile) + } + persist(profiles) + } + + internal func delete(_ profile: CompareSyncProfile) { + persist(allProfiles().filter { $0.id != profile.id }) + } + + private func persist(_ profiles: [CompareSyncProfile]) { + do { + defaults.set(try JSONEncoder().encode(profiles), forKey: Self.defaultsKey) + } catch { + Self.logger.error("Failed to persist profiles: \(error.localizedDescription, privacy: .public)") + } + } + + /// A profile written before an endpoint carried a database is adopted onto whichever database + /// its connections currently point at, which is where it was saved from. Discarding them + /// instead would lose setups the user had already named. + private func migrateLegacyProfiles(from data: Data) -> [CompareSyncProfile] { + guard let legacy = try? JSONDecoder().decode([LegacyProfile].self, from: data) else { return [] } + let connections = ConnectionStorage.shared.loadConnections() + let databaseByConnection = Dictionary( + connections.map { ($0.id, $0.database ?? "") }, uniquingKeysWith: { first, _ in first } + ) + let migrated = legacy.map { entry in + CompareSyncProfile( + id: entry.id, + name: entry.name, + source: DatabaseScope( + connectionId: entry.sourceConnectionId, + database: databaseByConnection[entry.sourceConnectionId] ?? "", + schema: nil + ), + target: DatabaseScope( + connectionId: entry.targetConnectionId, + database: databaseByConnection[entry.targetConnectionId] ?? "", + schema: nil + ), + mode: entry.mode, + includedKinds: [.table], + structureOptions: entry.structureOptions, + dataOptions: entry.dataOptions, + selectedObjects: entry.selectedTables + ) + } + persist(migrated) + Self.logger.notice("Migrated \(migrated.count, privacy: .public) saved comparisons onto database scopes") + return migrated + } + + private struct LegacyProfile: Codable { + let id: UUID + let name: String + let sourceConnectionId: UUID + let targetConnectionId: UUID + let mode: CompareSyncMode + let structureOptions: StructureCompareOptions + let dataOptions: DataCompareOptions + let selectedTables: [String] + } +} diff --git a/TablePro/Core/Compare/CompareSyncRunRegistry.swift b/TablePro/Core/Compare/CompareSyncRunRegistry.swift new file mode 100644 index 000000000..1c4a87aa3 --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncRunRegistry.swift @@ -0,0 +1,34 @@ +// +// CompareSyncRunRegistry.swift +// TablePro +// +// Tracks sessions that are mid-apply so closing the window or quitting the app +// can warn before a half-applied script is abandoned. +// + +import Foundation + +@MainActor +internal final class CompareSyncRunRegistry { + internal static let shared = CompareSyncRunRegistry() + + private var applyingTargets: [ObjectIdentifier: String] = [:] + + private init() {} + + internal func markApplying(_ session: CompareSyncSession, target: String) { + applyingTargets[ObjectIdentifier(session)] = target + } + + internal func clear(_ session: CompareSyncSession) { + applyingTargets.removeValue(forKey: ObjectIdentifier(session)) + } + + internal var isApplying: Bool { + !applyingTargets.isEmpty + } + + internal var applyingTargetNames: [String] { + applyingTargets.values.sorted() + } +} diff --git a/TablePro/Core/Compare/CompareSyncSession+Editing.swift b/TablePro/Core/Compare/CompareSyncSession+Editing.swift new file mode 100644 index 000000000..22f92a7d1 --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncSession+Editing.swift @@ -0,0 +1,146 @@ +// +// CompareSyncSession+Editing.swift +// TablePro +// +// Editing a comparison after a first run: which columns identify a row, which +// columns take part, which rows are excluded, and the saved setups. +// +// Every edit here clears the affected result rather than adjusting it. A key +// change means a different join, so the previous answer is not a stale version +// of the new one, it is an answer to a different question. +// + +import Foundation + +internal extension CompareSyncSession { + // MARK: - Key columns + + func setKeyColumns(_ columns: [String], for planId: String) { + guard let index = dataPlans.firstIndex(where: { $0.id == planId }) else { return } + dataPlans[index].keyColumns = columns + dataPlans[index].summary = nil + dataPlans[index].excludedRowKeys = [] + dataPlans[index].unavailableReason = DataComparePlan.unavailableReason(for: dataPlans[index]) + invalidateScript() + } + + func toggleKeyColumn(_ column: String, for planId: String) { + guard let plan = dataPlans.first(where: { $0.id == planId }) else { return } + var columns = plan.keyColumns + if let existing = columns.firstIndex(where: { $0.caseInsensitiveCompare(column) == .orderedSame }) { + columns.remove(at: existing) + } else { + columns.append(column) + } + setKeyColumns(columns, for: planId) + } + + func setPlanEnabled(_ enabled: Bool, for planId: String) { + guard let index = dataPlans.firstIndex(where: { $0.id == planId }) else { return } + dataPlans[index].isEnabled = enabled + invalidateScript() + } + + func setAllPlansEnabled(_ enabled: Bool) { + for index in dataPlans.indices where dataPlans[index].isComparable { + dataPlans[index].isEnabled = enabled + } + invalidateScript() + } + + // MARK: - Comparison columns + + func isColumnCompared(_ column: String) -> Bool { + !dataOptions.excludedFromComparison.contains { $0.caseInsensitiveCompare(column) == .orderedSame } + } + + func toggleComparedColumn(_ column: String) { + if let existing = dataOptions.excludedFromComparison + .first(where: { $0.caseInsensitiveCompare(column) == .orderedSame }) { + dataOptions.excludedFromComparison.remove(existing) + } else { + dataOptions.excludedFromComparison.insert(column) + } + clearDataSummaries() + } + + /// A row exclusion is keyed on the row's identity, which only the key columns decide. Changing + /// which columns take part in the comparison asks a different question of the same rows, so the + /// answers are discarded and the exclusions are not: wiping them threw away every per-row + /// decision in every table because one `updated_at` was unticked. `setKeyColumns` does clear + /// them, because a new key really does mean different rows. + func clearDataSummaries() { + for index in dataPlans.indices { + dataPlans[index].summary = nil + } + invalidateScript() + } + + // MARK: - Row inclusion + + /// Row-level exclusion can only name a row the review pane actually showed. Past the retention + /// cap the script is built from a fresh streamed pass, so an unseen row is included by + /// definition; the pane says as much rather than implying the list is the whole difference. + func isRowIncluded(_ entry: RowDiffEntry, in plan: DataComparePlan) -> Bool { + !plan.excludedRowKeys.contains(entry.keyIdentity) + } + + func setRowIncluded(_ included: Bool, entry: RowDiffEntry, planId: String) { + guard let index = dataPlans.firstIndex(where: { $0.id == planId }) else { return } + if included { + dataPlans[index].excludedRowKeys.remove(entry.keyIdentity) + } else { + dataPlans[index].excludedRowKeys.insert(entry.keyIdentity) + } + invalidateScript() + } + + var needsRecompare: Bool { + guard mode == .data, !dataPlans.isEmpty else { return false } + return dataPlans.contains { $0.isEnabled && $0.isComparable && $0.summary == nil } + } + + // MARK: - Saved comparisons + + var savedProfiles: [CompareSyncProfile] { + guard let source, let target else { return [] } + return CompareSyncProfileStorage.shared.profiles(source: source.scope, target: target.scope, mode: mode) + } + + func saveProfile(named name: String) { + guard let source, let target, !name.trimmingCharacters(in: .whitespaces).isEmpty else { return } + let profile = CompareSyncProfile( + name: name, + source: source.scope, + target: target.scope, + mode: mode, + includedKinds: includedKinds, + structureOptions: structureOptions, + dataOptions: dataOptions, + selectedObjects: selectedObjectIdentifiers + ) + CompareSyncProfileStorage.shared.save(profile) + } + + func apply(_ profile: CompareSyncProfile) { + mode = profile.mode + includedKinds = profile.includedKinds.isEmpty ? [.table] : profile.includedKinds + structureOptions = profile.structureOptions + dataOptions = profile.dataOptions + resetComparison() + pendingSelection = Set(profile.selectedObjects) + } + + func deleteProfile(_ profile: CompareSyncProfile) { + CompareSyncProfileStorage.shared.delete(profile) + } + + private var selectedObjectIdentifiers: [String] { + switch mode { + case .structure: + return actions.filter { $0.value != .skip }.map { $0.key }.sorted() + case .data: + return dataPlans.filter { $0.isEnabled }.map { $0.id }.sorted() + } + } +} diff --git a/TablePro/Core/Compare/CompareSyncSession.swift b/TablePro/Core/Compare/CompareSyncSession.swift new file mode 100644 index 000000000..6a963c2f0 --- /dev/null +++ b/TablePro/Core/Compare/CompareSyncSession.swift @@ -0,0 +1,427 @@ +// +// CompareSyncSession.swift +// TablePro +// +// The state of one comparison. It holds no driver and performs no I/O: that is +// `CompareRunner`'s job, the way `QueryExecutionCoordinator` holds a query tab's +// state and `QueryExecutor` does the talking. +// +// There are no steps. The window shows the setup, the results and the script at +// once, and an action is available when its preconditions hold, so re-running a +// comparison is one click rather than walking backwards through a wizard. +// + +import Foundation +import Observation +import os + +internal enum CompareSyncActivity: Equatable { + case idle + case connecting + case comparing + case applying +} + +internal enum CompareSyncLastAction: Equatable { + case none + case compared(Date, differences: Int) + case applied(Date, target: String, statements: Int) +} + +internal enum CompareDetailPane: String, CaseIterable, Hashable { + case definitions + case rows + case script + + internal var title: String { + switch self { + case .definitions: return String(localized: "Definitions") + case .rows: return String(localized: "Rows") + case .script: return String(localized: "Script") + } + } +} + +@MainActor +@Observable +internal final class CompareSyncSession { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "CompareSyncSession") + + // MARK: - Setup + + internal var mode: CompareSyncMode = .structure + internal var source: CompareSyncEndpoint? + internal var target: CompareSyncEndpoint? + internal var structureOptions = StructureCompareOptions.default + internal var dataOptions = DataCompareOptions.default + internal var executionSettings = CompareSyncExecutionSettings() + internal var includedKinds: Set = [.table] + + // MARK: - Results + + internal var report: CompareReport? + internal var dataPlans: [DataComparePlan] = [] + internal var actions: [String: TableSyncAction] = [:] + internal var statements: [SyncStatement] = [] + internal var runResult: CompareSyncRunResult? + internal var sourceSnapshots: [String: TableStructureSnapshot] = [:] + internal var targetSnapshots: [String: TableStructureSnapshot] = [:] + + // MARK: - Presentation + + internal var selectedObjectId: String? + internal var selectedPlanId: String? + internal var detailPane: CompareDetailPane = .definitions + internal var searchText = "" + internal var showsIdentical = false + internal var grouping: CompareGrouping = .byDifference + + // MARK: - Activity + + internal var activity: CompareSyncActivity = .idle + internal var errorMessage: String? + internal var informationalMessage: String? + internal var lastAction: CompareSyncLastAction = .none + internal var progress: Progress? + internal var hasWrittenToTarget = false + + /// True once a script has run against the target, until the next comparison. + internal var isStaleAfterApply = false + internal var runTask: Task? + internal var pendingSelection: Set = [] + + internal init() {} + + // MARK: - Direction + + internal var directionSentence: String? { + guard let source, let target else { return nil } + return String( + format: String(localized: "Compare %@ and write changes to %@."), + source.qualifiedDescription, target.qualifiedDescription + ) + } + + internal var canSwap: Bool { + source != nil || target != nil + } + + internal func swapEndpoints() { + let previousSource = source + source = target + target = previousSource + resetComparison() + } + + internal var canCompare: Bool { + compareDisabledReason == nil + } + + internal var canGenerateStructureScript: Bool { + guard mode == .structure, let source, let target else { return false } + return CompareSyncEngineFamily.canGenerateStructureScript(from: source.databaseType, to: target.databaseType) + } + + internal var crossEngineNotice: String? { + guard let source, let target else { return nil } + if mode == .structure, !canGenerateStructureScript { + return CompareSyncEngineFamily.structureScriptRefusal(from: source.databaseType, to: target.databaseType) + } + return CompareSyncEngineFamily.crossEngineDataWarning(from: source.databaseType, to: target.databaseType) + } + + // MARK: - Banner + + internal var bannerText: String { + switch activity { + case .applying: + return String(format: String(localized: "Applying to %@…"), target?.qualifiedDescription ?? "") + case .comparing, .connecting: + return String(localized: "Comparing only. Nothing has been written.") + case .idle: + return idleBannerText + } + } + + private var idleBannerText: String { + switch lastAction { + case .none: + return String(localized: "Comparing only. Nothing has been written.") + case .compared(let date, let differences): + /// The count is not the first argument, so a plural variation on the format string + /// cannot key on it; the singular is chosen here instead. Without this the strip read + /// "1 differences". + let time = Self.timeFormatter.string(from: date) + guard differences != 1 else { + return String( + format: String(localized: "Compared %@. 1 difference. Nothing has been written."), time + ) + } + return String( + format: String(localized: "Compared %@. %d differences. Nothing has been written."), + time, differences + ) + case .applied(let date, let name, let statements): + let time = Self.timeFormatter.string(from: date) + guard statements != 1 else { + return String(format: String(localized: "Applied to %@ at %@. 1 statement."), name, time) + } + return String( + format: String(localized: "Applied to %@ at %@. %d statements."), name, time, statements + ) + } + } + + // MARK: - Selection + + internal func action(for result: CompareObjectResult) -> TableSyncAction { + actions[result.id] ?? .skip + } + + internal func isIncluded(_ result: CompareObjectResult) -> Bool { + action(for: result) != .skip + } + + internal func setAction(_ action: TableSyncAction, for result: CompareObjectResult) { + actions[result.id] = action + invalidateScript() + } + + internal func setIncluded(_ included: Bool, for result: CompareObjectResult) { + setAction(included ? result.suggestedAction : .skip, for: result) + } + + internal func setIncluded(_ included: Bool, forIds ids: [String]) { + guard let report else { return } + let byId = Dictionary(report.comparable.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + for id in ids { + guard let result = byId[id] else { continue } + actions[result.id] = included ? result.suggestedAction : .skip + } + invalidateScript() + } + + internal var selectedObjectCount: Int { + switch mode { + case .structure: + return actions.values.filter { $0 != .skip }.count + case .data: + return dataPlans.filter { $0.isEnabled && $0.isComparable && ($0.summary?.differenceCount ?? 0) > 0 }.count + } + } + + internal var canBuildScript: Bool { + scriptDisabledReason == nil + } + + internal var canApply: Bool { + applyDisabledReason == nil + } + + // MARK: - Why an action is unavailable + + /// The HIG asks an app to "show people when a command can't be carried out and help people + /// understand why", and to give a disabled control a tooltip naming the unmet precondition + /// rather than repeating its own name. Validation used to compute these conditions and throw + /// the reason away, returning a bare Bool, so every disabled toolbar item said only what it + /// was called. + internal var compareDisabledReason: String? { + if isBusy { return String(localized: "A comparison is already running.") } + guard let source else { return String(localized: "Choose a source to compare from.") } + guard let target else { return String(localized: "Choose a target to compare against.") } + if let refusal = target.ineligibleAsTargetReason { return refusal } + guard source.id != target.id else { + return String(localized: "The source and the target are the same database.") + } + return nil + } + + internal var scriptDisabledReason: String? { + if isBusy { return String(localized: "A comparison is already running.") } + if isStaleAfterApply { + return String(localized: "The script already ran. Compare again to see where the target stands.") + } + if report == nil, dataPlans.isEmpty { + return String(localized: "Compare the two databases first.") + } + if mode == .structure, !canGenerateStructureScript { + return crossEngineNotice ?? String(localized: "These two engines cannot share a script.") + } + guard selectedObjectCount > 0 else { + return mode == .structure + ? String(localized: "Include at least one object to generate a script for it.") + : String(localized: "Include at least one table to generate a script for it.") + } + return nil + } + + internal var applyDisabledReason: String? { + if isBusy { return String(localized: "A run is already in progress.") } + if isStaleAfterApply { + return String(localized: "The script already ran. Compare again to see where the target stands.") + } + guard target?.canBeWrittenTo == true else { + return target?.ineligibleAsTargetReason ?? String(localized: "Choose a target to write to.") + } + guard !statements.isEmpty else { return String(localized: "Generate the script first.") } + guard unacknowledgedHazardCount == 0 else { + guard unacknowledgedHazardCount != 1 else { + return String(localized: "1 statement would destroy data and is not allowed yet.") + } + return String( + format: String(localized: "%d statements would destroy data and are not allowed yet."), + unacknowledgedHazardCount + ) + } + return nil + } + + /// A statement carrying an unacknowledged hazard is why Apply stays disabled rather than + /// silently dropping it: the count the user is about to run has to be the count they saw. + internal var unacknowledgedHazardCount: Int { + statements.filter { $0.isRefusedByDefault && !executionSettings.canRun($0) }.count + } + + internal var runnableStatementCount: Int { + statements.filter { executionSettings.canRun($0) }.count + } + + // MARK: - Results view + + internal var visibleResults: [CompareObjectResult] { + guard let report else { return [] } + var results = report.comparable.filter { includedKinds.contains($0.identity.kind) } + if !showsIdentical { + results = results.filter { $0.status != .identical } + } + let query = searchText.trimmingCharacters(in: .whitespaces) + guard !query.isEmpty else { return results } + return results.filter { $0.identity.displayName.localizedCaseInsensitiveContains(query) } + } + + internal var selectedResult: CompareObjectResult? { + guard let selectedObjectId else { return nil } + return report?.results.first { $0.id == selectedObjectId } + } + + internal var selectedPlan: DataComparePlan? { + guard let selectedPlanId else { return nil } + return dataPlans.first { $0.id == selectedPlanId } + } + + /// What the window's bottom status bar reads. The HIG names a count of a window's contents as + /// the sanctioned use of a bottom bar, with Finder's item and selection counts as the example. + internal var statusCounts: [CompareStatusCount] { + switch mode { + case .structure: + guard let report else { return [] } + return [ + CompareStatusCount(status: .onlyInSource, count: report.count(of: .onlyInSource)), + CompareStatusCount(status: .differs, count: report.count(of: .differs)), + CompareStatusCount(status: .onlyInTarget, count: report.count(of: .onlyInTarget)), + CompareStatusCount(status: .identical, count: report.count(of: .identical)) + ] + case .data: + guard !dataPlans.isEmpty else { return [] } + let compared = dataPlans.compactMap { $0.summary } + return [ + CompareStatusCount(status: .onlyInSource, count: compared.reduce(0) { $0 + $1.insertCount }), + CompareStatusCount(status: .differs, count: compared.reduce(0) { $0 + $1.updateCount }), + CompareStatusCount(status: .onlyInTarget, count: compared.reduce(0) { $0 + $1.deleteCount }), + CompareStatusCount(status: .identical, count: compared.reduce(0) { $0 + $1.identicalCount }) + ] + } + } + + internal var includedCount: Int { + selectedObjectCount + } + + internal var truncatedPlanNames: [String] { + dataPlans.filter { $0.summary?.truncatedEntries == true }.map { $0.id } + } + + internal var dataDifferenceTotal: Int { + dataPlans.compactMap { $0.summary?.differenceCount }.reduce(0, +) + } + + // MARK: - Lifecycle + + /// `lastAction` is what the banner reads, so it is part of the result and is cleared with it. + /// Leaving it behind made the banner claim "52 differences" over a pane reading "No Comparison + /// Yet": changing an endpoint correctly discarded the answer, and the banner kept advertising + /// it. `hasWrittenToTarget` deliberately survives, because a write already happened and no + /// later comparison makes that untrue. + internal func resetComparison() { + report = nil + sourceSnapshots = [:] + targetSnapshots = [:] + dataPlans = [] + actions = [:] + selectedObjectId = nil + selectedPlanId = nil + runResult = nil + errorMessage = nil + informationalMessage = nil + lastAction = .none + isStaleAfterApply = false + invalidateScript() + } + + /// After a run the report describes a target that has since changed, so it is stale rather than + /// wrong: it stays on screen to be read, and every action that would write again is withdrawn + /// until the user compares once more. + internal func markAppliedAndStale() { + statements = [] + actions = [:] + for index in dataPlans.indices { + dataPlans[index].isEnabled = false + } + isStaleAfterApply = true + } + + internal func invalidateScript() { + statements = [] + runResult = nil + } + + internal func cancelRunningWork() { + progress?.cancel() + runTask?.cancel() + } + + internal var isBusy: Bool { + activity != .idle + } + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.dateStyle = .none + return formatter + }() +} + +internal enum CompareGrouping: String, CaseIterable, Hashable { + case byDifference + case byObjectType + case none + + internal var title: String { + switch self { + case .byDifference: return String(localized: "Difference") + case .byObjectType: return String(localized: "Object Type") + case .none: return String(localized: "None") + } + } +} + +/// One cell of the bottom status bar. Data mode reuses the structure vocabulary deliberately: an +/// insert is a row that exists only on the source, a delete only on the target, so the same four +/// words describe both comparisons and the bar does not change shape between modes. +internal struct CompareStatusCount: Identifiable, Hashable { + internal let status: TableDiffStatus + internal let count: Int + + internal var id: String { status.rawValue } +} diff --git a/TablePro/Core/Compare/DataCompareOptions.swift b/TablePro/Core/Compare/DataCompareOptions.swift new file mode 100644 index 000000000..6b34db990 --- /dev/null +++ b/TablePro/Core/Compare/DataCompareOptions.swift @@ -0,0 +1,204 @@ +// +// DataCompareOptions.swift +// TablePro +// +// How two rows are matched and how two values are judged equal. +// The set of columns used to compare is deliberately separate from the set +// written: an audit column can be excluded from matching while still being +// carried into the generated statement. +// + +import Foundation +import TableProPluginKit + +internal struct DataCompareOptions: Codable, Hashable, Sendable { + internal var keyColumns: [String] = [] + internal var excludedFromComparison: Set = [] + internal var insertMissingRows = true + internal var updateDifferingRows = true + internal var deleteExtraRows = false + internal var floatTolerance: Double = 0 + internal var timestampFractionalDigits = 6 + internal var maxRetainedEntries = 5_000 + + internal init() {} + + internal static let `default` = DataCompareOptions() + + internal var hasKey: Bool { + !keyColumns.isEmpty + } + + internal func comparisonColumns(from columns: [String]) -> [String] { + let keys = Set(keyColumns.map { $0.lowercased() }) + return columns.filter { column in + let lowered = column.lowercased() + return !keys.contains(lowered) && !excludedFromComparison.contains(where: { $0.lowercased() == lowered }) + } + } +} + +internal enum ComparisonRule: String, Codable, Hashable, Sendable { + case exactValue + case nullEquality + case floatTolerance + case timestampPrecision + case binaryContent + case typeMismatch + + internal var displayName: String { + switch self { + case .exactValue: + return String(localized: "Exact value") + case .nullEquality: + return String(localized: "NULL only equals NULL") + case .floatTolerance: + return String(localized: "Numeric tolerance") + case .timestampPrecision: + return String(localized: "Timestamp precision") + case .binaryContent: + return String(localized: "Binary content") + case .typeMismatch: + return String(localized: "Value kind differs") + } + } +} + +internal struct ValueComparison { + internal let isEqual: Bool + internal let rule: ComparisonRule +} + +internal struct CellValueComparator { + private let options: DataCompareOptions + + internal init(options: DataCompareOptions) { + self.options = options + } + + internal func compare(_ lhs: PluginCellValue, _ rhs: PluginCellValue) -> ValueComparison { + switch (lhs, rhs) { + case (.null, .null): + return ValueComparison(isEqual: true, rule: .nullEquality) + case (.null, _), (_, .null): + return ValueComparison(isEqual: false, rule: .nullEquality) + case (.bytes(let left), .bytes(let right)): + return ValueComparison(isEqual: left == right, rule: .binaryContent) + case (.text(let left), .text(let right)): + return compareText(left, right) + default: + return ValueComparison(isEqual: false, rule: .typeMismatch) + } + } + + private func compareText(_ lhs: String, _ rhs: String) -> ValueComparison { + if lhs == rhs { + return ValueComparison(isEqual: true, rule: .exactValue) + } + if options.floatTolerance > 0, + let left = Double(lhs.trimmingCharacters(in: .whitespaces)), + let right = Double(rhs.trimmingCharacters(in: .whitespaces)) { + let equal = (left - right).magnitude <= options.floatTolerance + return ValueComparison(isEqual: equal, rule: .floatTolerance) + } + if let left = TimestampValue.parse(lhs), let right = TimestampValue.parse(rhs) { + let equal = left.equals(right, fractionalDigits: options.timestampFractionalDigits) + return ValueComparison(isEqual: equal, rule: .timestampPrecision) + } + return ValueComparison(isEqual: false, rule: .exactValue) + } +} + +/// An instant, held as whole nanoseconds since the epoch. +/// +/// `DateFormatter` clamps a fractional second to milliseconds whatever the pattern says, so +/// parsing `10:00:00.123456` and `10:00:00.123457` through it produced the same value and every +/// microsecond difference read as identical no matter what precision the user asked for. The +/// fraction is therefore split off and parsed as an integer, and the comparison stays in integer +/// arithmetic: scaling a `Double` seconds value by 1e9 leaves the exact-integer range and puts +/// the same precision loss back into the path that was just fixed. +internal struct TimestampValue: Hashable { + internal let nanosecondsSinceEpoch: Int64 + + internal func equals(_ other: TimestampValue, fractionalDigits: Int) -> Bool { + let divisor = Self.divisor(forFractionalDigits: fractionalDigits) + return Self.floorDivide(nanosecondsSinceEpoch, by: divisor) + == Self.floorDivide(other.nanosecondsSinceEpoch, by: divisor) + } + + internal static func parse(_ raw: String) -> TimestampValue? { + let trimmed = raw.trimmingCharacters(in: .whitespaces) + guard trimmed.count >= 10 else { return nil } + let split = FractionalSecond.split(from: trimmed) + for formatter in Self.formatters { + guard let date = formatter.date(from: split.withoutFraction) else { continue } + let seconds = date.timeIntervalSince1970.rounded() + guard seconds.magnitude < Double(Int64.max / Self.nanosecondsPerSecond) else { return nil } + return TimestampValue( + nanosecondsSinceEpoch: Int64(seconds) * Self.nanosecondsPerSecond + split.nanoseconds + ) + } + return nil + } + + private static func divisor(forFractionalDigits digits: Int) -> Int64 { + let clamped = max(0, min(9, digits)) + var divisor: Int64 = 1 + for _ in 0 ..< (9 - clamped) { divisor *= 10 } + return divisor + } + + private static func floorDivide(_ value: Int64, by divisor: Int64) -> Int64 { + let quotient = value / divisor + return value % divisor < 0 ? quotient - 1 : quotient + } + + private static let nanosecondsPerSecond: Int64 = 1_000_000_000 + + private static let formatters: [DateFormatter] = { + let patterns = [ + "yyyy-MM-dd HH:mm:ssXXXXX", + "yyyy-MM-dd'T'HH:mm:ssXXXXX", + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd" + ] + return patterns.map { pattern in + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = pattern + return formatter + } + }() +} + +/// Splits `.123456` off a timestamp so the whole-second part can go through `DateFormatter` and +/// the fraction can be read exactly. Only a dot followed by digits after the time counts, so a +/// date alone and an offset like `+05:30` both pass through untouched. +internal enum FractionalSecond { + internal struct Split { + internal let withoutFraction: String + internal let nanoseconds: Int64 + } + + internal static func split(from text: String) -> Split { + guard let dot = text.firstIndex(of: "."), dot > text.startIndex else { + return Split(withoutFraction: text, nanoseconds: 0) + } + let afterDot = text.index(after: dot) + let digits = text[afterDot...].prefix { $0.isASCII && $0.isNumber } + guard !digits.isEmpty else { return Split(withoutFraction: text, nanoseconds: 0) } + + var withoutFraction = String(text[text.startIndex ..< dot]) + withoutFraction += text[text.index(afterDot, offsetBy: digits.count)...] + return Split(withoutFraction: withoutFraction, nanoseconds: nanoseconds(from: digits)) + } + + private static func nanoseconds(from digits: Substring) -> Int64 { + let significant = digits.prefix(9) + guard var value = Int64(significant) else { return 0 } + for _ in 0 ..< (9 - significant.count) { value *= 10 } + return value + } +} diff --git a/TablePro/Core/Compare/DataComparePlan.swift b/TablePro/Core/Compare/DataComparePlan.swift new file mode 100644 index 000000000..713e6616b --- /dev/null +++ b/TablePro/Core/Compare/DataComparePlan.swift @@ -0,0 +1,134 @@ +// +// DataComparePlan.swift +// TablePro +// +// One table's data comparison: which columns identify a row, which take part +// in the comparison, and which get written. +// +// Those three sets are deliberately different. A generated column is read and +// compared but never written, because an engine rejects an explicit value for +// one. An excluded column is written but not compared, which is what lets an +// `updated_at` be carried across without every row reading as different. +// + +import Foundation +import TableProPluginKit + +internal struct DataComparePlan: Identifiable, Hashable, Sendable { + internal let table: String + internal let schema: String? + + /// The counterpart's schema, which is not always the source's. Reading the target with the + /// source's schema name is how a comparison of `audit.users` read `public.users`' columns. + internal let targetSchema: String? + internal var columns: [String] + internal var columnDescriptors: [KeyColumnDescriptor] + internal var generatedColumns: Set + internal var keyColumns: [String] + internal var isEnabled: Bool + internal var unavailableReason: String? + internal var summary: DataDiffSummary? + internal var excludedRowKeys: Set + + internal init( + table: String, + schema: String?, + targetSchema: String? = nil, + columns: [String], + columnDescriptors: [KeyColumnDescriptor] = [], + generatedColumns: Set = [], + keyColumns: [String], + isEnabled: Bool, + unavailableReason: String? = nil, + summary: DataDiffSummary? = nil, + excludedRowKeys: Set = [] + ) { + self.table = table + self.schema = schema + self.targetSchema = targetSchema ?? schema + self.columns = columns + self.columnDescriptors = columnDescriptors + /// Normalised here rather than at the call site: every comparison is case-insensitive, and + /// a caller that passed the engine's own spelling would silently write a generated column. + self.generatedColumns = Set(generatedColumns.map { $0.lowercased() }) + self.keyColumns = keyColumns + self.isEnabled = isEnabled + self.unavailableReason = unavailableReason + self.summary = summary + self.excludedRowKeys = excludedRowKeys + } + + internal var id: String { + guard let schema, !schema.isEmpty else { return table } + return "\(schema).\(table)" + } + + internal var isComparable: Bool { + unavailableReason == nil + } + + /// Every shared column is read: a value is needed to compare it or to write it, and a second + /// pass to fetch the rest would double the round trips. + internal var readColumns: [String] { + columns + } + + /// A generated column is computed by the engine. MySQL rejects an explicit value for one + /// outright, and PostgreSQL rejects it for a stored generated column, so it never appears in + /// an INSERT or UPDATE column list. + internal var writeColumns: [String] { + columns.filter { !generatedColumns.contains($0.lowercased()) } + } + + internal var comparisonColumnNames: [String] { + columns + } + + internal var keyDescriptors: [KeyColumnDescriptor] { + let wanted = Set(keyColumns.map { $0.lowercased() }) + return columnDescriptors.filter { wanted.contains($0.name.lowercased()) } + } + + internal func isKeyColumn(_ column: String) -> Bool { + keyColumns.contains { $0.caseInsensitiveCompare(column) == .orderedSame } + } + + internal func isGeneratedColumn(_ column: String) -> Bool { + generatedColumns.contains(column.lowercased()) + } + + internal static func == (lhs: DataComparePlan, rhs: DataComparePlan) -> Bool { + lhs.id == rhs.id + && lhs.keyColumns == rhs.keyColumns + && lhs.isEnabled == rhs.isEnabled + && lhs.excludedRowKeys == rhs.excludedRowKeys + && lhs.summary?.differenceCount == rhs.summary?.differenceCount + } + + internal func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +internal extension DataComparePlan { + static func unavailableReason(for plan: DataComparePlan) -> String? { + if plan.columns.isEmpty { + return String(localized: "No columns in common.") + } + if plan.keyColumns.isEmpty { + return String(localized: "No primary key. Choose key columns to compare this table.") + } + let available = Set(plan.columns.map { $0.lowercased() }) + let missing = plan.keyColumns.filter { !available.contains($0.lowercased()) } + guard missing.isEmpty else { + return String( + format: String(localized: "Key column %@ is not present on both sides. Choose a different key."), + missing.joined(separator: ", ") + ) + } + if plan.writeColumns.isEmpty { + return String(localized: "Every shared column is generated, so there is nothing to write.") + } + return nil + } +} diff --git a/TablePro/Core/Compare/DataDiffEngine.swift b/TablePro/Core/Compare/DataDiffEngine.swift new file mode 100644 index 000000000..8e1026c04 --- /dev/null +++ b/TablePro/Core/Compare/DataDiffEngine.swift @@ -0,0 +1,344 @@ +// +// DataDiffEngine.swift +// TablePro +// +// Key-ordered merge join over two row providers. Both sides are read in key +// order and walked in lockstep, so neither side is ever materialized and no +// server-side hash function has to agree between two engines. +// +// The walk depends on the client comparator agreeing with the order the server +// sent rows in. See KeyOrdering: numeric keys agree by construction, and any +// other key is verified as the stream is read, so a disagreeing collation is +// reported instead of silently producing a delete for a row that exists on both +// sides. +// + +import Foundation +import TableProPluginKit + +internal struct DataRow: Hashable, Sendable { + internal let values: [String: PluginCellValue] + + internal func value(for column: String) -> PluginCellValue { + if let exact = values[column] { return exact } + let lowered = column.lowercased() + guard let match = values.first(where: { $0.key.lowercased() == lowered }) else { return .null } + return match.value + } +} + +internal protocol DataRowProviding: AnyObject { + func nextRow() async throws -> DataRow? +} + +internal enum RowDiffKind: String, Codable, Hashable, Sendable { + case insert + case update + case delete + case identical +} + +internal struct CellDifference: Hashable, Sendable { + internal let column: String + internal let rule: ComparisonRule + internal let sourceValue: PluginCellValue + internal let targetValue: PluginCellValue +} + +internal struct RowDiffEntry: Identifiable, Hashable, Sendable { + internal let id: UUID + internal let kind: RowDiffKind + internal let keyDescription: String + internal let keyIdentity: String + internal let sourceRow: DataRow? + internal let targetRow: DataRow? + internal let cellDifferences: [CellDifference] + + internal init( + id: UUID = UUID(), + kind: RowDiffKind, + keyDescription: String, + keyIdentity: String? = nil, + sourceRow: DataRow?, + targetRow: DataRow?, + cellDifferences: [CellDifference] = [] + ) { + self.id = id + self.kind = kind + self.keyDescription = keyDescription + self.keyIdentity = keyIdentity ?? keyDescription + self.sourceRow = sourceRow + self.targetRow = targetRow + self.cellDifferences = cellDifferences + } +} + +internal struct DataDiffSummary: Hashable, Sendable { + internal let insertCount: Int + internal let updateCount: Int + internal let deleteCount: Int + internal let identicalCount: Int + internal let skippedNullKeyCount: Int + internal let entries: [RowDiffEntry] + internal let truncatedEntries: Bool + + internal var differenceCount: Int { + insertCount + updateCount + deleteCount + } + + internal var totalCount: Int { + differenceCount + identicalCount + } +} + +internal struct KeyedRow { + internal let row: DataRow + internal let key: [PluginCellValue] +} + +internal enum ComparisonSide: String { + case source + case target + + internal var displayName: String { + switch self { + case .source: return String(localized: "source") + case .target: return String(localized: "target") + } + } +} + +internal struct DataDiffEngine { + private let options: DataCompareOptions + private let comparator: CellValueComparator + private let comparisonColumns: [String] + private let ordering: KeyOrdering + + internal init( + options: DataCompareOptions, + columns: [String], + keyDescriptors: [KeyColumnDescriptor] = [] + ) { + self.options = options + self.comparator = CellValueComparator(options: options) + self.comparisonColumns = options.comparisonColumns(from: columns) + self.ordering = KeyOrdering( + orders: KeyOrdering.orders(for: options.keyColumns, descriptors: keyDescriptors) + ) + } + + /// `onEntry` sees every entry the walk produces, before the accumulator's retention cap. + /// Script generation runs the walk a second time with a sink rather than reading the capped + /// entry list, because that list is a preview: building the script from it emitted 5,000 + /// statements for a 12,000-row difference and reported success. + internal func compare( + source: DataRowProviding, + target: DataRowProviding, + onEntry: ((RowDiffEntry) throws -> Void)? = nil + ) async throws -> DataDiffSummary { + guard options.hasKey else { + throw CompareSyncError.noComparisonKey(String(localized: "Choose a key column before comparing data.")) + } + + var accumulator = Accumulator(limit: options.maxRetainedEntries) + let sourceReader = KeyedRowReader( + provider: source, keyColumns: options.keyColumns, ordering: ordering, side: .source + ) + let targetReader = KeyedRowReader( + provider: target, keyColumns: options.keyColumns, ordering: ordering, side: .target + ) + + var left = try await sourceReader.next(&accumulator) + var right = try await targetReader.next(&accumulator) + + func record(_ entry: RowDiffEntry) throws { + accumulator.add(entry) + try onEntry?(entry) + } + + while left != nil || right != nil { + try Task.checkCancellation() + + guard let sourceEntry = left else { + try record(deleteEntry(for: right)) + right = try await targetReader.next(&accumulator) + continue + } + guard let targetEntry = right else { + try record(insertEntry(for: sourceEntry)) + left = try await sourceReader.next(&accumulator) + continue + } + + switch ordering.compare(sourceEntry.key, targetEntry.key) { + case .orderedSame: + try record(matchedEntry(source: sourceEntry, target: targetEntry)) + left = try await sourceReader.next(&accumulator) + right = try await targetReader.next(&accumulator) + case .orderedAscending: + try record(insertEntry(for: sourceEntry)) + left = try await sourceReader.next(&accumulator) + case .orderedDescending: + try record(deleteEntry(for: targetEntry)) + right = try await targetReader.next(&accumulator) + } + } + + return accumulator.summary() + } + + private func insertEntry(for entry: KeyedRow) -> RowDiffEntry { + RowDiffEntry( + kind: .insert, + keyDescription: KeyOrdering.description(of: entry.key), + keyIdentity: KeyOrdering.identity(of: entry.key), + sourceRow: entry.row, + targetRow: nil + ) + } + + private func deleteEntry(for entry: KeyedRow?) -> RowDiffEntry { + RowDiffEntry( + kind: .delete, + keyDescription: entry.map { KeyOrdering.description(of: $0.key) } ?? "", + keyIdentity: entry.map { KeyOrdering.identity(of: $0.key) } ?? "", + sourceRow: nil, + targetRow: entry?.row + ) + } + + private func matchedEntry(source: KeyedRow, target: KeyedRow) -> RowDiffEntry { + var differences: [CellDifference] = [] + for column in comparisonColumns { + let sourceValue = source.row.value(for: column) + let targetValue = target.row.value(for: column) + let outcome = comparator.compare(sourceValue, targetValue) + guard !outcome.isEqual else { continue } + differences.append(CellDifference( + column: column, + rule: outcome.rule, + sourceValue: sourceValue, + targetValue: targetValue + )) + } + return RowDiffEntry( + kind: differences.isEmpty ? .identical : .update, + keyDescription: KeyOrdering.description(of: source.key), + keyIdentity: KeyOrdering.identity(of: source.key), + sourceRow: source.row, + targetRow: target.row, + cellDifferences: differences + ) + } +} + +internal extension DataDiffEngine { + struct Accumulator { + private let limit: Int + private var insertCount = 0 + private var updateCount = 0 + private var deleteCount = 0 + private var identicalCount = 0 + private var skippedNullKeyCount = 0 + private var entries: [RowDiffEntry] = [] + private var truncated = false + + init(limit: Int) { + self.limit = limit + } + + mutating func addSkippedNullKey() { + skippedNullKeyCount += 1 + } + + /// Only differences are retained. Keeping identical rows too meant a table with 100,000 + /// matching rows and ten differences near the end filled the retained list with matches and + /// dropped every difference, so the pane reported a count and listed nothing. The identical + /// count stays exact and the pane reports it as a number rather than as rows. + mutating func add(_ entry: RowDiffEntry) { + switch entry.kind { + case .insert: insertCount += 1 + case .update: updateCount += 1 + case .delete: deleteCount += 1 + case .identical: + identicalCount += 1 + return + } + guard entries.count < limit else { + truncated = true + return + } + entries.append(entry) + } + + func summary() -> DataDiffSummary { + DataDiffSummary( + insertCount: insertCount, + updateCount: updateCount, + deleteCount: deleteCount, + identicalCount: identicalCount, + skippedNullKeyCount: skippedNullKeyCount, + entries: entries, + truncatedEntries: truncated + ) + } + } +} + +private final class KeyedRowReader { + private let provider: DataRowProviding + private let keyColumns: [String] + private let ordering: KeyOrdering + private let side: ComparisonSide + private var previousKey: [PluginCellValue]? + + init(provider: DataRowProviding, keyColumns: [String], ordering: KeyOrdering, side: ComparisonSide) { + self.provider = provider + self.keyColumns = keyColumns + self.ordering = ordering + self.side = side + } + + func next(_ accumulator: inout DataDiffEngine.Accumulator) async throws -> KeyedRow? { + while let row = try await provider.nextRow() { + let key = keyColumns.map { row.value(for: $0) } + if KeyOrdering.hasNullComponent(key) { + accumulator.addSkippedNullKey() + continue + } + try checkOrder(of: key) + previousKey = key + return KeyedRow(row: row, key: key) + } + return nil + } + + /// Runs for every order kind, not just text. A numeric key that will not parse falls back to + /// byte order, and a collation this build does not recognise stays byte-ordered, so the check + /// is the only thing standing between a disagreeing server order and a wrong diff. + private func checkOrder(of key: [PluginCellValue]) throws { + guard let previousKey else { return } + guard ordering.compare(previousKey, key) == .orderedDescending else { return } + let explanation = String( + localized: "The %1$@ sorted rows differently than the comparison expects, near key %2$@. Pick a numeric key, or one that sorts by byte value." + ) + throw CompareSyncError.streamOutOfOrder( + String(format: explanation, side.displayName, KeyOrdering.description(of: key)) + ) + } +} + +internal final class ArrayRowProvider: DataRowProviding { + private let rows: [DataRow] + private var index = 0 + + internal init(rows: [DataRow]) { + self.rows = rows + } + + internal func nextRow() async throws -> DataRow? { + guard index < rows.count else { return nil } + defer { index += 1 } + return rows[index] + } +} diff --git a/TablePro/Core/Compare/DataSyncScriptBuilder.swift b/TablePro/Core/Compare/DataSyncScriptBuilder.swift new file mode 100644 index 000000000..12e7ae253 --- /dev/null +++ b/TablePro/Core/Compare/DataSyncScriptBuilder.swift @@ -0,0 +1,164 @@ +// +// DataSyncScriptBuilder.swift +// TablePro +// +// Turns row differences into DML for the target. Inserts run before updates +// before deletes, and parent tables before the tables that reference them. +// + +import Foundation +import TableProPluginKit + +/// One table's DML, kept in three buckets so the caller can interleave several tables in +/// dependency order. A flat `inserts + updates + deletes` per table is only correct for one +/// table: across tables it puts a child's insert before its parent's, which the server refuses. +internal struct DataSyncStatements { + internal var inserts: [SyncStatement] = [] + internal var updates: [SyncStatement] = [] + internal var deletes: [SyncStatement] = [] + + internal var isEmpty: Bool { + inserts.isEmpty && updates.isEmpty && deletes.isEmpty + } + + internal var flattened: [SyncStatement] { + inserts + updates + deletes + } +} + +internal struct DataSyncScriptBuilder { + private let targetDriver: any PluginDatabaseDriver + private let targetDatabaseType: DatabaseType + private let options: DataCompareOptions + + internal init( + targetDriver: any PluginDatabaseDriver, + targetDatabaseType: DatabaseType, + options: DataCompareOptions + ) { + self.targetDriver = targetDriver + self.targetDatabaseType = targetDatabaseType + self.options = options + } + + internal func build( + table: String, + schema: String?, + writeColumns: [String], + entries: [RowDiffEntry] + ) -> [SyncStatement] { + var statements = DataSyncStatements() + for entry in entries { + append(entry, table: table, schema: schema, writeColumns: writeColumns, into: &statements) + } + return statements.flattened + } + + internal func append( + _ entry: RowDiffEntry, + table: String, + schema: String?, + writeColumns: [String], + into statements: inout DataSyncStatements + ) { + switch entry.kind { + case .insert: + guard options.insertMissingRows, let row = entry.sourceRow else { return } + statements.inserts.append( + insertStatement(table: table, schema: schema, columns: writeColumns, row: row, entry: entry) + ) + case .update: + guard options.updateDifferingRows, let row = entry.sourceRow else { return } + guard let statement = updateStatement( + table: table, schema: schema, columns: writeColumns, row: row, entry: entry + ) else { return } + statements.updates.append(statement) + case .delete: + guard options.deleteExtraRows, let row = entry.targetRow else { return } + guard let statement = deleteStatement(table: table, schema: schema, row: row, entry: entry) else { return } + statements.deletes.append(statement) + case .identical: + return + } + } + + private func literal(for value: PluginCellValue) -> String { + CompareSQLLiteral.literal(for: value, databaseType: targetDatabaseType, driver: targetDriver) + } + + private func qualified(_ table: String, _ schema: String?) -> String { + guard let schema, !schema.isEmpty else { return targetDriver.quoteIdentifier(table) } + return "\(targetDriver.quoteIdentifier(schema)).\(targetDriver.quoteIdentifier(table))" + } + + private func insertStatement( + table: String, + schema: String?, + columns: [String], + row: DataRow, + entry: RowDiffEntry + ) -> SyncStatement { + let columnList = columns.map { targetDriver.quoteIdentifier($0) }.joined(separator: ", ") + let valueList = columns.map { literal(for: row.value(for: $0)) }.joined(separator: ", ") + return SyncStatement( + sql: "INSERT INTO \(qualified(table, schema)) (\(columnList)) VALUES (\(valueList));", + objectName: table, + summary: String(format: String(localized: "Insert row %@ into %@"), entry.keyDescription, table) + ) + } + + private func updateStatement( + table: String, + schema: String?, + columns: [String], + row: DataRow, + entry: RowDiffEntry + ) -> SyncStatement? { + let keySet = Set(options.keyColumns.map { $0.lowercased() }) + let assignable = columns.filter { !keySet.contains($0.lowercased()) } + guard !assignable.isEmpty else { return nil } + let assignments = assignable + .map { "\(targetDriver.quoteIdentifier($0)) = \(literal(for: row.value(for: $0)))" } + .joined(separator: ", ") + guard let predicate = keyPredicate(row: row) else { return nil } + return SyncStatement( + sql: "UPDATE \(qualified(table, schema)) SET \(assignments) WHERE \(predicate);", + objectName: table, + summary: String(format: String(localized: "Update row %@ in %@"), entry.keyDescription, table) + ) + } + + private func deleteStatement( + table: String, + schema: String?, + row: DataRow, + entry: RowDiffEntry + ) -> SyncStatement? { + guard let predicate = keyPredicate(row: row) else { return nil } + return SyncStatement( + sql: "DELETE FROM \(qualified(table, schema)) WHERE \(predicate);", + objectName: table, + summary: String(format: String(localized: "Delete row %@ from %@"), entry.keyDescription, table), + hazards: [SyncHazard( + kind: .dataLoss, + severity: .refusedByDefault, + explanation: String( + format: String(localized: "Deleting row %@ from %@ permanently removes it."), + entry.keyDescription, table + ) + )] + ) + } + + private func keyPredicate(row: DataRow) -> String? { + guard !options.keyColumns.isEmpty else { return nil } + return options.keyColumns + .map { column -> String in + let quoted = targetDriver.quoteIdentifier(column) + let value = row.value(for: column) + if case .null = value { return "\(quoted) IS NULL" } + return "\(quoted) = \(literal(for: value))" + } + .joined(separator: " AND ") + } +} diff --git a/TablePro/Core/Compare/KeyOrdering.swift b/TablePro/Core/Compare/KeyOrdering.swift new file mode 100644 index 000000000..9e566587f --- /dev/null +++ b/TablePro/Core/Compare/KeyOrdering.swift @@ -0,0 +1,184 @@ +// +// KeyOrdering.swift +// TablePro +// +// The order the merge join walks both streams in. +// +// Rows arrive ordered by the server's own collation, so the client comparator +// has to agree with it or the join desynchronises and reports rows that exist +// on both sides as a delete plus an insert. Three rules keep that from +// happening silently: +// +// 1. A numeric key column is compared as an exact decimal. Going through +// Double collapses any two integers that share the first 53 bits, so a pair +// of Snowflake ids one apart matched as the same row and the engine emitted +// an UPDATE that overwrote a different row. +// 2. A text key whose collation is case-insensitive is compared case-folded. +// The server treats 'Alice' and 'ALICE' as one key, so a byte comparator +// reports an orphan insert for a row the target already has. +// 3. Every other key is compared by UTF-8 bytes. +// +// None of the three is trusted on its own: the stream is verified as it is +// read, for every order kind, so a server whose collation disagrees is +// reported rather than guessed at. That check is the actual safety net, which +// is why it is no longer conditional. +// +// A NULL is never a usable row identity, so a row carrying one in a key column +// is excluded from the comparison rather than given an arbitrary sort position. +// + +import Foundation +import TableProPluginKit + +internal struct KeyColumnDescriptor: Hashable, Sendable { + internal let name: String + internal let dataType: String? + internal let collation: String? + + internal init(name: String, dataType: String? = nil, collation: String? = nil) { + self.name = name + self.dataType = dataType + self.collation = collation + } +} + +internal struct KeyOrdering { + internal enum ColumnOrder: Equatable { + case numeric + case caseSensitiveText + case caseInsensitiveText + } + + private let orders: [ColumnOrder] + + internal init(orders: [ColumnOrder]) { + self.orders = orders + } + + internal func compare(_ lhs: [PluginCellValue], _ rhs: [PluginCellValue]) -> ComparisonResult { + for (index, pair) in zip(lhs, rhs).enumerated() { + let order = index < orders.count ? orders[index] : .caseSensitiveText + let result = Self.compare(pair.0, pair.1, using: order) + guard result == .orderedSame else { return result } + } + if lhs.count == rhs.count { return .orderedSame } + return lhs.count < rhs.count ? .orderedAscending : .orderedDescending + } + + internal static func hasNullComponent(_ key: [PluginCellValue]) -> Bool { + key.contains { if case .null = $0 { return true } else { return false } } + } + + /// The row's identity, which is what excluding one row from a sync keys on, so two different + /// composite keys must never render the same. Joining with ", " made ("a", "b, c") and + /// ("a, b", "c") identical and silently excluded the wrong row. A unit separator cannot appear + /// in a key value. + internal static func identity(of key: [PluginCellValue]) -> String { + key.map(component(of:)).joined(separator: "\u{1F}") + } + + /// What a person reads. Ambiguity is fine here, because nothing keys on it. + internal static func description(of key: [PluginCellValue]) -> String { + key.map(component(of:)).joined(separator: ", ") + } + + private static func component(of value: PluginCellValue) -> String { + switch value { + case .null: return "NULL" + case .text(let text): return text + case .bytes(let data): return data.base64EncodedString() + } + } + + internal static func orders(for keyColumns: [String], descriptors: [KeyColumnDescriptor]) -> [ColumnOrder] { + keyColumns.map { column in + let lowered = column.lowercased() + guard let descriptor = descriptors.first(where: { $0.name.lowercased() == lowered }) else { + return .caseSensitiveText + } + if isNumeric(descriptor.dataType) { return .numeric } + return isCaseInsensitive(descriptor.collation) ? .caseInsensitiveText : .caseSensitiveText + } + } + + internal static func isNumeric(_ dataType: String?) -> Bool { + guard let dataType else { return false } + let base = dataType.lowercased().prefix { $0.isLetter || $0 == " " }.trimmingCharacters(in: .whitespaces) + if numericTypeNames.contains(base) { return true } + guard let leading = base.split(separator: " ").first else { return false } + return numericTypeNames.contains(String(leading)) + } + + /// Named per engine rather than guessed: MySQL and MariaDB suffix `_ci`, SQL Server spells + /// `_CI_` in the middle of a collation name, and SQLite has exactly one, `NOCASE`. A name + /// this does not recognise stays case-sensitive, where the stream-order check covers it. + internal static func isCaseInsensitive(_ collation: String?) -> Bool { + guard let collation, !collation.isEmpty else { return false } + let lowered = collation.lowercased() + if lowered == "nocase" { return true } + if lowered.hasSuffix("_ci") { return true } + return lowered.contains("_ci_") + } + + private static let numericTypeNames: Set = [ + "int", "integer", "tinyint", "smallint", "mediumint", "bigint", + "serial", "bigserial", "smallserial", "int2", "int4", "int8", + "decimal", "numeric", "number", "float", "double", "double precision", + "real", "money", "float4", "float8" + ] + + private static func compare( + _ lhs: PluginCellValue, + _ rhs: PluginCellValue, + using order: ColumnOrder + ) -> ComparisonResult { + switch order { + case .numeric: + guard let left = decimalValue(lhs), let right = decimalValue(rhs) else { + return compareBytes(byteValue(lhs), byteValue(rhs)) + } + if left == right { return .orderedSame } + return left < right ? .orderedAscending : .orderedDescending + case .caseSensitiveText: + return compareBytes(byteValue(lhs), byteValue(rhs)) + case .caseInsensitiveText: + return compareBytes(foldedByteValue(lhs), foldedByteValue(rhs)) + } + } + + private static func compareBytes(_ lhs: [UInt8], _ rhs: [UInt8]) -> ComparisonResult { + if lhs == rhs { return .orderedSame } + return lhs.lexicographicallyPrecedes(rhs) ? .orderedAscending : .orderedDescending + } + + /// `Decimal` carries 38 significant digits, which covers every integer key an engine can + /// hold and the NUMERIC range in practice. A value that will not parse returns nil so the + /// caller falls back to bytes, rather than collapsing onto a shared sentinel the way the + /// old `?? 0` did. + private static func decimalValue(_ value: PluginCellValue) -> Decimal? { + switch value { + case .null, .bytes: + return nil + case .text(let text): + return Decimal(string: text.trimmingCharacters(in: .whitespaces), locale: Self.posixLocale) + } + } + + private static func byteValue(_ value: PluginCellValue) -> [UInt8] { + switch value { + case .null: return [] + case .text(let text): return Array(text.utf8) + case .bytes(let data): return Array(data) + } + } + + private static func foldedByteValue(_ value: PluginCellValue) -> [UInt8] { + switch value { + case .null: return [] + case .text(let text): return Array(text.lowercased().utf8) + case .bytes(let data): return Array(data) + } + } + + private static let posixLocale = Locale(identifier: "en_US_POSIX") +} diff --git a/TablePro/Core/Compare/SchemaSyncOperation.swift b/TablePro/Core/Compare/SchemaSyncOperation.swift new file mode 100644 index 000000000..b283e9145 --- /dev/null +++ b/TablePro/Core/Compare/SchemaSyncOperation.swift @@ -0,0 +1,80 @@ +// +// SchemaSyncOperation.swift +// TablePro +// +// Table-level sync operations. SchemaChange covers everything inside one +// table; this covers the table's own lifecycle, which SchemaStatementGenerator +// deliberately does not model because it is scoped to a single fixed table. +// + +import Foundation +import TableProPluginKit + +internal enum SchemaSyncOperation: Identifiable { + case createTable(TableStructureSnapshot) + case dropTable(name: String, schema: String?) + case alterTable(name: String, schema: String?, changes: [SchemaChange]) + + internal var id: String { + switch self { + case .createTable(let snapshot): return "create-\(snapshot.qualifiedName)" + case .dropTable(let name, let schema): return "drop-\(Self.qualify(name, schema))" + case .alterTable(let name, let schema, _): return "alter-\(Self.qualify(name, schema))" + } + } + + internal var tableName: String { + switch self { + case .createTable(let snapshot): return snapshot.name + case .dropTable(let name, _): return name + case .alterTable(let name, _, _): return name + } + } + + internal var schema: String? { + switch self { + case .createTable(let snapshot): return snapshot.schema + case .dropTable(_, let schema): return schema + case .alterTable(_, let schema, _): return schema + } + } + + internal var tableIdentifier: String { + Self.qualify(tableName, schema) + } + + internal static func qualify(_ name: String, _ schema: String?) -> String { + guard let schema, !schema.isEmpty else { return name } + return "\(schema).\(name)" + } +} + +internal struct SyncStatement: Identifiable, Hashable, Sendable { + internal let id: UUID + internal let sql: String + internal let objectName: String + internal let summary: String + internal let hazards: [SyncHazard] + + internal init( + id: UUID = UUID(), + sql: String, + objectName: String, + summary: String, + hazards: [SyncHazard] = [] + ) { + self.id = id + self.sql = sql + self.objectName = objectName + self.summary = summary + self.hazards = hazards + } + + internal var isRefusedByDefault: Bool { + hazards.contains { $0.severity == .refusedByDefault } + } + + internal var highestSeverity: SyncHazardSeverity? { + hazards.map { $0.severity }.max() + } +} diff --git a/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift b/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift new file mode 100644 index 000000000..f607234eb --- /dev/null +++ b/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift @@ -0,0 +1,189 @@ +// +// SchemaSyncScriptBuilder.swift +// TablePro +// +// Turns table-level sync operations into dialect-correct statements for the +// target driver. Ordering across tables follows foreign key dependencies; +// ordering inside one table is delegated to SchemaStatementGenerator. +// + +import Foundation +import TableProPluginKit + +internal struct SchemaSyncScriptBuilder { + private let targetDriver: any PluginDatabaseDriver + private let classifier: SyncSafetyClassifier + + internal init(targetDriver: any PluginDatabaseDriver, classifier: SyncSafetyClassifier = SyncSafetyClassifier()) { + self.targetDriver = targetDriver + self.classifier = classifier + } + + internal func build( + operations: [SchemaSyncOperation], + foreignKeysByTable: [String: [PluginForeignKeyInfo]] + ) throws -> [SyncStatement] { + let ordered = Self.order(operations: operations, foreignKeysByTable: foreignKeysByTable) + var statements: [SyncStatement] = [] + for operation in ordered { + statements.append(contentsOf: try build(operation: operation)) + } + return statements + } + + internal static func order( + operations: [SchemaSyncOperation], + foreignKeysByTable: [String: [PluginForeignKeyInfo]] + ) -> [SchemaSyncOperation] { + var drops: [SchemaSyncOperation] = [] + var creates: [SchemaSyncOperation] = [] + var alters: [SchemaSyncOperation] = [] + for operation in operations { + switch operation { + case .dropTable: drops.append(operation) + case .createTable: creates.append(operation) + case .alterTable: alters.append(operation) + } + } + return sorted(drops, foreignKeysByTable: foreignKeysByTable, childrenFirst: true) + + sorted(creates, foreignKeysByTable: foreignKeysByTable, childrenFirst: false) + + sorted(alters, foreignKeysByTable: foreignKeysByTable, childrenFirst: false) + } + + private static func sorted( + _ operations: [SchemaSyncOperation], + foreignKeysByTable: [String: [PluginForeignKeyInfo]], + childrenFirst: Bool + ) -> [SchemaSyncOperation] { + guard operations.count > 1 else { return operations } + var byIdentifier: [String: [SchemaSyncOperation]] = [:] + for operation in operations { + byIdentifier[operation.tableIdentifier, default: []].append(operation) + } + let ordered = ForeignKeyTopologicalSort.ordered( + operations.map { ForeignKeyTopologicalSort.Table(name: $0.tableName, schema: $0.schema) }, + foreignKeysByTable: foreignKeysByTable, + childrenFirst: childrenFirst + ) + var emitted: Set = [] + var resolved: [SchemaSyncOperation] = [] + for node in ordered where !emitted.contains(node.identifier) { + emitted.insert(node.identifier) + resolved.append(contentsOf: byIdentifier[node.identifier] ?? []) + } + return resolved + } + + private func build(operation: SchemaSyncOperation) throws -> [SyncStatement] { + switch operation { + case .createTable(let snapshot): + return try createStatements(for: snapshot) + case .dropTable(let name, let schema): + return dropStatements(name: name, schema: schema) + case .alterTable(let name, let schema, let changes): + return try alterStatements(name: name, schema: schema, changes: changes) + } + } + + private func createStatements(for snapshot: TableStructureSnapshot) throws -> [SyncStatement] { + let definition = PluginCreateTableDefinition( + tableName: snapshot.name, + columns: snapshot.columns.map { $0.toPlugin() }, + indexes: snapshot.indexes.filter { !$0.isPrimary }.map { $0.toPlugin() }, + foreignKeys: snapshot.foreignKeys.map { $0.toPlugin() }, + primaryKeyColumns: snapshot.primaryKeyColumns, + engine: snapshot.engine, + charset: snapshot.charset, + collation: snapshot.collation + ) + guard let sql = targetDriver.generateCreateTableSQL(definition: definition) else { + throw CompareSyncError.unsupportedOperation(String( + format: String(localized: "The target does not support creating table %@."), + snapshot.name + )) + } + return [SyncStatement( + sql: Self.terminated(sql), + objectName: snapshot.qualifiedName, + summary: String(format: String(localized: "Create table %@"), snapshot.name) + )] + } + + private func dropStatements(name: String, schema: String?) -> [SyncStatement] { + guard let sql = targetDriver.dropObjectStatement( + name: name, objectType: "TABLE", schema: schema, cascade: false + ) else { return [] } + return [SyncStatement( + sql: Self.terminated(sql), + objectName: name, + summary: String(format: String(localized: "Drop table %@"), name), + hazards: classifier.hazards(forDropping: name) + )] + } + + private func alterStatements( + name: String, + schema: String?, + changes: [SchemaChange] + ) throws -> [SyncStatement] { + let generator = SchemaStatementGenerator(tableName: name, pluginDriver: targetDriver) + var statements: [SyncStatement] = [] + for change in SchemaChangeOrdering.sorted(changes) { + let hazards = classifier.hazards(for: change) + let generated = try generator.generate(changes: [change]) + for statement in generated { + statements.append(SyncStatement( + sql: Self.terminated(statement.sql), + objectName: name, + summary: statement.description, + hazards: hazards + )) + } + } + return statements + } + + private static func terminated(_ sql: String) -> String { + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.hasSuffix(";") ? trimmed : trimmed + ";" + } +} + +internal enum SchemaChangeOrdering { + internal static func sorted(_ changes: [SchemaChange]) -> [SchemaChange] { + var buckets: [[SchemaChange]] = Array(repeating: [], count: 8) + for change in changes { + buckets[bucket(for: change)].append(change) + } + return buckets.flatMap { $0 } + } + + private static func bucket(for change: SchemaChange) -> Int { + switch change { + case .deleteForeignKey, .modifyForeignKey: return 0 + case .deleteIndex, .modifyIndex: return 1 + case .deleteColumn: return 2 + case .modifyColumn: return 3 + case .addColumn: return 4 + case .modifyPrimaryKey: return 5 + case .addIndex: return 6 + case .addForeignKey: return 7 + } + } +} + +internal enum CompareSyncError: LocalizedError { + case unsupportedOperation(String) + case incompatibleEngines(String) + case noComparisonKey(String) + case streamOutOfOrder(String) + + internal var errorDescription: String? { + switch self { + case .unsupportedOperation(let message): return message + case .incompatibleEngines(let message): return message + case .noComparisonKey(let message): return message + case .streamOutOfOrder(let message): return message + } + } +} diff --git a/TablePro/Core/Compare/SourceObjectDiffEngine.swift b/TablePro/Core/Compare/SourceObjectDiffEngine.swift new file mode 100644 index 000000000..36039d549 --- /dev/null +++ b/TablePro/Core/Compare/SourceObjectDiffEngine.swift @@ -0,0 +1,112 @@ +// +// SourceObjectDiffEngine.swift +// TablePro +// +// Compares the objects whose definition is a body of SQL: views, procedures, +// functions and triggers. +// +// Tables are deliberately not compared this way. Driver-rendered DDL varies by +// formatting and by system-generated constraint names, and every tool that has +// diffed table DDL as text has shipped a false-positive storm. A routine has no +// parsed form to compare instead: its body IS the definition, so text is the +// only thing there is. What that costs is a formatting-only difference reading +// as a difference, which the normaliser below is there to reduce: it folds +// line endings, collapses runs of whitespace and drops trailing semicolons, +// and it folds case only when the compare options say identifier case is +// ignored. +// + +import Foundation + +internal struct SourceObjectDiffEngine { + private let options: StructureCompareOptions + + internal init(options: StructureCompareOptions = .default) { + self.options = options + } + + internal func compare( + source: [RoutineSourceRead], + target: [RoutineSourceRead] + ) -> [CompareObjectResult] { + let targetByKey = Dictionary( + target.map { (matchKey(for: $0), $0) }, + uniquingKeysWith: { first, _ in first } + ) + var handled: Set = [] + var results: [CompareObjectResult] = [] + + for read in source { + let key = matchKey(for: read) + handled.insert(key) + guard let counterpart = targetByKey[key] else { + results.append(result(for: read, counterpart: nil, status: .onlyInSource)) + continue + } + let equal = normalize(read.source) == normalize(counterpart.source) + results.append(result(for: read, counterpart: counterpart, status: equal ? .identical : .differs)) + } + + for read in target where !handled.contains(matchKey(for: read)) { + results.append(result(for: read, counterpart: nil, status: .onlyInTarget)) + } + + return results + } + + private func result( + for read: RoutineSourceRead, + counterpart: RoutineSourceRead?, + status: TableDiffStatus + ) -> CompareObjectResult { + let identity = CompareObjectIdentity( + kind: read.kind, schema: read.schema, name: read.name, signature: read.signature + ) + let sourceLines = status == .onlyInTarget ? [] : SqlNormalizer.lines(read.source) + let targetLines: [String] + switch status { + case .onlyInTarget: + targetLines = SqlNormalizer.lines(read.source) + case .onlyInSource: + targetLines = [] + case .differs, .identical: + targetLines = SqlNormalizer.lines(counterpart?.source ?? "") + } + return CompareObjectResult( + identity: identity, + status: status, + sourceDefinition: sourceLines, + targetDefinition: targetLines, + notes: notes(for: read, status: status) + ) + } + + private func notes(for read: RoutineSourceRead, status: TableDiffStatus) -> [String] { + guard status != .identical, read.source.isEmpty else { return [] } + return [String(localized: "The driver did not return this object's definition, so only its name was compared.")] + } + + private func matchKey(for read: RoutineSourceRead) -> String { + let name = options.ignoreIdentifierCase ? read.name.lowercased() : read.name + let schema = options.ignoreIdentifierCase ? (read.schema ?? "").lowercased() : (read.schema ?? "") + let signature = (read.signature ?? "").replacingOccurrences(of: " ", with: "").lowercased() + return "\(read.kind.rawValue)|\(schema)|\(name)|\(signature)" + } + + private func normalize(_ source: String) -> String { + var text = SqlNormalizer.normalize(source) + if options.ignoreWhitespaceInText { + text = text + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\t", with: " ") + while text.contains(" ") { + text = text.replacingOccurrences(of: " ", with: " ") + } + } + while text.hasSuffix(";") { + text.removeLast() + } + text = text.trimmingCharacters(in: .whitespacesAndNewlines) + return options.ignoreIdentifierCase ? text.lowercased() : text + } +} diff --git a/TablePro/Core/Compare/SourceObjectSyncBuilder.swift b/TablePro/Core/Compare/SourceObjectSyncBuilder.swift new file mode 100644 index 000000000..6274f918d --- /dev/null +++ b/TablePro/Core/Compare/SourceObjectSyncBuilder.swift @@ -0,0 +1,92 @@ +// +// SourceObjectSyncBuilder.swift +// TablePro +// +// Statements for the objects whose definition is SQL text. +// +// There is nothing to synthesise here: the source's own definition is the +// statement. What the builder decides is how to get from the target's current +// definition to that one, which for a view or a routine means dropping what is +// there and running the source's text. `CREATE OR REPLACE` is deliberately not +// used, because the engines spell it differently, several do not accept it for +// a signature change, and a replace that silently keeps the old object on +// failure is worse than an explicit drop the user allowed. +// + +import Foundation +import TableProPluginKit + +internal struct SourceObjectSyncBuilder { + private let targetDriver: any PluginDatabaseDriver + private let classifier = SyncSafetyClassifier() + + internal init(targetDriver: any PluginDatabaseDriver) { + self.targetDriver = targetDriver + } + + internal func build(for result: CompareObjectResult, action: TableSyncAction) -> [SyncStatement] { + switch action { + case .skip: + return [] + case .create: + return createStatements(for: result) + case .alter: + return dropStatements(for: result, isReplacement: true) + createStatements(for: result) + case .drop: + return dropStatements(for: result, isReplacement: false) + } + } + + private func createStatements(for result: CompareObjectResult) -> [SyncStatement] { + let definition = result.sourceDefinition.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + guard !definition.isEmpty else { return [] } + return [SyncStatement( + sql: terminated(definition), + objectName: result.identity.displayName, + summary: String( + format: String(localized: "Create %1$@ %2$@"), + result.identity.kind.displayName.lowercased(), result.identity.displayName + ) + )] + } + + private func dropStatements(for result: CompareObjectResult, isReplacement: Bool) -> [SyncStatement] { + guard let keyword = dropKeyword(for: result.identity.kind) else { return [] } + let name = qualified(result.identity) + return [SyncStatement( + sql: "DROP \(keyword) \(name);", + objectName: result.identity.displayName, + summary: isReplacement + ? String( + format: String(localized: "Replace %1$@ %2$@"), + result.identity.kind.displayName.lowercased(), result.identity.displayName + ) + : String( + format: String(localized: "Drop %1$@ %2$@"), + result.identity.kind.displayName.lowercased(), result.identity.displayName + ), + hazards: classifier.hazards(forDropping: result.identity, isReplacement: isReplacement) + )] + } + + private func dropKeyword(for kind: CompareObjectKind) -> String? { + switch kind { + case .view: return "VIEW" + case .materializedView: return "MATERIALIZED VIEW" + case .procedure: return "PROCEDURE" + case .function: return "FUNCTION" + case .trigger: return "TRIGGER" + case .table, .sequence: return nil + } + } + + private func qualified(_ identity: CompareObjectIdentity) -> String { + let quotedName = targetDriver.quoteIdentifier(identity.name) + guard let schema = identity.schema, !schema.isEmpty else { return quotedName } + return "\(targetDriver.quoteIdentifier(schema)).\(quotedName)" + } + + private func terminated(_ sql: String) -> String { + sql.hasSuffix(";") ? sql : sql + ";" + } +} diff --git a/TablePro/Core/Compare/StreamingRowProvider.swift b/TablePro/Core/Compare/StreamingRowProvider.swift new file mode 100644 index 000000000..2b6cd82e3 --- /dev/null +++ b/TablePro/Core/Compare/StreamingRowProvider.swift @@ -0,0 +1,82 @@ +// +// StreamingRowProvider.swift +// TablePro +// +// Feeds the merge join from a driver's row stream. Only the current batch is +// held, so neither side of a comparison is ever materialized in full. +// + +import Foundation +import TableProPluginKit + +internal final class StreamingRowProvider: DataRowProviding { + /// The merge join awaits one `nextRow()` at a time, so the iterator is only ever advanced + /// from a single task. Region isolation cannot see that invariant across the `next()` hop. + nonisolated(unsafe) private var iterator: AsyncThrowingStream.AsyncIterator + private var columns: [String] + private var buffer: [DataRow] = [] + private var bufferIndex = 0 + private var isFinished = false + + internal init(stream: AsyncThrowingStream, columns: [String] = []) { + self.iterator = stream.makeAsyncIterator() + self.columns = columns + } + + internal func nextRow() async throws -> DataRow? { + while bufferIndex >= buffer.count { + guard !isFinished else { return nil } + try await fillBuffer() + } + defer { bufferIndex += 1 } + return buffer[bufferIndex] + } + + private func fillBuffer() async throws { + buffer = [] + bufferIndex = 0 + while buffer.isEmpty { + guard let element = try await iterator.next() else { + isFinished = true + return + } + switch element { + case .header(let header): + if columns.isEmpty { columns = header.columns } + case .rows(let rows): + buffer = rows.map { row in + var values: [String: PluginCellValue] = [:] + for (index, column) in columns.enumerated() where index < row.count { + values[column] = row[index] + } + return DataRow(values: values) + } + } + } + } +} + +internal enum KeyOrderedQuery { + internal static func build( + table: String, + schema: String?, + columns: [String], + keyColumns: [String], + driver: any PluginDatabaseDriver + ) -> String { + let columnList = columns.isEmpty + ? "*" + : columns.map { driver.quoteIdentifier($0) }.joined(separator: ", ") + let orderBy = keyColumns.map { driver.quoteIdentifier($0) }.joined(separator: ", ") + var sql = "SELECT \(columnList) FROM \(qualified(table, schema, driver))" + if !orderBy.isEmpty { + sql += " ORDER BY \(orderBy)" + } + return sql + } + + private static func qualified(_ table: String, _ schema: String?, _ driver: any PluginDatabaseDriver) -> String { + guard let schema, !schema.isEmpty else { return driver.quoteIdentifier(table) } + return "\(driver.quoteIdentifier(schema)).\(driver.quoteIdentifier(table))" + } +} diff --git a/TablePro/Core/Compare/StructureCompareOptions.swift b/TablePro/Core/Compare/StructureCompareOptions.swift new file mode 100644 index 000000000..3807d0670 --- /dev/null +++ b/TablePro/Core/Compare/StructureCompareOptions.swift @@ -0,0 +1,82 @@ +// +// StructureCompareOptions.swift +// TablePro +// +// Normalization rules applied before two structures are considered different. +// Every option defaults to ignoring the difference, because the metadata these +// cover drifts between environments by design. +// + +import Foundation + +internal struct StructureCompareOptions: Codable, Hashable, Sendable { + internal var ignoreIdentifierCase = true + internal var ignoreColumnOrder = true + internal var ignoreWhitespaceInText = true + internal var ignoreAutoIncrementSeed = true + internal var ignoreCollationAndCharset = true + internal var ignoreCommentsAndOwners = true + + internal static let `default` = StructureCompareOptions() +} + +internal extension StructureCompareOptions { + func matchKey(_ identifier: String) -> String { + let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines) + return ignoreIdentifierCase ? trimmed.lowercased() : trimmed + } + + /// Two schemas of one database can hold the same table name, so the name alone cannot identify + /// a table. Matching on it collapsed `public.users` and `audit.users` onto one result, diffed + /// both against whichever target arrived first, and never reported the other as target-only. + /// The schema joins the key only when both sides carry one, so a schemaless engine is + /// unaffected. + func matchKey(name: String, schema: String?) -> String { + guard let schema, !schema.isEmpty else { return matchKey(name) } + return "\(matchKey(schema))\u{1F}\(matchKey(name))" + } + + func normalizedText(_ value: String?) -> String? { + guard let value else { return nil } + var result = value + if ignoreAutoIncrementSeed { + result = Self.autoIncrementSeed.stringByReplacingMatches( + in: result, + range: NSRange(result.startIndex..., in: result), + withTemplate: "AUTO_INCREMENT" + ) + } + if ignoreWhitespaceInText { + result = Self.whitespaceRun.stringByReplacingMatches( + in: result, + range: NSRange(result.startIndex..., in: result), + withTemplate: " " + ) + result = result.trimmingCharacters(in: .whitespacesAndNewlines) + } + return result.isEmpty ? nil : result + } + + func normalizedType(_ dataType: String) -> String { + let collapsed = Self.whitespaceRun.stringByReplacingMatches( + in: dataType, + range: NSRange(dataType.startIndex..., in: dataType), + withTemplate: " " + ) + return collapsed.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + func columnListKey(_ columns: [String]) -> String { + columns.map { matchKey($0) }.joined(separator: "\u{1F}") + } + + private static let whitespaceRun = makeRegex("\\s+") + private static let autoIncrementSeed = makeRegex("(?i)AUTO_INCREMENT\\s*=\\s*\\d+") + + private static func makeRegex(_ pattern: String) -> NSRegularExpression { + guard let regex = try? NSRegularExpression(pattern: pattern) else { + preconditionFailure("StructureCompareOptions pattern failed to compile: \(pattern)") + } + return regex + } +} diff --git a/TablePro/Core/Compare/StructureDiffEngine+Members.swift b/TablePro/Core/Compare/StructureDiffEngine+Members.swift new file mode 100644 index 000000000..8f24739d8 --- /dev/null +++ b/TablePro/Core/Compare/StructureDiffEngine+Members.swift @@ -0,0 +1,167 @@ +// +// StructureDiffEngine+Members.swift +// TablePro +// +// Column, primary key, index and foreign key comparison. +// Indexes and foreign keys are matched on structure rather than on name, so a +// system-generated name difference never reports an object as changed. +// + +import Foundation + +internal extension StructureDiffEngine { + struct MemberOutcome { + internal let changes: [SchemaChange] + internal let notes: [String] + } + + func columnChanges( + source: TableStructureSnapshot, + target: TableStructureSnapshot + ) -> [SchemaChange] { + let targetByKey = Dictionary( + target.columns.map { (options.matchKey($0.name), $0) }, + uniquingKeysWith: { first, _ in first } + ) + let sourceKeys = Set(source.columns.map { options.matchKey($0.name) }) + + var changes: [SchemaChange] = [] + for column in source.columns { + guard let existing = targetByKey[options.matchKey(column.name)] else { + changes.append(.addColumn(column)) + continue + } + guard columnSignature(column) != columnSignature(existing) else { continue } + changes.append(.modifyColumn(old: existing, new: column)) + } + for column in target.columns where !sourceKeys.contains(options.matchKey(column.name)) { + changes.append(.deleteColumn(column)) + } + return changes + } + + func primaryKeyChanges( + source: TableStructureSnapshot, + target: TableStructureSnapshot + ) -> [SchemaChange] { + let sourceKey = source.primaryKeyColumns + let targetKey = target.primaryKeyColumns + guard options.columnListKey(sourceKey) != options.columnListKey(targetKey) else { return [] } + return [.modifyPrimaryKey(old: targetKey, new: sourceKey)] + } + + func indexChanges( + source: TableStructureSnapshot, + target: TableStructureSnapshot + ) -> MemberOutcome { + let sourceIndexes = source.indexes.filter { !$0.isPrimary } + let targetIndexes = target.indexes.filter { !$0.isPrimary } + + var remaining = targetIndexes + var changes: [SchemaChange] = [] + var notes: [String] = [] + + for index in sourceIndexes { + let signature = indexSignature(index) + guard let position = remaining.firstIndex(where: { indexSignature($0) == signature }) else { + changes.append(.addIndex(index)) + continue + } + let matched = remaining.remove(at: position) + guard options.matchKey(matched.name) != options.matchKey(index.name) else { continue } + notes.append(String( + format: String(localized: "Index %@ matches %@ on the target but the names differ."), + index.name, matched.name + )) + } + for index in remaining { + changes.append(.deleteIndex(index)) + } + return MemberOutcome(changes: changes, notes: notes) + } + + func foreignKeyChanges( + source: TableStructureSnapshot, + target: TableStructureSnapshot + ) -> MemberOutcome { + var remaining = target.foreignKeys + var changes: [SchemaChange] = [] + var notes: [String] = [] + + for foreignKey in source.foreignKeys { + let signature = foreignKeySignature(foreignKey) + guard let position = remaining.firstIndex(where: { foreignKeySignature($0) == signature }) else { + changes.append(.addForeignKey(foreignKey)) + continue + } + let matched = remaining.remove(at: position) + guard options.matchKey(matched.name) != options.matchKey(foreignKey.name) else { continue } + notes.append(String( + format: String(localized: "Foreign key %@ matches %@ on the target but the names differ."), + foreignKey.name, matched.name + )) + } + for foreignKey in remaining { + changes.append(.deleteForeignKey(foreignKey)) + } + return MemberOutcome(changes: changes, notes: notes) + } +} + +private extension StructureDiffEngine { + func columnSignature(_ column: EditableColumnDefinition) -> String { + var parts: [String] = [ + options.matchKey(column.name), + options.normalizedType(column.dataType), + String(column.isNullable), + String(column.autoIncrement), + String(column.unsigned), + options.normalizedText(column.defaultValue) ?? "", + options.normalizedText(column.onUpdate) ?? "", + options.normalizedText(strippedExtra(column.extra)) ?? "" + ] + if !options.ignoreCollationAndCharset { + parts.append(options.matchKey(column.charset ?? "")) + parts.append(options.matchKey(column.collation ?? "")) + } + if !options.ignoreCommentsAndOwners { + parts.append(options.normalizedText(column.comment) ?? "") + } + return parts.joined(separator: "\u{1F}") + } + + func indexSignature(_ index: EditableIndexDefinition) -> String { + var parts: [String] = [ + options.columnListKey(index.columns), + String(index.isUnique), + options.matchKey(index.type.rawValue), + options.normalizedText(index.whereClause) ?? "" + ] + let prefixes = index.columnPrefixes + .sorted { $0.key.localizedStandardCompare($1.key) == .orderedAscending } + .map { "\(options.matchKey($0.key)):\($0.value)" } + parts.append(prefixes.joined(separator: ",")) + return parts.joined(separator: "\u{1F}") + } + + func foreignKeySignature(_ foreignKey: EditableForeignKeyDefinition) -> String { + [ + options.columnListKey(foreignKey.columns), + options.matchKey(foreignKey.referencedTable), + options.columnListKey(foreignKey.referencedColumns), + options.matchKey(foreignKey.referencedSchema ?? ""), + foreignKey.onDelete.rawValue, + foreignKey.onUpdate.rawValue + ].joined(separator: "\u{1F}") + } + + func strippedExtra(_ extra: String?) -> String? { + guard let extra else { return nil } + guard options.ignoreAutoIncrementSeed else { return extra } + return extra.replacingOccurrences( + of: "auto_increment=[0-9]+", + with: "auto_increment", + options: [.regularExpression, .caseInsensitive] + ) + } +} diff --git a/TablePro/Core/Compare/StructureDiffEngine.swift b/TablePro/Core/Compare/StructureDiffEngine.swift new file mode 100644 index 000000000..1b6e3dfc6 --- /dev/null +++ b/TablePro/Core/Compare/StructureDiffEngine.swift @@ -0,0 +1,127 @@ +// +// StructureDiffEngine.swift +// TablePro +// +// Compares parsed structure metadata between two connections and produces the +// SchemaChange list that would make the target match the source. +// Structure is never compared as DDL text: driver-rendered DDL varies by +// formatting and system-generated names, which reports identical objects as +// different. +// + +import Foundation + +internal struct StructureDiffEngine { + internal let options: StructureCompareOptions + + internal init(options: StructureCompareOptions = .default) { + self.options = options + } + + internal func compare( + source: [TableStructureSnapshot], + target: [TableStructureSnapshot] + ) -> StructureDiffReport { + let targetByKey = indexByMatchKey(target) + + var results: [TableDiffResult] = [] + var handled: Set = [] + + for snapshot in source { + let key = options.matchKey(name: snapshot.name, schema: snapshot.schema) + handled.insert(key) + guard let counterpart = targetByKey[key] else { + results.append(TableDiffResult( + tableName: snapshot.name, + schema: snapshot.schema, + status: .onlyInSource + )) + continue + } + results.append(compareTable(source: snapshot, target: counterpart)) + } + + for snapshot in target where !handled.contains(options.matchKey(name: snapshot.name, schema: snapshot.schema)) { + results.append(TableDiffResult( + tableName: snapshot.name, + schema: snapshot.schema, + status: .onlyInTarget + )) + } + + let sorted = results.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending } + return StructureDiffReport(results: sorted) + } + + internal func compareTable( + source: TableStructureSnapshot, + target: TableStructureSnapshot + ) -> TableDiffResult { + var changes: [SchemaChange] = [] + var notes: [String] = [] + + changes.append(contentsOf: columnChanges(source: source, target: target)) + changes.append(contentsOf: primaryKeyChanges(source: source, target: target)) + + let indexOutcome = indexChanges(source: source, target: target) + changes.append(contentsOf: indexOutcome.changes) + notes.append(contentsOf: indexOutcome.notes) + + let foreignKeyOutcome = foreignKeyChanges(source: source, target: target) + changes.append(contentsOf: foreignKeyOutcome.changes) + notes.append(contentsOf: foreignKeyOutcome.notes) + + notes.append(contentsOf: columnOrderNotes(source: source, target: target)) + notes.append(contentsOf: tableOptionNotes(source: source, target: target)) + + let status: TableDiffStatus = (changes.isEmpty && notes.isEmpty) ? .identical : .differs + return TableDiffResult( + tableName: source.name, + schema: source.schema, + status: status, + changes: changes, + notes: notes + ) + } + + private func indexByMatchKey(_ snapshots: [TableStructureSnapshot]) -> [String: TableStructureSnapshot] { + Dictionary( + snapshots.map { (options.matchKey(name: $0.name, schema: $0.schema), $0) }, + uniquingKeysWith: { first, _ in first } + ) + } + + private func columnOrderNotes( + source: TableStructureSnapshot, + target: TableStructureSnapshot + ) -> [String] { + guard !options.ignoreColumnOrder else { return [] } + let sourceOrder = source.columns.map { options.matchKey($0.name) } + let targetOrder = target.columns.map { options.matchKey($0.name) } + guard sourceOrder != targetOrder, Set(sourceOrder) == Set(targetOrder) else { return [] } + return [String(localized: "Column order differs. No statement is generated for reordering columns.")] + } + + private func tableOptionNotes( + source: TableStructureSnapshot, + target: TableStructureSnapshot + ) -> [String] { + var notes: [String] = [] + if let sourceEngine = source.engine, let targetEngine = target.engine, + options.matchKey(sourceEngine) != options.matchKey(targetEngine) { + notes.append(String( + format: String(localized: "Storage engine differs: %@ on source, %@ on target."), + sourceEngine, targetEngine + )) + } + guard !options.ignoreCollationAndCharset else { return notes } + if let sourceCollation = source.collation, let targetCollation = target.collation, + options.matchKey(sourceCollation) != options.matchKey(targetCollation) { + notes.append(String( + format: String(localized: "Table collation differs: %@ on source, %@ on target."), + sourceCollation, targetCollation + )) + } + return notes + } +} diff --git a/TablePro/Core/Compare/SyncHazard.swift b/TablePro/Core/Compare/SyncHazard.swift new file mode 100644 index 000000000..b05a2dd5e --- /dev/null +++ b/TablePro/Core/Compare/SyncHazard.swift @@ -0,0 +1,65 @@ +// +// SyncHazard.swift +// TablePro +// +// Typed risks attached to individual generated statements. +// A statement carrying a refused hazard is shown in the preview but is not +// executed unless it is explicitly allowed for that run. +// + +import Foundation + +internal enum SyncHazardKind: String, Codable, Hashable, Sendable { + case dataLoss + case lossyTypeChange + case tableRebuild + case collationOrCharsetChange + case primaryKeyChange + case engineOrStorageChange + case notSupportedByTarget +} + +internal enum SyncHazardSeverity: Int, Codable, Hashable, Sendable, Comparable { + case informational + case warning + case refusedByDefault + + internal static func < (lhs: SyncHazardSeverity, rhs: SyncHazardSeverity) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +internal struct SyncHazard: Identifiable, Hashable, Sendable { + internal let kind: SyncHazardKind + internal let severity: SyncHazardSeverity + internal let explanation: String + + internal var id: String { "\(kind.rawValue)-\(explanation)" } + + internal init(kind: SyncHazardKind, severity: SyncHazardSeverity, explanation: String) { + self.kind = kind + self.severity = severity + self.explanation = explanation + } +} + +internal extension SyncHazardKind { + var displayName: String { + switch self { + case .dataLoss: + return String(localized: "Data loss") + case .lossyTypeChange: + return String(localized: "Lossy type change") + case .tableRebuild: + return String(localized: "Table rebuild") + case .collationOrCharsetChange: + return String(localized: "Collation or character set change") + case .primaryKeyChange: + return String(localized: "Primary key change") + case .engineOrStorageChange: + return String(localized: "Storage engine change") + case .notSupportedByTarget: + return String(localized: "Not supported by the target") + } + } +} diff --git a/TablePro/Core/Compare/SyncSafetyClassifier.swift b/TablePro/Core/Compare/SyncSafetyClassifier.swift new file mode 100644 index 000000000..7286297dd --- /dev/null +++ b/TablePro/Core/Compare/SyncSafetyClassifier.swift @@ -0,0 +1,177 @@ +// +// SyncSafetyClassifier.swift +// TablePro +// +// Decides which generated statements are unsafe enough to be refused unless +// the user allows them for a single run. Refusing by default keeps the preview +// honest: nothing runs that the preview did not show as allowed. +// + +import Foundation + +internal struct SyncSafetyClassifier { + internal func hazards(for change: SchemaChange) -> [SyncHazard] { + switch change { + case .deleteColumn(let column): + return [SyncHazard( + kind: .dataLoss, + severity: .refusedByDefault, + explanation: String( + format: String(localized: "Dropping column %@ permanently removes its data."), + column.name + ) + )] + case .modifyColumn(let old, let new): + return modifyColumnHazards(old: old, new: new) + case .modifyPrimaryKey: + return [SyncHazard( + kind: .primaryKeyChange, + severity: .refusedByDefault, + explanation: String(localized: "Changing the primary key rebuilds the table and can fail on duplicate values.") + )] + case .deleteIndex(let index): + return [SyncHazard( + kind: .tableRebuild, + severity: .warning, + explanation: String( + format: String(localized: "Dropping index %@ can slow existing queries."), + index.name + ) + )] + case .deleteForeignKey, .addForeignKey, .modifyForeignKey, .addColumn, .addIndex, .modifyIndex: + return [] + } + } + + internal func hazards(forDropping tableName: String) -> [SyncHazard] { + [SyncHazard( + kind: .dataLoss, + severity: .refusedByDefault, + explanation: String( + format: String(localized: "Dropping table %@ permanently removes the table and all of its rows."), + tableName + ) + )] + } + + /// A view or a routine holds no rows, so dropping one is recoverable from the source and only + /// warns. A materialized view does hold rows, so it is refused like a table. Either way a drop + /// can break something that depends on it, which is what the second hazard says. + internal func hazards(forDropping identity: CompareObjectIdentity, isReplacement: Bool) -> [SyncHazard] { + var hazards: [SyncHazard] = [] + + if identity.kind == .materializedView { + hazards.append(SyncHazard( + kind: .dataLoss, + severity: .refusedByDefault, + explanation: String( + format: String( + localized: "Dropping materialized view %@ discards its stored rows, which have to be rebuilt." + ), + identity.displayName + ) + )) + } + + guard !isReplacement else { + hazards.append(SyncHazard( + kind: .notSupportedByTarget, + severity: .warning, + explanation: String( + format: String( + localized: "%1$@ %2$@ is dropped and recreated, so anything depending on it fails until it exists again." + ), + identity.kind.displayName, identity.displayName + ) + )) + return hazards + } + + hazards.append(SyncHazard( + kind: .dataLoss, + severity: .refusedByDefault, + explanation: String( + format: String(localized: "Dropping %1$@ %2$@ removes it from the target."), + identity.kind.displayName.lowercased(), identity.displayName + ) + )) + return hazards + } + + private func modifyColumnHazards( + old: EditableColumnDefinition, + new: EditableColumnDefinition + ) -> [SyncHazard] { + var hazards: [SyncHazard] = [] + + if let narrowing = TypeWidthComparison.classify(from: old.dataType, to: new.dataType), + narrowing == .narrowing { + hazards.append(SyncHazard( + kind: .lossyTypeChange, + severity: .refusedByDefault, + explanation: String( + format: String(localized: "Changing %@ from %@ to %@ can truncate existing values."), + old.name, old.dataType, new.dataType + ) + )) + } + + if old.isNullable, !new.isNullable { + hazards.append(SyncHazard( + kind: .dataLoss, + severity: .refusedByDefault, + explanation: String( + format: String(localized: "Making %@ NOT NULL fails if any existing row holds NULL."), + old.name + ) + )) + } + + if normalized(old.collation) != normalized(new.collation) + || normalized(old.charset) != normalized(new.charset) { + hazards.append(SyncHazard( + kind: .collationOrCharsetChange, + severity: .warning, + explanation: String( + format: String(localized: "Changing the collation of %@ can change sort order and uniqueness."), + old.name + ) + )) + } + + return hazards + } + + private func normalized(_ value: String?) -> String { + (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} + +internal enum TypeWidthComparison { + internal enum Outcome { + case widening + case narrowing + case equivalent + } + + internal static func classify(from oldType: String, to newType: String) -> Outcome? { + let old = parse(oldType) + let new = parse(newType) + guard old.base == new.base else { return .narrowing } + guard let oldWidth = old.width, let newWidth = new.width else { return .equivalent } + if newWidth > oldWidth { return .widening } + if newWidth < oldWidth { return .narrowing } + return .equivalent + } + + private static func parse(_ type: String) -> (base: String, width: Int?) { + let lowered = type.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard let open = lowered.firstIndex(of: "("), let close = lowered.firstIndex(of: ")"), open < close else { + return (lowered, nil) + } + let base = String(lowered[lowered.startIndex.. [String] { + var result: [String] = ["TABLE \(snapshot.name)"] + + for column in snapshot.columns { + result.append(" COLUMN \(column.name) \(column.dataType)\(columnAttributes(column))") + } + + let primaryKey = snapshot.primaryKeyColumns + if !primaryKey.isEmpty { + result.append(" PRIMARY KEY (\(primaryKey.joined(separator: ", ")))") + } + + for index in snapshot.indexes.filter({ !$0.isPrimary }) + .sorted(by: { $0.name.localizedStandardCompare($1.name) == .orderedAscending }) { + let unique = index.isUnique ? "UNIQUE " : "" + var line = " \(unique)INDEX \(index.name) (\(index.columns.joined(separator: ", "))) USING \(index.type.rawValue)" + if let whereClause = index.whereClause, !whereClause.isEmpty { + line += " WHERE \(whereClause)" + } + result.append(line) + } + + for foreignKey in snapshot.foreignKeys + .sorted(by: { $0.name.localizedStandardCompare($1.name) == .orderedAscending }) { + let columns = foreignKey.columns.joined(separator: ", ") + let referenced = foreignKey.referencedColumns.joined(separator: ", ") + result.append( + " FOREIGN KEY \(foreignKey.name) (\(columns)) REFERENCES \(foreignKey.referencedTable) (\(referenced))" + + " ON DELETE \(foreignKey.onDelete.rawValue) ON UPDATE \(foreignKey.onUpdate.rawValue)" + ) + } + + if let engine = snapshot.engine, !engine.isEmpty { + result.append(" ENGINE \(engine)") + } + if let collation = snapshot.collation, !collation.isEmpty { + result.append(" COLLATE \(collation)") + } + return result + } + + private static func columnAttributes(_ column: EditableColumnDefinition) -> String { + var parts: [String] = [] + if column.unsigned { parts.append("UNSIGNED") } + parts.append(column.isNullable ? "NULL" : "NOT NULL") + if let defaultValue = column.defaultValue, !defaultValue.isEmpty { + parts.append("DEFAULT \(defaultValue)") + } + if column.autoIncrement { parts.append("AUTO_INCREMENT") } + if let onUpdate = column.onUpdate, !onUpdate.isEmpty { parts.append("ON UPDATE \(onUpdate)") } + if let charset = column.charset, !charset.isEmpty { parts.append("CHARACTER SET \(charset)") } + if let collation = column.collation, !collation.isEmpty { parts.append("COLLATE \(collation)") } + if let comment = column.comment, !comment.isEmpty { parts.append("COMMENT '\(comment)'") } + return parts.isEmpty ? "" : " " + parts.joined(separator: " ") + } +} diff --git a/TablePro/Core/Compare/TableDiffResult.swift b/TablePro/Core/Compare/TableDiffResult.swift new file mode 100644 index 000000000..750fa6d9b --- /dev/null +++ b/TablePro/Core/Compare/TableDiffResult.swift @@ -0,0 +1,86 @@ +// +// TableDiffResult.swift +// TablePro +// +// Outcome of comparing one table between a source and a target connection. +// + +import Foundation + +internal enum TableDiffStatus: String, Codable, Hashable, Sendable { + case onlyInSource + case onlyInTarget + case differs + case identical +} + +internal enum TableSyncAction: String, Codable, Hashable, Sendable, CaseIterable { + case create + case alter + case drop + case skip +} + +internal struct TableDiffResult: Identifiable, Hashable { + internal let tableName: String + internal let schema: String? + internal let status: TableDiffStatus + internal let changes: [SchemaChange] + internal let notes: [String] + internal let comparisonError: String? + + internal var id: String { + guard let schema, !schema.isEmpty else { return tableName } + return "\(schema).\(tableName)" + } + + internal init( + tableName: String, + schema: String?, + status: TableDiffStatus, + changes: [SchemaChange] = [], + notes: [String] = [], + comparisonError: String? = nil + ) { + self.tableName = tableName + self.schema = schema + self.status = status + self.changes = changes + self.notes = notes + self.comparisonError = comparisonError + } + + internal var isComparable: Bool { + comparisonError == nil + } + + internal var suggestedAction: TableSyncAction { + guard comparisonError == nil else { return .skip } + switch status { + case .onlyInSource: return .create + case .onlyInTarget: return .drop + case .differs: return .alter + case .identical: return .skip + } + } +} + +internal struct StructureDiffReport { + internal let results: [TableDiffResult] + + internal init(results: [TableDiffResult]) { + self.results = results + } + + internal var comparable: [TableDiffResult] { + results.filter { $0.isComparable } + } + + internal var uncomparable: [TableDiffResult] { + results.filter { !$0.isComparable } + } + + internal func count(of status: TableDiffStatus) -> Int { + comparable.filter { $0.status == status }.count + } +} diff --git a/TablePro/Core/Compare/TableStructureSnapshot.swift b/TablePro/Core/Compare/TableStructureSnapshot.swift new file mode 100644 index 000000000..9f3da1076 --- /dev/null +++ b/TablePro/Core/Compare/TableStructureSnapshot.swift @@ -0,0 +1,129 @@ +// +// TableStructureSnapshot.swift +// TablePro +// +// One side's view of a table's structure, already converted out of plugin +// transfer types so the diff engine stays free of driver concerns. +// + +import Foundation +import TableProPluginKit + +internal struct TableStructureSnapshot: Hashable { + internal let name: String + internal let schema: String? + internal let columns: [EditableColumnDefinition] + internal let indexes: [EditableIndexDefinition] + internal let foreignKeys: [EditableForeignKeyDefinition] + internal let engine: String? + internal let charset: String? + internal let collation: String? + + internal init( + name: String, + schema: String? = nil, + columns: [EditableColumnDefinition], + indexes: [EditableIndexDefinition] = [], + foreignKeys: [EditableForeignKeyDefinition] = [], + engine: String? = nil, + charset: String? = nil, + collation: String? = nil + ) { + self.name = name + self.schema = schema + self.columns = columns + self.indexes = indexes + self.foreignKeys = foreignKeys + self.engine = engine + self.charset = charset + self.collation = collation + } + + internal var primaryKeyColumns: [String] { + if let primary = indexes.first(where: { $0.isPrimary }) { + return primary.columns + } + return columns.filter { $0.isPrimaryKey }.map { $0.name } + } + + internal var qualifiedName: String { + guard let schema, !schema.isEmpty else { return name } + return "\(schema).\(name)" + } +} + +internal extension TableStructureSnapshot { + static func from( + table: PluginTableInfo, + columns: [PluginColumnInfo], + indexes: [PluginIndexInfo], + foreignKeys: [PluginForeignKeyInfo], + metadata: PluginTableMetadata? = nil + ) -> TableStructureSnapshot { + TableStructureSnapshot( + name: table.name, + schema: table.schema, + columns: columns.map { EditableColumnDefinition.from($0.toColumnInfo()) }, + indexes: indexes.map { EditableIndexDefinition.from($0.toIndexInfo()) }, + foreignKeys: Self.groupForeignKeys(foreignKeys), + engine: metadata?.engine, + charset: nil, + collation: metadata?.collation + ) + } + + private static func groupForeignKeys(_ foreignKeys: [PluginForeignKeyInfo]) -> [EditableForeignKeyDefinition] { + var order: [String] = [] + var grouped: [String: [PluginForeignKeyInfo]] = [:] + for foreignKey in foreignKeys { + if grouped[foreignKey.name] == nil { order.append(foreignKey.name) } + grouped[foreignKey.name, default: []].append(foreignKey) + } + return order.compactMap { name in + guard let parts = grouped[name], let first = parts.first else { return nil } + return EditableForeignKeyDefinition( + id: UUID(), + name: name, + columns: parts.map { $0.column }, + referencedTable: first.referencedTable, + referencedColumns: parts.map { $0.referencedColumn }, + referencedSchema: first.referencedSchema, + onDelete: EditableForeignKeyDefinition.ReferentialAction( + rawValue: first.onDelete.uppercased()) ?? .noAction, + onUpdate: EditableForeignKeyDefinition.ReferentialAction( + rawValue: first.onUpdate.uppercased()) ?? .noAction + ) + } + } +} + +private extension PluginColumnInfo { + func toColumnInfo() -> ColumnInfo { + ColumnInfo( + name: name, + dataType: dataType, + isNullable: isNullable, + isPrimaryKey: isPrimaryKey, + defaultValue: defaultValue, + extra: extra, + charset: charset, + collation: collation, + comment: comment, + allowedValues: allowedValues + ) + } +} + +private extension PluginIndexInfo { + func toIndexInfo() -> IndexInfo { + IndexInfo( + name: name, + columns: columns, + isUnique: isUnique, + isPrimary: isPrimary, + type: type, + columnPrefixes: columnPrefixes, + whereClause: whereClause + ) + } +} diff --git a/TablePro/Core/Menu/AppDelegate+MainMenuActions.swift b/TablePro/Core/Menu/AppDelegate+MainMenuActions.swift index d7a6b2aa6..03751e64d 100644 --- a/TablePro/Core/Menu/AppDelegate+MainMenuActions.swift +++ b/TablePro/Core/Menu/AppDelegate+MainMenuActions.swift @@ -37,6 +37,10 @@ extension AppDelegate: NSMenuItemValidation { WindowOpener.shared.openWelcome() } + @objc func compareAndSyncDatabases(_ sender: Any?) { + CompareSyncLauncher.open() + } + @objc func reopenClosedTab(_ sender: Any?) { RecentlyClosedTabReopener.reopenMostRecent() } diff --git a/TablePro/Core/Menu/DatabaseMenuBuilder.swift b/TablePro/Core/Menu/DatabaseMenuBuilder.swift index 4be552843..e9ddb376c 100644 --- a/TablePro/Core/Menu/DatabaseMenuBuilder.swift +++ b/TablePro/Core/Menu/DatabaseMenuBuilder.swift @@ -88,6 +88,47 @@ enum DatabaseMenuBuilder { MenuItemFactory.item( String(localized: "Reconnect"), action: #selector(MainSplitViewController.retryConnection) + ), + MenuItemFactory.separator, + compareSubmenu() + ]) + } + + /// The HIG asks that every toolbar item also be a menu-bar command. These are the Compare & + /// Sync window's toolbar, mirrored here; they route by nil target, so they reach + /// `CompareSyncWindowController` only while that window is key and validate to disabled + /// everywhere else. + private static func compareSubmenu() -> NSMenuItem { + MenuItemFactory.submenu(String(localized: "Compare"), items: [ + MenuItemFactory.item( + String(localized: "Compare & Sync Databases…"), + action: #selector(AppDelegate.compareAndSyncDatabases(_:)) + ), + MenuItemFactory.separator, + MenuItemFactory.item( + String(localized: "Compare Now"), + action: #selector(CompareSyncWindowController.runComparison(_:)) + ), + MenuItemFactory.item( + String(localized: "Swap Source and Target"), + action: #selector(CompareSyncWindowController.swapEndpoints(_:)) + ), + MenuItemFactory.item( + String(localized: "Comparison Options…"), + action: #selector(CompareSyncWindowController.showOptions(_:)) + ), + MenuItemFactory.separator, + MenuItemFactory.item( + String(localized: "Generate Script"), + action: #selector(CompareSyncWindowController.generateScript(_:)) + ), + MenuItemFactory.item( + String(localized: "Apply to Target…"), + action: #selector(CompareSyncWindowController.applyToTarget(_:)) + ), + MenuItemFactory.item( + String(localized: "Stop Comparison"), + action: #selector(CompareSyncWindowController.stopComparison(_:)) ) ]) } diff --git a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift index d3b20cc35..a65a65601 100644 --- a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift +++ b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift @@ -118,7 +118,7 @@ final class ExportDataSourceAdapter: PluginExportDataSource, @unchecked Sendable /// The export tree names every group after a schema on a schema-aware engine and after a /// database everywhere else, so only a schema-aware driver can read that name as its /// schema. An empty name means the table sits in the driver's own container. - private func exportSchema(for databaseName: String) -> String? { + func exportSchema(for databaseName: String) -> String? { guard let pluginDriver else { return nil } guard pluginDriver.supportsSchemas, !databaseName.isEmpty else { return pluginDriver.currentSchema } return databaseName diff --git a/TablePro/Core/Services/Export/ExportService.swift b/TablePro/Core/Services/Export/ExportService.swift index cebcf703b..d8d097a79 100644 --- a/TablePro/Core/Services/Export/ExportService.swift +++ b/TablePro/Core/Services/Export/ExportService.swift @@ -148,7 +148,8 @@ final class ExportService { name: table.name, databaseName: table.databaseName, tableType: table.type.rawValue.lowercased(), - optionValues: table.optionValues + optionValues: table.optionValues, + schema: dataSource.exportSchema(for: table.databaseName) ) } @@ -249,7 +250,8 @@ final class ExportService { name: config.fileName, databaseName: "", tableType: "query", - optionValues: plugin.defaultTableOptionValues() + optionValues: plugin.defaultTableOptionValues(), + schema: nil ) let result: ExportFormatResult @@ -317,7 +319,8 @@ final class ExportService { name: config.fileName, databaseName: "", tableType: "query", - optionValues: plugin.defaultTableOptionValues() + optionValues: plugin.defaultTableOptionValues(), + schema: nil ) await suppressStatementTimeout(on: driver) diff --git a/TablePro/Core/Services/Infrastructure/WindowIdentifier.swift b/TablePro/Core/Services/Infrastructure/WindowIdentifier.swift index 9f23e3f5b..12184865c 100644 --- a/TablePro/Core/Services/Infrastructure/WindowIdentifier.swift +++ b/TablePro/Core/Services/Infrastructure/WindowIdentifier.swift @@ -15,6 +15,7 @@ internal enum WindowIdentifier { internal static let integrationsActivity = "integrations-activity" internal static let settings = "settings" internal static let acknowledgements = "acknowledgements" + internal static let compareSync = "compare-sync" internal static let connection = "main" internal static let documentInspector = "main-inspector" diff --git a/TablePro/Core/Services/Infrastructure/WindowOpener.swift b/TablePro/Core/Services/Infrastructure/WindowOpener.swift index 1db2f5df2..547367c2c 100644 --- a/TablePro/Core/Services/Infrastructure/WindowOpener.swift +++ b/TablePro/Core/Services/Infrastructure/WindowOpener.swift @@ -17,6 +17,7 @@ internal final class WindowOpener { @ObservationIgnored private var openWelcomeAction: (() -> Void)? @ObservationIgnored private var openConnectionFormAction: ((ConnectionFormRequest) -> Void)? @ObservationIgnored private var openIntegrationsActivityAction: (() -> Void)? + @ObservationIgnored private var openCompareSyncAction: ((UUID?) -> Void)? @ObservationIgnored private var openSettingsAction: ((SettingsPane?) -> Void)? @ObservationIgnored private var stagedDraftId: UUID? @ObservationIgnored private var pendingCalls: [() -> Void] = [] @@ -101,6 +102,14 @@ internal final class WindowOpener { } } + internal func openCompareSync(prefillSource connectionId: UUID? = nil) { + perform { opener in + guard let present = opener.openCompareSyncAction else { return false } + present(connectionId) + return true + } + } + internal func setWelcomePresenter(_ present: @escaping () -> Void) { openWelcomeAction = present drainPendingCalls() @@ -121,6 +130,11 @@ internal final class WindowOpener { drainPendingCalls() } + internal func setCompareSyncPresenter(_ present: @escaping (UUID?) -> Void) { + openCompareSyncAction = present + drainPendingCalls() + } + /// Returns false when the presenter for that window has not been registered yet, which /// queues the call. Each window registers independently, so one that has already migrated /// to AppKit never waits on one that has not. diff --git a/TablePro/Models/Settings/ProFeature.swift b/TablePro/Models/Settings/ProFeature.swift index 5789db22b..47ef04506 100644 --- a/TablePro/Models/Settings/ProFeature.swift +++ b/TablePro/Models/Settings/ProFeature.swift @@ -17,6 +17,7 @@ internal enum ProFeature: String, CaseIterable { case resultCharts case teamCatalog case teamLibrary + case compareSync var displayName: String { switch self { @@ -36,6 +37,8 @@ internal enum ProFeature: String, CaseIterable { return String(localized: "Team Catalog") case .teamLibrary: return String(localized: "Team Library") + case .compareSync: + return String(localized: "Compare & Sync") } } @@ -57,6 +60,8 @@ internal enum ProFeature: String, CaseIterable { return "person.2.fill" case .teamLibrary: return "books.vertical.fill" + case .compareSync: + return "arrow.left.arrow.right.square" } } @@ -78,13 +83,16 @@ internal enum ProFeature: String, CaseIterable { return String(localized: "Publish connections to a shared folder your team reads from. Passwords are never included.") case .teamLibrary: return String(localized: "Share connections and saved queries with your team through your account. Passwords are never included.") + case .compareSync: + return String(localized: "Compare structure or data between two connections and generate the sync script.") } } /// The lowest license tier that unlocks this feature. var requiredTier: LicenseTier { switch self { - case .iCloudSync, .encryptedExport, .envVarReferences, .linkedFolders, .queryInsights, .resultCharts: + case .iCloudSync, .encryptedExport, .envVarReferences, .linkedFolders, .queryInsights, .resultCharts, + .compareSync: return .starter case .teamCatalog, .teamLibrary: return .team diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index da439984e..9a1be3aef 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -373,6 +373,16 @@ } } }, + "%1$@ %2$@ is dropped and recreated, so anything depending on it fails until it exists again." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%1$@ %2$@을(를) 삭제한 뒤 다시 만들기 때문에, 이에 의존하는 항목은 다시 만들어질 때까지 실패합니다.", + "state" : "translated" + } + } + } + }, "%1$@ (%2$@)" : { "localizations" : { "ko" : { @@ -593,6 +603,16 @@ } } }, + "%1$@ → %2$@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%1$@ → %2$@", + "state" : "translated" + } + } + } + }, "%1$@ → %2$@ · %3$@ runs before, %4$@ now" : { "comment" : "Regression detail: prior duration, recent duration, prior count, recent count", "localizations" : { @@ -703,6 +723,26 @@ } } }, + "%1$d of %2$d allowed for this run" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%2$d개 중 %1$d개를 이번 실행에서 허용함", + "state" : "translated" + } + } + } + }, + "%1$d of %2$d included" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%2$d개 중 %1$d개 포함됨", + "state" : "translated" + } + } + } + }, "%1$d of %2$d rows, %3$d marked for deletion" : { "localizations" : { "ko" : { @@ -737,6 +777,36 @@ } } }, + "%1$d of %2$d tables will be compared." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%2$d개 테이블 중 %1$d개를 비교합니다.", + "state" : "translated" + } + } + } + }, + "%1$d statements stay out of this run and %2$@ keeps what it has for them." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "구문 %1$d개는 이번 실행에서 제외되며, %2$@은(는) 해당 항목을 그대로 유지합니다.", + "state" : "translated" + } + } + } + }, + "%1$d statements, %2$d will run." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "구문 %1$d개 중 %2$d개를 실행합니다.", + "state" : "translated" + } + } + } + }, "%1$lld of %2$lld statements were applied. This connection does not roll back user and role changes." : { "localizations" : { "ko" : { @@ -1172,6 +1242,16 @@ } } }, + "%@ cannot be compared, because its driver does not expose metadata." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%@은(는) 드라이버가 메타데이터를 제공하지 않아 비교할 수 없습니다.", + "state" : "translated" + } + } + } + }, "%@ cannot be empty" : { "localizations" : { "ko" : { @@ -1240,6 +1320,16 @@ } } }, + "%@ cannot compare two of its own databases at once, because it reads both through one connection. Use a second connection for the target." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%@은(는) 하나의 연결로 양쪽을 읽기 때문에 자체 데이터베이스 두 개를 동시에 비교할 수 없습니다. 대상에는 다른 연결을 사용하십시오.", + "state" : "translated" + } + } + } + }, "%@ completed" : { "localizations" : { "ko" : { @@ -1308,6 +1398,26 @@ } } }, + "%@ does not report structure metadata that can be compared." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%@은(는) 비교할 수 있는 구조 메타데이터를 제공하지 않습니다.", + "state" : "translated" + } + } + } + }, + "%@ does not support reading rows in key order, which data compare needs." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%@은(는) 데이터 비교에 필요한 키 순서 행 읽기를 지원하지 않습니다.", + "state" : "translated" + } + } + } + }, "%@ does not support switching schemas in TablePro." : { "localizations" : { "ko" : { @@ -1562,6 +1672,16 @@ } } }, + "%@ is no longer a saved connection." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%@은(는) 더 이상 저장된 연결이 아닙니다.", + "state" : "translated" + } + } + } + }, "%@ is required" : { "localizations" : { "ko" : { @@ -2867,6 +2987,34 @@ } } }, + "%d bytes" : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d byte" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d bytes" + } + } + } + } + }, + "ko" : { + "stringUnit" : { + "value" : "%d바이트", + "state" : "translated" + } + } + } + }, "%d cells selected, rows %d to %d, columns %d to %d" : { "localizations" : { "en" : { @@ -2907,6 +3055,34 @@ } } }, + "%d changes" : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d change" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d changes" + } + } + } + } + }, + "ko" : { + "stringUnit" : { + "value" : "변경 %d개", + "state" : "translated" + } + } + } + }, "%d columns" : { "localizations" : { "ko" : { @@ -3181,6 +3357,26 @@ } } }, + "%d included" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%d개 포함됨", + "state" : "translated" + } + } + } + }, + "%d left out" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%d개 제외됨", + "state" : "translated" + } + } + } + }, "%d more" : { "localizations" : { "ko" : { @@ -3375,6 +3571,16 @@ } } }, + "%d of 1 table will be compared." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "테이블 1개 중 %d개를 비교합니다.", + "state" : "translated" + } + } + } + }, "%d plugin(s) could not be loaded" : { "extractionState" : "stale", "localizations" : { @@ -3512,6 +3718,62 @@ } } }, + "%d rows hold NULL in a key column and were left out. Choose a key with no NULLs to compare them." : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d row holds NULL in a key column and was left out. Choose a key with no NULLs to compare it." + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d rows hold NULL in a key column and were left out. Choose a key with no NULLs to compare them." + } + } + } + } + }, + "ko" : { + "stringUnit" : { + "value" : "%d개 행은 키 열이 NULL이라 제외되었습니다. 비교하려면 NULL이 없는 키를 선택하십시오.", + "state" : "translated" + } + } + } + }, + "%d rows match. Matching rows are counted, not listed, so a difference is never crowded out of this list." : { + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d row matches. Matching rows are counted, not listed, so a difference is never crowded out of this list." + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d rows match. Matching rows are counted, not listed, so a difference is never crowded out of this list." + } + } + } + } + }, + "ko" : { + "stringUnit" : { + "value" : "%d개 행이 일치합니다. 일치하는 행은 목록에 넣지 않고 개수만 세므로, 차이점이 목록에서 밀려나지 않습니다.", + "state" : "translated" + } + } + } + }, "%d selected" : { "localizations" : { "ko" : { @@ -3580,6 +3842,16 @@ } } }, + "%d statements would destroy data and are not allowed yet." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "구문 %d개가 데이터를 파괴하며 아직 허용되지 않았습니다.", + "state" : "translated" + } + } + } + }, "%d-%d of %@%@ rows" : { "localizations" : { "en" : { @@ -6538,6 +6810,36 @@ } } }, + "1 statement stays out of this run and %@ keeps what it has for it." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "구문 1개는 이번 실행에서 제외되며, %@은(는) 해당 항목을 그대로 유지합니다.", + "state" : "translated" + } + } + } + }, + "1 statement would destroy data and is not allowed yet." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "구문 1개가 데이터를 파괴하며 아직 허용되지 않았습니다.", + "state" : "translated" + } + } + } + }, + "1 statement, %d will run." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "구문 1개 중 %d개를 실행합니다.", + "state" : "translated" + } + } + } + }, "1 year" : { "localizations" : { "ko" : { @@ -7769,6 +8071,16 @@ } } }, + "A column left out of the comparison is still written on insert and update. Changing either list needs another comparison." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교에서 제외한 열도 삽입과 업데이트에는 그대로 기록됩니다. 두 목록 중 하나를 바꾸면 다시 비교해야 합니다.", + "state" : "translated" + } + } + } + }, "A command named \"/%@\" already exists." : { "localizations" : { "ko" : { @@ -7803,6 +8115,16 @@ } } }, + "A comparison is already running." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교가 이미 실행 중입니다.", + "state" : "translated" + } + } + } + }, "A connection can use one connection method at a time. Disable the other methods to use %@." : { "localizations" : { "ko" : { @@ -8213,6 +8535,16 @@ } } }, + "A run is already in progress." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "실행이 이미 진행 중입니다.", + "state" : "translated" + } + } + } + }, "A service account key is required" : { "localizations" : { "ko" : { @@ -8315,6 +8647,26 @@ } } }, + "A sync is still running" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "동기화가 아직 실행 중입니다", + "state" : "translated" + } + } + } + }, + "A transaction cannot be combined with skip and continue: together they would leave the target half applied." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "트랜잭션은 건너뛰고 계속하기와 함께 쓸 수 없습니다. 두 가지를 함께 쓰면 대상이 절반만 적용된 상태로 남습니다.", + "state" : "translated" + } + } + } + }, "A user or role with this name already exists." : { "localizations" : { "ko" : { @@ -12865,6 +13217,16 @@ } } }, + "All Rows" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "모든 행", + "state" : "translated" + } + } + } + }, "All Time" : { "localizations" : { "ko" : { @@ -13376,6 +13738,16 @@ } } }, + "Allow every held-back statement on the Warnings tab, or exclude the objects that produced them, before applying." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "적용하기 전에 경고 탭에서 보류된 구문을 모두 허용하거나, 해당 구문을 만든 객체를 제외하십시오.", + "state" : "translated" + } + } + } + }, "Allow remote connections" : { "localizations" : { "ko" : { @@ -13444,6 +13816,16 @@ } } }, + "Allowing a statement applies to this run only. It is never saved." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "구문 허용은 이번 실행에만 적용되며 저장되지 않습니다.", + "state" : "translated" + } + } + } + }, "Also handles" : { "localizations" : { "ko" : { @@ -14495,6 +14877,38 @@ } } }, + "Applied to %@ at %@. %d statements." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Applied to %1$@ at %2$@. %3$d statements." + } + }, + "ko" : { + "stringUnit" : { + "value" : "%2$@에 %1$@(으)로 적용했습니다. 구문 %3$d개.", + "state" : "translated" + } + } + } + }, + "Applied to %@ at %@. 1 statement." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Applied to %1$@ at %2$@. 1 statement." + } + }, + "ko" : { + "stringUnit" : { + "value" : "%2$@에 %1$@(으)로 적용했습니다. 구문 1개.", + "state" : "translated" + } + } + } + }, "Applied when opening a table. Click a column header to override." : { "extractionState" : "stale", "localizations" : { @@ -14564,6 +14978,42 @@ } } }, + "Apply %1$d statements to %2$@?" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "구문 %1$d개를 %2$@에 적용하시겠습니까?", + "state" : "translated" + } + } + } + }, + "Apply %@ sync to %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Apply %1$@ sync to %2$@" + } + }, + "ko" : { + "stringUnit" : { + "value" : "%@ 동기화를 %@에 적용", + "state" : "translated" + } + } + } + }, + "Apply 1 statement to %@?" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "구문 1개를 %@에 적용하시겠습니까?", + "state" : "translated" + } + } + } + }, "Apply All" : { "extractionState" : "stale", "localizations" : { @@ -15045,6 +15495,16 @@ } } }, + "Apply to %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%@에 적용", + "state" : "translated" + } + } + } + }, "Apply to Editor" : { "localizations" : { "ko" : { @@ -15079,6 +15539,16 @@ } } }, + "Apply to Target…" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "대상에 적용…", + "state" : "translated" + } + } + } + }, "Applying or clearing filters will reload data and discard all unsaved changes." : { "localizations" : { "ko" : { @@ -15113,6 +15583,26 @@ } } }, + "Applying to %@…" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%@에 적용하는 중…", + "state" : "translated" + } + } + } + }, + "Apply…" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "적용…", + "state" : "translated" + } + } + } + }, "Approve" : { "localizations" : { "ko" : { @@ -16794,6 +17284,16 @@ } } }, + "Auto-increment seed" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "자동 증가 시작값", + "state" : "translated" + } + } + } + }, "Auto-indent" : { "extractionState" : "stale", "localizations" : { @@ -17627,6 +18127,16 @@ } } }, + "Binary content" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "이진 콘텐츠", + "state" : "translated" + } + } + } + }, "Block" : { "extractionState" : "stale", "localizations" : { @@ -18140,6 +18650,16 @@ } } }, + "Build the SQL that brings the target in line" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "대상을 맞추는 SQL 생성", + "state" : "translated" + } + } + } + }, "Building chart" : { "localizations" : { "ko" : { @@ -18825,6 +19345,16 @@ } } }, + "CREATE statement" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "CREATE 구문", + "state" : "translated" + } + } + } + }, "CRITICAL: Transaction rollback failed - database may be in inconsistent state: %@" : { "extractionState" : "stale", "localizations" : { @@ -19619,6 +20149,16 @@ } } }, + "Cancelled before the script finished." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "스크립트가 끝나기 전에 취소되었습니다.", + "state" : "translated" + } + } + } + }, "Cancelled by user." : { "localizations" : { "ko" : { @@ -20998,6 +21538,16 @@ } } }, + "Change the filter to see the other rows." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "다른 행을 보려면 필터를 변경하십시오.", + "state" : "translated" + } + } + } + }, "Changed" : { "comment" : "Text for a split diff marker that indicates a change in a split diff.", "isCommentAutoGenerated" : true, @@ -21034,6 +21584,52 @@ } } }, + "Changes" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "변경 사항", + "state" : "translated" + } + } + } + }, + "Changing %@ from %@ to %@ can truncate existing values." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Changing %1$@ from %2$@ to %3$@ can truncate existing values." + } + }, + "ko" : { + "stringUnit" : { + "value" : "%@을(를) %@에서 %@(으)로 변경하면 기존 값이 잘릴 수 있습니다.", + "state" : "translated" + } + } + } + }, + "Changing the collation of %@ can change sort order and uniqueness." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%@의 콜레이션을 변경하면 정렬 순서와 고유성이 달라질 수 있습니다.", + "state" : "translated" + } + } + } + }, + "Changing the primary key rebuilds the table and can fail on duplicate values." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "기본 키를 변경하면 테이블을 다시 만들며, 값이 중복되면 실패할 수 있습니다.", + "state" : "translated" + } + } + } + }, "Character Set" : { "extractionState" : "stale", "localizations" : { @@ -21725,6 +22321,16 @@ } } }, + "Choose %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%@ 선택", + "state" : "translated" + } + } + } + }, "Choose AI provider and model" : { "localizations" : { "ko" : { @@ -22168,6 +22774,16 @@ } } }, + "Choose a key column before comparing data." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "데이터를 비교하기 전에 키 열을 선택하십시오.", + "state" : "translated" + } + } + } + }, "Choose a location to save the diagram as PNG." : { "localizations" : { "ko" : { @@ -22373,6 +22989,66 @@ } } }, + "Choose a source and a target first." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "먼저 원본과 대상을 선택하십시오.", + "state" : "translated" + } + } + } + }, + "Choose a source and a target, then compare them." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "원본과 대상을 선택한 다음 비교하십시오.", + "state" : "translated" + } + } + } + }, + "Choose a source or a target first." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "먼저 원본 또는 대상을 선택하십시오.", + "state" : "translated" + } + } + } + }, + "Choose a source to compare from." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교할 원본을 선택하십시오.", + "state" : "translated" + } + } + } + }, + "Choose a target to compare against." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교 대상을 선택하십시오.", + "state" : "translated" + } + } + } + }, + "Choose a target to write to." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "기록할 대상을 선택하십시오.", + "state" : "translated" + } + } + } + }, "Choose an export file to import" : { "localizations" : { "ko" : { @@ -24991,6 +25667,16 @@ } } }, + "Closing now stops the run between statements. Statements that already ran stay applied." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "지금 닫으면 구문 사이에서 실행이 중단됩니다. 이미 실행된 구문은 적용된 상태로 남습니다.", + "state" : "translated" + } + } + } + }, "Closing this tab will discard all unsaved changes." : { "extractionState" : "stale", "localizations" : { @@ -25604,6 +26290,26 @@ } } }, + "Collation and character set" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "콜레이션 및 문자 집합", + "state" : "translated" + } + } + } + }, + "Collation or character set change" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "콜레이션 또는 문자 집합 변경", + "state" : "translated" + } + } + } + }, "Collation:" : { "localizations" : { "ko" : { @@ -26061,6 +26767,26 @@ } } }, + "Column order" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "열 순서", + "state" : "translated" + } + } + } + }, + "Column order differs. No statement is generated for reordering columns." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "열 순서가 다릅니다. 열 순서를 바꾸는 구문은 생성되지 않습니다.", + "state" : "translated" + } + } + } + }, "Column reorder failed: %@" : { "localizations" : { "ko" : { @@ -26539,6 +27265,16 @@ } } }, + "Comments and owners" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "주석 및 소유자", + "state" : "translated" + } + } + } + }, "Compact" : { "localizations" : { "ko" : { @@ -26607,6 +27343,198 @@ } } }, + "Compare" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교", + "state" : "translated" + } + } + } + }, + "Compare %@ and write changes to %@." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Compare %1$@ and write changes to %2$@." + } + }, + "ko" : { + "stringUnit" : { + "value" : "%@을(를) 비교하고 변경 사항을 %@에 기록합니다.", + "state" : "translated" + } + } + } + }, + "Compare & Sync" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교 및 동기화", + "state" : "translated" + } + } + } + }, + "Compare & Sync Databases…" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "데이터베이스 비교 및 동기화…", + "state" : "translated" + } + } + } + }, + "Compare & Sync requires a license" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교 및 동기화에는 라이선스가 필요합니다", + "state" : "translated" + } + } + } + }, + "Compare Now" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "지금 비교", + "state" : "translated" + } + } + } + }, + "Compare lists the tables both sides share. Choose the tables to compare, then press Compare." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교는 양쪽이 공유하는 테이블을 나열합니다. 비교할 테이블을 선택한 다음 비교를 누르십시오.", + "state" : "translated" + } + } + } + }, + "Compare structure or data" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "구조 또는 데이터 비교", + "state" : "translated" + } + } + } + }, + "Compare structure or data between two connections and generate the sync script." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "두 연결 사이의 구조 또는 데이터를 비교하고 동기화 스크립트를 생성합니다.", + "state" : "translated" + } + } + } + }, + "Compare the two databases" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "두 데이터베이스 비교", + "state" : "translated" + } + } + } + }, + "Compare the two databases first." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "먼저 두 데이터베이스를 비교하십시오.", + "state" : "translated" + } + } + } + }, + "Compare/Sync with…" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "다음과 비교/동기화…", + "state" : "translated" + } + } + } + }, + "Compared %@. %d differences. Nothing has been written." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Compared %1$@. %2$d differences. Nothing has been written." + } + }, + "ko" : { + "stringUnit" : { + "value" : "%1$@을(를) 비교했습니다. 차이점 %2$d개. 아무것도 기록되지 않았습니다.", + "state" : "translated" + } + } + } + }, + "Compared %@. 1 difference. Nothing has been written." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%@을(를) 비교했습니다. 차이점 1개. 아무것도 기록되지 않았습니다.", + "state" : "translated" + } + } + } + }, + "Compared columns" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교한 열", + "state" : "translated" + } + } + } + }, + "Comparing only. Nothing has been written." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교만 했습니다. 아무것도 기록되지 않았습니다.", + "state" : "translated" + } + } + } + }, + "Comparison Options…" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교 옵션…", + "state" : "translated" + } + } + } + }, + "Comparison cancelled." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교가 취소되었습니다.", + "state" : "translated" + } + } + } + }, "Complete Sign In" : { "localizations" : { "ko" : { @@ -31082,6 +32010,16 @@ } } }, + "Could Not Compare" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교할 수 없음", + "state" : "translated" + } + } + } + }, "Could Not Open File" : { "localizations" : { "ko" : { @@ -32574,6 +33512,16 @@ } } }, + "Create %1$@ %2$@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%1$@ %2$@ 생성", + "state" : "translated" + } + } + } + }, "Create %@" : { "localizations" : { "ko" : { @@ -33267,6 +34215,16 @@ } } }, + "Create table %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "테이블 %@ 생성", + "state" : "translated" + } + } + } + }, "Create your own slash commands. Use {{query}}, {{schema}}, {{database}}, or {{body}} in the template to insert chat context at runtime." : { "localizations" : { "ko" : { @@ -34742,6 +35700,16 @@ } } }, + "Data Comparison" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "데이터 비교", + "state" : "translated" + } + } + } + }, "Data Grid" : { "localizations" : { "ko" : { @@ -34913,6 +35881,16 @@ } } }, + "Data loss" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "데이터 손실", + "state" : "translated" + } + } + } + }, "Database" : { "localizations" : { "ko" : { @@ -36900,6 +37878,36 @@ } } }, + "Definition" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "정의", + "state" : "translated" + } + } + } + }, + "Definitions" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "정의", + "state" : "translated" + } + } + } + }, + "Definitions Compare Structure" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "정의는 구조를 비교합니다", + "state" : "translated" + } + } + } + }, "Delete" : { "localizations" : { "ko" : { @@ -37851,6 +38859,42 @@ } } }, + "Delete is off by default, so a first run cannot remove a row from the target." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "삭제는 기본적으로 꺼져 있으므로, 첫 실행에서는 대상의 행을 제거할 수 없습니다.", + "state" : "translated" + } + } + } + }, + "Delete row %@ from %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete row %1$@ from %2$@" + } + }, + "ko" : { + "stringUnit" : { + "value" : "%2$@에서 행 %1$@ 삭제", + "state" : "translated" + } + } + } + }, + "Delete rows the source does not have" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "원본에 없는 행 삭제", + "state" : "translated" + } + } + } + }, "Delete this column?" : { "localizations" : { "ko" : { @@ -38123,6 +39167,22 @@ } } }, + "Deleting row %@ from %@ permanently removes it." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Deleting row %1$@ from %2$@ permanently removes it." + } + }, + "ko" : { + "stringUnit" : { + "value" : "%2$@에서 행 %1$@을(를) 삭제하면 영구적으로 제거됩니다.", + "state" : "translated" + } + } + } + }, "Delimiter" : { "localizations" : { "ko" : { @@ -38599,6 +39659,16 @@ } } }, + "Detail" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "세부사항", + "state" : "translated" + } + } + } + }, "Details" : { "localizations" : { "ko" : { @@ -38837,6 +39907,26 @@ } } }, + "Difference" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "차이점", + "state" : "translated" + } + } + } + }, + "Differs" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "다름", + "state" : "translated" + } + } + } + }, "Digits" : { "localizations" : { "ko" : { @@ -40411,6 +41501,16 @@ } } }, + "Drop %1$@ %2$@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%1$@ %2$@ 삭제", + "state" : "translated" + } + } + } + }, "Drop %1$@ “%2$@”?" : { "localizations" : { "ko" : { @@ -41345,6 +42445,16 @@ } } }, + "Drop table %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "테이블 %@ 삭제", + "state" : "translated" + } + } + } + }, "Drop table '%@'" : { "localizations" : { "ko" : { @@ -41413,6 +42523,56 @@ } } }, + "Dropping %1$@ %2$@ removes it from the target." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%1$@ %2$@을(를) 삭제하면 대상에서 제거됩니다.", + "state" : "translated" + } + } + } + }, + "Dropping column %@ permanently removes its data." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "열 %@을(를) 삭제하면 해당 데이터가 영구적으로 제거됩니다.", + "state" : "translated" + } + } + } + }, + "Dropping index %@ can slow existing queries." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "인덱스 %@을(를) 삭제하면 기존 쿼리가 느려질 수 있습니다.", + "state" : "translated" + } + } + } + }, + "Dropping materialized view %@ discards its stored rows, which have to be rebuilt." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "구체화 뷰 %@을(를) 삭제하면 저장된 행이 버려지며, 다시 만들어야 합니다.", + "state" : "translated" + } + } + } + }, + "Dropping table %@ permanently removes the table and all of its rows." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "테이블 %@을(를) 삭제하면 테이블과 모든 행이 영구적으로 제거됩니다.", + "state" : "translated" + } + } + } + }, "Dropping..." : { "extractionState" : "stale", "localizations" : { @@ -45126,6 +46286,40 @@ } } }, + "Error %d" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오류 %d" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hata %d" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lỗi %d" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "错误 %d" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "錯誤 %d" + } + } + } + }, "Error Applying Changes" : { "localizations" : { "ko" : { @@ -45433,6 +46627,26 @@ } } }, + "Every object that was compared matches." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교한 모든 객체가 일치합니다.", + "state" : "translated" + } + } + } + }, + "Every shared column is generated, so there is nothing to write." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "공유하는 모든 열이 생성 열이므로 기록할 내용이 없습니다.", + "state" : "translated" + } + } + } + }, "Every table needs at least one column. Click + to get started" : { "localizations" : { "ko" : { @@ -45501,6 +46715,16 @@ } } }, + "Exact value" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "정확한 값", + "state" : "translated" + } + } + } + }, "Examples" : { "localizations" : { "ko" : { @@ -45569,6 +46793,26 @@ } } }, + "Exclude" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "제외", + "state" : "translated" + } + } + } + }, + "Exclude Every Table" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "모든 테이블 제외", + "state" : "translated" + } + } + } + }, "Exclude from iCloud Sync" : { "localizations" : { "ko" : { @@ -46184,6 +47428,16 @@ } } }, + "Execution" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "실행", + "state" : "translated" + } + } + } + }, "Execution: %.3fms" : { "localizations" : { "ko" : { @@ -51627,6 +52881,16 @@ } } }, + "Filter by name" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "이름으로 필터링", + "state" : "translated" + } + } + } + }, "Filter by only this row" : { "extractionState" : "stale", "localizations" : { @@ -52596,6 +53860,16 @@ } } }, + "Finished" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "완료됨", + "state" : "translated" + } + } + } + }, "First Page" : { "localizations" : { "ko" : { @@ -53006,6 +54280,16 @@ } } }, + "Fold" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "접기", + "state" : "translated" + } + } + } + }, "Folder" : { "localizations" : { "ko" : { @@ -53390,6 +54674,22 @@ } } }, + "Foreign key %@ matches %@ on the target but the names differ." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Foreign key %1$@ matches %2$@ on the target but the names differ." + } + }, + "ko" : { + "stringUnit" : { + "value" : "외래 키 %@은(는) 대상의 %@과(와) 일치하지만 이름이 다릅니다.", + "state" : "translated" + } + } + } + }, "Forever" : { "localizations" : { "ko" : { @@ -53527,40 +54827,6 @@ } } }, - "Forward" : { - "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "앞으로" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "İleri" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tiến tới" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "前进" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "前進" - } - } - } - }, "Format Query" : { "localizations" : { "ko" : { @@ -53801,6 +55067,40 @@ } } }, + "Forward" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앞으로" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İleri" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiến tới" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "前进" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "前進" + } + } + } + }, "From %@…" : { "localizations" : { "ko" : { @@ -54073,6 +55373,16 @@ } } }, + "Generate Script" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "스크립트 생성", + "state" : "translated" + } + } + } + }, "Generate Token" : { "localizations" : { "ko" : { @@ -54141,6 +55451,26 @@ } } }, + "Generate the script first." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "먼저 스크립트를 생성하십시오.", + "state" : "translated" + } + } + } + }, + "Generate the script to see exactly what would run." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "무엇이 실행될지 정확히 보려면 스크립트를 생성하십시오.", + "state" : "translated" + } + } + } + }, "Generate token" : { "localizations" : { "ko" : { @@ -55164,6 +56494,16 @@ } } }, + "Group By" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "그룹화 기준", + "state" : "translated" + } + } + } + }, "Group name" : { "localizations" : { "ko" : { @@ -55198,6 +56538,16 @@ } } }, + "Group results by difference or object type" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "차이점 또는 객체 유형으로 결과 그룹화", + "state" : "translated" + } + } + } + }, "Group: %@" : { "localizations" : { "ko" : { @@ -55266,6 +56616,16 @@ } } }, + "Has Differences" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "차이 있음", + "state" : "translated" + } + } + } + }, "Has unsaved changes" : { "localizations" : { "ko" : { @@ -55300,6 +56660,26 @@ } } }, + "Held Back" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "보류됨", + "state" : "translated" + } + } + } + }, + "Held back" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "보류됨", + "state" : "translated" + } + } + } + }, "Help" : { "localizations" : { "ko" : { @@ -56670,6 +58050,26 @@ } } }, + "Identical" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "동일", + "state" : "translated" + } + } + } + }, + "Identifier case" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "식별자 대소문자", + "state" : "translated" + } + } + } + }, "Identity" : { "localizations" : { "ko" : { @@ -58220,6 +59620,16 @@ } } }, + "Include" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "포함", + "state" : "translated" + } + } + } + }, "Include Credentials" : { "localizations" : { "ko" : { @@ -58254,6 +59664,16 @@ } } }, + "Include Every Table" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "모든 테이블 포함", + "state" : "translated" + } + } + } + }, "Include NULL values" : { "extractionState" : "stale", "localizations" : { @@ -58323,6 +59743,26 @@ } } }, + "Include at least one object to generate a script for it." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "스크립트를 생성하려면 객체를 하나 이상 포함하십시오.", + "state" : "translated" + } + } + } + }, + "Include at least one table to generate a script for it." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "스크립트를 생성하려면 테이블을 하나 이상 포함하십시오.", + "state" : "translated" + } + } + } + }, "Include column headers" : { "extractionState" : "stale", "localizations" : { @@ -58461,6 +59901,26 @@ } } }, + "Include every object in this group" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "이 그룹의 모든 객체 포함", + "state" : "translated" + } + } + } + }, + "Include every table in this group" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "이 그룹의 모든 테이블 포함", + "state" : "translated" + } + } + } + }, "Include in iCloud Sync" : { "localizations" : { "ko" : { @@ -58597,6 +60057,46 @@ } } }, + "Include this object" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "이 객체 포함", + "state" : "translated" + } + } + } + }, + "Include this row" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "이 행 포함", + "state" : "translated" + } + } + } + }, + "Include this table and compare again to see its rows." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "행을 보려면 이 테이블을 포함하고 다시 비교하십시오.", + "state" : "translated" + } + } + } + }, + "Include this table in the comparison" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "이 테이블을 비교에 포함", + "state" : "translated" + } + } + } + }, "Incompatible plugin version" : { "extractionState" : "stale", "localizations" : { @@ -58768,6 +60268,22 @@ } } }, + "Index %@ matches %@ on the target but the names differ." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Index %1$@ matches %2$@ on the target but the names differ." + } + }, + "ko" : { + "stringUnit" : { + "value" : "인덱스 %@은(는) 대상의 %@과(와) 일치하지만 이름이 다릅니다.", + "state" : "translated" + } + } + } + }, "Index Size" : { "localizations" : { "ko" : { @@ -59491,6 +61007,32 @@ } } }, + "Insert row %@ into %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Insert row %1$@ into %2$@" + } + }, + "ko" : { + "stringUnit" : { + "value" : "%2$@에 행 %1$@ 삽입", + "state" : "translated" + } + } + } + }, + "Insert rows the target is missing" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "대상에 없는 행 삽입", + "state" : "translated" + } + } + } + }, "Inserted" : { "localizations" : { "ko" : { @@ -61931,6 +63473,26 @@ } } }, + "Key column %@ is not present on both sides. Choose a different key." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "키 열 %@이(가) 양쪽에 모두 있지는 않습니다. 다른 키를 선택하십시오.", + "state" : "translated" + } + } + } + }, + "Key columns" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "키 열", + "state" : "translated" + } + } + } + }, "Key pattern" : { "localizations" : { "ko" : { @@ -66338,6 +67900,16 @@ } } }, + "Lossy type change" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "손실이 있는 타입 변경", + "state" : "translated" + } + } + } + }, "Low" : { "localizations" : { "ko" : { @@ -66997,6 +68569,26 @@ } } }, + "Make the target the source and the source the target" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "대상을 원본으로, 원본을 대상으로 바꿉니다", + "state" : "translated" + } + } + } + }, + "Making %@ NOT NULL fails if any existing row holds NULL." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "기존 행에 NULL이 있으면 %@을(를) NOT NULL로 만드는 작업은 실패합니다.", + "state" : "translated" + } + } + } + }, "Malformed deep link path: %@" : { "localizations" : { "ko" : { @@ -70250,6 +71842,40 @@ } } }, + "Moves the editor cursor to the statement that produced this result" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 결과를 생성한 문으로 편집기 커서를 이동합니다" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düzenleyici imlecini bu sonucu üreten ifadeye taşır" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Di chuyển con trỏ trình soạn thảo tới câu lệnh đã tạo ra kết quả này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将编辑器光标移到生成此结果的语句" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "將編輯器游標移到產生此結果的陳述式" + } + } + } + }, "Multi-model database with SurrealQL" : { "localizations" : { "ko" : { @@ -70837,6 +72463,16 @@ } } }, + "NULL only equals NULL" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "NULL은 NULL과만 같음", + "state" : "translated" + } + } + } + }, "NULL — no referenced row" : { "extractionState" : "stale", "localizations" : { @@ -73215,6 +74851,16 @@ } } }, + "No Comparison Yet" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "아직 비교하지 않음", + "state" : "translated" + } + } + } + }, "No Connections" : { "localizations" : { "ko" : { @@ -73487,6 +75133,16 @@ } } }, + "No Differences" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "차이 없음", + "state" : "translated" + } + } + } + }, "No Favorites" : { "localizations" : { "ko" : { @@ -73772,6 +75428,16 @@ } } }, + "No Object" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "객체 없음", + "state" : "translated" + } + } + } + }, "No Object Selected" : { "localizations" : { "ko" : { @@ -74091,6 +75757,16 @@ } } }, + "No Rows Match" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "일치하는 행 없음", + "state" : "translated" + } + } + } + }, "No SSL encryption" : { "localizations" : { "ko" : { @@ -74161,6 +75837,16 @@ } } }, + "No Script Yet" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "아직 스크립트 없음", + "state" : "translated" + } + } + } + }, "No Selection" : { "localizations" : { "ko" : { @@ -74195,6 +75881,16 @@ } } }, + "No Table Selected" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "선택된 테이블 없음", + "state" : "translated" + } + } + } + }, "No Tables" : { "extractionState" : "stale", "localizations" : { @@ -74230,6 +75926,16 @@ } } }, + "No Tables Yet" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "아직 테이블 없음", + "state" : "translated" + } + } + } + }, "No Triggers" : { "localizations" : { "ko" : { @@ -74298,6 +76004,16 @@ } } }, + "No Warnings" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "경고 없음", + "state" : "translated" + } + } + } + }, "No activations found" : { "localizations" : { "en" : { @@ -74645,6 +76361,26 @@ } } }, + "No columns in common." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "공통된 열이 없습니다.", + "state" : "translated" + } + } + } + }, + "No comparison yet." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "아직 비교하지 않았습니다.", + "state" : "translated" + } + } + } + }, "No compatible build is available yet. This plugin will update automatically once one is published." : { "localizations" : { "ko" : { @@ -76081,6 +77817,16 @@ } } }, + "No primary key. Choose key columns to compare this table." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "기본 키가 없습니다. 이 테이블을 비교하려면 키 열을 선택하십시오.", + "state" : "translated" + } + } + } + }, "No privileges" : { "localizations" : { "ko" : { @@ -76662,6 +78408,16 @@ } } }, + "No saved setups for this source and target." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "이 원본과 대상에 저장된 설정이 없습니다.", + "state" : "translated" + } + } + } + }, "No saved templates" : { "extractionState" : "stale", "localizations" : { @@ -77007,6 +78763,16 @@ } } }, + "No tables were compared. Choose the tables to compare, then press Compare." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교한 테이블이 없습니다. 비교할 테이블을 선택한 다음 비교를 누르십시오.", + "state" : "translated" + } + } + } + }, "No tabs open" : { "localizations" : { "ko" : { @@ -77280,6 +79046,16 @@ } } }, + "None chosen" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "선택 없음", + "state" : "translated" + } + } + } + }, "Normal" : { "localizations" : { "ko" : { @@ -77348,6 +79124,26 @@ } } }, + "Not Compared" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교하지 않음", + "state" : "translated" + } + } + } + }, + "Not Compared Yet" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "아직 비교하지 않음", + "state" : "translated" + } + } + } + }, "Not Installed" : { "localizations" : { "ko" : { @@ -77416,6 +79212,16 @@ } } }, + "Not compared" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교하지 않음", + "state" : "translated" + } + } + } + }, "Not configured" : { "localizations" : { "ko" : { @@ -77913,6 +79719,16 @@ } } }, + "Not supported by the target" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "대상에서 지원하지 않음", + "state" : "translated" + } + } + } + }, "Not supported by this database" : { "localizations" : { "ko" : { @@ -78083,6 +79899,16 @@ } } }, + "Notes" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "참고", + "state" : "translated" + } + } + } + }, "Nothing got at least %1$lld%% slower than the period before. A query needs %2$lld runs in both periods, and has to have grown by at least %3$@." : { "comment" : "Empty state for regressions: %1$lld is a percentage, %2$lld a run count, %3$@ a duration", "localizations" : { @@ -78118,6 +79944,56 @@ } } }, + "Nothing in this script destroys data in %@." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "이 스크립트에는 %@의 데이터를 파괴하는 내용이 없습니다.", + "state" : "translated" + } + } + } + }, + "Nothing is running." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "실행 중인 작업이 없습니다.", + "state" : "translated" + } + } + } + }, + "Nothing is selected to apply." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "적용할 항목이 선택되지 않았습니다.", + "state" : "translated" + } + } + } + }, + "Nothing is written until Apply." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "적용하기 전에는 아무것도 기록되지 않습니다.", + "state" : "translated" + } + } + } + }, + "Nothing to Show" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "표시할 내용 없음", + "state" : "translated" + } + } + } + }, "Null" : { "localizations" : { "ko" : { @@ -78256,6 +80132,16 @@ } } }, + "Numeric tolerance" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "숫자 허용 오차", + "state" : "translated" + } + } + } + }, "OAuth Client ID" : { "localizations" : { "ko" : { @@ -78461,6 +80347,16 @@ } } }, + "Object Type" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "객체 유형", + "state" : "translated" + } + } + } + }, "Objects that depend on them will be dropped too." : { "comment" : "Message in a confirmation alert when the user has selected to drop all objects that depend on the dropped objects.", "isCommentAutoGenerated" : true, @@ -78497,6 +80393,16 @@ } } }, + "Objects to Compare" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교할 객체", + "state" : "translated" + } + } + } + }, "Off" : { "localizations" : { "ko" : { @@ -78770,6 +80676,16 @@ } } }, + "On error" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "오류 발생 시", + "state" : "translated" + } + } + } + }, "Only affects new saves. Re-save a password to update its sync." : { "localizations" : { "ko" : { @@ -78804,6 +80720,26 @@ } } }, + "Only in Source" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "원본에만 있음", + "state" : "translated" + } + } + } + }, + "Only in Target" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "대상에만 있음", + "state" : "translated" + } + } + } + }, "Only the first %1$lld nodes were loaded, so this value was not searched in full. Switch to %2$@ to search all of it." : { "localizations" : { "ko" : { @@ -86715,6 +88651,16 @@ } } }, + "Primary key change" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "기본 키 변경", + "state" : "translated" + } + } + } + }, "Priority %d" : { "localizations" : { "ko" : { @@ -89563,6 +91509,16 @@ } } }, + "Quitting stops the run against %@. Statements that already ran stay applied." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "종료하면 %@에 대한 실행이 중단됩니다. 이미 실행된 구문은 적용된 상태로 남습니다.", + "state" : "translated" + } + } + } + }, "Quote" : { "extractionState" : "stale", "localizations" : { @@ -90419,6 +92375,16 @@ } } }, + "Read-Only. Choose a different connection to write changes to." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "읽기 전용입니다. 변경 사항을 기록하려면 다른 연결을 선택하십시오.", + "state" : "translated" + } + } + } + }, "Read-Write" : { "extractionState" : "stale", "localizations" : { @@ -93829,6 +95795,16 @@ } } }, + "Replace %1$@ %2$@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%1$@ %2$@ 교체", + "state" : "translated" + } + } + } + }, "Replace the estimate with an exact row count." : { "localizations" : { "ko" : { @@ -95483,6 +97459,40 @@ } } }, + "Result %d" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "결과 %d" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonuç %d" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết quả %d" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "结果 %d" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "結果 %d" + } + } + } + }, "Result Charts" : { "localizations" : { "ko" : { @@ -96940,6 +98950,16 @@ } } }, + "Rows Compare Data" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "행은 데이터를 비교합니다", + "state" : "translated" + } + } + } + }, "Rows per INSERT" : { "extractionState" : "stale", "localizations" : { @@ -97350,6 +99370,16 @@ } } }, + "Run in a transaction" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "트랜잭션으로 실행", + "state" : "translated" + } + } + } + }, "Run some queries and this tab will show which you run most, which run slowest, and which got slower." : { "localizations" : { "ko" : { @@ -97452,6 +99482,16 @@ } } }, + "Run the script against the target" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "대상에 대해 스크립트 실행", + "state" : "translated" + } + } + } + }, "Run the statement starting on line %d" : { "localizations" : { "ko" : { @@ -99894,6 +101934,16 @@ } } }, + "Same" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "동일", + "state" : "translated" + } + } + } + }, "Same options will be applied to all selected tables." : { "localizations" : { "ko" : { @@ -100833,6 +102883,16 @@ } } }, + "Saved Comparisons" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "저장된 비교", + "state" : "translated" + } + } + } + }, "Saved Connections" : { "localizations" : { "ko" : { @@ -101038,6 +103098,16 @@ } } }, + "Save…" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "저장…", + "state" : "translated" + } + } + } + }, "Scale" : { "extractionState" : "stale", "localizations" : { @@ -101586,6 +103656,26 @@ } } }, + "Script" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "스크립트", + "state" : "translated" + } + } + } + }, + "Script generation cancelled." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "스크립트 생성이 취소되었습니다.", + "state" : "translated" + } + } + } + }, "Scroll to latest message" : { "localizations" : { "ko" : { @@ -102767,6 +104857,16 @@ } } }, + "Select" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "선택", + "state" : "translated" + } + } + } + }, "Select %@" : { "localizations" : { "ko" : { @@ -103385,6 +105485,16 @@ } } }, + "Select a table to see its row differences." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "행 차이를 보려면 테이블을 선택하십시오.", + "state" : "translated" + } + } + } + }, "Select a table…" : { "localizations" : { "ko" : { @@ -103521,6 +105631,16 @@ } } }, + "Select an object to see its definition on both sides." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "양쪽 정의를 보려면 객체를 선택하십시오.", + "state" : "translated" + } + } + } + }, "Select filter column" : { "localizations" : { "ko" : { @@ -104142,6 +106262,16 @@ } } }, + "Sequence" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "시퀀스", + "state" : "translated" + } + } + } + }, "Series" : { "localizations" : { "ko" : { @@ -105629,6 +107759,16 @@ } } }, + "Show" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "표시", + "state" : "translated" + } + } + } + }, "Show All" : { "localizations" : { "ko" : { @@ -106076,6 +108216,16 @@ } } }, + "Show Identical Objects" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "동일한 객체 표시", + "state" : "translated" + } + } + } + }, "Show Inspector" : { "localizations" : { "ko" : { @@ -107041,6 +109191,16 @@ } } }, + "Show only the objects whose name contains this text" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "이 텍스트가 이름에 포함된 객체만 표시합니다", + "state" : "translated" + } + } + } + }, "Show password" : { "localizations" : { "ko" : { @@ -108894,6 +111054,16 @@ } } }, + "Skip and continue" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "건너뛰고 계속하기", + "state" : "translated" + } + } + } + }, "Skips foreign key constraint checks for this operation" : { "extractionState" : "stale", "localizations" : { @@ -109339,6 +111509,16 @@ } } }, + "Some tables have not been compared with the columns now chosen." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "지금 선택한 열로 아직 비교하지 않은 테이블이 있습니다.", + "state" : "translated" + } + } + } + }, "Some tabs have unsaved edits. Quitting will discard these changes." : { "localizations" : { "ko" : { @@ -110435,6 +112615,40 @@ } } }, + "Statement %1$d/%2$d failed: %3$@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$d개 중 %1$d번째 문 실패: %3$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d/%2$d numaralı ifade başarısız oldu: %3$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Câu lệnh %1$d/%2$d thất bại: %3$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "第 %1$d/%2$d 条语句失败:%3$@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "第 %1$d/%2$d 條陳述式失敗:%3$@" + } + } + } + }, "Statement %lld" : { "extractionState" : "stale", "localizations" : { @@ -110546,6 +112760,16 @@ } } }, + "Statements that already ran stay applied to %@. Compare again to see where it stands." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "이미 실행된 구문은 %@에 적용된 상태로 남습니다. 현재 상태를 보려면 다시 비교하십시오.", + "state" : "translated" + } + } + } + }, "Status" : { "localizations" : { "ko" : { @@ -111132,6 +113356,16 @@ } } }, + "Stop Comparison" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교 중지", + "state" : "translated" + } + } + } + }, "Stop Export?" : { "localizations" : { "ko" : { @@ -111210,6 +113444,46 @@ } } }, + "Stop and Close" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "중지하고 닫기", + "state" : "translated" + } + } + } + }, + "Stop and Quit" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "중지하고 종료", + "state" : "translated" + } + } + } + }, + "Stop and keep what ran" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "중지하고 실행된 내용 유지", + "state" : "translated" + } + } + } + }, + "Stop and roll back" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "중지하고 롤백", + "state" : "translated" + } + } + } + }, "Stop recording queries on this Mac" : { "localizations" : { "ko" : { @@ -111298,6 +113572,42 @@ } } }, + "Storage engine" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "스토리지 엔진", + "state" : "translated" + } + } + } + }, + "Storage engine change" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "스토리지 엔진 변경", + "state" : "translated" + } + } + } + }, + "Storage engine differs: %@ on source, %@ on target." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Storage engine differs: %1$@ on source, %2$@ on target." + } + }, + "ko" : { + "stringUnit" : { + "value" : "스토리지 엔진이 다릅니다. 원본은 %@, 대상은 %@입니다.", + "state" : "translated" + } + } + } + }, "Stored in the macOS Keychain and written to a temporary file only while the proxy runs." : { "localizations" : { "ko" : { @@ -111468,6 +113778,32 @@ } } }, + "Structure Comparison" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "구조 비교", + "state" : "translated" + } + } + } + }, + "Structure sync needs matching database types. %@ and %@ can be compared, but no script is generated." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Structure sync needs matching database types. %1$@ and %2$@ can be compared, but no script is generated." + } + }, + "ko" : { + "stringUnit" : { + "value" : "구조 동기화에는 같은 데이터베이스 타입이 필요합니다. %@과(와) %@은(는) 비교할 수 있지만 스크립트는 생성되지 않습니다.", + "state" : "translated" + } + } + } + }, "Structure, Drop, and Data options are configured per table in the table list." : { "extractionState" : "stale", "localizations" : { @@ -111778,6 +114114,16 @@ } } }, + "Summary" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "요약", + "state" : "translated" + } + } + } + }, "Suspended" : { "localizations" : { "ko" : { @@ -111812,6 +114158,26 @@ } } }, + "Swap" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "바꾸기", + "state" : "translated" + } + } + } + }, + "Swap Source and Target" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "원본과 대상 바꾸기", + "state" : "translated" + } + } + } + }, "Switch" : { "localizations" : { "ko" : { @@ -112291,6 +114657,26 @@ } } }, + "Switch the comparison to Data to see row differences." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "행 차이를 보려면 비교를 데이터로 전환하십시오.", + "state" : "translated" + } + } + } + }, + "Switch the comparison to Structure to see definitions." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "정의를 보려면 비교를 구조로 전환하십시오.", + "state" : "translated" + } + } + } + }, "Switch to Inline Configuration" : { "localizations" : { "en" : { @@ -112994,6 +115380,22 @@ } } }, + "Syncing data from %@ to %@. Value formatting can differ between engines; review the script before applying." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Syncing data from %1$@ to %2$@. Value formatting can differ between engines; review the script before applying." + } + }, + "ko" : { + "stringUnit" : { + "value" : "%@에서 %@(으)로 데이터를 동기화합니다. 값 형식은 엔진마다 다를 수 있으므로, 적용하기 전에 스크립트를 확인하십시오.", + "state" : "translated" + } + } + } + }, "Syncing with iCloud…" : { "localizations" : { "ko" : { @@ -114073,6 +116475,22 @@ } } }, + "Table collation differs: %@ on source, %@ on target." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Table collation differs: %1$@ on source, %2$@ on target." + } + }, + "ko" : { + "stringUnit" : { + "value" : "테이블 콜레이션이 다릅니다. 원본은 %@, 대상은 %@입니다.", + "state" : "translated" + } + } + } + }, "Table creation options not available" : { "extractionState" : "stale", "localizations" : { @@ -114210,6 +116628,16 @@ } } }, + "Table rebuild" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "테이블 재생성", + "state" : "translated" + } + } + } + }, "Table: %@" : { "extractionState" : "stale", "localizations" : { @@ -114554,6 +116982,26 @@ } } }, + "Tables always take part." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "테이블은 항상 포함됩니다.", + "state" : "translated" + } + } + } + }, + "Tables start out of the comparison so a first run cannot stream every row." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "첫 실행에서 모든 행을 읽지 않도록, 테이블은 비교에서 빠진 상태로 시작합니다.", + "state" : "translated" + } + } + } + }, "Tables with more estimated rows use approximate counts to avoid slow COUNT(*) queries" : { "localizations" : { "ko" : { @@ -114930,6 +117378,26 @@ } } }, + "Target" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "대상", + "state" : "translated" + } + } + } + }, + "Target: %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "대상: %@", + "state" : "translated" + } + } + } + }, "Team" : { "localizations" : { "ko" : { @@ -115787,6 +118255,16 @@ } } }, + "The %1$@ sorted rows differently than the comparison expects, near key %2$@. Pick a numeric key, or one that sorts by byte value." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%1$@이(가) 키 %2$@ 부근에서 비교가 예상한 것과 다른 순서로 행을 정렬했습니다. 숫자 키나 바이트 값으로 정렬되는 키를 선택하십시오.", + "state" : "translated" + } + } + } + }, "The %@ command line tool is not installed." : { "localizations" : { "ko" : { @@ -117147,6 +119625,16 @@ } } }, + "The driver did not return this object's definition, so only its name was compared." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "드라이버가 이 객체의 정의를 반환하지 않아 이름만 비교했습니다.", + "state" : "translated" + } + } + } + }, "The encrypted file is corrupt or incomplete" : { "localizations" : { "ko" : { @@ -117181,6 +119669,16 @@ } } }, + "The filter field is not in the toolbar." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "필터 필드가 도구 막대에 없습니다.", + "state" : "translated" + } + } + } + }, "The folder \"%@\" will be deleted. Items inside will be moved to the parent level." : { "localizations" : { "ko" : { @@ -117577,6 +120075,16 @@ } } }, + "The object types taking part in the comparison are set in Options." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교에 포함할 객체 유형은 옵션에서 설정합니다.", + "state" : "translated" + } + } + } + }, "The partial backup file will be removed." : { "comment" : "A message displayed in a cancel confirmation alert for a backup.", "isCommentAutoGenerated" : true, @@ -118032,6 +120540,26 @@ } } }, + "The run was rolled back. %@ is unchanged." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "실행이 롤백되었습니다. %@은(는) 변경되지 않았습니다.", + "state" : "translated" + } + } + } + }, + "The script already ran. Compare again to see where the target stands." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "스크립트가 이미 실행되었습니다. 대상의 현재 상태를 보려면 다시 비교하십시오.", + "state" : "translated" + } + } + } + }, "The secret manager did not return valid JSON." : { "localizations" : { "ko" : { @@ -118178,6 +120706,36 @@ } } }, + "The source and the target are the same database." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "원본과 대상이 같은 데이터베이스입니다.", + "state" : "translated" + } + } + } + }, + "The source driver cannot stream rows for a comparison." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "원본 드라이버는 비교를 위해 행을 스트리밍할 수 없습니다.", + "state" : "translated" + } + } + } + }, + "The statements ran but the transaction could not be committed: %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "구문은 실행되었지만 트랜잭션을 커밋할 수 없었습니다: %@", + "state" : "translated" + } + } + } + }, "The target connection is no longer open." : { "localizations" : { "ko" : { @@ -118284,6 +120842,46 @@ } } }, + "The target does not support creating table %@." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "대상은 테이블 %@ 생성을 지원하지 않습니다.", + "state" : "translated" + } + } + } + }, + "The target driver cannot generate a sync script." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "대상 드라이버는 동기화 스크립트를 생성할 수 없습니다.", + "state" : "translated" + } + } + } + }, + "The target driver cannot run a sync script." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "대상 드라이버는 동기화 스크립트를 실행할 수 없습니다.", + "state" : "translated" + } + } + } + }, + "The target driver cannot stream rows for a comparison." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "대상 드라이버는 비교를 위해 행을 스트리밍할 수 없습니다.", + "state" : "translated" + } + } + } + }, "The text could not be parsed as JSON." : { "localizations" : { "ko" : { @@ -118420,6 +121018,40 @@ } } }, + "The transaction could not be committed: %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "트랜잭션을 커밋할 수 없습니다: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İşlem kaydedilemedi: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể commit giao dịch: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "事务无法提交:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "交易無法提交:%@" + } + } + } + }, "The value could not be parsed. Use raw mode to inspect it as text." : { "localizations" : { "ko" : { @@ -118764,6 +121396,16 @@ } } }, + "These two engines cannot share a script." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "이 두 엔진은 스크립트를 공유할 수 없습니다.", + "state" : "translated" + } + } + } + }, "This %1$@ has no %2$@ yet." : { "localizations" : { "ko" : { @@ -120372,6 +123014,16 @@ } } }, + "This list is a capped preview. Apply covers every difference, not only the rows listed here." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "이 목록은 개수가 제한된 미리 보기입니다. 적용은 여기 나열된 행뿐 아니라 모든 차이점을 포함합니다.", + "state" : "translated" + } + } + } + }, "This machine is not activated for this license." : { "localizations" : { "ko" : { @@ -121872,6 +124524,26 @@ } } }, + "Timestamp digits compared" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교할 타임스탬프 자릿수", + "state" : "translated" + } + } + } + }, + "Timestamp precision" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "타임스탬프 정밀도", + "state" : "translated" + } + } + } + }, "Timing" : { "localizations" : { "ko" : { @@ -123430,6 +126102,16 @@ } } }, + "Trigger" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "트리거", + "state" : "translated" + } + } + } + }, "Trigger operation failed" : { "localizations" : { "ko" : { @@ -126363,6 +129045,32 @@ } } }, + "Update row %@ in %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Update row %1$@ in %2$@" + } + }, + "ko" : { + "stringUnit" : { + "value" : "%2$@의 행 %1$@ 업데이트", + "state" : "translated" + } + } + } + }, + "Update rows that differ" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "다른 행 업데이트", + "state" : "translated" + } + } + } + }, "Update to v%@" : { "localizations" : { "ko" : { @@ -127898,6 +130606,16 @@ } } }, + "Value kind differs" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "값 종류가 다름", + "state" : "translated" + } + } + } + }, "Value too large to parse" : { "localizations" : { "ko" : { @@ -128487,6 +131205,16 @@ } } }, + "View Account" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "계정 보기", + "state" : "translated" + } + } + } + }, "View Activity…" : { "localizations" : { "ko" : { @@ -129007,6 +131735,16 @@ } } }, + "Warnings" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "경고", + "state" : "translated" + } + } + } + }, "Watch shared folders for connection files." : { "localizations" : { "ko" : { @@ -129179,6 +131917,56 @@ } } }, + "What is switched on here is ignored when two structures are compared. These drift between environments by design." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "여기서 켠 항목은 두 구조를 비교할 때 무시됩니다. 이 항목들은 환경마다 다른 것이 정상입니다.", + "state" : "translated" + } + } + } + }, + "What ran against %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%@에 대해 실행된 내용", + "state" : "translated" + } + } + } + }, + "What to Compare" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "비교할 대상", + "state" : "translated" + } + } + } + }, + "What to compare, and how" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "무엇을 어떻게 비교할지", + "state" : "translated" + } + } + } + }, + "What will run against %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "%@에 대해 실행될 내용", + "state" : "translated" + } + } + } + }, "What you can do" : { "localizations" : { "ko" : { @@ -129352,6 +132140,16 @@ } } }, + "Whitespace in text" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "텍스트의 공백", + "state" : "translated" + } + } + } + }, "Wide-Column" : { "localizations" : { "ko" : { @@ -129422,6 +132220,16 @@ } } }, + "Will be changed" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "변경됩니다", + "state" : "translated" + } + } + } + }, "Will be created when you apply changes" : { "localizations" : { "ko" : { @@ -129490,6 +132298,26 @@ } } }, + "Will not change" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "변경되지 않습니다", + "state" : "translated" + } + } + } + }, + "Will run" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "실행됨", + "state" : "translated" + } + } + } + }, "Window" : { "localizations" : { "ko" : { @@ -130723,106 +133551,126 @@ } } }, - "Zoom in" : { - "extractionState" : "stale", + "Zoom in" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확대" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yakınlaştır" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phóng to" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "放大" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "放大" + } + } + } + }, + "Zoom out" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "축소" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uzaklaştır" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thu nhỏ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "缩小" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "縮小" + } + } + } + }, + "a query" : { "localizations" : { "ko" : { "stringUnit" : { - "state" : "translated", - "value" : "확대" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Yakınlaştır" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Phóng to" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "放大" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "放大" + "value" : "쿼리", + "state" : "translated" } } } }, - "Zoom out" : { - "extractionState" : "stale", + "admin" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "축소" + "value" : "admin" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Uzaklaştır" + "value" : "yönetici" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Thu nhỏ" + "value" : "admin" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "缩小" + "value" : "admin" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "縮小" + "value" : "admin" } } } }, - "admin" : { + "an export" : { "localizations" : { "ko" : { "stringUnit" : { - "state" : "translated", - "value" : "admin" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "yönetici" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "admin" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "admin" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "admin" + "value" : "내보내기", + "state" : "translated" } } } @@ -133455,6 +136303,16 @@ } } }, + "source" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "원본", + "state" : "translated" + } + } + } + }, "sqlite3 is included with macOS" : { "extractionState" : "stale", "localizations" : { @@ -133799,6 +136657,16 @@ } } }, + "target" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "대상", + "state" : "translated" + } + } + } + }, "the SOCKS proxy" : { "localizations" : { "ko" : { @@ -133833,6 +136701,16 @@ } } }, + "the target" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "value" : "대상", + "state" : "translated" + } + } + } + }, "to view data" : { "localizations" : { "ko" : { @@ -134933,176 +137811,6 @@ } } } - }, - "Result %d" : { - "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "결과 %d" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sonuç %d" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kết quả %d" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "结果 %d" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "結果 %d" - } - } - } - }, - "Error %d" : { - "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "오류 %d" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Hata %d" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lỗi %d" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "错误 %d" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "錯誤 %d" - } - } - } - }, - "Statement %1$d/%2$d failed: %3$@" : { - "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%2$d개 중 %1$d번째 문 실패: %3$@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$d/%2$d numaralı ifade başarısız oldu: %3$@" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Câu lệnh %1$d/%2$d thất bại: %3$@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "第 %1$d/%2$d 条语句失败:%3$@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "第 %1$d/%2$d 條陳述式失敗:%3$@" - } - } - } - }, - "The transaction could not be committed: %@" : { - "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "트랜잭션을 커밋할 수 없습니다: %@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "İşlem kaydedilemedi: %@" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Không thể commit giao dịch: %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "事务无法提交:%@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "交易無法提交:%@" - } - } - } - }, - "Moves the editor cursor to the statement that produced this result" : { - "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이 결과를 생성한 문으로 편집기 커서를 이동합니다" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Düzenleyici imlecini bu sonucu üreten ifadeye taşır" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Di chuyển con trỏ trình soạn thảo tới câu lệnh đã tạo ra kết quả này" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "将编辑器光标移到生成此结果的语句" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "將編輯器游標移到產生此結果的陳述式" - } - } - } } }, "version" : "1.1" diff --git a/TablePro/Views/Compare/CompareApplySheetView.swift b/TablePro/Views/Compare/CompareApplySheetView.swift new file mode 100644 index 000000000..e278e5c18 --- /dev/null +++ b/TablePro/Views/Compare/CompareApplySheetView.swift @@ -0,0 +1,449 @@ +// +// CompareApplySheetView.swift +// TablePro +// +// The last thing shown before anything is written, and the record of what was. +// +// Cancel is the default button and Apply is destructive, because this is the +// only sheet in the app whose confirmation writes to someone else's database. +// Every sentence names the target, so a user reading only one of them still +// knows which database is about to change. +// + +import SwiftUI + +internal struct CompareApplySheetView: View { + internal enum Choice { + case cancel + case apply + } + + private struct PlannedObject: Identifiable { + let name: String + let count: Int + + var id: String { name } + } + + private enum Pane: String, CaseIterable, Hashable { + case script + case summary + case warnings + + var title: String { + switch self { + case .script: + return String(localized: "Script") + case .summary: + return String(localized: "Summary") + case .warnings: + return String(localized: "Warnings") + } + } + } + + @Bindable internal var session: CompareSyncSession + internal let callback: (Choice) -> Void + + @AppStorage("structureCodeFontSize", store: AppStorageEnvironment.shared.defaults) private var fontSize = 13.0 + + @State private var pane: Pane = .summary + + internal var body: some View { + VStack(spacing: 0) { + headline + Divider() + TabView(selection: $pane) { + scriptPane + .tabItem { Text(Pane.script.title) } + .tag(Pane.script) + summaryPane + .tabItem { Text(Pane.summary.title) } + .tag(Pane.summary) + warningsPane + .tabItem { Text(Pane.warnings.title) } + .tag(Pane.warnings) + } + .padding(.horizontal, 12) + .padding(.top, 8) + Divider() + actionRow + } + /// Apply is disabled while anything is held back and the action row says to go to Warnings, + /// so the sheet opens on the tab that can actually unblock it. + .onAppear { + guard session.unacknowledgedHazardCount > 0 else { return } + pane = .warnings + } + } + + // MARK: - Headline + + private var headline: some View { + VStack(alignment: .leading, spacing: 4) { + Text(headlineText) + .font(.headline) + .fixedSize(horizontal: false, vertical: true) + Text("Nothing is written until Apply.") + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + } + + /// The count carries the noun and the target name follows it, so the singular cannot come from a + /// plural variation on the format string and is chosen here instead. + private var headlineText: String { + let count = session.runnableStatementCount + guard count != 1 else { + return String(format: String(localized: "Apply 1 statement to %@?"), targetName) + } + return String(format: String(localized: "Apply %1$d statements to %2$@?"), count, targetName) + } + + private var targetName: String { + session.target?.qualifiedDescription ?? String(localized: "the target") + } + + // MARK: - Script + + private var scriptPane: some View { + DDLTextView(ddl: runnableScript, fontSize: $fontSize, databaseType: session.target?.databaseType) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var runnableStatements: [SyncStatement] { + session.statements.filter { session.executionSettings.canRun($0) } + } + + private var runnableScript: String { + runnableStatements.map { $0.sql }.joined(separator: "\n") + } + + // MARK: - Summary + + private var summaryPane: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let result = session.runResult { + runResultSection(result) + Divider() + } + plannedWorkSection + } + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var plannedWorkSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(format: String(localized: "What will run against %@"), targetName)) + .font(.subheadline.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + + ForEach(plannedObjects) { entry in + HStack(spacing: 8) { + Text(entry.name) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + Text(entry.count, format: .number) + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + + if session.unacknowledgedHazardCount > 0 { + Label { + Text(heldBackText) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "hand.raised.fill") + } + .foregroundStyle(CompareStatusStyle.warning) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var plannedObjects: [PlannedObject] { + var order: [String] = [] + var counts: [String: Int] = [:] + for statement in runnableStatements { + if counts[statement.objectName] == nil { + order.append(statement.objectName) + } + counts[statement.objectName, default: 0] += 1 + } + return order.map { PlannedObject(name: $0, count: counts[$0] ?? 0) } + } + + private var heldBackText: String { + let count = session.unacknowledgedHazardCount + guard count != 1 else { + return String( + format: String(localized: "1 statement stays out of this run and %@ keeps what it has for it."), + targetName + ) + } + return String( + format: String(localized: "%1$d statements stay out of this run and %2$@ keeps what it has for them."), + count, targetName + ) + } + + // MARK: - Run result + + private func runResultSection(_ result: CompareSyncRunResult) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(format: String(localized: "What ran against %@"), targetName)) + .font(.subheadline.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 16) { + countBadge( + String(localized: "Ran"), result.executedCount, + symbol: "checkmark.circle.fill", tint: CompareStatusStyle.success + ) + countBadge( + String(localized: "Failed"), result.failedCount, + symbol: "xmark.circle.fill", tint: CompareStatusStyle.error + ) + countBadge( + String(localized: "Held back"), result.heldBackCount, + symbol: "hand.raised.fill", tint: CompareStatusStyle.warning + ) + Spacer(minLength: 0) + } + + if result.rolledBack { + noticeLabel( + String(format: String(localized: "The run was rolled back. %@ is unchanged."), targetName), + systemImage: "arrow.uturn.backward.circle.fill" + ) + } else if result.failedCount > 0 { + noticeLabel( + String( + format: String(localized: "Statements that already ran stay applied to %@. Compare again to see where it stands."), + targetName + ), + systemImage: "exclamationmark.triangle.fill" + ) + } + + if let commitFailure = result.commitFailure { + Label { + Text(String( + format: String( + localized: "The statements ran but the transaction could not be committed: %@" + ), + commitFailure + )) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "exclamationmark.octagon.fill") + } + .foregroundStyle(CompareStatusStyle.error) + } + + if result.cancelled { + noticeLabel( + String(localized: "Cancelled before the script finished."), + systemImage: "stop.circle" + ) + } + + ForEach(result.outcomes) { outcome in + outcomeRow(outcome) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func outcomeRow(_ outcome: SyncStatementOutcome) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Image(systemName: symbolName(for: outcome)) + .foregroundStyle(tint(for: outcome)) + Text(outcome.statement.summary) + .font(.callout) + .lineLimit(1) + Spacer(minLength: 0) + Text(stateLabel(for: outcome)) + .font(.caption) + .foregroundStyle(.secondary) + } + if let error = outcome.error { + Text(error) + .font(.caption) + .foregroundStyle(CompareStatusStyle.error) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func symbolName(for outcome: SyncStatementOutcome) -> String { + if outcome.wasSkipped { return "hand.raised.fill" } + return outcome.error == nil ? "checkmark.circle.fill" : "xmark.circle.fill" + } + + private func tint(for outcome: SyncStatementOutcome) -> Color { + if outcome.wasSkipped { return CompareStatusStyle.warning } + return outcome.error == nil ? CompareStatusStyle.success : CompareStatusStyle.error + } + + private func stateLabel(for outcome: SyncStatementOutcome) -> String { + if outcome.wasSkipped { return String(localized: "Held back") } + return outcome.error == nil ? String(localized: "Ran") : String(localized: "Failed") + } + + // MARK: - Warnings + + /// The tab the action row sends people to, so the allowance has to be reachable here. It used to + /// render the hazards read-only while Apply stayed disabled until every one of them was allowed, + /// and the only checkbox that could allow one lived in the script pane behind this sheet. + private var warningsPane: some View { + Group { + if hazardStatements.isEmpty { + ContentUnavailableView { + Label("No Warnings", systemImage: "checkmark.shield") + } description: { + Text(String(format: String(localized: "Nothing in this script destroys data in %@."), targetName)) + } + } else { + List { + Section { + ForEach(hazardStatements) { statement in + hazardRow(statement) + } + } footer: { + Text("Allowing a statement applies to this run only. It is never saved.") + } + } + .listStyle(.inset) + } + } + } + + private var hazardStatements: [SyncStatement] { + session.statements.filter { !$0.hazards.isEmpty } + } + + private func hazardRow(_ statement: SyncStatement) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + hazardHeadline(statement) + Spacer(minLength: 0) + Text(session.executionSettings.canRun(statement) + ? String(localized: "Will run") + : String(localized: "Held back")) + .font(.caption) + .foregroundStyle(.secondary) + } + ForEach(statement.hazards) { hazard in + VStack(alignment: .leading, spacing: 1) { + Text(hazard.kind.displayName) + .font(.caption.weight(.semibold)) + Text(hazard.explanation) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + Text(statement.sql) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(3) + } + .padding(.vertical, 2) + } + + /// Only a statement the classifier refuses by default has an allowance to grant. A hazard that + /// runs anyway gets a label rather than a checkbox, because a checkbox that cannot stop it would + /// say the opposite of what it does. + @ViewBuilder + private func hazardHeadline(_ statement: SyncStatement) -> some View { + if statement.isRefusedByDefault { + Toggle(isOn: CompareHazardAllowance.binding(for: statement, in: session)) { + Text(statement.summary) + .font(.callout) + .lineLimit(1) + } + .toggleStyle(.checkbox) + .accessibilityIdentifier("compare.apply.allow.\(statement.id.uuidString)") + } else { + Label { + Text(statement.summary) + .font(.callout) + .lineLimit(1) + } icon: { + Image(systemName: "play.circle.fill") + } + .foregroundStyle(CompareStatusStyle.warning) + } + } + + // MARK: - Actions + + private var actionRow: some View { + HStack(spacing: 12) { + if session.unacknowledgedHazardCount > 0 { + Label { + Text("Allow every held-back statement on the Warnings tab, or exclude the objects that produced them, before applying.") + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "hand.raised.fill") + } + .font(.callout) + .foregroundStyle(CompareStatusStyle.warning) + } + Spacer(minLength: 0) + Button("Cancel") { + callback(.cancel) + } + .keyboardShortcut(.defaultAction) + .accessibilityIdentifier("compare.apply.cancel") + Button(applyTitle, role: .destructive) { + callback(.apply) + } + .disabled(!canApply) + .accessibilityIdentifier("compare.apply.confirm") + } + .padding(16) + } + + private var applyTitle: String { + String(format: String(localized: "Apply to %@"), targetName) + } + + private var canApply: Bool { + session.canApply && session.unacknowledgedHazardCount == 0 && session.runnableStatementCount > 0 + } + + private func countBadge(_ label: String, _ count: Int, symbol: String, tint: Color) -> some View { + HStack(spacing: 4) { + Image(systemName: symbol) + .foregroundStyle(count > 0 ? tint : Color.secondary) + Text(label) + Text(count, format: .number) + .monospacedDigit() + .fontWeight(.semibold) + } + .font(.callout) + } + + private func noticeLabel(_ text: String, systemImage: String) -> some View { + Label { + Text(text) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: systemImage) + } + .font(.callout) + .foregroundStyle(CompareStatusStyle.warning) + } +} diff --git a/TablePro/Views/Compare/CompareDataPlanGrouping.swift b/TablePro/Views/Compare/CompareDataPlanGrouping.swift new file mode 100644 index 000000000..fd3bc49ae --- /dev/null +++ b/TablePro/Views/Compare/CompareDataPlanGrouping.swift @@ -0,0 +1,202 @@ +// +// CompareDataPlanGrouping.swift +// TablePro +// +// Turns the tables a data comparison can walk into the rows a `Table` draws, +// searched and grouped the way the session asks for. +// +// The data list used to read neither: it sorted `session.dataPlans` and ignored +// `searchText` and `grouping` outright while both toolbar controls stayed +// enabled, so typing in the search field and choosing a grouping did nothing at +// all in Data mode. +// +// Pure and free of SwiftUI, so a grouping can be checked without a view. The +// shape mirrors `CompareResultGrouping` deliberately: one list, two row kinds, +// and a header whose members are exactly what a bulk include may touch. +// + +import Foundation + +internal struct CompareDataPlanRow: Identifiable, Hashable { + internal enum Kind: Hashable { + case group(memberIds: [String]) + case plan(DataComparePlan) + } + + internal let id: String + internal let tableName: String + internal let insertCount: Int? + internal let updateCount: Int? + internal let deleteCount: Int? + internal let identicalCount: Int? + internal let kind: Kind + + internal var plan: DataComparePlan? { + guard case .plan(let plan) = kind else { return nil } + return plan + } + + internal var memberIds: [String] { + guard case .group(let ids) = kind else { return [] } + return ids + } + + internal var isGroup: Bool { + plan == nil + } +} + +internal struct CompareDataPlanGroup: Identifiable { + internal let header: CompareDataPlanRow + internal let rows: [CompareDataPlanRow] + + internal var id: String { header.id } +} + +internal enum CompareDataPlanGrouping { + /// A group header shares the table's row type, so its identifier has to be one no plan can + /// produce: a plan identifier is `schema.table` and never carries this prefix. + internal static let groupIdentifierPrefix = "compare-plan-group|" + + internal static func isGroupIdentifier(_ identifier: String) -> Bool { + identifier.hasPrefix(groupIdentifierPrefix) + } + + /// The same substring rule the structure list applies, so one search field means one thing in + /// both modes. + internal static func matching(_ plans: [DataComparePlan], searchText: String) -> [DataComparePlan] { + let query = searchText.trimmingCharacters(in: .whitespaces) + guard !query.isEmpty else { return plans } + return plans.filter { $0.id.localizedCaseInsensitiveContains(query) } + } + + internal static func rows( + from plans: [DataComparePlan], + sortedUsing comparators: [KeyPathComparator] + ) -> [CompareDataPlanRow] { + plans.map(row(for:)).sorted(using: comparators) + } + + internal static func groups( + from plans: [DataComparePlan], + grouping: CompareGrouping, + sortedUsing comparators: [KeyPathComparator] + ) -> [CompareDataPlanGroup] { + switch grouping { + case .byDifference: + let buckets = Dictionary(grouping: plans, by: bucket(for:)) + return Bucket.allCases.compactMap { bucket in + group( + named: bucket.title, + identifier: bucket.identifier, + plans: buckets[bucket] ?? [], + comparators: comparators + ) + } + case .byObjectType: + /// Every plan is a table, so this is one section rather than none. The control stays + /// live and says what it groups by instead of looking broken in one of the two modes. + return [ + group( + named: CompareObjectKind.table.displayName, + identifier: CompareObjectKind.table.rawValue, + plans: plans, + comparators: comparators + ) + ].compactMap { $0 } + case .none: + return [] + } + } + + internal static func planIds( + in selection: Set, + groups: [CompareDataPlanGroup] + ) -> [String] { + var resolved: [String] = [] + for identifier in selection.sorted() { + guard isGroupIdentifier(identifier) else { + resolved.append(identifier) + continue + } + guard let group = groups.first(where: { $0.id == identifier }) else { continue } + resolved.append(contentsOf: group.header.memberIds) + } + return resolved + } + + /// A plan that has not been compared is its own answer, not a synonym for "matches". Folding the + /// two together would report a table nobody has looked at as identical. + private enum Bucket: CaseIterable, Hashable { + case differing + case identical + case notCompared + case uncomparable + + var title: String { + switch self { + case .differing: return String(localized: "Has Differences") + case .identical: return String(localized: "Identical") + case .notCompared: return String(localized: "Not Compared") + case .uncomparable: return String(localized: "Could Not Compare") + } + } + + var identifier: String { + switch self { + case .differing: return "differing" + case .identical: return "identical" + case .notCompared: return "not-compared" + case .uncomparable: return "uncomparable" + } + } + } + + private static func bucket(for plan: DataComparePlan) -> Bucket { + guard plan.isComparable else { return .uncomparable } + guard let summary = plan.summary else { return .notCompared } + return summary.differenceCount > 0 ? .differing : .identical + } + + private static func group( + named name: String, + identifier: String, + plans: [DataComparePlan], + comparators: [KeyPathComparator] + ) -> CompareDataPlanGroup? { + guard !plans.isEmpty else { return nil } + let summaries = plans.compactMap { $0.summary } + let header = CompareDataPlanRow( + id: groupIdentifierPrefix + identifier, + tableName: name, + insertCount: total(of: summaries, \.insertCount), + updateCount: total(of: summaries, \.updateCount), + deleteCount: total(of: summaries, \.deleteCount), + identicalCount: total(of: summaries, \.identicalCount), + kind: .group(memberIds: plans.filter { $0.isComparable }.map { $0.id }) + ) + return CompareDataPlanGroup(header: header, rows: plans.map(row(for:)).sorted(using: comparators)) + } + + private static func row(for plan: DataComparePlan) -> CompareDataPlanRow { + CompareDataPlanRow( + id: plan.id, + tableName: plan.id, + insertCount: plan.summary?.insertCount, + updateCount: plan.summary?.updateCount, + deleteCount: plan.summary?.deleteCount, + identicalCount: plan.summary?.identicalCount, + kind: .plan(plan) + ) + } + + /// Nothing compared means no number, not a zero: the count columns already draw a blank rather + /// than a zero for a table nobody ran, and a header has to say the same thing. + private static func total( + of summaries: [DataDiffSummary], + _ keyPath: KeyPath + ) -> Int? { + guard !summaries.isEmpty else { return nil } + return summaries.reduce(0) { $0 + $1[keyPath: keyPath] } + } +} diff --git a/TablePro/Views/Compare/CompareDataPlansView.swift b/TablePro/Views/Compare/CompareDataPlansView.swift new file mode 100644 index 000000000..fdcc29bc8 --- /dev/null +++ b/TablePro/Views/Compare/CompareDataPlansView.swift @@ -0,0 +1,316 @@ +// +// CompareDataPlansView.swift +// TablePro +// +// The tables a data comparison can walk, and the counts it found. +// +// A plan starts excluded on purpose. A first Compare that opted every table in +// would stream every row of every table before the user had chosen anything, so +// the list says why it is empty rather than looking broken. +// +// Search and grouping are the session's, not this view's: the same toolbar +// controls drive both modes, so they have to mean the same thing in both. +// + +import SwiftUI + +internal struct CompareDataPlansView: View { + @Bindable internal var session: CompareSyncSession + internal let onCompare: () -> Void + + @State private var sortOrder = [KeyPathComparator(\CompareDataPlanRow.tableName)] + + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + + internal var body: some View { + VStack(spacing: 0) { + CompareMessageBanner(session: session) + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private var content: some View { + if session.dataPlans.isEmpty { + emptyState + } else { + planList + } + } + + // MARK: - List + + private var planList: some View { + let visible = CompareDataPlanGrouping.matching(session.dataPlans, searchText: session.searchText) + return VStack(spacing: 0) { + selectionHeader + Divider() + if visible.isEmpty { + ContentUnavailableView.search(text: session.searchText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + plansTable(visible) + } + } + } + + private func plansTable(_ visible: [DataComparePlan]) -> some View { + let groups = CompareDataPlanGrouping.groups( + from: visible, grouping: session.grouping, sortedUsing: sortOrder + ) + let flatRows = CompareDataPlanGrouping.rows(from: visible, sortedUsing: sortOrder) + + return Table(of: CompareDataPlanRow.self, selection: $session.selectedPlanId, sortOrder: $sortOrder) { + TableColumn("Include") { row in + includeCell(row) + } + .width(min: 56, ideal: 64) + + TableColumn("Table", value: \.tableName) { row in + Text(row.tableName) + .fontWeight(row.isGroup ? .semibold : .regular) + .lineLimit(1) + .truncationMode(.middle) + .help(row.tableName) + } + + TableColumn("Key") { row in + keyCell(row) + } + + TableColumn("Insert") { row in + countCell(row.insertCount, kind: .insert) + } + + TableColumn("Update") { row in + countCell(row.updateCount, kind: .update) + } + + TableColumn("Delete") { row in + countCell(row.deleteCount, kind: .delete) + } + + TableColumn("Same") { row in + countCell(row.identicalCount, kind: .identical) + } + } rows: { + if session.grouping == .none { + ForEach(flatRows) { row in + SwiftUI.TableRow(row) + } + } else { + ForEach(groups) { group in + DisclosureTableRow(group.header) { + ForEach(group.rows) { row in + SwiftUI.TableRow(row) + } + } + } + } + } + .contextMenu(forSelectionType: CompareDataPlanRow.ID.self) { selection in + planCommands(for: selection, groups: groups) + } + } + + @ViewBuilder + private func planCommands(for selection: Set, groups: [CompareDataPlanGroup]) -> some View { + let selected = Set(CompareDataPlanGrouping.planIds(in: selection, groups: groups)) + let comparable = session.dataPlans + .filter { selected.contains($0.id) && $0.isComparable } + .map { $0.id } + Button("Include") { + setEnabled(true, forIds: comparable) + } + .disabled(comparable.isEmpty) + Button("Exclude") { + setEnabled(false, forIds: comparable) + } + .disabled(comparable.isEmpty) + Divider() + Button("Include Every Table") { + session.setAllPlansEnabled(true) + } + Button("Exclude Every Table") { + session.setAllPlansEnabled(false) + } + } + + // MARK: - Header + + private var selectionHeader: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(selectionSummary) + .font(.callout) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + Menu(String(localized: "Select")) { + Button("All") { + session.setAllPlansEnabled(true) + } + Button("None") { + session.setAllPlansEnabled(false) + } + } + .fixedSize() + .accessibilityIdentifier("compare.plans.select") + } + if enabledPlanCount == 0 { + Text("Tables start out of the comparison so a first run cannot stream every row.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.bar) + } + + private var enabledPlanCount: Int { + session.dataPlans.filter { $0.isEnabled }.count + } + + /// A total of one has to read "of 1 table". The counted noun follows the second argument, which + /// a plural variation on the format string cannot reach, so the sentence carries its own + /// singular the way the app's other counted confirmations do. + private var selectionSummary: String { + let total = session.dataPlans.count + guard total != 1 else { + return String(format: String(localized: "%d of 1 table will be compared."), enabledPlanCount) + } + return String( + format: String(localized: "%1$d of %2$d tables will be compared."), + enabledPlanCount, total + ) + } + + // MARK: - Cells + + /// A group is three-valued: some of its tables in, some out. macOS has no mixed state for a + /// `Toggle`, so a plain `Bool` would render "five of six" exactly like "none". + @ViewBuilder + private func includeCell(_ row: CompareDataPlanRow) -> some View { + switch row.kind { + case .group(let memberIds): + if !memberIds.isEmpty { + TristateCheckbox( + state: groupInclusionState(memberIds), + accessibilityLabel: String(localized: "Include every table in this group"), + accessibilityValue: groupInclusionValue(memberIds) + ) { + setEnabled(groupInclusionState(memberIds) != .checked, forIds: memberIds) + } + .accessibilityIdentifier("compare.plans.includeGroup.\(row.id)") + } + case .plan(let plan): + Toggle(String(localized: "Include this table in the comparison"), isOn: enabledBinding(plan)) + .labelsHidden() + .toggleStyle(.checkbox) + .disabled(!plan.isComparable) + .help(plan.unavailableReason ?? String(localized: "Include this table in the comparison")) + .accessibilityIdentifier("compare.plans.include.\(plan.id)") + } + } + + @ViewBuilder + private func keyCell(_ row: CompareDataPlanRow) -> some View { + if let plan = row.plan { + planKeyCell(plan) + } + } + + @ViewBuilder + private func planKeyCell(_ plan: DataComparePlan) -> some View { + if let reason = plan.unavailableReason { + Label { + Text(reason) + .lineLimit(1) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .foregroundStyle(differentiateWithoutColor ? .primary : CompareStatusStyle.warning) + .help(reason) + } else { + Text(plan.keyColumns.joined(separator: ", ")) + .foregroundStyle(.secondary) + .lineLimit(1) + .help(plan.keyColumns.joined(separator: ", ")) + } + } + + /// A table that has not been compared shows nothing rather than a zero: no difference found + /// and no comparison run are not the same answer. + @ViewBuilder + private func countCell(_ value: Int?, kind: RowDiffKind) -> some View { + if let value { + Text(value, format: .number) + .monospacedDigit() + .foregroundStyle(countTint(value, kind: kind)) + } + } + + private func countTint(_ value: Int, kind: RowDiffKind) -> Color { + guard value > 0 else { return .secondary } + guard !differentiateWithoutColor else { return .primary } + return CompareStatusStyle.tint(for: kind) + } + + // MARK: - Inclusion + + private func enabledBinding(_ plan: DataComparePlan) -> Binding { + Binding( + get: { plan.isEnabled }, + set: { session.setPlanEnabled($0, for: plan.id) } + ) + } + + private func setEnabled(_ enabled: Bool, forIds ids: [String]) { + for id in ids { + session.setPlanEnabled(enabled, for: id) + } + } + + private func enabledMemberCount(_ memberIds: [String]) -> Int { + let enabled = Set(session.dataPlans.filter { $0.isEnabled }.map { $0.id }) + return memberIds.filter { enabled.contains($0) }.count + } + + private func groupInclusionState(_ memberIds: [String]) -> TristateCheckbox.State { + let enabled = enabledMemberCount(memberIds) + guard enabled > 0 else { return .unchecked } + return enabled == memberIds.count ? .checked : .mixed + } + + private func groupInclusionValue(_ memberIds: [String]) -> String { + String( + format: String(localized: "%1$d of %2$d included"), + enabledMemberCount(memberIds), memberIds.count + ) + } + + // MARK: - Empty state + + private var emptyState: some View { + ContentUnavailableView { + Label("No Tables Yet", systemImage: "tablecells") + } description: { + Text(emptyDescription) + } actions: { + Button("Compare", action: onCompare) + .disabled(!session.canCompare) + .accessibilityIdentifier("compare.plans.compare") + } + } + + /// Why Compare is unavailable beats a generic invitation to press it, which is what the HIG asks + /// for when a command cannot be carried out. + private var emptyDescription: String { + session.compareDisabledReason + ?? String( + localized: "Compare lists the tables both sides share. Choose the tables to compare, then press Compare." + ) + } +} diff --git a/TablePro/Views/Compare/CompareDetailView.swift b/TablePro/Views/Compare/CompareDetailView.swift new file mode 100644 index 000000000..2ad10e8e6 --- /dev/null +++ b/TablePro/Views/Compare/CompareDetailView.swift @@ -0,0 +1,152 @@ +// +// CompareDetailView.swift +// TablePro +// +// The right-hand pane: the definition of the selected object, the rows of the +// selected table, or the script the whole comparison would run. +// +// All three are reachable at any time. Nothing here is a step, so a user who +// wants to see the script before choosing what to include can. +// + +import SwiftUI + +internal struct CompareDetailView: View { + @Bindable internal var session: CompareSyncSession + internal let onCompare: () -> Void + internal let onGenerateScript: () -> Void + + internal var body: some View { + VStack(spacing: 0) { + paneSelector + Divider() + paneContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + /// Without an infinite frame the stack sizes to its content and the split view centres the + /// whole block, which put the pane switcher halfway down an empty pane. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + private var paneSelector: some View { + Picker(String(localized: "Detail"), selection: $session.detailPane) { + ForEach(CompareDetailPane.allCases, id: \.self) { pane in + Text(pane.title).tag(pane) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private var paneContent: some View { + switch session.detailPane { + case .definitions: + CompareDefinitionsPane(session: session) + case .rows: + CompareRowDiffPane(session: session, onCompare: onCompare) + case .script: + CompareScriptPane(session: session, onGenerateScript: onGenerateScript) + } + } +} + +internal struct CompareDefinitionsPane: View { + @Bindable internal var session: CompareSyncSession + + internal var body: some View { + if let result = session.selectedResult { + ScrollView { + definitionBody(result) + } + } else if session.mode == .data { + ContentUnavailableView { + Label("Definitions Compare Structure", systemImage: "doc.text.magnifyingglass") + } description: { + Text("Switch the comparison to Structure to see definitions.") + } + } else { + ContentUnavailableView { + Label("No Object Selected", systemImage: "doc.text") + } description: { + Text("Select an object to see its definition on both sides.") + } + } + } + + private func definitionBody(_ result: CompareObjectResult) -> some View { + VStack(alignment: .leading, spacing: 16) { + if let error = result.comparisonError { + Label { + Text(error) + .textSelection(.enabled) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .foregroundStyle(CompareStatusStyle.warning) + .fixedSize(horizontal: false, vertical: true) + } + + StructureDefinitionDiffView( + sourceLines: result.sourceDefinition, + targetLines: result.targetDefinition + ) + + if !result.changes.isEmpty { + changesSection(result.changes) + } + + if !result.notes.isEmpty { + notesSection(result.notes) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func changesSection(_ changes: [SchemaChange]) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text("Changes") + .font(.subheadline.weight(.semibold)) + ForEach(Array(changes.enumerated()), id: \.offset) { _, change in + Label { + Text(change.description) + .textSelection(.enabled) + } icon: { + Image(systemName: change.isDestructive ? "exclamationmark.triangle.fill" : "pencil") + } + .foregroundStyle(changeTint(change)) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func notesSection(_ notes: [String]) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text("Notes") + .font(.subheadline.weight(.semibold)) + ForEach(notes, id: \.self) { note in + Label { + Text(note) + .textSelection(.enabled) + } icon: { + Image(systemName: "info.circle") + } + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func changeTint(_ change: SchemaChange) -> Color { + guard change.isDestructive else { return .primary } + return CompareStatusStyle.warning + } +} diff --git a/TablePro/Views/Compare/CompareEndpointPicker.swift b/TablePro/Views/Compare/CompareEndpointPicker.swift new file mode 100644 index 000000000..5b1806498 --- /dev/null +++ b/TablePro/Views/Compare/CompareEndpointPicker.swift @@ -0,0 +1,253 @@ +// +// CompareEndpointPicker.swift +// TablePro +// +// Choosing the database a comparison reads or writes. +// +// This was an NSMenu whose submenus fetched on open, which cannot work: +// AppKit runs a tracking session in `NSEventTrackingRunLoopMode`, the main +// actor's executor is not drained in that mode, and a menu's `menuNeedsUpdate` +// runs at most once per session. So the fetch never started while the menu was +// up, and the "Loading…" row it left behind could never be replaced, however +// many times the pointer went away and came back. A popover is the macOS idiom +// for a toolbar control that reveals a chooser, and it runs an ordinary event +// loop where `.task` and a spinner both behave. +// + +import AppKit +import SwiftUI + +internal enum CompareEndpointSide: Hashable { + case source + case target + + internal var title: String { + switch self { + case .source: return String(localized: "Source") + case .target: return String(localized: "Target") + } + } + + internal var caption: String { + switch self { + case .source: return String(localized: "Will not change") + case .target: return String(localized: "Will be changed") + } + } + + internal var symbol: String { + switch self { + case .source: return "arrow.up.right.square" + case .target: return "arrow.down.right.square" + } + } +} + +internal struct CompareEndpointPicker: View { + internal let side: CompareEndpointSide + internal let current: CompareSyncEndpoint? + internal let onPick: (CompareSyncEndpoint) -> Void + internal let dismiss: () -> Void + + internal static let contentSize = NSSize(width: 320, height: 400) + + @State private var model = CompareEndpointPickerModel() + @State private var path: [CompareEndpointRoute] = [] + @State private var connections: [DatabaseConnection] = [] + + internal var body: some View { + NavigationStack(path: $path) { + connectionList + .navigationTitle(side.title) + .navigationDestination(for: CompareEndpointRoute.self) { route in + destination(route) + } + } + .frame(width: Self.contentSize.width, height: Self.contentSize.height) + .onAppear { connections = ConnectionStorage.shared.loadConnections() } + } + + // MARK: - Connections + + private var connectionList: some View { + Group { + if connections.isEmpty { + ContentUnavailableView { + Label("No Saved Connections", systemImage: "externaldrive.badge.questionmark") + } description: { + Text("Add a connection before comparing.") + } + } else { + List(connections) { connection in + connectionRow(connection) + } + .listStyle(.inset) + } + } + } + + @ViewBuilder + private func connectionRow(_ connection: DatabaseConnection) -> some View { + let endpoint = CompareSyncEndpoint.from(connection: connection) + if side == .target, let reason = endpoint.ineligibleAsTargetReason { + Label { + VStack(alignment: .leading, spacing: 1) { + Text(connection.name) + Text(reason) + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + swatch(connection.color) + } + .foregroundStyle(.tertiary) + } else { + NavigationLink(value: CompareEndpointRoute.databases(connection.id)) { + Label { + Text(connection.name) + } icon: { + swatch(connection.color) + } + } + } + } + + // MARK: - Databases and schemas + + @ViewBuilder + private func destination(_ route: CompareEndpointRoute) -> some View { + switch route { + case let .databases(connectionId): + if let connection = connections.first(where: { $0.id == connectionId }) { + databaseList(connection) + .navigationTitle(connection.name) + .task { await model.loadDatabases(for: connection) } + } + case let .schemas(endpoint, connectionId): + if let connection = connections.first(where: { $0.id == connectionId }) { + schemaList(endpoint, connection: connection) + .navigationTitle(endpoint.databaseLabel) + .task { await model.loadSchemas(for: endpoint, connection: connection) } + } + } + } + + @ViewBuilder + private func databaseList(_ connection: DatabaseConnection) -> some View { + switch model.databases(for: connection.id) { + case .loading: + loadingPane + case let .failed(message): + failurePane(message) { await model.loadDatabases(for: connection, reload: true) } + case let .loaded(names): + let base = CompareSyncEndpoint.from(connection: connection) + if names.isEmpty { + List { + endpointRow(base, title: base.databaseLabel.isEmpty ? connection.name : base.databaseLabel) + } + .listStyle(.inset) + } else { + List(names, id: \.self) { name in + databaseRow(base.withDatabase(name, label: label(name, connection)), connection: connection) + } + .listStyle(.inset) + } + } + } + + @ViewBuilder + private func databaseRow(_ endpoint: CompareSyncEndpoint, connection: DatabaseConnection) -> some View { + if PluginManager.shared.supportsSchemaSwitching(for: connection.type) { + NavigationLink(value: CompareEndpointRoute.schemas(endpoint, connection.id)) { + Text(endpoint.databaseLabel) + } + } else { + endpointRow(endpoint, title: endpoint.databaseLabel) + } + } + + /// There is deliberately no "All schemas". Comparing every schema at once reads two schemas' + /// same-named tables as one object, and the generated ALTER carries no schema of its own, so it + /// would land on whichever schema the connection happens to be on. + @ViewBuilder + private func schemaList(_ endpoint: CompareSyncEndpoint, connection: DatabaseConnection) -> some View { + switch model.schemas(for: endpoint) { + case .loading: + loadingPane + case let .failed(message): + failurePane(message) { await model.loadSchemas(for: endpoint, connection: connection, reload: true) } + case let .loaded(names): + if names.isEmpty { + ContentUnavailableView { + Label("No Schemas", systemImage: "tray") + } description: { + Text("This database reports no schemas to compare.") + } + } else { + List(names, id: \.self) { name in + endpointRow(endpoint.withSchema(name), title: name) + } + .listStyle(.inset) + } + } + } + + private func endpointRow(_ endpoint: CompareSyncEndpoint, title: String) -> some View { + Button { + onPick(endpoint) + dismiss() + } label: { + HStack { + Text(title) + Spacer(minLength: 8) + if current?.id == endpoint.id { + Image(systemName: "checkmark") + .foregroundStyle(.secondary) + } + } + .contentShape(.rect) + } + .buttonStyle(.plain) + .help(endpoint.fullDescription) + } + + // MARK: - States + + private var loadingPane: some View { + VStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Connecting…") + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func failurePane(_ message: String, retry: @escaping () async -> Void) -> some View { + ContentUnavailableView { + Label("Cannot Read This Connection", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button("Try Again") { + Task { await retry() } + } + } + } + + private func swatch(_ color: ConnectionColor) -> some View { + Circle() + .fill(color == .none ? Color.secondary.opacity(0.35) : color.color) + .frame(width: 10, height: 10) + } + + private func label(_ database: String, _ connection: DatabaseConnection) -> String { + CompareSyncEndpoint.label(for: database, type: connection.type) + } +} + +internal enum CompareEndpointRoute: Hashable { + case databases(UUID) + case schemas(CompareSyncEndpoint, UUID) +} diff --git a/TablePro/Views/Compare/CompareEndpointPickerModel.swift b/TablePro/Views/Compare/CompareEndpointPickerModel.swift new file mode 100644 index 000000000..88e1e87b5 --- /dev/null +++ b/TablePro/Views/Compare/CompareEndpointPickerModel.swift @@ -0,0 +1,106 @@ +// +// CompareEndpointPickerModel.swift +// TablePro +// +// The databases and schemas the endpoint picker browses, loaded on demand and +// kept for as long as the picker's window is open. +// +// Reaching a database means connecting to its server, so a level loads only +// once the user opens it, never because a pointer passed over it. A failure is +// kept as a failure: an empty list and an unreachable server are not the same +// answer, and remembering the second as the first is how a picker tells a user +// their server has no databases. +// + +import Foundation + +internal enum CompareEndpointListState: Equatable { + case loading + case loaded([String]) + case failed(String) +} + +@MainActor +@Observable +internal final class CompareEndpointPickerModel { + private var databaseStates: [UUID: CompareEndpointListState] = [:] + private var schemaStates: [String: CompareEndpointListState] = [:] + @ObservationIgnored private var inFlight: Set = [] + @ObservationIgnored private let databaseLoader: (DatabaseConnection) async throws -> [String] + @ObservationIgnored private let schemaLoader: (CompareSyncEndpoint, DatabaseConnection) async throws -> [String] + + internal convenience init() { + let metadata = CompareMetadataService() + self.init( + databaseLoader: { try await metadata.databases(for: $0) }, + schemaLoader: { try await metadata.schemas(for: $0, connection: $1) } + ) + } + + internal init( + databaseLoader: @escaping (DatabaseConnection) async throws -> [String], + schemaLoader: @escaping (CompareSyncEndpoint, DatabaseConnection) async throws -> [String] + ) { + self.databaseLoader = databaseLoader + self.schemaLoader = schemaLoader + } + + internal func databases(for connectionId: UUID) -> CompareEndpointListState { + databaseStates[connectionId] ?? .loading + } + + internal func schemas(for endpoint: CompareSyncEndpoint) -> CompareEndpointListState { + schemaStates[Self.schemaKey(endpoint)] ?? .loading + } + + internal func loadDatabases(for connection: DatabaseConnection, reload: Bool = false) async { + let key = "db|\(connection.id.uuidString)" + guard shouldLoad(current: databaseStates[connection.id], key: key, reload: reload) else { return } + defer { inFlight.remove(key) } + + beginLoading(&databaseStates[connection.id]) + do { + databaseStates[connection.id] = .loaded(try await databaseLoader(connection)) + } catch { + databaseStates[connection.id] = .failed(error.localizedDescription) + } + } + + internal func loadSchemas( + for endpoint: CompareSyncEndpoint, + connection: DatabaseConnection, + reload: Bool = false + ) async { + let mapKey = Self.schemaKey(endpoint) + guard shouldLoad(current: schemaStates[mapKey], key: "schema|\(mapKey)", reload: reload) else { return } + defer { inFlight.remove("schema|\(mapKey)") } + + beginLoading(&schemaStates[mapKey]) + do { + schemaStates[mapKey] = .loaded(try await schemaLoader(endpoint, connection)) + } catch { + schemaStates[mapKey] = .failed(error.localizedDescription) + } + } + + private func shouldLoad(current: CompareEndpointListState?, key: String, reload: Bool) -> Bool { + if !reload, current != nil, !isFailed(current) { return false } + return inFlight.insert(key).inserted + } + + /// Only a level with nothing to show becomes a spinner. A reload keeps the list it is + /// replacing on screen, so retrying never blanks a pane that already had an answer. + private func beginLoading(_ state: inout CompareEndpointListState?) { + guard state == nil || isFailed(state) else { return } + state = .loading + } + + private func isFailed(_ state: CompareEndpointListState?) -> Bool { + guard case .failed = state else { return false } + return true + } + + private static func schemaKey(_ endpoint: CompareSyncEndpoint) -> String { + "\(endpoint.connectionId.uuidString)\u{1F}\(endpoint.database)" + } +} diff --git a/TablePro/Views/Compare/CompareEndpointToolbarController.swift b/TablePro/Views/Compare/CompareEndpointToolbarController.swift new file mode 100644 index 000000000..e882468c9 --- /dev/null +++ b/TablePro/Views/Compare/CompareEndpointToolbarController.swift @@ -0,0 +1,141 @@ +// +// CompareEndpointToolbarController.swift +// TablePro +// +// The Source and Target toolbar buttons, and the chooser each one reveals. +// +// An endpoint is a database, not a connection. Picking only a connection is +// what made two databases on one server impossible to compare and left the +// schema unset, so the chooser is connection then database then schema. +// + +import AppKit +import SwiftUI + +@MainActor +internal final class CompareEndpointToolbarController: NSObject { + private let session: CompareSyncSession + private let onChange: () -> Void + private let windowProvider: () -> NSWindow? + + private var identifiers: [CompareEndpointSide: NSToolbarItem.Identifier] = [:] + private var popover: NSPopover? + private var closeObserver: (any NSObjectProtocol)? + + internal init( + session: CompareSyncSession, + windowProvider: @escaping () -> NSWindow?, + onChange: @escaping () -> Void + ) { + self.session = session + self.windowProvider = windowProvider + self.onChange = onChange + super.init() + } + + internal func item(for side: CompareEndpointSide, identifier: NSToolbarItem.Identifier) -> NSToolbarItem { + identifiers[side] = identifier + let item = NSToolbarItem(itemIdentifier: identifier) + item.label = side.title + item.paletteLabel = side.title + item.image = NSImage(systemSymbolName: side.symbol, accessibilityDescription: side.title) + item.isBordered = true + item.target = self + item.action = side == .source ? #selector(chooseSource(_:)) : #selector(chooseTarget(_:)) + apply(side, to: item) + return item + } + + /// Resolved from the live toolbar rather than from an item this controller kept. Customize + /// Toolbar asks the delegate for more copies with `willBeInsertedIntoToolbar` false, so a + /// retained reference ends up naming a palette item that was thrown away, and the button the + /// user is looking at keeps its old title forever. + internal func refreshTitles() { + guard let toolbar = windowProvider()?.toolbar else { return } + for (side, identifier) in identifiers { + for item in toolbar.items where item.itemIdentifier == identifier { + apply(side, to: item) + } + } + } + + private func apply(_ side: CompareEndpointSide, to item: NSToolbarItem) { + let endpoint = endpoint(for: side) + item.title = endpoint?.qualifiedDescription ?? String(format: String(localized: "Choose %@"), side.title) + item.toolTip = endpoint?.fullDescription ?? side.caption + } + + private func endpoint(for side: CompareEndpointSide) -> CompareSyncEndpoint? { + side == .source ? session.source : session.target + } + + // MARK: - Presentation + + @objc private func chooseSource(_ sender: Any?) { + present(.source) + } + + @objc private func chooseTarget(_ sender: Any?) { + present(.target) + } + + /// Pressing the button again closes the chooser, which is what a pull-down control does and + /// what the popover's own `.transient` dismissal would otherwise fight. + private func present(_ side: CompareEndpointSide) { + guard popover?.isShown != true else { + dismiss() + return + } + guard let identifier = identifiers[side], + let anchor = ToolbarSwitcherPresenter.anchor(in: windowProvider(), identifier) else { return } + + let shown = PopoverPresenter.show( + relativeTo: anchor, + contentSize: CompareEndpointPicker.contentSize, + behavior: .transient + ) { dismiss in + CompareEndpointPicker( + side: side, + current: self.endpoint(for: side), + onPick: { [weak self] endpoint in self?.pick(endpoint, for: side) }, + dismiss: dismiss + ) + } + popover = shown + /// AppKit closes a transient popover by itself and reports it nowhere else. Without this + /// the controller holds a closed popover, and through it the SwiftUI tree and every + /// database list the chooser loaded, until the next presentation. + closeObserver = NotificationCenter.default.addObserver( + forName: NSPopover.didCloseNotification, + object: shown, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.forgetPopover() } + } + } + + internal func dismiss() { + popover?.performClose(nil) + forgetPopover() + } + + private func forgetPopover() { + if let closeObserver { + NotificationCenter.default.removeObserver(closeObserver) + } + closeObserver = nil + popover = nil + } + + /// Re-picking the endpoint that is already chosen must not reach `onChange`, which resets the + /// comparison: the report, both snapshots, every data plan and the user's per-object choices. + private func pick(_ endpoint: CompareSyncEndpoint, for side: CompareEndpointSide) { + guard self.endpoint(for: side)?.id != endpoint.id else { return } + switch side { + case .source: session.source = endpoint + case .target: session.target = endpoint + } + refreshTitles() + onChange() + } +} diff --git a/TablePro/Views/Compare/CompareOptionsView.swift b/TablePro/Views/Compare/CompareOptionsView.swift new file mode 100644 index 000000000..6c105028a --- /dev/null +++ b/TablePro/Views/Compare/CompareOptionsView.swift @@ -0,0 +1,212 @@ +// +// CompareOptionsView.swift +// TablePro +// +// Everything that changes what a comparison means, in one popover. +// +// Changing an option here throws the previous answer away rather than adjusting +// it. A different normalisation rule or a different tolerance is a different +// question, so the old result is not a stale version of the new one. +// + +import SwiftUI +import TableProPluginKit + +internal struct CompareOptionsView: View { + @Bindable internal var session: CompareSyncSession + + @State private var savedProfiles: [CompareSyncProfile] = [] + @State private var newProfileName = "" + + internal var body: some View { + Form { + objectKindsSection + structureSection + dataSection + executionSection + savedComparisonsSection + } + .formStyle(.grouped) + .onAppear { + savedProfiles = session.savedProfiles + } + .onChange(of: session.mode) { + savedProfiles = session.savedProfiles + } + .onChange(of: session.includedKinds) { + session.resetComparison() + } + .onChange(of: session.structureOptions) { + session.resetComparison() + } + .onChange(of: session.dataOptions) { previous, current in + applyDataOptionChange(from: previous, to: current) + } + } + + /// A tolerance or a column set changes the answer, so every summary goes. A write-direction + /// toggle only changes which statements come out of an answer that still stands, so the script + /// goes and the summaries stay. + private func applyDataOptionChange(from previous: DataCompareOptions, to current: DataCompareOptions) { + let comparisonChanged = previous.floatTolerance != current.floatTolerance + || previous.timestampFractionalDigits != current.timestampFractionalDigits + || previous.excludedFromComparison != current.excludedFromComparison + || previous.keyColumns != current.keyColumns + if comparisonChanged { + session.clearDataSummaries() + } else { + session.invalidateScript() + } + } + + // MARK: - Objects + + private var objectKindsSection: some View { + Section { + ForEach(CompareObjectKind.allCases, id: \.self) { kind in + Toggle(kind.displayName, isOn: kindBinding(kind)) + .disabled(kind == .table) + .accessibilityIdentifier("compare.options.kind.\(kind.rawValue)") + } + } header: { + Text("Objects to Compare") + } footer: { + Text("Tables always take part.") + } + } + + private func kindBinding(_ kind: CompareObjectKind) -> Binding { + Binding( + get: { session.includedKinds.contains(kind) }, + set: { included in + guard kind != .table else { return } + if included { + session.includedKinds.insert(kind) + } else { + session.includedKinds.remove(kind) + } + } + ) + } + + // MARK: - Structure + + private var structureSection: some View { + Section { + Toggle("Identifier case", isOn: $session.structureOptions.ignoreIdentifierCase) + Toggle("Column order", isOn: $session.structureOptions.ignoreColumnOrder) + Toggle("Whitespace in text", isOn: $session.structureOptions.ignoreWhitespaceInText) + Toggle("Auto-increment seed", isOn: $session.structureOptions.ignoreAutoIncrementSeed) + Toggle("Collation and character set", isOn: $session.structureOptions.ignoreCollationAndCharset) + Toggle("Comments and owners", isOn: $session.structureOptions.ignoreCommentsAndOwners) + } header: { + Text("Structure Comparison") + } footer: { + Text("What is switched on here is ignored when two structures are compared. These drift between environments by design.") + } + } + + // MARK: - Data + + private var dataSection: some View { + Section { + Toggle("Insert rows the target is missing", isOn: $session.dataOptions.insertMissingRows) + Toggle("Update rows that differ", isOn: $session.dataOptions.updateDifferingRows) + Toggle("Delete rows the source does not have", isOn: $session.dataOptions.deleteExtraRows) + + LabeledContent("Numeric tolerance") { + TextField( + String(localized: "Numeric tolerance"), + value: $session.dataOptions.floatTolerance, + format: .number + ) + .labelsHidden() + .multilineTextAlignment(.trailing) + .accessibilityIdentifier("compare.options.floatTolerance") + } + + LabeledContent("Timestamp digits compared") { + Stepper(value: $session.dataOptions.timestampFractionalDigits, in: 0 ... 9) { + Text(session.dataOptions.timestampFractionalDigits, format: .number) + .monospacedDigit() + } + .accessibilityIdentifier("compare.options.timestampDigits") + } + } header: { + Text("Data Comparison") + } footer: { + Text("Delete is off by default, so a first run cannot remove a row from the target.") + } + } + + // MARK: - Execution + + private var executionSection: some View { + Section { + Picker("On error", selection: $session.executionSettings.errorHandling) { + Text("Stop and roll back").tag(ImportErrorHandling.stopAndRollback) + Text("Stop and keep what ran").tag(ImportErrorHandling.stopAndCommit) + Text("Skip and continue").tag(ImportErrorHandling.skipAndContinue) + } + Toggle("Run in a transaction", isOn: $session.executionSettings.wrapInTransaction) + .disabled(session.executionSettings.errorHandling == .skipAndContinue) + } header: { + Text("Execution") + } footer: { + if session.executionSettings.errorHandling == .skipAndContinue { + Text("A transaction cannot be combined with skip and continue: together they would leave the target half applied.") + } + } + } + + // MARK: - Saved comparisons + + private var savedComparisonsSection: some View { + Section { + if savedProfiles.isEmpty { + Text("No saved setups for this source and target.") + .foregroundStyle(.secondary) + } else { + ForEach(savedProfiles) { profile in + profileRow(profile) + } + } + HStack(spacing: 8) { + TextField(String(localized: "Name"), text: $newProfileName) + .accessibilityIdentifier("compare.options.profileName") + Button("Save") { + session.saveProfile(named: newProfileName) + newProfileName = "" + savedProfiles = session.savedProfiles + } + .disabled(!canSaveProfile) + .accessibilityIdentifier("compare.options.saveProfile") + } + } header: { + Text("Saved Comparisons") + } + } + + private func profileRow(_ profile: CompareSyncProfile) -> some View { + HStack(spacing: 8) { + Text(profile.name) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + Button("Load") { + session.apply(profile) + } + .accessibilityIdentifier("compare.options.loadProfile.\(profile.id.uuidString)") + Button("Delete", role: .destructive) { + session.deleteProfile(profile) + savedProfiles = session.savedProfiles + } + .accessibilityIdentifier("compare.options.deleteProfile.\(profile.id.uuidString)") + } + } + + private var canSaveProfile: Bool { + guard session.source != nil, session.target != nil else { return false } + return !newProfileName.trimmingCharacters(in: .whitespaces).isEmpty + } +} diff --git a/TablePro/Views/Compare/CompareProgressView.swift b/TablePro/Views/Compare/CompareProgressView.swift new file mode 100644 index 000000000..3e2ea1882 --- /dev/null +++ b/TablePro/Views/Compare/CompareProgressView.swift @@ -0,0 +1,154 @@ +// +// CompareProgressView.swift +// TablePro +// +// What the comparison is doing right now, how to stop it, and what it said. +// +// Cancel updates the button synchronously and never waits on the driver: +// `Task.cancel()` is cooperative, so a driver already blocked in a C call +// cannot be interrupted and may complete long after the user gave up. +// +// The root carries no padding of its own, so a host that already draws a strip +// around it does not end up padding it twice. +// + +import SwiftUI + +internal struct CompareProgressView: View { + @Bindable internal var session: CompareSyncSession + + internal var body: some View { + VStack(alignment: .leading, spacing: 8) { + if session.isBusy { + activityRow + } + } + } + + private var activityRow: some View { + HStack(spacing: 8) { + progressBar + .frame(maxWidth: 200) + Button("Cancel") { + session.cancelRunningWork() + } + .controlSize(.small) + .accessibilityIdentifier("compare.progress.cancel") + } + } + + /// `session.progress` exists only for a run whose total is known. Everything else reports no + /// countable unit of work, so it stays indeterminate rather than inventing one. + @ViewBuilder + private var progressBar: some View { + if let progress = session.progress { + ProgressView(progress) + .progressViewStyle(.linear) + } else { + ProgressView() + .progressViewStyle(.linear) + } + } + + @ViewBuilder + private var messages: some View { + Text(session.bannerText) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if let message = session.errorMessage { + messageLabel(message, systemImage: "exclamationmark.triangle.fill", tint: CompareStatusStyle.error) + } + + if let message = session.informationalMessage { + messageLabel(message, systemImage: "info.circle", tint: CompareStatusStyle.warning) + } + } + + private func messageLabel(_ text: String, systemImage: String, tint: Color) -> some View { + Label { + Text(text) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: systemImage) + } + .font(.callout) + .foregroundStyle(tint) + } +} + +/// Every error and notice the session raises, at the top of the pane the user is already reading. +/// +/// A message is set exactly when work stops, so anything that renders one only while the session is +/// busy renders it never: a failed comparison, a capability refusal, a cross-engine warning, a +/// cancelled run, a denied Apply authorization and "Nothing is selected to apply." all produced no +/// output at all. It is inline rather than an alert because the HIG reserves an alert for a +/// situation the user must resolve before continuing and allows one at a time, and a restored +/// window can raise several at once. +internal struct CompareMessageBanner: View { + @Bindable internal var session: CompareSyncSession + + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + + internal var body: some View { + if session.errorMessage != nil || session.informationalMessage != nil { + VStack(alignment: .leading, spacing: 6) { + if let message = session.errorMessage { + messageRow( + message, + systemImage: "exclamationmark.triangle.fill", + tint: CompareStatusStyle.error, + identifier: "compare.message.dismissError" + ) { + session.errorMessage = nil + } + } + if let message = session.informationalMessage { + messageRow( + message, + systemImage: "info.circle.fill", + tint: CompareStatusStyle.warning, + identifier: "compare.message.dismissNotice" + ) { + session.informationalMessage = nil + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + + Divider() + } + } + + private func messageRow( + _ text: String, + systemImage: String, + tint: Color, + identifier: String, + onDismiss: @escaping () -> Void + ) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Label { + Text(text) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: systemImage) + } + .foregroundStyle(differentiateWithoutColor ? Color.primary : tint) + + Spacer(minLength: 0) + + Button(String(localized: "Dismiss"), systemImage: "xmark", action: onDismiss) + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .controlSize(.small) + .accessibilityIdentifier(identifier) + } + .font(.callout) + } +} diff --git a/TablePro/Views/Compare/CompareResultGrouping.swift b/TablePro/Views/Compare/CompareResultGrouping.swift new file mode 100644 index 000000000..9ae59cce0 --- /dev/null +++ b/TablePro/Views/Compare/CompareResultGrouping.swift @@ -0,0 +1,174 @@ +// +// CompareResultGrouping.swift +// TablePro +// +// Turns a comparison report into the rows a `Table` draws, grouped the way the +// session asks for. +// +// The grouping is real: it produces a section per difference or per object kind +// and the table renders each as a `DisclosureTableRow`. The picker this replaces +// only re-sorted, so "Group by Object Type" looked like it did nothing. +// +// Pure and free of SwiftUI so the shape of a grouping can be checked without a +// view. +// + +import Foundation + +internal struct CompareResultRow: Identifiable, Hashable { + internal enum Kind: Hashable { + case group(memberIds: [String]) + case object(CompareObjectResult) + } + + internal let id: String + internal let objectName: String + internal let typeName: String + internal let differenceName: String + internal let changeSummary: String + internal let kind: Kind + + internal var result: CompareObjectResult? { + guard case .object(let result) = kind else { return nil } + return result + } + + internal var memberIds: [String] { + guard case .group(let ids) = kind else { return [] } + return ids + } + + internal var isGroup: Bool { + result == nil + } +} + +internal struct CompareResultGroup: Identifiable { + internal let header: CompareResultRow + internal let rows: [CompareResultRow] + + internal var id: String { header.id } +} + +internal enum CompareResultGrouping { + /// A group header shares the table's row type, so its identifier has to be one no object can + /// produce: an object identifier is `kind|schema|name|signature` and never carries this prefix. + internal static let groupIdentifierPrefix = "compare-group|" + + internal static func isGroupIdentifier(_ identifier: String) -> Bool { + identifier.hasPrefix(groupIdentifierPrefix) + } + + internal static func rows( + from results: [CompareObjectResult], + sortedUsing comparators: [KeyPathComparator] + ) -> [CompareResultRow] { + results.map(row(for:)).sorted(using: comparators) + } + + internal static func groups( + from results: [CompareObjectResult], + grouping: CompareGrouping, + sortedUsing comparators: [KeyPathComparator] + ) -> [CompareResultGroup] { + switch grouping { + case .byDifference: + let buckets = Dictionary(grouping: results, by: { $0.status }) + return statusOrder.compactMap { status in + group( + named: CompareStatusStyle.title(for: status), + identifier: status.rawValue, + results: buckets[status] ?? [], + comparators: comparators + ) + } + case .byObjectType: + let buckets = Dictionary(grouping: results, by: { $0.identity.kind }) + return CompareObjectKind.allCases.compactMap { kind in + group( + named: kind.displayName, + identifier: kind.rawValue, + results: buckets[kind] ?? [], + comparators: comparators + ) + } + case .none: + return [] + } + } + + /// Objects whose metadata could not be read keep their own section whatever the grouping is: + /// they have no difference and no usable action, so folding them in with the rest would + /// present a failure as a result. + internal static func uncomparableGroup( + from results: [CompareObjectResult], + sortedUsing comparators: [KeyPathComparator] + ) -> CompareResultGroup? { + group( + named: String(localized: "Could Not Compare"), + identifier: "uncomparable", + results: results, + comparators: comparators + ) + } + + internal static func objectIds( + in selection: Set, + groups: [CompareResultGroup] + ) -> [String] { + var resolved: [String] = [] + for identifier in selection.sorted() { + guard isGroupIdentifier(identifier) else { + resolved.append(identifier) + continue + } + guard let group = groups.first(where: { $0.id == identifier }) else { continue } + resolved.append(contentsOf: group.header.memberIds) + } + return resolved + } + + private static let statusOrder: [TableDiffStatus] = [.onlyInSource, .onlyInTarget, .differs, .identical] + + private static func group( + named name: String, + identifier: String, + results: [CompareObjectResult], + comparators: [KeyPathComparator] + ) -> CompareResultGroup? { + guard !results.isEmpty else { return nil } + let includableIds = results + .filter { $0.isComparable && $0.suggestedAction != .skip } + .map { $0.id } + let header = CompareResultRow( + id: groupIdentifierPrefix + identifier, + objectName: name, + typeName: "", + differenceName: "", + changeSummary: results.count.formatted(), + kind: .group(memberIds: includableIds) + ) + return CompareResultGroup(header: header, rows: results.map(row(for:)).sorted(using: comparators)) + } + + private static func row(for result: CompareObjectResult) -> CompareResultRow { + CompareResultRow( + id: result.id, + objectName: result.identity.displayName, + typeName: result.identity.kind.displayName, + differenceName: result.isComparable + ? CompareStatusStyle.title(for: result.status) + : CompareStatusStyle.notComparedTitle, + changeSummary: changeSummary(for: result), + kind: .object(result) + ) + } + + /// A source-defined object has no parsed change list, only a body of SQL, so it deliberately + /// summarises to nothing rather than to a count of zero. + private static func changeSummary(for result: CompareObjectResult) -> String { + if let error = result.comparisonError { return error } + guard result.identity.kind == .table, !result.changes.isEmpty else { return "" } + return String(format: String(localized: "%d changes"), result.changes.count) + } +} diff --git a/TablePro/Views/Compare/CompareResultsView.swift b/TablePro/Views/Compare/CompareResultsView.swift new file mode 100644 index 000000000..75a22222d --- /dev/null +++ b/TablePro/Views/Compare/CompareResultsView.swift @@ -0,0 +1,261 @@ +// +// CompareResultsView.swift +// TablePro +// +// Every compared object in one table, grouped the way the session asks for. +// +// Grouping produces real sections through `DisclosureTableRow`, and a section +// header carries the include state of everything under it, so including a whole +// difference class is one click rather than one per row. +// + +import SwiftUI + +internal struct CompareResultsView: View { + @Bindable internal var session: CompareSyncSession + internal let onCompare: () -> Void + + @State private var sortOrder = [KeyPathComparator(\CompareResultRow.objectName)] + + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + + internal var body: some View { + VStack(spacing: 0) { + CompareMessageBanner(session: session) + filterBar + Divider() + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private var content: some View { + let visible = session.visibleResults + let uncomparable = session.report?.uncomparable ?? [] + if session.report == nil { + noReportState + } else if visible.isEmpty, uncomparable.isEmpty { + emptyResultState + } else { + resultsTable(visible: visible, uncomparable: uncomparable) + } + } + + /// Identical objects are hidden by default and the only way back used to be a button inside the + /// empty state, which renders only when nothing differs at all. Nothing ever set it back, so the + /// first press was permanent for the life of the window. + private var filterBar: some View { + HStack(spacing: 8) { + Toggle("Show Identical Objects", isOn: $session.showsIdentical) + .toggleStyle(.checkbox) + .accessibilityIdentifier("compare.results.showIdentical") + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.bar) + } + + // MARK: - Table + + private func resultsTable( + visible: [CompareObjectResult], + uncomparable: [CompareObjectResult] + ) -> some View { + let groups = CompareResultGrouping.groups( + from: visible, grouping: session.grouping, sortedUsing: sortOrder + ) + let flatRows = CompareResultGrouping.rows(from: visible, sortedUsing: sortOrder) + let unreadable = CompareResultGrouping.uncomparableGroup(from: uncomparable, sortedUsing: sortOrder) + let selectableGroups = groups + (unreadable.map { [$0] } ?? []) + + return Table(of: CompareResultRow.self, selection: $session.selectedObjectId, sortOrder: $sortOrder) { + TableColumn("Include") { row in + includeToggle(for: row) + } + .width(min: 52, ideal: 60) + + TableColumn("Object", value: \.objectName) { row in + Text(row.objectName) + .fontWeight(row.isGroup ? .semibold : .regular) + .lineLimit(1) + .truncationMode(.middle) + .help(row.objectName) + } + + TableColumn("Type", value: \.typeName) { row in + Text(row.typeName) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + TableColumn("Difference", value: \.differenceName) { row in + differenceCell(row) + } + + TableColumn("Change", value: \.changeSummary) { row in + Text(row.changeSummary) + .foregroundStyle(.secondary) + .lineLimit(1) + .help(row.changeSummary) + } + } rows: { + if session.grouping == .none { + ForEach(flatRows) { row in + SwiftUI.TableRow(row) + } + } else { + ForEach(groups) { group in + DisclosureTableRow(group.header) { + ForEach(group.rows) { row in + SwiftUI.TableRow(row) + } + } + } + } + if let unreadable { + DisclosureTableRow(unreadable.header) { + ForEach(unreadable.rows) { row in + SwiftUI.TableRow(row) + } + } + } + } + .contextMenu(forSelectionType: CompareResultRow.ID.self) { selection in + inclusionCommands(for: selection, groups: selectableGroups) + } + } + + @ViewBuilder + private func inclusionCommands(for selection: Set, groups: [CompareResultGroup]) -> some View { + let ids = CompareResultGrouping.objectIds(in: selection, groups: groups) + Button("Include") { + session.setIncluded(true, forIds: ids) + } + .disabled(ids.isEmpty) + Button("Exclude") { + session.setIncluded(false, forIds: ids) + } + .disabled(ids.isEmpty) + } + + // MARK: - Cells + + /// A group is three-valued, so it cannot be a `Toggle`: macOS has no mixed state for one. Five of + /// six included used to render exactly like none included, and the first click then included the + /// sixth rather than doing anything the checkbox appeared to offer. + @ViewBuilder + private func includeToggle(for row: CompareResultRow) -> some View { + switch row.kind { + case .group(let memberIds): + if !memberIds.isEmpty { + TristateCheckbox( + state: groupInclusionState(memberIds), + accessibilityLabel: String(localized: "Include every object in this group"), + accessibilityValue: groupInclusionValue(memberIds) + ) { + session.setIncluded(groupInclusionState(memberIds) != .checked, forIds: memberIds) + } + .accessibilityIdentifier("compare.results.includeGroup.\(row.id)") + } + case .object(let result): + Toggle(String(localized: "Include this object"), isOn: objectInclusion(result)) + .labelsHidden() + .toggleStyle(.checkbox) + .disabled(!result.isComparable || result.suggestedAction == .skip) + .accessibilityIdentifier("compare.results.include.\(result.id)") + } + } + + @ViewBuilder + private func differenceCell(_ row: CompareResultRow) -> some View { + if let result = row.result { + Label { + Text(row.differenceName) + .lineLimit(1) + } icon: { + Image(systemName: symbolName(for: result)) + } + .foregroundStyle(tint(for: result)) + } + } + + private func symbolName(for result: CompareObjectResult) -> String { + guard result.isComparable else { return "exclamationmark.triangle.fill" } + return CompareStatusStyle.symbolName(for: result.status) + } + + private func tint(for result: CompareObjectResult) -> Color { + guard !differentiateWithoutColor else { return .primary } + guard result.isComparable else { return CompareStatusStyle.warning } + return CompareStatusStyle.tint(for: result.status) + } + + // MARK: - Inclusion + + private func objectInclusion(_ result: CompareObjectResult) -> Binding { + Binding( + get: { session.isIncluded(result) }, + set: { session.setIncluded($0, for: result) } + ) + } + + private func includedMemberCount(_ memberIds: [String]) -> Int { + memberIds.filter { session.actions[$0, default: .skip] != .skip }.count + } + + private func groupInclusionState(_ memberIds: [String]) -> TristateCheckbox.State { + let included = includedMemberCount(memberIds) + guard included > 0 else { return .unchecked } + return included == memberIds.count ? .checked : .mixed + } + + private func groupInclusionValue(_ memberIds: [String]) -> String { + String( + format: String(localized: "%1$d of %2$d included"), + includedMemberCount(memberIds), memberIds.count + ) + } + + // MARK: - Empty states + + /// The description says why Compare is unavailable when it is. A generic line over a dimmed + /// button leaves the user with no way to work out what is missing, which the HIG asks an app not + /// to do. + private var noReportState: some View { + ContentUnavailableView { + Label("No Comparison Yet", systemImage: "arrow.left.arrow.right.circle") + } description: { + Text(noReportDescription) + } actions: { + Button("Compare", action: onCompare) + .disabled(!session.canCompare) + .accessibilityIdentifier("compare.results.compare") + } + } + + private var noReportDescription: String { + session.compareDisabledReason + ?? String(localized: "Choose a source and a target, then compare them.") + } + + @ViewBuilder + private var emptyResultState: some View { + if session.report?.differenceCount == 0 { + ContentUnavailableView { + Label("No Differences", systemImage: "equal.circle") + } description: { + Text("Every object that was compared matches.") + } + } else if !session.searchText.isEmpty { + ContentUnavailableView.search(text: session.searchText) + } else { + ContentUnavailableView { + Label("Nothing to Show", systemImage: "line.3.horizontal.decrease.circle") + } description: { + Text("The object types taking part in the comparison are set in Options.") + } + } + } +} diff --git a/TablePro/Views/Compare/CompareRowDiffPane.swift b/TablePro/Views/Compare/CompareRowDiffPane.swift new file mode 100644 index 000000000..80c02feef --- /dev/null +++ b/TablePro/Views/Compare/CompareRowDiffPane.swift @@ -0,0 +1,396 @@ +// +// CompareRowDiffPane.swift +// TablePro +// +// The row differences of one table, and the two column sets that decide them. +// +// The entry list is a capped preview, never the whole difference: the script is +// built from a fresh streamed pass. Anywhere the cap is in play the pane says +// so, because a list that silently stops looks like a smaller difference. +// + +import SwiftUI +import TableProPluginKit + +internal enum RowDiffFilter: String, CaseIterable, Hashable { + case all + case difference + case insert + case update + case delete + case same + + internal var title: String { + switch self { + case .all: + return String(localized: "All Rows") + case .difference: + return String(localized: "Difference") + case .insert: + return String(localized: "Insert") + case .update: + return String(localized: "Update") + case .delete: + return String(localized: "Delete") + case .same: + return String(localized: "Same") + } + } + + internal func matches(_ entry: RowDiffEntry) -> Bool { + switch self { + case .all: + return true + case .difference: + return entry.kind != .identical + case .insert: + return entry.kind == .insert + case .update: + return entry.kind == .update + case .delete: + return entry.kind == .delete + case .same: + return entry.kind == .identical + } + } +} + +internal struct CompareRowDiffPane: View { + @Bindable internal var session: CompareSyncSession + internal let onCompare: () -> Void + + @State private var filter: RowDiffFilter = .difference + + internal var body: some View { + if let plan = session.selectedPlan { + planBody(plan) + } else if session.mode == .structure { + ContentUnavailableView { + Label("Rows Compare Data", systemImage: "tablecells") + } description: { + Text("Switch the comparison to Data to see row differences.") + } + } else { + ContentUnavailableView { + Label("No Table Selected", systemImage: "tablecells") + } description: { + Text("Select a table to see its row differences.") + } + } + } + + private func planBody(_ plan: DataComparePlan) -> some View { + VStack(spacing: 0) { + columnEditors(plan) + Divider() + filterBar + if plan.summary?.truncatedEntries == true { + notice(String( + localized: "This list is a capped preview. Apply covers every difference, not only the rows listed here." + )) + } + if let skipped = plan.summary?.skippedNullKeyCount, skipped > 0 { + notice(String( + format: String( + localized: "%d rows hold NULL in a key column and were left out. Choose a key with no NULLs to compare them." + ), + skipped + )) + } + if filter == .same, plan.summary?.identicalCount ?? 0 > 0 { + notice(String( + format: String( + localized: "%d rows match. Matching rows are counted, not listed, so a difference is never crowded out of this list." + ), + plan.summary?.identicalCount ?? 0 + )) + } + Divider() + entryList(plan) + } + } + + // MARK: - Column editors + + private func columnEditors(_ plan: DataComparePlan) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 16) { + columnMenu( + title: String(localized: "Key columns"), + summary: keySummary(plan), + systemImage: "key", + identifier: "compare.rows.keyColumns" + ) { + ForEach(plan.columns, id: \.self) { column in + Toggle(column, isOn: keyBinding(column, plan: plan)) + } + } + + columnMenu( + title: String(localized: "Compared columns"), + summary: comparedSummary(plan), + systemImage: "text.magnifyingglass", + identifier: "compare.rows.comparedColumns" + ) { + ForEach(nonKeyColumns(plan), id: \.self) { column in + Toggle(column, isOn: comparedBinding(column)) + } + } + + Spacer(minLength: 0) + } + + Text("A column left out of the comparison is still written on insert and update. Changing either list needs another comparison.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if session.needsRecompare { + recompareNotice + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + /// The label is the joined key column list, so `.fixedSize()` on the menu overrode both the + /// truncation and the width the pane proposed. A composite key pushed the second menu past the + /// detail pane's minimum width, where it was clipped and could not be opened at all. The label + /// truncates instead and the full list stays reachable through the tooltip. + private func columnMenu( + title: String, + summary: String, + systemImage: String, + identifier: String, + @ViewBuilder content: () -> some View + ) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Menu { + content() + } label: { + Label(summary, systemImage: systemImage) + .lineLimit(1) + .truncationMode(.middle) + } + .help(summary) + .accessibilityIdentifier(identifier) + } + } + + private var recompareNotice: some View { + HStack(spacing: 8) { + Label { + Text("Some tables have not been compared with the columns now chosen.") + } icon: { + Image(systemName: "exclamationmark.arrow.circlepath") + } + .font(.callout) + .foregroundStyle(CompareStatusStyle.warning) + .fixedSize(horizontal: false, vertical: true) + Button("Compare", action: onCompare) + .disabled(!session.canCompare) + .accessibilityIdentifier("compare.rows.recompare") + } + } + + // MARK: - Filter + + private var filterBar: some View { + HStack(spacing: 8) { + Picker(String(localized: "Show"), selection: $filter) { + ForEach(RowDiffFilter.allCases, id: \.self) { option in + Text(option.title).tag(option) + } + } + .fixedSize() + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + /// A row whose key holds NULL has no identity a merge join can use, so it is left out of the + /// comparison and out of the sync. That used to be counted and never shown, so the pane + /// reported no differences and the user concluded the two tables matched. + private func notice(_ text: String) -> some View { + Label { + Text(text) + } icon: { + Image(systemName: "info.circle.fill") + } + .font(.callout) + .foregroundStyle(CompareStatusStyle.warning) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.bottom, 6) + } + + // MARK: - Entries + + @ViewBuilder + private func entryList(_ plan: DataComparePlan) -> some View { + if let summary = plan.summary { + let entries = summary.entries.filter { filter.matches($0) } + if entries.isEmpty { + ContentUnavailableView { + Label("No Rows Match", systemImage: "line.3.horizontal.decrease.circle") + } description: { + Text("Change the filter to see the other rows.") + } + } else { + List(entries) { entry in + CompareRowDiffEntryView( + entry: entry, + isIncluded: rowBinding(entry, plan: plan) + ) + } + .listStyle(.inset) + } + } else { + ContentUnavailableView { + Label("Not Compared Yet", systemImage: "arrow.clockwise") + } description: { + Text(notComparedDescription(for: plan)) + } + } + } + + /// The plan's own refusal first, then whatever is keeping Compare itself unavailable, and only + /// then the generic invitation. A dimmed action with no reason is what the HIG asks an app not + /// to leave a user holding. + private func notComparedDescription(for plan: DataComparePlan) -> String { + plan.unavailableReason + ?? session.compareDisabledReason + ?? String(localized: "Include this table and compare again to see its rows.") + } + + // MARK: - Bindings + + private func keyBinding(_ column: String, plan: DataComparePlan) -> Binding { + Binding( + get: { plan.isKeyColumn(column) }, + set: { _ in session.toggleKeyColumn(column, for: plan.id) } + ) + } + + private func comparedBinding(_ column: String) -> Binding { + Binding( + get: { session.isColumnCompared(column) }, + set: { _ in session.toggleComparedColumn(column) } + ) + } + + private func rowBinding(_ entry: RowDiffEntry, plan: DataComparePlan) -> Binding { + Binding( + get: { session.isRowIncluded(entry, in: plan) }, + set: { session.setRowIncluded($0, entry: entry, planId: plan.id) } + ) + } + + // MARK: - Summaries + + private func nonKeyColumns(_ plan: DataComparePlan) -> [String] { + plan.columns.filter { !plan.isKeyColumn($0) } + } + + private func keySummary(_ plan: DataComparePlan) -> String { + guard !plan.keyColumns.isEmpty else { return String(localized: "None chosen") } + return plan.keyColumns.joined(separator: ", ") + } + + private func comparedSummary(_ plan: DataComparePlan) -> String { + let excluded = nonKeyColumns(plan).filter { !session.isColumnCompared($0) } + guard !excluded.isEmpty else { return String(localized: "All") } + return String(format: String(localized: "%d left out"), excluded.count) + } +} + +internal struct CompareRowDiffEntryView: View { + internal let entry: RowDiffEntry + @Binding internal var isIncluded: Bool + + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + + internal var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Toggle(String(localized: "Include this row"), isOn: $isIncluded) + .labelsHidden() + .toggleStyle(.checkbox) + .disabled(entry.kind == .identical) + .accessibilityIdentifier("compare.rows.include.\(entry.id.uuidString)") + + Label { + Text(CompareStatusStyle.title(for: entry.kind)) + } icon: { + Image(systemName: CompareStatusStyle.symbolName(for: entry.kind)) + } + .font(.caption) + .foregroundStyle(kindTint) + + Text(entry.keyDescription) + .font(.system(.callout, design: .monospaced)) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 0) + } + + ForEach(entry.cellDifferences, id: \.column) { difference in + cellDifferenceRow(difference) + } + } + .padding(.vertical, 2) + .listRowBackground(rowBackground) + } + + private func cellDifferenceRow(_ difference: CellDifference) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(difference.column) + .font(.caption.weight(.semibold)) + .lineLimit(1) + Text(Self.describe(difference.sourceValue)) + .font(.system(.caption, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + Image(systemName: "arrow.right") + .font(.caption2) + .foregroundStyle(.tertiary) + Text(Self.describe(difference.targetValue)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + Text(difference.rule.displayName) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + private var kindTint: Color { + guard !differentiateWithoutColor else { return .primary } + return CompareStatusStyle.tint(for: entry.kind) + } + + private var rowBackground: Color { + guard !differentiateWithoutColor else { return .clear } + return CompareStatusStyle.rowTint(for: entry.kind) + } + + private static func describe(_ value: PluginCellValue) -> String { + switch value { + case .null: + return "NULL" + case .text(let text): + return text + case .bytes(let data): + return String(format: String(localized: "%d bytes"), data.count) + } + } +} diff --git a/TablePro/Views/Compare/CompareScriptPane.swift b/TablePro/Views/Compare/CompareScriptPane.swift new file mode 100644 index 000000000..184dcac97 --- /dev/null +++ b/TablePro/Views/Compare/CompareScriptPane.swift @@ -0,0 +1,258 @@ +// +// CompareScriptPane.swift +// TablePro +// +// The generated script, read-only, with the statements it is holding back. +// +// The script is not editable. Each statement carries the hazards the operation +// plan computed for it, and there is no way to recover those from edited text, +// so an editable script would quietly drop the only thing standing between a +// DROP COLUMN and the target. +// + +import SwiftUI +import UniformTypeIdentifiers + +internal struct CompareScriptPane: View { + @Bindable internal var session: CompareSyncSession + internal let onGenerateScript: () -> Void + + @AppStorage("structureCodeFontSize", store: AppStorageEnvironment.shared.defaults) private var fontSize = 13.0 + + @State private var isExporting = false + + internal var body: some View { + if session.statements.isEmpty { + emptyState + } else { + scriptContent + } + } + + // MARK: - Script + + private var scriptContent: some View { + let script = scriptText + return VStack(spacing: 0) { + header(script: script) + Divider() + if heldBackStatements.isEmpty { + editor(script: script) + } else { + /// `VSplitView` gets no divider cursor once it is hosted inside SwiftUI: AppKit's + /// cursor-rect system never fires there, so the divider drags while the pointer + /// never changes. `AutosavingSplitView` sits on `ResizeCursorSplitViewController`, + /// which is why every other divider in this window is one. + AutosavingSplitView( + autosaveName: "com.TablePro.CompareSync.script", + isVertical: false, + primaryMinimum: 120, + secondaryMinimum: 160 + ) { + hazardList + } secondary: { + editor(script: script) + } + } + } + .fileExporter( + isPresented: $isExporting, + document: SyncScriptDocument(text: script), + contentType: SyncScriptDocument.sqlType, + defaultFilename: exportFileName + ) { _ in } + } + + private func header(script: String) -> some View { + HStack(spacing: 12) { + Text(statementSummary) + .font(.callout) + .foregroundStyle(.secondary) + if !heldBackStatements.isEmpty { + Label { + Text(heldBackSummary) + } icon: { + Image(systemName: "hand.raised.fill") + } + .font(.callout) + .foregroundStyle(CompareStatusStyle.warning) + } + Spacer(minLength: 0) + Button("Copy") { + ClipboardService.shared.writeText(script) + } + .accessibilityIdentifier("compare.script.copy") + Button("Save\u{2026}") { + isExporting = true + } + .accessibilityIdentifier("compare.script.save") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private func editor(script: String) -> some View { + DDLTextView(ddl: script, fontSize: $fontSize, databaseType: session.target?.databaseType) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Hazards + + private var hazardList: some View { + List { + Section { + ForEach(heldBackStatements) { statement in + CompareHeldBackStatementRow(statement: statement, session: session) + } + } header: { + Text("Held Back") + } footer: { + Text("Allowing a statement applies to this run only. It is never saved.") + } + } + .listStyle(.inset) + } + + private var heldBackStatements: [SyncStatement] { + session.statements.filter { $0.isRefusedByDefault } + } + + // MARK: - Text + + private var scriptText: String { + session.statements.map { $0.sql }.joined(separator: "\n") + } + + /// `database` is the whole file path on SQLite and DuckDB, so Save… prefilled + /// `/Users/…/Chinook.sqlite-sync`. `databaseLabel` is the shortened name the rest of the window + /// already shows. + private var exportFileName: String { + guard let target = session.target, !target.databaseLabel.isEmpty else { return "sync" } + return "\(target.databaseLabel)-sync" + } + + /// One statement reads "1 statement". The count that carries the noun is the first argument and + /// the second is bare, so the singular is chosen here rather than in the format string. + private var statementSummary: String { + let total = session.statements.count + guard total != 1 else { + return String(format: String(localized: "1 statement, %d will run."), session.runnableStatementCount) + } + return String( + format: String(localized: "%1$d statements, %2$d will run."), + total, session.runnableStatementCount + ) + } + + private var heldBackSummary: String { + String( + format: String(localized: "%1$d of %2$d allowed for this run"), + heldBackStatements.count - session.unacknowledgedHazardCount, + heldBackStatements.count + ) + } + + // MARK: - Empty state + + private var emptyState: some View { + ContentUnavailableView { + Label("No Script Yet", systemImage: "doc.plaintext") + } description: { + Text(emptyDescription) + } actions: { + Button("Generate Script", action: onGenerateScript) + .disabled(!session.canBuildScript) + .accessibilityIdentifier("compare.script.generate") + } + } + + /// The one reason Generate Script is unavailable, rather than this pane's own second guess at it. + /// `scriptDisabledReason` already carries the cross-engine refusal, the missing comparison and + /// the empty selection, and it is what the toolbar item's tooltip reads. + private var emptyDescription: String { + session.scriptDisabledReason + ?? String(localized: "Generate the script to see exactly what would run.") + } +} + +internal struct CompareHeldBackStatementRow: View { + internal let statement: SyncStatement + @Bindable internal var session: CompareSyncSession + + internal var body: some View { + VStack(alignment: .leading, spacing: 4) { + Toggle(isOn: CompareHazardAllowance.binding(for: statement, in: session)) { + Text(statement.summary) + .font(.callout) + } + .toggleStyle(.checkbox) + .accessibilityIdentifier("compare.script.allow.\(statement.id.uuidString)") + + ForEach(statement.hazards) { hazard in + Label { + VStack(alignment: .leading, spacing: 1) { + Text(hazard.kind.displayName) + .font(.caption.weight(.semibold)) + Text(hazard.explanation) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(CompareStatusStyle.warning) + } + } + + Text(statement.sql) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(3) + } + .padding(.vertical, 4) + } +} + +/// One writer for the allowance set, because the script pane and the Apply sheet both offer it. Two +/// copies of the same insert-or-remove is how the two surfaces drift apart, and this set is what +/// stands between a DROP COLUMN and the target. +internal enum CompareHazardAllowance { + @MainActor + internal static func binding(for statement: SyncStatement, in session: CompareSyncSession) -> Binding { + Binding( + get: { session.executionSettings.allowedHazardStatementIds.contains(statement.id) }, + set: { allowed in + if allowed { + session.executionSettings.allowedHazardStatementIds.insert(statement.id) + } else { + session.executionSettings.allowedHazardStatementIds.remove(statement.id) + } + } + ) + } +} + +internal struct SyncScriptDocument: FileDocument { + internal static let sqlType = UTType(filenameExtension: "sql") ?? .plainText + + internal static var readableContentTypes: [UTType] { [sqlType, .plainText] } + + internal let text: String + + internal init(text: String) { + self.text = text + } + + internal init(configuration: ReadConfiguration) throws { + guard let data = configuration.file.regularFileContents, + let decoded = String(bytes: data, encoding: .utf8) else { + throw CocoaError(.fileReadCorruptFile) + } + text = decoded + } + + internal func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: Data(text.utf8)) + } +} diff --git a/TablePro/Views/Compare/CompareStatusBar.swift b/TablePro/Views/Compare/CompareStatusBar.swift new file mode 100644 index 000000000..7dee88edf --- /dev/null +++ b/TablePro/Views/Compare/CompareStatusBar.swift @@ -0,0 +1,96 @@ +// +// CompareStatusBar.swift +// TablePro +// +// The window's bottom bar: what the comparison found, and how much of it the +// user has included. +// +// Counts only, never an action. The HIG asks an app to avoid putting critical +// information or actions in a bottom bar and to "use it only to display a small +// amount of information directly related to a window's contents or to a +// selected item within it", with Finder's item and selection counts as the +// example. Compare, Generate Script and Apply stay in the toolbar and in the +// Database > Compare menu, which is also the only place they are reachable when +// the window's bottom edge is off screen. +// +// It keeps the same four cells in both modes. Data mode reuses the structure +// vocabulary because an insert is a row that exists only on the source, so the +// bar never changes shape when the mode does. +// + +import SwiftUI + +internal struct CompareStatusBar: View { + @Bindable internal var session: CompareSyncSession + + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + + internal var body: some View { + HStack(spacing: StatusBarChrome.clusterSpacing) { + content + } + .statusBarChrome() + } + + /// A resting line rather than nothing at all, so the bar does not appear and disappear under the + /// content and shift everything above it the first time a comparison finishes. + @ViewBuilder + private var content: some View { + let counts = session.statusCounts + if counts.isEmpty { + Text("No comparison yet.") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer(minLength: 0) + } else { + ForEach(counts) { entry in + statusCount(entry) + } + Spacer(minLength: StatusBarChrome.clusterSpacing) + includedCount + } + } + + private func statusCount(_ entry: CompareStatusCount) -> some View { + let title = CompareStatusStyle.title(for: entry.status) + return HStack(spacing: 4) { + Image(systemName: CompareStatusStyle.symbolName(for: entry.status)) + .foregroundStyle(tint(for: entry)) + Text(entry.count, format: .number) + .monospacedDigit() + .fontWeight(.semibold) + Text(title) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .font(.caption) + .accessibilityElement(children: .combine) + .accessibilityLabel(title) + .accessibilityValue(Text(entry.count, format: .number)) + .accessibilityIdentifier("compare.status.\(entry.status.rawValue)") + } + + private var includedCount: some View { + Text(includedText) + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + .lineLimit(1) + .accessibilityIdentifier("compare.status.included") + } + + private var includedText: String { + String(format: String(localized: "%d included"), session.includedCount) + } + + /// Colour is never the only carrier: every cell pairs the tint with the status symbol and its + /// word, and drops the tint when the system asks for shapes instead of colour. A zero reads as a + /// zero rather than as a green success. + private func tint(for entry: CompareStatusCount) -> Color { + let value = entry.count + guard value > 0 else { return .secondary } + guard !differentiateWithoutColor else { return .primary } + return CompareStatusStyle.tint(for: entry.status) + } +} diff --git a/TablePro/Views/Compare/CompareStatusStyle.swift b/TablePro/Views/Compare/CompareStatusStyle.swift new file mode 100644 index 000000000..754ddcb06 --- /dev/null +++ b/TablePro/Views/Compare/CompareStatusStyle.swift @@ -0,0 +1,137 @@ +// +// CompareStatusStyle.swift +// TablePro +// +// One vocabulary for a comparison outcome: a word, a symbol and a tint. +// +// Colour is never the carrier on its own. Every call site pairs the tint with +// the symbol and the word, and drops the tint when the system asks for shapes +// instead of colour. Tints resolve through `ThemeEngine` rather than the +// built-in `Color` literals, so a theme owns them the way it owns the grid. +// + +import SwiftUI + +internal enum CompareStatusStyle { + internal static let notComparedTitle = String(localized: "Not compared") + + // MARK: - Object status + + internal static func title(for status: TableDiffStatus) -> String { + switch status { + case .onlyInSource: + return String(localized: "Only in Source") + case .onlyInTarget: + return String(localized: "Only in Target") + case .differs: + return String(localized: "Differs") + case .identical: + return String(localized: "Identical") + } + } + + internal static func symbolName(for status: TableDiffStatus) -> String { + switch status { + case .onlyInSource: + return "plus.circle" + case .onlyInTarget: + return "minus.circle" + case .differs: + return "circle.lefthalf.filled" + case .identical: + return "equal.circle" + } + } + + @MainActor + internal static func tint(for status: TableDiffStatus) -> Color { + let colors = ThemeEngine.shared.colors.ui + switch status { + case .onlyInSource: + return colors.successSwiftUI + case .onlyInTarget: + return colors.errorSwiftUI + case .differs: + return colors.warningSwiftUI + case .identical: + return colors.secondaryTextSwiftUI + } + } + + // MARK: - Row difference + + internal static func title(for kind: RowDiffKind) -> String { + switch kind { + case .insert: + return String(localized: "Insert") + case .update: + return String(localized: "Update") + case .delete: + return String(localized: "Delete") + case .identical: + return String(localized: "Same") + } + } + + internal static func symbolName(for kind: RowDiffKind) -> String { + switch kind { + case .insert: + return "plus.circle.fill" + case .update: + return "circle.lefthalf.filled" + case .delete: + return "minus.circle.fill" + case .identical: + return "equal.circle" + } + } + + @MainActor + internal static func tint(for kind: RowDiffKind) -> Color { + let colors = ThemeEngine.shared.colors.ui + switch kind { + case .insert: + return colors.successSwiftUI + case .update: + return colors.warningSwiftUI + case .delete: + return colors.errorSwiftUI + case .identical: + return colors.secondaryTextSwiftUI + } + } + + /// The same tints the data grid paints an inserted, modified or deleted row with, so a row + /// difference here reads the way the same row reads in the grid. + @MainActor + internal static func rowTint(for kind: RowDiffKind) -> Color { + let colors = ThemeEngine.shared.colors.dataGrid + switch kind { + case .insert: + return colors.insertedSwiftUI + case .update: + return colors.modifiedSwiftUI + case .delete: + return colors.deletedSwiftUI + case .identical: + return .clear + } + } + + // MARK: - Message tints + + @MainActor + internal static var warning: Color { + ThemeEngine.shared.colors.ui.warningSwiftUI + } + + @MainActor + internal static var error: Color { + ThemeEngine.shared.colors.ui.errorSwiftUI + } + + @MainActor + internal static var success: Color { + ThemeEngine.shared.colors.ui.successSwiftUI + } +} diff --git a/TablePro/Views/Compare/CompareSyncWindowController.swift b/TablePro/Views/Compare/CompareSyncWindowController.swift new file mode 100644 index 000000000..5f132143a --- /dev/null +++ b/TablePro/Views/Compare/CompareSyncWindowController.swift @@ -0,0 +1,578 @@ +// +// CompareSyncWindowController.swift +// TablePro +// +// The Compare & Sync window. +// +// Shaped like FileMerge's result window rather than an assistant: one +// persistent window with a customizable, autosaving toolbar, which is what +// Apple ships for a comparison the user re-runs. The four-step wizard this +// replaces had no toolbar, so it grew a numbered step header and a bottom +// action bar, both of which the HIG names directly: "Avoid creating custom +// window UI" and "Avoid putting critical information or actions in a bottom +// bar, because people often relocate a window in a way that hides its bottom +// edge." +// +// The toolbar is attached in `init`, after the session exists, because a +// delegate that returns nil for an item while `autosavesConfiguration` is on +// permanently prunes that item from the saved configuration on disk. +// + +import AppKit +import SwiftUI + +internal extension NSToolbarItem.Identifier { + static let compareSource = NSToolbarItem.Identifier("com.TablePro.compare.source") + static let compareSwap = NSToolbarItem.Identifier("com.TablePro.compare.swap") + static let compareTarget = NSToolbarItem.Identifier("com.TablePro.compare.target") + static let compareMode = NSToolbarItem.Identifier("com.TablePro.compare.mode") + static let compareRun = NSToolbarItem.Identifier("com.TablePro.compare.run") + static let compareOptions = NSToolbarItem.Identifier("com.TablePro.compare.options") + static let compareGrouping = NSToolbarItem.Identifier("com.TablePro.compare.grouping") + static let compareSearch = NSToolbarItem.Identifier("com.TablePro.compare.search") + static let compareGenerate = NSToolbarItem.Identifier("com.TablePro.compare.generate") + static let compareApply = NSToolbarItem.Identifier("com.TablePro.compare.apply") +} + +@MainActor +internal final class CompareSyncWindowController: NSWindowController, + NSWindowDelegate, NSToolbarDelegate, NSUserInterfaceValidations { + private static var controllers: [UUID?: CompareSyncWindowController] = [:] + + private let session = CompareSyncSession() + private lazy var runner = CompareRunner(session: session) + private lazy var endpointMenus = CompareEndpointToolbarController( + session: session, + windowProvider: { [weak self] in self?.window } + ) { [weak self] in + self?.endpointsChanged() + } + private weak var modeControl: NSSegmentedControl? + private weak var searchToolbarItem: NSSearchToolbarItem? + + internal static func present(prefillSource connectionId: UUID?) { + let controller = controllers[connectionId] ?? CompareSyncWindowController(prefillSource: connectionId) + controllers[connectionId] = controller + controller.showWindow(nil) + controller.window?.makeKeyAndOrderFront(nil) + AppActivationPolicyController.shared.activate() + } + + private init(prefillSource connectionId: UUID?) { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1_100, height: 700), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.minSize = NSSize(width: 720, height: 460) + window.title = String(localized: "Compare & Sync") + window.identifier = NSUserInterfaceItemIdentifier(WindowIdentifier.compareSync) + window.isRestorable = false + window.isReleasedWhenClosed = false + super.init(window: window) + + applyPrefill(connectionId) + window.contentViewController = makeContentController() + window.delegate = self + window.applyAutosaveName(WindowIdentifier.compareSync) + installToolbar(on: window) + installStatusStrip(on: window) + } + + @available(*, unavailable) + internal required init?(coder: NSCoder) { + fatalError("init(coder:) not supported") + } + + private func makeContentController() -> NSViewController { + let content = CompareWindowContentView( + session: session, + onCompare: { [weak self] in self?.runner.compare() }, + onGenerateScript: { [weak self] in self?.runner.buildScript() }, + onApply: { [weak self] in self?.presentApplySheet() } + ) + .environment(\.appServices, .live) + let hosting = NSHostingController(rootView: content) + /// A standalone window wants the content's minimum to become the window's, unlike a split + /// pane's host, where the same minimum would pin the window's dividers. + hosting.sizingOptions = [.minSize] + return hosting + } + + private func applyPrefill(_ connectionId: UUID?) { + guard let connectionId else { return } + let connections = ConnectionStorage.shared.loadConnections() + guard let match = connections.first(where: { $0.id == connectionId }) else { return } + session.source = CompareSyncEndpoint.from(connection: match) + } + + /// The strip belongs to the window frame, not to the content: it reports what the window is + /// doing, so it stays put while the panes scroll and it is not a row the split view has to + /// budget for. `NSTitlebarAccessoryViewController`'s `.bottom` means the bottom of the + /// *titlebar*, which is exactly this position, directly under the toolbar. + private func installStatusStrip(on window: NSWindow) { + let hosting = NSHostingController(rootView: CompareStatusStrip(session: session)) + hosting.sizingOptions = [.preferredContentSize] + let accessory = NSTitlebarAccessoryViewController() + /// The accessory owns the hosting controller, not just its view. Handing over the bare view + /// leaves nothing retaining the controller, and a deallocated `NSHostingController` stops + /// updating the SwiftUI it was built from. + accessory.addChild(hosting) + accessory.view = hosting.view + accessory.layoutAttribute = .bottom + window.addTitlebarAccessoryViewController(accessory) + } + + // MARK: - Toolbar + + private func installToolbar(on window: NSWindow) { + let toolbar = NSToolbar(identifier: "com.TablePro.CompareSyncToolbar") + toolbar.delegate = self + toolbar.displayMode = .iconAndLabel + toolbar.allowsUserCustomization = true + toolbar.autosavesConfiguration = true + window.toolbar = toolbar + window.toolbarStyle = .unified + } + + /// The HIG's item grouping: what the window is about on the leading edge, view controls in the + /// middle, and the actions on the trailing edge, where "items on the trailing edge remain + /// visible at all window sizes" and where the one primary action belongs. Compare used to sit + /// on the leading edge beside the pickers, which put the primary action in the group reserved + /// for identity and made it the first thing to be clipped. + internal func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + [ + .compareSource, .compareSwap, .compareTarget, + .flexibleSpace, + .compareMode, .compareGrouping, .compareOptions, .compareSearch, + .space, + .compareGenerate, .compareApply, .compareRun + ] + } + + internal func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + toolbarDefaultItemIdentifiers(toolbar) + [.space, .sidebarTrackingSeparator] + } + + internal func toolbar( + _ toolbar: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool + ) -> NSToolbarItem? { + switch itemIdentifier { + case .compareSource: + return endpointMenus.item(for: .source, identifier: itemIdentifier) + case .compareTarget: + return endpointMenus.item(for: .target, identifier: itemIdentifier) + case .compareSwap: + return button( + itemIdentifier, + label: String(localized: "Swap"), + symbol: "arrow.left.arrow.right", + action: #selector(swapEndpoints(_:)) + ) + case .compareMode: + return modeItem(itemIdentifier) + case .compareRun: + return button( + itemIdentifier, + label: String(localized: "Compare"), + symbol: "arrow.triangle.2.circlepath", + action: #selector(runComparison(_:)), + prominent: true, + visibility: .high + ) + case .compareGenerate: + return button( + itemIdentifier, + label: String(localized: "Generate Script"), + symbol: "doc.text", + action: #selector(generateScript(_:)) + ) + case .compareApply: + return button( + itemIdentifier, + label: String(localized: "Apply…"), + symbol: "square.and.arrow.down.on.square", + action: #selector(applyToTarget(_:)), + visibility: .high + ) + case .compareOptions: + return button( + itemIdentifier, + label: String(localized: "Options"), + symbol: "slider.horizontal.3", + action: #selector(showOptions(_:)), + visibility: .low + ) + case .compareGrouping: + return groupingItem(itemIdentifier) + case .compareSearch: + return searchItem(itemIdentifier) + default: + return nil + } + } + + /// `toolTip` is deliberately not the item's own label. The HIG says to "consider offering + /// context-sensitive tooltips" with "different text for a control's different states" and, in + /// the same breath, to "avoid repeating a control's name in its tooltip". Repeating the name is + /// what this did, so a disabled Apply explained nothing. `validateUserInterfaceItem` refreshes + /// the tip from the session's reason every time AppKit validates. + private func button( + _ identifier: NSToolbarItem.Identifier, + label: String, + symbol: String, + action: Selector, + prominent: Bool = false, + visibility: NSToolbarItem.VisibilityPriority = .standard + ) -> NSToolbarItem { + let item = NSToolbarItem(itemIdentifier: identifier) + item.label = label + item.paletteLabel = label + item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) + item.action = action + item.target = self + item.isBordered = true + item.visibilityPriority = visibility + item.menuFormRepresentation = NSMenuItem(title: label, action: action, keyEquivalent: "") + item.menuFormRepresentation?.target = self + /// macOS 26 is the first release with a prominent toolbar item style. Before it, weight + /// comes from position and grouping alone: a hand-tinted bezel would be this app inventing + /// chrome, which is what the rebuild set out to remove. + if prominent, #available(macOS 26.0, *) { + item.style = .prominent + } + return item + } + + private func modeItem(_ identifier: NSToolbarItem.Identifier) -> NSToolbarItem { + let control = NSSegmentedControl( + labels: CompareSyncMode.allCases.map { $0.displayName }, + trackingMode: .selectOne, + target: self, + action: #selector(modeChanged(_:)) + ) + control.selectedSegment = CompareSyncMode.allCases.firstIndex(of: session.mode) ?? 0 + modeControl = control + let item = NSToolbarItem(itemIdentifier: identifier) + /// Not "Compare": the button beside it already carries that label, and two toolbar items + /// reading the same word cannot be told apart in the overflow menu or by VoiceOver. + item.label = String(localized: "Mode") + item.paletteLabel = String(localized: "What to Compare") + item.toolTip = String(localized: "Compare structure or data") + item.view = control + /// A view-backed item collapses to an inert titled entry in the overflow menu unless it + /// supplies its own. Without this, narrowing the window took Structure and Data away with + /// no way to switch back. + let overflow = NSMenuItem(title: String(localized: "Compare"), action: nil, keyEquivalent: "") + let submenu = NSMenu() + for mode in CompareSyncMode.allCases { + let entry = NSMenuItem(title: mode.displayName, action: #selector(modePicked(_:)), keyEquivalent: "") + entry.target = self + entry.representedObject = mode.rawValue + entry.state = session.mode == mode ? .on : .off + submenu.addItem(entry) + } + overflow.submenu = submenu + item.menuFormRepresentation = overflow + return item + } + + /// The segmented control is seeded once at construction, so anything that writes `session.mode` + /// from elsewhere, loading a saved comparison for instance, used to leave the toolbar showing + /// the other mode while the panes had already switched. + private func syncModeControl() { + guard let index = CompareSyncMode.allCases.firstIndex(of: session.mode) else { return } + if modeControl?.selectedSegment != index { + modeControl?.selectedSegment = index + } + modeControl?.isEnabled = !session.isBusy + } + + private func groupingItem(_ identifier: NSToolbarItem.Identifier) -> NSToolbarItem { + let item = NSMenuToolbarItem(itemIdentifier: identifier) + item.label = String(localized: "Group By") + item.paletteLabel = String(localized: "Group By") + item.toolTip = String(localized: "Group results by difference or object type") + item.image = NSImage( + systemSymbolName: "list.bullet.indent", + accessibilityDescription: String(localized: "Group By") + ) + item.showsIndicator = true + let menu = NSMenu() + for grouping in CompareGrouping.allCases { + let entry = NSMenuItem(title: grouping.title, action: #selector(groupingChanged(_:)), keyEquivalent: "") + entry.target = self + entry.representedObject = grouping.rawValue + entry.state = session.grouping == grouping ? .on : .off + menu.addItem(entry) + } + item.menu = menu + return item + } + + /// `NSSearchToolbarItem` stretches past `preferredWidthForSearchField` to absorb whatever slack + /// the toolbar has: measured at 325pt in a 1400pt window against a preferred 240, which is why + /// the field swallowed a third of the toolbar. The preferred width is documented as the width + /// it takes "whenever it gets the keyboard focus", not a cap, so the cap has to be a real + /// constraint on the field. `NSSearchToolbarItem.h`: "If specifying custom width constraints to + /// the search field, they should not conflict with this value", so both are the same number. + /// + /// The field is configured before it is assigned, which is the order the header asks for: + /// "While inside the toolbar item, the field properties and layout constraints are managed by + /// the item. The field should be configured before assigned." + private func searchItem(_ identifier: NSToolbarItem.Identifier) -> NSSearchToolbarItem { + let field = NSSearchField() + field.sendsWholeSearchString = false + field.sendsSearchStringImmediately = true + field.placeholderString = String(localized: "Filter by name") + field.target = self + field.action = #selector(searchChanged(_:)) + field.stringValue = session.searchText + + let item = NSSearchToolbarItem(itemIdentifier: identifier) + item.label = String(localized: "Filter") + item.paletteLabel = String(localized: "Filter") + item.toolTip = String(localized: "Show only the objects whose name contains this text") + item.searchField = field + item.preferredWidthForSearchField = Self.searchFieldWidth + field.widthAnchor.constraint(lessThanOrEqualToConstant: Self.searchFieldWidth).isActive = true + item.visibilityPriority = .low + searchToolbarItem = item + return item + } + + private static let searchFieldWidth: CGFloat = 220 + + // MARK: - Actions + + @objc internal func runComparison(_ sender: Any?) { + runner.compare() + } + + @objc internal func generateScript(_ sender: Any?) { + runner.buildScript() + } + + @objc internal func applyToTarget(_ sender: Any?) { + presentApplySheet() + } + + @objc internal func swapEndpoints(_ sender: Any?) { + session.swapEndpoints() + endpointsChanged() + } + + /// Every path that changes an endpoint funnels here, because the toolbar's Source and Target + /// titles are rendered once and do not observe the session. Swap used to leave them naming the + /// old pair while the strip and the subtitle had already flipped, so the toolbar pointed at the + /// wrong database as the one about to be written to. + private func endpointsChanged() { + session.resetComparison() + endpointMenus.refreshTitles() + updateSubtitle() + } + + @objc internal func showOptions(_ sender: Any?) { + presentOptionsPopover(from: sender) + } + + @objc internal func stopComparison(_ sender: Any?) { + session.cancelRunningWork() + } + + /// Edit > Find > Find… already owns Command F and routes by nil target, so the Compare window + /// answers that command rather than declaring a second binding for the same idea: two menu + /// items sharing a key equivalent leaves one of them permanently dead. + /// `beginSearchInteraction` is AppKit's own entry point, and it works even once the item has + /// been clipped into the overflow menu. + @objc internal func performFind(_ sender: Any?) { + searchToolbarItem?.beginSearchInteraction() + } + + @objc private func modeChanged(_ sender: NSSegmentedControl) { + let index = sender.selectedSegment + guard CompareSyncMode.allCases.indices.contains(index) else { return } + adopt(CompareSyncMode.allCases[index]) + } + + @objc private func modePicked(_ sender: NSMenuItem) { + guard let raw = sender.representedObject as? String, + let mode = CompareSyncMode(rawValue: raw) else { return } + adopt(mode) + } + + private func adopt(_ mode: CompareSyncMode) { + guard session.mode != mode else { return } + session.mode = mode + session.resetComparison() + syncModeControl() + endpointMenus.refreshTitles() + } + + /// Only the Group By menu, found by identifier. Walking every `NSMenuToolbarItem` also reached + /// the Source and Target popups and rewrote their checkmarks from a grouping value. + @objc private func groupingChanged(_ sender: NSMenuItem) { + guard let raw = sender.representedObject as? String, + let grouping = CompareGrouping(rawValue: raw) else { return } + session.grouping = grouping + let item = window?.toolbar?.items.first { $0.itemIdentifier == .compareGrouping } + guard let menu = (item as? NSMenuToolbarItem)?.menu else { return } + for entry in menu.items { + entry.state = (entry.representedObject as? String) == raw ? .on : .off + } + } + + @objc private func searchChanged(_ sender: NSSearchField) { + session.searchText = sender.stringValue + } + + /// `NSWindow.subtitle` is "a secondary line of text that appears in the title bar": contextual + /// identity for the window, on the row it shares with toolbar items. It carried the whole + /// direction sentence, which restated the status strip word for word and spent titlebar width + /// the HIG reserves for controls. `WindowTitleResolver`, the app's own rule for every other + /// window, puts the compact scope binding there and nothing else. + private func updateSubtitle() { + guard let source = session.source, let target = session.target else { + window?.subtitle = "" + return + } + window?.subtitle = String( + format: String(localized: "%1$@ → %2$@"), + source.scopeDescription, target.scopeDescription + ) + } + + // MARK: - Validation + + /// Every toolbar item is also a menu-bar command, so both validate here. The HIG requires the + /// menu-bar mirror; this is the single place that decides whether either is available. + internal func validateUserInterfaceItem(_ item: any NSValidatedUserInterfaceItem) -> Bool { + let reason = disabledReason(for: item.action) + describe(item, reason: reason) + syncModeControl() + return reason == nil + } + + private func disabledReason(for action: Selector?) -> String? { + switch action { + case #selector(runComparison(_:)): + return session.compareDisabledReason + case #selector(generateScript(_:)): + return session.scriptDisabledReason + case #selector(applyToTarget(_:)): + return session.applyDisabledReason + case #selector(swapEndpoints(_:)): + guard session.canSwap else { return String(localized: "Choose a source or a target first.") } + return session.isBusy ? String(localized: "A comparison is already running.") : nil + case #selector(stopComparison(_:)): + return session.isBusy ? nil : String(localized: "Nothing is running.") + case #selector(performFind(_:)): + return searchToolbarItem == nil ? String(localized: "The filter field is not in the toolbar.") : nil + default: + return nil + } + } + + /// The tooltip is the reason when there is one and the item's purpose when there is not, which + /// is what the HIG means by a tooltip carrying "different text for a control's different + /// states" rather than repeating the control's name. + private func describe(_ item: any NSValidatedUserInterfaceItem, reason: String?) { + guard let toolbarItem = item as? NSToolbarItem else { return } + /// An identifier with no purpose of its own keeps whatever tooltip it was built with. The + /// Source and Target items carry the unshortened scope there, and blanking it would undo + /// the only place the full path is still readable. + guard let text = reason ?? purpose(of: toolbarItem.itemIdentifier) else { return } + toolbarItem.toolTip = text + } + + private func purpose(of identifier: NSToolbarItem.Identifier) -> String? { + switch identifier { + case .compareRun: return String(localized: "Compare the two databases") + case .compareGenerate: return String(localized: "Build the SQL that brings the target in line") + case .compareApply: return String(localized: "Run the script against the target") + case .compareSwap: return String(localized: "Make the target the source and the source the target") + case .compareOptions: return String(localized: "What to compare, and how") + default: return nil + } + } + + // MARK: - Sheets + + private func presentApplySheet() { + guard let window, session.canApply else { return } + let sheet = EscapeDismissingHostingController( + rootView: CompareApplySheetView(session: session) { [weak self] choice in + guard let self else { return } + self.dismissSheet() + guard choice == .apply else { return } + self.runner.apply() + } + .environment(\.appServices, .live) + ) + sheet.sizingOptions = [] + sheet.preferredContentSize = NSSize(width: 640, height: 520) + window.contentViewController?.presentAsSheet(sheet) + } + + /// The anchor used to be `(sender as? NSToolbarItem)?.view`, which is nil for a plain + /// image-and-action item, so the guard below it returned every time and the whole options + /// popover was unreachable. `PopoverPresenter` takes the item itself, and AppKit resolves the + /// anchor even when the item has been clipped into the overflow menu. + private func presentOptionsPopover(from sender: Any?) { + let item = (sender as? NSToolbarItem) + ?? window?.toolbar?.items.first { $0.itemIdentifier == .compareOptions } + guard let item else { return } + PopoverPresenter.show( + relativeTo: item, + contentSize: NSSize(width: 420, height: 520) + ) { _ in + CompareOptionsView(session: self.session) + .environment(\.appServices, .live) + } + } + + private func dismissSheet() { + guard let sheet = window?.contentViewController?.presentedViewControllers?.last else { return } + window?.contentViewController?.dismiss(sheet) + } + + // MARK: - NSWindowDelegate + + /// The controller owns the window's delegate, so the close guard lives here. The view this + /// replaces installed a proxy delegate from a background `NSViewRepresentable` to answer this + /// one message, which meant a SwiftUI view reaching around AppKit's ownership to do it. + internal func windowShouldClose(_ sender: NSWindow) -> Bool { + guard session.activity == .applying else { return true } + + let alert = NSAlert() + alert.alertStyle = .critical + alert.messageText = String(localized: "A sync is still running") + alert.informativeText = String( + localized: "Closing now stops the run between statements. Statements that already ran stay applied." + ) + alert.addButton(withTitle: String(localized: "Keep Running")) + alert.addButton(withTitle: String(localized: "Stop and Close")) + guard alert.runModal() == .alertSecondButtonReturn else { return false } + + session.cancelRunningWork() + return true + } + + internal func windowWillClose(_ notification: Notification) { + session.cancelRunningWork() + Self.controllers = Self.controllers.filter { $0.value !== self } + } +} + +/// Cancel is the sheet's default button, per the HIG's rule for a destructive confirmation, so it +/// carries the Return shortcut and cannot also carry Escape. A sheet still has to answer Escape, +/// which is what `cancelOperation` is. +@MainActor +private final class EscapeDismissingHostingController: NSHostingController { + override func cancelOperation(_ sender: Any?) { + presentingViewController?.dismiss(self) + } +} diff --git a/TablePro/Views/Compare/CompareWindowContentView.swift b/TablePro/Views/Compare/CompareWindowContentView.swift new file mode 100644 index 000000000..4c90d0852 --- /dev/null +++ b/TablePro/Views/Compare/CompareWindowContentView.swift @@ -0,0 +1,106 @@ +// +// CompareWindowContentView.swift +// TablePro +// +// The window's body: results on the left, detail on the right. +// +// There is no action bar under it. Compare, Generate Script and Apply live in +// the toolbar and in the Database > Compare menu, which is where the HIG puts +// a window's primary actions and the only place a user can reach them when the +// window's bottom edge is off screen. +// + +import SwiftUI + +internal struct CompareWindowContentView: View { + @Bindable internal var session: CompareSyncSession + internal var onCompare: () -> Void + internal var onGenerateScript: () -> Void + internal var onApply: () -> Void + + internal var body: some View { + AutosavingSplitView( + autosaveName: "com.TablePro.CompareSync.main", + primaryMinimum: 260, + secondaryMinimum: 360, + primaryThicknessFraction: 0.33, + primaryAutomaticMaximum: 576 + ) { + resultsPane + } secondary: { + CompareDetailView( + session: session, + onCompare: onCompare, + onGenerateScript: onGenerateScript + ) + } + /// The HIG's sanctioned use of a bottom bar, and the reason this one carries no action: + /// "use it only to display a small amount of information directly related to a window's + /// contents... For example, Finder uses a bottom bar (called the status bar) to display the + /// total number of items in a window, the number of selected items". + .safeAreaInset(edge: .bottom, spacing: 0) { + CompareStatusBar(session: session) + } + .frame(minWidth: 720, minHeight: 460) + } + + @ViewBuilder + private var resultsPane: some View { + switch session.mode { + case .structure: + CompareResultsView(session: session, onCompare: onCompare) + case .data: + CompareDataPlansView(session: session, onCompare: onCompare) + } + } +} + +/// The one line that says whether anything has been written, plus in-flight progress. +/// +/// It sits at the top rather than the bottom for the reason the HIG gives about bottom bars, and +/// it is deliberately not an action bar: it carries state and a Cancel, never a primary action. +internal struct CompareStatusStrip: View { + @Bindable internal var session: CompareSyncSession + + internal var body: some View { + HStack(spacing: 12) { + Label { + Text(session.bannerText) + } icon: { + Image(systemName: symbol) + } + .font(.callout) + + if let target = session.target { + Divider().frame(height: 14) + HStack(spacing: 6) { + Circle() + .fill(target.color.color) + .frame(width: 9, height: 9) + .accessibilityHidden(true) + Text(String(format: String(localized: "Target: %@"), target.qualifiedDescription)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer(minLength: 12) + + if session.isBusy { + CompareProgressView(session: session) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .contain) + } + + private var symbol: String { + switch session.activity { + case .applying: return "exclamationmark.triangle.fill" + case .comparing, .connecting: return "magnifyingglass" + case .idle: return session.hasWrittenToTarget ? "checkmark.circle" : "eye" + } + } +} diff --git a/TablePro/Views/Compare/StructureDefinitionDiffView.swift b/TablePro/Views/Compare/StructureDefinitionDiffView.swift new file mode 100644 index 000000000..ef4552412 --- /dev/null +++ b/TablePro/Views/Compare/StructureDefinitionDiffView.swift @@ -0,0 +1,145 @@ +// +// StructureDefinitionDiffView.swift +// TablePro +// +// Source and target definitions side by side. Both sides are rendered from +// parsed metadata through the same function, so only real differences show. +// Difference type carries a glyph as well as a colour. +// + +import SwiftUI + +internal struct StructureDefinitionDiffView: View { + internal let sourceLines: [String] + internal let targetLines: [String] + + @State private var isUnified = false + + @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor + + private var pairs: [DiffPair] { + DiffComputer.computeSplit(before: targetLines, after: sourceLines) + } + + internal var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Definition") + .font(.subheadline.weight(.semibold)) + Spacer() + Picker("", selection: $isUnified) { + Text("Split").tag(false) + Text("Unified").tag(true) + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + } + .padding(.bottom, 6) + + if isUnified { + unifiedBody + } else { + splitBody + } + } + } + + private var splitBody: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + columnHeader(String(localized: "Target")) + columnHeader(String(localized: "Source")) + } + ForEach(Array(pairs.enumerated()), id: \.offset) { _, pair in + HStack(spacing: 0) { + diffCell(pair.before, kind: pair.kind, isBefore: true) + diffCell(pair.after, kind: pair.kind, isBefore: false) + } + } + } + .background(RoundedRectangle(cornerRadius: 4).stroke(Color.secondary.opacity(0.2))) + } + + private var unifiedBody: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(DiffComputer.computeUnified(from: pairs)) { line in + HStack(spacing: 6) { + Text(marker(for: line.kind)) + .font(.system(.caption2, design: .monospaced)) + .frame(width: 12) + Text(line.text) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + Spacer(minLength: 0) + } + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(background(for: line.kind)) + } + } + .background(RoundedRectangle(cornerRadius: 4).stroke(Color.secondary.opacity(0.2))) + } + + private func columnHeader(_ title: String) -> some View { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 6) + .padding(.vertical, 3) + } + + private func diffCell(_ text: String?, kind: DiffPair.Kind, isBefore: Bool) -> some View { + HStack(spacing: 4) { + Text(glyph(for: kind, isBefore: isBefore)) + .font(.system(.caption2, design: .monospaced)) + .frame(width: 10) + Text(text ?? "") + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + Spacer(minLength: 0) + } + .padding(.horizontal, 6) + .padding(.vertical, 1) + .frame(maxWidth: .infinity, alignment: .leading) + .background(splitBackground(kind: kind, isBefore: isBefore, isEmpty: text == nil)) + } + + private func glyph(for kind: DiffPair.Kind, isBefore: Bool) -> String { + switch kind { + case .unchanged: return " " + case .changed: return "~" + case .added: return isBefore ? " " : "+" + case .removed: return isBefore ? "-" : " " + } + } + + private func marker(for kind: DiffUnifiedLine.Kind) -> String { + switch kind { + case .context: return " " + case .added: return "+" + case .removed: return "-" + } + } + + private func splitBackground(kind: DiffPair.Kind, isBefore: Bool, isEmpty: Bool) -> Color { + guard !differentiateWithoutColor else { return .clear } + guard !isEmpty else { return Color.secondary.opacity(0.05) } + switch kind { + case .unchanged: return .clear + case .changed: return CompareStatusStyle.rowTint(for: .update) + case .added: return isBefore ? .clear : CompareStatusStyle.rowTint(for: .insert) + case .removed: return isBefore ? CompareStatusStyle.rowTint(for: .delete) : .clear + } + } + + private func background(for kind: DiffUnifiedLine.Kind) -> Color { + guard !differentiateWithoutColor else { return .clear } + switch kind { + case .context: return .clear + case .added: return CompareStatusStyle.rowTint(for: .insert) + case .removed: return CompareStatusStyle.rowTint(for: .delete) + } + } +} diff --git a/TablePro/Views/Components/AutosavingSplitView.swift b/TablePro/Views/Components/AutosavingSplitView.swift index 443dbd79e..378e781a1 100644 --- a/TablePro/Views/Components/AutosavingSplitView.swift +++ b/TablePro/Views/Components/AutosavingSplitView.swift @@ -16,6 +16,14 @@ struct AutosavingSplitView: NSViewControllerRepr var primaryMinimum: CGFloat var primaryMaximum: CGFloat? var secondaryMinimum: CGFloat + + /// The share of the width the primary pane takes before the user has ever dragged the divider. + /// `NSSplitViewItem.contentListWithViewController` sets 0.33 for a list beside a detail view, + /// "akin to Mail's message list", and caps automatic sizing at 576pt so a wide display grows + /// the detail pane rather than the list. Nil keeps the previous behaviour, where the first + /// layout fell out of the minimums. + var primaryThicknessFraction: CGFloat? + var primaryAutomaticMaximum: CGFloat? var primaryHoldingPriority: NSLayoutConstraint.Priority = .splitPaneHolding var collapsesPrimaryWhenTight = false @ViewBuilder let primary: () -> Primary @@ -48,6 +56,12 @@ struct AutosavingSplitView: NSViewControllerRepr if let primaryMaximum { primaryItem.maximumThickness = primaryMaximum } + if let primaryThicknessFraction { + primaryItem.preferredThicknessFraction = primaryThicknessFraction + } + if let primaryAutomaticMaximum { + primaryItem.automaticMaximumThickness = primaryAutomaticMaximum + } let secondaryItem = NSSplitViewItem(viewController: secondaryController) secondaryItem.minimumThickness = secondaryMinimum diff --git a/TablePro/Views/Connection/WelcomeContextMenus.swift b/TablePro/Views/Connection/WelcomeContextMenus.swift index b3710c786..0944d0a91 100644 --- a/TablePro/Views/Connection/WelcomeContextMenus.swift +++ b/TablePro/Views/Connection/WelcomeContextMenus.swift @@ -154,6 +154,12 @@ extension WelcomeWindowView { Divider() + Button { CompareSyncLauncher.open(prefillSource: connection.id) } label: { + Label(String(localized: "Compare/Sync with…"), systemImage: "arrow.left.arrow.right.square") + } + + Divider() + Button { vm.toggleFavorite([connection]) } label: { Label( connection.isFavorite diff --git a/TableProTests/Core/Compare/CompareCapabilityDeclarationTests.swift b/TableProTests/Core/Compare/CompareCapabilityDeclarationTests.swift new file mode 100644 index 000000000..7f8e5f03e --- /dev/null +++ b/TableProTests/Core/Compare/CompareCapabilityDeclarationTests.swift @@ -0,0 +1,58 @@ +// +// CompareCapabilityDeclarationTests.swift +// TableProTests +// +// Compare & Sync is gated on `.schemaCompare` and `.dataCompare`, and both bits shipped for two +// releases with no driver setting either, so the feature refused every connection it was offered. +// A driver lives in a plugin bundle the test target cannot import, so the declaration is checked by +// reading the driver's source. +// + +import Foundation +import Testing + +@Suite("Compare capability declaration") +struct CompareCapabilityDeclarationTests { + private static let repositoryRoot: URL = { + var url = URL(fileURLWithPath: #filePath) + for _ in 0 ..< 4 { + url.deleteLastPathComponent() + } + return url + }() + + private static let bundledSQLDriverSources = [ + "Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift", + "Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift", + "Plugins/SQLiteDriverPlugin/SQLitePlugin.swift", + ] + + private func capabilitiesDeclaration(in source: String) -> String? { + guard let start = source.range(of: "var capabilities: PluginCapabilities") else { return nil } + let body = source[start.upperBound...] + guard let end = body.range(of: "\n }") else { return nil } + return String(body[.. DataComparePlan { + DataComparePlan( + table: table, + schema: "public", + columns: ["id", "name"], + keyColumns: ["id"], + isEnabled: false, + unavailableReason: unavailableReason, + summary: summary + ) + } + + private func summary(inserts: Int = 0, updates: Int = 0, deletes: Int = 0, identical: Int = 0) -> DataDiffSummary { + DataDiffSummary( + insertCount: inserts, + updateCount: updates, + deleteCount: deletes, + identicalCount: identical, + skippedNullKeyCount: 0, + entries: [], + truncatedEntries: false + ) + } + + // MARK: - Search + + func testSearchMatchesTableNamesCaseInsensitively() { + let matched = CompareDataPlanGrouping.matching( + [plan("orders"), plan("customers"), plan("order_items")], + searchText: "ORDER" + ) + + XCTAssertEqual(matched.map { $0.id }, ["public.orders", "public.order_items"]) + } + + func testAnEmptySearchKeepsEveryPlan() { + let plans = [plan("orders"), plan("customers")] + + XCTAssertEqual(CompareDataPlanGrouping.matching(plans, searchText: " ").count, 2) + XCTAssertEqual(CompareDataPlanGrouping.matching(plans, searchText: "").count, 2) + } + + func testSearchMatchesTheQualifiedName() { + let matched = CompareDataPlanGrouping.matching([plan("orders")], searchText: "public.") + + XCTAssertEqual(matched.count, 1) + } + + // MARK: - Sections + + func testGroupingByDifferenceSeparatesEveryOutcome() { + let groups = CompareDataPlanGrouping.groups( + from: [ + plan("orders", summary: summary(inserts: 3)), + plan("customers", summary: summary(identical: 12)), + plan("audit"), + plan("legacy", unavailableReason: "No primary key.") + ], + grouping: .byDifference, + sortedUsing: comparators + ) + + XCTAssertEqual(groups.count, 4) + XCTAssertEqual(groups.flatMap { $0.rows }.count, 4) + XCTAssertEqual(groups.map { $0.rows.count }, [1, 1, 1, 1]) + } + + /// A plan nobody has run and a plan that matched are different answers. Folding them together + /// would report a table that was never compared as identical. + func testAPlanThatWasNeverComparedIsNotReportedAsIdentical() { + let groups = CompareDataPlanGrouping.groups( + from: [plan("customers", summary: summary(identical: 4)), plan("audit")], + grouping: .byDifference, + sortedUsing: comparators + ) + + XCTAssertEqual(groups.count, 2) + XCTAssertTrue(groups.allSatisfy { $0.rows.count == 1 }, "the two outcomes never share a section") + XCTAssertEqual(Set(groups.map { $0.header.id }).count, 2) + } + + func testAnEmptyBucketProducesNoSection() { + let groups = CompareDataPlanGrouping.groups( + from: [plan("orders", summary: summary(inserts: 1))], + grouping: .byDifference, + sortedUsing: comparators + ) + + XCTAssertEqual(groups.count, 1) + } + + /// Every plan is a table, so this grouping is one section rather than none. The control stays + /// live and says what it grouped by instead of looking broken in one of the two modes. + func testGroupingByObjectTypeProducesOneSection() { + let groups = CompareDataPlanGrouping.groups( + from: [plan("orders"), plan("customers")], + grouping: .byObjectType, + sortedUsing: comparators + ) + + XCTAssertEqual(groups.count, 1) + XCTAssertEqual(groups.first?.rows.count, 2) + } + + func testNoGroupingProducesFlatRows() { + let rows = CompareDataPlanGrouping.rows( + from: [plan("orders"), plan("customers")], sortedUsing: comparators + ) + let groups = CompareDataPlanGrouping.groups( + from: [plan("orders"), plan("customers")], grouping: .none, sortedUsing: comparators + ) + + XCTAssertEqual(rows.map { $0.tableName }, ["public.customers", "public.orders"]) + XCTAssertTrue(rows.allSatisfy { !$0.isGroup }) + XCTAssertTrue(groups.isEmpty) + } + + // MARK: - Bulk include + + func testGroupMembersExcludeUnreadablePlans() { + let groups = CompareDataPlanGrouping.groups( + from: [plan("orders"), plan("legacy", unavailableReason: "No primary key.")], + grouping: .byObjectType, + sortedUsing: comparators + ) + + let members = groups.flatMap { $0.header.memberIds } + XCTAssertEqual(members, ["public.orders"]) + } + + func testSelectingAGroupHeaderExpandsToItsMembers() { + let groups = CompareDataPlanGrouping.groups( + from: [plan("orders"), plan("customers")], + grouping: .byObjectType, + sortedUsing: comparators + ) + let header = groups.first?.header.id ?? "" + + let ids = CompareDataPlanGrouping.planIds(in: [header], groups: groups) + + XCTAssertEqual(ids.sorted(), ["public.customers", "public.orders"]) + } + + func testSelectingPlansPassesTheirOwnIdentifiersThrough() { + let ids = CompareDataPlanGrouping.planIds(in: ["public.orders"], groups: []) + + XCTAssertEqual(ids, ["public.orders"]) + } + + func testAGroupIdentifierCannotCollideWithAPlanIdentifier() { + let groups = CompareDataPlanGrouping.groups( + from: [plan("orders")], grouping: .byObjectType, sortedUsing: comparators + ) + + XCTAssertTrue(CompareDataPlanGrouping.isGroupIdentifier(groups[0].header.id)) + XCTAssertFalse(CompareDataPlanGrouping.isGroupIdentifier(plan("orders").id)) + } + + // MARK: - Header counts + + func testAGroupHeaderSumsItsMembersCounts() { + let groups = CompareDataPlanGrouping.groups( + from: [ + plan("orders", summary: summary(inserts: 2, updates: 1, deletes: 0, identical: 5)), + plan("customers", summary: summary(inserts: 3, updates: 0, deletes: 4, identical: 6)) + ], + grouping: .byObjectType, + sortedUsing: comparators + ) + let header = groups.first?.header + + XCTAssertEqual(header?.insertCount, 5) + XCTAssertEqual(header?.updateCount, 1) + XCTAssertEqual(header?.deleteCount, 4) + XCTAssertEqual(header?.identicalCount, 11) + } + + /// Nothing compared means no number, not a zero: the count columns draw a blank for a table + /// nobody ran, and a header has to say the same thing rather than claim it found none. + func testAGroupWithNothingComparedCarriesNoCounts() { + let groups = CompareDataPlanGrouping.groups( + from: [plan("orders"), plan("customers")], + grouping: .byObjectType, + sortedUsing: comparators + ) + let header = groups.first?.header + + XCTAssertNil(header?.insertCount) + XCTAssertNil(header?.identicalCount) + } + + func testAPlanRowCarriesItsOwnCounts() { + let rows = CompareDataPlanGrouping.rows( + from: [plan("orders", summary: summary(inserts: 2, identical: 7))], sortedUsing: comparators + ) + + XCTAssertEqual(rows.first?.insertCount, 2) + XCTAssertEqual(rows.first?.identicalCount, 7) + XCTAssertNotNil(rows.first?.plan) + } +} diff --git a/TableProTests/Core/Compare/CompareEndpointPickerModelTests.swift b/TableProTests/Core/Compare/CompareEndpointPickerModelTests.swift new file mode 100644 index 000000000..c6b43a792 --- /dev/null +++ b/TableProTests/Core/Compare/CompareEndpointPickerModelTests.swift @@ -0,0 +1,143 @@ +// +// CompareEndpointPickerModelTests.swift +// TableProTests +// +// The chooser's loading rules. Each of these was a defect in the NSMenu this +// replaced, where a failed connect was cached as an empty database list and +// every submenu open refetched. +// + +@testable import TablePro +import XCTest + +@MainActor +final class CompareEndpointPickerModelTests: XCTestCase { + private struct Unreachable: LocalizedError { + var errorDescription: String? { "Connection refused" } + } + + private func connection(name: String = "Prod") -> DatabaseConnection { + DatabaseConnection(name: name, type: .postgresql) + } + + private func model( + databases: @escaping (DatabaseConnection) async throws -> [String] = { _ in [] }, + schemas: @escaping (CompareSyncEndpoint, DatabaseConnection) async throws -> [String] = { _, _ in [] } + ) -> CompareEndpointPickerModel { + CompareEndpointPickerModel(databaseLoader: databases, schemaLoader: schemas) + } + + func testAnUnopenedConnectionReportsLoadingRatherThanAnEmptyList() { + XCTAssertEqual(model().databases(for: UUID()), .loading) + } + + func testDatabasesAreLoadedOnce() async { + let calls = Counter() + let subject = model(databases: { _ in + calls.increment() + return ["orders"] + }) + let connection = connection() + + await subject.loadDatabases(for: connection) + await subject.loadDatabases(for: connection) + + XCTAssertEqual(calls.value, 1, "reopening the chooser must not refetch") + XCTAssertEqual(subject.databases(for: connection.id), .loaded(["orders"])) + } + + /// A server that refused the connection is not a server with no databases. Caching the failure + /// as `[]` is what told users their database list was empty, with no way to retry. + func testAFailedLoadIsRememberedAsAFailure() async { + let subject = model(databases: { _ in throw Unreachable() }) + let connection = connection() + + await subject.loadDatabases(for: connection) + + XCTAssertEqual(subject.databases(for: connection.id), .failed("Connection refused")) + } + + func testAFailedLoadCanBeRetriedWithoutAskingForAReload() async { + let calls = Counter() + let subject = model(databases: { _ in + calls.increment() + guard calls.value > 1 else { throw Unreachable() } + return ["orders"] + }) + let connection = connection() + + await subject.loadDatabases(for: connection) + await subject.loadDatabases(for: connection) + + XCTAssertEqual(subject.databases(for: connection.id), .loaded(["orders"])) + } + + /// `CLAUDE.md`: a refresh never clears the cache it is refreshing. The list stays on screen + /// while the reload runs, so Try Again cannot blank a pane that already had an answer. + func testAReloadKeepsTheLoadedListUntilTheNewOneArrives() async { + let gate = AsyncGate() + let subject = model(databases: { _ in + await gate.wait() + return ["orders", "audit"] + }) + let connection = connection() + + await gate.open() + await subject.loadDatabases(for: connection) + await gate.close() + + let reload = Task { await subject.loadDatabases(for: connection, reload: true) } + await Task.yield() + XCTAssertEqual(subject.databases(for: connection.id), .loaded(["orders", "audit"])) + + await gate.open() + await reload.value + XCTAssertEqual(subject.databases(for: connection.id), .loaded(["orders", "audit"])) + } + + func testSchemasAreKeyedPerDatabaseRatherThanPerConnection() async { + let subject = model(schemas: { endpoint, _ in [endpoint.database + "_schema"] }) + let connection = connection() + let orders = CompareSyncEndpoint.from(connection: connection, database: "orders") + let audit = CompareSyncEndpoint.from(connection: connection, database: "audit") + + await subject.loadSchemas(for: orders, connection: connection) + await subject.loadSchemas(for: audit, connection: connection) + + XCTAssertEqual(subject.schemas(for: orders), .loaded(["orders_schema"])) + XCTAssertEqual(subject.schemas(for: audit), .loaded(["audit_schema"])) + } +} + +private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { + lock.withLock { count } + } + + func increment() { + lock.withLock { count += 1 } + } +} + +private actor AsyncGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func open() { + isOpen = true + for waiter in waiters { waiter.resume() } + waiters.removeAll() + } + + func close() { + isOpen = false + } + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { waiters.append($0) } + } +} diff --git a/TableProTests/Core/Compare/CompareObjectScopeTests.swift b/TableProTests/Core/Compare/CompareObjectScopeTests.swift new file mode 100644 index 000000000..1fe05b2ed --- /dev/null +++ b/TableProTests/Core/Compare/CompareObjectScopeTests.swift @@ -0,0 +1,157 @@ +// +// CompareObjectScopeTests.swift +// TableProTests +// +// Two things the comparison used to get wrong before an endpoint was a scope and an object had a +// kind: it could not address two databases on one server, and `fetchTables` reports views +// alongside tables so a view reached the table paths and produced CREATE TABLE for a view. +// + +@testable import TablePro +import TableProPluginKit +import XCTest + +final class CompareSyncEndpointScopeTests: XCTestCase { + private let connectionId = UUID() + + private func endpoint(database: String, schema: String? = nil) -> CompareSyncEndpoint { + CompareSyncEndpoint( + scope: DatabaseScope(connectionId: connectionId, database: database, schema: schema), + connectionName: "server", + databaseType: .postgresql, + safeModeLevel: .silent, + color: .blue + ) + } + + /// `canCompare` used to key on the connection id, which made this pair identical and refused + /// the comparison the issue asks for by name: two versions of a schema on one server. + func testTwoDatabasesOnOneConnectionAreDistinctEndpoints() { + XCTAssertNotEqual(endpoint(database: "app_prod").id, endpoint(database: "app_staging").id) + } + + func testTwoSchemasInOneDatabaseAreDistinctEndpoints() { + XCTAssertNotEqual( + endpoint(database: "app", schema: "public").id, + endpoint(database: "app", schema: "sales").id + ) + } + + func testTheSameScopeIsTheSameEndpoint() { + XCTAssertEqual(endpoint(database: "app", schema: "public").id, endpoint(database: "app", schema: "public").id) + } + + func testQualifiedDescriptionNamesEveryLevelThatIsSet() { + XCTAssertEqual(endpoint(database: "app", schema: "public").qualifiedDescription, "server / app / public") + XCTAssertEqual(endpoint(database: "app").qualifiedDescription, "server / app") + XCTAssertEqual(endpoint(database: "").qualifiedDescription, "server") + } + + func testChangingDatabaseClearsTheSchema() { + let moved = endpoint(database: "app", schema: "public").withDatabase("other") + + XCTAssertEqual(moved.database, "other") + XCTAssertNil(moved.schema) + } + + func testReadOnlyEndpointIsRefusedAsATarget() { + let readOnly = CompareSyncEndpoint( + scope: DatabaseScope(connectionId: connectionId, database: "prod", schema: nil), + connectionName: "prod", + databaseType: .postgresql, + safeModeLevel: .readOnly, + color: .red + ) + + XCTAssertFalse(readOnly.canBeWrittenTo) + XCTAssertNotNil(readOnly.ineligibleAsTargetReason) + } +} + +final class CompareTableKindClassifierTests: XCTestCase { + private func table(_ type: String) -> PluginTableInfo { + PluginTableInfo(name: "orders", type: type, schema: "public", comment: nil) + } + + func testViewsAreRecognisedHoweverTheEngineSpellsThem() { + for spelling in ["VIEW", "view", "SYSTEM VIEW", "BASE VIEW"] { + XCTAssertEqual(CompareTableKindClassifier.kind(of: table(spelling)), .view, spelling) + } + } + + func testMaterializedViewsAreTheirOwnKind() { + for spelling in ["MATERIALIZED VIEW", "MATERIALIZED_VIEW", "materialized view"] { + XCTAssertEqual(CompareTableKindClassifier.kind(of: table(spelling)), .materializedView, spelling) + } + } + + /// PostgreSQL reports PARTITIONED TABLE for an ordinary, directly queryable table. Matching the + /// literal string TABLE would have silently dropped it from every comparison, which is why the + /// rule is subtractive. + func testPartitionedAndForeignTablesStillCompareAsTables() { + for spelling in ["TABLE", "BASE TABLE", "PARTITIONED TABLE", "FOREIGN TABLE", "LOCAL TEMPORARY"] { + XCTAssertEqual(CompareTableKindClassifier.kind(of: table(spelling)), .table, spelling) + } + } + + func testAForeignTableIsFlaggedSeparatelyFromItsKind() { + XCTAssertTrue(CompareTableKindClassifier.isForeign(table("FOREIGN TABLE"))) + XCTAssertFalse(CompareTableKindClassifier.isForeign(table("BASE TABLE"))) + } + + func testOnlyATableCarriesRows() { + XCTAssertTrue(CompareObjectKind.table.carriesRows) + for kind in CompareObjectKind.allCases where kind != .table { + XCTAssertFalse(kind.carriesRows, "\(kind.rawValue) holds no rows a data compare can walk") + } + } +} + +final class DataComparePlanColumnTests: XCTestCase { + private func plan(generated: Set, keys: [String] = ["id"]) -> DataComparePlan { + DataComparePlan( + table: "orders", + schema: "public", + columns: ["id", "total", "line_total"], + columnDescriptors: [ + KeyColumnDescriptor(name: "id", dataType: "bigint"), + KeyColumnDescriptor(name: "total", dataType: "numeric"), + KeyColumnDescriptor(name: "line_total", dataType: "numeric") + ], + generatedColumns: generated, + keyColumns: keys, + isEnabled: true + ) + } + + /// MySQL rejects an explicit value for a generated column outright and PostgreSQL rejects it + /// for a stored one, so it is read and compared but never written. + func testGeneratedColumnsAreReadButNotWritten() { + let plan = plan(generated: ["line_total"]) + + XCTAssertEqual(plan.readColumns, ["id", "total", "line_total"]) + XCTAssertEqual(plan.writeColumns, ["id", "total"]) + } + + func testGeneratedColumnMatchIsCaseInsensitive() { + XCTAssertEqual(plan(generated: ["LINE_TOTAL"]).writeColumns, ["id", "total"]) + } + + func testATableWhoseSharedColumnsAreAllGeneratedIsNotComparable() { + let allGenerated = plan(generated: ["id", "total", "line_total"]) + + XCTAssertNotNil(DataComparePlan.unavailableReason(for: allGenerated)) + } + + func testKeyDescriptorsCarryOnlyTheKeyColumns() { + XCTAssertEqual(plan(generated: []).keyDescriptors.map { $0.name }, ["id"]) + } + + func testATableWithNoKeyIsNotComparable() { + XCTAssertNotNil(DataComparePlan.unavailableReason(for: plan(generated: [], keys: []))) + } + + func testAKeyMissingFromTheSharedColumnsIsNotComparable() { + XCTAssertNotNil(DataComparePlan.unavailableReason(for: plan(generated: [], keys: ["tenant_id"]))) + } +} diff --git a/TableProTests/Core/Compare/CompareResultGroupingTests.swift b/TableProTests/Core/Compare/CompareResultGroupingTests.swift new file mode 100644 index 000000000..68f9e1e79 --- /dev/null +++ b/TableProTests/Core/Compare/CompareResultGroupingTests.swift @@ -0,0 +1,164 @@ +// +// CompareResultGroupingTests.swift +// TableProTests +// +// The results list had a "Group by" picker that only re-sorted: both of its cases returned a flat +// array, so choosing "Object Type" changed the row order and produced no sections at all. These +// pin that grouping is real, and that a group header expands to exactly the objects a bulk include +// should touch. +// + +@testable import TablePro +import XCTest + +final class CompareResultGroupingTests: XCTestCase { + private let comparators = [KeyPathComparator(\CompareResultRow.objectName)] + + private func result( + _ name: String, + kind: CompareObjectKind = .table, + status: TableDiffStatus = .differs, + error: String? = nil + ) -> CompareObjectResult { + CompareObjectResult( + identity: CompareObjectIdentity(kind: kind, schema: "public", name: name), + status: status, + comparisonError: error + ) + } + + // MARK: - Sections + + func testGroupingByDifferenceProducesOneSectionPerStatus() { + let groups = CompareResultGrouping.groups( + from: [ + result("a", status: .onlyInSource), + result("b", status: .onlyInTarget), + result("c", status: .differs) + ], + grouping: .byDifference, + sortedUsing: comparators + ) + + XCTAssertEqual(groups.count, 3) + XCTAssertEqual(groups.flatMap { $0.rows }.count, 3) + } + + func testGroupingByObjectTypeProducesOneSectionPerKind() { + let groups = CompareResultGrouping.groups( + from: [ + result("orders", kind: .table), + result("reporting", kind: .view), + result("audit", kind: .function) + ], + grouping: .byObjectType, + sortedUsing: comparators + ) + + XCTAssertEqual(groups.count, 3) + XCTAssertEqual(Set(groups.map { $0.header.objectName }).count, 3) + } + + func testTheTwoGroupingsSectionTheSameResultsDifferently() { + let results = [ + result("orders", kind: .table, status: .differs), + result("reporting", kind: .view, status: .differs) + ] + + let byDifference = CompareResultGrouping.groups( + from: results, grouping: .byDifference, sortedUsing: comparators + ) + let byKind = CompareResultGrouping.groups( + from: results, grouping: .byObjectType, sortedUsing: comparators + ) + + XCTAssertEqual(byDifference.count, 1, "both differ, so one difference section") + XCTAssertEqual(byKind.count, 2, "two kinds, so two kind sections") + } + + func testAnEmptyBucketProducesNoSection() { + let groups = CompareResultGrouping.groups( + from: [result("orders", status: .differs)], + grouping: .byDifference, + sortedUsing: comparators + ) + + XCTAssertEqual(groups.count, 1) + } + + func testNoGroupingProducesFlatRows() { + let rows = CompareResultGrouping.rows( + from: [result("b"), result("a")], sortedUsing: comparators + ) + + XCTAssertEqual(rows.map { $0.objectName }, ["public.a", "public.b"]) + XCTAssertTrue(rows.allSatisfy { !$0.isGroup }) + } + + // MARK: - Bulk include + + /// A group header's include checkbox must not reach an identical object, whose only available + /// action is skip, nor one that could not be compared at all. + func testGroupMembersExcludeIdenticalAndUncomparableResults() { + let groups = CompareResultGrouping.groups( + from: [ + result("a", status: .differs), + result("b", status: .identical), + result("c", status: .differs, error: "permission denied") + ], + grouping: .byObjectType, + sortedUsing: comparators + ) + + let members = groups.flatMap { $0.header.memberIds } + XCTAssertEqual(members.count, 1) + XCTAssertTrue(members[0].contains("a")) + } + + func testSelectingAGroupHeaderExpandsToItsMembers() { + let groups = CompareResultGrouping.groups( + from: [result("a"), result("b")], + grouping: .byObjectType, + sortedUsing: comparators + ) + let header = try? XCTUnwrap(groups.first).header + + let ids = CompareResultGrouping.objectIds(in: [header?.id ?? ""], groups: groups) + + XCTAssertEqual(ids.count, 2) + } + + func testSelectingObjectsPassesTheirOwnIdentifiersThrough() { + let orders = result("orders") + let ids = CompareResultGrouping.objectIds(in: [orders.id], groups: []) + + XCTAssertEqual(ids, [orders.id]) + } + + /// A group header shares the table's row type, so its identifier has to be one no object can + /// produce, or a bulk include would resolve to a nonexistent object. + func testAGroupIdentifierCannotCollideWithAnObjectIdentifier() { + let groups = CompareResultGrouping.groups( + from: [result("a")], grouping: .byObjectType, sortedUsing: comparators + ) + + XCTAssertTrue(CompareResultGrouping.isGroupIdentifier(groups[0].header.id)) + XCTAssertFalse(CompareResultGrouping.isGroupIdentifier(result("a").id)) + } + + // MARK: - Uncomparable + + func testUncomparableResultsGetTheirOwnSection() { + let group = CompareResultGrouping.uncomparableGroup( + from: [result("a", error: "permission denied")], sortedUsing: comparators + ) + + XCTAssertNotNil(group) + XCTAssertEqual(group?.rows.count, 1) + XCTAssertTrue(group?.header.memberIds.isEmpty ?? false, "nothing unreadable can be included") + } + + func testNoUncomparableSectionWhenEverythingCompared() { + XCTAssertNil(CompareResultGrouping.uncomparableGroup(from: [], sortedUsing: comparators)) + } +} diff --git a/TableProTests/Core/Compare/CompareReviewFindingTests.swift b/TableProTests/Core/Compare/CompareReviewFindingTests.swift new file mode 100644 index 000000000..27e54937e --- /dev/null +++ b/TableProTests/Core/Compare/CompareReviewFindingTests.swift @@ -0,0 +1,218 @@ +// +// CompareReviewFindingTests.swift +// TableProTests +// +// One test per defect a review of this feature turned up. Each fails against the code as it stood +// before the fix, which is the only reason to keep them together in one file. +// + +@testable import TablePro +import TableProPluginKit +import XCTest + +final class CompareRetentionCapTests: XCTestCase { + private func engine(limit: Int) -> DataDiffEngine { + var options = DataCompareOptions() + options.keyColumns = ["id"] + options.maxRetainedEntries = limit + return DataDiffEngine( + options: options, + columns: ["id", "name"], + keyDescriptors: [KeyColumnDescriptor(name: "id", dataType: "int")] + ) + } + + private func row(_ id: Int, name: String) -> DataRow { + DataRow(values: ["id": .text(String(format: "%06d", id)), "name": .text(name)]) + } + + /// Identical rows used to consume the retained list, so a table with thousands of matches and + /// a difference at the end reported the difference in the count and listed nothing. + func testIdenticalRowsDoNotCrowdDifferencesOutOfTheRetainedList() async throws { + var source: [DataRow] = (1 ... 50).map { row($0, name: "same") } + var target: [DataRow] = (1 ... 50).map { row($0, name: "same") } + source.append(row(51, name: "changed")) + target.append(row(51, name: "original")) + + let summary = try await engine(limit: 10).compare( + source: ArrayRowProvider(rows: source), + target: ArrayRowProvider(rows: target) + ) + + XCTAssertEqual(summary.identicalCount, 50, "matches are still counted exactly") + XCTAssertEqual(summary.updateCount, 1) + XCTAssertEqual(summary.entries.count, 1, "only the difference is retained") + XCTAssertEqual(summary.entries[0].kind, .update) + XCTAssertFalse(summary.truncatedEntries, "one difference is well inside the cap") + } + + func testTheCapStillAppliesToDifferences() async throws { + let source = (1 ... 20).map { row($0, name: "n") } + + let summary = try await engine(limit: 5).compare( + source: ArrayRowProvider(rows: source), + target: ArrayRowProvider(rows: []) + ) + + XCTAssertEqual(summary.insertCount, 20) + XCTAssertEqual(summary.entries.count, 5) + XCTAssertTrue(summary.truncatedEntries) + } +} + +final class CompareKeyIdentityTests: XCTestCase { + /// Excluding one row from a sync keys on this string, so two different composite keys rendering + /// the same silently excluded the wrong row. + func testTwoCompositeKeysThatReadAlikeHaveDistinctIdentities() { + let first: [PluginCellValue] = [.text("a"), .text("b, c")] + let second: [PluginCellValue] = [.text("a, b"), .text("c")] + + XCTAssertEqual(KeyOrdering.description(of: first), KeyOrdering.description(of: second)) + XCTAssertNotEqual(KeyOrdering.identity(of: first), KeyOrdering.identity(of: second)) + } + + func testTheSameKeyAlwaysHasTheSameIdentity() { + let key: [PluginCellValue] = [.text("tenant"), .text("42")] + + XCTAssertEqual(KeyOrdering.identity(of: key), KeyOrdering.identity(of: key)) + } + + func testASingleKeyIdentityMatchesItsDescription() { + XCTAssertEqual(KeyOrdering.identity(of: [.text("42")]), KeyOrdering.description(of: [.text("42")])) + } + + func testAnEntryCarriesBothTheReadableKeyAndItsIdentity() { + let entry = RowDiffEntry( + kind: .insert, + keyDescription: "a, b", + keyIdentity: "a\u{1F}b", + sourceRow: nil, + targetRow: nil + ) + + XCTAssertEqual(entry.keyDescription, "a, b") + XCTAssertEqual(entry.keyIdentity, "a\u{1F}b") + } + + func testAnEntryWithNoExplicitIdentityFallsBackToItsDescription() { + let entry = RowDiffEntry(kind: .insert, keyDescription: "42", sourceRow: nil, targetRow: nil) + + XCTAssertEqual(entry.keyIdentity, "42") + } +} + +final class CompareSchemaMatchingTests: XCTestCase { + private func snapshot(_ name: String, schema: String?) -> TableStructureSnapshot { + TableStructureSnapshot( + name: name, + schema: schema, + columns: [ + EditableColumnDefinition( + id: UUID(), + name: "id", + dataType: "int", + isNullable: false, + defaultValue: nil, + autoIncrement: false, + unsigned: false, + comment: nil, + collation: nil, + onUpdate: nil, + charset: nil, + extra: nil, + isPrimaryKey: true + ) + ] + ) + } + + /// Matching on the bare name collapsed two schemas' same-named tables onto one result, diffed + /// both against whichever target arrived first, and never reported the other as target-only. + func testTwoSchemasSharingATableNameAreComparedSeparately() { + let report = StructureDiffEngine().compare( + source: [snapshot("users", schema: "public"), snapshot("users", schema: "audit")], + target: [snapshot("users", schema: "public")] + ) + + XCTAssertEqual(report.results.count, 2) + XCTAssertEqual(report.count(of: .identical), 1) + XCTAssertEqual(report.count(of: .onlyInSource), 1) + } + + func testATargetOnlyTableInASecondSchemaIsReported() { + let report = StructureDiffEngine().compare( + source: [snapshot("users", schema: "public")], + target: [snapshot("users", schema: "public"), snapshot("users", schema: "audit")] + ) + + XCTAssertEqual(report.count(of: .onlyInTarget), 1) + } + + func testASchemalessEngineStillMatchesOnNameAlone() { + let report = StructureDiffEngine().compare( + source: [snapshot("users", schema: nil)], + target: [snapshot("users", schema: nil)] + ) + + XCTAssertEqual(report.count(of: .identical), 1) + } + + func testMatchKeyIgnoresSchemaWhenOnlyOneSideHasOne() { + let options = StructureCompareOptions.default + + XCTAssertEqual(options.matchKey(name: "users", schema: nil), options.matchKey("users")) + XCTAssertNotEqual( + options.matchKey(name: "users", schema: "audit"), + options.matchKey(name: "users", schema: "public") + ) + } +} + +final class CompareRunResultTests: XCTestCase { + /// A commit that threw used to propagate past the whole run, so the result was discarded and + /// the user got an error with no record of which statements had already executed. + func testACommitFailureIsCarriedOnTheResultRatherThanReplacingIt() { + let statement = SyncStatement(sql: "A;", objectName: "t", summary: "A") + let result = CompareSyncRunResult( + outcomes: [SyncStatementOutcome(id: statement.id, statement: statement, error: nil, wasSkipped: false)], + rolledBack: false, + cancelled: false, + commitFailure: "deadlock detected" + ) + + XCTAssertEqual(result.executedCount, 1, "the per-statement record survives a failed commit") + XCTAssertEqual(result.commitFailure, "deadlock detected") + } + + func testASuccessfulRunCarriesNoCommitFailure() { + let result = CompareSyncRunResult(outcomes: [], rolledBack: false, cancelled: false) + + XCTAssertNil(result.commitFailure) + } +} + +final class CompareDataPlanSchemaTests: XCTestCase { + /// The target used to be read with the source's schema name, so a comparison of `audit.users` + /// took `public.users`' columns while reading `audit.users`' rows. + func testAPlanRemembersTheTargetsOwnSchema() { + let plan = DataComparePlan( + table: "users", + schema: "public", + targetSchema: "audit", + columns: ["id"], + keyColumns: ["id"], + isEnabled: true + ) + + XCTAssertEqual(plan.schema, "public") + XCTAssertEqual(plan.targetSchema, "audit") + } + + func testAPlanWithNoTargetSchemaMirrorsTheSource() { + let plan = DataComparePlan( + table: "users", schema: "public", columns: ["id"], keyColumns: ["id"], isEnabled: true + ) + + XCTAssertEqual(plan.targetSchema, "public") + } +} diff --git a/TableProTests/Core/Compare/CompareSQLLiteralTests.swift b/TableProTests/Core/Compare/CompareSQLLiteralTests.swift new file mode 100644 index 000000000..b48b5f3c3 --- /dev/null +++ b/TableProTests/Core/Compare/CompareSQLLiteralTests.swift @@ -0,0 +1,54 @@ +// +// CompareSQLLiteralTests.swift +// TableProTests +// +// The PluginKit default renders binary as `X'89504E47'`, a bit-string literal. MySQL, MariaDB, +// SQLite and ClickHouse accept it. PostgreSQL rejects it with "column is of type bytea but +// expression is of type bit", SQL Server wants `0x`, and Oracle wants `HEXTORAW`. No shipped +// driver overrides `sqlLiteral(for:)`, so the spelling is decided per engine here. +// + +@testable import TablePro +import XCTest + +final class CompareSQLLiteralTests: XCTestCase { + private let png = Data([0x89, 0x50, 0x4E, 0x47]) + + func testBitStringEnginesKeepTheDefaultSpelling() throws { + for type in [DatabaseType.mysql, .mariadb, .sqlite, .clickhouse, .duckdb, .libsql, .turso, .cloudflareD1] { + let literal = try XCTUnwrap(CompareSQLLiteral.binaryLiteral(for: png, databaseType: type)) + XCTAssertEqual(literal, "X'89504E47'", "\(type.rawValue) uses a bit-string literal") + } + } + + func testPostgresFamilyUsesAByteaCast() throws { + for type in [DatabaseType.postgresql, .cockroachdb, .redshift, .pglite] { + let literal = try XCTUnwrap(CompareSQLLiteral.binaryLiteral(for: png, databaseType: type)) + XCTAssertEqual(literal, "'\\x89504e47'::bytea", "\(type.rawValue) uses a bytea literal") + } + } + + func testSQLServerUsesZeroX() throws { + let literal = try XCTUnwrap(CompareSQLLiteral.binaryLiteral(for: png, databaseType: .mssql)) + + XCTAssertEqual(literal, "0x89504E47") + } + + func testOracleUsesHexToRaw() throws { + let literal = try XCTUnwrap(CompareSQLLiteral.binaryLiteral(for: png, databaseType: .oracle)) + + XCTAssertEqual(literal, "HEXTORAW('89504E47')") + } + + /// An engine this build does not name falls back to the driver's own spelling rather than + /// guessing at one, which is why the helper returns nil instead of a default. + func testAnUnnamedEngineHasNoOpinion() { + XCTAssertNil(CompareSQLLiteral.binaryLiteral(for: png, databaseType: DatabaseType(rawValue: "Whatever"))) + } + + func testEmptyDataStillProducesAValidLiteral() throws { + let literal = try XCTUnwrap(CompareSQLLiteral.binaryLiteral(for: Data(), databaseType: .postgresql)) + + XCTAssertEqual(literal, "'\\x'::bytea") + } +} diff --git a/TableProTests/Core/Compare/CompareSyncExecutorTests.swift b/TableProTests/Core/Compare/CompareSyncExecutorTests.swift new file mode 100644 index 000000000..66fa86ccf --- /dev/null +++ b/TableProTests/Core/Compare/CompareSyncExecutorTests.swift @@ -0,0 +1,366 @@ +// +// CompareSyncExecutorTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import XCTest + +@testable import TablePro + +private final class RecordingDriver: PluginDatabaseDriver, @unchecked Sendable { + var executed: [String] = [] + var transactionEvents: [String] = [] + var failingStatements: Set = [] + var transactionsSupported = true + var transactionalDDLSupported = true + var declaredCapabilities: PluginCapabilities = [] + + var capabilities: PluginCapabilities { declaredCapabilities } + var supportsTransactions: Bool { transactionsSupported } + var supportsTransactionalDDL: Bool { transactionalDDLSupported } + + func connect() async throws {} + + func disconnect() {} + + func execute(query: String) async throws -> PluginQueryResult { + executed.append(query) + if failingStatements.contains(query) { + throw CompareSyncError.unsupportedOperation("boom") + } + return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func beginTransaction() async throws { transactionEvents.append("begin") } + func commitTransaction() async throws { transactionEvents.append("commit") } + func rollbackTransaction() async throws { transactionEvents.append("rollback") } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +private struct AlwaysAllowGate: ExecutionGate { + func authorize(_ request: OperationRequest) async -> OperationDecision { + .authorized(OperationReceipt( + connectionId: request.connectionId, + kind: request.kind, + effectiveWrite: true, + grantedAt: Date(), + token: UUID() + )) + } +} + +private struct AlwaysDenyGate: ExecutionGate { + func authorize(_ request: OperationRequest) async -> OperationDecision { + .denied(reason: "Read-Only connection") + } +} + +final class CompareSyncExecutorTests: XCTestCase { + private func endpoint() -> CompareSyncEndpoint { + CompareSyncEndpoint( + scope: DatabaseScope(connectionId: UUID(), database: "app", schema: nil), + connectionName: "staging", + databaseType: .mysql, + safeModeLevel: .silent, + color: .blue + ) + } + + private func statement(_ sql: String, refused: Bool = false) -> SyncStatement { + SyncStatement( + sql: sql, + objectName: "t", + summary: sql, + hazards: refused + ? [SyncHazard(kind: .dataLoss, severity: .refusedByDefault, explanation: "drops data")] + : [] + ) + } + + private func run( + statements: [SyncStatement], + settings: CompareSyncExecutionSettings = CompareSyncExecutionSettings(), + driver: RecordingDriver, + gate: any ExecutionGate = AlwaysAllowGate() + ) async throws -> CompareSyncRunResult { + try await CompareSyncExecutor(gate: gate).apply( + statements: statements, + mode: .structure, + settings: settings, + target: endpoint(), + driver: driver, + progress: Progress() + ) + } + + // MARK: - Held back statements + + func testRefusedStatementIsNotExecutedUnlessAllowed() async throws { + let driver = RecordingDriver() + let dropStatement = statement("DROP TABLE t;", refused: true) + + let result = try await run(statements: [statement("SELECT 1;"), dropStatement], driver: driver) + + XCTAssertEqual(driver.executed, ["SELECT 1;"], "a refused statement must not reach the driver") + XCTAssertEqual(result.heldBackCount, 1) + XCTAssertEqual(result.executedCount, 1) + } + + func testAllowingARefusedStatementRunsIt() async throws { + let driver = RecordingDriver() + let dropStatement = statement("DROP TABLE t;", refused: true) + var settings = CompareSyncExecutionSettings() + settings.allowedHazardStatementIds = [dropStatement.id] + + let result = try await run(statements: [dropStatement], settings: settings, driver: driver) + + XCTAssertEqual(driver.executed, ["DROP TABLE t;"]) + XCTAssertEqual(result.heldBackCount, 0) + } + + func testAllRefusedMeansNothingRunsAndNoTransactionOpens() async throws { + let driver = RecordingDriver() + + let result = try await run(statements: [statement("DROP TABLE t;", refused: true)], driver: driver) + + XCTAssertTrue(driver.executed.isEmpty) + XCTAssertTrue(driver.transactionEvents.isEmpty) + XCTAssertEqual(result.heldBackCount, 1) + } + + // MARK: - Transactions + + func testSuccessfulRunCommits() async throws { + let driver = RecordingDriver() + + _ = try await run(statements: [statement("A;"), statement("B;")], driver: driver) + + XCTAssertEqual(driver.transactionEvents, ["begin", "commit"]) + } + + func testFailureRollsBackWithStopAndRollback() async throws { + let driver = RecordingDriver() + driver.failingStatements = ["B;"] + var settings = CompareSyncExecutionSettings() + settings.errorHandling = .stopAndRollback + + let result = try await run( + statements: [statement("A;"), statement("B;"), statement("C;")], + settings: settings, + driver: driver + ) + + XCTAssertEqual(driver.transactionEvents, ["begin", "rollback"]) + XCTAssertTrue(result.rolledBack) + XCTAssertEqual(driver.executed, ["A;", "B;"], "execution stops at the first failure") + } + + func testFailureCommitsWithStopAndCommit() async throws { + let driver = RecordingDriver() + driver.failingStatements = ["B;"] + var settings = CompareSyncExecutionSettings() + settings.errorHandling = .stopAndCommit + + let result = try await run( + statements: [statement("A;"), statement("B;")], + settings: settings, + driver: driver + ) + + XCTAssertEqual(driver.transactionEvents, ["begin", "commit"]) + XCTAssertFalse(result.rolledBack) + } + + func testSkipAndContinueRunsEverythingAndUsesNoTransaction() async throws { + let driver = RecordingDriver() + driver.failingStatements = ["B;"] + var settings = CompareSyncExecutionSettings() + settings.errorHandling = .skipAndContinue + + let result = try await run( + statements: [statement("A;"), statement("B;"), statement("C;")], + settings: settings, + driver: driver + ) + + XCTAssertEqual(driver.executed, ["A;", "B;", "C;"]) + XCTAssertTrue(driver.transactionEvents.isEmpty, "skip and continue must not open a transaction") + XCTAssertEqual(result.failedCount, 1) + XCTAssertEqual(result.executedCount, 2) + } + + func testNoTransactionWhenDriverDoesNotSupportOne() async throws { + let driver = RecordingDriver() + driver.transactionsSupported = false + driver.transactionalDDLSupported = false + + _ = try await run(statements: [statement("A;")], driver: driver) + + XCTAssertTrue(driver.transactionEvents.isEmpty) + } + + /// A structure sync on MySQL, MariaDB or Oracle must not open a transaction it cannot roll + /// back: DDL commits implicitly there, so the ROLLBACK undid nothing while the run reported + /// "The target is unchanged." + func testStructureSyncOpensNoTransactionWhenDDLCommitsImplicitly() async throws { + let driver = RecordingDriver() + driver.transactionsSupported = true + driver.transactionalDDLSupported = false + + _ = try await run(statements: [statement("ALTER TABLE users DROP COLUMN legacy_id;")], driver: driver) + + XCTAssertTrue(driver.transactionEvents.isEmpty) + } + + // MARK: - Gate + + func testDeniedAuthorizationRunsNoStatements() async { + let driver = RecordingDriver() + + do { + _ = try await run(statements: [statement("A;")], driver: driver, gate: AlwaysDenyGate()) + XCTFail("Expected the gate to deny the run") + } catch { + XCTAssertTrue(driver.executed.isEmpty, "nothing may run when the gate denies") + } + } + + // MARK: - Progress + + func testProgressReachesTotalOnCompletion() async throws { + let driver = RecordingDriver() + let progress = Progress() + + _ = try await CompareSyncExecutor(gate: AlwaysAllowGate()).apply( + statements: [statement("A;"), statement("B;"), statement("C;")], + mode: .structure, + settings: CompareSyncExecutionSettings(), + target: endpoint(), + driver: driver, + progress: progress + ) + + XCTAssertEqual(progress.totalUnitCount, 3) + XCTAssertEqual(progress.completedUnitCount, 3) + } +} + +final class CompareSyncExecutionSettingsTests: XCTestCase { + private func driver(transactions: Bool, transactionalDDL: Bool) -> RecordingDriver { + let driver = RecordingDriver() + driver.transactionsSupported = transactions + driver.transactionalDDLSupported = transactionalDDL + return driver + } + + func testTransactionIsDisabledForSkipAndContinue() { + var settings = CompareSyncExecutionSettings() + settings.wrapInTransaction = true + settings.errorHandling = .skipAndContinue + + XCTAssertFalse(settings.usesTransaction( + for: .data, driver: driver(transactions: true, transactionalDDL: true) + )) + } + + func testTransactionRequiresDriverSupport() { + var settings = CompareSyncExecutionSettings() + settings.wrapInTransaction = true + settings.errorHandling = .stopAndRollback + + XCTAssertTrue(settings.usesTransaction( + for: .data, driver: driver(transactions: true, transactionalDDL: true) + )) + XCTAssertFalse(settings.usesTransaction( + for: .data, driver: driver(transactions: false, transactionalDDL: true) + )) + } + + /// MySQL, MariaDB and Oracle commit implicitly on every DDL statement, so a structure sync + /// wrapped in a transaction reported "The target is unchanged" after a ROLLBACK that undid + /// nothing. Structure asks `supportsTransactionalDDL`, data asks `supportsTransactions`. + func testStructureSyncAsksForTransactionalDDLRatherThanTransactions() { + var settings = CompareSyncExecutionSettings() + settings.wrapInTransaction = true + settings.errorHandling = .stopAndRollback + let mysqlLike = driver(transactions: true, transactionalDDL: false) + + XCTAssertFalse(settings.usesTransaction(for: .structure, driver: mysqlLike)) + XCTAssertTrue(settings.usesTransaction(for: .data, driver: mysqlLike)) + } + + func testStructureSyncUsesATransactionWhenTheEngineSupportsTransactionalDDL() { + var settings = CompareSyncExecutionSettings() + settings.wrapInTransaction = true + settings.errorHandling = .stopAndRollback + + XCTAssertTrue(settings.usesTransaction( + for: .structure, driver: driver(transactions: true, transactionalDDL: true) + )) + } +} + +final class CompareSyncEligibilityTests: XCTestCase { + func testMissingSchemaCompareBitIsRefused() { + let driver = RecordingDriver() + driver.declaredCapabilities = [] + + XCTAssertNotNil(CompareSyncEligibility.refusalReason(for: driver, mode: .structure, endpointName: "db")) + } + + func testDeclaredBitPasses() { + let driver = RecordingDriver() + driver.declaredCapabilities = [.schemaCompare, .dataCompare] + + XCTAssertNil(CompareSyncEligibility.refusalReason(for: driver, mode: .structure, endpointName: "db")) + XCTAssertNil(CompareSyncEligibility.refusalReason(for: driver, mode: .data, endpointName: "db")) + } + + func testStructureBitDoesNotGrantDataCompare() { + let driver = RecordingDriver() + driver.declaredCapabilities = [.schemaCompare] + + XCTAssertNotNil(CompareSyncEligibility.refusalReason(for: driver, mode: .data, endpointName: "db")) + } +} + +final class CompareSyncEndpointTests: XCTestCase { + private func endpoint(_ level: SafeModeLevel) -> CompareSyncEndpoint { + CompareSyncEndpoint( + scope: DatabaseScope(connectionId: UUID(), database: "prod", schema: nil), + connectionName: "prod", + databaseType: .postgresql, + safeModeLevel: level, + color: .red + ) + } + + func testReadOnlyEndpointCannotBeWrittenTo() { + let target = endpoint(.readOnly) + + XCTAssertFalse(target.canBeWrittenTo) + XCTAssertNotNil(target.ineligibleAsTargetReason) + } + + func testOtherLevelsCanBeWrittenTo() { + for level in [SafeModeLevel.silent, .alert, .alertFull, .safeMode, .safeModeFull] { + XCTAssertTrue(endpoint(level).canBeWrittenTo, "\(level) should be a valid target") + XCTAssertNil(endpoint(level).ineligibleAsTargetReason) + } + } +} diff --git a/TableProTests/Core/Compare/DataDiffEngineTests.swift b/TableProTests/Core/Compare/DataDiffEngineTests.swift new file mode 100644 index 000000000..b02c35f94 --- /dev/null +++ b/TableProTests/Core/Compare/DataDiffEngineTests.swift @@ -0,0 +1,377 @@ +// +// DataDiffEngineTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import XCTest + +@testable import TablePro + +final class DataDiffEngineTests: XCTestCase { + private func row(_ pairs: [String: String?]) -> DataRow { + var values: [String: PluginCellValue] = [:] + for (key, value) in pairs { + values[key] = value.map { PluginCellValue.text($0) } ?? .null + } + return DataRow(values: values) + } + + private func engine( + key: [String] = ["id"], + columns: [String] = ["id", "name"], + configure: (inout DataCompareOptions) -> Void = { _ in } + ) -> DataDiffEngine { + var options = DataCompareOptions() + options.keyColumns = key + configure(&options) + return DataDiffEngine(options: options, columns: columns) + } + + private func diff( + source: [DataRow], + target: [DataRow], + engine: DataDiffEngine + ) async throws -> DataDiffSummary { + try await engine.compare( + source: ArrayRowProvider(rows: source), + target: ArrayRowProvider(rows: target) + ) + } + + // MARK: - Merge join classification + + /// The retained entry list is a preview, capped at `maxRetainedEntries`. Script generation used + /// to build DML from that list, so a table with 12,000 differences produced 5,000 statements and + /// the run reported success. The sink sees every entry the walk produces, before the cap. + func testTheEntrySinkSeesEveryDifferenceEvenPastTheRetentionCap() async throws { + var options = DataCompareOptions() + options.keyColumns = ["id"] + options.maxRetainedEntries = 10 + + let source = ArrayRowProvider(rows: (1 ... 50).map { + DataRow(values: ["id": .text(String(format: "%04d", $0)), "name": .text("n")]) + }) + let engine = DataDiffEngine( + options: options, + columns: ["id", "name"], + keyDescriptors: [KeyColumnDescriptor(name: "id", dataType: "int")] + ) + + var seen = 0 + let summary = try await engine.compare(source: source, target: ArrayRowProvider(rows: [])) { _ in + seen += 1 + } + + XCTAssertEqual(summary.insertCount, 50, "counts stay exact") + XCTAssertEqual(summary.entries.count, 10, "the retained preview stays capped") + XCTAssertTrue(summary.truncatedEntries) + XCTAssertEqual(seen, 50, "the sink must see every difference, not the capped preview") + } + + func testTheSinkIsOptionalAndTheCapStillAppliesWithoutIt() async throws { + var options = DataCompareOptions() + options.keyColumns = ["id"] + options.maxRetainedEntries = 2 + + let source = ArrayRowProvider(rows: (1 ... 5).map { + DataRow(values: ["id": .text("\($0)"), "name": .text("n")]) + }) + let engine = DataDiffEngine( + options: options, + columns: ["id", "name"], + keyDescriptors: [KeyColumnDescriptor(name: "id", dataType: "int")] + ) + + let summary = try await engine.compare(source: source, target: ArrayRowProvider(rows: [])) + + XCTAssertEqual(summary.insertCount, 5) + XCTAssertEqual(summary.entries.count, 2) + } + + func testRowMissingFromTargetIsAnInsert() async throws { + let summary = try await diff( + source: [row(["id": "1", "name": "a"]), row(["id": "2", "name": "b"])], + target: [row(["id": "1", "name": "a"])], + engine: engine() + ) + + XCTAssertEqual(summary.insertCount, 1) + XCTAssertEqual(summary.identicalCount, 1) + XCTAssertEqual(summary.updateCount, 0) + XCTAssertEqual(summary.deleteCount, 0) + } + + func testRowMissingFromSourceIsADelete() async throws { + let summary = try await diff( + source: [row(["id": "1", "name": "a"])], + target: [row(["id": "1", "name": "a"]), row(["id": "2", "name": "b"])], + engine: engine() + ) + + XCTAssertEqual(summary.deleteCount, 1) + XCTAssertEqual(summary.identicalCount, 1) + } + + func testDifferingValueIsAnUpdateAndRecordsWhichRuleFired() async throws { + let summary = try await diff( + source: [row(["id": "1", "name": "alice"])], + target: [row(["id": "1", "name": "bob"])], + engine: engine() + ) + + XCTAssertEqual(summary.updateCount, 1) + let entry = try XCTUnwrap(summary.entries.first) + XCTAssertEqual(entry.cellDifferences.count, 1) + XCTAssertEqual(entry.cellDifferences[0].column, "name") + XCTAssertEqual(entry.cellDifferences[0].rule, .exactValue) + } + + func testInterleavedKeysAreAllClassified() async throws { + let summary = try await diff( + source: [row(["id": "1"]), row(["id": "3"]), row(["id": "5"])], + target: [row(["id": "2"]), row(["id": "3"]), row(["id": "4"])], + engine: engine(columns: ["id"]) + ) + + XCTAssertEqual(summary.insertCount, 2) + XCTAssertEqual(summary.deleteCount, 2) + XCTAssertEqual(summary.identicalCount, 1) + } + + func testEmptySourceMakesEveryTargetRowADelete() async throws { + let summary = try await diff( + source: [], + target: [row(["id": "1"]), row(["id": "2"])], + engine: engine(columns: ["id"]) + ) + + XCTAssertEqual(summary.deleteCount, 2) + } + + // MARK: - Composite keys + + func testCompositeKeyMatchesOnBothColumns() async throws { + let compareEngine = engine(key: ["tenant", "id"], columns: ["tenant", "id", "name"]) + let summary = try await diff( + source: [ + row(["tenant": "a", "id": "1", "name": "x"]), + row(["tenant": "b", "id": "1", "name": "y"]) + ], + target: [ + row(["tenant": "a", "id": "1", "name": "x"]), + row(["tenant": "b", "id": "1", "name": "z"]) + ], + engine: compareEngine + ) + + XCTAssertEqual(summary.identicalCount, 1) + XCTAssertEqual(summary.updateCount, 1, "composite keys must not collapse distinct rows") + } + + // MARK: - No key + + func testComparingWithoutAKeyThrowsRatherThanGuessing() async { + var options = DataCompareOptions() + options.keyColumns = [] + let keyless = DataDiffEngine(options: options, columns: ["id"]) + + do { + _ = try await keyless.compare( + source: ArrayRowProvider(rows: [row(["id": "1"])]), + target: ArrayRowProvider(rows: []) + ) + XCTFail("Expected a missing-key error") + } catch { + XCTAssertTrue(error is CompareSyncError) + } + } + + // MARK: - NULL semantics + + func testNullEqualsNullAndNeverEqualsEmptyString() async throws { + let bothNull = try await diff( + source: [row(["id": "1", "name": nil])], + target: [row(["id": "1", "name": nil])], + engine: engine() + ) + XCTAssertEqual(bothNull.identicalCount, 1) + + let nullVersusEmpty = try await diff( + source: [row(["id": "1", "name": nil])], + target: [row(["id": "1", "name": ""])], + engine: engine() + ) + XCTAssertEqual(nullVersusEmpty.updateCount, 1) + XCTAssertEqual(nullVersusEmpty.entries.first?.cellDifferences.first?.rule, .nullEquality) + } + + // MARK: - Compare set versus write set + + func testExcludedColumnNeverCausesADifference() async throws { + let compareEngine = engine(columns: ["id", "name", "updated_at"]) { options in + options.excludedFromComparison = ["updated_at"] + } + let summary = try await diff( + source: [row(["id": "1", "name": "a", "updated_at": "2026-01-01 00:00:00"])], + target: [row(["id": "1", "name": "a", "updated_at": "2020-01-01 00:00:00"])], + engine: compareEngine + ) + + XCTAssertEqual(summary.identicalCount, 1, "an excluded audit column must not create a diff") + } + + func testExcludedColumnIsStillCarriedOnTheSourceRow() async throws { + let compareEngine = engine(columns: ["id", "name", "updated_at"]) { options in + options.excludedFromComparison = ["updated_at"] + options.insertMissingRows = true + } + let summary = try await diff( + source: [row(["id": "1", "name": "a", "updated_at": "2026-01-01 00:00:00"])], + target: [], + engine: compareEngine + ) + + let entry = try XCTUnwrap(summary.entries.first) + XCTAssertEqual(entry.kind, .insert) + XCTAssertEqual(entry.sourceRow?.value(for: "updated_at"), .text("2026-01-01 00:00:00")) + } + + // MARK: - Key columns are never treated as comparison columns + + func testKeyColumnIsNotAlsoComparedAsAValue() async throws { + var options = DataCompareOptions() + options.keyColumns = ["id"] + + XCTAssertEqual(options.comparisonColumns(from: ["id", "name"]), ["name"]) + } + + // MARK: - Retention cap + + func testCountsStayExactWhenRetainedEntriesAreCapped() async throws { + let compareEngine = engine(columns: ["id"]) { options in + options.maxRetainedEntries = 2 + } + let source = (1...10).map { row(["id": String(format: "%03d", $0)]) } + + let summary = try await diff(source: source, target: [], engine: compareEngine) + + XCTAssertEqual(summary.insertCount, 10, "counts must be exact even when entries are capped") + XCTAssertEqual(summary.entries.count, 2) + XCTAssertTrue(summary.truncatedEntries) + } + + // MARK: - Cancellation + + func testCancellationStopsTheComparison() async { + let compareEngine = engine(columns: ["id"]) + let source = (1...5_000).map { row(["id": String(format: "%06d", $0)]) } + + let task = Task { + try await compareEngine.compare( + source: ArrayRowProvider(rows: source), + target: ArrayRowProvider(rows: []) + ) + } + task.cancel() + + do { + _ = try await task.value + } catch { + XCTAssertTrue(error is CancellationError) + return + } + XCTAssertTrue(true, "comparison finished before cancellation was observed") + } +} + +final class CellValueComparatorTests: XCTestCase { + private func comparator(tolerance: Double = 0, fractionalDigits: Int = 6) -> CellValueComparator { + var options = DataCompareOptions() + options.floatTolerance = tolerance + options.timestampFractionalDigits = fractionalDigits + return CellValueComparator(options: options) + } + + func testFloatToleranceTreatsNearlyEqualValuesAsEqual() { + let outcome = comparator(tolerance: 0.001).compare( + .text("109.05999755859375"), + .text("109.05999755859381") + ) + + XCTAssertTrue(outcome.isEqual) + XCTAssertEqual(outcome.rule, .floatTolerance) + } + + func testFloatToleranceIsSymmetric() { + let subject = comparator(tolerance: 0.5) + let pairs: [(String, String)] = [ + ("1.0", "1.2"), + ("1.0", "1.8"), + ("100.10", "100.1000"), + ("-3.0", "-3.4"), + ("0.0", "0.6") + ] + + for (left, right) in pairs { + let forward = subject.compare(.text(left), .text(right)) + let backward = subject.compare(.text(right), .text(left)) + XCTAssertEqual( + forward.isEqual, + backward.isEqual, + "comparison of \(left) and \(right) must not depend on argument order" + ) + } + } + + func testExactComparisonIsSymmetricForMismatchedKinds() { + let subject = comparator() + + XCTAssertEqual( + subject.compare(.null, .text("x")).isEqual, + subject.compare(.text("x"), .null).isEqual + ) + XCTAssertEqual( + subject.compare(.bytes(Data([0x01])), .text("x")).isEqual, + subject.compare(.text("x"), .bytes(Data([0x01]))).isEqual + ) + } + + func testZeroToleranceKeepsExactNumericComparison() { + let outcome = comparator(tolerance: 0).compare(.text("1.0"), .text("1.00")) + + XCTAssertFalse(outcome.isEqual, "without a declared tolerance nothing is smoothed over") + } + + func testEquivalentInstantsWithDifferentOffsetsCompareEqual() { + let outcome = comparator().compare( + .text("1999-01-15 08:00:00-08:00"), + .text("1999-01-15 11:00:00-05:00") + ) + + XCTAssertTrue(outcome.isEqual, "the same instant written at two offsets is not a difference") + XCTAssertEqual(outcome.rule, .timestampPrecision) + } + + func testTimestampPrecisionTruncationIsHonoured() { + let coarse = comparator(fractionalDigits: 0).compare( + .text("2026-01-01 00:00:00.100000"), + .text("2026-01-01 00:00:00.200000") + ) + XCTAssertTrue(coarse.isEqual) + + let fine = comparator(fractionalDigits: 6).compare( + .text("2026-01-01 00:00:00.100000"), + .text("2026-01-01 00:00:00.200000") + ) + XCTAssertFalse(fine.isEqual) + } + + func testBinaryContentComparedByBytes() { + let subject = comparator() + + XCTAssertTrue(subject.compare(.bytes(Data([1, 2, 3])), .bytes(Data([1, 2, 3]))).isEqual) + XCTAssertFalse(subject.compare(.bytes(Data([1, 2, 3])), .bytes(Data([1, 2, 4]))).isEqual) + } +} diff --git a/TableProTests/Core/Compare/DataSyncScriptBuilderTests.swift b/TableProTests/Core/Compare/DataSyncScriptBuilderTests.swift new file mode 100644 index 000000000..e56fb945e --- /dev/null +++ b/TableProTests/Core/Compare/DataSyncScriptBuilderTests.swift @@ -0,0 +1,338 @@ +// +// DataSyncScriptBuilderTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import XCTest + +@testable import TablePro + +/// `sqlLiteral(for:)` lives only in a protocol extension, so it is statically +/// dispatched and cannot be overridden here. These tests assert against the +/// shared default, which emits numeric-looking text as a bare numeric literal. +private final class QuotingDriver: PluginDatabaseDriver, @unchecked Sendable { + func quoteIdentifier(_ name: String) -> String { "`\(name)`" } + + func connect() async throws {} + func disconnect() {} + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +final class DataSyncScriptBuilderTests: XCTestCase { + private let driver = QuotingDriver() + + private func options( + insert: Bool = true, + update: Bool = true, + delete: Bool = true, + keys: [String] = ["id"] + ) -> DataCompareOptions { + var result = DataCompareOptions() + result.keyColumns = keys + result.insertMissingRows = insert + result.updateDifferingRows = update + result.deleteExtraRows = delete + return result + } + + private func row(_ pairs: [String: PluginCellValue]) -> DataRow { + DataRow(values: pairs) + } + + private func build( + entries: [RowDiffEntry], + options overrides: DataCompareOptions? = nil, + columns: [String] = ["id", "name"], + schema: String? = nil + ) -> [SyncStatement] { + DataSyncScriptBuilder( + targetDriver: driver, targetDatabaseType: .mysql, options: overrides ?? options() + ) + .build(table: "users", schema: schema, writeColumns: columns, entries: entries) + } + + // MARK: - Insert + + func testInsertQuotesIdentifiersAndEscapesValues() { + let entry = RowDiffEntry( + kind: .insert, + keyDescription: "1", + sourceRow: row(["id": .text("1"), "name": .text("O'Hara")]), + targetRow: nil + ) + + let statements = build(entries: [entry]) + + XCTAssertEqual(statements.count, 1) + XCTAssertEqual( + statements[0].sql, + "INSERT INTO `users` (`id`, `name`) VALUES (1, 'O''Hara');" + ) + } + + func testInsertQualifiesWithSchemaWhenPresent() { + let entry = RowDiffEntry( + kind: .insert, keyDescription: "1", + sourceRow: row(["id": .text("1"), "name": .text("a")]), targetRow: nil + ) + + let statements = build(entries: [entry], schema: "app") + + XCTAssertTrue(statements[0].sql.hasPrefix("INSERT INTO `app`.`users`"), statements[0].sql) + } + + // MARK: - Update + + func testUpdateSetsNonKeyColumnsAndKeysTheWhereClause() { + let entry = RowDiffEntry( + kind: .update, keyDescription: "1", + sourceRow: row(["id": .text("1"), "name": .text("new")]), + targetRow: row(["id": .text("1"), "name": .text("old")]) + ) + + let statements = build(entries: [entry]) + + XCTAssertEqual(statements.count, 1) + XCTAssertEqual(statements[0].sql, "UPDATE `users` SET `name` = 'new' WHERE `id` = 1;") + XCTAssertFalse(statements[0].sql.contains("SET `id`"), "the key must never be assigned in SET") + } + + func testUpdateIsSkippedWhenEveryColumnIsAKey() { + let entry = RowDiffEntry( + kind: .update, keyDescription: "1", + sourceRow: row(["id": .text("1")]), targetRow: row(["id": .text("1")]) + ) + + let statements = build(entries: [entry], options: options(keys: ["id"]), columns: ["id"]) + + XCTAssertTrue(statements.isEmpty, "there is nothing to assign, so no statement should be produced") + } + + func testCompositeKeyProducesConjunctionInWhereClause() { + let entry = RowDiffEntry( + kind: .update, keyDescription: "a, 1", + sourceRow: row(["tenant": .text("a"), "id": .text("1"), "name": .text("new")]), + targetRow: row(["tenant": .text("a"), "id": .text("1"), "name": .text("old")]) + ) + + let statements = build( + entries: [entry], + options: options(keys: ["tenant", "id"]), + columns: ["tenant", "id", "name"] + ) + + XCTAssertTrue(statements[0].sql.contains("WHERE `tenant` = 'a' AND `id` = 1;"), statements[0].sql) + } + + // MARK: - Delete + + func testDeleteIsKeyedAndCarriesARefusedHazard() { + let entry = RowDiffEntry( + kind: .delete, keyDescription: "7", + sourceRow: nil, targetRow: row(["id": .text("7"), "name": .text("x")]) + ) + + let statements = build(entries: [entry]) + + XCTAssertEqual(statements.count, 1) + XCTAssertEqual(statements[0].sql, "DELETE FROM `users` WHERE `id` = 7;") + XCTAssertTrue(statements[0].isRefusedByDefault, "a delete must be held back until allowed") + } + + // MARK: - NULL handling + + func testNullKeyUsesIsNullRatherThanEquality() { + let entry = RowDiffEntry( + kind: .delete, keyDescription: "NULL", + sourceRow: nil, targetRow: row(["id": .null, "name": .text("x")]) + ) + + let statements = build(entries: [entry]) + + XCTAssertEqual(statements[0].sql, "DELETE FROM `users` WHERE `id` IS NULL;") + } + + func testNullValueIsWrittenAsNullLiteralOnInsert() { + let entry = RowDiffEntry( + kind: .insert, keyDescription: "1", + sourceRow: row(["id": .text("1"), "name": .null]), targetRow: nil + ) + + let statements = build(entries: [entry]) + + XCTAssertTrue(statements[0].sql.hasSuffix("VALUES (1, NULL);"), statements[0].sql) + } + + // MARK: - Action toggles + + func testDisabledActionsProduceNoStatements() { + let entries = [ + RowDiffEntry(kind: .insert, keyDescription: "1", sourceRow: row(["id": .text("1")]), targetRow: nil), + RowDiffEntry( + kind: .update, keyDescription: "2", + sourceRow: row(["id": .text("2"), "name": .text("a")]), + targetRow: row(["id": .text("2"), "name": .text("b")]) + ), + RowDiffEntry(kind: .delete, keyDescription: "3", sourceRow: nil, targetRow: row(["id": .text("3")])) + ] + + let statements = build(entries: entries, options: options(insert: false, update: false, delete: false)) + + XCTAssertTrue(statements.isEmpty) + } + + func testIdenticalRowsNeverProduceStatements() { + let entry = RowDiffEntry( + kind: .identical, keyDescription: "1", + sourceRow: row(["id": .text("1")]), targetRow: row(["id": .text("1")]) + ) + + XCTAssertTrue(build(entries: [entry]).isEmpty) + } + + // MARK: - Ordering + + func testInsertsComeBeforeUpdatesWhichComeBeforeDeletes() { + let entries = [ + RowDiffEntry(kind: .delete, keyDescription: "3", sourceRow: nil, targetRow: row(["id": .text("3")])), + RowDiffEntry( + kind: .update, keyDescription: "2", + sourceRow: row(["id": .text("2"), "name": .text("a")]), + targetRow: row(["id": .text("2"), "name": .text("b")]) + ), + RowDiffEntry( + kind: .insert, keyDescription: "1", + sourceRow: row(["id": .text("1"), "name": .text("c")]), targetRow: nil + ) + ] + + let verbs = build(entries: entries).map { String($0.sql.prefix(6)) } + + XCTAssertEqual(verbs, ["INSERT", "UPDATE", "DELETE"]) + } + + // MARK: - Write set + + func testExcludedFromComparisonColumnIsStillWritten() { + var overrides = options() + overrides.excludedFromComparison = ["updated_at"] + let entry = RowDiffEntry( + kind: .insert, keyDescription: "1", + sourceRow: row(["id": .text("1"), "name": .text("a"), "updated_at": .text("2026-01-01")]), + targetRow: nil + ) + + let statements = build(entries: [entry], options: overrides, columns: ["id", "name", "updated_at"]) + + XCTAssertTrue( + statements[0].sql.contains("`updated_at`") && statements[0].sql.contains("'2026-01-01'"), + "a column excluded from matching must still be written: \(statements[0].sql)" + ) + } +} + +final class DataSyncScriptBuilderColumnTests: XCTestCase { + private func builder( + _ databaseType: DatabaseType = .postgresql, + options: DataCompareOptions + ) -> DataSyncScriptBuilder { + DataSyncScriptBuilder( + targetDriver: QuotingDriver(), + targetDatabaseType: databaseType, + options: options + ) + } + + private func options() -> DataCompareOptions { + var options = DataCompareOptions() + options.keyColumns = ["id"] + options.insertMissingRows = true + options.updateDifferingRows = true + options.deleteExtraRows = true + return options + } + + private func insertEntry() -> RowDiffEntry { + RowDiffEntry( + kind: .insert, + keyDescription: "1", + sourceRow: DataRow(values: [ + "id": .text("1"), + "blob": .bytes(Data([0x89, 0x50])), + "total": .text("9") + ]), + targetRow: nil + ) + } + + /// PostgreSQL rejects `X'8950'` with "column is of type bytea but expression is of type bit". + func testBinaryValuesUseTheTargetEnginesSpelling() { + let statements = builder(.postgresql, options: options()).build( + table: "files", schema: "public", writeColumns: ["id", "blob"], entries: [insertEntry()] + ) + + XCTAssertEqual(statements.count, 1) + XCTAssertTrue(statements[0].sql.contains("'\\x8950'::bytea"), statements[0].sql) + XCTAssertFalse(statements[0].sql.contains("X'"), statements[0].sql) + } + + func testBitStringEnginesKeepTheirOwnSpelling() { + let statements = builder(.mysql, options: options()).build( + table: "files", schema: nil, writeColumns: ["id", "blob"], entries: [insertEntry()] + ) + + XCTAssertTrue(statements[0].sql.contains("X'8950'"), statements[0].sql) + } + + /// The three buckets exist so a caller can interleave several tables in dependency order. A + /// flat per-table inserts+updates+deletes is only correct for one table. + func testStatementsAreBucketedByKind() { + var statements = DataSyncStatements() + let entries = [ + insertEntry(), + RowDiffEntry( + kind: .delete, keyDescription: "2", + sourceRow: nil, targetRow: DataRow(values: ["id": .text("2")]) + ) + ] + let builder = builder(.mysql, options: options()) + for entry in entries { + builder.append(entry, table: "files", schema: nil, writeColumns: ["id", "blob"], into: &statements) + } + + XCTAssertEqual(statements.inserts.count, 1) + XCTAssertEqual(statements.deletes.count, 1) + XCTAssertTrue(statements.updates.isEmpty) + XCTAssertFalse(statements.isEmpty) + } + + func testDeleteStatementsCarryARefusedHazard() { + var statements = DataSyncStatements() + builder(.mysql, options: options()).append( + RowDiffEntry( + kind: .delete, keyDescription: "2", + sourceRow: nil, targetRow: DataRow(values: ["id": .text("2")]) + ), + table: "files", schema: nil, writeColumns: ["id"], into: &statements + ) + + XCTAssertTrue(statements.deletes[0].isRefusedByDefault) + } +} diff --git a/TableProTests/Core/Compare/ForeignKeyTopologicalSortTests.swift b/TableProTests/Core/Compare/ForeignKeyTopologicalSortTests.swift new file mode 100644 index 000000000..524fdc8bc --- /dev/null +++ b/TableProTests/Core/Compare/ForeignKeyTopologicalSortTests.swift @@ -0,0 +1,122 @@ +// +// ForeignKeyTopologicalSortTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("ForeignKeyTopologicalSort") +struct ForeignKeyTopologicalSortTests { + private func table(_ name: String, _ schema: String? = nil) -> ForeignKeyTopologicalSort.Table { + ForeignKeyTopologicalSort.Table(name: name, schema: schema) + } + + private func foreignKey(to referencedTable: String, schema: String? = nil) -> PluginForeignKeyInfo { + PluginForeignKeyInfo( + name: "fk_\(referencedTable)", + column: "\(referencedTable)_id", + referencedTable: referencedTable, + referencedColumn: "id", + referencedSchema: schema + ) + } + + @Test("A table with no schema is identified by its bare name") + func bareNameIsTheIdentifierWithoutASchema() { + #expect(table("orders").identifier == "orders") + #expect(table("orders", "").identifier == "orders") + #expect(table("orders", "public").identifier == "public.orders") + } + + @Test("The same table name in two schemas stays two tables") + func sameNameInTwoSchemasStaysDistinct() { + let ordered = ForeignKeyTopologicalSort.ordered( + [table("orders", "public"), table("orders", "sales")], + foreignKeysByTable: [:] + ) + + #expect(ordered.map { $0.identifier } == ["public.orders", "sales.orders"]) + #expect(ordered.map { $0.schema } == ["public", "sales"]) + } + + @Test("One table listed twice is emitted once") + func repeatedTableIsEmittedOnce() { + let ordered = ForeignKeyTopologicalSort.ordered( + [table("orders", "public"), table("orders", "public")], + foreignKeysByTable: [:] + ) + + #expect(ordered.map { $0.identifier } == ["public.orders"]) + } + + @Test("A parent precedes every child that references it") + func parentPrecedesChild() { + let ordered = ForeignKeyTopologicalSort.ordered( + [table("orders", "public"), table("customers", "public")], + foreignKeysByTable: ["public.orders": [foreignKey(to: "customers", schema: "public")]] + ) + + #expect(ordered.map { $0.identifier } == ["public.customers", "public.orders"]) + } + + @Test("A foreign key that names no schema points inside the referencing table's schema") + func unqualifiedForeignKeyResolvesInsideTheReferencingSchema() { + let ordered = ForeignKeyTopologicalSort.ordered( + [table("invoices", "sales"), table("regions", "sales")], + foreignKeysByTable: ["sales.invoices": [foreignKey(to: "regions")]] + ) + + #expect(ordered.map { $0.identifier } == ["sales.regions", "sales.invoices"]) + } + + @Test("A foreign key across schemas orders the table it really points at") + func crossSchemaForeignKeyOrdersTheReferencedSchema() { + let ordered = ForeignKeyTopologicalSort.ordered( + [table("orders", "sales"), table("customers", "public"), table("customers", "sales")], + foreignKeysByTable: [ + "sales.orders": [foreignKey(to: "customers", schema: "public")], + "sales.customers": [foreignKey(to: "orders", schema: "sales")] + ] + ) + + #expect(ordered.map { $0.identifier } == ["public.customers", "sales.orders", "sales.customers"]) + } + + @Test("childrenFirst puts a child ahead of its parent") + func childrenFirstReversesDependencyOrder() { + let ordered = ForeignKeyTopologicalSort.ordered( + [table("orders", "public"), table("customers", "public")], + foreignKeysByTable: ["public.orders": [foreignKey(to: "customers", schema: "public")]], + childrenFirst: true + ) + + #expect(ordered.map { $0.identifier } == ["public.orders", "public.customers"]) + } + + @Test("A dependency cycle keeps every table exactly once") + func cycleKeepsEveryTableExactlyOnce() { + let ordered = ForeignKeyTopologicalSort.ordered( + [table("orders", "public"), table("customers", "public"), table("orders", "sales")], + foreignKeysByTable: [ + "public.orders": [foreignKey(to: "customers", schema: "public")], + "public.customers": [foreignKey(to: "orders", schema: "public")] + ] + ) + let identifiers = ordered.map { $0.identifier } + + #expect(identifiers.count == 3) + #expect(Set(identifiers) == ["public.orders", "public.customers", "sales.orders"]) + } + + @Test("A self-referencing foreign key does not strand its table") + func selfReferenceDoesNotStrandTheTable() { + let ordered = ForeignKeyTopologicalSort.ordered( + [table("employees", "public"), table("departments", "public")], + foreignKeysByTable: ["public.employees": [foreignKey(to: "employees", schema: "public")]] + ) + + #expect(ordered.map { $0.identifier } == ["public.departments", "public.employees"]) + } +} diff --git a/TableProTests/Core/Compare/KeyOrderingTests.swift b/TableProTests/Core/Compare/KeyOrderingTests.swift new file mode 100644 index 000000000..2cb9891db --- /dev/null +++ b/TableProTests/Core/Compare/KeyOrderingTests.swift @@ -0,0 +1,243 @@ +// +// KeyOrderingTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import XCTest + +@testable import TablePro + +final class KeyOrderingPolicyTests: XCTestCase { + private func numeric() -> KeyOrdering { KeyOrdering(orders: [.numeric]) } + private func text() -> KeyOrdering { KeyOrdering(orders: [.caseSensitiveText]) } + + func testNumericKeysOrderNumerically() { + XCTAssertEqual(numeric().compare([.text("9")], [.text("10")]), .orderedAscending) + XCTAssertEqual(numeric().compare([.text("10")], [.text("9")]), .orderedDescending) + } + + func testTextKeysOrderByBytesNotByNumericValue() { + XCTAssertEqual( + text().compare([.text("10")], [.text("9")]), .orderedAscending, + "a text key must sort the way a byte-ordered collation sorts, not numerically" + ) + } + + func testTextOrderingIsCaseSensitiveByBytes() { + XCTAssertEqual(text().compare([.text("Carol")], [.text("bob")]), .orderedAscending) + } + + func testEqualKeysReportSame() { + XCTAssertEqual(text().compare([.text("a"), .text("1")], [.text("a"), .text("1")]), .orderedSame) + } + + func testOrderingIsAntisymmetric() { + let pairs: [([PluginCellValue], [PluginCellValue])] = [ + ([.text("1")], [.text("2")]), + ([.text("b")], [.text("a")]), + ([.bytes(Data([1]))], [.bytes(Data([2]))]) + ] + for (left, right) in pairs { + XCTAssertNotEqual(text().compare(left, right), text().compare(right, left)) + } + } + + func testCompositeKeyFallsThroughToSecondColumn() { + let ordering = KeyOrdering(orders: [.caseSensitiveText, .numeric]) + + XCTAssertEqual( + ordering.compare([.text("a"), .text("2")], [.text("a"), .text("10")]), + .orderedAscending + ) + } + + // MARK: - Type classification + + func testNumericTypesAreClassifiedNumeric() { + for type in ["int", "INT(11)", "bigint unsigned", "numeric(10,2)", "double precision", "serial"] { + XCTAssertTrue(KeyOrdering.isNumeric(type), "\(type) should compare numerically") + } + } + + func testTextAndDateTypesAreNotNumeric() { + for type in ["varchar(255)", "text", "uuid", "timestamp", "date", "bytea"] { + XCTAssertFalse(KeyOrdering.isNumeric(type), "\(type) should not compare numerically") + } + } + + func testOrdersDerivedFromDeclaredColumnTypes() { + let orders = KeyOrdering.orders( + for: ["id", "name"], + descriptors: [ + KeyColumnDescriptor(name: "id", dataType: "bigint"), + KeyColumnDescriptor(name: "name", dataType: "varchar(50)") + ] + ) + + XCTAssertEqual(orders, [.numeric, .caseSensitiveText]) + } + + func testUnknownColumnTypeFallsBackToCaseSensitiveText() { + XCTAssertEqual(KeyOrdering.orders(for: ["mystery"], descriptors: []), [.caseSensitiveText]) + } + + // MARK: - Exact numeric ordering + + /// Going through Double collapsed every pair of integers sharing the first 53 bits, so two + /// Snowflake ids one apart matched as the same row and the engine emitted an UPDATE that + /// overwrote a different row. + func testTwoBigIntegersAboveTwoToTheFiftyThreeAreNotEqual() { + let ordering = numeric() + let lower: [PluginCellValue] = [.text("1234567890123456789")] + let higher: [PluginCellValue] = [.text("1234567890123456790")] + + XCTAssertEqual(ordering.compare(lower, higher), .orderedAscending) + XCTAssertEqual(ordering.compare(higher, lower), .orderedDescending) + XCTAssertEqual(ordering.compare(lower, lower), .orderedSame) + } + + func testNumericOrderingIsNotLexicographic() { + XCTAssertEqual(numeric().compare([.text("9")], [.text("10")]), .orderedAscending) + } + + /// An unparsable numeric key used to collapse onto a shared zero, which made every one of them + /// compare equal to every other. + func testUnparsableNumericKeysStayDistinct() { + XCTAssertNotEqual(numeric().compare([.text("n/a")], [.text("unknown")]), .orderedSame) + } + + // MARK: - Collation + + func testCaseInsensitiveCollationIsDetectedPerEngine() { + for collation in ["utf8mb4_general_ci", "NOCASE", "SQL_Latin1_General_CP1_CI_AS"] { + XCTAssertTrue(KeyOrdering.isCaseInsensitive(collation), "\(collation) is case-insensitive") + } + for collation in ["utf8mb4_bin", "SQL_Latin1_General_CP1_CS_AS", "C", nil] { + XCTAssertFalse(KeyOrdering.isCaseInsensitive(collation), "\(collation ?? "nil") is case-sensitive") + } + } + + /// The server's own key constraint treats these as one row, so a byte comparator reported an + /// orphan insert for a row the target already had. + func testCaseInsensitiveKeyMatchesRegardlessOfCase() { + let ordering = KeyOrdering(orders: [.caseInsensitiveText]) + + XCTAssertEqual(ordering.compare([.text("Alice")], [.text("ALICE")]), .orderedSame) + XCTAssertEqual(KeyOrdering(orders: [.caseSensitiveText]).compare( + [.text("Alice")], [.text("ALICE")] + ), .orderedDescending) + } + + func testCollationDecidesTheTextOrder() { + let orders = KeyOrdering.orders( + for: ["name", "code"], + descriptors: [ + KeyColumnDescriptor(name: "name", dataType: "varchar(50)", collation: "utf8mb4_general_ci"), + KeyColumnDescriptor(name: "code", dataType: "varchar(50)", collation: "utf8mb4_bin") + ] + ) + + XCTAssertEqual(orders, [.caseInsensitiveText, .caseSensitiveText]) + } + + // MARK: - NULL keys + + func testNullComponentIsDetected() { + XCTAssertTrue(KeyOrdering.hasNullComponent([.text("a"), .null])) + XCTAssertFalse(KeyOrdering.hasNullComponent([.text("a"), .text("b")])) + } +} + +final class DataDiffOrderingSafetyTests: XCTestCase { + private func row(_ id: String) -> DataRow { + DataRow(values: ["id": .text(id), "name": .text("n")]) + } + + private func engine(numericKey: Bool) -> DataDiffEngine { + var options = DataCompareOptions() + options.keyColumns = ["id"] + return DataDiffEngine( + options: options, + columns: ["id", "name"], + keyDescriptors: [ + KeyColumnDescriptor(name: "id", dataType: numericKey ? "int" : "varchar(20)") + ] + ) + } + + /// The case that previously produced a delete for a row present on both sides. + func testDisagreeingCollationIsReportedInsteadOfDeletingAMatchingRow() async { + let source = ArrayRowProvider(rows: [row("Alice"), row("bob"), row("Carol")]) + let target = ArrayRowProvider(rows: [row("Alice"), row("Carol")]) + + do { + let summary = try await engine(numericKey: false).compare(source: source, target: target) + XCTAssertEqual( + summary.deleteCount, 0, + "a row present on both sides must never be reported as a delete" + ) + } catch let error as CompareSyncError { + guard case .streamOutOfOrder = error else { + return XCTFail("Expected an out-of-order report, got \(error)") + } + } catch { + XCTFail("Unexpected error \(error)") + } + } + + func testOutOfOrderTextStreamThrowsRatherThanMismatching() async { + let source = ArrayRowProvider(rows: [row("Alice"), row("bob"), row("Carol")]) + let target = ArrayRowProvider(rows: [row("Alice")]) + + do { + _ = try await engine(numericKey: false).compare(source: source, target: target) + XCTFail("Expected an out-of-order error for a case-insensitively sorted stream") + } catch let error as CompareSyncError { + guard case .streamOutOfOrder(let message) = error else { + return XCTFail("Expected streamOutOfOrder, got \(error)") + } + XCTAssertTrue( + message.contains("Carol"), + "the message must name the key the read stopped at: \(message)" + ) + } catch { + XCTFail("Unexpected error \(error)") + } + } + + func testInOrderTextStreamComparesNormally() async throws { + let source = ArrayRowProvider(rows: [row("Alice"), row("Carol"), row("bob")]) + let target = ArrayRowProvider(rows: [row("Alice"), row("Carol")]) + + let summary = try await engine(numericKey: false).compare(source: source, target: target) + + XCTAssertEqual(summary.identicalCount, 2) + XCTAssertEqual(summary.insertCount, 1) + XCTAssertEqual(summary.deleteCount, 0) + } + + func testNumericKeysNeedNoOrderCheckAndCompareNumerically() async throws { + let source = ArrayRowProvider(rows: [row("2"), row("9"), row("10")]) + let target = ArrayRowProvider(rows: [row("2"), row("10")]) + + let summary = try await engine(numericKey: true).compare(source: source, target: target) + + XCTAssertEqual(summary.identicalCount, 2) + XCTAssertEqual(summary.insertCount, 1, "9 is only on the source") + XCTAssertEqual(summary.deleteCount, 0, "10 exists on both sides and must not be deleted") + } + + func testRowsWithNullKeysAreSkippedNotGivenAnArbitraryOrder() async throws { + let withNullKey = DataRow(values: ["id": .null, "name": .text("n")]) + let source = ArrayRowProvider(rows: [withNullKey, row("1"), row("2")]) + let target = ArrayRowProvider(rows: [row("1")]) + + let summary = try await engine(numericKey: true).compare(source: source, target: target) + + XCTAssertEqual(summary.skippedNullKeyCount, 1) + XCTAssertEqual(summary.identicalCount, 1) + XCTAssertEqual(summary.insertCount, 1, "only the non-null key rows take part") + } +} diff --git a/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift b/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift new file mode 100644 index 000000000..87939491d --- /dev/null +++ b/TableProTests/Core/Compare/SchemaSyncScriptBuilderTests.swift @@ -0,0 +1,364 @@ +// +// SchemaSyncScriptBuilderTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import XCTest + +@testable import TablePro + +private final class StubSyncDriver: PluginDatabaseDriver, @unchecked Sendable { + func connect() async throws {} + + func disconnect() {} + + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + func fetchDatabases() async throws -> [String] { [] } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + "CREATE TABLE \(definition.tableName)" + } + + func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { + "DROP \(objectType) \(name)" + } + + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { + "ALTER TABLE \(table) ADD \(column.name)" + } + + func generateDropColumnSQL(table: String, columnName: String) -> String? { + "ALTER TABLE \(table) DROP \(columnName)" + } + + func generateModifyColumnSQL( + table: String, + oldColumn: PluginColumnDefinition, + newColumn: PluginColumnDefinition + ) -> String? { + "ALTER TABLE \(table) MODIFY \(newColumn.name)" + } + + func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { + "CREATE INDEX \(index.name) ON \(table)" + } + + func generateDropIndexSQL(table: String, indexName: String) -> String? { + "DROP INDEX \(indexName) ON \(table)" + } + + func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? { + "ALTER TABLE \(table) ADD CONSTRAINT \(fk.name)" + } + + func generateDropForeignKeySQL(table: String, constraintName: String) -> String? { + "ALTER TABLE \(table) DROP CONSTRAINT \(constraintName)" + } +} + +final class SchemaSyncScriptBuilderTests: XCTestCase { + private var driver: StubSyncDriver! + private var builder: SchemaSyncScriptBuilder! + + override func setUp() { + super.setUp() + driver = StubSyncDriver() + builder = SchemaSyncScriptBuilder(targetDriver: driver) + } + + override func tearDown() { + driver = nil + builder = nil + super.tearDown() + } + + private func snapshot(_ name: String) -> TableStructureSnapshot { + TableStructureSnapshot( + name: name, + columns: [ + EditableColumnDefinition( + id: UUID(), name: "id", dataType: "int", isNullable: false, defaultValue: nil, + autoIncrement: false, unsigned: false, comment: nil, collation: nil, + onUpdate: nil, charset: nil, extra: nil, isPrimaryKey: true + ) + ] + ) + } + + private func foreignKey(from child: String, to parent: String) -> PluginForeignKeyInfo { + PluginForeignKeyInfo( + name: "fk_\(child)_\(parent)", + column: "\(parent)_id", + referencedTable: parent, + referencedColumn: "id" + ) + } + + // MARK: - Cross-table ordering + + func testCreatesEmitParentsBeforeChildren() { + let operations: [SchemaSyncOperation] = [ + .createTable(snapshot("orders")), + .createTable(snapshot("customers")) + ] + let foreignKeys = ["orders": [foreignKey(from: "orders", to: "customers")]] + + let ordered = SchemaSyncScriptBuilder.order(operations: operations, foreignKeysByTable: foreignKeys) + + XCTAssertEqual(ordered.map { $0.tableName }, ["customers", "orders"]) + } + + func testDropsEmitChildrenBeforeParents() { + let operations: [SchemaSyncOperation] = [ + .dropTable(name: "customers", schema: nil), + .dropTable(name: "orders", schema: nil) + ] + let foreignKeys = ["orders": [foreignKey(from: "orders", to: "customers")]] + + let ordered = SchemaSyncScriptBuilder.order(operations: operations, foreignKeysByTable: foreignKeys) + + XCTAssertEqual(ordered.map { $0.tableName }, ["orders", "customers"]) + } + + func testThreeTableChainOrdersTransitively() { + let operations: [SchemaSyncOperation] = [ + .createTable(snapshot("line_items")), + .createTable(snapshot("orders")), + .createTable(snapshot("customers")) + ] + let foreignKeys = [ + "orders": [foreignKey(from: "orders", to: "customers")], + "line_items": [foreignKey(from: "line_items", to: "orders")] + ] + + let ordered = SchemaSyncScriptBuilder.order(operations: operations, foreignKeysByTable: foreignKeys) + + XCTAssertEqual(ordered.map { $0.tableName }, ["customers", "orders", "line_items"]) + } + + func testDropsRunBeforeCreatesWhichRunBeforeAlters() { + let operations: [SchemaSyncOperation] = [ + .alterTable(name: "a", schema: nil, changes: []), + .createTable(snapshot("b")), + .dropTable(name: "c", schema: nil) + ] + + let ordered = SchemaSyncScriptBuilder.order(operations: operations, foreignKeysByTable: [:]) + + XCTAssertEqual(ordered.map { $0.tableName }, ["c", "b", "a"]) + } + + func testSameTableNameInTwoSchemasBothSurviveOrdering() { + let operations: [SchemaSyncOperation] = [ + .alterTable(name: "users", schema: "app", changes: []), + .alterTable(name: "users", schema: "audit", changes: []) + ] + + let ordered = SchemaSyncScriptBuilder.order(operations: operations, foreignKeysByTable: [:]) + + XCTAssertEqual(ordered.count, 2, "a bare-name collision must not drop or duplicate an operation") + XCTAssertEqual(Set(ordered.map { $0.tableIdentifier }), ["app.users", "audit.users"]) + } + + func testOrderingUsesQualifiedNamesForDependencies() { + let operations: [SchemaSyncOperation] = [ + .createTable(snapshot("orders")), + .createTable(snapshot("customers")) + ] + let foreignKeys = [ + "orders": [PluginForeignKeyInfo( + name: "fk", column: "customer_id", referencedTable: "customers", referencedColumn: "id" + )] + ] + + let ordered = SchemaSyncScriptBuilder.order(operations: operations, foreignKeysByTable: foreignKeys) + + XCTAssertEqual(ordered.map { $0.tableIdentifier }, ["customers", "orders"]) + } + + func testCircularForeignKeysStillEmitEveryTable() { + let operations: [SchemaSyncOperation] = [ + .createTable(snapshot("a")), + .createTable(snapshot("b")) + ] + let foreignKeys = [ + "a": [foreignKey(from: "a", to: "b")], + "b": [foreignKey(from: "b", to: "a")] + ] + + let ordered = SchemaSyncScriptBuilder.order(operations: operations, foreignKeysByTable: foreignKeys) + + XCTAssertEqual(Set(ordered.map { $0.tableName }), ["a", "b"], "a cycle must not drop tables from the script") + } + + // MARK: - Intra-table ordering + + func testIntraTableOrderDropsConstraintsBeforeColumnsAndAddsThemLast() { + let column = EditableColumnDefinition.placeholder() + let index = EditableIndexDefinition.placeholder() + let foreignKeyDefinition = EditableForeignKeyDefinition.placeholder() + + let sortedChanges = SchemaChangeOrdering.sorted([ + .addForeignKey(foreignKeyDefinition), + .addColumn(column), + .deleteForeignKey(foreignKeyDefinition), + .addIndex(index), + .deleteColumn(column) + ]) + + let positions = sortedChanges.map { change -> Int in + switch change { + case .deleteForeignKey: return 0 + case .deleteColumn: return 1 + case .addColumn: return 2 + case .addIndex: return 3 + case .addForeignKey: return 4 + default: return 99 + } + } + XCTAssertEqual(positions, positions.sorted(), "changes must be emitted in dependency-safe order") + } + + // MARK: - Hazards + + func testDropTableIsRefusedByDefault() throws { + let statements = try builder.build( + operations: [.dropTable(name: "users", schema: nil)], + foreignKeysByTable: [:] + ) + + XCTAssertEqual(statements.count, 1) + XCTAssertTrue(statements[0].isRefusedByDefault) + XCTAssertEqual(statements[0].hazards.first?.kind, .dataLoss) + } + + func testCreateTableCarriesNoHazard() throws { + let statements = try builder.build( + operations: [.createTable(snapshot("users"))], + foreignKeysByTable: [:] + ) + + XCTAssertEqual(statements.count, 1) + XCTAssertFalse(statements[0].isRefusedByDefault) + XCTAssertTrue(statements[0].hazards.isEmpty) + } + + func testEveryStatementIsTerminated() throws { + let statements = try builder.build( + operations: [.createTable(snapshot("users")), .dropTable(name: "old", schema: nil)], + foreignKeysByTable: [:] + ) + + for statement in statements { + XCTAssertTrue(statement.sql.hasSuffix(";"), "\(statement.sql) is not terminated") + } + } +} + +final class SyncSafetyClassifierTests: XCTestCase { + private let classifier = SyncSafetyClassifier() + + private func column(_ name: String, _ dataType: String, nullable: Bool = true) -> EditableColumnDefinition { + EditableColumnDefinition( + id: UUID(), name: name, dataType: dataType, isNullable: nullable, defaultValue: nil, + autoIncrement: false, unsigned: false, comment: nil, collation: nil, + onUpdate: nil, charset: nil, extra: nil, isPrimaryKey: false + ) + } + + func testDropColumnIsRefusedByDefault() { + let hazards = classifier.hazards(for: .deleteColumn(column("email", "varchar(255)"))) + + XCTAssertEqual(hazards.first?.severity, .refusedByDefault) + XCTAssertEqual(hazards.first?.kind, .dataLoss) + } + + func testNarrowingTypeChangeIsRefused() { + let hazards = classifier.hazards(for: .modifyColumn( + old: column("name", "varchar(255)"), + new: column("name", "varchar(50)") + )) + + XCTAssertTrue(hazards.contains { $0.kind == .lossyTypeChange && $0.severity == .refusedByDefault }) + } + + func testWideningTypeChangeIsNotRefused() { + let hazards = classifier.hazards(for: .modifyColumn( + old: column("name", "varchar(50)"), + new: column("name", "varchar(255)") + )) + + XCTAssertFalse(hazards.contains { $0.kind == .lossyTypeChange }) + } + + func testMakingColumnNotNullIsRefused() { + let hazards = classifier.hazards(for: .modifyColumn( + old: column("email", "varchar(50)", nullable: true), + new: column("email", "varchar(50)", nullable: false) + )) + + XCTAssertTrue(hazards.contains { $0.kind == .dataLoss && $0.severity == .refusedByDefault }) + } + + func testAddColumnCarriesNoHazard() { + XCTAssertTrue(classifier.hazards(for: .addColumn(column("nickname", "varchar(20)"))).isEmpty) + } + + func testPrimaryKeyChangeIsRefused() { + let hazards = classifier.hazards(for: .modifyPrimaryKey(old: ["id"], new: ["id", "tenant"])) + + XCTAssertEqual(hazards.first?.severity, .refusedByDefault) + } +} + +final class CompareSyncEngineFamilyTests: XCTestCase { + func testSameTypeIsAlwaysAllowed() { + XCTAssertTrue(CompareSyncEngineFamily.canGenerateStructureScript(from: .postgresql, to: .postgresql)) + } + + func testMySqlAndMariaDbAreCompatibleInBothDirections() { + XCTAssertTrue(CompareSyncEngineFamily.canGenerateStructureScript(from: .mysql, to: .mariadb)) + XCTAssertTrue(CompareSyncEngineFamily.canGenerateStructureScript(from: .mariadb, to: .mysql)) + } + + func testUnrelatedEnginesAreRefused() { + XCTAssertFalse(CompareSyncEngineFamily.canGenerateStructureScript(from: .postgresql, to: .mysql)) + } + + func testUnknownFutureTypeIsCompatibleOnlyWithItself() { + let future = DatabaseType(rawValue: "SomeFutureEngine") + + XCTAssertTrue(CompareSyncEngineFamily.canGenerateStructureScript(from: future, to: future)) + XCTAssertFalse(CompareSyncEngineFamily.canGenerateStructureScript(from: future, to: .mysql)) + } + + func testCrossEngineDataWarningOnlyAppearsWhenTypesDiffer() { + XCTAssertNil(CompareSyncEngineFamily.crossEngineDataWarning(from: .mysql, to: .mysql)) + XCTAssertNotNil(CompareSyncEngineFamily.crossEngineDataWarning(from: .mysql, to: .postgresql)) + } +} diff --git a/TableProTests/Core/Compare/SourceObjectDiffEngineTests.swift b/TableProTests/Core/Compare/SourceObjectDiffEngineTests.swift new file mode 100644 index 000000000..d6cda302d --- /dev/null +++ b/TableProTests/Core/Compare/SourceObjectDiffEngineTests.swift @@ -0,0 +1,193 @@ +// +// SourceObjectDiffEngineTests.swift +// TableProTests +// +// Views, procedures, functions and triggers have no parsed structure to compare: their body IS +// the definition. What the engine has to get right is the matching, so an overloaded routine is +// not confused with its sibling, and the normalising, so a formatting difference is not reported +// as a real one. +// + +@testable import TablePro +import XCTest + +final class SourceObjectDiffEngineTests: XCTestCase { + private func read( + _ name: String, + kind: CompareObjectKind = .function, + schema: String? = "public", + signature: String? = nil, + source: String + ) -> RoutineSourceRead { + RoutineSourceRead(name: name, kind: kind, schema: schema, signature: signature, source: source) + } + + private func engine(_ options: StructureCompareOptions = .default) -> SourceObjectDiffEngine { + SourceObjectDiffEngine(options: options) + } + + // MARK: - Status + + func testAnObjectOnlyOnTheSourceIsCreated() { + let results = engine().compare( + source: [read("audit", source: "BEGIN END")], + target: [] + ) + + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results[0].status, .onlyInSource) + XCTAssertEqual(results[0].suggestedAction, .create) + } + + func testAnObjectOnlyOnTheTargetIsDropped() { + let results = engine().compare( + source: [], + target: [read("stale", source: "BEGIN END")] + ) + + XCTAssertEqual(results[0].status, .onlyInTarget) + XCTAssertEqual(results[0].suggestedAction, .drop) + } + + func testAMatchingDefinitionIsIdentical() { + let results = engine().compare( + source: [read("audit", source: "BEGIN\n SELECT 1;\nEND")], + target: [read("audit", source: "BEGIN\n SELECT 1;\nEND")] + ) + + XCTAssertEqual(results[0].status, .identical) + XCTAssertEqual(results[0].suggestedAction, .skip) + } + + func testADifferentDefinitionIsAlter() { + let results = engine().compare( + source: [read("audit", source: "BEGIN SELECT 1; END")], + target: [read("audit", source: "BEGIN SELECT 2; END")] + ) + + XCTAssertEqual(results[0].status, .differs) + XCTAssertEqual(results[0].suggestedAction, .alter) + XCTAssertFalse(results[0].sourceDefinition.isEmpty) + XCTAssertFalse(results[0].targetDefinition.isEmpty) + } + + // MARK: - Normalising + + func testTrailingSemicolonsAndLineEndingsAreNotADifference() { + let results = engine().compare( + source: [read("audit", source: "BEGIN SELECT 1; END;")], + target: [read("audit", source: "BEGIN SELECT 1; END\r\n")] + ) + + XCTAssertEqual(results[0].status, .identical) + } + + func testWhitespaceIsADifferenceUntilItIsIgnored() { + let source = [read("audit", source: "BEGIN\n SELECT 1;\nEND")] + let target = [read("audit", source: "BEGIN SELECT 1; END")] + + XCTAssertEqual(engine(strict()).compare(source: source, target: target)[0].status, .differs) + + var lenient = StructureCompareOptions.default + lenient.ignoreWhitespaceInText = true + XCTAssertEqual(engine(lenient).compare(source: source, target: target)[0].status, .identical) + } + + func testIdentifierCaseIsADifferenceUntilItIsIgnored() { + let source = [read("audit", source: "BEGIN SELECT 1; END")] + let target = [read("audit", source: "begin select 1; end")] + + XCTAssertEqual(engine(strict()).compare(source: source, target: target)[0].status, .differs) + + var lenient = StructureCompareOptions.default + lenient.ignoreIdentifierCase = true + XCTAssertEqual(engine(lenient).compare(source: source, target: target)[0].status, .identical) + } + + // MARK: - Matching + + /// PostgreSQL and Oracle both allow two routines to share a name, so the signature is part of + /// the identity. Without it one overload is compared against the other. + func testTwoOverloadsOfOneNameAreMatchedBySignature() { + let results = engine().compare( + source: [ + read("area", signature: "(integer)", source: "SELECT 1"), + read("area", signature: "(geometry)", source: "SELECT 2") + ], + target: [ + read("area", signature: "(geometry)", source: "SELECT 2") + ] + ) + + XCTAssertEqual(results.count, 2) + XCTAssertEqual(results.filter { $0.status == .identical }.count, 1) + XCTAssertEqual(results.filter { $0.status == .onlyInSource }.count, 1) + } + + func testTwoKindsSharingOneNameAreNotMatched() { + let results = engine().compare( + source: [read("audit", kind: .function, source: "SELECT 1")], + target: [read("audit", kind: .procedure, source: "SELECT 1")] + ) + + XCTAssertEqual(results.count, 2) + XCTAssertEqual(Set(results.map { $0.status }), [.onlyInSource, .onlyInTarget]) + } + + func testTwoSchemasSharingOneNameAreNotMatched() { + let results = engine().compare( + source: [read("audit", schema: "public", source: "SELECT 1")], + target: [read("audit", schema: "sales", source: "SELECT 1")] + ) + + XCTAssertEqual(results.count, 2) + } + + // MARK: - Missing definitions + + /// A driver that lists a routine but cannot return its body must not report it as identical to + /// another routine whose body is also empty. + func testAnObjectWithNoDefinitionCarriesANote() { + let results = engine().compare( + source: [read("audit", source: "")], + target: [] + ) + + XCTAssertFalse(results[0].notes.isEmpty) + } + + private func strict() -> StructureCompareOptions { + var options = StructureCompareOptions.default + options.ignoreWhitespaceInText = false + options.ignoreIdentifierCase = false + return options + } +} + +final class SourceObjectHazardTests: XCTestCase { + private let classifier = SyncSafetyClassifier() + + private func identity(_ kind: CompareObjectKind) -> CompareObjectIdentity { + CompareObjectIdentity(kind: kind, schema: "public", name: "reporting") + } + + /// A view holds no rows, so dropping one is recoverable from the source and only warns. A + /// materialized view does hold rows, so it is refused like a table. + func testDroppingAViewIsRefusedButDroppingAMaterializedViewAlsoWarnsAboutItsRows() { + let view = classifier.hazards(forDropping: identity(.view), isReplacement: false) + let materialized = classifier.hazards(forDropping: identity(.materializedView), isReplacement: false) + + XCTAssertTrue(view.contains { $0.severity == .refusedByDefault }) + XCTAssertTrue(materialized.contains { $0.severity == .refusedByDefault }) + XCTAssertGreaterThan(materialized.count, view.count) + } + + /// A replace drops and recreates, so it warns about dependents rather than about losing the + /// object: the object comes straight back. + func testAReplacementWarnsAboutDependentsRatherThanBeingRefused() { + let hazards = classifier.hazards(forDropping: identity(.function), isReplacement: true) + + XCTAssertFalse(hazards.contains { $0.severity == .refusedByDefault }) + XCTAssertTrue(hazards.contains { $0.severity == .warning }) + } +} diff --git a/TableProTests/Core/Compare/StreamingRowProviderTests.swift b/TableProTests/Core/Compare/StreamingRowProviderTests.swift new file mode 100644 index 000000000..9aca8f7da --- /dev/null +++ b/TableProTests/Core/Compare/StreamingRowProviderTests.swift @@ -0,0 +1,160 @@ +// +// StreamingRowProviderTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import XCTest + +@testable import TablePro + +final class StreamingRowProviderTests: XCTestCase { + private func stream(_ elements: [PluginStreamElement]) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + for element in elements { continuation.yield(element) } + continuation.finish() + } + } + + private func header(_ columns: [String]) -> PluginStreamElement { + .header(PluginStreamHeader(columns: columns, columnTypeNames: columns.map { _ in "text" })) + } + + func testHeaderColumnsAreUsedToNameValues() async throws { + let provider = StreamingRowProvider( + stream: stream([header(["id", "name"]), .rows([[.text("1"), .text("a")]])]) + ) + + let row = try await provider.nextRow() + + XCTAssertEqual(row?.value(for: "id"), .text("1")) + XCTAssertEqual(row?.value(for: "name"), .text("a")) + } + + func testExplicitColumnsSurviveAMissingHeader() async throws { + let provider = StreamingRowProvider( + stream: stream([.rows([[.text("1"), .text("a")]])]), + columns: ["id", "name"] + ) + + let row = try await provider.nextRow() + + XCTAssertEqual(row?.value(for: "name"), .text("a")) + } + + func testRowsAcrossMultipleBatchesAreAllReturnedInOrder() async throws { + let provider = StreamingRowProvider(stream: stream([ + header(["id"]), + .rows([[.text("1")], [.text("2")]]), + .rows([[.text("3")]]) + ])) + + var seen: [String] = [] + while let row = try await provider.nextRow() { + if case .text(let value) = row.value(for: "id") { seen.append(value) } + } + + XCTAssertEqual(seen, ["1", "2", "3"]) + } + + func testEmptyBatchesAreSkippedRatherThanEndingTheStream() async throws { + let provider = StreamingRowProvider(stream: stream([ + header(["id"]), + .rows([]), + .rows([]), + .rows([[.text("1")]]) + ])) + + let row = try await provider.nextRow() + + XCTAssertEqual(row?.value(for: "id"), .text("1"), "empty batches must not be mistaken for end of stream") + } + + func testExhaustedStreamReturnsNilRepeatedly() async throws { + let provider = StreamingRowProvider(stream: stream([header(["id"]), .rows([[.text("1")]])])) + + _ = try await provider.nextRow() + + let firstNil = try await provider.nextRow() + let secondNil = try await provider.nextRow() + XCTAssertNil(firstNil) + XCTAssertNil(secondNil) + } + + func testShortRowDoesNotOverrunTheColumnList() async throws { + let provider = StreamingRowProvider(stream: stream([ + header(["id", "name", "extra"]), + .rows([[.text("1")]]) + ])) + + let row = try await provider.nextRow() + + XCTAssertEqual(row?.value(for: "id"), .text("1")) + XCTAssertEqual(row?.value(for: "name"), .null, "a column with no value reads as NULL") + } + + func testErrorsFromTheStreamPropagate() async { + struct Boom: Error {} + let failing = AsyncThrowingStream { continuation in + continuation.yield(self.header(["id"])) + continuation.finish(throwing: Boom()) + } + let provider = StreamingRowProvider(stream: failing) + + do { + _ = try await provider.nextRow() + XCTFail("Expected the stream error to surface") + } catch { + XCTAssertTrue(error is Boom) + } + } +} + +final class KeyOrderedQueryTests: XCTestCase { + private final class Quoting: PluginDatabaseDriver, @unchecked Sendable { + func quoteIdentifier(_ name: String) -> String { "\"\(name)\"" } + func connect() async throws {} + func disconnect() {} + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } + } + + func testQuerySelectsRequestedColumnsOrderedByKey() { + let sql = KeyOrderedQuery.build( + table: "users", schema: nil, columns: ["id", "name"], keyColumns: ["id"], driver: Quoting() + ) + + XCTAssertEqual(sql, "SELECT \"id\", \"name\" FROM \"users\" ORDER BY \"id\"") + } + + func testCompositeKeyOrdersByEveryKeyColumn() { + let sql = KeyOrderedQuery.build( + table: "t", schema: nil, columns: ["a"], keyColumns: ["tenant", "id"], driver: Quoting() + ) + + XCTAssertTrue(sql.hasSuffix("ORDER BY \"tenant\", \"id\""), sql) + } + + func testSchemaIsQualifiedAndQuoted() { + let sql = KeyOrderedQuery.build( + table: "users", schema: "app", columns: ["id"], keyColumns: ["id"], driver: Quoting() + ) + + XCTAssertTrue(sql.contains("FROM \"app\".\"users\""), sql) + } +} diff --git a/TableProTests/Core/Compare/StructureDiffEngineTests.swift b/TableProTests/Core/Compare/StructureDiffEngineTests.swift new file mode 100644 index 000000000..a2b7f3d65 --- /dev/null +++ b/TableProTests/Core/Compare/StructureDiffEngineTests.swift @@ -0,0 +1,356 @@ +// +// StructureDiffEngineTests.swift +// TableProTests +// + +import Foundation +import XCTest + +@testable import TablePro + +final class StructureDiffEngineTests: XCTestCase { + private func column( + _ name: String, + _ dataType: String = "int", + nullable: Bool = true, + defaultValue: String? = nil, + autoIncrement: Bool = false, + comment: String? = nil, + collation: String? = nil, + charset: String? = nil, + extra: String? = nil, + primaryKey: Bool = false + ) -> EditableColumnDefinition { + EditableColumnDefinition( + id: UUID(), + name: name, + dataType: dataType, + isNullable: nullable, + defaultValue: defaultValue, + autoIncrement: autoIncrement, + unsigned: false, + comment: comment, + collation: collation, + onUpdate: nil, + charset: charset, + extra: extra, + isPrimaryKey: primaryKey + ) + } + + private func index( + _ name: String, + columns: [String], + unique: Bool = false, + primary: Bool = false + ) -> EditableIndexDefinition { + EditableIndexDefinition( + id: UUID(), + name: name, + columns: columns, + type: .btree, + isUnique: unique, + isPrimary: primary, + comment: nil, + columnPrefixes: [:], + whereClause: nil + ) + } + + private func foreignKey( + _ name: String, + columns: [String], + referencedTable: String, + referencedColumns: [String] + ) -> EditableForeignKeyDefinition { + EditableForeignKeyDefinition( + id: UUID(), + name: name, + columns: columns, + referencedTable: referencedTable, + referencedColumns: referencedColumns, + referencedSchema: nil, + onDelete: .noAction, + onUpdate: .noAction + ) + } + + private func table( + _ name: String, + columns: [EditableColumnDefinition], + indexes: [EditableIndexDefinition] = [], + foreignKeys: [EditableForeignKeyDefinition] = [], + engine: String? = nil, + collation: String? = nil + ) -> TableStructureSnapshot { + TableStructureSnapshot( + name: name, + schema: nil, + columns: columns, + indexes: indexes, + foreignKeys: foreignKeys, + engine: engine, + charset: nil, + collation: collation + ) + } + + // MARK: - Table presence + + func testTableOnlyInSourceIsCreateAndDefaultsToSkip() { + let engine = StructureDiffEngine() + let report = engine.compare( + source: [table("users", columns: [column("id")])], + target: [] + ) + + XCTAssertEqual(report.results.count, 1) + XCTAssertEqual(report.results[0].status, .onlyInSource) + XCTAssertEqual(report.results[0].suggestedAction, .create) + } + + func testTableOnlyInTargetIsDrop() { + let engine = StructureDiffEngine() + let report = engine.compare( + source: [], + target: [table("legacy", columns: [column("id")])] + ) + + XCTAssertEqual(report.results[0].status, .onlyInTarget) + XCTAssertEqual(report.results[0].suggestedAction, .drop) + } + + func testIdenticalTablesReportIdentical() { + let engine = StructureDiffEngine() + let source = table("users", columns: [column("id"), column("name", "varchar(20)")]) + let target = table("users", columns: [column("id"), column("name", "varchar(20)")]) + + let result = engine.compareTable(source: source, target: target) + + XCTAssertEqual(result.status, .identical) + XCTAssertTrue(result.changes.isEmpty) + } + + // MARK: - Column changes, direction is target becomes source + + func testMissingColumnInTargetProducesAddColumn() { + let engine = StructureDiffEngine() + let result = engine.compareTable( + source: table("users", columns: [column("id"), column("email", "varchar(255)")]), + target: table("users", columns: [column("id")]) + ) + + XCTAssertEqual(result.status, .differs) + guard case .addColumn(let added) = result.changes.first else { + return XCTFail("Expected addColumn, got \(result.changes)") + } + XCTAssertEqual(added.name, "email") + } + + func testExtraColumnInTargetProducesDeleteColumn() { + let engine = StructureDiffEngine() + let result = engine.compareTable( + source: table("users", columns: [column("id")]), + target: table("users", columns: [column("id"), column("obsolete")]) + ) + + guard case .deleteColumn(let removed) = result.changes.first else { + return XCTFail("Expected deleteColumn, got \(result.changes)") + } + XCTAssertEqual(removed.name, "obsolete") + } + + func testModifyColumnCarriesTargetAsOldAndSourceAsNew() { + let engine = StructureDiffEngine() + let result = engine.compareTable( + source: table("users", columns: [column("id", "bigint")]), + target: table("users", columns: [column("id", "int")]) + ) + + guard case .modifyColumn(let old, let new) = result.changes.first else { + return XCTFail("Expected modifyColumn, got \(result.changes)") + } + XCTAssertEqual(old.dataType, "int", "old must be the target's current state") + XCTAssertEqual(new.dataType, "bigint", "new must be the source's desired state") + } + + func testNullabilityChangeIsDetected() { + let engine = StructureDiffEngine() + let result = engine.compareTable( + source: table("users", columns: [column("id", nullable: false)]), + target: table("users", columns: [column("id", nullable: true)]) + ) + + XCTAssertEqual(result.status, .differs) + XCTAssertEqual(result.changes.count, 1) + } + + // MARK: - Ignore options each kill a named false positive + + func testIdentifierCaseOnlyDifferenceIsIgnoredByDefault() { + let result = StructureDiffEngine().compareTable( + source: table("Users", columns: [column("ID")]), + target: table("users", columns: [column("id")]) + ) + + XCTAssertEqual(result.status, .identical) + } + + func testIdentifierCaseDifferenceIsReportedWhenOptionOff() { + var options = StructureCompareOptions() + options.ignoreIdentifierCase = false + let result = StructureDiffEngine(options: options).compareTable( + source: table("users", columns: [column("ID")]), + target: table("users", columns: [column("id")]) + ) + + XCTAssertEqual(result.status, .differs) + } + + func testAutoIncrementSeedOnlyDifferenceIsIgnoredByDefault() { + let result = StructureDiffEngine().compareTable( + source: table("users", columns: [column("id", extra: "auto_increment=1522")]), + target: table("users", columns: [column("id", extra: "auto_increment=184")]) + ) + + XCTAssertEqual(result.status, .identical, "AUTO_INCREMENT drift must not be a difference") + } + + func testWhitespaceOnlyDefaultDifferenceIsIgnoredByDefault() { + let result = StructureDiffEngine().compareTable( + source: table("users", columns: [column("flag", defaultValue: "0")]), + target: table("users", columns: [column("flag", defaultValue: " 0 ")]) + ) + + XCTAssertEqual(result.status, .identical) + } + + func testCollationOnlyDifferenceIsIgnoredByDefaultAndReportedWhenOptionOff() { + let source = table("users", columns: [column("name", "varchar(20)", collation: "utf8mb4_general_ci")]) + let target = table("users", columns: [column("name", "varchar(20)", collation: "utf8mb4_unicode_ci")]) + + XCTAssertEqual(StructureDiffEngine().compareTable(source: source, target: target).status, .identical) + + var options = StructureCompareOptions() + options.ignoreCollationAndCharset = false + XCTAssertEqual( + StructureDiffEngine(options: options).compareTable(source: source, target: target).status, + .differs + ) + } + + func testCommentOnlyDifferenceIsIgnoredByDefaultAndReportedWhenOptionOff() { + let source = table("users", columns: [column("id", comment: "primary id")]) + let target = table("users", columns: [column("id", comment: nil)]) + + XCTAssertEqual(StructureDiffEngine().compareTable(source: source, target: target).status, .identical) + + var options = StructureCompareOptions() + options.ignoreCommentsAndOwners = false + XCTAssertEqual( + StructureDiffEngine(options: options).compareTable(source: source, target: target).status, + .differs + ) + } + + func testColumnOrderIsIgnoredByDefaultAndNotedWhenOptionOff() { + let source = table("users", columns: [column("a"), column("b")]) + let target = table("users", columns: [column("b"), column("a")]) + + XCTAssertEqual(StructureDiffEngine().compareTable(source: source, target: target).status, .identical) + + var options = StructureCompareOptions() + options.ignoreColumnOrder = false + let result = StructureDiffEngine(options: options).compareTable(source: source, target: target) + XCTAssertEqual(result.status, .differs) + XCTAssertTrue(result.changes.isEmpty, "reordering emits no DDL") + XCTAssertEqual(result.notes.count, 1) + } + + // MARK: - Name-only differences never mark an object changed + + func testIndexMatchedOnStructureDespiteGeneratedNameDifference() { + let result = StructureDiffEngine().compareTable( + source: table("users", columns: [column("email")], indexes: [index("idx_a1b2", columns: ["email"])]), + target: table("users", columns: [column("email")], indexes: [index("idx_c3d4", columns: ["email"])]) + ) + + XCTAssertTrue(result.changes.isEmpty, "a name-only index difference must emit no DDL") + XCTAssertEqual(result.notes.count, 1) + } + + func testIndexColumnDifferenceIsARealChange() { + let result = StructureDiffEngine().compareTable( + source: table("users", columns: [column("a"), column("b")], indexes: [index("i", columns: ["a", "b"])]), + target: table("users", columns: [column("a"), column("b")], indexes: [index("i", columns: ["a"])]) + ) + + XCTAssertEqual(result.changes.count, 2, "expected one add and one delete") + } + + func testForeignKeyMatchedOnStructureDespiteNameDifference() { + let source = table( + "orders", + columns: [column("user_id")], + foreignKeys: [foreignKey("fk_1", columns: ["user_id"], referencedTable: "users", referencedColumns: ["id"])] + ) + let target = table( + "orders", + columns: [column("user_id")], + foreignKeys: [foreignKey("fk_2", columns: ["user_id"], referencedTable: "users", referencedColumns: ["id"])] + ) + + let result = StructureDiffEngine().compareTable(source: source, target: target) + + XCTAssertTrue(result.changes.isEmpty) + XCTAssertEqual(result.notes.count, 1) + } + + // MARK: - Primary key + + func testPrimaryKeyDifferenceProducesSingleModifyPrimaryKey() { + let source = table("users", columns: [column("id", primaryKey: true), column("tenant", primaryKey: true)]) + let target = table("users", columns: [column("id", primaryKey: true), column("tenant")]) + + let result = StructureDiffEngine().compareTable(source: source, target: target) + + let primaryKeyChanges = result.changes.filter { + if case .modifyPrimaryKey = $0 { return true } + return false + } + XCTAssertEqual(primaryKeyChanges.count, 1) + guard case .modifyPrimaryKey(let old, let new) = primaryKeyChanges[0] else { + return XCTFail("Expected modifyPrimaryKey") + } + XCTAssertEqual(old, ["id"]) + XCTAssertEqual(new, ["id", "tenant"]) + } + + func testPrimaryKeyMembershipAloneDoesNotAlsoEmitModifyColumn() { + let source = table("users", columns: [column("id", primaryKey: true)]) + let target = table("users", columns: [column("id", primaryKey: false)]) + + let result = StructureDiffEngine().compareTable(source: source, target: target) + + let columnChanges = result.changes.filter { + if case .modifyColumn = $0 { return true } + return false + } + XCTAssertTrue(columnChanges.isEmpty, "primary key membership is reported once, via modifyPrimaryKey") + } + + // MARK: - Symmetry + + func testComparisonIsSymmetricInTheObjectsItReports() { + let engine = StructureDiffEngine() + let left = [table("a", columns: [column("id")]), table("b", columns: [column("id")])] + let right = [table("b", columns: [column("id")]), table("c", columns: [column("id")])] + + let forward = engine.compare(source: left, target: right) + let backward = engine.compare(source: right, target: left) + + XCTAssertEqual(forward.count(of: .onlyInSource), backward.count(of: .onlyInTarget)) + XCTAssertEqual(forward.count(of: .onlyInTarget), backward.count(of: .onlyInSource)) + XCTAssertEqual(forward.count(of: .identical), backward.count(of: .identical)) + } +} diff --git a/TableProTests/Core/Compare/TableDefinitionRendererTests.swift b/TableProTests/Core/Compare/TableDefinitionRendererTests.swift new file mode 100644 index 000000000..0264fa13c --- /dev/null +++ b/TableProTests/Core/Compare/TableDefinitionRendererTests.swift @@ -0,0 +1,214 @@ +// +// TableDefinitionRendererTests.swift +// TableProTests +// + +import Foundation +import XCTest + +@testable import TablePro + +final class TableDefinitionRendererTests: XCTestCase { + private func column( + _ name: String, + _ dataType: String = "int", + nullable: Bool = true, + primaryKey: Bool = false, + comment: String? = nil + ) -> EditableColumnDefinition { + EditableColumnDefinition( + id: UUID(), name: name, dataType: dataType, isNullable: nullable, defaultValue: nil, + autoIncrement: false, unsigned: false, comment: comment, collation: nil, + onUpdate: nil, charset: nil, extra: nil, isPrimaryKey: primaryKey + ) + } + + private func index(_ name: String, columns: [String], unique: Bool = false) -> EditableIndexDefinition { + EditableIndexDefinition( + id: UUID(), name: name, columns: columns, type: .btree, isUnique: unique, + isPrimary: false, comment: nil, columnPrefixes: [:], whereClause: nil + ) + } + + func testRendersColumnsPrimaryKeyAndIndexes() { + let snapshot = TableStructureSnapshot( + name: "users", + columns: [column("id", "int", nullable: false, primaryKey: true), column("email", "varchar(255)")], + indexes: [index("idx_email", columns: ["email"], unique: true)] + ) + + let lines = TableDefinitionRenderer.lines(for: snapshot) + + XCTAssertEqual(lines.first, "TABLE users") + XCTAssertTrue(lines.contains { $0.contains("COLUMN id int NOT NULL") }) + XCTAssertTrue(lines.contains { $0.contains("COLUMN email varchar(255) NULL") }) + XCTAssertTrue(lines.contains(" PRIMARY KEY (id)")) + XCTAssertTrue(lines.contains { $0.contains("UNIQUE INDEX idx_email (email)") }) + } + + func testRenderingIsDeterministicRegardlessOfIndexOrder() { + let first = TableStructureSnapshot( + name: "t", + columns: [column("id")], + indexes: [index("b_idx", columns: ["a"]), index("a_idx", columns: ["b"])] + ) + let second = TableStructureSnapshot( + name: "t", + columns: [column("id")], + indexes: [index("a_idx", columns: ["b"]), index("b_idx", columns: ["a"])] + ) + + XCTAssertEqual( + TableDefinitionRenderer.lines(for: first), + TableDefinitionRenderer.lines(for: second), + "index declaration order must not change the rendered definition" + ) + } + + func testIdenticalSnapshotsRenderIdenticallySoTheDiffIsEmpty() { + let snapshot = TableStructureSnapshot( + name: "users", + columns: [column("id", nullable: false), column("name", "varchar(20)", comment: "hi")], + indexes: [index("i", columns: ["name"])] + ) + + let pairs = DiffComputer.computeSplit( + before: TableDefinitionRenderer.lines(for: snapshot), + after: TableDefinitionRenderer.lines(for: snapshot) + ) + + XCTAssertTrue(pairs.allSatisfy { $0.kind == .unchanged }) + } + + func testChangedColumnTypeProducesAChangedLine() { + let target = TableStructureSnapshot(name: "t", columns: [column("id", "int")]) + let source = TableStructureSnapshot(name: "t", columns: [column("id", "bigint")]) + + let pairs = DiffComputer.computeSplit( + before: TableDefinitionRenderer.lines(for: target), + after: TableDefinitionRenderer.lines(for: source) + ) + + XCTAssertTrue(pairs.contains { $0.kind != .unchanged }) + } +} + +@MainActor +final class CompareSyncProfileStorageTests: XCTestCase { + private var defaults: UserDefaults! + private var storage: CompareSyncProfileStorage! + private let suiteName = "CompareSyncProfileStorageTests" + + override func setUp() { + super.setUp() + UserDefaults().removePersistentDomain(forName: suiteName) + defaults = UserDefaults(suiteName: suiteName) + storage = CompareSyncProfileStorage(defaults: defaults) + } + + override func tearDown() { + UserDefaults().removePersistentDomain(forName: suiteName) + defaults = nil + storage = nil + super.tearDown() + } + + private func scope(_ connectionId: UUID, database: String = "app", schema: String? = nil) -> DatabaseScope { + DatabaseScope(connectionId: connectionId, database: database, schema: schema) + } + + private func profile( + name: String, + source: DatabaseScope, + target: DatabaseScope, + mode: CompareSyncMode = .structure + ) -> CompareSyncProfile { + CompareSyncProfile( + name: name, + source: source, + target: target, + mode: mode, + structureOptions: .default, + dataOptions: .default, + selectedObjects: ["users"] + ) + } + + func testSavedProfileRoundTrips() { + let source = scope(UUID()) + let target = scope(UUID()) + storage.save(profile(name: "nightly", source: source, target: target)) + + let loaded = storage.profiles(source: source, target: target, mode: .structure) + + XCTAssertEqual(loaded.count, 1) + XCTAssertEqual(loaded[0].name, "nightly") + XCTAssertEqual(loaded[0].selectedObjects, ["users"]) + } + + func testProfilesAreScopedToSourceTargetAndMode() { + let source = scope(UUID()) + let target = scope(UUID()) + storage.save(profile(name: "structure", source: source, target: target, mode: .structure)) + storage.save(profile(name: "data", source: source, target: target, mode: .data)) + + XCTAssertEqual(storage.profiles(source: source, target: target, mode: .structure).map(\.name), ["structure"]) + XCTAssertEqual(storage.profiles(source: source, target: target, mode: .data).map(\.name), ["data"]) + XCTAssertTrue(storage.profiles(source: target, target: source, mode: .structure).isEmpty) + } + + /// Keying on the connection pair alone could not tell two databases on one server apart, so a + /// comparison saved against staging came back for production. + func testTwoDatabasesOnOneConnectionKeepSeparateProfiles() { + let connectionId = UUID() + let staging = scope(connectionId, database: "app_staging") + let production = scope(connectionId, database: "app_prod") + let target = scope(UUID()) + storage.save(profile(name: "staging", source: staging, target: target)) + storage.save(profile(name: "production", source: production, target: target)) + + XCTAssertEqual(storage.profiles(source: staging, target: target, mode: .structure).map(\.name), ["staging"]) + XCTAssertEqual( + storage.profiles(source: production, target: target, mode: .structure).map(\.name), ["production"] + ) + } + + func testTwoSchemasInOneDatabaseKeepSeparateProfiles() { + let connectionId = UUID() + let publicSchema = scope(connectionId, schema: "public") + let salesSchema = scope(connectionId, schema: "sales") + let target = scope(UUID()) + storage.save(profile(name: "public", source: publicSchema, target: target)) + storage.save(profile(name: "sales", source: salesSchema, target: target)) + + XCTAssertEqual(storage.profiles(source: publicSchema, target: target, mode: .structure).map(\.name), ["public"]) + XCTAssertEqual(storage.profiles(source: salesSchema, target: target, mode: .structure).map(\.name), ["sales"]) + } + + func testSavingSameProfileIdUpdatesRatherThanDuplicates() { + let source = scope(UUID()) + let target = scope(UUID()) + var existing = profile(name: "first", source: source, target: target) + storage.save(existing) + existing.name = "renamed" + storage.save(existing) + + let loaded = storage.profiles(source: source, target: target, mode: .structure) + + XCTAssertEqual(loaded.count, 1) + XCTAssertEqual(loaded[0].name, "renamed") + } + + func testDeleteRemovesOnlyThatProfile() { + let source = scope(UUID()) + let target = scope(UUID()) + let keep = profile(name: "keep", source: source, target: target) + let drop = profile(name: "drop", source: source, target: target) + storage.save(keep) + storage.save(drop) + + storage.delete(drop) + + XCTAssertEqual(storage.profiles(source: source, target: target, mode: .structure).map(\.name), ["keep"]) + } +} diff --git a/TableProTests/Core/Compare/TimestampValueTests.swift b/TableProTests/Core/Compare/TimestampValueTests.swift new file mode 100644 index 000000000..08ddb36fd --- /dev/null +++ b/TableProTests/Core/Compare/TimestampValueTests.swift @@ -0,0 +1,116 @@ +// +// TimestampValueTests.swift +// TableProTests +// +// `DateFormatter` clamps a fractional second to milliseconds whatever the pattern says, so +// `.123456` and `.123457` parsed to the same instant and every microsecond difference between two +// `timestamp(6)` columns read as identical no matter what precision the user asked for. The +// comparison then scaled a `Double` seconds value by up to 1e9, which leaves Double's +// exact-integer range and puts the same class of precision loss back into the fixed path. +// + +@testable import TablePro +import XCTest + +final class TimestampValueTests: XCTestCase { + private func value(_ text: String) throws -> TimestampValue { + try XCTUnwrap(TimestampValue.parse(text), "\(text) should parse as a timestamp") + } + + // MARK: - Sub-second precision + + func testMicrosecondDifferenceIsNotIdenticalAtSixDigits() throws { + let earlier = try value("2024-01-01 10:00:00.123456") + let later = try value("2024-01-01 10:00:00.123457") + + XCTAssertFalse(earlier.equals(later, fractionalDigits: 6)) + } + + func testMicrosecondDifferenceIsIdenticalAtThreeDigits() throws { + let earlier = try value("2024-01-01 10:00:00.123456") + let later = try value("2024-01-01 10:00:00.123999") + + XCTAssertTrue(earlier.equals(later, fractionalDigits: 3)) + } + + func testNanosecondDifferenceIsNotIdenticalAtNineDigits() throws { + let earlier = try value("2024-01-01 10:00:00.123456789") + let later = try value("2024-01-01 10:00:00.123456790") + + XCTAssertFalse(earlier.equals(later, fractionalDigits: 9)) + XCTAssertTrue(earlier.equals(later, fractionalDigits: 6)) + } + + func testWholeSecondsCompareEqualAtEveryPrecision() throws { + let first = try value("2024-01-01 10:00:00") + let second = try value("2024-01-01T10:00:00") + + for digits in 0 ... 9 { + XCTAssertTrue(first.equals(second, fractionalDigits: digits)) + } + } + + func testFractionalDigitsAreReadExactlyRatherThanRounded() throws { + let value = try value("2024-01-01 00:00:00.5") + + XCTAssertEqual(value.nanosecondsSinceEpoch % 1_000_000_000, 500_000_000) + } + + func testMoreThanNineFractionalDigitsAreTruncatedNotMisread() throws { + let value = try value("2024-01-01 00:00:00.1234567891234") + + XCTAssertEqual(value.nanosecondsSinceEpoch % 1_000_000_000, 123_456_789) + } + + // MARK: - Offsets + + /// The same instant written at two offsets is one instant, so it is never a difference. + func testEquivalentInstantsAtDifferentOffsetsCompareEqual() throws { + let utc = try value("2024-01-01 10:00:00+00:00") + let offset = try value("2024-01-01 15:30:00+05:30") + + XCTAssertTrue(utc.equals(offset, fractionalDigits: 6)) + } + + func testOffsetWithFractionalSecondsParses() throws { + let utc = try value("2024-01-01 10:00:00.250000+00:00") + + XCTAssertEqual(utc.nanosecondsSinceEpoch % 1_000_000_000, 250_000_000) + } + + // MARK: - Non-timestamps + + func testShortStringsAndPlainNumbersAreNotTimestamps() { + for text in ["", "abc", "12", "2024"] { + XCTAssertNil(TimestampValue.parse(text), "\(text) should not parse as a timestamp") + } + } + + func testDateOnlyParses() throws { + XCTAssertNoThrow(try value("2024-01-01")) + } +} + +final class FractionalSecondTests: XCTestCase { + func testFractionIsSplitOffAndScaledToNanoseconds() { + let split = FractionalSecond.split(from: "2024-01-01 10:00:00.123456") + + XCTAssertEqual(split.withoutFraction, "2024-01-01 10:00:00") + XCTAssertEqual(split.nanoseconds, 123_456_000) + } + + /// A `+05:30` offset holds a colon, not a dot, so it must survive untouched. + func testOffsetSurvivesTheSplit() { + let split = FractionalSecond.split(from: "2024-01-01 10:00:00.5+05:30") + + XCTAssertEqual(split.withoutFraction, "2024-01-01 10:00:00+05:30") + XCTAssertEqual(split.nanoseconds, 500_000_000) + } + + func testTextWithNoFractionIsUnchanged() { + let split = FractionalSecond.split(from: "2024-01-01 10:00:00") + + XCTAssertEqual(split.withoutFraction, "2024-01-01 10:00:00") + XCTAssertEqual(split.nanoseconds, 0) + } +} diff --git a/TableProTests/Core/Services/Infrastructure/WindowOpenerTests.swift b/TableProTests/Core/Services/Infrastructure/WindowOpenerTests.swift index de3ae0081..af5d39f9d 100644 --- a/TableProTests/Core/Services/Infrastructure/WindowOpenerTests.swift +++ b/TableProTests/Core/Services/Infrastructure/WindowOpenerTests.swift @@ -10,12 +10,14 @@ import XCTest final class WindowOpenerTests: XCTestCase { private var openedRequests: [ConnectionFormRequest] = [] private var openedSettingsPanes: [SettingsPane?] = [] + private var openedCompareSources: [UUID?] = [] override func setUp() async throws { try await super.setUp() _ = WelcomeRouter.shared.consumePendingRequest() openedRequests = [] openedSettingsPanes = [] + openedCompareSources = [] WindowOpener.shared.setWelcomePresenter {} WindowOpener.shared.setConnectionFormPresenter { [weak self] request in self?.openedRequests.append(request) @@ -24,6 +26,9 @@ final class WindowOpenerTests: XCTestCase { WindowOpener.shared.setSettingsPresenter { [weak self] pane in self?.openedSettingsPanes.append(pane) } + WindowOpener.shared.setCompareSyncPresenter { [weak self] connectionId in + self?.openedCompareSources.append(connectionId) + } } override func tearDown() async throws { @@ -185,6 +190,33 @@ final class WindowOpenerTests: XCTestCase { XCTAssertEqual(opened, [.ai]) } + func testCompareSyncCarriesThePrefilledSourceToThePresenter() { + let connectionId = UUID() + + WindowOpener.shared.openCompareSync(prefillSource: connectionId) + + XCTAssertEqual(openedCompareSources, [connectionId]) + } + + func testCompareSyncOpenedFromTheMenuCarriesNoSource() { + WindowOpener.shared.openCompareSync() + + XCTAssertEqual(openedCompareSources, [UUID?.none]) + } + + func testACompareSyncCallQueuedBeforeItsPresenterKeepsItsSource() { + let opener = WindowOpener() + var opened: [UUID?] = [] + let connectionId = UUID() + + opener.openCompareSync(prefillSource: connectionId) + XCTAssertTrue(opened.isEmpty, "No presenter yet, so the call has to wait") + + opener.setCompareSyncPresenter { opened.append($0) } + + XCTAssertEqual(opened, [connectionId]) + } + func testEditingTheSameConnectionTwiceRequestsTheSameWindow() { let connectionId = UUID() diff --git a/TableProTests/Views/Compare/CompareCountedStringTests.swift b/TableProTests/Views/Compare/CompareCountedStringTests.swift new file mode 100644 index 000000000..dedcf7e26 --- /dev/null +++ b/TableProTests/Views/Compare/CompareCountedStringTests.swift @@ -0,0 +1,134 @@ +// +// CompareCountedStringTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +/// A Compare & Sync window that reads "1 differences" is a defect the compiler cannot see. The +/// string catalog is the only place a plural can live for a string whose whole content is one +/// counted noun: `String(format:)` resolves a plural variation, but only when the catalog declares +/// one. A sentence whose counted noun follows a later argument cannot be reached that way, so those +/// carry their own singular in Swift and are pinned here as separate keys. +@Suite("Compare counted strings") +struct CompareCountedStringTests { + @Test("A single change reads as one change") + func singleChangeReadsAsSingular() { + #expect(String(format: String(localized: "%d changes"), 1) == "1 change") + #expect(String(format: String(localized: "%d changes"), 4) == "4 changes") + } + + @Test("A single byte reads as one byte") + func singleByteReadsAsSingular() { + #expect(String(format: String(localized: "%d bytes"), 1) == "1 byte") + #expect(String(format: String(localized: "%d bytes"), 12) == "12 bytes") + } + + @Test("A single NULL-keyed row reads in the singular") + func singleSkippedRowReadsAsSingular() { + let text = String( + format: String( + localized: "%d rows hold NULL in a key column and were left out. Choose a key with no NULLs to compare them." + ), + 1 + ) + + #expect(text.hasPrefix("1 row holds NULL")) + } + + @Test("A single matching row reads in the singular") + func singleMatchingRowReadsAsSingular() { + let text = String( + format: String( + localized: "%d rows match. Matching rows are counted, not listed, so a difference is never crowded out of this list." + ), + 1 + ) + + #expect(text.hasPrefix("1 row matches.")) + } + + @Test("Every Compare plural lives in the catalog rather than in a Swift branch") + func catalogDeclaresTheComparePlurals() throws { + for key in Self.pluralKeys { + let entry = try Self.catalogEntry(key) + let localizations = try #require(entry["localizations"] as? [String: Any]) + let english = try #require(localizations["en"] as? [String: Any]) + let variations = try #require(english["variations"] as? [String: Any]) + let plural = try #require(variations["plural"] as? [String: Any]) + + #expect(plural["one"] != nil, "\(key) declares no singular") + #expect(plural["other"] != nil, "\(key) declares no plural") + } + } + + /// A plural variation rewrites only the source language. Rewriting the English `stringUnit` into + /// a `%#@substitution@` form instead changes the source every translation is checked against, + /// which is what leaves `StringCatalogIntegrityTests` reporting five offenders on the one string + /// that already does it. + @Test("A Compare plural leaves the English source string alone") + func pluralsDoNotRewriteTheEnglishSourceString() throws { + for key in Self.pluralKeys { + let entry = try Self.catalogEntry(key) + let localizations = try #require(entry["localizations"] as? [String: Any]) + let english = try #require(localizations["en"] as? [String: Any]) + + #expect(english["stringUnit"] == nil, "\(key) rewrote its source string") + #expect(english["substitutions"] == nil, "\(key) rewrote its source string") + } + } + + /// The counted noun follows the second argument in each of these, which a plural variation on + /// the format string cannot reach, so the view picks the singular sentence instead. They are + /// keys of their own and have to stay that way. + @Test("A sentence whose count is not its first argument carries its own singular") + func restructuredSentencesReadCorrectlyAtOne() { + #expect( + String(format: String(localized: "Apply 1 statement to %@?"), "staging") == "Apply 1 statement to staging?" + ) + #expect( + String(format: String(localized: "%d of 1 table will be compared."), 1) == "1 of 1 table will be compared." + ) + #expect( + String(format: String(localized: "1 statement, %d will run."), 0) == "1 statement, 0 will run." + ) + #expect( + String( + format: String(localized: "1 statement stays out of this run and %@ keeps what it has for it."), + "staging" + ) == "1 statement stays out of this run and staging keeps what it has for it." + ) + } + + private static let pluralKeys = [ + "%d changes", + "%d bytes", + "%d rows hold NULL in a key column and were left out. Choose a key with no NULLs to compare them.", + "%d rows match. Matching rows are counted, not listed, so a difference is never crowded out of this list." + ] + + private static func catalogEntry(_ key: String) throws -> [String: Any] { + let url = try repoRoot().appendingPathComponent("TablePro/Resources/Localizable.xcstrings") + let data = try Data(contentsOf: url) + let catalog = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let strings = try #require(catalog["strings"] as? [String: Any]) + return try #require(strings[key] as? [String: Any]) + } + + private static func repoRoot() throws -> URL { + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0 ..< 12 { + if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { + return directory + } + directory = directory.deletingLastPathComponent() + } + throw CatalogError.repoRootNotFound + } + + private enum CatalogError: Error { + case repoRootNotFound + } +} diff --git a/TableProUITests/CompareSyncUITests.swift b/TableProUITests/CompareSyncUITests.swift new file mode 100644 index 000000000..943b0367a --- /dev/null +++ b/TableProUITests/CompareSyncUITests.swift @@ -0,0 +1,89 @@ +import XCTest + +final class CompareSyncUITests: UITestCase { + private func launchAndOpenCompareSync() throws -> XCUIApplication { + let app = try launchApp() + + let menuBar = app.menuBars.firstMatch + XCTAssertTrue(menuBar.waitForExistence(timeout: 10)) + menuBar.menuBarItems["Database"].click() + menuBar.menuItems["Compare"].click() + + let item = menuBar.menuItems["Compare & Sync Databases…"] + XCTAssertTrue( + item.waitForExistence(timeout: 5), + "Compare & Sync must be reachable from Database > Compare" + ) + guard item.isEnabled else { + throw XCTSkip("Compare & Sync is licence gated and unavailable in this build") + } + item.click() + return app + } + + private func compareWindow(in app: XCUIApplication) -> XCUIElement { + app.windows["Compare & Sync"] + } + + func testCompareSyncOpensFromFileMenu() throws { + let app = try launchAndOpenCompareSync() + + XCTAssertTrue( + compareWindow(in: app).waitForExistence(timeout: 10), + "Choosing the menu item must open the Compare & Sync window" + ) + } + + func testBannerStatesNothingHasBeenWrittenBeforeAnyRun() throws { + let app = try launchAndOpenCompareSync() + let window = compareWindow(in: app) + XCTAssertTrue(window.waitForExistence(timeout: 10)) + + let banner = window.staticTexts["Comparing only. Nothing has been written."] + XCTAssertTrue( + banner.waitForExistence(timeout: 5), + "The window must say nothing has been written until the user applies" + ) + } + + func testCompareIsDisabledUntilBothEndpointsAreChosen() throws { + let app = try launchAndOpenCompareSync() + let window = compareWindow(in: app) + XCTAssertTrue(window.waitForExistence(timeout: 10)) + + let compareButton = window.buttons["Compare"] + XCTAssertTrue(compareButton.waitForExistence(timeout: 5)) + XCTAssertFalse( + compareButton.isEnabled, + "Compare must stay disabled while no target is chosen, so the write side is always deliberate" + ) + } + + func testTargetPickerStartsWithNoConnectionChosen() throws { + let app = try launchAndOpenCompareSync() + let window = compareWindow(in: app) + XCTAssertTrue(window.waitForExistence(timeout: 10)) + + let placeholders = window.popUpButtons.matching( + NSPredicate(format: "value == %@", "Choose a connection") + ) + XCTAssertGreaterThanOrEqual( + placeholders.count, 2, + "Neither source nor target may be preselected" + ) + } + + func testSwapIsDisabledWhenNoEndpointIsChosen() throws { + let app = try launchAndOpenCompareSync() + let window = compareWindow(in: app) + XCTAssertTrue(window.waitForExistence(timeout: 10)) + + let swap = window.buttons.matching( + NSPredicate(format: "label CONTAINS[c] %@", "Swap") + ).firstMatch + guard swap.waitForExistence(timeout: 5) else { + throw XCTSkip("Swap control not exposed to accessibility in this build") + } + XCTAssertFalse(swap.isEnabled, "Swap has nothing to swap before an endpoint is chosen") + } +} diff --git a/docs/docs.json b/docs/docs.json index 199c324fb..c1f37c8bb 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -214,7 +214,8 @@ "icon": "arrow-right-arrow-left", "pages": [ "features/import-export", - "features/backup-restore" + "features/backup-restore", + "features/compare-sync" ] }, { diff --git a/docs/features/compare-sync.mdx b/docs/features/compare-sync.mdx new file mode 100644 index 000000000..c4da9bc45 --- /dev/null +++ b/docs/features/compare-sync.mdx @@ -0,0 +1,112 @@ +--- +title: Compare & Sync +description: Compare two databases and generate the SQL script that brings one in line with the other +--- + +Pick a source and a target, press **Compare**, and every object that differs lists in one window. Include the ones to change, generate the script, and read it before anything runs. Requires a Starter license. + + + Compare and Sync window with a results table on the left and a two-up definition diff on the right + Compare and Sync window with a results table on the left and a two-up definition diff on the right + + +## Opening it + +- **Database > Compare > Compare & Sync Databases…** +- Right-click a connection in the connection list and choose **Compare/Sync with…**. The connection clicked becomes the source. + +Every toolbar control also sits under **Database > Compare**, so the whole flow is reachable from the keyboard. + +## Choosing the two sides + +**Source** and **Target** are database pickers, not connection pickers: each one walks connection, then database, then schema. Two databases on one server are a valid pair, and so are two schemas in one database. + +The **source** never changes. The **target** is written to. A connection whose safe mode level is **Read-Only** is disabled in the target picker with the reason shown, so the refusal arrives at selection time rather than after comparing. **Swap** reverses the direction. + +Nothing is written until **Apply**. Until then the strip along the top reads **Comparing only. Nothing has been written.** + +## What takes part + +**Options** chooses the object kinds. Tables are always compared; views, materialized views, procedures, functions and triggers are opt-in. + +| Kind | Compared as | +|---|---| +| Tables | Parsed columns, indexes, foreign keys, storage engine and collation | +| Views, materialized views | Normalized definition text | +| Procedures, functions | Normalized definition text, matched on name and argument list | +| Triggers | Normalized definition text, per table | + +Tables are never compared as DDL text. Driver-rendered DDL varies by formatting and by system-generated constraint names, which reports identical tables as different. A routine has no parsed form to compare instead, so its body is the definition, and the normalizer folds line endings, trailing semicolons and, when the options say so, whitespace and identifier case. + +### What is ignored + +These drift between environments by design, so they are ignored by default. Turn one off to have the difference reported. + +| Option | What it ignores | +|---|---| +| Identifier case | `Orders` against `orders` | +| Column order | The same columns in a different order | +| Whitespace in text | Indentation inside default values, comments and routine bodies | +| Auto-increment seed | The counter's current value | +| Collation and character set | A table-level or column-level collation difference | +| Comments, owners, definers | Descriptive metadata with no runtime effect | + +## Reading the results + +Each object lands in one of four states: **only in source**, **only in target**, **different**, or **identical**. **Group By** sections the table by difference or by object kind, and the search field filters by name. Identical objects stay hidden until **Show Identical Objects**. + +Every row carries an **Include** checkbox, and a group header carries one for everything under it. Nothing is included until it is checked. + +An object whose metadata could not be read keeps its own **Could Not Compare** section with the driver's reason. One unreadable object never stops the rest of the comparison. + +The detail pane on the right has three tabs. **Definitions** shows the source and target side by side, split or unified, rendered from the same function so a formatting difference cannot appear as a real one. **Rows** is the data comparison. **Script** is the generated SQL. + +## Comparing rows + +Switch the mode control to **Data**. The left pane lists the tables present on both sides. + +Tables start unchecked. Choose the ones to compare, then press **Compare**: a data comparison reads every row of every checked table on both sides, so comparing a whole database by accident is expensive. + +Rows are matched by key, read in key order from both sides and walked in lockstep, so neither side is ever held in memory in full. + +- Key columns default to the primary key and are editable per table. Composite keys work. A table with no usable key lists as not comparable rather than being matched on a guess. +- The compare set and the write set are separate. Exclude `updated_at` from the comparison and it is still written on insert and update. +- A generated column is read and compared but never written, because engines reject an explicit value for one. +- NULL equals only NULL. Numeric tolerance and timestamp precision are set in **Options**, and two spellings of the same instant at different offsets are equal. +- When a value is flagged, the row names the rule that fired. + +Filter the rows six ways: **All**, **Difference**, **Insert**, **Update**, **Delete**, **Same**. Each row has its own **Include** checkbox. + +The row list is a capped preview. Past the cap the pane says so, and **Apply** still covers every difference: the script is built from a fresh pass over both sides, not from the rows on screen. + +### One connection, two databases + +A data comparison needs both sides open at once. On an engine that pools connections, two databases on one connection is fine. On an engine that cannot pool, both sides share one driver and one database position, so the comparison refuses that pair by name and asks for a second connection for the target. + +## The script + +**Generate Script** builds the SQL. It is read-only: each statement carries the hazards its operation plan computed, and those cannot be recovered from edited text. **Copy** and **Save…** take it elsewhere, and the query editor runs a hand-edited version through the normal path. + +Statements are ordered by foreign key dependency, not alphabetically. Tables are created parent-first and dropped child-first; row inserts run parent-first and row deletes child-first. + +Script generation needs matching database types, with MySQL and MariaDB counting as one family. A cross-engine pair still compares, read-only: column data types are engine-specific strings, so generating DDL for one engine from another's metadata is not sound. + +## Applying + +Anything that would destroy data is generated, listed, and held back. Dropping a table, dropping a column, narrowing a type, adding NOT NULL, changing a primary key and deleting a row each need an explicit allowance, and that allowance covers one run and is never saved. + +**Apply…** opens a sheet with the script, a summary, and the warnings. **Cancel** is the default button and **Apply** is marked destructive. Apply stays disabled while any included statement still has an unacknowledged hazard. + + + Applying runs the script against the target. Statements that already ran stay applied unless the whole run is inside a transaction that rolls back. + + +**On error** chooses between stopping and rolling back, stopping and keeping what ran, and skipping and continuing. A transaction cannot be combined with skip and continue, because together they leave the target half-applied. + +Whether a transaction covers the run depends on the engine. MySQL, MariaDB and Oracle commit implicitly on every DDL statement, so a structure sync on those engines runs without one rather than implying a rollback that will not happen. + +Closing the window mid-run asks first, and says that statements which already ran stay applied. + +## Saving a comparison + +A named comparison remembers the source, the target, the mode, the object kinds, the options and the included objects. Load it from **Options** to run the same comparison again. diff --git a/docs/images/compare-sync-window-dark.png b/docs/images/compare-sync-window-dark.png new file mode 100644 index 000000000..82fe667b8 Binary files /dev/null and b/docs/images/compare-sync-window-dark.png differ diff --git a/docs/images/compare-sync-window.png b/docs/images/compare-sync-window.png new file mode 100644 index 000000000..e57b6c7db Binary files /dev/null and b/docs/images/compare-sync-window.png differ