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 @@
-
+
@@ -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 @@
-
+
-
+
@@ -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: