diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4d32b51 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,111 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + linux: + # Live tests against MariaDB and MySQL servers. + name: Linux (Swift ${{ matrix.swift }}, ${{ matrix.configuration }}) + runs-on: ubuntu-24.04 + container: swift:${{ matrix.swift }}-noble + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # The oldest supported Swift (swift-tools-version 6.2) and the newest. + swift: ["6.2", "6.4"] + configuration: [debug, release] + services: + mariadb: + image: mariadb:11.8 + env: + MARIADB_ROOT_PASSWORD: "123" + options: >- + --health-cmd "healthcheck.sh --connect --innodb_initialized" + --health-interval 5s --health-timeout 5s --health-retries 30 + mysql: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: "123" + options: >- + --health-cmd "mysqladmin ping -h 127.0.0.1" + --health-interval 5s --health-timeout 5s --health-retries 30 + env: + MARIA_TESTS: "1" + MARIA_FK_LIVE_TESTS: "1" + MARIA_TEST_PORT: "3306" + # Both servers use the same root password, so no step needs an empty value. + MARIA_TEST_PASSWORD: "123" + # Dates must read back the same outside UTC (and across a DST gap). + TZ: America/Denver + steps: + - uses: actions/checkout@v7 + + - name: Install client libraries + run: | + apt-get update + apt-get install -y --no-install-recommends pkg-config libmariadb-dev tzdata + + - name: Toolchain + run: swift --version + + - name: Build (warnings are errors) + run: | + flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors" + # @testable imports need testing enabled in an optimized build. + [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing" + echo "FLAGS=$flags" >> "$GITHUB_ENV" + swift build --build-tests $flags + + - name: Test against MariaDB 11.8 + run: swift test $FLAGS + env: + MARIA_TEST_HOST: mariadb + + - name: Test against MySQL 8.4 + run: swift test $FLAGS + env: + MARIA_TEST_HOST: mysql + + macos: + # GitHub's macOS runners have no database server, so the live tests are skipped here. + name: macOS (Xcode ${{ matrix.xcode }}, ${{ matrix.configuration }}) + runs-on: macos-26 + timeout-minutes: 30 + env: + TZ: America/Denver + strategy: + fail-fast: false + matrix: + # The oldest Xcode with Swift 6.2, and the runner's default. + xcode: ["26.0.1", "26.6"] + configuration: [debug, release] + steps: + - uses: actions/checkout@v7 + + - name: Select Xcode + run: | + sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + swift --version + + - name: Install client library + run: | + brew install mariadb-connector-c + # Keg-only; Swift 6.2's SwiftPM (Xcode 26.3) didn't find it through the brew provider alone. + echo "PKG_CONFIG_PATH=$(brew --prefix mariadb-connector-c)/lib/pkgconfig" >> "$GITHUB_ENV" + + - name: Build (warnings are errors) + run: | + flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors" + # @testable imports need testing enabled in an optimized build. + [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing" + echo "FLAGS=$flags" >> "$GITHUB_ENV" + swift build --build-tests $flags + + - name: Test + run: swift test $FLAGS diff --git a/Package.swift b/Package.swift index 8c459b2..ba82864 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ let package = Package( .library(name: "PerfectMariaDB", targets: ["MariaDB"]), ], dependencies: [ - .package(url: "https://github.com/PerfectlySoft/Perfect-CRUD.git", branch: "main"), + .package(url: "https://github.com/PerfectlySoft/Perfect-CRUD.git", from: "3.0.1"), ], targets: [ .systemLibrary( @@ -30,12 +30,7 @@ let package = Package( .testTarget( name: "MariaDBTests", dependencies: ["MariaDB", "mariadbclient"], - swiftSettings: [.swiftLanguageMode(.v6)], - // macOS: search for dylibs before static archives in every -L directory, so - // `-lmariadb` always finds the system libmariadb, never an archive in the build - // products directory that matches case-insensitively. Test targets of dependencies - // are never built, so this doesn't affect packages that depend on this one. - linkerSettings: [.unsafeFlags(["-Xlinker", "-search_dylibs_first"], .when(platforms: [.macOS]))] + swiftSettings: [.swiftLanguageMode(.v6)] ), ] ) diff --git a/README.md b/README.md index 18991ba..4089010 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

Swift 6.2 - Platforms macOS 12+ + Platforms macOS 12+ | Linux License Apache 2.0

@@ -22,25 +22,37 @@ MariaDB target rather than not-yet-in-use or dead code. The pre-Swift-6 version of this package is preserved on the [`legacy`](../../tree/legacy) branch. -## macOS Build Notes +## Requirements -`Package.swift` declares `platforms: [.macOS(.v12)]`. +- Swift 6.2 or newer (`swift-tools-version: 6.2`, Swift 6 language mode). +- macOS 12 or newer, or Linux. CI builds and tests on macOS (Xcode 26.0.1 and 26.6) and on Ubuntu 24.04 + (Swift 6.2 and 6.4), with the Linux jobs running the live tests against MariaDB 11.8 and MySQL 8.4. +- MariaDB Connector/C (libmariadb). It talks to both MariaDB and MySQL servers. +- [Perfect-CRUD](https://github.com/PerfectlySoft/Perfect-CRUD) 3.0.1 or newer, which SwiftPM fetches for you. -### To install the MariaDB connector: +### macOS ```bash brew install mariadb-connector-c ``` -## Linux Build Notes +Homebrew installs it keg-only (it conflicts with the `mariadb` formula), so it isn't linked into `/opt/homebrew`. +SwiftPM in Swift 6.4 finds it through the package's Homebrew provider. With older toolchains (seen with Swift 6.2 in +Xcode 26.3), point pkg-config at it, or the build fails with `'mysql.h' file not found`: -Linux is not declared in the `platforms` array in `Package.swift` (only `.macOS(.v12)` is), so it is not an officially asserted/tested target. That said, the `mariadbclient` system-library target still declares an `.apt(["libmariadb-dev"])` provider, so a Linux build remains possible at the toolchain level if you ensure the library is installed: +```bash +export PKG_CONFIG_PATH="$(brew --prefix mariadb-connector-c)/lib/pkgconfig" +``` + +Having Homebrew's `mysql-client` installed as well is fine: the package links `libmariadb` only. + +### Linux ```bash sudo apt-get install pkg-config libmariadb-dev ``` -To test if pkg-config is working, try running the command: +To check that pkg-config finds the library: ```bash pkg-config libmariadb --cflags --libs @@ -48,12 +60,12 @@ pkg-config libmariadb --cflags --libs ## Building -Add it to your `Package.swift`. There is no tagged release of the Swift 6 version yet, so depend on `main`: +Add it to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/PerfectlySoft/Perfect-MariaDB.git", branch: "main"), - .package(url: "https://github.com/PerfectlySoft/Perfect-CRUD.git", branch: "main"), + .package(url: "https://github.com/PerfectlySoft/Perfect-MariaDB.git", from: "4.0.0"), + .package(url: "https://github.com/PerfectlySoft/Perfect-CRUD.git", from: "3.0.1"), ], targets: [ .target( @@ -67,9 +79,7 @@ targets: [ ``` The library product is named `PerfectMariaDB`, matching `PerfectMySQL` and `PerfectPostgreSQL`; the module is still -imported as `MariaDB`. **Breaking change:** it used to be called `MariaDB`, so if you depended on `main` before this -rename, change `.product(name: "MariaDB", ...)` (or a bare `"MariaDB"` dependency) to -`.product(name: "PerfectMariaDB", package: "Perfect-MariaDB")`. +imported as `MariaDB`. Import required libraries: ```swift @@ -81,6 +91,23 @@ Perfect-MariaDB implements the Perfect-CRUD protocol via `MySQLDatabaseConfigura Note: the source files retain their original `MySQLCRUD.swift`/`MySQLStmt.swift` naming from this package's shared lineage with [Perfect-MySQL](https://github.com/PerfectlySoft/Perfect-MySQL) — MariaDB is wire-compatible with the MySQL client protocol, and the two packages are separate, independently-buildable connectors in this ecosystem. +### Dynamic queries + +Perfect-CRUD's Dynamic API (`select(_: DynamicQuery)`, `mutate(_:)`, `execute(_: DynamicSQL)`) works with this +connector. Integer columns read as `.int` or `.uint` (unsigned columns), FLOAT/DOUBLE as `.double`, binary values as +`.bytes`, and everything else, including DECIMAL and date/time columns, as `.string`. Column names are matched ignoring +case; when a result has several columns whose names differ only by case, or not at all (`p.id, c.id` in a join), the +row keeps the first, so alias the others. `affectedRows` and `insertedID` are reported; a statement that returns rows +(SELECT, CALL, MariaDB's `INSERT ... RETURNING`) reports its row count and no insert ID. A prepared statement holds one +statement, so `DynamicSQL` text with several statements fails as a whole, unless it starts with a statement that runs +unprepared (such as `BEGIN`, or upper-case `CREATE`) on a connection opened with `CLIENT_MULTI_STATEMENTS`, in which +case every statement runs and an error in any of them is thrown. + +Limits: an inserted ID above `Int64.max` (BIGINT UNSIGNED) is reported as nil; an UPDATE that changes nothing reports +0 affected rows (the server counts changed rows, not matched ones); `TINYINT(1)` reads as `.int`, not `.bool`; `YEAR` reads as `.string`; statements that run +unprepared (`BEGIN`, `SAVEPOINT`, `LOCK`, upper-case `CREATE`/`DROP`/`ALTER` and the like) report 0 affected rows; and +only the first result set of a CALL that returns several is visible. + ### TLS Set `MYSQL_OPT_SSL_MODE` before connecting, using MySQL's `SSL_MODE_*` values: 1 = DISABLED, 2 = PREFERRED, @@ -102,9 +129,82 @@ guard mysql.setOption(.MYSQL_OPT_SSL_MODE, 5) else { fatalError("SSL mode not su `MYSQL_OPT_SSL_CA`, the CA on local (loopback or socket) connections. - **DISABLED** still uses TLS if any `MYSQL_OPT_SSL_*` file or cipher option is set. -## Testing - -A `MariaDBTests` target and a `docker-compose.yml` (spins up a local MariaDB container) are included for running the test suite against a real server. +Connector/C 3.4 (Homebrew's current version) has stricter defaults when `MYSQL_OPT_SSL_MODE` isn't set. It refuses a +server without TLS ("SSL is required, but the server does not support it"), even on 127.0.0.1; set DISABLED or +PREFERRED to allow one. On connections that aren't local it also verifies the server certificate, so a remote server +with a self-signed certificate is refused unless you pass its CA with `MYSQL_OPT_SSL_CA` or choose PREFERRED/REQUIRED, +which don't verify. Against MariaDB 11.4 and newer, with a password, it can instead verify the server's self-signed +certificate through a fingerprint derived from the password. Connector/C 3.3 (Ubuntu 24.04's `libmariadb-dev`) does none +of this. + +## Migrating from 3.x + +The previous release was 3.0.4 (2020). 4.0.0 is a Swift 6 rewrite on Perfect-CRUD 3; see Perfect-CRUD's own +release notes for its changes. + +- **Toolchain and platforms:** Swift 6.2 or newer and macOS 12 or newer. On macOS with a toolchain older than Swift + 6.4, set `PKG_CONFIG_PATH` as described above. +- **Product name:** depend on `.product(name: "PerfectMariaDB", package: "Perfect-MariaDB")` instead of `"MariaDB"`. + `import MariaDB` is unchanged. +- **Binary values are bytes:** through `MySQLStmt`, BINARY, VARBINARY, BIT and GEOMETRY values, binary expressions such + as `CAST(x AS BINARY)` or `UNHEX(...)`, and every string column after `SET NAMES binary` are `[UInt8]`, not `String`. + Use `String(decoding: bytes, as: UTF8.self)`, or `CAST(... AS CHAR)` in SQL. CRUD models with `String`, `UUID`, + `Date` or `URL` properties over such columns still decode. +- **Text isn't truncated:** values with invalid UTF-8 used to stop at the first bad byte; they now come back in full, + with U+FFFD in place of each invalid sequence. `MySQL.Results` gains `nextBytes()`, `forEachRowBytes(callback:)` + and `fieldIsBinary(at:)` for exact bytes. +- **Statements the server can't prepare run directly,** so Perfect-CRUD's nested transactions (`SAVEPOINT`) and + `LOCK`/`UNLOCK TABLES` work against MySQL servers. A statement sent this way that returns rows throws. +- **`.reconcileTable`** no longer drops and re-adds columns whose names have uppercase letters (it lost their data), + adds the FOREIGN KEY constraint of a `@ForeignKey` column it adds (the ALTER fails if existing rows violate it), and + drops a removed `@ForeignKey` column's constraint with the column. +- **`create()`** creates a table before the sub-tables that reference it, and `.dropTable` drops them in reverse. +- **Errors while reading rows throw:** an error the server reports partway through a result (for example 1242, a + subquery returning more than one row) used to crash the process inside libmariadb. Perfect-CRUD queries now throw + `MySQLCRUDError`, and `MySQLStmt.Results` has new `failed`, `errorCode` and `errorMessage` properties (its `next()` + returns nil and `forEachRow` returns false after a failure). +- **DECIMAL decodes into `Double` and `Float`:** such properties over a DECIMAL column used to read 0.0; they now read + the value, and text that doesn't parse throws. +- **Dates are read as UTC:** Perfect-CRUD has always written `Date` values as UTC text, but 3.x parsed them in the + process's local time zone, so outside UTC they came back shifted by the UTC offset, and a row whose UTC time fell in + a local daylight-saving gap couldn't be decoded. Dates are now parsed as UTC, and `DATETIME(6)` fractions and `DATE` + values are read too. Rows written through Perfect-CRUD on or after 1582-10-15 read back correctly with no migration. + What does shift: apps that compensated for the old offset by hand; local-time values written outside Perfect-CRUD, + such as `NOW()` on a server whose time zone isn't UTC, or `TIMESTAMP` columns read in a non-UTC session `time_zone` + (run the connection in UTC, e.g. `SET time_zone = '+00:00'`, if you need those to line up); and dates before + 1582-10-15 that 3.x wrote. Dates now use the proleptic Gregorian calendar, as the server does, while 3.x used + Foundation's Julian-before-1582 calendar for both writing and reading, so those older values (including + `Date.distantPast`, which 3.x stored as `0001-01-01`) read back 2 or more days different. Year 0 is accepted, so + `Date.distantPast` (`0000-12-30`) round-trips. Fractional seconds are read but not written (whole seconds, + truncated), and `9999-12-31 23:59:59.999999` no longer turns into year 10000. A `Date` the server can't store + (non-finite, or outside years 0–9999) now throws when bound instead of crashing: from `insert`, `update`, `count()` + and `sql`. In a `select()` whose `where` compares against such a `Date`, Perfect-CRUD catches the error, logs it and + returns no rows. The zero date (`0000-00-00`) reads + as nil for an optional `Date`; for a non-optional one it's a decoding error. Note that Perfect-CRUD's `select()` + stops at a row it can't decode and silently drops it and every later row, so any such error loses the rest of the + result. +- **CALL rows are readable:** `db.sql("CALL p(?)", bindings: ..., T.self)` and Dynamic queries return the procedure's + rows (in 3.x every column read as missing). Only the first result set is read; the others are discarded, so the + connection stays in sync. If a later statement in the procedure fails, the typed and Dynamic calls throw. An untyped + `db.sql("CALL p()")` can't report such an error, because Perfect-CRUD only starts the statement there; read the rows + with a type (or use `execute(DynamicSQL)`) when the procedure can fail after its first result set. +- **`MySQLOpt` has a new case,** `MYSQL_OPT_SSL_MODE`, so an exhaustive `switch` over `MySQLOpt` needs a case for it. +- **`listTables` / `listDatabases` wildcards** can't end the string literal any more. In 3.x libmariadb pasted the + wildcard into `SHOW ... LIKE '...'` unescaped, so with `CLIENT_MULTI_STATEMENTS` the rest of it ran as SQL. Now a + `'` in the wildcard is doubled (which works whatever the server's `NO_BACKSLASH_ESCAPES` mode), the rest is escaped + by the client library, and some wildcards are refused (the result is `[]`): one with both `'` and `\`, and, under + big5, cp932, gbk, gb18030 or sjis, one with a non-ASCII byte and a `'` or `\`. Wildcards keep working under + `NO_BACKSLASH_ESCAPES`. The charset checked is the one set with `MYSQL_SET_CHARSET_NAME`. +- **Statements can be reused safely:** a `MySQLStmt.Results` that's replaced by a newer one (`r = stmt.results()`), or + outlives `nextResult()` or a re-prepare, no longer disturbs the statement's current result, and re-executing a + prepared `CALL` no longer reads freed metadata. `execute()` now returns false (error 2031, `CR_PARAMS_NOT_BOUND`) + unless every parameter has been bound since the last `reset()`; re-executing with the old binds read freed memory. + Binding more values than the statement has parameters, which wrote past the bind array and crashed (also through + `db.sql(_:bindings:)`), is refused: `bindParam` sets `errorCode()` to 2034 (`CR_INVALID_PARAMETER_NO`) and doesn't + store the value, and `execute()` returns false with 2034 until `reset()`. + Blob parameters (`[UInt8]`, `[Int8]`, `bindParam(_:length:)`) and the parameter array no longer leak. +- **`[Int8]` properties** read negative bytes instead of trapping (and Perfect-CRUD 3.0.1, now the minimum, writes them). +- **`connect()`** always passes `CLIENT_REMEMBER_OPTIONS`, so options survive a failed connection attempt. ## Further Information For background on the broader Perfect framework, see [perfect.org](http://perfect.org) and [PerfectlySoft/Perfect](https://github.com/PerfectlySoft/Perfect). @@ -116,8 +216,9 @@ The tests that need a server drop and recreate databases, so they are skipped (a otherwise fall back to 3306, which may be a real server. They connect to `127.0.0.1` as `root` with password `123` by default; override with `MARIA_TEST_HOST`, `MARIA_TEST_USER` and `MARIA_TEST_PASSWORD`. `MARIA_TEST_HOST=localhost` is refused, and so is an empty host, because the client then uses the Unix socket and ignores the port. The foreign-key -tests also need `MARIA_FK_LIVE_TESTS=1`, and their password defaults to empty rather than `123`. For example, with a -throwaway MariaDB container on port 3308: +tests also need `MARIA_FK_LIVE_TESTS=1`, and their password defaults to empty rather than `123`. Two TLS tests need +more: one a non-loopback `MARIA_TEST_HOST`, the other a server without TLS named by `MARIA_TEST_NOTLS_PORT` (and +`MARIA_TEST_NOTLS_HOST`). For example, with the throwaway MariaDB container from `docker-compose.yml` on port 3308: ```sh MARIA_TESTS=1 MARIA_TEST_PORT=3308 swift test diff --git a/README.zh_CN.md b/README.zh_CN.md index 3dd7889..f6c3769 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -23,10 +23,10 @@

