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

## [Unreleased]

### Added

- Exclude the AUTO_INCREMENT counter and Exclude DEFINER clauses in the SQL export, both on by default. (#2516)

### Changed

- PluginKit ABI 21. Every registry plugin needs rebuilding before or with this release.
Expand Down
255 changes: 255 additions & 0 deletions Plugins/SQLExportPlugin/SQLExportDDLRewriter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
//
// SQLExportDDLRewriter.swift
// SQLExportPlugin
//

import Foundation
import TableProPluginKit

/// Removes the clauses that pin a CREATE statement to the server it was read from: the table's
/// `AUTO_INCREMENT` counter and a view's `DEFINER` account. Both are MySQL's own spelling, so every
/// other dialect is handed back untouched.
///
/// Each clause is recognised only in the one position its grammar puts it. The counter is a table
/// option, so it is taken at parenthesis depth zero alone; the account belongs to the CREATE header,
/// so it is taken between `CREATE` and the object keyword alone. Without that, a column named
/// `auto_increment` or `definer` in a CHECK constraint is stripped out of its own expression, which
/// SQLite reports verbatim from its catalog and PostgreSQL renders from `pg_get_constraintdef`:
/// `CHECK (auto_increment = 4)` came back as `CHECK ()`.
///
/// The scan is quote-aware for the same reason. A `SHOW CREATE TABLE` reports a column COMMENT and a
/// quoted column name as the schema wrote them, so a table carrying `AUTO_INCREMENT=5` in either has
/// its own data rewritten by anything that cannot see quoting.
///
/// Only the clause forms carry `=`. The column-level `AUTO_INCREMENT` attribute and
/// `SQL SECURITY DEFINER` do not, so both survive: dropping the latter, as `mysqlpump --skip-definer`
/// does, would turn a view declared `SQL SECURITY INVOKER` into a definer-rights view.
internal struct SQLExportDDLRewriter {
internal let dialect: SqlDialect
internal let excludesAutoIncrementValue: Bool
internal let excludesDefiner: Bool

private struct ScanState {
var parenthesisDepth = 0
var isInCreateHeader = false
}

internal func rewrite(_ ddl: String) -> String {
guard dialect == .mysql, excludesAutoIncrementValue || excludesDefiner else { return ddl }

let characters = Array(ddl)
var output: [Character] = []
output.reserveCapacity(characters.count)
var state = ScanState()
var index = 0

while index < characters.count {
if let quoted = Self.quotedRunEnd(in: characters, from: index) {
output.append(contentsOf: characters[index ..< quoted])
index = quoted
continue
}
if let commented = Self.commentRunEnd(in: characters, from: index) {
output.append(contentsOf: characters[index ..< commented])
index = commented
continue
}
guard Self.isWordCharacter(characters[index]) else {
Self.track(characters[index], in: &state)
output.append(characters[index])
index += 1
continue
}

var wordEnd = index
while wordEnd < characters.count, Self.isWordCharacter(characters[wordEnd]) {
wordEnd += 1
}
let keyword = String(characters[index ..< wordEnd]).uppercased()

if let clauseEnd = clauseEnd(keyword: keyword, in: characters, assignmentStart: wordEnd, state: state) {
index = Self.closeGap(in: characters, after: clauseEnd, output: &output)
continue
}

Self.track(keyword, in: &state)
output.append(contentsOf: characters[index ..< wordEnd])
index = wordEnd
}

return String(output)
}

private func clauseEnd(
keyword: String,
in characters: [Character],
assignmentStart: Int,
state: ScanState
) -> Int? {
switch keyword {
case "AUTO_INCREMENT" where excludesAutoIncrementValue && state.parenthesisDepth == 0:
Self.assignedValueEnd(in: characters, from: assignmentStart, value: Self.digitRunEnd)
case "DEFINER" where excludesDefiner && state.isInCreateHeader:
Self.assignedValueEnd(in: characters, from: assignmentStart, value: Self.accountEnd)
default:
nil
}
}

/// The header runs from `CREATE` to the object keyword. Recognising it from the words allowed
/// inside it rather than the words that end it means an unlisted word closes the header, which
/// leaves a clause in place instead of taking one out of a statement body.
private static let headerKeywords: Set<String> = [
"CREATE", "OR", "REPLACE", "ALGORITHM", "UNDEFINED", "MERGE", "TEMPTABLE",
"DEFINER", "SQL", "SECURITY", "INVOKER", "TEMPORARY", "AGGREGATE"
]

private static func track(_ keyword: String, in state: inout ScanState) {
if keyword == "CREATE" {
state.isInCreateHeader = true
return
}
if !headerKeywords.contains(keyword) {
state.isInCreateHeader = false
}
}

private static func track(_ character: Character, in state: inout ScanState) {
if character == "(" {
state.parenthesisDepth += 1
} else if character == ")" {
state.parenthesisDepth = max(0, state.parenthesisDepth - 1)
} else if character == ";" {
state.isInCreateHeader = false
}
}

private static func assignedValueEnd(
in characters: [Character],
from start: Int,
value: (_ characters: [Character], _ start: Int) -> Int?
) -> Int? {
var index = skippingBlanks(in: characters, from: start)
guard index < characters.count, characters[index] == "=" else { return nil }
index = skippingBlanks(in: characters, from: index + 1)
return value(characters, index)
}

private static func digitRunEnd(in characters: [Character], from start: Int) -> Int? {
var index = start
while index < characters.count, characters[index].isASCII, characters[index].isNumber {
index += 1
}
return index > start ? index : nil
}

/// A DEFINER is `user@host`, where either half arrives quoted or bare, and `CURRENT_USER`
/// stands alone without a host.
private static func accountEnd(in characters: [Character], from start: Int) -> Int? {
guard let userEnd = accountPartEnd(in: characters, from: start) else { return nil }
let separator = skippingBlanks(in: characters, from: userEnd)
guard separator < characters.count, characters[separator] == "@" else { return userEnd }
let hostStart = skippingBlanks(in: characters, from: separator + 1)
return accountPartEnd(in: characters, from: hostStart) ?? userEnd
}

private static func accountPartEnd(in characters: [Character], from start: Int) -> Int? {
if let quoted = quotedRunEnd(in: characters, from: start) { return quoted }
var index = start
while index < characters.count, isAccountCharacter(characters[index]) {
index += 1
}
return index > start ? index : nil
}

/// A removed clause leaves the separator that preceded it. Take the run of blanks that followed
/// too, and where the clause ended its fragment, the one that preceded it as well.
private static func closeGap(
in characters: [Character],
after clauseEnd: Int,
output: inout [Character]
) -> Int {
let precededByBlank = output.last.map(isBlank) ?? true
guard precededByBlank else { return clauseEnd }

let index = skippingBlanks(in: characters, from: clauseEnd)
guard index >= characters.count || endsFragment(characters[index]) else { return index }
while let last = output.last, isBlank(last) {
output.removeLast()
}
return index
}

private static func quotedRunEnd(in characters: [Character], from start: Int) -> Int? {
guard start < characters.count else { return nil }
let delimiter = characters[start]
guard delimiter == "'" || delimiter == "\"" || delimiter == "`" else { return nil }

let escapesWithBackslash = delimiter != "`"
var index = start + 1
while index < characters.count {
if escapesWithBackslash, characters[index] == "\\" {
index += 2
continue
}
guard characters[index] == delimiter else {
index += 1
continue
}
if index + 1 < characters.count, characters[index + 1] == delimiter {
index += 2
continue
}
return index + 1
}
return characters.count
}

/// Text inside a comment is copied rather than rewritten. Every dialect spells its comments
/// differently enough that a wrong guess here only ever leaves a clause in place.
private static func commentRunEnd(in characters: [Character], from start: Int) -> Int? {
let next = start + 1 < characters.count ? characters[start + 1] : nil
if characters[start] == "#" || (characters[start] == "-" && next == "-") {
return lineEnd(in: characters, from: start)
}
guard characters[start] == "/", next == "*" else { return nil }
var index = start + 2
while index + 1 < characters.count {
if characters[index] == "*", characters[index + 1] == "/" { return index + 2 }
index += 1
}
return characters.count
}

private static func lineEnd(in characters: [Character], from start: Int) -> Int {
var index = start
while index < characters.count, characters[index] != "\n", characters[index] != "\r" {
index += 1
}
return index
}

private static func skippingBlanks(in characters: [Character], from start: Int) -> Int {
var index = start
while index < characters.count, isBlank(characters[index]) {
index += 1
}
return index
}

private static func isBlank(_ character: Character) -> Bool {
character == " " || character == "\t"
}

private static func endsFragment(_ character: Character) -> Bool {
character == ";" || character == ")" || character == "\n" || character == "\r"
}

private static func isWordCharacter(_ character: Character) -> Bool {
character.isLetter || character.isNumber || character == "_" || character == "$"
}

private static func isAccountCharacter(_ character: Character) -> Bool {
isWordCharacter(character) || character == "." || character == "-" || character == "%"
}
}
17 changes: 17 additions & 0 deletions Plugins/SQLExportPlugin/SQLExportModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,23 @@ import Foundation
public struct SQLExportOptions: Equatable, Codable {
public var compressWithGzip: Bool = false
public var batchSize: Int = 500
public var excludeAutoIncrementValue: Bool = true
public var excludeDefiner: Bool = true

public init() {}

/// A synthesized `init(from:)` throws `keyNotFound` for a key the saved payload predates, and
/// never falls back to the property's default, so every option added here would silently reset
/// the ones a user had already chosen.
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let defaults = SQLExportOptions()
compressWithGzip = try container.decodeIfPresent(Bool.self, forKey: .compressWithGzip)
?? defaults.compressWithGzip
batchSize = try container.decodeIfPresent(Int.self, forKey: .batchSize) ?? defaults.batchSize
excludeAutoIncrementValue = try container.decodeIfPresent(Bool.self, forKey: .excludeAutoIncrementValue)
?? defaults.excludeAutoIncrementValue
excludeDefiner = try container.decodeIfPresent(Bool.self, forKey: .excludeDefiner)
?? defaults.excludeDefiner
}
}
24 changes: 24 additions & 0 deletions Plugins/SQLExportPlugin/SQLExportOptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ struct SQLExportOptionsView: View {

private static let batchSizeOptions = [1, 100, 500, 1_000]

private static let autoIncrementHelp = String(
localized: "MySQL and MariaDB. Drops the table's next key value. The column keeps its AUTO_INCREMENT attribute, and restoring rows sets the counter from the data.",
bundle: .main
)

private static let definerHelp = String(
localized: """
MySQL and MariaDB. Drops the account a view was created under. The importing account \
becomes the definer, so the view runs with its privileges. An account the target server \
does not have makes the import fail.
""",
bundle: .main
)

var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Structure, Drop, and Data options are configured per table in the table list.")
Expand Down Expand Up @@ -38,6 +52,16 @@ struct SQLExportOptionsView: View {
}
.help("Higher values create fewer INSERT statements, resulting in smaller files and faster imports")

Toggle("Exclude the AUTO_INCREMENT counter", isOn: $plugin.settings.excludeAutoIncrementValue)
.toggleStyle(.checkbox)
.font(.system(size: 13))
.help(Self.autoIncrementHelp)

Toggle("Exclude DEFINER clauses", isOn: $plugin.settings.excludeDefiner)
.toggleStyle(.checkbox)
.font(.system(size: 13))
.help(Self.definerHelp)

Toggle("Compress the file using Gzip", isOn: $plugin.settings.compressWithGzip)
.toggleStyle(.checkbox)
.font(.system(size: 13))
Expand Down
13 changes: 11 additions & 2 deletions Plugins/SQLExportPlugin/SQLExportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@
settings.compressWithGzip ? "sql.gz" : "sql"
}

