Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Favorites and Recent sections, sorting, drag and drop into groups, inline rename and tag search tokens in the welcome window.
- **File > New Group…**, **File > Rename** and **View > Sort Connections By** for the welcome window.
- Favorites, Recent, nested groups, sorting and tag search tokens in the iOS connection list.
- Per-table row filter, with an optional separate target filter, and row limit in data Compare & Sync. (#2537)
- Row grid for data Compare & Sync with every column shown and each differing value marked. (#2537)

### Changed

Expand All @@ -33,10 +35,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Welcome window list moved with the arrow keys instead of `Ctrl+J`, `Ctrl+K`, `Ctrl+H` and `Ctrl+L`.
- Connection switcher lists Favorites, Recent and groups at every depth.
- Connection rows without colored dots, on the Mac and on iOS.
- Compared columns in data Compare & Sync chosen per table, and saved with each table's key, filter and row limit. (#2537)

### Fixed

- SQL editor jumping back while scrolling sideways near the start of a long line. (#2841)
- Data sync scripts missing every UPDATE and DELETE. (#2537)
- Data sync statements written to the source schema instead of the target.
- Numeric-looking text such as `007` written unquoted by data sync, and key matches that hit extra rows.
- Data sync pairing arbitrary rows on a key that is not unique.
- Data sync inserts failing on SQL Server identity and PostgreSQL `GENERATED ALWAYS` columns.
- Text columns compared as timestamps, and keys that differ only in case never synced.
- Data sync scripts including tables never compared, or rows that changed after comparing.
- Apply unavailable for a second sync in the same Compare & Sync window.
- Choosing a source, target, mode or option during Apply cancelling the running sync.
- Apply offered for a target switched to Read-Only after it was picked.
- Compare & Sync reporting nothing written after a sync had written to the target.
- Rolled-back data sync on MyISAM tables reported as leaving the target unchanged.
- Cancelling a repeated data comparison clearing the previous results.
- Table whose data comparison failed stuck included with no way to exclude it.
- SSH settings dropped from a Mac connection after it synced from the iPhone app, turning off its tunnel or remote database file.
- Remote database file path and access mode dropped when a connection was exported, shared as a link, or imported.
- Remote database file connection hanging for minutes when its SSH connection dropped silently, with Cancel doing nothing.
Expand Down
17 changes: 13 additions & 4 deletions TablePro/Core/Compare/CompareMetadataService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,17 @@ internal struct CompareMetadataService {
internal func bothSideTableReads(
context: CompareRunner.Context,
includeViews: Bool,
profile: TableReadProfile
profile: TableReadProfile,
targetProfile: TableReadProfile? = nil
) async throws -> (source: [TableStructureRead], target: [TableStructureRead]) {
async let source = tableReads(
for: context.source, connection: context.sourceConnection, includeViews: includeViews, profile: profile
)
async let target = tableReads(
for: context.target, connection: context.targetConnection, includeViews: includeViews, profile: profile
for: context.target,
connection: context.targetConnection,
includeViews: includeViews,
profile: targetProfile ?? profile
)
return try await (source, target)
}
Expand Down Expand Up @@ -555,9 +559,14 @@ internal struct TableReadProfile: Sendable {
)

/// A data comparison pairs tables by name, reads the columns they share and walks their rows.
/// It reads foreign keys to order the statements it writes, and it never looks at an index or
/// at a storage engine.
/// It reads foreign keys to order the statements it writes, and it never looks at an index.
internal static let data = TableReadProfile(
wantsIndexes: false, wantsForeignKeys: true, wantsTableMetadata: false
)

/// A MySQL-family target also needs its storage engines, because a MyISAM table cannot roll
/// back and a run that stops part way has to say so.
internal static let dataWithStorageEngines = TableReadProfile(
wantsIndexes: false, wantsForeignKeys: true, wantsTableMetadata: true
)
}
78 changes: 78 additions & 0 deletions TablePro/Core/Compare/CompareRowFilter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// CompareRowFilter.swift
// TablePro
//

import Foundation

internal enum CompareRowFilter {
internal static func normalized(_ text: String?) -> String? {
guard let trimmed = text?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
return nil
}
return trimmed
}

internal static func validationError(for text: String) -> String? {
guard let filter = normalized(text) else { return nil }
guard !filter.contains(";") else {
return String(localized: "A filter is a single condition and cannot contain a semicolon.")
}
/// `SQLBoundaryValidator` anchors `--` to the start or to whitespace, and the filter is
/// spliced into a single-line statement, so `id = 1--x` would comment out the ORDER BY and
/// the row limit that follow it. `#` is MySQL's line comment and does the same.
guard !filter.contains("--"), !filter.contains("#"),
SQLBoundaryValidator.isRawFilterConditionSafe(filter) else {
return String(localized: "A filter cannot contain a comment.")
}
/// Both readings of a backslash have to agree. MySQL, MariaDB and ClickHouse treat it as an
/// escape inside a string literal and PostgreSQL does not, so a filter that is balanced
/// under one reading and not the other closes the parenthesis this condition is wrapped in
/// on one engine and not on the other.
guard isBalanced(filter, backslashEscapes: true), isBalanced(filter, backslashEscapes: false) else {
return String(localized: "A quote or parenthesis in this filter is not closed.")
}
return nil
}

internal static func condition(for filter: String) -> String {
"(\(filter))"
}

private static func isBalanced(_ text: String, backslashEscapes: Bool) -> Bool {
var depth = 0
var closingQuote: Character?
var characters = Array(text).makeIterator()
var pending: Character?

while let character = pending ?? characters.next() {
pending = nil
if let quote = closingQuote {
if backslashEscapes, character == "\\", quote != "]" {
_ = characters.next()
continue
}
guard character == quote else { continue }
let next = characters.next()
if next == quote, quote != "]" { continue }
closingQuote = nil
pending = next
continue
}
switch character {
case "'", "\"", "`":
closingQuote = character
case "[":
closingQuote = "]"
case "(":
depth += 1
case ")":
depth -= 1
guard depth >= 0 else { return false }
default:
break
}
}
return depth == 0 && closingQuote == nil
}
}
Loading
Loading