- Swift 5.0 + Swift 6.2 - Platforms OS X | Linux + Platforms macOS 12+ | Linux License Apache @@ -47,71 +47,24 @@ 该软件使用SPM进行编译和测试,本软件也是[Perfect](https://github.com/PerfectlySoft/Perfect)项目的一部分。本软件包可独立使用,因此使用时可以脱离PerfectLib等其他组件。 -请确保您已经安装并激活了最新版本的 Swift tool chain 工具链。 +需要 Swift 6.2 或更高版本,支持 macOS 12+ 和 Linux。最新的编译说明、TLS 说明以及从 3.x 迁移的说明请参阅[英文 README](README.md)。 -## OS X 编译时的注意事项 +## macOS 编译时的注意事项 -本软件组件需要[Home Brew](http://brew.sh)管理的 MariaDB 连接库函数二进制版本。 ```bash - brew install mariadb-connector-c +brew install mariadb-connector-c ``` -您必须手工处理pkg-config的.pc文件,比如/usr/local/lib/pkgconfig/mariadb.pc,内容类似如下: +Homebrew 以 keg-only 方式安装该库。Swift 6.4 可以直接找到它;使用更早的工具链时(例如 Xcode 26.3 中的 Swift 6.2),请先设置: ```bash -libdir=/usr/local/lib/mariadb -includedir=/usr/local/include/mariadb - -Name: mariadb -Description: MariaDB Connector/C -Version: 5.5.1 -Requires: -Libs: -L${libdir} -lmariadb -ldl -lm -lpthread -Cflags: -I${includedir} -Libs_r: -L${libdir} -lmariadb -ldl -lm -lpthread -``` - - -为了pkg-config正常工作,最好编辑一下当前用户根目录的 ~/.bash_profile 文件,在最后一行增加以下内容: - -``` -export PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/usr/lib/pkgconfig" -``` - -编辑保存后,将pkg-config需要的变量导入当前环境: - -``` -source ~/.bash_profile -``` - -要验证是否配置成功,请使用命令: - -``` -$ pkg-config libmariadb --cflags --libs +export PKG_CONFIG_PATH="$(brew --prefix mariadb-connector-c)/lib/pkgconfig" ``` ## Linux 编译时注意事项 - -该组件在 Ubuntu 16.04 测试通过。在编译本库函数之前,请确定安装了: - -``` -sudo apt-get install pkg-config libmariadb2 libmariadb-client-lgpl-dev -``` - -与在MacOS下编译前的准备工作一致,Linux一样需要手工编辑pkg-config文件,比如 /usr/lib/pkgconfig/mariadb.pc 。请务必确保其内容正确无误,以下内容为该文件的一个模板: - ```bash -libdir=/usr/lib/x86_64-linux-gnu -includedir=/usr/include/mariadb - -Name: mariadb -Description: MariaDB Connector/C -Version: 5.5.0 -Requires: -Libs: -L${libdir} -lmariadb -ldl -lm -lpthread -Cflags: -I${includedir} -Libs_r: -L${libdir} -lmariadb -ldl -lm -lpthread +sudo apt-get install pkg-config libmariadb-dev ``` 验证是否配置成功,请使用命令: @@ -122,15 +75,19 @@ $ pkg-config libmariadb --cflags --libs ## 编译 -请在您的 Package.swift 文件中增加以下内容。Swift 6 版本尚未发布带标签的正式版本,因此请依赖 `main` 分支: +请在您的 Package.swift 文件中增加以下内容: ```swift -.package(url: "https://github.com/PerfectlySoft/Perfect-MariaDB.git", branch: "main") +.package(url: "https://github.com/PerfectlySoft/Perfect-MariaDB.git", from: "4.0.0"), +.package(url: "https://github.com/PerfectlySoft/Perfect-CRUD.git", from: "3.0.1"), ... -dependencies: [.product(name: "PerfectMariaDB", package: "Perfect-MariaDB")]), +dependencies: [ + .product(name: "PerfectMariaDB", package: "Perfect-MariaDB"), + .product(name: "PerfectCRUD", package: "Perfect-CRUD"), +]), ``` -库产品名为 `PerfectMariaDB`(与 `PerfectMySQL`、`PerfectPostgreSQL` 一致),模块仍以 `import MariaDB` 导入。**破坏性变更:** 该产品原名为 `MariaDB`,如果您此前依赖 `main` 分支,请把 `.product(name: "MariaDB", ...)`(或直接写的 `"MariaDB"`)改为 `.product(name: "PerfectMariaDB", package: "Perfect-MariaDB")`。 +库产品名为 `PerfectMariaDB`(与 `PerfectMySQL`、`PerfectPostgreSQL` 一致),模块仍以 `import MariaDB` 导入。**破坏性变更:** 在 3.x 中该产品名为 `MariaDB`,请把 `.product(name: "MariaDB", ...)`(或直接写的 `"MariaDB"`)改为 `.product(name: "PerfectMariaDB", package: "Perfect-MariaDB")`。 ## 快速上手 diff --git a/Sources/MariaDB/MariaDB.swift b/Sources/MariaDB/MariaDB.swift index b991934..d1b57e8 100644 --- a/Sources/MariaDB/MariaDB.swift +++ b/Sources/MariaDB/MariaDB.swift @@ -181,46 +181,100 @@ public final class MySQL: @unchecked Sendable { return mysql_select_db(self.ptr!, namd) == 0 } - /// Table names matching an optional LIKE pattern (`%` and `_` are wildcards; `\` escapes them). + /// Character sets in which the second byte of a multibyte character can be `\` (0x5C). + static let backslashUnsafeCharsets: Set = ["big5", "cp932", "gbk", "gb18030", "sjis"] + + /// Whether `wild` is refused by listTables / listDatabases: under big5, cp932, gbk, gb18030 + /// or sjis, a wildcard with a byte >= 0x80 that also contains `'` or `\`. A backslash put + /// before that `'` can become the second byte of a multibyte character, so the `'` ends the + /// string literal. Oracle's mysql_list_tables escapes byte by byte without regard to the + /// charset, and libmariadb's mysql_real_escape_string gets big5 wrong on x86_64. The charset + /// is the one the client library knows: set it with MYSQL_SET_CHARSET_NAME, not with a + /// `SET NAMES` query, which it may not see. + func listingWildcardIsUnsafe(_ wild: String?) -> Bool { + guard let wild, let name = mysql_character_set_name(self.ptr!), + Self.backslashUnsafeCharsets.contains(String(cString: name).lowercased()) else { + return false + } + let bytes = wild.utf8 + return bytes.contains { $0 >= 0x80 } && bytes.contains { $0 == UInt8(ascii: "'") || $0 == UInt8(ascii: "\\") } + } + + /// Table names matching an optional LIKE pattern (`%` and `_` are wildcards; `\` escapes them, + /// except on a MySQL server in `NO_BACKSLASH_ESCAPES` mode, where it is an ordinary character). + /// Returns [] on an error, and for a refused wildcard: one containing both `'` and `\`, or one + /// refused by `listingWildcardIsUnsafe`. public func listTables(wildcard wild: String? = nil) -> [String] { return listNames("SHOW TABLES", like: wild) } - /// Database names matching an optional LIKE pattern (`%` and `_` are wildcards; `\` escapes them). + /// Database names matching an optional LIKE pattern (`%` and `_` are wildcards; `\` escapes them, + /// except on a MySQL server in `NO_BACKSLASH_ESCAPES` mode, where it is an ordinary character). + /// Returns [] on an error, and for a refused wildcard: one containing both `'` and `\`, or one + /// refused by `listingWildcardIsUnsafe`. public func listDatabases(wildcard wild: String? = nil) -> [String] { return listNames("SHOW DATABASES", like: wild) } - // libmariadb's mysql_list_tables / mysql_list_dbs paste the wildcard into - // "SHOW ... LIKE '%s'" without escaping it (and cut it off at 255 bytes), so a `'` in it - // ended the string literal. The query is built here instead, with the pattern escaped by - // mysql_real_escape_string, which follows the connection's character set and the server's - // NO_BACKSLASH_ESCAPES mode as libmariadb knows them. (A `SET NAMES` the client doesn't - // see, such as with session_track_system_variables off, isn't followed: set the charset - // with MYSQL_SET_CHARSET_NAME instead.) It's built as bytes: under a charset other than - // UTF-8 the escaped pattern needn't be valid UTF-8. + // mysql_list_tables / mysql_list_dbs aren't used: libmariadb's paste the wildcard into + // "SHOW ... LIKE '%s'" without escaping it at all (and cut it off at 255 bytes), so a `'` + // ended the string literal on a Connector/C build, and Oracle's escape byte by byte (see + // listingWildcardIsUnsafe). The literal is built here, as bytes (under a charset other than + // UTF-8 the result needn't be valid UTF-8), so that nothing depends on knowing the server's + // NO_BACKSLASH_ESCAPES mode. The client library's escaping functions take that mode from a + // status bit that goes stale after a stored routine changes sql_mode, and then a `'` came out + // as `\'` on a server in that mode, or a `\` was left alone on one that isn't, and either + // ended the literal. + // - A `'` is written as `''`, a quote in both modes (and 0x27 is never the second byte of a + // multibyte character). A wildcard that also contains `\` is refused: its meaning would + // depend on the mode. + // - A wildcard without `'` goes through perfect_mysql_escape_string_quote (libmysqlclient's + // mysql_real_escape_string_quote, which unlike mysql_real_escape_string works under + // NO_BACKSLASH_ESCAPES; libmariadb's mysql_real_escape_string). Its output has no `'`, so + // with a wrong mode bit the worst case is a syntax error. + // - The multibyte guard above still applies first. private func listNames(_ show: String, like wild: String?) -> [String] { + guard !listingWildcardIsUnsafe(wild) else { + return [] + } var query = Array(show.utf8) - if let wild = wild { - let from = Array(wild.utf8).map { CChar(bitPattern: $0) } + [0] - var escaped = [CChar](repeating: 0, count: (from.count - 1) * 2 + 1) - let length = mysql_real_escape_string(self.ptr!, &escaped, from, UInt(from.count - 1)) - guard length != UInt.max else { return [] } + if let wild { + let bytes = Array(wild.utf8) + let quote = UInt8(ascii: "'") query += Array(" LIKE '".utf8) - query += escaped[0..: KeyedDecodingContainerProtocol, @uncheck } } func contains(_ key: Key) -> Bool { return nil != columns[key.stringValue] } - func decodeNil(forKey key: Key) throws -> Bool { return nil == column(key) } + func decodeNil(forKey key: Key) throws -> Bool { + guard let value = column(key) else { return true } + // The zero date ('0000-00-00', '0000-00-00 00:00:00') isn't a date. An optional + // property over a date column reads it as nil; a non-optional Date throws. + if let text = value as? String, text.hasPrefix("0000-00-00"), + let index = columns[key.stringValue], statement.fieldInfo(index: index)?.type == .date { + return true + } + return false + } func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool { return ((column(key) as? Int8) ?? 0) != 0 } @@ -72,8 +81,30 @@ class MySQLCRUDRowReader: KeyedDecodingContainerProtocol, @uncheck func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 { return (column(key) as? UInt16) ?? 0 } func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 { return (column(key) as? UInt32) ?? 0 } func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { return (column(key) as? UInt64) ?? 0 } - func decode(_ type: Float.Type, forKey key: Key) throws -> Float { return (column(key) as? Float) ?? 0 } - func decode(_ type: Double.Type, forKey key: Key) throws -> Double { return (column(key) as? Double) ?? 0 } + func decode(_ type: Float.Type, forKey key: Key) throws -> Float { + switch column(key) { + case let f as Float: return f + case let d as Double: return Float(d) + case let text as String: // DECIMAL arrives as text + guard let f = Float(text) else { + throw MySQLCRUDError("Could not convert \(text) into a Float for key: \(key.stringValue)") + } + return f + default: return 0 + } + } + func decode(_ type: Double.Type, forKey key: Key) throws -> Double { + switch column(key) { + case let d as Double: return d + case let f as Float: return Double(f) + case let text as String: // DECIMAL arrives as text + guard let d = Double(text) else { + throw MySQLCRUDError("Could not convert \(text) into a Double for key: \(key.stringValue)") + } + return d + default: return 0 + } + } func decode(_ type: String.Type, forKey key: Key) throws -> String { return text(column(key)) ?? "" } @@ -87,7 +118,7 @@ class MySQLCRUDRowReader: KeyedDecodingContainerProtocol, @uncheck let ret: [UInt8] = (val as? [UInt8]) ?? [] return ret as! T case .int8Array: - let ret: [Int8] = ((val as? [UInt8]) ?? []).map { Int8($0) } + let ret: [Int8] = ((val as? [UInt8]) ?? []).map { Int8(bitPattern: $0) } return ret as! T case .data: let bytes: [UInt8] = (val as? [UInt8]) ?? [] @@ -99,6 +130,9 @@ class MySQLCRUDRowReader: KeyedDecodingContainerProtocol, @uncheck return uuid as! T case .date: guard let str = text(val), let date = Date(fromMysqlFormatted: str) else { + if let str = text(val), str.hasPrefix("0000-00-00") { + throw CRUDDecoderError("Zero date \(str) for key \(key.stringValue) can't be a Date; use an optional property.") + } throw CRUDDecoderError("Invalid Date string \(String(describing: val)).") } return date as! T @@ -447,15 +481,86 @@ class MySQLStmtExeDelegate: SQLExeDelegate, @unchecked Sendable { let connection: MySQL let statement: MySQLStmt var results: MySQLStmt.Results? - let columnMap: MySQLColumnMap + /// Built after each execute(): a CALL has no result metadata until then. + private(set) var columnMap = MySQLColumnMap() + /// For the Dynamic API: each column's position and its name, lowercased, keeping only the + /// first of the columns whose names differ only by case (or not at all), such as `p.Name` + /// and `c.name` in a join. Perfect-CRUD 3.0.0's `DynamicRow.init` traps on a duplicate key. + private(set) var dynamicColumns: [(index: Int, key: String)] = [] + /// Captured after executing, before the results are read and the statement reset. + var affectedRows = 0 + var insertedID: Int64? init(connection c: MySQL, stat: MySQLStmt) { connection = c statement = stat + } + + deinit { + // Callers can stop before the rows run out (Database.sql without a type, first(), a + // decoding error). Leave the connection ready for the next statement anyway. + if results != nil { + results = nil + // Nothing can throw from here (Database.sql without a type calls hasNext() once), so + // an error from a later result is only logged. + if let later = statement.discardRemainingResults() { + CRUDLogging.log(.error, "Error executing statement. \(later.message)") + } + statement.reset() + } + } + + private func buildColumnMaps() { + // When several columns share a name (`a.id, b.id`), the first one wins. This used to + // depend on dictionary order. var m = MySQLColumnMap() - let inv = stat.fieldNames() - inv.forEach { (pos, name) in m[name] = pos } + var dynamic: [(index: Int, key: String)] = [] + var seen = Set() + for (index, name) in statement.fieldNames().sorted(by: { $0.key < $1.key }) { + if m[name] == nil { + m[name] = index + } + if seen.insert(name.lowercased()).inserted { + dynamic.append((index, name.lowercased())) + } + } columnMap = m + dynamicColumns = dynamic + } + + func nextDynamicRow() throws -> DynamicRow? { + guard let row = results?.currentRow() else { return nil } + var values: [String: DynamicValue] = [:] + for (index, key) in dynamicColumns { + guard row.indices.contains(index) else { + throw MySQLCRUDError("Missing dynamic row value for column \(key).") + } + values[key] = try Self.dynamicValue(row[index], column: key) + } + return DynamicRow(values) + } + + func affectedRowCount() -> Int { affectedRows } + + func lastInsertedID() -> Int64? { insertedID } + + static func dynamicValue(_ value: Any?, column: String) throws -> DynamicValue { + switch value { + case nil: return .null + case let v as Int64: return .int(v) + case let v as Int32: return .int(Int64(v)) + case let v as Int16: return .int(Int64(v)) + case let v as Int8: return .int(Int64(v)) + case let v as UInt64: return .uint(v) + case let v as UInt32: return .uint(UInt64(v)) + case let v as UInt16: return .uint(UInt64(v)) + case let v as UInt8: return .uint(UInt64(v)) + case let v as Double: return .double(v) + case let v as Float: return .double(Double(v)) + case let v as String: return .string(v) + case let v as [UInt8]: return .bytes(v) + default: throw MySQLCRUDError("Unsupported value \(String(describing: value)) in column \(column)") + } } func bind(_ bindings: Bindings, skip: Int) throws { @@ -470,11 +575,34 @@ class MySQLStmtExeDelegate: SQLExeDelegate, @unchecked Sendable { guard statement.execute() else { throw MySQLCRUDError("Error executing statement. \(statement.errorMessage())") } + let returnsRows = statement.fieldCount() > 0 + if !returnsRows { + affectedRows = Int(exactly: statement.affectedRows()) ?? 0 + let id = statement.insertId() + insertedID = id == 0 ? nil : Int64(exactly: id) + } results = statement.results() + if returnsRows { + // For a statement that returns rows (SELECT, CALL, MariaDB's INSERT ... RETURNING) + // the count is the number of rows stored, and there's no insert ID to report. + affectedRows = Int(exactly: statement.numRows()) ?? 0 + insertedID = nil + buildColumnMaps() + } } if results?.fetchRow() ?? false { return true } - statement.reset() + let failure = results.flatMap { $0.failed ? $0.errorMessage : nil } results = nil + // Read past any further result sets (a CALL ends with a status result), then reset. + // A CALL can fail after its first result set; report that too. + let later = statement.discardRemainingResults() + statement.reset() + if let failure { + throw MySQLCRUDError("Error reading results. \(failure)") + } + if let later { + throw MySQLCRUDError("Error executing statement. \(later.message)") + } return false } @@ -491,7 +619,7 @@ class MySQLStmtExeDelegate: SQLExeDelegate, @unchecked Sendable { case .string(let s): statement.bindParam(s) case .blob(let b): statement.bindParam(b) case .bool(let b): statement.bindParam(b ? 1 : 0) - case .date(let d): statement.bindParam(d.mysqlFormatted()) + case .date(let d): statement.bindParam(try d.mysqlFormatted()) case .url(let u): statement.bindParam(u.absoluteString) case .uuid(let u): statement.bindParam(u.uuidString) case .null: statement.bindParam() @@ -593,25 +721,136 @@ public struct MySQLDatabaseConfiguration: DatabaseConfigurationProtocol, @unchec } } -extension Date { - func mysqlFormatted() -> String { - let dateFormatter = DateFormatter() - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - dateFormatter.timeZone = TimeZone(abbreviation: "GMT") - dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - return dateFormatter.string(from: self) +// Perfect-CRUD writes dates as UTC wall-clock text, so they're read back as UTC too. Neither +// direction depends on the process's time zone. +// +// Calendar arithmetic is proleptic Gregorian, as MySQL's is. Foundation's .gregorian calendar +// switches to the Julian calendar before 1582-10-15, so it rejected 1582-10-05...14 and put +// earlier dates 10 or more days away from the server's. These are Howard Hinnant's +// days_from_civil / civil_from_days. + +/// Days since 1970-01-01 of a proleptic Gregorian date. +func mysqlDaysFromCivil(year: Int, month: Int, day: Int) -> Int { + let y = month <= 2 ? year - 1 : year + let era = (y >= 0 ? y : y - 399) / 400 + let yearOfEra = y - era * 400 + let dayOfYear = (153 * (month > 2 ? month - 3 : month + 9) + 2) / 5 + day - 1 + let dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear + return era * 146097 + dayOfEra - 719468 +} + +/// The proleptic Gregorian date `days` after 1970-01-01. +func mysqlCivilFromDays(_ days: Int) -> (year: Int, month: Int, day: Int) { + let z = days + 719468 + let era = (z >= 0 ? z : z - 146096) / 146097 + let dayOfEra = z - era * 146097 + let yearOfEra = (dayOfEra - dayOfEra / 1460 + dayOfEra / 36524 - dayOfEra / 146096) / 365 + let dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100) + let mp = (5 * dayOfYear + 2) / 153 + let day = dayOfYear - (153 * mp + 2) / 5 + 1 + let month = mp < 10 ? mp + 3 : mp - 9 + return (yearOfEra + era * 400 + (month <= 2 ? 1 : 0), month, day) +} + +private func mysqlDaysInMonth(year: Int, month: Int) -> Int { + switch month { + case 2: + return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 ? 29 : 28 + case 4, 6, 9, 11: + return 30 + default: + return 31 } +} + +/// Seconds between 1970-01-01 and 2001-01-01, Date's reference date. +private let mysqlReferenceOffset = 978_307_200 +extension Date { + /// `yyyy-MM-dd HH:mm:ss` in UTC, in whole seconds truncated toward the past (rounding could + /// carry 9999-12-31 23:59:59.9999995 into a year-10000 string the server rejects). + func mysqlFormatted() throws -> String { + // A non-finite Date has no text, and MySQL can't store years outside 0...9999; throwing + // (rather than clamping) keeps a wrong value out of the database. + let interval = timeIntervalSinceReferenceDate.rounded(.down) + guard interval.isFinite, abs(interval) < 1e15 else { + throw MySQLCRUDError("Can't store the Date \(timeIntervalSinceReferenceDate) (seconds since 2001) in MySQL.") + } + let seconds = Int(interval) + mysqlReferenceOffset + let days = seconds >= 0 ? seconds / 86400 : (seconds - 86399) / 86400 + let secondOfDay = seconds - days * 86400 + let (year, month, day) = mysqlCivilFromDays(days) + guard (0...9999).contains(year) else { + throw MySQLCRUDError("Can't store the Date \(timeIntervalSinceReferenceDate) (seconds since 2001) in MySQL: year \(year) is outside 0...9999.") + } + func pad(_ value: Int, _ width: Int) -> String { + let digits = String(value) + return String(repeating: "0", count: max(0, width - digits.count)) + digits + } + return "\(pad(year, 4))-\(pad(month, 2))-\(pad(day, 2)) " + + "\(pad(secondOfDay / 3600, 2)):\(pad(secondOfDay / 60 % 60, 2)):\(pad(secondOfDay % 60, 2))" + } + + /// Parses MySQL's DATETIME / TIMESTAMP / DATE text as UTC: `YYYY-MM-DD`, `YYYY-MM-DD HH:MM:SS`, + /// optionally with up to six fractional digits, in the proleptic Gregorian calendar (year 0 + /// included: `Date.distantPast` is 0000-12-30). Returns nil for anything else, including the + /// zero date (month or day 0) and out-of-range parts such as February 30. init?(fromMysqlFormatted string: String) { - let dateFormatter = DateFormatter() - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - dateFormatter.timeZone = TimeZone.current - dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - if let d = dateFormatter.date(from: string) { - self = d - return - } - return nil + let chars = Array(string.utf8) + func number(_ range: Range) -> Int? { + guard !range.isEmpty, range.upperBound <= chars.count else { + return nil + } + var value = 0 + for i in range { + guard chars[i] >= 48, chars[i] <= 57 else { + return nil + } + value = value * 10 + Int(chars[i] - 48) + } + return value + } + guard chars.count == 10 || (19...26).contains(chars.count), + chars[4] == UInt8(ascii: "-"), chars[7] == UInt8(ascii: "-"), + let year = number(0..<4), let month = number(5..<7), let day = number(8..<10), + (1...12).contains(month), + (1...mysqlDaysInMonth(year: year, month: month)).contains(day) else { + return nil + } + var secondOfDay = 0 + var microseconds = 0 + if chars.count >= 19 { + guard chars[10] == UInt8(ascii: " ") || chars[10] == UInt8(ascii: "T"), + chars[13] == UInt8(ascii: ":"), chars[16] == UInt8(ascii: ":"), + let hour = number(11..<13), let minute = number(14..<16), let second = number(17..<19), + hour < 24, minute < 60, second < 60 else { + return nil + } + secondOfDay = hour * 3600 + minute * 60 + second + if chars.count > 19 { + guard chars[19] == UInt8(ascii: "."), let digits = number(20..= seconds + 1 { + interval = (seconds + 1).nextDown + } + self.init(timeIntervalSinceReferenceDate: interval) } } diff --git a/Sources/MariaDB/MySQLStmt.swift b/Sources/MariaDB/MySQLStmt.swift index 76e2a12..17de8cd 100644 --- a/Sources/MariaDB/MySQLStmt.swift +++ b/Sources/MariaDB/MySQLStmt.swift @@ -4,10 +4,17 @@ import Foundation public final class MySQLStmt: @unchecked Sendable { private let ptr: UnsafeMutablePointer /// Keeps the connection alive for as long as the statement: `ptr` belongs to it. - private let connection: MySQL + let connection: MySQL private var paramBinds: UnsafeMutablePointer? private var paramBindsOffset = 0 + /// The number of parameters the prepared statement has: the size of `paramBinds`. + private var paramBindsCapacity = 0 var meta: UnsafeMutablePointer? + /// The Results whose buffers the client library is currently bound to, if any. + var resultOwner: ObjectIdentifier? + /// An error this wrapper raised itself (not the client library), reported by errorCode() / + /// errorMessage() until the next prepare() or execute(). + private var wrapperError: (code: UInt32, message: String)? public init(_ mysql: MySQL) { connection = mysql @@ -44,8 +51,13 @@ public final class MySQLStmt: @unchecked Sendable { public func fieldInfo(index: Int) -> FieldInfo? { let fieldCount = Int(self.fieldCount()) - guard index < fieldCount else { return nil } - guard let field = mysql_fetch_field_direct(meta, UInt32(index)) else { return nil } + guard index >= 0, index < fieldCount else { + return nil + } + refreshMetadataIfStale() + guard let meta, let field = mysql_fetch_field_direct(meta, UInt32(index)) else { + return nil + } let f: MYSQL_FIELD = field.pointee return FieldInfo( name: String(validatingCString: f.name) ?? "invalid field name", @@ -75,7 +87,9 @@ public final class MySQLStmt: @unchecked Sendable { } public func resetBinds() { - let count = paramBindsOffset + // Values bound past the statement's parameter count were never stored (see + // reserveParamBind), so only the array's own entries are freed. + let count = min(paramBindsOffset, paramBindsCapacity) if let paramBinds = self.paramBinds, count > 0 { for i in 0.. 0, nil != paramBinds { - resetBinds() - paramBinds?.deallocate() - paramBinds = nil - paramBindsOffset = 0 + // Free the array whenever there is one: reset() zeroes paramBindsOffset, so checking it + // here leaked every parameterized statement's bind array. + resetBinds() + paramBinds?.deallocate() + paramBinds = nil + paramBindsOffset = 0 + paramBindsCapacity = 0 + } + + /// Whether there's room in `paramBinds` for one more value. Past the statement's parameter + /// count, a value used to be written past the end of the array; now it's counted but not + /// stored, the error is 2034 (CR_INVALID_PARAMETER_NO), and execute() refuses until reset(). + private func reserveParamBind() -> Bool { + guard paramBinds != nil, paramBindsOffset < paramBindsCapacity else { + wrapperError = (2034, "Invalid parameter number") // CR_INVALID_PARAMETER_NO + paramBindsOffset += 1 + return false } + return true } public func freeResult() { mysql_stmt_free_result(ptr) } - public func errorCode() -> UInt32 { return mysql_stmt_errno(ptr) } - public func errorMessage() -> String { return String(validatingCString: mysql_stmt_error(ptr)) ?? "" } + public func errorCode() -> UInt32 { return wrapperError?.code ?? mysql_stmt_errno(ptr) } + public func errorMessage() -> String { return wrapperError?.message ?? String(validatingCString: mysql_stmt_error(ptr)) ?? "" } public func prepare(statement query: String) -> Bool { + wrapperError = nil let utf8Chars = query.utf8 let r = mysql_stmt_prepare(ptr, query, UInt(utf8Chars.count)) + // A statement prepared again gets a fresh bind array. After a failed prepare there's no + // statement to bind to, and execute() then fails with the library's own error (on MariaDB + // Connector/C that's 2014, "Commands out of sync"), not this wrapper's 2031. + clearBinds() guard r == 0 else { return false } if let m = meta { mysql_free_result(m) } meta = mysql_stmt_result_metadata(ptr) @@ -123,15 +158,39 @@ public final class MySQLStmt: @unchecked Sendable { paramBinds = UnsafeMutablePointer.allocate(capacity: count) let initBind = MYSQL_BIND() for i in 0.. Bool { + wrapperError = nil + // The client library keeps the previous execution's parameter binds, whose buffers + // reset() has freed. Executing again without binding every parameter would make it read + // them (a use-after-free), so refuse, with libmysqlclient's own error for this case. + let count = paramCount() + // paramBindsOffset <= paramBindsCapacity: after clearBinds() on a prepared statement every + // value is refused (there's no array), so the count can match without anything stored. + guard paramBindsOffset == count, paramBindsOffset <= paramBindsCapacity else { + wrapperError = paramBindsOffset < count + ? (2031, "No data supplied for parameters in prepared statement") // CR_PARAMS_NOT_BOUND + : (2034, "Invalid parameter number") // CR_INVALID_PARAMETER_NO: values that weren't stored + return false + } if paramBindsOffset > 0 { guard let paramBinds = self.paramBinds, 0 == mysql_stmt_bind_param(ptr, paramBinds) else { return false } } - return mysql_stmt_execute(ptr) == 0 + let r = mysql_stmt_execute(ptr) + guard r == 0 else { + return false + } + // The cached metadata's fields can point into storage the client library replaces on + // each execution (MariaDB Connector/C does for a CALL), so fetch it afresh when needed. + if let m = meta { + mysql_free_result(m) + meta = nil + } + return true } public func results() -> MySQLStmt.Results { return Results(self) } @@ -161,6 +220,42 @@ public final class MySQLStmt: @unchecked Sendable { return r } + /// Reads and discards the current result set and any that follow (a CALL returns its rows + /// and then a status result), so the connection can run the next statement instead of + /// failing with "Commands out of sync". + /// + /// Returns the first error a later result reported (a CALL can fail after returning rows), + /// or nil. After an error the server sends no more results. + @discardableResult + func discardRemainingResults() -> (code: UInt32, message: String)? { + mysql_stmt_free_result(ptr) + var firstError: (code: UInt32, message: String)? + while true { + let r = nextResult() + if r > 0 { + return firstError ?? currentError() + } + if r < 0 { + return firstError + } + if fieldCount() > 0 && mysql_stmt_store_result(ptr) != 0 && firstError == nil { + firstError = currentError() + } + mysql_stmt_free_result(ptr) + } + } + + /// The statement's error, or the connection's when the client library reported it there + /// (MariaDB Connector/C does for a failed mysql_stmt_store_result). Call right after the + /// failing call. + func currentError() -> (code: UInt32, message: String) { + let code = mysql_stmt_errno(ptr) + if code != 0 { + return (code, errorMessage()) + } + return (connection.errorCode(), connection.errorMessage()) + } + /// Fetches result metadata if there is none cached, or if the cached metadata's column count /// doesn't match the current result set's. deinit frees it. func refreshMetadataIfStale() { @@ -197,6 +292,7 @@ public final class MySQLStmt: @unchecked Sendable { } func bindParam(_ s: String, type: enum_field_types) { + guard reserveParamBind() else { return } guard let allocd = allocated(s) else { return } paramBinds?[paramBindsOffset].buffer_type = type paramBinds?[paramBindsOffset].buffer_length = UInt(allocd.count-1) @@ -207,6 +303,7 @@ public final class MySQLStmt: @unchecked Sendable { } public func bindParam(_ d: Double) { + guard reserveParamBind() else { return } paramBinds?[paramBindsOffset].buffer_type = MYSQL_TYPE_DOUBLE paramBinds?[paramBindsOffset].buffer_length = UInt(MemoryLayout.size) let a = UnsafeMutablePointer.allocate(capacity: 1) @@ -216,6 +313,7 @@ public final class MySQLStmt: @unchecked Sendable { } public func bindParam(_ d: Float) { + guard reserveParamBind() else { return } paramBinds?[paramBindsOffset].buffer_type = MYSQL_TYPE_FLOAT paramBinds?[paramBindsOffset].buffer_length = UInt(MemoryLayout.size) let a = UnsafeMutablePointer.allocate(capacity: 1) @@ -247,16 +345,49 @@ public final class MySQLStmt: @unchecked Sendable { public func bindParam(_ i: Int) { bindParam(Int64(i)) } public func bindParam(_ i: UInt) { bindParam(UInt64(i)) } - public func bindParam(_ i: Int64) { paramBinds?[paramBindsOffset] = genBind(type: MYSQL_TYPE_LONGLONG, value: i); paramBindsOffset += 1 } - public func bindParam(_ i: UInt64) { paramBinds?[paramBindsOffset] = genBind(type: MYSQL_TYPE_LONGLONG, value: i); paramBindsOffset += 1 } - public func bindParam(_ i: Int32) { paramBinds?[paramBindsOffset] = genBind(type: MYSQL_TYPE_LONG, value: i); paramBindsOffset += 1 } - public func bindParam(_ i: UInt32) { paramBinds?[paramBindsOffset] = genBind(type: MYSQL_TYPE_LONG, value: i); paramBindsOffset += 1 } - public func bindParam(_ i: Int16) { paramBinds?[paramBindsOffset] = genBind(type: MYSQL_TYPE_SHORT, value: i); paramBindsOffset += 1 } - public func bindParam(_ i: UInt16) { paramBinds?[paramBindsOffset] = genBind(type: MYSQL_TYPE_SHORT, value: i); paramBindsOffset += 1 } - public func bindParam(_ i: Int8) { paramBinds?[paramBindsOffset] = genBind(type: MYSQL_TYPE_TINY, value: i); paramBindsOffset += 1 } - public func bindParam(_ i: UInt8) { paramBinds?[paramBindsOffset] = genBind(type: MYSQL_TYPE_TINY, value: i); paramBindsOffset += 1 } + public func bindParam(_ i: Int64) { + guard reserveParamBind() else { return } + paramBinds?[paramBindsOffset] = genBind(type: MYSQL_TYPE_LONGLONG, value: i) + paramBindsOffset += 1 + } + public func bindParam(_ i: UInt64) { + guard reserveParamBind() else { return } + paramBinds?[paramBindsOffset] = genBind(type: MYSQL_TYPE_LONGLONG, value: i) + paramBindsOffset += 1 + } + public func bindParam(_ i: Int32) { + guard reserveParamBind() else { return } + paramBinds?[paramBindsOffset] = genBind(type: MYSQL_TYPE_LONG, value: i) + paramBindsOffset += 1 + } + public func bindParam(_ i: UInt32) { + guard reserveParamBind() else { return } + paramBinds?[paramBindsOffset] = genBind(type: MYSQL_TYPE_LONG, value: i) + paramBindsOffset += 1 + } + public func bindParam(_ i: Int16) { + guard reserveParamBind() else { return } + paramBinds?[paramBindsOffset] = genBind(type: MYSQL_TYPE_SHORT, value: i) + paramBindsOffset += 1 + } + public func bindParam(_ i: UInt16) { + guard reserveParamBind() else { return } + paramBinds?[paramBindsOffset] = genBind(type: MYSQL_TYPE_SHORT, value: i) + paramBindsOffset += 1 + } + public func bindParam(_ i: Int8) { + guard reserveParamBind() else { return } + paramBinds?[paramBindsOffset] = genBind(type: MYSQL_TYPE_TINY, value: i) + paramBindsOffset += 1 + } + public func bindParam(_ i: UInt8) { + guard reserveParamBind() else { return } + paramBinds?[paramBindsOffset] = genBind(type: MYSQL_TYPE_TINY, value: i) + paramBindsOffset += 1 + } public func bindParam(_ s: String) { + guard reserveParamBind() else { return } guard let allocd = allocated(s) else { return } paramBinds?[paramBindsOffset].buffer_type = MYSQL_TYPE_VAR_STRING paramBinds?[paramBindsOffset].buffer_length = UInt(allocd.count-1) @@ -267,6 +398,7 @@ public final class MySQLStmt: @unchecked Sendable { } public func bindParam(_ a: [UInt8]) { + guard reserveParamBind() else { return } guard let allocd = allocated(a) else { return } paramBinds?[paramBindsOffset].buffer_type = MYSQL_TYPE_LONG_BLOB paramBinds?[paramBindsOffset].buffer_length = UInt(allocd.count) @@ -277,6 +409,7 @@ public final class MySQLStmt: @unchecked Sendable { } public func bindParam(_ a: [Int8]) { + guard reserveParamBind() else { return } guard let allocd = allocated(a) else { return } paramBinds?[paramBindsOffset].buffer_type = MYSQL_TYPE_LONG_BLOB paramBinds?[paramBindsOffset].buffer_length = UInt(allocd.count) @@ -287,6 +420,7 @@ public final class MySQLStmt: @unchecked Sendable { } public func bindParam(_ b: UnsafePointer, length: Int) { + guard reserveParamBind() else { return } guard let allocd = allocated(b, length: length) else { return } paramBinds?[paramBindsOffset].buffer_type = MYSQL_TYPE_LONG_BLOB paramBinds?[paramBindsOffset].buffer_length = UInt(length) @@ -297,6 +431,7 @@ public final class MySQLStmt: @unchecked Sendable { } public func bindParam() { + guard reserveParamBind() else { return } paramBinds?[paramBindsOffset].buffer_type = MYSQL_TYPE_NULL paramBinds?[paramBindsOffset].length = UnsafeMutablePointer.allocate(capacity: 1) paramBindsOffset += 1 @@ -312,6 +447,18 @@ public final class MySQLStmt: @unchecked Sendable { let binds: UnsafeMutablePointer let lengthBuffers: UnsafeMutablePointer let isNullBuffers: UnsafeMutablePointer + /// True once reading the result failed (the rows couldn't be stored, or a fetch + /// returned an error). `next()` then returns nil; `errorCode` and `errorMessage` say why. + public private(set) var failed = false + /// The error that made reading fail. libmariadb reports an error from storing the rows + /// on the connection rather than the statement, so it's captured from whichever has one. + public private(set) var errorCode: UInt32 = 0 + public private(set) var errorMessage = "" + + private func recordFailure() { + failed = true + (errorCode, errorMessage) = stmt.currentError() + } init(_ s: MySQLStmt) { stmt = s @@ -322,11 +469,38 @@ public final class MySQLStmt: @unchecked Sendable { binds = UnsafeMutablePointer.allocate(capacity: numFields) lengthBuffers = UnsafeMutablePointer.allocate(capacity: numFields) isNullBuffers = UnsafeMutablePointer.allocate(capacity: numFields) - mysql_stmt_store_result(stmt.ptr) + // unbind() reads every bind, including any bind() skips. + for i in 0.. 0 && mysql_stmt_store_result(stmt.ptr) != 0 { + recordFailure() + } bind() } deinit { + // The client library keeps a copy of these binds, with their buffer, length and + // is_null pointers, until the next mysql_stmt_bind_result, and executing the statement + // again writes through them. If the library is still bound to this Results (not to a + // newer one), point it at binds that use its own storage before the buffers below are + // freed; otherwise reusing a statement corrupted the heap. bind_result copies one + // bind per column of the statement's *current* result, which can differ from ours. + if stmt.resultOwner == ObjectIdentifier(self) { + stmt.resultOwner = nil + let count = Int(stmt.fieldCount()) + if count > 0 { + let detached = UnsafeMutablePointer.allocate(capacity: count) + for i in 0.. Bool { + guard !failed, numFields > 0 else { return false } let fetchRes = mysql_stmt_fetch(stmt.ptr) - return fetchRes != MYSQL_NO_DATA && fetchRes != 1 + if fetchRes == 1 { + recordFailure() + return false + } + // MYSQL_DATA_TRUNCATED is expected: string and byte columns are bound with empty + // buffers and read afterwards with mysql_stmt_fetch_column. + return fetchRes != MYSQL_NO_DATA } public func currentRow() -> [Any?]? { @@ -440,9 +622,10 @@ public final class MySQLStmt: @unchecked Sendable { } } + /// Returns false if reading the rows failed partway (see `failed`). public func forEachRow(callback: (Element) -> ()) -> Bool { while let row = next() { callback(row) } - return true + return !failed } func bindToType(_ type: GeneralType) -> MYSQL_BIND { @@ -504,7 +687,13 @@ public final class MySQLStmt: @unchecked Sendable { } binds.advanced(by: i).initialize(to: bind) } + // With no columns (INSERT, UPDATE...) there is nothing to bind; libmysqlclient would + // set CR_NO_STMT_METADATA on the statement. + guard numFields > 0 else { + return + } mysql_stmt_bind_result(stmt.ptr, binds) + stmt.resultOwner = ObjectIdentifier(self) } private func unbind() { diff --git a/Sources/mariadbclient/mariadb.h b/Sources/mariadbclient/mariadb.h index d8e1dd8..84edb76 100644 --- a/Sources/mariadbclient/mariadb.h +++ b/Sources/mariadbclient/mariadb.h @@ -42,4 +42,14 @@ static inline int perfect_mariadb_set_ssl_mode(MYSQL *mysql, unsigned int mode) return 1; return mysql_options(mysql, MYSQL_OPT_SSL_VERIFY_SERVER_CERT, &verify); } + +// Escapes `from` for use inside a string literal quoted with `quote`, following the connection's +// character set and the server's NO_BACKSLASH_ESCAPES mode. Same signature as Perfect-MySQL's shim; +// libmariadb has no _quote variant, and its mysql_real_escape_string already doubles quotes under +// NO_BACKSLASH_ESCAPES. Returns the escaped length, or (unsigned long)-1. +static inline unsigned long perfect_mysql_escape_string_quote(MYSQL *mysql, char *to, const char *from, + unsigned long length, char quote) { + (void)quote; + return mysql_real_escape_string(mysql, to, from, length); +} #endif diff --git a/Tests/MariaDBTests/DateTests.swift b/Tests/MariaDBTests/DateTests.swift new file mode 100644 index 0000000..4d6d0cf --- /dev/null +++ b/Tests/MariaDBTests/DateTests.swift @@ -0,0 +1,220 @@ +import Foundation +import PerfectCRUD +import Testing +@testable import MariaDB + +/// Dates are written as UTC text and must be read back as UTC, whatever the process's time zone. +/// `TimeZone.current` can't be changed in-process on macOS (`NSTimeZone.default` doesn't affect +/// it), so these tests only catch a regression when the suite runs with a non-UTC `TZ`; CI runs +/// it under America/Denver. Before the fix, Denver shifted every date and lost the +/// spring-forward row. Same tests as Perfect-MySQL's DateTests. +private let schema = "perfect_maria_date_test" + +private func connection(_ database: String) throws -> MySQL { + let mysql = MySQL() + mysql.setOption(.MYSQL_SET_CHARSET_NAME, "utf8mb4") + guard mysql.connect(host: MariaTestEnvironment.host, user: MariaTestEnvironment.user, + password: MariaTestEnvironment.password, db: database, + port: UInt32(MariaTestEnvironment.port)) else { + throw MySQLCRUDError("Could not connect. \(mysql.errorMessage())") + } + return mysql +} + +private func freshDatabase() throws -> Database { + let admin = Database(configuration: MySQLDatabaseConfiguration(connection: try connection("mysql"))) + try admin.sql("DROP DATABASE IF EXISTS `\(schema)`") + try admin.sql("CREATE DATABASE `\(schema)` DEFAULT CHARACTER SET utf8mb4") + return Database(configuration: MySQLDatabaseConfiguration(connection: try connection(schema))) +} + +private func dropSchema() { + guard let mysql = try? connection("mysql") else { return } + _ = mysql.query(statement: "DROP DATABASE IF EXISTS `\(schema)`") + mysql.close() +} + +@Suite(.serialized) +struct DateTests { + static let zone = TimeZone.current.identifier + + static func utc(_ y: Int, _ mo: Int, _ d: Int, _ h: Int = 0, _ mi: Int = 0, _ s: Int = 0) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar.date(from: DateComponents(year: y, month: mo, day: d, hour: h, minute: mi, second: s))! + } + + @Test func parsesMySQLTextAsUTC() throws { + let zone = Self.zone + #expect(Date(fromMysqlFormatted: "2023-11-14 22:13:20") == Self.utc(2023, 11, 14, 22, 13, 20), "\(zone)") + // In America/Denver's spring-forward gap; parsing as local time returned nil. + #expect(Date(fromMysqlFormatted: "2026-03-08 02:30:00") == Self.utc(2026, 3, 8, 2, 30), "\(zone)") + #expect(Date(fromMysqlFormatted: "2024-02-29") == Self.utc(2024, 2, 29), "\(zone)") + let fractional = Date(fromMysqlFormatted: "2023-11-14 22:13:20.250000") + #expect(fractional == Self.utc(2023, 11, 14, 22, 13, 20).addingTimeInterval(0.25), "\(zone)") + #expect(Date(fromMysqlFormatted: "2023-11-14 22:13:20.5") == Self.utc(2023, 11, 14, 22, 13, 20).addingTimeInterval(0.5)) + #expect(Date(fromMysqlFormatted: "2023-11-14T22:13:20") == Self.utc(2023, 11, 14, 22, 13, 20)) + for bad in ["0000-00-00", "0000-00-00 00:00:00", "2023-02-30", "2023-13-01", "2023-11-14 24:00:00", + "2023-11-14 22:13", "2023-11-14 22:13:20.", "2023-11-14 22:13:20.1234567", "22:13:20", "", "abcd-ef-gh"] { + #expect(Date(fromMysqlFormatted: bad) == nil, "\(bad) in \(zone)") + } + #expect(try Self.utc(2026, 3, 8, 2, 30).mysqlFormatted() == "2026-03-08 02:30:00", "\(zone)") + } + + // Fractions are carried as whole microseconds and truncated when writing: a Double sum used + // to round '9999-12-31 23:59:59.999999' into year 10000, which the writer then emitted. + @Test func fractionsStayInsideTheirSecond() throws { + let lastSecond = Date(fromMysqlFormatted: "9999-12-31 23:59:59.999999") + #expect(lastSecond.map { $0 < Self.utc(9999, 12, 31, 23, 59, 59).addingTimeInterval(1) } == true) + #expect(try lastSecond?.mysqlFormatted() == "9999-12-31 23:59:59") + let beforeEpoch = Date(fromMysqlFormatted: "1969-12-31 23:59:59.999999") + #expect(try beforeEpoch?.mysqlFormatted() == "1969-12-31 23:59:59") + #expect(beforeEpoch.map { abs($0.timeIntervalSince1970 + 0.000001) < 0.0000005 } == true) + #expect(try Date(fromMysqlFormatted: "2023-11-14 22:13:20.000001")?.mysqlFormatted() == "2023-11-14 22:13:20") + #expect(try Date(timeIntervalSince1970: -0.5).mysqlFormatted() == "1969-12-31 23:59:59") + #expect(try Date(timeIntervalSinceReferenceDate: 721_692_800.9999).mysqlFormatted() == "2023-11-14 22:13:20") + } + + // MySQL's calendar is proleptic Gregorian. Foundation's .gregorian switches to Julian before + // 1582-10-15: 1582-10-05...14 didn't parse and earlier dates were 10+ days off the server's. + @Test func datesUseTheProlepticGregorianCalendar() throws { + let cases: [(String, TimeInterval)] = [ // Unix times from Python's proleptic datetime + ("0001-01-01", -62_135_596_800), + ("1000-03-01", -30_605_126_400), + ("1582-10-04", -12_220_243_200), + ("1582-10-10", -12_219_724_800), + ("1582-10-15", -12_219_292_800), + ("1970-01-01", 0), + ("2000-02-29", 951_782_400), + ("9999-12-31", 253_402_214_400), + ] + for (text, unix) in cases { + let date = Date(fromMysqlFormatted: text) + #expect(date?.timeIntervalSince1970 == unix, "\(text)") + #expect(try date?.mysqlFormatted() == text + " 00:00:00", "\(text)") + } + #expect(Date(fromMysqlFormatted: "1900-02-29") == nil) + #expect(Date(fromMysqlFormatted: "1600-02-29") != nil) + #expect(try Date(fromMysqlFormatted: "1000-03-01 12:34:56")?.mysqlFormatted() == "1000-03-01 12:34:56") + // Every day of a 400-year cycle round-trips. + var day = Date(fromMysqlFormatted: "1600-01-01")! + for _ in 0..<146_097 { + let text = try day.mysqlFormatted() + #expect(Date(fromMysqlFormatted: text) == day, "\(text)") + if Date(fromMysqlFormatted: text) != day { break } + day = day.addingTimeInterval(86_400) + } + #expect(try day.mysqlFormatted() == "2000-01-01 00:00:00") + } + + // The server agrees: dates written through Perfect-CRUD land on the same calendar day. + @Test(.mariaLive) + func oldDatesMatchTheServersCalendar() throws { + struct Day: Codable { let d: String } + let db = try freshDatabase() + defer { dropSchema() } + try db.create(DateRow.self, policy: .dropTable) + let texts = ["1000-03-01 00:00:00", "1582-10-10 00:00:00", "1582-10-04 12:00:00"] + try db.table(DateRow.self).insert(texts.enumerated().map { DateRow(id: $0, d: Date(fromMysqlFormatted: $1)!) }) + let server = try db.sql("SELECT DATE_FORMAT(d, '%Y-%m-%d %H:%i:%s') AS d FROM date_row ORDER BY id", Day.self) + #expect(server.map(\.d) == texts) + let read = try db.table(DateRow.self).order(by: \.id).select().map { try $0.d.mysqlFormatted() } + #expect(read == texts) + #expect(try db.sql("SELECT CAST(DATEDIFF('1582-10-15', '1582-10-04') AS CHAR) AS d", Day.self).first?.d == "11") + } + + // Date.distantPast is 0000-12-30 in the proleptic calendar. The writer emitted it and MySQL + // stored it, but the reader rejected year 0, and Perfect-CRUD's select() stops at a row that + // fails to decode, so every later row vanished. + @Test func sentinelDatesRoundTrip() throws { + #expect(try Date.distantPast.mysqlFormatted() == "0000-12-30 00:00:00") + #expect(Date(fromMysqlFormatted: "0000-12-30 00:00:00") == .distantPast) + #expect(try Date.distantFuture.mysqlFormatted() == "4001-01-01 00:00:00") + #expect(Date(fromMysqlFormatted: "4001-01-01 00:00:00") == .distantFuture) + #expect(Date(fromMysqlFormatted: "0000-01-01") != nil) + #expect(Date(fromMysqlFormatted: "0000-02-29") != nil) // year 0 is a leap year + #expect(Date(fromMysqlFormatted: "0000-00-00") == nil) + #expect(Date(fromMysqlFormatted: "0000-01-00") == nil) + // Not representable: thrown, not clamped and not a trap. + for bad in [Date(timeIntervalSinceReferenceDate: .infinity), Date(timeIntervalSinceReferenceDate: -.infinity), + Date(timeIntervalSinceReferenceDate: .nan), Date(timeIntervalSinceReferenceDate: 1e300), + Date(fromMysqlFormatted: "0000-01-01")!.addingTimeInterval(-1), + Date(fromMysqlFormatted: "9999-12-31 23:59:59")!.addingTimeInterval(1)] { + #expect(throws: MySQLCRUDError.self) { try bad.mysqlFormatted() } + } + } + + @Test(.mariaLive) + func sentinelDatesDontEndASelect() throws { + let db = try freshDatabase() + defer { dropSchema() } + try db.create(DateRow.self, policy: .dropTable) + let written: [DateRow] = [ + DateRow(id: 0, d: Date(timeIntervalSince1970: 0)), DateRow(id: 1, d: .distantPast), + DateRow(id: 2, d: Self.utc(1, 1, 1)), DateRow(id: 3, d: .distantFuture), + DateRow(id: 4, d: Date(timeIntervalSince1970: -0.000001)), DateRow(id: 5, d: Self.utc(9999, 12, 31, 23, 59, 59)), + ] + try db.table(DateRow.self).insert(written) + let read = try db.table(DateRow.self).order(by: \.id).select().map { $0 } + #expect(read.map(\.id) == [0, 1, 2, 3, 4, 5]) + #expect(read.map(\.d) == written.map { Date(timeIntervalSinceReferenceDate: $0.d.timeIntervalSinceReferenceDate.rounded(.down)) }) + let infinite = DateRow(id: 6, d: Date(timeIntervalSinceReferenceDate: .infinity)) + #expect(throws: (any Error).self) { try db.table(DateRow.self).insert(infinite) } + } + + struct DateRow: Codable, TableNameProvider { + static let tableName = "date_row" + let id: Int + let d: Date + } + + struct OptionalDateRow: Codable, TableNameProvider { + static let tableName = "date_row" + let id: Int + let d: Date? + } + + @Test(.mariaLive) + func datesRoundTripInEveryZone() throws { + let db = try freshDatabase() + defer { dropSchema() } + let zone = Self.zone + let written = [ + DateRow(id: 1, d: Self.utc(2023, 11, 14, 22, 13, 20)), + DateRow(id: 2, d: Self.utc(2026, 3, 8, 2, 30)), // Denver DST gap, in UTC wall time + ] + try db.create(DateRow.self, policy: .dropTable) + try db.table(DateRow.self).insert(written) + let read = try db.table(DateRow.self).order(by: \.id).select().map { $0 } + #expect(read.map(\.id) == [1, 2], "\(zone): every row comes back") + #expect(read.map(\.d) == written.map(\.d), "\(zone)") + } + + @Test(.mariaLive) + func readsFractionalDateOnlyAndZeroDates() throws { + let db = try freshDatabase() + defer { dropSchema() } + let zone = Self.zone + try db.sql("CREATE TABLE date_row (id INT PRIMARY KEY, d DATETIME(6) NULL)") + try db.sql("INSERT INTO date_row VALUES (1, '2023-11-14 22:13:20.250000'), (2, NULL)") + let fractional = try db.table(OptionalDateRow.self).order(by: \.id).select().map { $0 } + #expect(fractional.map(\.id) == [1, 2], "\(zone)") + #expect(fractional.first?.d == Self.utc(2023, 11, 14, 22, 13, 20).addingTimeInterval(0.25), "\(zone)") + #expect(fractional.last?.d == nil, "\(zone)") + + try db.sql("DROP TABLE date_row") + try db.sql("CREATE TABLE date_row (id INT PRIMARY KEY, d DATE NULL)") + try db.sql("INSERT INTO date_row VALUES (1, '2024-02-29')") + #expect(try db.table(DateRow.self).first()?.d == Self.utc(2024, 2, 29), "\(zone)") + + // The zero date: nil for an optional property, an error (so no row) for a non-optional one. + try db.sql("DROP TABLE date_row") + try db.sql("CREATE TABLE date_row (id INT PRIMARY KEY, d DATETIME NULL)") + try db.sql("SET SESSION sql_mode = ''") + try db.sql("INSERT INTO date_row VALUES (1, '0000-00-00 00:00:00')") + let optional = try db.table(OptionalDateRow.self).select().map { $0 } + #expect(optional.count == 1 && optional.first?.d == nil, "\(zone)") + #expect(try db.table(DateRow.self).select().map { $0 }.isEmpty, "\(zone)") + try db.sql("SET SESSION sql_mode = DEFAULT") + } +} diff --git a/Tests/MariaDBTests/DynamicAPITests.swift b/Tests/MariaDBTests/DynamicAPITests.swift new file mode 100644 index 0000000..86f3ee2 --- /dev/null +++ b/Tests/MariaDBTests/DynamicAPITests.swift @@ -0,0 +1,174 @@ +import Foundation +import Testing +@testable import MariaDB +import PerfectCRUD + +// MARK: - Perfect-CRUD's Dynamic API (select / mutate / execute) on a live server +// +// Run with: MARIA_TESTS=1 MARIA_TEST_PORT=… [MARIA_TEST_HOST=… MARIA_TEST_USER=… +// MARIA_TEST_PASSWORD=…] swift test --filter DynamicAPITests + +private let schema = "perfect_maria_dynamic_test" +private let clientMultiStatements: UInt = 1 << 16 + +private func connection(_ database: String, flag: UInt = 0) throws -> MySQL { + let mysql = MySQL() + mysql.setOption(.MYSQL_SET_CHARSET_NAME, "utf8mb4") + guard mysql.connect(host: MariaTestEnvironment.host, user: MariaTestEnvironment.user, + password: MariaTestEnvironment.password, db: database, + port: UInt32(MariaTestEnvironment.port), flag: flag) else { + throw MySQLCRUDError("Could not connect. \(mysql.errorMessage())") + } + return mysql +} + +private func freshDatabase(flag: UInt = 0) throws -> Database { + let admin = Database(configuration: MySQLDatabaseConfiguration(connection: try connection("mysql"))) + try admin.sql("DROP DATABASE IF EXISTS `\(schema)`") + try admin.sql("CREATE DATABASE `\(schema)` DEFAULT CHARACTER SET utf8mb4") + return Database(configuration: MySQLDatabaseConfiguration(connection: try connection(schema, flag: flag))) +} + +private func dropSchema() { + guard let mysql = try? connection("mysql") else { return } + _ = mysql.query(statement: "DROP DATABASE IF EXISTS `\(schema)`") + mysql.close() +} + +private func count(_ db: Database, _ table: String) throws -> Int { + struct Count: Codable { let c: Int } + let rows = try db.sql("SELECT COUNT(*) AS c FROM `\(table)`", Count.self) + return rows[0].c +} + +@Suite("Dynamic API on a live server", .serialized) +struct DynamicAPITests { + + @Test(.mariaLive) + func selectReturnsTypedRows() throws { + let db = try freshDatabase() + defer { dropSchema() } + try db.sql(""" + CREATE TABLE `items` (`id` INT PRIMARY KEY, `name` VARCHAR(20), `price` DOUBLE, + `qty` SMALLINT UNSIGNED, `data` VARBINARY(8), `note` TEXT NULL) + """) + try db.sql("INSERT INTO `items` VALUES (1, 'one', 1.5, 7, X'00FF', NULL), (2, 'two', 2.5, 8, X'01', 'n')") + let result = try db.select(DynamicQuery( + table: "items", + predicates: [.init(field: "id", comparison: .greaterThanOrEqual, value: .int(1))], + orderings: [.init(field: "id", descending: true)], + limit: 10)) + #expect(result.rows.count == 2) + let first = try #require(result.rows.first) + #expect(first["id"] == .int(2)) + #expect(first["NAME"] == .string("two")) + #expect(first["price"] == .double(2.5)) + #expect(first["qty"] == .uint(8)) + #expect(first["data"] == .bytes([0x01])) + #expect(first["note"] == .string("n")) + #expect(result.rows[1]["note"] == .null) + #expect(result.rows[1]["data"] == .bytes([0x00, 0xFF])) + } + + // CRUD 3.0.0's DynamicRow.init lowercases the column names and traps on a duplicate key. + // The connector keeps the first of the columns whose names differ only by case, or not + // at all, so a join or an alias can't crash the process. + @Test(.mariaLive) + func columnsDifferingOnlyByCaseKeepTheFirst() throws { + let db = try freshDatabase() + defer { dropSchema() } + var result = try db.execute(DynamicSQL(sql: "SELECT 1 AS a, 2 AS A, 3 AS `a`")) + #expect(result.rows.count == 1) + #expect(result.rows.first?["a"] == .int(1)) + #expect(result.rows.first?.values.count == 1) + + try db.sql("CREATE TABLE `parent` (`id` INT PRIMARY KEY, `Name` VARCHAR(20))") + try db.sql("CREATE TABLE `child` (`id` INT PRIMARY KEY, `parentId` INT, `name` VARCHAR(20))") + try db.sql("INSERT INTO `parent` VALUES (1, 'P')") + try db.sql("INSERT INTO `child` VALUES (10, 1, 'c')") + result = try db.execute(DynamicSQL(sql: """ + SELECT p.id, p.Name, c.id, c.name FROM `parent` p JOIN `child` c ON c.parentId = p.id + """)) + let row = try #require(result.rows.first) + #expect(row["id"] == .int(1)) + #expect(row["name"] == .string("P")) + #expect(row.values.count == 2) + } + + @Test(.mariaLive) + func mutateReportsAffectedRowsAndInsertedID() throws { + let db = try freshDatabase() + defer { dropSchema() } + try db.sql("CREATE TABLE `auto` (`id` INT AUTO_INCREMENT PRIMARY KEY, `v` INT)") + var result = try db.mutate(DynamicMutation(action: .insert, table: "auto", values: ["v": .int(5)])) + #expect(result.affectedRows == 1) + #expect(result.insertedID == 1) + result = try db.mutate(DynamicMutation(action: .insert, table: "auto", values: ["v": .int(6)])) + #expect(result.insertedID == 2) + result = try db.mutate(DynamicMutation( + action: .update, table: "auto", values: ["v": .int(0)], + predicates: [.init(field: "v", comparison: .greaterThan, value: .int(0))])) + #expect(result.affectedRows == 2) + #expect(result.insertedID == nil) + result = try db.mutate(DynamicMutation( + action: .delete, table: "auto", + predicates: [.init(field: "id", comparison: .equal, value: .int(1))])) + #expect(result.affectedRows == 1) + result = try db.execute(DynamicSQL(sql: "SELECT * FROM `auto`")) + #expect(result.rows.count == 1) + // A statement that returns rows reports how many (as Perfect-MySQL does), and no insert ID. + #expect(result.affectedRows == 1) + #expect(result.insertedID == nil) + } + + // Unsigned values bind as unsigned: no Int conversion that could trap or wrap. + @Test(.mariaLive) + func extremeIntegersRoundTrip() throws { + let db = try freshDatabase() + defer { dropSchema() } + try db.sql("CREATE TABLE `nums` (`id` INT PRIMARY KEY, `u` BIGINT UNSIGNED, `s` BIGINT)") + _ = try db.mutate(DynamicMutation(action: .insert, table: "nums", + values: ["id": .int(1), "u": .uint(UInt64.max), "s": .int(Int64.min)])) + _ = try db.execute(DynamicSQL(sql: "INSERT INTO `nums` VALUES (?, ?, ?)", + bindings: [.uint(2), .uint(UInt64(Int64.max) + 1), .int(Int64.max)])) + let result = try db.select(DynamicQuery( + table: "nums", predicates: [.init(field: "u", comparison: .greaterThan, value: .uint(UInt64(Int64.max)))], + orderings: [.init(field: "id")])) + #expect(result.rows.map { $0["u"] } == [.uint(UInt64.max), .uint(UInt64(Int64.max) + 1)]) + #expect(result.rows.map { $0["s"] } == [.int(Int64.min), .int(Int64.max)]) + } + + // A prepared statement holds one statement, so multi-statement text fails as a whole, + // before any of it runs, rather than running only the first statement. + @Test(.mariaLive) + func multipleStatementsRunAllOrThrow() throws { + let db = try freshDatabase() + defer { dropSchema() } + try db.sql("CREATE TABLE `m` (`id` INT PRIMARY KEY)") + #expect(throws: (any Error).self) { + try db.execute(DynamicSQL(sql: "INSERT INTO `m` VALUES (1); INSERT INTO `m` VALUES (2)", + allowsMultipleStatements: true)) + } + #expect(try count(db, "m") == 0) + // Text starting with a statement that runs unprepared needs CLIENT_MULTI_STATEMENTS; + // without it the server rejects the whole text. + #expect(throws: (any Error).self) { + try db.execute(DynamicSQL(sql: "BEGIN; INSERT INTO `m` VALUES (1); COMMIT", + allowsMultipleStatements: true)) + } + #expect(try count(db, "m") == 0) + + // With CLIENT_MULTI_STATEMENTS, every statement runs. + let multi = Database(configuration: MySQLDatabaseConfiguration( + connection: try connection(schema, flag: clientMultiStatements))) + _ = try multi.execute(DynamicSQL(sql: "BEGIN; INSERT INTO `m` VALUES (1); INSERT INTO `m` VALUES (2); COMMIT", + allowsMultipleStatements: true)) + #expect(try count(db, "m") == 2) + // ...and an error in a later statement is reported. + #expect(throws: (any Error).self) { + try multi.execute(DynamicSQL(sql: "BEGIN; INSERT INTO `m` VALUES (3); INSERT INTO `m` VALUES (1); COMMIT", + allowsMultipleStatements: true)) + } + _ = multi.configuration.connection.query(statement: "ROLLBACK") + } +} diff --git a/Tests/MariaDBTests/ListingWildcardTests.swift b/Tests/MariaDBTests/ListingWildcardTests.swift index 85a8bee..ee87e7e 100644 --- a/Tests/MariaDBTests/ListingWildcardTests.swift +++ b/Tests/MariaDBTests/ListingWildcardTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing @testable import MariaDB +import mariadbclient // MARK: - listTables(wildcard:) / listDatabases(wildcard:) // @@ -55,7 +56,11 @@ struct ListingWildcardTests { // Under NO_BACKSLASH_ESCAPES a quote has to be doubled rather than backslashed. #expect(mysql.query(statement: "SET SESSION sql_mode = CONCAT(@@sql_mode, ',NO_BACKSLASH_ESCAPES')"), "\(mysql.errorMessage())") #expect(mysql.listTables(wildcard: "it's") == ["it's"]) - #expect(mysql.listTables(wildcard: #"a\_b"#) == ["a_b"]) + // MariaDB keeps `\` as the LIKE escape character in this mode; MySQL treats it as a + // literal character, so `a\_b` matches neither table there. + let isMariaDB = mysql.query(statement: "SELECT VERSION()") + && (mysql.storeResults()?.next()?[0] ?? nil)?.contains("MariaDB") == true + #expect(mysql.listTables(wildcard: #"a\_b"#) == (isMariaDB ? ["a_b"] : [])) #expect(mysql.query(statement: "SET SESSION sql_mode = DEFAULT"), "\(mysql.errorMessage())") #expect(mysql.query(statement: "CREATE DATABASE `\(schema)'db`"), "\(mysql.errorMessage())") @@ -65,15 +70,19 @@ struct ListingWildcardTests { // On main the DROP ran: "SHOW TABLES LIKE 'x'; DROP TABLE wild_victim; -- '". Under gbk the // escaping has to follow the connection's charset: 中 is E4 B8 AD in UTF-8, and AD followed - // by the backslash that escapes the quote is one gbk character. - @Test(.mariaLive, arguments: ["utf8mb4", "gbk", "big5"]) + // by the backslash that escapes the quote is one gbk character. Under big5 on x86_64, + // libmariadb 3.3's mysql_real_escape_string turned E4 B8 AD 27 into E4 B8 AD 5C 27, so the + // DROP ran even with escaping; wildcards like that are now refused under the five charsets + // where 0x5C can be a trail byte. + @Test(.mariaLive, arguments: ["utf8mb4", "latin1", "gbk", "big5", "cp932", "sjis", "gb18030"]) func wildcardCannotRunAnotherStatement(charset: String) throws { let setup = freshSchema() defer { setup.close(); dropSchema() } let listings: [(String, (MySQL, String) -> [String])] = [ ("listTables", { $0.listTables(wildcard: $1) }), ("listDatabases", { $0.listDatabases(wildcard: $1) })] - for wildcard in ["x'; DROP TABLE wild_victim; -- ", "中'; DROP TABLE wild_victim; -- "] { + for wildcard in ["x'; DROP TABLE wild_victim; -- ", "中'; DROP TABLE wild_victim; -- ", + "中\\'; DROP TABLE wild_victim; -- ", "ア'; DROP TABLE wild_victim; -- "] { for (name, list) in listings { #expect(setup.query(statement: "CREATE TABLE IF NOT EXISTS `wild_victim` (id INT)"), "\(setup.errorMessage())") let mysql = connect(charset: charset, flag: clientMultiStatements) @@ -86,4 +95,142 @@ struct ListingWildcardTests { } } } + + // The refusal applies only where it's needed: a byte >= 0x80 together with `'` or `\`, under + // big5, cp932, gbk, gb18030 or sjis. + @Test(.mariaLive) + func unsafeWildcardsAreRefusedOnlyUnderTrailByteCharsets() throws { + for charset in ["big5", "cp932", "gbk", "gb18030", "sjis"] { + let mysql = connect(charset: charset) + defer { mysql.close() } + // MariaDB 11.8 has no gb18030; the client then stays on its default (utf8mb4), where + // nothing needs refusing. + let actual = String(cString: mysql_character_set_name(mysql.ptr!)) + #expect(mysql.listingWildcardIsUnsafe("中'") == (actual == charset), "\(charset), client charset \(actual)") + #expect(mysql.listingWildcardIsUnsafe("中\\") == (actual == charset), "\(charset), client charset \(actual)") + #expect(!mysql.listingWildcardIsUnsafe("中%"), "\(charset)") + #expect(!mysql.listingWildcardIsUnsafe("it's"), "\(charset)") + #expect(!mysql.listingWildcardIsUnsafe(#"a\_b"#), "\(charset)") + #expect(!mysql.listingWildcardIsUnsafe(nil), "\(charset)") + } + for charset in ["utf8mb4", "latin1"] { + let mysql = connect(charset: charset) + defer { mysql.close() } + #expect(!mysql.listingWildcardIsUnsafe("中'"), "\(charset)") + #expect(!mysql.listingWildcardIsUnsafe("中\\"), "\(charset)") + } + // Ordinary wildcards still work under such a charset. + let setup = freshSchema() + defer { setup.close(); dropSchema() } + #expect(setup.query(statement: "CREATE TABLE `it's` (id INT)"), "\(setup.errorMessage())") + let big5 = connect(charset: "big5") + defer { big5.close() } + #expect(big5.selectDatabase(named: schema)) + #expect(big5.listTables(wildcard: "it's") == ["it's"]) + #expect(big5.listTables(wildcard: "it%") == ["it's"]) + } + + // Under NO_BACKSLASH_ESCAPES quotes are doubled instead of backslash-escaped, and wildcards keep + // working; injection stays closed. (Perfect-MySQL's test, adapted.) + @Test(.mariaLive, arguments: ["utf8mb4", "big5"]) + func wildcardsWorkUnderNoBackslashEscapes(charset: String) throws { + let setup = freshSchema() + defer { setup.close(); dropSchema() } + #expect(setup.query(statement: "CREATE TABLE `victim` (i INT)"), "\(setup.errorMessage())") + #expect(setup.query(statement: "CREATE TABLE `it's` (i INT)"), "\(setup.errorMessage())") + let mysql = connect(charset: charset, flag: clientMultiStatements) + defer { mysql.close() } + #expect(mysql.selectDatabase(named: schema)) + #expect(mysql.query(statement: "SET SESSION sql_mode = CONCAT(@@sql_mode, ',NO_BACKSLASH_ESCAPES')"), "\(mysql.errorMessage())") + #expect(mysql.listTables(wildcard: "vic%") == ["victim"], "\(charset)") + #expect(mysql.listTables(wildcard: "it's") == ["it's"], "\(charset)") + #expect(mysql.listDatabases(wildcard: schema) == [schema], "\(charset)") + for payload in ["x'; DROP TABLE victim; -- ", "x\\'; DROP TABLE victim; -- ", "中'; DROP TABLE victim; -- "] { + #expect(mysql.listTables(wildcard: payload).isEmpty, "\(charset)") + while mysql.moreResults() { _ = mysql.nextResult() } + #expect(mysql.query(statement: "SELECT 1"), "\(charset): \(mysql.errorMessage())") + _ = mysql.storeResults() + } + #expect(setup.listTables(wildcard: "victim") == ["victim"]) + } + + // libmariadb (and libmysqlclient) choose between backslash-escaping and quote-doubling from the + // NO_BACKSLASH_ESCAPES bit in the server's last status. After a stored routine changes + // sql_mode, both servers leave that bit as the routine set it while the session's sql_mode is + // restored, so the client escaped for the wrong mode and a wildcard could end the literal: + // the DROP below ran. A `'` is now always doubled (valid in both modes), and a wildcard with + // both `'` and `\` is refused. + @Test(.mariaLive) + func wildcardsStaySafeWhenARoutineLeavesTheModeBitStale() throws { + let setup = freshSchema() + defer { setup.close(); dropSchema() } + #expect(setup.query(statement: "CREATE PROCEDURE clear_mode() SET SESSION sql_mode = ''"), "\(setup.errorMessage())") + #expect(setup.query(statement: "CREATE PROCEDURE set_nbe() SET SESSION sql_mode = 'NO_BACKSLASH_ESCAPES'"), "\(setup.errorMessage())") + #expect(setup.query(statement: "CREATE TABLE `it's` (i INT)"), "\(setup.errorMessage())") + let scenarios: [(String, String, String)] = [ + // session mode before, routine, payload + ("NO_BACKSLASH_ESCAPES", "clear_mode", "x'; DROP TABLE victim; -- "), + ("", "set_nbe", "x\\'; DROP TABLE victim; -- "), + ("", "set_nbe", "x'; DROP TABLE victim; -- "), + ] + for (mode, routine, payload) in scenarios { + #expect(setup.query(statement: "CREATE TABLE IF NOT EXISTS `victim` (id INT)"), "\(setup.errorMessage())") + let mysql = connect(flag: clientMultiStatements) + #expect(mysql.selectDatabase(named: schema)) + #expect(mysql.query(statement: "SET SESSION sql_mode = '\(mode)'"), "\(mysql.errorMessage())") + #expect(mysql.query(statement: "CALL \(routine)()"), "\(mysql.errorMessage())") + while mysql.moreResults() { _ = mysql.storeResults(); if mysql.nextResult() != 0 { break } } + #expect(mysql.listTables(wildcard: payload).isEmpty, "\(routine) \(payload)") + #expect(mysql.listTables(wildcard: "it's") == ["it's"], "\(routine)") + while mysql.moreResults() { _ = mysql.nextResult() } + mysql.close() + #expect(setup.listTables(wildcard: "victim") == ["victim"], "\(routine) \(payload)") + } + } + + /// Runs `sql` and reads every result (a CALL returns several). + static func run(_ mysql: MySQL, _ sql: String) throws { + try #require(mysql.query(statement: sql), "\(sql): \(mysql.errorMessage())") + _ = mysql.storeResults() + while mysql.moreResults() { + _ = mysql.nextResult() + _ = mysql.storeResults() + } + } + + // Perfect-MySQL's test for the same fix (5c9c5fd), adapted: four stale-bit setups, each with + // `x'...`, `x\'...` and a trailing `\`; the connection must stay in sync, and vic% must + // still match. + @Test(.mariaLive, arguments: ["utf8mb4", "latin1", "big5"]) + func aStaleNoBackslashEscapesBitCannotEndTheLiteral(charset: String) throws { + let setup = freshSchema() + defer { setup.close(); dropSchema() } + try Self.run(setup, "CREATE TABLE victim (i INT)") + try Self.run(setup, "CREATE TABLE `it's` (i INT)") + let mysql = connect(charset: charset, flag: clientMultiStatements) + defer { mysql.close() } + #expect(mysql.selectDatabase(named: schema)) + try Self.run(mysql, "CREATE PROCEDURE clear_mode() SET SESSION sql_mode = ''") + try Self.run(mysql, "CREATE PROCEDURE set_nbe() SET SESSION sql_mode = 'NO_BACKSLASH_ESCAPES'") + try Self.run(mysql, "CREATE FUNCTION nbe_fn() RETURNS INT DETERMINISTIC BEGIN SET SESSION sql_mode = 'NO_BACKSLASH_ESCAPES'; RETURN 1; END") + let payloads = ["x'; DROP TABLE victim; -- ", "x\\'; DROP TABLE victim; -- ", "x\\"] + let setups: [[String]] = [ + ["SET SESSION sql_mode = 'NO_BACKSLASH_ESCAPES'", "CALL clear_mode()"], + ["SET SESSION sql_mode = 'NO_BACKSLASH_ESCAPES'", "CALL clear_mode()", "DO 1"], + ["SET SESSION sql_mode = ''", "CALL set_nbe()"], + ["SET SESSION sql_mode = ''", "SELECT nbe_fn()", "DO 1"], + ] + for steps in setups { + for payload in payloads { + for sql in steps { try Self.run(mysql, sql) } + #expect(mysql.listTables(wildcard: payload).isEmpty, "\(charset) \(steps)") + #expect(mysql.query(statement: "SELECT 1"), "\(charset) \(steps): \(mysql.errorMessage())") + _ = mysql.storeResults() + } + for sql in steps { try Self.run(mysql, sql) } + #expect(mysql.listTables(wildcard: "vic%") == ["victim"], "\(charset) \(steps)") + #expect(mysql.listTables(wildcard: "it_s") == ["it's"], "\(charset) \(steps)") + } + } } + diff --git a/Tests/MariaDBTests/SSLModeTests.swift b/Tests/MariaDBTests/SSLModeTests.swift index 363d997..a93401a 100644 --- a/Tests/MariaDBTests/SSLModeTests.swift +++ b/Tests/MariaDBTests/SSLModeTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing @testable import MariaDB +import mariadbclient // MYSQL_OPT_SSL_MODE takes MySQL's SSL_MODE_* values; libmariadb has no such option, so // MySQL.setOption maps the modes onto MYSQL_OPT_SSL_ENFORCE / MYSQL_OPT_SSL_VERIFY_SERVER_CERT. @@ -95,12 +96,25 @@ ioamVHL6w6te45I= } // Connector/C 3.4 deliberately skips verification on loopback connections without a CA. + // With Connector/C 3.4 against MariaDB 11.4+ and a password, it instead verifies the server's + // self-signed certificate through a fingerprint derived from the password (MDEV-31855), so + // there the verifying modes are expected to connect. @Test(.mariaLive, .enabled(if: !["127.0.0.1", "::1"].contains(host), "needs a non-loopback MARIA_TEST_HOST")) func verifyingModesRejectASelfSignedServerWithoutCA() { + let probe = connect(.preferred) + let version = probe.query(statement: "SELECT VERSION()") ? (probe.storeResults()?.next()?[0] ?? nil) : nil + probe.close() + let fingerprintVerified = mysql_get_client_version() >= 30400 + && version?.contains("MariaDB") == true && !password.isEmpty for mode in [Mode.verifyCA, .verifyIdentity] { let mysql = connect(mode) - #expect(mysql.errorCode() != 0, "\(mode) accepted a self-signed certificate") - #expect(!mysql.ping()) + if fingerprintVerified { + #expect(mysql.errorCode() == 0, "\(mode): \(mysql.errorMessage())") + #expect(cipher(mysql) != "", "\(mode) connected without TLS") + } else { + #expect(mysql.errorCode() != 0, "\(mode) accepted a self-signed certificate") + #expect(!mysql.ping()) + } } } diff --git a/Tests/MariaDBTests/StatementResultTests.swift b/Tests/MariaDBTests/StatementResultTests.swift new file mode 100644 index 0000000..8e34e8d --- /dev/null +++ b/Tests/MariaDBTests/StatementResultTests.swift @@ -0,0 +1,488 @@ +import Foundation +import Testing +@testable import MariaDB +import PerfectCRUD + +// MARK: - MySQLStmtExeDelegate result handling on a live server +// +// Run with: MARIA_TESTS=1 MARIA_TEST_PORT=… [MARIA_TEST_HOST=… MARIA_TEST_USER=… +// MARIA_TEST_PASSWORD=…] swift test --filter StatementResultTests + +private let schema = "perfect_maria_stmt_result_test" + +private func connection(_ database: String) throws -> MySQL { + let mysql = MySQL() + mysql.setOption(.MYSQL_SET_CHARSET_NAME, "utf8mb4") + guard mysql.connect(host: MariaTestEnvironment.host, user: MariaTestEnvironment.user, + password: MariaTestEnvironment.password, db: database, + port: UInt32(MariaTestEnvironment.port)) else { + throw MySQLCRUDError("Could not connect. \(mysql.errorMessage())") + } + return mysql +} + +private func freshDatabase() throws -> Database { + let admin = Database(configuration: MySQLDatabaseConfiguration(connection: try connection("mysql"))) + try admin.sql("DROP DATABASE IF EXISTS `\(schema)`") + try admin.sql("CREATE DATABASE `\(schema)` DEFAULT CHARACTER SET utf8mb4") + return Database(configuration: MySQLDatabaseConfiguration(connection: try connection(schema))) +} + +private func dropSchema() { + guard let mysql = try? connection("mysql") else { return } + _ = mysql.query(statement: "DROP DATABASE IF EXISTS `\(schema)`") + mysql.close() +} + +private struct Pair: Codable, Equatable { let id: Int; let name: String } + +@Suite("Statement results on a live server", .serialized) +struct StatementResultTests { + + // Codable decoding used a [name: index] map built from a dictionary, so with duplicate + // names the column that won depended on dictionary order. + @Test(.mariaLive) + func duplicateColumnNamesDecodeTheFirst() throws { + let db = try freshDatabase() + defer { dropSchema() } + for _ in 0..<20 { + let rows = try db.sql("SELECT 1 AS id, 'a' AS name, 2 AS id, 'b' AS name", Pair.self) + #expect(rows == [Pair(id: 1, name: "a")]) + } + } + + // A subquery returning two rows fails with error 1242 while the result is being read. + // mysql_stmt_store_result's failure was ignored and mysql_stmt_fetch then crashed inside + // libmariadb (SIGSEGV); now the error is thrown and the connection stays usable. + @Test(.mariaLive) + func errorWhileReadingRowsThrows() throws { + let db = try freshDatabase() + defer { dropSchema() } + try db.sql("CREATE TABLE `a` (`id` INT PRIMARY KEY)") + try db.sql("CREATE TABLE `b` (`k` INT, `x` INT)") + try db.sql("INSERT INTO `a` VALUES (1), (2), (3)") + try db.sql("INSERT INTO `b` VALUES (1, 10), (2, 20), (2, 21)") + struct Row: Codable { let id: Int; let x: Int? } + let sql = "SELECT id, (SELECT x FROM b WHERE b.k = a.id) AS x FROM a ORDER BY id" + #expect(throws: MySQLCRUDError.self) { try db.sql(sql, Row.self) } + #expect(throws: MySQLCRUDError.self) { try db.execute(DynamicSQL(sql: sql)) } + // The raw statement API reports it too. + let stmt = MySQLStmt(db.configuration.connection) + #expect(stmt.prepare(statement: sql)) + #expect(stmt.execute()) + let results = stmt.results() + #expect(results.next() == nil) + #expect(results.failed) + #expect(results.errorCode == 1242, "\(results.errorMessage)") + stmt.reset() + #expect(stmt.execute()) + #expect(!stmt.results().forEachRow { _ in }) + // The connection still works. + #expect(try db.sql("SELECT id, NULL AS x FROM a ORDER BY id", Row.self).map(\.id) == [1, 2, 3]) + } + + // A statement prepared again gets a bind array sized for the new statement, and one that's + // executed, read and reset many times keeps working. (The bind array used to leak on every + // parameterized statement; that was checked with `leaks`, which a test can't run.) + @Test(.mariaLive) + func statementsRebindAfterResetAndReprepare() throws { + let db = try freshDatabase() + defer { dropSchema() } + let stmt = MySQLStmt(db.configuration.connection) + for i in 0..<50 { + #expect(stmt.prepare(statement: "SELECT ?, ?")) + stmt.bindParam(i) + stmt.bindParam("x\(i)") + #expect(stmt.execute(), "\(stmt.errorMessage())") + let row = stmt.results().next() + #expect(row?[0] as? Int64 == Int64(i)) + #expect(row?[1] as? String == "x\(i)") + stmt.reset() + } + #expect(stmt.prepare(statement: "SELECT ?, ?, ?")) + stmt.bindParam(1) + stmt.bindParam(2) + stmt.bindParam(3) + #expect(stmt.execute(), "\(stmt.errorMessage())") + #expect(stmt.results().next()?.count == 3) + } + + // DECIMAL values arrive as text; Double and Float properties used to read them as 0. + @Test(.mariaLive) + func decimalColumnsDecodeIntoFloatingPoint() throws { + struct Price: Codable, TableNameProvider { + static let tableName = "decimal_price" + let id: Int + let amount: Double + let rate: Float + let optional: Double? + } + let db = try freshDatabase() + defer { dropSchema() } + try db.sql("CREATE TABLE decimal_price (id INT PRIMARY KEY, amount DECIMAL(12,2), rate DECIMAL(5,3), optional DECIMAL(8,4) NULL)") + try db.sql("INSERT INTO decimal_price VALUES (1, 1234.56, 0.125, NULL), (2, -0.50, 2.5, 3.1415)") + let prices = try db.table(Price.self).order(by: \.id).select().map { $0 } + #expect(prices.map(\.amount) == [1234.56, -0.5]) + #expect(prices.map(\.rate) == [0.125, 2.5]) + #expect(prices.map(\.optional) == [nil, 3.1415]) + } + + // Blob parameters (bindParam([UInt8]), ([Int8]) and (_:length:)) are freed on reset, and a + // statement reused with blobs of different sizes, including empty ones, keeps working. (The + // buffers used to leak; that was checked with `leaks`, which a test can't run.) + @Test(.mariaLive) + func blobParametersSurviveResetAndReuse() throws { + let db = try freshDatabase() + defer { dropSchema() } + let stmt = MySQLStmt(db.configuration.connection) + for i in 0..<50 { + #expect(stmt.prepare(statement: "SELECT ?, ?, ?")) + let bytes = [UInt8](repeating: UInt8(i), count: i) + stmt.bindParam(bytes) + stmt.bindParam([Int8](repeating: 7, count: 50 - i)) + "abc".withCString { stmt.bindParam($0, length: 3) } + #expect(stmt.execute(), "\(stmt.errorMessage())") + // MariaDB returns a blob parameter's SELECT as bytes; MySQL 8.4 as text. + func asBytes(_ value: Any?) -> [UInt8]? { + (value as? [UInt8]) ?? (value as? String).map { Array($0.utf8) } + } + let row = stmt.results().next() + #expect(asBytes(row?[0]) == bytes) + #expect(asBytes(row?[1])?.count == 50 - i) + #expect(asBytes(row?[2]) == Array("abc".utf8)) + stmt.reset() + } + } + + // Reusing a statement (execute, read results, reset, execute again) corrupted the heap with + // libmysqlclient: the client library kept pointers into the freed Results' buffers and wrote + // through them on the next execute (Perfect-MySQL 376a1c3). + @Test(.mariaLive) + func aStatementCanBeReusedAfterReadingResults() throws { + let db = try freshDatabase() + defer { dropSchema() } + let stmt = MySQLStmt(db.configuration.connection) + try #require(stmt.prepare(statement: "SELECT CAST(? AS SIGNED) AS n, ? * 1.5 AS d, CONCAT('v', ?) AS s, LENGTH(?) AS l")) + for n in 0..<300 { + stmt.bindParam(n) + stmt.bindParam(Double(n)) + stmt.bindParam("\(n)") + stmt.bindParam([UInt8](repeating: 1, count: n % 9)) + try #require(stmt.execute(), "\(n): \(stmt.errorMessage())") + let row = stmt.results().next() + #expect(row?[0] as? Int64 == Int64(n)) + #expect(row?[2] as? String == "v\(n)") + // LENGTH() is an INT on MariaDB and a BIGINT on MySQL. + #expect((row?[3] as? any BinaryInteger).map { Int64($0) } == Int64(n % 9)) + stmt.reset() + } + } + + // An [Int8] property read its blob with Int8($0), which traps on any byte >= 0x80, so a + // negative value crashed on the way back. The first rows are written with SQL, the last through + // Perfect-CRUD (its 3.0.0 encoder trapped on a negative [Int8]; 3.0.1 and later don't). + @Test(.mariaLive) + func signedByteArraysRoundTrip() throws { + struct Signed: Codable, TableNameProvider { + static let tableName = "signed_bytes" + let id: Int + let bytes: [Int8] + } + let db = try freshDatabase() + defer { dropSchema() } + try db.create(Signed.self, policy: .dropTable) + let written = [Signed(id: 1, bytes: [-1, -128, 0, 127, 1]), Signed(id: 2, bytes: [])] + try db.sql("INSERT INTO signed_bytes (id, bytes) VALUES (1, X'FF80007F01'), (2, X'')") + let read = try db.table(Signed.self).order(by: \.id).select().map { $0 } + #expect(read.map(\.bytes) == written.map(\.bytes)) + let all = Signed(id: 3, bytes: (Int8.min...Int8.max).map { $0 }) + try db.table(Signed.self).insert(all) + #expect(try db.table(Signed.self).where(\Signed.id == 3).first()?.bytes == all.bytes) + } + + // Replacing a Results with a newer one releases the older one after the newer has bound. + // The older one's cleanup used to detach the newer one's binds, so it read 0 / "". + @Test(.mariaLive) + func reassigningAResultsVariableKeepsTheNewOneBound() throws { + let db = try freshDatabase() + defer { dropSchema() } + let stmt = MySQLStmt(db.configuration.connection) + try #require(stmt.prepare(statement: "SELECT CAST(? AS SIGNED) AS n, CONCAT('v', ?) AS s, ? * 2.5 AS d")) + stmt.bindParam(0) + stmt.bindParam("0") + stmt.bindParam(0.0) + try #require(stmt.execute()) + var results = stmt.results() + _ = results.next() + for n in 1...50 { + stmt.reset() + stmt.bindParam(n) + stmt.bindParam("\(n)") + stmt.bindParam(Double(n)) + try #require(stmt.execute(), "\(stmt.errorMessage())") + results = stmt.results() + let row = results.next() + #expect(row?[0] as? Int64 == Int64(n)) + #expect(row?[1] as? String == "v\(n)") + #expect(row?[2] as? Double == Double(n) * 2.5) + } + } + + // Walking a CALL's result sets with one variable, and keeping an older Results alive while + // the statement moves to a wider result set or is prepared again with more columns. The + // older one's cleanup used to bind as many binds as it had columns while the library copied + // the current column count, reading past the allocation. + @Test(.mariaLive) + func olderResultsDontDisturbLaterResultSets() throws { + let db = try freshDatabase() + defer { dropSchema() } + try db.sql("CREATE PROCEDURE three_sets() BEGIN SELECT 1 AS a, 'x' AS b; SELECT 2 AS a, 'y' AS b, 0 AS c; SELECT 3 AS a, 'z' AS b; END") + let mysql = db.configuration.connection + let call = MySQLStmt(mysql) + try #require(call.prepare(statement: "CALL three_sets()")) + func int(_ value: Any?) -> Int64 { (value as? any BinaryInteger).map { Int64($0) } ?? -1 } + for _ in 0..<3 { + try #require(call.execute(), "\(call.errorMessage())") + var results = call.results() + var got: [String] = [] + while true { + while let row = results.next() { + got.append("\(int(row[0]))/\(row[1] as? String ?? "nil")") + } + call.freeResult() + guard call.nextResult() == 0, call.fieldCount() > 0 else { break } + results = call.results() + } + #expect(got == ["1/x", "2/y", "3/z"]) + while call.nextResult() == 0 {} + call.reset() + } + + let stmt = MySQLStmt(mysql) + try #require(stmt.prepare(statement: "SELECT 1 AS a")) + try #require(stmt.execute()) + var older: MySQLStmt.Results? = stmt.results() + _ = older?.next() + stmt.freeResult() + try #require(stmt.prepare(statement: "SELECT 1 AS a, 2 AS b, 3 AS c, 4 AS d, 5 AS e, 6 AS f, 7 AS g, 8 AS h")) + try #require(stmt.execute()) + older = nil + #expect(older == nil) + let row = stmt.results().next() + #expect(row?.count == 8) + #expect(int(row?[7]) == 8) + } + + // Re-executing one prepared CALL. With MariaDB Connector/C the cached result metadata + // pointed into storage the library replaces on each execution, so reading the second + // execution's rows used freed memory (seen under Guard Malloc). + @Test(.mariaLive) + func aPreparedCallCanBeExecutedRepeatedly() throws { + let db = try freshDatabase() + defer { dropSchema() } + try db.sql("CREATE TABLE items (id INT PRIMARY KEY, name VARCHAR(10))") + try db.sql("INSERT INTO items VALUES (1, 'a'), (2, 'b'), (3, 'c')") + try db.sql("CREATE PROCEDURE items_from(IN lo INT) SELECT id, name FROM items WHERE id >= lo ORDER BY id") + let mysql = db.configuration.connection + let stmt = MySQLStmt(mysql) + try #require(stmt.prepare(statement: "CALL items_from(?)")) + for n in 0..<20 { + let low = n % 3 + 1 + stmt.bindParam(low) + try #require(stmt.execute(), "\(n): \(stmt.errorMessage())") + #expect(stmt.fieldInfo(index: 1)?.name == "name") + let results = stmt.results() + var ids: [Int64] = [] + while let row = results.next() { ids.append((row[0] as? any BinaryInteger).map { Int64($0) } ?? -1) } + #expect(ids == Array(Int64(low)...3), "\(n)") + stmt.freeResult() + while stmt.nextResult() == 0 { stmt.freeResult() } + stmt.reset() + } + #expect(mysql.query(statement: "SELECT 1"), "\(mysql.errorMessage())") + } + + // After reset() the parameter buffers are freed, but the client library still holds the old + // binds; executing again without rebinding read freed memory. It's now refused. + @Test(.mariaLive) + func executingWithoutBindingEveryParameterIsRefused() throws { + let db = try freshDatabase() + defer { dropSchema() } + let mysql = db.configuration.connection + func int(_ value: Any?) -> Int64? { (value as? any BinaryInteger).map { Int64($0) } } + let stmt = MySQLStmt(mysql) + try #require(stmt.prepare(statement: "SELECT LENGTH(?) AS l, ? AS s")) + stmt.bindParam([UInt8](repeating: 7, count: 4096)) + stmt.bindParam("hello") + try #require(stmt.execute()) + #expect(int(stmt.results().next()?[0]) == 4096) + stmt.reset() + + #expect(!stmt.execute()) + #expect(stmt.errorCode() == 2031) + #expect(stmt.errorMessage().contains("No data supplied")) + stmt.bindParam([UInt8](repeating: 1, count: 3)) + #expect(!stmt.execute(), "one of two parameters bound") + stmt.reset() + + stmt.bindParam([UInt8](repeating: 1, count: 3)) + stmt.bindParam("again") + #expect(stmt.execute(), "\(stmt.errorMessage())") + #expect(stmt.errorCode() == 0) + #expect(int(stmt.results().next()?[0]) == 3) + + let plain = MySQLStmt(mysql) // no parameters: nothing to bind + try #require(plain.prepare(statement: "SELECT 1")) + #expect(plain.execute()) + _ = plain.results().next() + plain.reset() + #expect(plain.execute()) + } + + // Binding more values than a statement has parameters wrote past the bind array (SIGSEGV + // through Perfect-CRUD, even with the system allocator). The extra values are now refused with + // error 2034, and execute() refuses until the statement is reset and bound correctly. + @Test(.mariaLive) + func bindingTooManyParametersIsRefused() throws { + struct X: Codable { let x: Int } + let db = try freshDatabase() + defer { dropSchema() } + #expect(throws: (any Error).self) { + _ = try db.sql("SELECT ? AS x", bindings: [("?", .integer(1)), ("?", .string("two")), ("?", .blob([3]))], X.self) + } + #expect(try db.sql("SELECT ? AS x", bindings: [("?", .integer(4))], X.self).map(\.x) == [4]) + + let mysql = db.configuration.connection + let stmt = MySQLStmt(mysql) + try #require(stmt.prepare(statement: "SELECT ? AS x")) + stmt.bindParam(1) + stmt.bindParam("x") + stmt.bindParam([UInt8](repeating: 1, count: 64)) + stmt.bindParam() + #expect(stmt.errorCode() == 2034) + #expect(!stmt.execute()) + #expect(stmt.errorCode() == 2034) + stmt.reset() + #expect(stmt.errorCode() == 0, "a 2034 doesn't outlive reset()") + stmt.bindParam(5) + #expect(stmt.errorCode() == 0) + #expect(stmt.execute(), "\(stmt.errorMessage())") + #expect((stmt.results().next()?[0] as? any BinaryInteger).map { Int64($0) } == 5) + stmt.reset() + + // A stray value on a statement without parameters doesn't stick after reset(). + let plain = MySQLStmt(mysql) + try #require(plain.prepare(statement: "SELECT 1")) + plain.bindParam(1) + #expect(!plain.execute()) + plain.reset() + #expect(plain.execute(), "\(plain.errorMessage())") + _ = plain.results().next() + + // After clearBinds() on a prepared statement there's no array: values are refused, and + // execute() says so (2034) rather than failing with no error. + let cleared = MySQLStmt(mysql) + try #require(cleared.prepare(statement: "SELECT ? AS x")) + cleared.clearBinds() + cleared.bindParam(1) + #expect(!cleared.execute()) + #expect(cleared.errorCode() == 2034, "\(cleared.errorCode()) \(cleared.errorMessage())") + + // After a failed prepare, execute() fails with the library's error (2014 on MariaDB + // Connector/C), not 2031. + let broken = MySQLStmt(mysql) + try #require(broken.prepare(statement: "SELECT ? AS x")) + broken.bindParam(1) + #expect(!broken.prepare(statement: "SELEC nonsense")) + #expect(!broken.execute()) + #expect(broken.errorCode() != 2031, "\(broken.errorCode()) \(broken.errorMessage())") + } + + // A CALL has no result metadata until it runs, and it ends with an extra status result. + // The column maps were built before execute(), so every column read as missing (Codable + // threw, Dynamic rows were empty), and the leftover result had to be drained. + @Test(.mariaLive) + func callReturnsRowsAndLeavesTheConnectionUsable() throws { + let db = try freshDatabase() + defer { dropSchema() } + try db.sql("CREATE TABLE `t` (`id` INT PRIMARY KEY, `name` VARCHAR(10))") + try db.sql("INSERT INTO `t` VALUES (1, 'one'), (2, 'two'), (3, 'three')") + try db.sql("CREATE PROCEDURE `rows_from`(IN lo INT) SELECT id, name FROM t WHERE id >= lo ORDER BY id") + try db.sql("CREATE PROCEDURE `two_sets`() BEGIN SELECT id, name FROM t WHERE id = 1; SELECT 9 AS id, 'x' AS name; END") + + #expect(try db.sql("CALL rows_from(?)", bindings: [("?", .integer(2))], Pair.self) + == [Pair(id: 2, name: "two"), Pair(id: 3, name: "three")]) + #expect(try db.sql("SELECT id, name FROM t WHERE id = 1", Pair.self) == [Pair(id: 1, name: "one")]) + + let dynamic = try db.execute(DynamicSQL(sql: "CALL rows_from(3)")) + #expect(dynamic.rows == [DynamicRow(["id": .int(3), "name": .string("three")])]) + #expect(try db.sql("SELECT id, name FROM t WHERE id = 2", Pair.self) == [Pair(id: 2, name: "two")]) + + // Only the first result set of a multi-result CALL is read; the rest are discarded. + #expect(try db.sql("CALL two_sets()", Pair.self) == [Pair(id: 1, name: "one")]) + #expect(try db.sql("SELECT id, name FROM t WHERE id = 3", Pair.self) == [Pair(id: 3, name: "three")]) + + // A CALL run without reading its rows, then more statements, in a transaction. + try db.transaction { + try db.sql("CALL two_sets()") + try db.sql("CALL rows_from(1)") + try db.sql("UPDATE t SET name = 'uno' WHERE id = 1") + } + #expect(try db.sql("SELECT id, name FROM t WHERE id = 1", Pair.self) == [Pair(id: 1, name: "uno")]) + } + + // A CALL whose first result set is fine but whose later statement fails used to report + // success with the first set's rows: draining the remaining results dropped the error. + // Untyped `db.sql("CALL ...")` reads one row at most and can't see it (Perfect-CRUD calls + // hasNext() once), which the README documents. + @Test(.mariaLive) + func errorAfterACallsFirstResultSetThrows() throws { + let db = try freshDatabase() + defer { dropSchema() } + try db.sql("CREATE TABLE `u` (`id` INT PRIMARY KEY)") + try db.sql("INSERT INTO `u` VALUES (1)") + try db.sql("CREATE PROCEDURE `dup`() BEGIN SELECT 1 AS id, 'a' AS name; INSERT INTO u VALUES (1); END") + try db.sql("CREATE PROCEDURE `sub`() BEGIN SELECT 1 AS id, 'a' AS name; SELECT (SELECT 1 UNION SELECT 2) AS id, 'b' AS name; END") + try db.sql("CREATE PROCEDURE `sig`() BEGIN SELECT 1 AS id, 'a' AS name; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'boom'; END") + for call in ["CALL dup()", "CALL sub()", "CALL sig()"] { + #expect(throws: MySQLCRUDError.self, "\(call)") { try db.sql(call, Pair.self) } + #expect(throws: MySQLCRUDError.self, "\(call)") { try db.execute(DynamicSQL(sql: call)) } + // The connection still works afterwards. + #expect(try db.sql("SELECT 2 AS id, 'ok' AS name", Pair.self) == [Pair(id: 2, name: "ok")]) + // Untyped: Perfect-CRUD calls hasNext() once, so the error can't be reported (it's + // logged), but the connection must still be usable. + try db.sql(call) + #expect(try db.sql("SELECT 3 AS id, 'ok' AS name", Pair.self) == [Pair(id: 3, name: "ok")]) + } + // A CALL that succeeds still doesn't throw. + try db.sql("CREATE PROCEDURE `fine`() BEGIN SELECT 1 AS id, 'a' AS name; SELECT 2 AS id, 'b' AS name; END") + #expect(try db.sql("CALL fine()", Pair.self) == [Pair(id: 1, name: "a")]) + } + + // MariaDB's INSERT ... RETURNING returns rows from an INSERT. + @Test(.mariaLive) + func insertReturningReturnsRows() throws { + let db = try freshDatabase() + defer { dropSchema() } + let version = try db.sql("SELECT VERSION() AS v", Version.self).first?.v ?? "" + guard version.contains("MariaDB") else { return } // MySQL has no RETURNING + try db.sql("CREATE TABLE `r` (`id` INT AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(10))") + let result = try db.execute(DynamicSQL( + sql: "INSERT INTO `r` (`name`) VALUES (?), (?) RETURNING id, name", + bindings: [.string("a"), .string("b")])) + #expect(result.rows == [DynamicRow(["id": .int(1), "name": .string("a")]), + DynamicRow(["id": .int(2), "name": .string("b")])]) + #expect(result.affectedRows == 2) + #expect(result.insertedID == nil) + #expect(try db.sql("INSERT INTO `r` (`name`) VALUES ('c') RETURNING id, name", Pair.self) + == [Pair(id: 3, name: "c")]) + #expect(try count(db) == 3) + } +} + +private struct Version: Codable { let v: String } + +private func count(_ db: Database) throws -> Int { + struct C: Codable { let c: Int } + return try db.sql("SELECT COUNT(*) AS c FROM `r`", C.self)[0].c +} diff --git a/Tests/MariaDBTests/SubTableCreateOrderTests.swift b/Tests/MariaDBTests/SubTableCreateOrderTests.swift index 6be90c6..61e1748 100644 --- a/Tests/MariaDBTests/SubTableCreateOrderTests.swift +++ b/Tests/MariaDBTests/SubTableCreateOrderTests.swift @@ -158,7 +158,8 @@ struct SubTableCreateOrderLiveTests { let db = try freshDatabase() defer { try? dropSchema() } func count(_ table: String) throws -> Int { - try db.sql("SELECT COUNT(*) AS c FROM \(table)", Count.self)[0].c + let rows = try db.sql("SELECT COUNT(*) AS c FROM \(table)", Count.self) + return rows[0].c } try db.sql("CREATE TABLE `quote_victim` (`id` INT PRIMARY KEY)") for mark in ["\u{301}", "\u{FE0F}"] { diff --git a/docker-compose.yml b/docker-compose.yml index a2da67d..9cab16b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: image: mariadb restart: always ports: - - 3306:3306 + - 127.0.0.1:3308:3306 networks: - maria-net environment: