diff --git a/CHANGELOG.md b/CHANGELOG.md index f0acd079d..04780c899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Plugins/SQLExportPlugin/SQLExportDDLRewriter.swift b/Plugins/SQLExportPlugin/SQLExportDDLRewriter.swift new file mode 100644 index 000000000..029f54e16 --- /dev/null +++ b/Plugins/SQLExportPlugin/SQLExportDDLRewriter.swift @@ -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 = [ + "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 == "%" + } +} diff --git a/Plugins/SQLExportPlugin/SQLExportModels.swift b/Plugins/SQLExportPlugin/SQLExportModels.swift index 45d7255ff..9e326beee 100644 --- a/Plugins/SQLExportPlugin/SQLExportModels.swift +++ b/Plugins/SQLExportPlugin/SQLExportModels.swift @@ -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 + } } diff --git a/Plugins/SQLExportPlugin/SQLExportOptionsView.swift b/Plugins/SQLExportPlugin/SQLExportOptionsView.swift index ba6f6e097..69dc352ed 100644 --- a/Plugins/SQLExportPlugin/SQLExportOptionsView.swift +++ b/Plugins/SQLExportPlugin/SQLExportOptionsView.swift @@ -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.") @@ -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)) diff --git a/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/Plugins/SQLExportPlugin/SQLExportPlugin.swift index 30e76a247..a4e0ef7f4 100644 --- a/Plugins/SQLExportPlugin/SQLExportPlugin.swift +++ b/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -64,6 +64,13 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send 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)) @@ -339,6 +346,7 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send 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) @@ -347,8 +355,9 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send 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()) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 2b4c3c746..7396cb62e 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -172,6 +172,146 @@ } } }, + "Exclude the AUTO_INCREMENT counter" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "AUTO_INCREMENT 카운터 제외" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AUTO_INCREMENT sayacını hariç tut" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bỏ bộ đếm AUTO_INCREMENT" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "排除 AUTO_INCREMENT 计数器" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "排除 AUTO_INCREMENT 計數器" + } + } + } + }, + "Exclude DEFINER clauses" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "DEFINER 절 제외" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "DEFINER yan tümcelerini hariç tut" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bỏ mệnh đề DEFINER" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "排除 DEFINER 子句" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "排除 DEFINER 子句" + } + } + } + }, + "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." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "MySQL 및 MariaDB. 뷰를 만든 계정을 제외합니다. 가져오는 계정이 정의자가 되므로 뷰는 해당 계정의 권한으로 실행됩니다. 대상 서버에 없는 계정은 가져오기를 실패하게 만듭니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "MySQL ve MariaDB. Görünümün oluşturulduğu hesabı çıkarır. İçe aktaran hesap tanımlayıcı olur, böylece görünüm o hesabın ayrıcalıklarıyla çalışır. Hedef sunucuda bulunmayan bir hesap içe aktarmayı başarısız kılar." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "MySQL và MariaDB. Bỏ tài khoản đã tạo view. Tài khoản nhập dữ liệu trở thành người định nghĩa, nên view chạy với quyền của tài khoản đó. Tài khoản không có trên máy chủ đích sẽ làm việc nhập thất bại." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "MySQL 和 MariaDB。移除创建视图所用的账户。导入的账户成为定义者,视图将以该账户的权限运行。目标服务器上不存在的账户会导致导入失败。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "MySQL 和 MariaDB。移除建立檢視表所用的帳戶。匯入的帳戶會成為定義者,檢視表將以該帳戶的權限執行。目標伺服器上不存在的帳戶會導致匯入失敗。" + } + } + } + }, + "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." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "MySQL 및 MariaDB. 테이블의 다음 키 값을 제외합니다. 열은 AUTO_INCREMENT 속성을 유지하며, 행을 복원하면 데이터를 기준으로 카운터가 설정됩니다." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "MySQL ve MariaDB. Tablonun bir sonraki anahtar değerini çıkarır. Sütun AUTO_INCREMENT özelliğini korur ve satırlar geri yüklendiğinde sayaç verilerden belirlenir." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "MySQL và MariaDB. Bỏ giá trị khóa kế tiếp của bảng. Cột vẫn giữ thuộc tính AUTO_INCREMENT, và khi khôi phục các hàng, bộ đếm được đặt theo dữ liệu." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "MySQL 和 MariaDB。移除表的下一个键值。列仍保留 AUTO_INCREMENT 属性,恢复数据行时会根据数据设置计数器。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "MySQL 和 MariaDB。移除資料表的下一個鍵值。欄位仍保留 AUTO_INCREMENT 屬性,還原資料列時會依資料設定計數器。" + } + } + } + }, "—" : { "extractionState" : "stale", "localizations" : { diff --git a/TableProTests/Plugins/SQLExportDDLRewriterTests.swift b/TableProTests/Plugins/SQLExportDDLRewriterTests.swift new file mode 100644 index 000000000..8def9924b --- /dev/null +++ b/TableProTests/Plugins/SQLExportDDLRewriterTests.swift @@ -0,0 +1,204 @@ +// +// SQLExportDDLRewriterTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +/// Every fixture here is the literal output of `SHOW CREATE TABLE` or `SHOW CREATE VIEW` on +/// MariaDB 12.3, or of `SELECT sql FROM sqlite_master` on SQLite, so the rewriter is judged against +/// what a driver really hands the export. +@Suite("SQL export DDL rewriter") +struct SQLExportDDLRewriterTests { + private static let stripping = SQLExportDDLRewriter( + dialect: .mysql, + excludesAutoIncrementValue: true, + excludesDefiner: true) + + private static let keeping = SQLExportDDLRewriter( + dialect: .mysql, + excludesAutoIncrementValue: false, + excludesDefiner: false) + + private static let createTable = """ + CREATE TABLE `users` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) DEFAULT NULL COMMENT 'AUTO_INCREMENT=5 and DEFINER=`x`@`y` inside a comment', + `weird AUTO_INCREMENT=9 col` int(11) DEFAULT NULL, + PRIMARY KEY (`id`) + ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci + """ + + private static let createView = "CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`localhost` " + + "SQL SECURITY DEFINER VIEW `v_users` AS select `users`.`id` AS `id` from `users`" + + @Test("The table's counter goes and the column's attribute stays") + func tableCounterIsRemoved() { + let rewritten = Self.stripping.rewrite(Self.createTable) + #expect(rewritten.contains(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_uca1400_ai_ci")) + #expect(rewritten.contains("`id` int(11) NOT NULL AUTO_INCREMENT,")) + #expect(!rewritten.contains("AUTO_INCREMENT=4")) + } + + /// A column COMMENT and a quoted column name are the schema's own data. A pattern that cannot + /// see quoting rewrites both, and the table comes back with a different column name. + @Test("Quoted text is left alone") + func quotedTextIsUntouched() { + let rewritten = Self.stripping.rewrite(Self.createTable) + #expect(rewritten.contains("COMMENT 'AUTO_INCREMENT=5 and DEFINER=`x`@`y` inside a comment'")) + #expect(rewritten.contains("`weird AUTO_INCREMENT=9 col` int(11) DEFAULT NULL")) + } + + @Test("A view loses its definer and keeps its security clause") + func viewDefinerIsRemoved() { + let rewritten = Self.stripping.rewrite(Self.createView) + #expect(rewritten == "CREATE ALGORITHM=UNDEFINED SQL SECURITY DEFINER VIEW `v_users` " + + "AS select `users`.`id` AS `id` from `users`") + } + + /// Dropping SQL SECURITY along with the account, which is what `mysqlpump --skip-definer` does, + /// turns a view the server runs as its caller into one it runs as its owner. + @Test("An invoker-rights view stays invoker-rights") + func invokerSecurityIsPreserved() { + let ddl = "CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`localhost` " + + "SQL SECURITY INVOKER VIEW `v_inv` AS select 1" + #expect(Self.stripping.rewrite(ddl) == "CREATE ALGORITHM=UNDEFINED SQL SECURITY INVOKER VIEW `v_inv` AS select 1") + } + + @Test("A definer with no host is removed") + func currentUserDefinerIsRemoved() { + let ddl = "CREATE DEFINER = CURRENT_USER SQL SECURITY DEFINER VIEW `v` AS select 1" + #expect(Self.stripping.rewrite(ddl) == "CREATE SQL SECURITY DEFINER VIEW `v` AS select 1") + } + + @Test("A quoted definer with spaces in it is removed whole") + func quotedDefinerIsRemoved() { + let ddl = "CREATE DEFINER='report user'@'10.0.0.%' SQL SECURITY DEFINER VIEW `v` AS select 1" + #expect(Self.stripping.rewrite(ddl) == "CREATE SQL SECURITY DEFINER VIEW `v` AS select 1") + } + + @Test("A trigger loses its definer") + func triggerDefinerIsRemoved() { + let ddl = "CREATE DEFINER=`root`@`localhost` TRIGGER `tr` BEFORE INSERT ON `users` " + + "FOR EACH ROW SET NEW.name = NEW.name" + #expect(Self.stripping.rewrite(ddl) == "CREATE TRIGGER `tr` BEFORE INSERT ON `users` " + + "FOR EACH ROW SET NEW.name = NEW.name") + } + + @Test("A clause that ends the statement takes the space before it") + func trailingClauseLeavesNoGap() { + #expect(Self.stripping.rewrite("CREATE TABLE `t` (`id` int) ENGINE=InnoDB AUTO_INCREMENT=4;") + == "CREATE TABLE `t` (`id` int) ENGINE=InnoDB;") + #expect(Self.stripping.rewrite("CREATE TABLE `t` (`id` int) ENGINE=InnoDB AUTO_INCREMENT=4") + == "CREATE TABLE `t` (`id` int) ENGINE=InnoDB") + } + + @Test("Whitespace around the clause is accepted") + func spacedAssignmentIsRemoved() { + #expect(Self.stripping.rewrite(") ENGINE=InnoDB AUTO_INCREMENT = 17 DEFAULT CHARSET=utf8") + == ") ENGINE=InnoDB DEFAULT CHARSET=utf8") + } + + @Test("A word that only ends in the keyword is not a clause") + func longerIdentifiersAreNotClauses() { + #expect(Self.stripping.rewrite(") ENGINE=InnoDB COMMENT_AUTO_INCREMENT=4") + == ") ENGINE=InnoDB COMMENT_AUTO_INCREMENT=4") + #expect(Self.stripping.rewrite("CREATE my_definer=1 VIEW v AS select 1") + == "CREATE my_definer=1 VIEW v AS select 1") + } + + @Test("Text inside a comment is copied rather than rewritten") + func commentTextIsUntouched() { + let ddl = ") ENGINE=InnoDB -- AUTO_INCREMENT=9\nENGINE=InnoDB AUTO_INCREMENT=9" + #expect(Self.stripping.rewrite(ddl) == ") ENGINE=InnoDB -- AUTO_INCREMENT=9\nENGINE=InnoDB") + let block = "/* DEFINER=`a`@`b` */ CREATE DEFINER=`a`@`b` VIEW v AS select 1" + #expect(Self.stripping.rewrite(block) == "/* DEFINER=`a`@`b` */ CREATE VIEW v AS select 1") + } + + @Test("Each option only removes its own clause") + func optionsAreIndependent() { + let definerOnly = SQLExportDDLRewriter( + dialect: .mysql, excludesAutoIncrementValue: false, excludesDefiner: true) + let counterOnly = SQLExportDDLRewriter( + dialect: .mysql, excludesAutoIncrementValue: true, excludesDefiner: false) + let ddl = "CREATE DEFINER=`a`@`b` VIEW v AS select 1; ) ENGINE=InnoDB AUTO_INCREMENT=4;" + #expect(definerOnly.rewrite(ddl) == "CREATE VIEW v AS select 1; ) ENGINE=InnoDB AUTO_INCREMENT=4;") + #expect(counterOnly.rewrite(ddl) == "CREATE DEFINER=`a`@`b` VIEW v AS select 1; ) ENGINE=InnoDB;") + } + + @Test("Both options off leaves the statement byte for byte") + func disabledRewriterIsIdentity() { + #expect(Self.keeping.rewrite(Self.createTable) == Self.createTable) + #expect(Self.keeping.rewrite(Self.createView) == Self.createView) + } + + @Test("An unterminated quote is copied rather than parsed past") + func unterminatedQuoteIsCopied() { + let ddl = ") ENGINE=InnoDB COMMENT 'oops AUTO_INCREMENT=4" + #expect(Self.stripping.rewrite(ddl) == ddl) + } + + // MARK: - Position + + /// `auto_increment` and `definer` are ordinary identifiers on other engines, and a driver hands + /// back the catalog's own text. Taking the clause wherever it appears emptied the constraint: + /// SQLite reported `CHECK (auto_increment = 4)` and the export wrote `CHECK ()`. + @Test("A counter inside a constraint expression is not a table option") + func constraintExpressionSurvives() { + let ddl = "CREATE TABLE t (auto_increment int, CONSTRAINT c CHECK ((auto_increment = 4))) ENGINE=InnoDB AUTO_INCREMENT=7" + #expect(Self.stripping.rewrite(ddl) + == "CREATE TABLE t (auto_increment int, CONSTRAINT c CHECK ((auto_increment = 4))) ENGINE=InnoDB") + } + + @Test("A definer column in a view body is not a definer clause") + func viewBodyComparisonSurvives() { + let ddl = "CREATE DEFINER=`root`@`localhost` SQL SECURITY DEFINER VIEW v AS select 1 where definer = CURRENT_USER" + #expect(Self.stripping.rewrite(ddl) + == "CREATE SQL SECURITY DEFINER VIEW v AS select 1 where definer = CURRENT_USER") + } + + @Test("A statement that never opened a CREATE header keeps its definer text") + func definerOutsideAHeaderSurvives() { + let ddl = "ALTER TABLE t ADD COLUMN c int; UPDATE t SET definer = 'a'@'b'" + #expect(Self.stripping.rewrite(ddl) == ddl) + } + + /// Under `ANSI_QUOTES` the server quotes identifiers with `"` instead of a backtick. It still + /// writes a literal backslash doubled and an embedded quote doubled, measured on MariaDB 12.3, + /// so a name carrying a clause spelling reaches the scanner inside a quoted run either way. + @Test("An ANSI_QUOTES identifier carrying a clause spelling is left alone") + func ansiQuotedIdentifierSurvives() { + let ddl = """ + CREATE TABLE "weird" ( + "a\\\\""b AUTO_INCREMENT=5" int(11) DEFAULT NULL + ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 + """ + let rewritten = Self.stripping.rewrite(ddl) + #expect(rewritten.contains(#""a\\""b AUTO_INCREMENT=5" int(11) DEFAULT NULL"#)) + #expect(rewritten.hasSuffix(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")) + } + + // MARK: - Dialect + + /// The clauses are MySQL's spelling. Running the scan on another engine's DDL can only take + /// something out of a statement that never had one. + @Test("Another engine's DDL is handed back untouched") + func otherDialectsArePassedThrough() { + let sqlite = "CREATE TABLE t (auto_increment INTEGER, definer TEXT, CHECK (auto_increment = 4))" + let postgres = """ + CREATE TABLE public.users ( + auto_increment integer, + definer text, + CONSTRAINT c CHECK ((auto_increment = 4)) + ); + """ + for dialect in [SqlDialect.sqlite, .postgres, .generic] { + let rewriter = SQLExportDDLRewriter( + dialect: dialect, excludesAutoIncrementValue: true, excludesDefiner: true) + #expect(rewriter.rewrite(sqlite) == sqlite) + #expect(rewriter.rewrite(postgres) == postgres) + } + } +} diff --git a/TableProTests/Plugins/SQLExportOptionsDecodingTests.swift b/TableProTests/Plugins/SQLExportOptionsDecodingTests.swift new file mode 100644 index 000000000..c30a07c53 --- /dev/null +++ b/TableProTests/Plugins/SQLExportOptionsDecodingTests.swift @@ -0,0 +1,46 @@ +// +// SQLExportOptionsDecodingTests.swift +// TableProTests +// + +import Foundation +import Testing + +/// Settings are stored as JSON, so a payload written by an older build carries only the keys that +/// build knew. A synthesized `Decodable` throws `keyNotFound` for the rest and never falls back to +/// the property's default, and `PluginSettingsStorage.load` answers a throwing decode with nil, so +/// one added option silently resets every choice the user had already made. +@Suite("SQL export options decoding") +struct SQLExportOptionsDecodingTests { + @Test("A payload that predates the exclusions keeps the choices it does carry") + func legacyPayloadKeepsItsValues() throws { + let stored = Data(#"{"batchSize":1000,"compressWithGzip":true}"#.utf8) + let decoded = try JSONDecoder().decode(SQLExportOptions.self, from: stored) + #expect(decoded.batchSize == 1_000) + #expect(decoded.compressWithGzip) + #expect(decoded.excludeAutoIncrementValue) + #expect(decoded.excludeDefiner) + } + + @Test("An absent key takes the default rather than failing the decode") + func absentKeysTakeDefaults() throws { + let decoded = try JSONDecoder().decode(SQLExportOptions.self, from: Data("{}".utf8)) + #expect(decoded == SQLExportOptions()) + } + + @Test("A stored false survives the round trip") + func storedFalseIsNotOverwrittenByTheDefault() throws { + var options = SQLExportOptions() + options.excludeAutoIncrementValue = false + options.excludeDefiner = false + let restored = try JSONDecoder().decode(SQLExportOptions.self, from: JSONEncoder().encode(options)) + #expect(restored == options) + } + + @Test("Both exclusions start on") + func exclusionsDefaultToOn() { + let options = SQLExportOptions() + #expect(options.excludeAutoIncrementValue) + #expect(options.excludeDefiner) + } +} diff --git a/docs/features/import-export.mdx b/docs/features/import-export.mdx index 07ef9f722..8f9d38f42 100644 --- a/docs/features/import-export.mdx +++ b/docs/features/import-export.mdx @@ -63,6 +63,8 @@ A whole-table export streams from the database at constant memory, with no row-c |--------|---------| | Compress with gzip (`.sql.gz`) | No | | Batch size (rows per INSERT: 1, 100, 500, 1,000) | 500 | + | Exclude the AUTO_INCREMENT counter | Yes | + | Exclude DEFINER clauses | Yes | Structure (CREATE TABLE), Drop (DROP TABLE IF EXISTS), and Data (INSERT statements) are per-table checkboxes, and a multi-table export can mix them. @@ -72,6 +74,12 @@ A whole-table export streams from the database at constant memory, with no row-c A multi-table export orders the tables by their foreign keys, so a parent is created and filled before the rows that reference it and dropped after them. Foreign keys between two tables that reference each other leave no such order: those tables keep the order the export listed them in, the file says so in a comment, and the summary repeats it. Import that one with **Disable foreign key checks** ticked. + The last two exclusions cover MySQL and MariaDB, and pass every other engine through untouched. + + Excluding the counter drops `AUTO_INCREMENT=` from the table options and leaves the column's own `AUTO_INCREMENT` attribute alone. Restoring rows sets the counter one past the highest key in the data, so a source counter that had run ahead of its rows, after deletes or a reset, does not carry over. + + Excluding definers drops `DEFINER=user@host` from a view. The account running the import becomes the definer, and `SQL SECURITY` is untouched, so a definer-rights view then runs with that account's privileges. Keep the clause and the import fails with `ERROR 1227 (42000): Access denied; you need (at least one of) the SET USER privilege(s) for this operation` unless the importing account is privileged, and a view that does get created answers `ERROR 1446 (HY000): The user specified as a definer ('…') does not exist` on every query against it. An invoker-rights view still runs as its caller. + Not available on MongoDB or Redis. diff --git a/project.yml b/project.yml index 1bc7baa27..388c3f60f 100644 --- a/project.yml +++ b/project.yml @@ -484,6 +484,7 @@ targets: - Plugins/RedisDriverPlugin/RedisSentinelResolver.swift - Plugins/RedisDriverPlugin/RedisStatementGenerator.swift - Plugins/RedisDriverPlugin/RedisTopologyDiagnostics.swift + - Plugins/SQLExportPlugin/SQLExportDDLRewriter.swift - Plugins/SQLExportPlugin/SQLExportModels.swift - Plugins/SQLExportPlugin/SQLExportOptionsView.swift - Plugins/SQLExportPlugin/SQLExportPlugin.swift