private func ddlRewriter(for dataSource: any PluginExportDataSource) -> SQLExportDDLRewriter {
SQLExportDDLRewriter(
dialect: SqlDialect.from(databaseTypeId: dataSource.databaseTypeId),
excludesAutoIncrementValue: settings.excludeAutoIncrementValue,
excludesDefiner: settings.excludeDefiner)
}

@MainActor
func settingsView() -> AnyView? {
AnyView(SQLExportOptionsView(plugin: self))
Expand Down Expand Up @@ -339,6 +346,7 @@
to fileHandle: FileHandle,
progress: PluginExportProgress
) async throws {
let rewriter = ddlRewriter(for: dataSource)
for (index, table) in sortedTables.enumerated() where optionValue(table, at: 0) {
try progress.checkCancellation()
progress.setCurrentTable(table.qualifiedName, index: index + 1)
Expand All @@ -347,8 +355,9 @@
try fileHandle.write(contentsOf: "-- Table: \(sanitizedName)\n".toUTF8Data())
try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n\n".toUTF8Data())
do {
let ddl = try await dataSource.fetchTableDDL(
table: table.name, databaseName: table.databaseName)
let ddl = rewriter.rewrite(
try await dataSource.fetchTableDDL(
table: table.name, databaseName: table.databaseName))
try fileHandle.write(contentsOf: ddl.toUTF8Data())
if !ddl.hasSuffix(";") {
try fileHandle.write(contentsOf: ";".toUTF8Data())
Expand Down Expand Up @@ -537,7 +546,7 @@
switch element {
case .header(let header):
columns = header.columns
columnTypeNames = header.columnTypeNames ?? []

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

View workflow job for this annotation

GitHub Actions / Build for testing

left side of nil coalescing operator '??' has non-optional type '[String]', so the right side is never used
case .rows(let rows):
for row in rows {
rowBatch.append(row)
Expand Down
Loading
Loading