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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Copy To across database engines, with every type approximation listed before the copy runs. (#1491)
- Per-table `WHERE` and row limit in Copy To. (#1491)
- Server-side `INSERT … SELECT` when a copy's two sides are one connection. (#1491)
- Tunnel Command pane, with presets for `kubectl port-forward` and `aws ssm start-session` and a custom command line. (#2520)
- Bar chart column in the EXPLAIN tree, with a Metric menu for self cost, self time and row counts. (#2633)

Expand Down
95 changes: 95 additions & 0 deletions TablePro/Core/CrossEngine/CanonicalColumnType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//
// CanonicalColumnType.swift
// TablePro
//
// One vocabulary every engine's column types can be said in.
//
// `PluginColumnKind` has five cases and `ColumnType` is documented as
// display-only, and neither keeps a length, a precision or a time zone. Both
// are enough to decide how to draw a cell and neither is enough to write a
// `CREATE TABLE` for a different engine, which is why a copy across engines
// was refused rather than approximated. This is the third vocabulary and the
// only one that carries what DDL needs.
//
// It is deliberately not a superset of every engine's type system. A type no
// family here can express arrives as `.unsupported` carrying the source's own
// spelling, so the renderer can say what it could not translate instead of
// guessing a shape for it.
//

import Foundation

/// What a column holds, said without naming an engine.
internal enum CanonicalTypeKind: Hashable, Sendable {
case boolean
/// Width in bytes, which is what decides the target spelling: 1 is a MySQL `TINYINT` and a
/// PostgreSQL `SMALLINT`, and no engine has a type for every width.
case integer(bytes: Int)
case decimal(precision: Int?, scale: Int?)
case floatingPoint(bits: Int)
case text(length: Int?, isFixed: Bool)
case binary(length: Int?, isFixed: Bool)
case date
case time(precision: Int?, hasTimeZone: Bool)
case timestamp(precision: Int?, hasTimeZone: Bool)
case interval
case uuid
case json
case xml
/// Carries its labels because every engine that lacks `ENUM` needs them to size the text
/// column that replaces it, and MySQL needs them to write the type back out.
case enumeration(values: [String])
case bitString(length: Int?)
case money
case spatial
indirect case array(element: CanonicalTypeKind)
case unsupported
}

internal struct CanonicalColumnType: Hashable, Sendable {
internal let kind: CanonicalTypeKind
/// MySQL's own modifier. Kept beside the kind rather than inside it because it doubles the
/// integer cases for one engine, and because it is the fact that forces a widening on every
/// other engine rather than a different type.
internal let isUnsigned: Bool
/// Exactly what the source called it, so a refusal can quote the user's own type name.
internal let sourceSpelling: String

internal init(kind: CanonicalTypeKind, isUnsigned: Bool = false, sourceSpelling: String) {
self.kind = kind
self.isUnsigned = isUnsigned
self.sourceSpelling = sourceSpelling
}
}

/// How close a rendered spelling is to what the source held.
///
/// Ordered so a table's worst column decides what the review step says about the table.
internal enum CanonicalTypeFidelity: Int, Comparable, Sendable {
/// The target has the same type. Nothing to tell the user.
case exact
/// The target has no type this narrow, so a wider one is used. Every source value still fits.
case widened
/// The target has nothing equivalent and a different shape is used instead. Values survive as
/// text, constraints and semantics do not.
case approximated
/// Nothing sensible to write. The column is left out.
case unsupported

internal static func < (lhs: CanonicalTypeFidelity, rhs: CanonicalTypeFidelity) -> Bool {
lhs.rawValue < rhs.rawValue
}
}

internal struct RenderedColumnType: Sendable {
internal let spelling: String
internal let fidelity: CanonicalTypeFidelity
/// Why it is not exact, in the user's words. Nil when it is.
internal let reason: String?

internal init(spelling: String, fidelity: CanonicalTypeFidelity = .exact, reason: String? = nil) {
self.spelling = spelling
self.fidelity = fidelity
self.reason = reason
}
}
54 changes: 54 additions & 0 deletions TablePro/Core/CrossEngine/CrossEngineConversionNote.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// CrossEngineConversionNote.swift
// TablePro
//
// One thing the copy could not carry across unchanged.
//
// Every note is produced before anything runs and read out in the review step,
// because a conversion the user finds out about afterwards is indistinguishable
// from a bug. A copy that says "TIMESTAMPTZ became DATETIME and the offset was
// dropped" is a decision; the same copy in silence is data loss.
//

import Foundation

internal struct CrossEngineConversionNote: Identifiable, Hashable, Sendable {
internal let table: String
/// The column or index this is about. Empty for something about the table itself.
internal let subject: String
/// What changed, in the form the review step lists: `created_at: TIMESTAMPTZ → DATETIME`.
internal let summary: String
internal let reason: String
internal let fidelity: CanonicalTypeFidelity

internal init(
table: String,
subject: String,
summary: String,
reason: String,
fidelity: CanonicalTypeFidelity
) {
self.table = table
self.subject = subject
self.summary = summary
self.reason = reason
self.fidelity = fidelity
}

internal var id: String { "\(table)\u{1F}\(subject)\u{1F}\(summary)" }

/// Whether the note is about something the user may want to change their mind over, rather
/// than a widening that keeps every value.
internal var isLossy: Bool { fidelity >= .approximated }
}

internal extension Array where Element == CrossEngineConversionNote {
/// Lossy first, then by table and subject, so the review step opens on the ones that matter.
var orderedForReview: [CrossEngineConversionNote] {
sorted { lhs, rhs in
guard lhs.fidelity == rhs.fidelity else { return lhs.fidelity > rhs.fidelity }
guard lhs.table == rhs.table else { return lhs.table < rhs.table }
return lhs.subject < rhs.subject
}
}
}
142 changes: 142 additions & 0 deletions TablePro/Core/CrossEngine/CrossEngineDefaultValue.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//
// CrossEngineDefaultValue.swift
// TablePro
//
// What a column's default becomes on the other engine.
//
// A default is the source's own SQL text and the target's DDL writer quotes
// whatever it does not recognise, so an untranslated `now()` does not fail: it
// becomes the literal string `'now()'` in every row, which is worse than
// failing because nothing reports it. Everything here is therefore either
// translated to the target's own spelling or dropped with a reason.
//
// `nextval(...)` is the one that is not a default at all. PostgreSQL renders a
// `SERIAL` as an ordinary integer whose default calls its sequence, and the
// sequence is a separate object written in PostgreSQL's DDL. Copied across
// engines the sequence cannot come with it, so the default becomes the target's
// own auto-increment instead, which is what a `SERIAL` meant in the first place.
//

import Foundation

internal enum CrossEngineDefaultValue {
internal enum Outcome: Equatable, Sendable {
case none
case keep(String)
/// The column carries the target's own generated-key attribute and no default text.
case autoIncrement
case drop(reason: String)
}

internal static func translate(
_ value: String?,
kind: CanonicalTypeKind,
to target: SQLTypeFamily
) -> Outcome {
guard let raw = value?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return .none
}
let stripped = strippingCast(raw)
let upper = stripped.uppercased()

if upper.hasPrefix("NEXTVAL(") { return .autoIncrement }
if upper == "NULL" { return .keep("NULL") }
if let now = currentTimestamp(upper, target: target) { return .keep(now) }
if let today = currentDate(upper, target: target) { return .keep(today) }
if let boolean = booleanLiteral(upper, kind: kind, target: target) { return .keep(boolean) }
if isNumericLiteral(stripped) { return .keep(stripped) }
if let literal = stringLiteral(stripped) { return .keep(literal) }
if isBareWord(stripped) { return .keep(stripped) }

return .drop(reason: String(
format: String(localized: "The default %@ is not written in a form this engine shares."),
raw
))
}

// MARK: - Shapes

/// PostgreSQL writes a default's type onto it: `'active'::character varying`, `0::numeric`,
/// `'{}'::jsonb`. The cast is PostgreSQL's own syntax and every other engine rejects it, while
/// the value in front of it is portable.
internal static func strippingCast(_ value: String) -> String {
guard let range = value.range(of: "::") else { return value }
/// Only a cast that ends the expression. A `::` inside quotes is part of the value, and one
/// in the middle of a larger expression means the expression is not a bare literal anyway.
let head = String(value[value.startIndex..<range.lowerBound])
let tail = String(value[range.upperBound...])
guard !tail.contains("'"), !tail.contains("("), !head.isEmpty else { return value }
return head.trimmingCharacters(in: .whitespaces)
}

private static func currentTimestamp(_ upper: String, target: SQLTypeFamily) -> String? {
let names = [
"CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP()", "NOW()", "GETDATE()", "GETUTCDATE()",
"SYSDATETIME()", "SYSDATE", "SYSTIMESTAMP", "LOCALTIMESTAMP", "LOCALTIMESTAMP()",
"STATEMENT_TIMESTAMP()", "TRANSACTION_TIMESTAMP()", "CLOCK_TIMESTAMP()"
]
let matches = names.contains(upper) || upper.hasPrefix("CURRENT_TIMESTAMP(")
guard matches else { return nil }
return target == .clickhouse ? "now()" : "CURRENT_TIMESTAMP"
}

private static func currentDate(_ upper: String, target: SQLTypeFamily) -> String? {
guard ["CURRENT_DATE", "CURRENT_DATE()", "CURDATE()", "TODAY()"].contains(upper) else { return nil }
return target == .clickhouse ? "today()" : "CURRENT_DATE"
}

/// Every engine spells a boolean default differently and half of them have no boolean type at
/// all, so the literal follows the target rather than the source. PostgreSQL reports a `false`
/// default on a `boolean` column as `false`, and MySQL stores it in a `TINYINT(1)` as 0.
private static func booleanLiteral(
_ upper: String,
kind: CanonicalTypeKind,
target: SQLTypeFamily
) -> String? {
guard case .boolean = kind else { return nil }
let isTrue = ["TRUE", "'T'", "T", "1", "'1'", "YES", "'YES'", "B'1'"].contains(upper)
let isFalse = ["FALSE", "'F'", "F", "0", "'0'", "NO", "'NO'", "B'0'"].contains(upper)
guard isTrue || isFalse else { return nil }
switch target {
case .postgres, .duckdb:
return isTrue ? "TRUE" : "FALSE"
case .clickhouse:
return isTrue ? "true" : "false"
case .mysql, .sqlite, .mssql, .oracle, .generic:
return isTrue ? "1" : "0"
}
}

private static func isNumericLiteral(_ value: String) -> Bool {
guard !value.isEmpty else { return false }
return Int64(value) != nil || Double(value) != nil
}

/// A quoted literal, which every engine here writes the same way. A doubled quote inside is the
/// escape all of them share; a backslash escape is MySQL's alone and is left to be dropped.
private static func stringLiteral(_ value: String) -> String? {
guard value.hasPrefix("'"), value.hasSuffix("'"), value.count >= 2 else { return nil }
guard !value.contains("\\") else { return nil }
let inner = value.dropFirst().dropLast()
var index = inner.startIndex
while index < inner.endIndex {
guard inner[index] == "'" else {
index = inner.index(after: index)
continue
}
let next = inner.index(after: index)
guard next < inner.endIndex, inner[next] == "'" else { return nil }
index = inner.index(after: next)
}
return value
}

/// MySQL reports a string default without its quotes, so `active` on a `VARCHAR` arrives as a
/// bare word. Every target's DDL writer quotes an unrecognised word, which is the right answer
/// for it; what must not reach them is a word with a bracket or a space in it, because that is
/// an expression and quoting one turns it into a string.
private static func isBareWord(_ value: String) -> Bool {
guard !value.isEmpty, value.count <= 128 else { return false }
return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" || $0 == "." }
}
}
Loading
Loading