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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Edit > Data shown only in data file windows.
- Smaller updates: about 6 MB from any of the last five releases and 21 MB from older ones, down from 34 MB.
- Several statements run together on PostgreSQL from iPhone and iPad commit one at a time, and a failure keeps the ones before it. (#3399)
- MySQL dumps write TIMESTAMP values in UTC and turn off foreign key checks while they load, as mysqldump does.

### Removed

Expand Down Expand Up @@ -85,6 +86,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Database type icon and checkmark hard to see on the selected row in Choose a Database.
- Oracle connection timing out after 30 seconds when the password is about to expire. (#3401)
- Oracle session left open on the server when a connect was canceled during login.
- MySQL dumps failing to restore on zero dates such as `0000-00-00`, or renumbering an AUTO_INCREMENT value of 0. (#3414)
- Strings with backslashes changing when a MySQL dump is restored on a server using NO_BACKSLASH_ESCAPES.
- TIMESTAMP values shifting when a MySQL dump or copy moves between servers in different time zones.
- MySQL dumps exported from an ANSI_QUOTES session failing to restore.
- Restored MySQL routines, triggers and events losing the SQL mode they were created with.
- MySQL dumps failing in the mysql client when a table references one created after it.
- PostgreSQL dumps changing dates, intervals and floats exported from a session with non-default output settings.
- Imported SQL files leaving their settings on the editor's connection, such as an empty search_path after a pg_dump file.
- MySQL Object Copy and Import from connection failing on zero dates and renumbering AUTO_INCREMENT values of 0.
- SQL import reporting a rollback after MySQL had already committed the file's schema changes.
- Only the first statement running on iPhone and iPad when the query holds several. (#3399)
- Query on iPhone and iPad appearing to do nothing after CREATE, DROP or a write that changed no rows, including every Oracle write. (#3399)
- SQLite on iPhone and iPad reporting a CHECK or UNIQUE violation as a saved row in Add Row, the row editor and Shortcuts. (#3399)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation

/// The `sql_mode`, and for an event the `time_zone`, the server recorded when a routine, trigger or event was created
/// and applies whenever it runs, as the statements a script writes around the object's `CREATE`.
///
/// mysqldump's form (`switch_sql_mode`, `switch_time_zone` and their restores in `client/mysqldump.cc`): the object
/// saves the script's values in variables of its own, never the dump-wide `@OLD_SQL_MODE`, and the closing puts them
/// back in reverse order. No statement carries a terminator.
public struct MySQLCreationSettings: Equatable, Sendable {
public let sqlMode: String
public let timeZone: String?

/// Nil when a value would close the version comment it is written in.
public init?(sqlMode: String, timeZone: String? = nil) {
guard !sqlMode.contains("*/"), timeZone?.contains("*/") != true else { return nil }
let portableMode = Self.portableMode(sqlMode)
self.sqlMode = portableMode
self.timeZone = timeZone
}

/// MariaDB records `NO_AUTO_CREATE_USER` on every routine and trigger its default mode creates, and MySQL 8
/// refuses the name with 1231, so a dump would stop at its first routine there. The mode only decides whether
/// `GRANT` may create an account, and mysqldump 5.7 drops it the same way (`remove_sql_mode`).
static func portableMode(_ mode: String) -> String {
mode.split(separator: ",", omittingEmptySubsequences: false)
.filter { $0 != "NO_AUTO_CREATE_USER" }
.joined(separator: ",")
}

public var opening: [String] {
var statements = [
"/*!50003 SET @saved_sql_mode = @@sql_mode */",
"/*!50003 SET sql_mode = '\(mysqlEscapeStringLiteral(sqlMode))' */",
]
if let timeZone {
statements.append("/*!50003 SET @saved_time_zone = @@time_zone */")
statements.append("/*!50003 SET time_zone = '\(mysqlEscapeStringLiteral(timeZone))' */")
}
return statements
}

public var closing: [String] {
var statements: [String] = []
if timeZone != nil {
statements.append("/*!50003 SET time_zone = @saved_time_zone */")
}
statements.append("/*!50003 SET sql_mode = @saved_sql_mode */")
return statements
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,41 @@ public enum MySQLObjectQueries {
return header
}

// The creation-settings reads select the name too: the catalog compares names case-insensitively on MySQL 5.7
// and earlier and on MariaDB, while a trigger name is case-sensitive wherever table names are, so the driver
// keeps only the row named exactly.

/// The `sql_mode` the server recorded for one routine.
public static func routineCreationSettings(schema: String, name: String, kind: String) -> String {
"""
SELECT ROUTINE_NAME, SQL_MODE
FROM information_schema.ROUTINES
WHERE ROUTINE_SCHEMA = '\(escapeLiteral(schema))'
AND ROUTINE_NAME = '\(escapeLiteral(name))'
AND ROUTINE_TYPE = '\(escapeLiteral(kind))'
"""
}

/// The `sql_mode` the server recorded for one trigger.
public static func triggerCreationSettings(schema: String, name: String) -> String {
"""
SELECT TRIGGER_NAME, SQL_MODE
FROM information_schema.TRIGGERS
WHERE TRIGGER_SCHEMA = '\(escapeLiteral(schema))'
AND TRIGGER_NAME = '\(escapeLiteral(name))'
"""
}

/// The `sql_mode` and `time_zone` the server recorded for one event.
public static func eventCreationSettings(schema: String, name: String) -> String {
"""
SELECT EVENT_NAME, SQL_MODE, TIME_ZONE
FROM information_schema.EVENTS
WHERE EVENT_SCHEMA = '\(escapeLiteral(schema))'
AND EVENT_NAME = '\(escapeLiteral(name))'
"""
}

/// A DEFINER arrives as `user@host` and both halves are identifiers, so quoting the whole
/// string produces a name no server will accept.
public static func quotedDefiner(_ definer: String) -> String {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Foundation

/// The statements that read a session's settings and put them back, spelled with backslash escapes like every
/// statement the plugin writes.
public enum MySQLSessionStatements {
/// A system variable name the driver may interpolate into a statement: lowercase letters and underscores only.
public static func isSettingName(_ name: String) -> Bool {
!name.isEmpty && name.unicodeScalars.allSatisfy { ("a"..."z").contains($0) || $0 == "_" }
}

/// `SHOW` rather than `SELECT @@name`, because `SHOW` reports a boolean as `ON` or `OFF`, which `SET` takes back
/// quoted; `SET SESSION foreign_key_checks = '1'` fails with 1231 (`scripts/probes/check-mysql-dump-session.sh`).
/// A name that is not one is left out, and nil means nothing was left to ask about.
public static func captureQuery(names: [String]) -> String? {
let asked = names.filter(isSettingName)
guard !asked.isEmpty else { return nil }
let list = asked.map { "'\($0)'" }.joined(separator: ", ")
return "SHOW SESSION VARIABLES WHERE Variable_name IN (\(list))"
}

/// What a `SHOW` row's value stands for. `SHOW` prints a NULL `character_set_results` as an empty string,
/// measured on MariaDB 13.0.2, and no character set or collation is named by one, so there it means NULL.
public static func reportedValue(name: String, value: String?) -> String? {
guard let value, value.isEmpty else { return value }
let namesCharset = name.hasPrefix("character_set_") || name.hasPrefix("collation_")
return namesCharset ? nil : value
}

/// `SET SESSION <name> = <literal>`, with SQL `NULL` for a nil value, or nil for a name that is not one.
public static func assignment(name: String, value: String?) -> String? {
guard isSettingName(name) else { return nil }
let literal = value.map { "'\(mysqlEscapeStringLiteral($0))'" } ?? "NULL"
return "SET SESSION \(name) = \(literal)"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import Foundation
import Testing

@testable import TableProMySQLCore

struct MySQLCreationSettingsTests {
@Test("A routine or trigger switches the mode through its own variable and puts it back")
func modeOnly() throws {
let settings = try #require(MySQLCreationSettings(sqlMode: "STRICT_TRANS_TABLES,NO_ZERO_DATE"))
#expect(settings.opening == [
"/*!50003 SET @saved_sql_mode = @@sql_mode */",
"/*!50003 SET sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_DATE' */",
])
#expect(settings.closing == ["/*!50003 SET sql_mode = @saved_sql_mode */"])
}

@Test("An event also switches the time zone, and the closing undoes both in reverse order")
func modeAndTimeZone() throws {
let settings = try #require(MySQLCreationSettings(sqlMode: "", timeZone: "SYSTEM"))
#expect(settings.opening == [
"/*!50003 SET @saved_sql_mode = @@sql_mode */",
"/*!50003 SET sql_mode = '' */",
"/*!50003 SET @saved_time_zone = @@time_zone */",
"/*!50003 SET time_zone = 'SYSTEM' */",
])
#expect(settings.closing == [
"/*!50003 SET time_zone = @saved_time_zone */",
"/*!50003 SET sql_mode = @saved_sql_mode */",
])
}

@Test("No statement carries a terminator, and none touches the dump-wide @OLD_SQL_MODE")
func leavesTheDumpAlone() throws {
let settings = try #require(MySQLCreationSettings(sqlMode: "ANSI_QUOTES", timeZone: "+02:00"))
for statement in settings.opening + settings.closing {
#expect(!statement.hasSuffix(";"), "\(statement)")
#expect(!statement.contains("@OLD_SQL_MODE"), "\(statement)")
}
}

@Test("A quote in a value is escaped")
func quotesAreEscaped() throws {
let settings = try #require(MySQLCreationSettings(sqlMode: "A'B", timeZone: "C'D"))
#expect(settings.opening[1] == "/*!50003 SET sql_mode = 'A''B' */")
#expect(settings.opening[3] == "/*!50003 SET time_zone = 'C''D' */")
}

/// MariaDB's default mode puts it on every routine and trigger, and MySQL 8 refuses the name with 1231.
@Test("NO_AUTO_CREATE_USER is dropped wherever it stands in the recorded mode", arguments: [
("STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION",
"STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION"),
("NO_AUTO_CREATE_USER,STRICT_TRANS_TABLES", "STRICT_TRANS_TABLES"),
("STRICT_TRANS_TABLES,NO_AUTO_CREATE_USER", "STRICT_TRANS_TABLES"),
("NO_AUTO_CREATE_USER", ""),
("STRICT_TRANS_TABLES", "STRICT_TRANS_TABLES"),
("", ""),
])
func noAutoCreateUserIsDropped(_ recorded: String, _ written: String) throws {
let settings = try #require(MySQLCreationSettings(sqlMode: recorded))
#expect(settings.sqlMode == written)
#expect(settings.opening[1] == "/*!50003 SET sql_mode = '\(written)' */")
}

@Test("A value that would close the version comment gives no settings")
func commentCloserIsRefused() {
#expect(MySQLCreationSettings(sqlMode: "x */ DROP TABLE t; /*") == nil)
#expect(MySQLCreationSettings(sqlMode: "", timeZone: "*/") == nil)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -220,4 +220,41 @@ struct MySQLObjectQueryTests {
#expect(legacy.contains("WHERE r.ROUTINE_SCHEMA = 'app'"))
#expect(MySQLObjectQueries.routineList(schema: "app", includesParameters: true).contains("information_schema.PARAMETERS"))
}

@Test("A routine's recorded mode is read by schema, name and type, with the name selected for an exact match")
func routineCreationSettings() {
let sql = MySQLObjectQueries.routineCreationSettings(schema: "app", name: "add_course", kind: "PROCEDURE")
#expect(sql.contains("SELECT ROUTINE_NAME, SQL_MODE"))
#expect(sql.contains("FROM information_schema.ROUTINES"))
#expect(sql.contains("ROUTINE_SCHEMA = 'app'"))
#expect(sql.contains("ROUTINE_NAME = 'add_course'"))
#expect(sql.contains("ROUTINE_TYPE = 'PROCEDURE'"))
}

@Test("A trigger's recorded mode is read by its own schema and name")
func triggerCreationSettings() {
let sql = MySQLObjectQueries.triggerCreationSettings(schema: "app", name: "audit")
#expect(sql.contains("SELECT TRIGGER_NAME, SQL_MODE"))
#expect(sql.contains("FROM information_schema.TRIGGERS"))
#expect(sql.contains("TRIGGER_SCHEMA = 'app'"))
#expect(sql.contains("TRIGGER_NAME = 'audit'"))
}

@Test("An event's recorded mode comes with its time zone")
func eventCreationSettings() {
let sql = MySQLObjectQueries.eventCreationSettings(schema: "app", name: "nightly")
#expect(sql.contains("SELECT EVENT_NAME, SQL_MODE, TIME_ZONE"))
#expect(sql.contains("FROM information_schema.EVENTS"))
#expect(sql.contains("EVENT_SCHEMA = 'app'"))
#expect(sql.contains("EVENT_NAME = 'nightly'"))
}

@Test("Names in the creation-settings reads are escaped as literals")
func creationSettingsEscapeNames() {
let routine = MySQLObjectQueries.routineCreationSettings(schema: #"a'b"#, name: #"c\d"#, kind: "FUNCTION")
#expect(routine.contains(#"ROUTINE_SCHEMA = 'a''b'"#))
#expect(routine.contains(#"ROUTINE_NAME = 'c\\d'"#))
#expect(MySQLObjectQueries.triggerCreationSettings(schema: "app", name: "it's").contains("TRIGGER_NAME = 'it''s'"))
#expect(MySQLObjectQueries.eventCreationSettings(schema: "app", name: "it's").contains("EVENT_NAME = 'it''s'"))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Foundation
import Testing

@testable import TableProMySQLCore

struct MySQLSessionStatementsTests {
@Test("The capture query asks for the names in the order given")
func captureQueryListsNames() {
let query = MySQLSessionStatements.captureQuery(names: ["sql_mode", "time_zone", "foreign_key_checks"])
#expect(query == "SHOW SESSION VARIABLES WHERE Variable_name IN ('sql_mode', 'time_zone', 'foreign_key_checks')")
}

@Test("A name that is not a setting name is left out of the capture query")
func captureQueryDropsForeignNames() {
let query = MySQLSessionStatements.captureQuery(names: ["sql_mode", "x') OR ('1", "time_zone"])
#expect(query == "SHOW SESSION VARIABLES WHERE Variable_name IN ('sql_mode', 'time_zone')")
#expect(MySQLSessionStatements.captureQuery(names: []) == nil)
#expect(MySQLSessionStatements.captureQuery(names: ["@@sql_mode"]) == nil)
}

@Test("Only lowercase letters and underscores make a setting name", arguments: [
("sql_mode", true),
("character_set_results", true),
("", false),
("SQL_MODE", false),
("sql_mode2", false),
("@@sql_mode", false),
("sql_mode = ''", false),
("sql_mode;", false),
("time_zone/*", false),
])
func settingNames(_ name: String, _ isValid: Bool) {
#expect(MySQLSessionStatements.isSettingName(name) == isValid)
}

@Test("A nil value is SQL NULL, any other value a quoted literal")
func assignmentSpellsNullAndLiterals() {
#expect(MySQLSessionStatements.assignment(name: "character_set_results", value: nil)
== "SET SESSION character_set_results = NULL")
#expect(MySQLSessionStatements.assignment(name: "sql_mode", value: "") == "SET SESSION sql_mode = ''")
#expect(MySQLSessionStatements.assignment(name: "time_zone", value: "+00:00") == "SET SESSION time_zone = '+00:00'")
}

@Test("A value is escaped the way every plugin statement is, before the session respells it")
func assignmentEscapesValues() {
#expect(MySQLSessionStatements.assignment(name: "time_zone", value: #"it's\here"#)
== #"SET SESSION time_zone = 'it''s\\here'"#)
}

/// `SHOW` reports a boolean as `ON` or `OFF`, which `SET` takes back quoted, where `'1'` is refused with 1231.
@Test("ON and OFF pass through as SHOW reported them")
func booleansPassThrough() {
#expect(MySQLSessionStatements.reportedValue(name: "foreign_key_checks", value: "OFF") == "OFF")
#expect(MySQLSessionStatements.assignment(name: "foreign_key_checks", value: "OFF")
== "SET SESSION foreign_key_checks = 'OFF'")
#expect(MySQLSessionStatements.assignment(name: "unique_checks", value: "ON") == "SET SESSION unique_checks = 'ON'")
}

@Test("An empty character set or collation is NULL, and an empty sql_mode stays empty")
func emptyReportedValues() {
#expect(MySQLSessionStatements.reportedValue(name: "character_set_results", value: "") == nil)
#expect(MySQLSessionStatements.reportedValue(name: "collation_connection", value: "") == nil)
#expect(MySQLSessionStatements.reportedValue(name: "sql_mode", value: "") == "")
#expect(MySQLSessionStatements.reportedValue(name: "time_zone", value: nil) == nil)
#expect(MySQLSessionStatements.reportedValue(name: "character_set_results", value: "utf8mb4") == "utf8mb4")
}

@Test("A name that is not a setting name gets no statement")
func assignmentRefusesForeignNames() {
#expect(MySQLSessionStatements.assignment(name: "sql_mode = '', time_zone", value: "x") == nil)
#expect(MySQLSessionStatements.assignment(name: "", value: nil) == nil)
}
}
Loading
Loading