From 8a7ebec772be71e7ac2f147eff8db9734be05949 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 07:24:39 -0600 Subject: [PATCH 01/32] Depend on Perfect-CRUD 3.0.0; drop the test target's unsafe linker flag - Perfect-CRUD is now `from: "3.0.0"` instead of `branch: "main"`. - The test target no longer passes `-Xlinker -search_dylibs_first`. It was a belt-and-braces guard for the old `MariaDB` product name, whose libMariaDB.a in the build products directory shadowed `-lmariadb` on a case-insensitive file system. The rename to PerfectMariaDB removed the cause: renamed back in a scratch copy, the test build still fails with undefined mysql_* symbols; with the current name and no flag, the test binary links /opt/homebrew/opt/mariadb-connector-c/lib/libmariadb.3.dylib (otool -L), with mysql-client 9.6 also installed. The manifest now has no unsafe flags at all. - Swift 6.4 warned that a `try` covered no throwing call in a chained `db.sql(...)[0].c` expression in SubTableCreateOrderTests; split it. - ListingWildcardTests failed against MySQL 8.4: under NO_BACKSLASH_ESCAPES MySQL treats `\` as an ordinary character in LIKE (checked with the mysql client), while MariaDB keeps it as the escape. The test now expects the server's behaviour, and the listTables/listDatabases docs say so. - verifyingModesRejectASelfSignedServerWithoutCA failed against MariaDB 11.8 over a non-loopback address: Connector/C 3.4 verifies a MariaDB 11.4+ server's self-signed certificate through a fingerprint derived from the password (MDEV-31855). With a passwordless user it is rejected as the test expected. The test now expects a connection in the fingerprint case. Co-Authored-By: Claude Opus 5.5 --- Package.swift | 9 ++------- Sources/MariaDB/MariaDB.swift | 6 ++++-- Tests/MariaDBTests/ListingWildcardTests.swift | 6 +++++- Tests/MariaDBTests/SSLModeTests.swift | 16 ++++++++++++++-- .../MariaDBTests/SubTableCreateOrderTests.swift | 3 ++- 5 files changed, 27 insertions(+), 13 deletions(-) diff --git a/Package.swift b/Package.swift index 8c459b2..ad5caba 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.0"), ], 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/Sources/MariaDB/MariaDB.swift b/Sources/MariaDB/MariaDB.swift index b991934..7734435 100644 --- a/Sources/MariaDB/MariaDB.swift +++ b/Sources/MariaDB/MariaDB.swift @@ -181,12 +181,14 @@ 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). + /// 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). 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). public func listDatabases(wildcard wild: String? = nil) -> [String] { return listNames("SHOW DATABASES", like: wild) } diff --git a/Tests/MariaDBTests/ListingWildcardTests.swift b/Tests/MariaDBTests/ListingWildcardTests.swift index 85a8bee..f1502bd 100644 --- a/Tests/MariaDBTests/ListingWildcardTests.swift +++ b/Tests/MariaDBTests/ListingWildcardTests.swift @@ -55,7 +55,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())") diff --git a/Tests/MariaDBTests/SSLModeTests.swift b/Tests/MariaDBTests/SSLModeTests.swift index 363d997..ad003e1 100644 --- a/Tests/MariaDBTests/SSLModeTests.swift +++ b/Tests/MariaDBTests/SSLModeTests.swift @@ -95,12 +95,24 @@ ioamVHL6w6te45I= } // Connector/C 3.4 deliberately skips verification on loopback connections without a CA. + // Against MariaDB 11.4+ with 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 = 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/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}"] { From ec92735641a5d184987d21a8025640bbce1fdd36 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 07:30:31 -0600 Subject: [PATCH 02/32] SSL test: expect fingerprint verification only with Connector/C 3.4 Connector/C 3.3 (Ubuntu 24.04's libmariadb-dev) has no password-based fingerprint verification, so against MariaDB with a password it still rejects a self-signed certificate in the verifying modes. Checked on Linux (Swift 6.2, Connector/C 3.3) against MariaDB 11.8 over a non-loopback address, and on macOS (Connector/C 3.4.9) with and without a password. Co-Authored-By: Claude Opus 5.5 --- Tests/MariaDBTests/SSLModeTests.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Tests/MariaDBTests/SSLModeTests.swift b/Tests/MariaDBTests/SSLModeTests.swift index ad003e1..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,15 +96,16 @@ ioamVHL6w6te45I= } // Connector/C 3.4 deliberately skips verification on loopback connections without a CA. - // Against MariaDB 11.4+ with 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. + // 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 = version?.contains("MariaDB") == true && !password.isEmpty + let fingerprintVerified = mysql_get_client_version() >= 30400 + && version?.contains("MariaDB") == true && !password.isEmpty for mode in [Mode.verifyCA, .verifyIdentity] { let mysql = connect(mode) if fingerprintVerified { From 674b9181b44219734223d73b9150fe2dc4037c47 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 07:30:31 -0600 Subject: [PATCH 03/32] Add CI; README for 4.0.0 - .github/workflows/ci.yml, modeled on Perfect-CRUD's: Linux (Swift 6.2 and 6.4 x debug/release in swift:X-noble, libmariadb-dev) running the live tests against MariaDB 11.8 and MySQL 8.4 service containers, including the foreign-key tests; macOS 26 (Xcode 26.0.1 and 26.6 x debug/release) with Homebrew's mariadb-connector-c and no server. Warnings are errors everywhere. Not run on GitHub yet. - macOS needs PKG_CONFIG_PATH for the keg-only mariadb-connector-c with Swift 6.2/6.3: Xcode 26.3's SwiftPM failed with "'mysql.h' file not found" without it, while Swift 6.4's finds it through the brew provider. CI sets it; the README says so. - README: install snippet `from: "4.0.0"` with Perfect-CRUD `from: "3.0.0"`, a Requirements section (Linux is now built and tested), Connector/C 3.4's default certificate verification, a "Migrating from 3.x" section, one Testing section instead of two (with the TLS test variables). - README.zh_CN: same install snippet and current build notes, replacing instructions for Ubuntu 16.04 and hand-written .pc files. - docker-compose.yml publishes MariaDB on 127.0.0.1:3308, not 3306, matching the README's test example and keeping the drop-and-recreate tests away from a real server. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 107 +++++++++++++++++++++++++++++++++++++++ README.md | 73 +++++++++++++++++++------- README.zh_CN.md | 69 ++++--------------------- docker-compose.yml | 2 +- 4 files changed, 174 insertions(+), 77 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..908475f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,107 @@ +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" + steps: + - uses: actions/checkout@v7 + + - name: Install client libraries + run: | + apt-get update + apt-get install -y --no-install-recommends pkg-config libmariadb-dev + + - 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 + 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; SwiftPM before Swift 6.4 doesn'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/README.md b/README.md index 18991ba..9f3cea2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

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

@@ -22,25 +22,37 @@ MariaDB target rather than not-yet-in-use or dead code. The pre-Swift-6 version of this package is preserved on the [`legacy`](../../tree/legacy) branch. -## macOS Build Notes +## Requirements -`Package.swift` declares `platforms: [.macOS(.v12)]`. +- Swift 6.2 or newer (`swift-tools-version: 6.2`, Swift 6 language mode). +- macOS 12 or newer, or Linux. CI builds and tests on macOS (Xcode 26.0.1 and 26.6) and on Ubuntu 24.04 + (Swift 6.2 and 6.4), with the Linux jobs running the live tests against MariaDB 11.8 and MySQL 8.4. +- MariaDB Connector/C (libmariadb). It talks to both MariaDB and MySQL servers. +- [Perfect-CRUD](https://github.com/PerfectlySoft/Perfect-CRUD) 3.0.0 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 Swift 6.2 and 6.3 (Xcode 26.x), 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.0"), ], 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 @@ -102,9 +112,35 @@ 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) verifies the server certificate by default on connections that aren't +local, even without `MYSQL_OPT_SSL_MODE`: 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 neither. + +## 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 Swift 6.2/6.3, 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. +- **`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 +152,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..33f6989 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -23,10 +23,10 @@

- Swift 5.0 + Swift 6.2 - Platforms OS X | Linux + Platforms macOS 12+ | Linux License Apache @@ -47,71 +47,24 @@ 该软件使用SPM进行编译和测试,本软件也是[Perfect](https://github.com/PerfectlySoft/Perfect)项目的一部分。本软件包可独立使用,因此使用时可以脱离PerfectLib等其他组件。 -请确保您已经安装并激活了最新版本的 Swift tool chain 工具链。 +需要 Swift 6.2 或更高版本,支持 macOS 12+ 和 Linux。最新的编译说明、TLS 说明以及从 3.x 迁移的说明请参阅[英文 README](README.md)。 -## OS X 编译时的注意事项 +## macOS 编译时的注意事项 -本软件组件需要[Home Brew](http://brew.sh)管理的 MariaDB 连接库函数二进制版本。 ```bash - brew install mariadb-connector-c +brew install mariadb-connector-c ``` -您必须手工处理pkg-config的.pc文件,比如/usr/local/lib/pkgconfig/mariadb.pc,内容类似如下: +Homebrew 以 keg-only 方式安装该库。使用 Swift 6.2 / 6.3(Xcode 26.x)时,请先设置: ```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,15 @@ $ 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") ... dependencies: [.product(name: "PerfectMariaDB", package: "Perfect-MariaDB")]), ``` -库产品名为 `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/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: From eb6904376e8142aee2245ee047c40507d9b18988 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 08:25:09 -0600 Subject: [PATCH 04/32] Support Perfect-CRUD's Dynamic API: rows, affected rows, insert ID The connector implemented none of SQLExeDelegate's Dynamic hooks, so `select(_: DynamicQuery)` and any `execute(_: DynamicSQL)` that returned rows threw "Dynamic rows are not supported by this connector", and `mutate`/`execute` reported 0 affected rows and no inserted ID. New DynamicAPITests fail on the previous commit in four of five tests. - nextDynamicRow() maps each column to a DynamicValue: signed integers to .int, unsigned to .uint, FLOAT/DOUBLE to .double, binary to .bytes, and the rest (DECIMAL, dates, text) to .string; NULL to .null. - Column names are lowercased and only the first of the columns whose names differ only by case, or not at all, is kept (`SELECT 1 AS a, 2 AS A`, `p.Name, c.name` in a join). Perfect-CRUD 3.0.0's DynamicRow.init lowercases the keys with Dictionary(uniqueKeysWithValues:) and would trap on them; this matches the first-wins rule planned for CRUD 3.0.1. - affectedRowCount() and lastInsertedID() are captured right after executing a statement that returns no rows (the statement is reset after its results are read). Out-of-range values become 0 / nil rather than trapping. Checked by the tests, not changed: unsigned DynamicValues already bind with is_unsigned (UInt64.max and Int64.min round-trip), and multi-statement DynamicSQL either fails as a whole (prepared path, or unprepared without CLIENT_MULTI_STATEMENTS; nothing runs) or runs every statement and reports an error from any of them (unprepared with CLIENT_MULTI_STATEMENTS). Run against MariaDB 11.8 and MySQL 8.4 on macOS (Swift 6.4, debug). Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MySQLCRUD.swift | 51 +++++++ Tests/MariaDBTests/DynamicAPITests.swift | 172 +++++++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 Tests/MariaDBTests/DynamicAPITests.swift diff --git a/Sources/MariaDB/MySQLCRUD.swift b/Sources/MariaDB/MySQLCRUD.swift index 86f7304..04d4ed0 100644 --- a/Sources/MariaDB/MySQLCRUD.swift +++ b/Sources/MariaDB/MySQLCRUD.swift @@ -448,6 +448,13 @@ class MySQLStmtExeDelegate: SQLExeDelegate, @unchecked Sendable { let statement: MySQLStmt var results: MySQLStmt.Results? let 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. + let dynamicColumns: [(index: Int, key: String)] + /// Captured right 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 @@ -456,6 +463,43 @@ class MySQLStmtExeDelegate: SQLExeDelegate, @unchecked Sendable { let inv = stat.fieldNames() inv.forEach { (pos, name) in m[name] = pos } columnMap = m + var seen = Set() + dynamicColumns = inv.keys.sorted().compactMap { index in + let key = inv[index]!.lowercased() + return seen.insert(key).inserted ? (index, key) : nil + } + } + + func nextDynamicRow() throws -> DynamicRow? { + guard let row = results?.currentRow() else { return nil } + var values: [String: DynamicValue] = [:] + for (index, key) in dynamicColumns where index < row.count { + 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,6 +514,13 @@ class MySQLStmtExeDelegate: SQLExeDelegate, @unchecked Sendable { guard statement.execute() else { throw MySQLCRUDError("Error executing statement. \(statement.errorMessage())") } + // A statement that returns rows reports no affected rows here ((my_ulonglong)-1 or + // a count of rows that haven't been read yet), so only count those that don't. + if statement.fieldCount() == 0 { + affectedRows = Int(exactly: statement.affectedRows()) ?? 0 + let id = statement.insertId() + insertedID = id == 0 ? nil : Int64(exactly: id) + } results = statement.results() } if results?.fetchRow() ?? false { return true } diff --git a/Tests/MariaDBTests/DynamicAPITests.swift b/Tests/MariaDBTests/DynamicAPITests.swift new file mode 100644 index 0000000..236f476 --- /dev/null +++ b/Tests/MariaDBTests/DynamicAPITests.swift @@ -0,0 +1,172 @@ +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) + #expect(result.affectedRows == 0) + } + + // 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") + } +} From 8af75e4064b76275634ca0f4ce5b4dc42a7e7373 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 08:25:09 -0600 Subject: [PATCH 05/32] README: Dynamic API notes; Connector/C 3.4 refuses servers without TLS Checked with Homebrew's Connector/C 3.4.9 against MariaDB 11.8 started with --skip-ssl on 127.0.0.1: with no MYSQL_OPT_SSL_MODE the connection fails with "SSL is required, but the server does not support it"; DISABLED and PREFERRED connect. Co-Authored-By: Claude Opus 5.5 --- README.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9f3cea2..79e7046 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,17 @@ 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 for statements that don't +return rows. 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. + ### TLS Set `MYSQL_OPT_SSL_MODE` before connecting, using MySQL's `SSL_MODE_*` values: 1 = DISABLED, 2 = PREFERRED, @@ -112,11 +123,13 @@ 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. -Connector/C 3.4 (Homebrew's current version) verifies the server certificate by default on connections that aren't -local, even without `MYSQL_OPT_SSL_MODE`: 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 neither. +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 From 472b076a70b99eb9a3fd0ac61848436bd6e67a25 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 08:59:18 -0600 Subject: [PATCH 06/32] Codable decoding: the first of several same-named columns wins MySQLStmtExeDelegate built its [name: index] column map by iterating a [Int: String] dictionary, so with duplicate column names (`a.id, b.id`, or `SELECT 1 AS id, 2 AS id`) which column a Codable property read was arbitrary. It now walks the columns in result order and keeps the first, as Perfect-MySQL 5b19235 does and as the Dynamic rows already did. New StatementResultTests.duplicateColumnNamesDecodeTheFirst (20 decodes of a four-column result with two ids and two names). Without the fix it fails in some iterations against MariaDB 11.8; with it, it passes. Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MySQLCRUD.swift | 6 ++- Tests/MariaDBTests/StatementResultTests.swift | 53 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 Tests/MariaDBTests/StatementResultTests.swift diff --git a/Sources/MariaDB/MySQLCRUD.swift b/Sources/MariaDB/MySQLCRUD.swift index 04d4ed0..80db9b6 100644 --- a/Sources/MariaDB/MySQLCRUD.swift +++ b/Sources/MariaDB/MySQLCRUD.swift @@ -459,9 +459,13 @@ class MySQLStmtExeDelegate: SQLExeDelegate, @unchecked Sendable { init(connection c: MySQL, stat: MySQLStmt) { connection = c statement = stat + // 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 } + for index in inv.keys.sorted() where m[inv[index]!] == nil { + m[inv[index]!] = index + } columnMap = m var seen = Set() dynamicColumns = inv.keys.sorted().compactMap { index in diff --git a/Tests/MariaDBTests/StatementResultTests.swift b/Tests/MariaDBTests/StatementResultTests.swift new file mode 100644 index 0000000..c9547e5 --- /dev/null +++ b/Tests/MariaDBTests/StatementResultTests.swift @@ -0,0 +1,53 @@ +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")]) + } + } +} From 0e395221237c40ddd5b04586597d00acba0b8f91 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 09:01:18 -0600 Subject: [PATCH 07/32] Throw when reading rows fails instead of crashing in libmariadb MySQLStmt.Results.init ignored mysql_stmt_store_result's return value, and fetchRow() then called mysql_stmt_fetch on the failed statement, which crashed inside libmariadb (SIGSEGV). Reproduced with `SELECT id, (SELECT x FROM b WHERE b.k = a.id) FROM a` where one row's subquery returns two rows (error 1242 partway through the result), through Database.sql(_:_:) against MariaDB 11.8. - Results records a failed store and never fetches after it; a fetch returning 1 is an error, not the end of the rows. New public `failed`, `errorCode` and `errorMessage` on MySQLStmt.Results. libmariadb reports the store error on the connection, not the statement (mysql_stmt_errno stays 0), so the error is captured from whichever has one. - MySQLStmtExeDelegate.hasNext() throws "Error reading results. " instead of reporting the end of the rows. - Results zero-initializes its binds before binding, so deinit never reads an uninitialized bind. New StatementResultTests.errorWhileReadingRowsThrows: Codable sql(), DynamicSQL and the raw MySQLStmt API, then a query on the same connection. Before the fix the test process dies with signal 11; with it, the full suite passes against MariaDB 11.8 and MySQL 8.4 (macOS, Swift 6.4, debug). Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MySQLCRUD.swift | 4 ++ Sources/MariaDB/MySQLStmt.swift | 39 +++++++++++++++++-- Tests/MariaDBTests/StatementResultTests.swift | 27 +++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/Sources/MariaDB/MySQLCRUD.swift b/Sources/MariaDB/MySQLCRUD.swift index 80db9b6..fc5e02d 100644 --- a/Sources/MariaDB/MySQLCRUD.swift +++ b/Sources/MariaDB/MySQLCRUD.swift @@ -528,8 +528,12 @@ class MySQLStmtExeDelegate: SQLExeDelegate, @unchecked Sendable { results = statement.results() } if results?.fetchRow() ?? false { return true } + let failure = results.flatMap { $0.failed ? $0.errorMessage : nil } statement.reset() results = nil + if let failure { + throw MySQLCRUDError("Error reading results. \(failure)") + } return false } diff --git a/Sources/MariaDB/MySQLStmt.swift b/Sources/MariaDB/MySQLStmt.swift index 76e2a12..b492bc7 100644 --- a/Sources/MariaDB/MySQLStmt.swift +++ b/Sources/MariaDB/MySQLStmt.swift @@ -4,7 +4,7 @@ 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 var meta: UnsafeMutablePointer? @@ -312,6 +312,24 @@ 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 + if stmt.errorCode() != 0 { + errorCode = stmt.errorCode() + errorMessage = stmt.errorMessage() + } else { + errorCode = stmt.connection.errorCode() + errorMessage = stmt.connection.errorMessage() + } + } init(_ s: MySQLStmt) { stmt = s @@ -322,7 +340,14 @@ 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() } @@ -335,9 +360,17 @@ public final class MySQLStmt: @unchecked Sendable { public func close() {} + /// Fetches the next row. False at the end of the rows, or on an error (then `failed`). public func fetchRow() -> 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?]? { diff --git a/Tests/MariaDBTests/StatementResultTests.swift b/Tests/MariaDBTests/StatementResultTests.swift index c9547e5..3ce4a7c 100644 --- a/Tests/MariaDBTests/StatementResultTests.swift +++ b/Tests/MariaDBTests/StatementResultTests.swift @@ -50,4 +50,31 @@ struct StatementResultTests { #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)") + // 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]) + } } From da62aef2b2b46546b0e3f0ad8eaa3fefbb58143a Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 09:02:50 -0600 Subject: [PATCH 08/32] Read CALL and RETURNING rows; drain leftover result sets MySQLStmtExeDelegate built its column maps in init, before execute(). A CALL has no result metadata until it runs, so every column read as missing: db.sql("CALL p()", T.self) threw "Could not convert nil into an Int", and Dynamic rows came back empty ([[:]]) with no error. MariaDB's INSERT ... RETURNING did the same, and reported 0 affected rows. Same change as Perfect-MySQL 9f3056b and 11541d0: - The maps are built after each execute(). A dynamic column index outside the row now throws "Missing dynamic row value" instead of being skipped. - When the rows run out, and in deinit if a caller stopped early, the delegate frees the current result and stores/frees every remaining one (MySQLStmt.discardRemainingResults) before resetting, so a CALL's trailing status result can't leave the connection out of sync. Only the first result set of a multi-result CALL is read. - A statement that returns rows (SELECT, CALL, INSERT ... RETURNING) reports the number of rows stored as its affected rows, and no insert ID. Statements without rows still report the count and insert ID read right after execute(). DynamicAPITests' SELECT expectation changes from 0 to the row count accordingly. New StatementResultTests: callReturnsRowsAndLeavesTheConnectionUsable (Codable CALL with a bound argument, Dynamic CALL, a two-set procedure, CALLs without reading rows inside a transaction, a statement after each) and insertReturningReturnsRows (MariaDB only; returns on MySQL). Before the fix both fail ("Could not convert nil into an Int for key: id", empty dynamic rows, 0 affected rows); with it the full suite passes against MariaDB 11.8 and MySQL 8.4 (macOS, Swift 6.4, debug). Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MySQLCRUD.swift | 59 +++++++++++++----- Sources/MariaDB/MySQLStmt.swift | 13 ++++ Tests/MariaDBTests/DynamicAPITests.swift | 4 +- Tests/MariaDBTests/StatementResultTests.swift | 60 +++++++++++++++++++ 4 files changed, 119 insertions(+), 17 deletions(-) diff --git a/Sources/MariaDB/MySQLCRUD.swift b/Sources/MariaDB/MySQLCRUD.swift index fc5e02d..8d408c3 100644 --- a/Sources/MariaDB/MySQLCRUD.swift +++ b/Sources/MariaDB/MySQLCRUD.swift @@ -447,37 +447,56 @@ 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. - let dynamicColumns: [(index: Int, key: String)] - /// Captured right after executing, before the results are read and the statement reset. + 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 + statement.discardRemainingResults() + 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() - for index in inv.keys.sorted() where m[inv[index]!] == nil { - m[inv[index]!] = index - } - columnMap = m + var dynamic: [(index: Int, key: String)] = [] var seen = Set() - dynamicColumns = inv.keys.sorted().compactMap { index in - let key = inv[index]!.lowercased() - return seen.insert(key).inserted ? (index, key) : nil + 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 where index < row.count { + 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) @@ -518,19 +537,27 @@ class MySQLStmtExeDelegate: SQLExeDelegate, @unchecked Sendable { guard statement.execute() else { throw MySQLCRUDError("Error executing statement. \(statement.errorMessage())") } - // A statement that returns rows reports no affected rows here ((my_ulonglong)-1 or - // a count of rows that haven't been read yet), so only count those that don't. - if statement.fieldCount() == 0 { + 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 } let failure = results.flatMap { $0.failed ? $0.errorMessage : nil } - statement.reset() results = nil + // Read past any further result sets (a CALL ends with a status result), then reset. + statement.discardRemainingResults() + statement.reset() if let failure { throw MySQLCRUDError("Error reading results. \(failure)") } diff --git a/Sources/MariaDB/MySQLStmt.swift b/Sources/MariaDB/MySQLStmt.swift index b492bc7..15d88ae 100644 --- a/Sources/MariaDB/MySQLStmt.swift +++ b/Sources/MariaDB/MySQLStmt.swift @@ -161,6 +161,19 @@ 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". + func discardRemainingResults() { + mysql_stmt_free_result(ptr) + while nextResult() == 0 { + if fieldCount() > 0 { + mysql_stmt_store_result(ptr) + } + mysql_stmt_free_result(ptr) + } + } + /// 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() { diff --git a/Tests/MariaDBTests/DynamicAPITests.swift b/Tests/MariaDBTests/DynamicAPITests.swift index 236f476..86f3ee2 100644 --- a/Tests/MariaDBTests/DynamicAPITests.swift +++ b/Tests/MariaDBTests/DynamicAPITests.swift @@ -116,7 +116,9 @@ struct DynamicAPITests { #expect(result.affectedRows == 1) result = try db.execute(DynamicSQL(sql: "SELECT * FROM `auto`")) #expect(result.rows.count == 1) - #expect(result.affectedRows == 0) + // 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. diff --git a/Tests/MariaDBTests/StatementResultTests.swift b/Tests/MariaDBTests/StatementResultTests.swift index 3ce4a7c..b2083eb 100644 --- a/Tests/MariaDBTests/StatementResultTests.swift +++ b/Tests/MariaDBTests/StatementResultTests.swift @@ -77,4 +77,64 @@ struct StatementResultTests { // 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 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")]) + } + + // 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 } From 9aa070db5e6d9f0b38374c8211161c629ddd60e9 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 09:04:32 -0600 Subject: [PATCH 09/32] Read dates as UTC; accept fractions and DATE; zero dates read as nil Perfect-CRUD writes Date values as UTC text ("yyyy-MM-dd HH:mm:ss" with a GMT formatter), but Date(fromMysqlFormatted:) parsed them in TimeZone.current. Outside UTC that meant every date came back shifted by the local offset (+7h in Denver), a UTC wall time inside the local DST spring-forward gap didn't parse, and DATETIME(6) fractions and DATE values never parsed in any zone. A failed parse threw, and Perfect-CRUD's SelectIterator swallows the error, so those rows silently vanished. Ported from Perfect-MySQL 705e9db so both connectors behave the same: the parser reads YYYY-MM-DD and YYYY-MM-DD HH:MM:SS (space or T) with up to six fractional digits, always as UTC through a fixed gregorian/UTC calendar, and rejects out-of-range parts; the writer gets an explicit UTC calendar. decodeNil reports nil for a zero date ('0000-00-00...') in a date-typed column, so an optional Date reads nil; a non-optional Date throws a "zero date ... use an optional property" error (Perfect-CRUD's select() still drops that row). README "Migrating from 3.x" describes the change. CI now runs every job with TZ=America/Denver (tzdata added to the Linux packages). New DateTests (Perfect-MySQL's, adapted to this package's live-test setup): parser cases, a CRUD round trip including the Denver gap date, DATETIME(6), DATE, NULL and zero dates. Under TZ=America/Denver the previous commit fails the parser cases and drops the gap row; TZ=UTC hides the round-trip bug, since TimeZone.current can't be changed in-process. With the fix the full suite passes under TZ=America/Denver against MariaDB 11.8 and MySQL 8.4 (macOS, Swift 6.4, debug). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 +- README.md | 9 +++ Sources/MariaDB/MySQLCRUD.swift | 73 +++++++++++++++--- Tests/MariaDBTests/DateTests.swift | 119 +++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 Tests/MariaDBTests/DateTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 908475f..f0387e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,13 +41,15 @@ jobs: 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 + apt-get install -y --no-install-recommends pkg-config libmariadb-dev tzdata - name: Toolchain run: swift --version @@ -75,6 +77,8 @@ jobs: name: macOS (Xcode ${{ matrix.xcode }}, ${{ matrix.configuration }}) runs-on: macos-26 timeout-minutes: 30 + env: + TZ: America/Denver strategy: fail-fast: false matrix: diff --git a/README.md b/README.md index 79e7046..a6d9a11 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,15 @@ release notes for its changes. 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. +- **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 was silently dropped. Dates are now parsed as UTC, and `DATETIME(6)` fractions and `DATE` + values are read too (they used to drop the row). Rows written through Perfect-CRUD read back correctly with no + migration. What does shift: apps that compensated for the old offset by hand, and 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). The zero + date (`0000-00-00`) reads as nil for an optional `Date`; for a non-optional one it's an error, which Perfect-CRUD's + `select()` currently skips the row on. - **`connect()`** always passes `CLIENT_REMEMBER_OPTIONS`, so options survive a failed connection attempt. ## Further Information diff --git a/Sources/MariaDB/MySQLCRUD.swift b/Sources/MariaDB/MySQLCRUD.swift index 8d408c3..b268e42 100644 --- a/Sources/MariaDB/MySQLCRUD.swift +++ b/Sources/MariaDB/MySQLCRUD.swift @@ -38,7 +38,16 @@ class MySQLCRUDRowReader: 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 } @@ -99,6 +108,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 @@ -679,25 +691,66 @@ public struct MySQLDatabaseConfiguration: DatabaseConfigurationProtocol, @unchec } } +// 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. +private let mysqlUTC = TimeZone(identifier: "UTC")! + +private let mysqlUTCCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = mysqlUTC + return calendar +}() + extension Date { func mysqlFormatted() -> String { let dateFormatter = DateFormatter() dateFormatter.locale = Locale(identifier: "en_US_POSIX") - dateFormatter.timeZone = TimeZone(abbreviation: "GMT") + dateFormatter.calendar = mysqlUTCCalendar + dateFormatter.timeZone = mysqlUTC dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" return dateFormatter.string(from: self) } + /// 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. Returns nil for anything else, including the + /// zero date 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 + 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) else { + return nil + } + var components = DateComponents(year: year, month: month, day: day, hour: 0, minute: 0, second: 0) + var fraction = 0.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) else { + return nil + } + components.hour = hour + components.minute = minute + components.second = second + if chars.count > 19 { + guard chars[19] == UInt8(ascii: "."), let digits = number(20..= 1, components.isValidDate(in: mysqlUTCCalendar), + let date = mysqlUTCCalendar.date(from: components) else { + return nil } - return nil + self = date.addingTimeInterval(fraction) } } diff --git a/Tests/MariaDBTests/DateTests.swift b/Tests/MariaDBTests/DateTests.swift new file mode 100644 index 0000000..78232de --- /dev/null +++ b/Tests/MariaDBTests/DateTests.swift @@ -0,0 +1,119 @@ +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() { + 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(Self.utc(2026, 3, 8, 2, 30).mysqlFormatted() == "2026-03-08 02:30:00", "\(zone)") + } + + 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") + } +} From d13f4eb8f155656519e8a3b740d029e58112407c Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 09:06:11 -0600 Subject: [PATCH 10/32] Free a statement's bind array; it leaked on every parameterized statement MySQLStmt.clearBinds() freed paramBinds only when paramBindsOffset > 0, but reset() (through resetBinds) zeroes the offset, so a statement that was bound, executed and reset leaked its MYSQL_BIND array in deinit. Perfect-CRUD resets every statement after reading it. clearBinds() now frees the array whenever there is one, and prepare() clears the old array before allocating one for the new statement, so re-preparing doesn't leak either. Measured with `leaks` on a probe that prepares, binds two parameters, executes, reads and resets 500 statements (MallocStackLogging=1, macOS, Connector/C 3.4.9, MariaDB 11.8): - before: "500 leaks for 120000 total leaked bytes", every one a ROOT LEAK allocated in MySQLStmt.prepare(statement:); - after: "0 leaks for 0 total leaked bytes". New StatementResultTests.statementsRebindAfterResetAndReprepare covers reusing one statement 50 times and re-preparing it with more parameters (a test can't run `leaks` itself). Full suite passes under TZ=America/Denver against MariaDB 11.8 and MySQL 8.4. Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MySQLStmt.swift | 14 +++++----- Tests/MariaDBTests/StatementResultTests.swift | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/Sources/MariaDB/MySQLStmt.swift b/Sources/MariaDB/MySQLStmt.swift index 15d88ae..453af05 100644 --- a/Sources/MariaDB/MySQLStmt.swift +++ b/Sources/MariaDB/MySQLStmt.swift @@ -99,13 +99,12 @@ public final class MySQLStmt: @unchecked Sendable { } public func clearBinds() { - let count = paramBindsOffset - if count > 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 } public func freeResult() { mysql_stmt_free_result(ptr) } @@ -116,6 +115,7 @@ public final class MySQLStmt: @unchecked Sendable { let utf8Chars = query.utf8 let r = mysql_stmt_prepare(ptr, query, UInt(utf8Chars.count)) guard r == 0 else { return false } + clearBinds() // a statement prepared again gets a fresh bind array if let m = meta { mysql_free_result(m) } meta = mysql_stmt_result_metadata(ptr) let count = paramCount() diff --git a/Tests/MariaDBTests/StatementResultTests.swift b/Tests/MariaDBTests/StatementResultTests.swift index b2083eb..044e61c 100644 --- a/Tests/MariaDBTests/StatementResultTests.swift +++ b/Tests/MariaDBTests/StatementResultTests.swift @@ -78,6 +78,32 @@ struct StatementResultTests { #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) + } + // 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. From b295a424d2ca4133ddf17d8576dc24551f90258e Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 09:06:40 -0600 Subject: [PATCH 11/32] README: Dynamic API limits, the new MySQLOpt case, PKG_CONFIG_PATH wording - The Dynamic API section lists its limits: an inserted ID above Int64.max is nil; an UPDATE that changes nothing reports 0 affected rows; row-returning statements report their row count; TINYINT(1) reads as .int and YEAR as .string; statements run unprepared report 0 affected rows; only the first result set of a multi-result CALL is visible. - "Migrating from 3.x" lists the new MySQLOpt.MYSQL_OPT_SSL_MODE case as breaking for exhaustive switches. - PKG_CONFIG_PATH: only Swift 6.2 (Xcode 26.3) was seen to need it, so the README, README.zh_CN and the CI comment no longer claim Swift 6.3 does. - README.zh_CN's Package snippet adds the Perfect-CRUD dependency and product. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- README.md | 23 +++++++++++++++-------- README.zh_CN.md | 10 +++++++--- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0387e4..4d32b51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,7 @@ jobs: - name: Install client library run: | brew install mariadb-connector-c - # Keg-only; SwiftPM before Swift 6.4 doesn't find it through the brew provider alone. + # 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) diff --git a/README.md b/README.md index a6d9a11..5bab499 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ brew install mariadb-connector-c ``` 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 Swift 6.2 and 6.3 (Xcode 26.x), point -pkg-config at it, or the build fails with `'mysql.h' file not found`: +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`: ```bash export PKG_CONFIG_PATH="$(brew --prefix mariadb-connector-c)/lib/pkgconfig" @@ -97,10 +97,16 @@ Perfect-CRUD's Dynamic API (`select(_: DynamicQuery)`, `mutate(_:)`, `execute(_: 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 for statements that don't -return rows. 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. +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 @@ -136,8 +142,8 @@ of this. 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 Swift 6.2/6.3, set - `PKG_CONFIG_PATH` as described above. +- **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 @@ -162,6 +168,7 @@ release notes for its changes. `time_zone` (run the connection in UTC, e.g. `SET time_zone = '+00:00'`, if you need those to line up). The zero date (`0000-00-00`) reads as nil for an optional `Date`; for a non-optional one it's an error, which Perfect-CRUD's `select()` currently skips the row on. +- **`MySQLOpt` has a new case,** `MYSQL_OPT_SSL_MODE`, so an exhaustive `switch` over `MySQLOpt` needs a case for it. - **`connect()`** always passes `CLIENT_REMEMBER_OPTIONS`, so options survive a failed connection attempt. ## Further Information diff --git a/README.zh_CN.md b/README.zh_CN.md index 33f6989..bd0e9e5 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -55,7 +55,7 @@ brew install mariadb-connector-c ``` -Homebrew 以 keg-only 方式安装该库。使用 Swift 6.2 / 6.3(Xcode 26.x)时,请先设置: +Homebrew 以 keg-only 方式安装该库。Swift 6.4 可以直接找到它;使用更早的工具链时(例如 Xcode 26.3 中的 Swift 6.2),请先设置: ```bash export PKG_CONFIG_PATH="$(brew --prefix mariadb-connector-c)/lib/pkgconfig" @@ -78,9 +78,13 @@ $ pkg-config libmariadb --cflags --libs 请在您的 Package.swift 文件中增加以下内容: ```swift -.package(url: "https://github.com/PerfectlySoft/Perfect-MariaDB.git", from: "4.0.0") +.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.0"), ... -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` 导入。**破坏性变更:** 在 3.x 中该产品名为 `MariaDB`,请把 `.product(name: "MariaDB", ...)`(或直接写的 `"MariaDB"`)改为 `.product(name: "PerfectMariaDB", package: "Perfect-MariaDB")`。 From d42da2a748e3f3045ce29415a29562a6cc5e07c4 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 09:13:56 -0600 Subject: [PATCH 12/32] MySQLStmt.Results.forEachRow returns false when reading failed It always returned true, so a raw caller couldn't tell a result cut short by an error (see 0e39522) from a complete one. Same as Perfect-MySQL dda4fec. errorWhileReadingRowsThrows now also checks forEachRow on the failing query; the full suite passes under TZ=America/Denver against MariaDB 11.8 (debug) and MySQL 8.4 (release). Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MySQLStmt.swift | 3 ++- Tests/MariaDBTests/StatementResultTests.swift | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/MariaDB/MySQLStmt.swift b/Sources/MariaDB/MySQLStmt.swift index 453af05..f3c1bad 100644 --- a/Sources/MariaDB/MySQLStmt.swift +++ b/Sources/MariaDB/MySQLStmt.swift @@ -486,9 +486,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 { diff --git a/Tests/MariaDBTests/StatementResultTests.swift b/Tests/MariaDBTests/StatementResultTests.swift index 044e61c..cbf4654 100644 --- a/Tests/MariaDBTests/StatementResultTests.swift +++ b/Tests/MariaDBTests/StatementResultTests.swift @@ -74,6 +74,9 @@ struct StatementResultTests { #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]) } From b48903def0b7eaaf65874be18a44ba1d3c12a368 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 09:15:53 -0600 Subject: [PATCH 13/32] Decode DECIMAL columns into Double and Float properties MySQLStmt hands DECIMAL values over as text, and the row reader's decode(Double.self) / decode(Float.self) only accepted a Double / Float, so a Double or Float property over a DECIMAL column silently read 0.0. Both now also accept the other floating-point type and parse decimal text; text that doesn't parse throws instead of reading 0. A missing column still reads 0, as before. Ported from Perfect-MySQL f2c011c so the two connectors stay identical. New StatementResultTests.decimalColumnsDecodeIntoFloatingPoint (Perfect- MySQL's test): Double, Float and Double? properties over DECIMAL columns, including negative, fractional and NULL values. On the previous commit, against MariaDB 11.8, all three expectations fail ([0.0, 0.0], [0.0, 0.0], [nil, 0.0]); with the fix the full suite passes under TZ=America/Denver against MariaDB 11.8 and MySQL 8.4. Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MySQLCRUD.swift | 26 +++++++++++++++++-- Tests/MariaDBTests/StatementResultTests.swift | 20 ++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/Sources/MariaDB/MySQLCRUD.swift b/Sources/MariaDB/MySQLCRUD.swift index b268e42..d621e14 100644 --- a/Sources/MariaDB/MySQLCRUD.swift +++ b/Sources/MariaDB/MySQLCRUD.swift @@ -81,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)) ?? "" } diff --git a/Tests/MariaDBTests/StatementResultTests.swift b/Tests/MariaDBTests/StatementResultTests.swift index cbf4654..e70c9c5 100644 --- a/Tests/MariaDBTests/StatementResultTests.swift +++ b/Tests/MariaDBTests/StatementResultTests.swift @@ -107,6 +107,26 @@ struct StatementResultTests { #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]) + } + // 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. From a7f883e89bdd484b72af194766d209ca0041bd04 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 08:35:01 -0500 Subject: [PATCH 14/32] Throw an error a CALL raises after its first result set discardRemainingResults() stopped at the first mysql_stmt_next_result() that didn't return 0, so when a procedure's later statement failed (a duplicate key, error 1242, SIGNAL) the error was dropped and the CALL reported success with its first set's rows, through typed db.sql(_:_:) and DynamicSQL alike. Found by the final verification. discardRemainingResults() now returns the first error a later result reports: from mysql_stmt_next_result() > 0, or from a failing mysql_stmt_store_result (that result is freed and draining continues). hasNext() throws it as MySQLCRUDError after reset(), so the connection is reusable. deinit (a caller that stopped early) can only log it through CRUDLogging. MySQLStmt.currentError() (the statement's error, else the connection's) is shared with Results' failure capture. Same code as Perfect-MySQL 354664c. Limit, documented in README "Migrating from 3.x" with the other CALL notes: Perfect-CRUD's untyped db.sql("CALL p()") calls hasNext() once, so it can't report such an error. New StatementResultTests.errorAfterACallsFirstResultSetThrows: three procedures (duplicate key, 1242, SIGNAL) through typed sql and DynamicSQL, the untyped path, a query on the same connection after each, and a two-set CALL that succeeds. Before the fix all six error expectations fail against MariaDB 11.8; with it the full suite passes under TZ=America/Denver against MariaDB 11.8 and MySQL 8.4. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++ Sources/MariaDB/MySQLCRUD.swift | 12 +++++- Sources/MariaDB/MySQLStmt.swift | 39 +++++++++++++------ Tests/MariaDBTests/StatementResultTests.swift | 28 +++++++++++++ 4 files changed, 71 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5bab499..80b64b1 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,11 @@ release notes for its changes. `time_zone` (run the connection in UTC, e.g. `SET time_zone = '+00:00'`, if you need those to line up). The zero date (`0000-00-00`) reads as nil for an optional `Date`; for a non-optional one it's an error, which Perfect-CRUD's `select()` currently skips the row on. +- **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. - **`connect()`** always passes `CLIENT_REMEMBER_OPTIONS`, so options survive a failed connection attempt. diff --git a/Sources/MariaDB/MySQLCRUD.swift b/Sources/MariaDB/MySQLCRUD.swift index d621e14..aecab58 100644 --- a/Sources/MariaDB/MySQLCRUD.swift +++ b/Sources/MariaDB/MySQLCRUD.swift @@ -501,7 +501,11 @@ class MySQLStmtExeDelegate: SQLExeDelegate, @unchecked Sendable { // decoding error). Leave the connection ready for the next statement anyway. if results != nil { results = nil - statement.discardRemainingResults() + // 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() } } @@ -590,11 +594,15 @@ class MySQLStmtExeDelegate: SQLExeDelegate, @unchecked Sendable { 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. - statement.discardRemainingResults() + // 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 } diff --git a/Sources/MariaDB/MySQLStmt.swift b/Sources/MariaDB/MySQLStmt.swift index f3c1bad..358dc3d 100644 --- a/Sources/MariaDB/MySQLStmt.swift +++ b/Sources/MariaDB/MySQLStmt.swift @@ -164,16 +164,39 @@ public final class MySQLStmt: @unchecked Sendable { /// 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". - func discardRemainingResults() { + /// + /// 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) - while nextResult() == 0 { - if fieldCount() > 0 { - mysql_stmt_store_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() { @@ -335,13 +358,7 @@ public final class MySQLStmt: @unchecked Sendable { private func recordFailure() { failed = true - if stmt.errorCode() != 0 { - errorCode = stmt.errorCode() - errorMessage = stmt.errorMessage() - } else { - errorCode = stmt.connection.errorCode() - errorMessage = stmt.connection.errorMessage() - } + (errorCode, errorMessage) = stmt.currentError() } init(_ s: MySQLStmt) { diff --git a/Tests/MariaDBTests/StatementResultTests.swift b/Tests/MariaDBTests/StatementResultTests.swift index e70c9c5..ed0e571 100644 --- a/Tests/MariaDBTests/StatementResultTests.swift +++ b/Tests/MariaDBTests/StatementResultTests.swift @@ -160,6 +160,34 @@ struct StatementResultTests { #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 { From b22a5f0755d0f01d594df7059b01eafa6c2e4b08 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 08:47:05 -0500 Subject: [PATCH 15/32] Free blob parameter buffers on reset bindParam([UInt8]), bindParam([Int8]) and bindParam(_:length:) allocate a buffer for the value and bind it as MYSQL_TYPE_LONG_BLOB, but resetBinds() skipped that type, so every blob parameter leaked its whole value, including every Data / [UInt8] / [Int8] that Perfect-CRUD binds for an insert, update or where. resetBinds() now frees it (NULL stays a no-op). Same change as the verifier's prototype for Perfect-MySQL. Measured with `leaks` on a probe that prepares, binds three blob parameters ([UInt8] x32, [Int8] x16, 8 bytes via length:), executes, reads and resets 300 times (MallocStackLogging=1, macOS, Connector/C 3.4.9, MariaDB 11.8): - before: "895 leaks for 28672 total leaked bytes", the roots allocated in UnsafeMutableRawBufferPointer.allocate (the blob buffers); - after: "0 leaks for 0 total leaked bytes". d13f4eb fixed only the MYSQL_BIND array; its "now none" was about that array, not these buffers. New StatementResultTests.blobParametersSurviveResetAndReuse: one statement reused 50 times with blobs of every size from 0 to 49 bytes, checking the values come back (a test can't run `leaks`). Full suite passes under TZ=America/Denver against MariaDB 11.8 and MySQL 8.4. Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MySQLStmt.swift | 5 +++- Tests/MariaDBTests/StatementResultTests.swift | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/Sources/MariaDB/MySQLStmt.swift b/Sources/MariaDB/MySQLStmt.swift index 358dc3d..a89f2b6 100644 --- a/Sources/MariaDB/MySQLStmt.swift +++ b/Sources/MariaDB/MySQLStmt.swift @@ -88,7 +88,10 @@ public final class MySQLStmt: @unchecked Sendable { case MYSQL_TYPE_TINY.rawValue: bind.buffer.assumingMemoryBound(to: UInt8.self).deallocate() case MYSQL_TYPE_VAR_STRING.rawValue, MYSQL_TYPE_DATE.rawValue, MYSQL_TYPE_DATETIME.rawValue: bind.buffer.assumingMemoryBound(to: Int8.self).deallocate() - case MYSQL_TYPE_LONG_BLOB.rawValue, MYSQL_TYPE_NULL.rawValue: () + case MYSQL_TYPE_LONG_BLOB.rawValue: + // bindParam([UInt8]) / ([Int8]) / (_:length:) copy the value into this buffer. + bind.buffer?.deallocate() + case MYSQL_TYPE_NULL.rawValue: () default: assertionFailure("Unhandled MySQL type \(bind.buffer_type)") } if bind.length != nil { bind.length.deallocate() } diff --git a/Tests/MariaDBTests/StatementResultTests.swift b/Tests/MariaDBTests/StatementResultTests.swift index ed0e571..b16ef96 100644 --- a/Tests/MariaDBTests/StatementResultTests.swift +++ b/Tests/MariaDBTests/StatementResultTests.swift @@ -127,6 +127,33 @@ struct StatementResultTests { #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() + } + } + // 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. From 2d47ad170f623c081fa32088de743c7cd31cd1b7 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 08:48:58 -0500 Subject: [PATCH 16/32] Keep date fractions inside their second; truncate when writing '9999-12-31 23:59:59.999999' (a common "forever" value in DATETIME(6)) parsed as 10000-01-01 00:00:00: the fraction was added as a Double, and a Date (a Double count of seconds since 2001) can't hold six fractional digits near year 9999. mysqlFormatted() then wrote '10000-01-01 00:00:00', which the server rejects. The writer's DateFormatter also rounded, so '1969-12-31 23:59:59.999999' was written back as '1970-01-01 00:00:00'. Found by the final verification. Ported from Perfect-MySQL ce82e24; the date code is identical to its (indentation aside). The parser carries the fraction as whole microseconds and builds the Date with Date(mysqlSeconds:microseconds:), which keeps it inside its second; the writer floors to whole seconds. New DateTests.fractionsStayInsideTheirSecond (Perfect-MySQL's). On the previous commit it fails with 4 issues; with the fix the full suite passes under TZ=America/Denver against MariaDB 11.8 and MySQL 8.4. Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MySQLCRUD.swift | 41 ++++++++++++++++++++++++------ Tests/MariaDBTests/DateTests.swift | 14 ++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/Sources/MariaDB/MySQLCRUD.swift b/Sources/MariaDB/MySQLCRUD.swift index aecab58..9024eb9 100644 --- a/Sources/MariaDB/MySQLCRUD.swift +++ b/Sources/MariaDB/MySQLCRUD.swift @@ -738,19 +738,27 @@ extension Date { dateFormatter.calendar = mysqlUTCCalendar dateFormatter.timeZone = mysqlUTC dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - return dateFormatter.string(from: self) + // Whole seconds, truncated (toward the past): rounding could carry + // 9999-12-31 23:59:59.9999995 into a year-10000 string the server rejects. + let seconds = timeIntervalSinceReferenceDate.rounded(.down) + let ret = dateFormatter.string(from: Date(timeIntervalSinceReferenceDate: seconds)) + return ret } - + /// 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. Returns nil for anything else, including the /// zero date and out-of-range parts such as February 30. init?(fromMysqlFormatted string: String) { let chars = Array(string.utf8) func number(_ range: Range) -> Int? { - guard !range.isEmpty, range.upperBound <= chars.count else { return nil } + 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 } + guard chars[i] >= 48, chars[i] <= 57 else { + return nil + } value = value * 10 + Int(chars[i] - 48) } return value @@ -761,7 +769,7 @@ extension Date { return nil } var components = DateComponents(year: year, month: month, day: day, hour: 0, minute: 0, second: 0) - var fraction = 0.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: ":"), @@ -772,15 +780,32 @@ extension Date { components.minute = minute components.second = second if chars.count > 19 { - guard chars[19] == UInt8(ascii: "."), let digits = number(20..= 1, components.isValidDate(in: mysqlUTCCalendar), let date = mysqlUTCCalendar.date(from: components) else { return nil } - self = date.addingTimeInterval(fraction) + self.init(mysqlSeconds: date.timeIntervalSinceReferenceDate, microseconds: microseconds) + } + + /// A whole second plus a fraction kept inside that second. A Date is a Double count of seconds + /// since 2001, which near year 9999 can't hold six fractional digits: plain addition rounded + /// 9999-12-31 23:59:59.999999 up to year 10000. Such values become the largest Date still + /// inside the second. + init(mysqlSeconds seconds: TimeInterval, microseconds: Int) { + var interval = seconds + Double(microseconds) / 1_000_000 + if interval >= seconds + 1 { + interval = (seconds + 1).nextDown + } + self.init(timeIntervalSinceReferenceDate: interval) } } diff --git a/Tests/MariaDBTests/DateTests.swift b/Tests/MariaDBTests/DateTests.swift index 78232de..fc629a5 100644 --- a/Tests/MariaDBTests/DateTests.swift +++ b/Tests/MariaDBTests/DateTests.swift @@ -61,6 +61,20 @@ struct DateTests { #expect(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() { + 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(lastSecond?.mysqlFormatted() == "9999-12-31 23:59:59") + let beforeEpoch = Date(fromMysqlFormatted: "1969-12-31 23:59:59.999999") + #expect(beforeEpoch?.mysqlFormatted() == "1969-12-31 23:59:59") + #expect(beforeEpoch.map { abs($0.timeIntervalSince1970 + 0.000001) < 0.0000005 } == true) + #expect(Date(fromMysqlFormatted: "2023-11-14 22:13:20.000001")?.mysqlFormatted() == "2023-11-14 22:13:20") + #expect(Date(timeIntervalSince1970: -0.5).mysqlFormatted() == "1969-12-31 23:59:59") + #expect(Date(timeIntervalSinceReferenceDate: 721_692_800.9999).mysqlFormatted() == "2023-11-14 22:13:20") + } + struct DateRow: Codable, TableNameProvider { static let tableName = "date_row" let id: Int From 071bd83be028f2341700ba9caba0c6bd4926d6d7 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 08:49:55 -0500 Subject: [PATCH 17/32] Use the proleptic Gregorian calendar for dates, as MariaDB and MySQL do Date parsing and formatting went through Foundation's .gregorian calendar, which switches to the Julian calendar before 1582-10-15. So the valid dates 1582-10-05...14 didn't parse (decode threw "Invalid Date string", and Perfect-CRUD's select() dropped the row), and earlier dates became instants 10 or more days away from what the server's proleptic calendar means. Found by the final verification. Ported from Perfect-MySQL 1bffe69: reader and writer compute days from and to 1970-01-01 with Howard Hinnant's days_from_civil / civil_from_days, with explicit range checks; DateFormatter and Calendar are no longer used, so neither direction depends on the process locale, calendar or time zone. The date code is now identical to Perfect-MySQL's (checked by diffing the two blocks with whitespace removed). New tests, Perfect-MySQL's (the live one adapted to this package's setup): DateTests.datesUseTheProlepticGregorianCalendar (Unix times for 0001-01-01 ... 9999-12-31, the leap rule, every day from 1600 to 2000 round-tripping) and DateTests.oldDatesMatchTheServersCalendar (dates written through Perfect-CRUD read back with DATE_FORMAT as the same text). On the previous commit the first fails with 5 issues and the live one traps on a nil 1582-10-10; with the fix the full suite passes under TZ=America/Denver against MariaDB 11.8 and MySQL 8.4. Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MySQLCRUD.swift | 89 +++++++++++++++++++++--------- Tests/MariaDBTests/DateTests.swift | 48 ++++++++++++++++ 2 files changed, 110 insertions(+), 27 deletions(-) diff --git a/Sources/MariaDB/MySQLCRUD.swift b/Sources/MariaDB/MySQLCRUD.swift index 9024eb9..793ed06 100644 --- a/Sources/MariaDB/MySQLCRUD.swift +++ b/Sources/MariaDB/MySQLCRUD.swift @@ -723,26 +723,63 @@ public struct MySQLDatabaseConfiguration: DatabaseConfigurationProtocol, @unchec // 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. -private let mysqlUTC = TimeZone(identifier: "UTC")! +// +// 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 + } +} -private let mysqlUTCCalendar: Calendar = { - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = mysqlUTC - return calendar -}() +/// 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() -> String { - let dateFormatter = DateFormatter() - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - dateFormatter.calendar = mysqlUTCCalendar - dateFormatter.timeZone = mysqlUTC - dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - // Whole seconds, truncated (toward the past): rounding could carry - // 9999-12-31 23:59:59.9999995 into a year-10000 string the server rejects. - let seconds = timeIntervalSinceReferenceDate.rounded(.down) - let ret = dateFormatter.string(from: Date(timeIntervalSinceReferenceDate: seconds)) - return ret + let seconds = Int(timeIntervalSinceReferenceDate.rounded(.down)) + mysqlReferenceOffset + let days = seconds >= 0 ? seconds / 86400 : (seconds - 86399) / 86400 + let secondOfDay = seconds - days * 86400 + let (year, month, day) = mysqlCivilFromDays(days) + 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`, @@ -765,20 +802,21 @@ extension Date { } 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) else { + let year = number(0..<4), let month = number(5..<7), let day = number(8..<10), + year >= 1, (1...12).contains(month), + (1...mysqlDaysInMonth(year: year, month: month)).contains(day) else { return nil } - var components = DateComponents(year: year, month: month, day: day, hour: 0, minute: 0, second: 0) + 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) else { + let hour = number(11..<13), let minute = number(14..<16), let second = number(17..<19), + hour < 24, minute < 60, second < 60 else { return nil } - components.hour = hour - components.minute = minute - components.second = second + secondOfDay = hour * 3600 + minute * 60 + second if chars.count > 19 { guard chars[19] == UInt8(ascii: "."), let digits = number(20..= 1, components.isValidDate(in: mysqlUTCCalendar), - let date = mysqlUTCCalendar.date(from: components) else { - return nil - } - self.init(mysqlSeconds: date.timeIntervalSinceReferenceDate, microseconds: microseconds) + let seconds = mysqlDaysFromCivil(year: year, month: month, day: day) * 86400 + secondOfDay + self.init(mysqlSeconds: TimeInterval(seconds - mysqlReferenceOffset), microseconds: microseconds) } /// A whole second plus a fraction kept inside that second. A Date is a Double count of seconds diff --git a/Tests/MariaDBTests/DateTests.swift b/Tests/MariaDBTests/DateTests.swift index fc629a5..f77d61d 100644 --- a/Tests/MariaDBTests/DateTests.swift +++ b/Tests/MariaDBTests/DateTests.swift @@ -75,6 +75,54 @@ struct DateTests { #expect(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() { + 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(date?.mysqlFormatted() == text + " 00:00:00", "\(text)") + } + #expect(Date(fromMysqlFormatted: "1900-02-29") == nil) + #expect(Date(fromMysqlFormatted: "1600-02-29") != nil) + #expect(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 = day.mysqlFormatted() + #expect(Date(fromMysqlFormatted: text) == day, "\(text)") + if Date(fromMysqlFormatted: text) != day { break } + day = day.addingTimeInterval(86_400) + } + #expect(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 { $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") + } + struct DateRow: Codable, TableNameProvider { static let tableName = "date_row" let id: Int From 30bdf0445cc3ea64daf37d0874d63d270c348e99 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 08:51:37 -0500 Subject: [PATCH 18/32] Detach result binds before freeing them, as Perfect-MySQL does Perfect-MySQL 376a1c3 found that reusing a MySQLStmt (execute, read results, reset, execute again) corrupted the heap with libmysqlclient: mysql_stmt_bind_result keeps a copy of the MYSQL_BINDs, with their buffer / length / is_null pointers, until the next bind_result, and Results.deinit freed those buffers before the next execute wrote through the copy. Results.deinit now rebinds the statement to MYSQL_TYPE_NULL binds with no pointers before freeing its buffers. Same code as Perfect-MySQL; Results.init already zero-initialised its binds here. Not reproduced with Connector/C: a 300-iteration reuse probe (int, double, string and blob parameters, each row checked) runs clean before and after this change, with the system allocator and under Guard Malloc. This is ported so the shared code stays identical and so it doesn't depend on Connector/C not writing through the stale copy. New StatementResultTests.aStatementCanBeReusedAfterReadingResults (Perfect-MySQL's test; LENGTH() is compared as any integer, since it's an INT on MariaDB and a BIGINT on MySQL). Passes before and after here; the full suite passes under TZ=America/Denver against MariaDB 11.8 and MySQL 8.4. Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MySQLStmt.swift | 14 +++++++++++ Tests/MariaDBTests/StatementResultTests.swift | 24 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/Sources/MariaDB/MySQLStmt.swift b/Sources/MariaDB/MySQLStmt.swift index a89f2b6..59189ba 100644 --- a/Sources/MariaDB/MySQLStmt.swift +++ b/Sources/MariaDB/MySQLStmt.swift @@ -385,6 +385,20 @@ public final class MySQLStmt: @unchecked Sendable { } 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. Point it at binds that use its own storage before the + // buffers below are freed; otherwise reusing a statement corrupted the heap. + if numFields > 0 { + let detached = UnsafeMutablePointer.allocate(capacity: numFields) + for i in 0.. Date: Tue, 13 Oct 2026 08:52:26 -0500 Subject: [PATCH 19/32] Read [Int8] blobs with Int8(bitPattern:); fixes a trap on negative bytes The row reader decoded a blob into [Int8] with `.map { Int8($0) }`, which traps ("Not enough bits to represent the passed value") on any byte >= 0x80, so reading back a negative [Int8] value crashed the process. It now uses Int8(bitPattern:). Perfect-MySQL has the same line and is fixed identically. New StatementResultTests.signedByteArraysRoundTrip: rows holding X'FF80007F01' and X'' read into an [Int8] property as [-1, -128, 0, 127, 1] and []. The rows are written with SQL, because Perfect-CRUD 3.0.0 itself traps encoding a negative [Int8] (`UInt8($0)` in CodingBindings; fixed in Perfect-CRUD 3.0.1), so a write through CRUD needs 3.0.1. On the previous commit the test traps; with the fix the full suite passes under TZ=America/Denver against MariaDB 11.8 and MySQL 8.4. Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MySQLCRUD.swift | 2 +- Tests/MariaDBTests/StatementResultTests.swift | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Sources/MariaDB/MySQLCRUD.swift b/Sources/MariaDB/MySQLCRUD.swift index 793ed06..38bfec7 100644 --- a/Sources/MariaDB/MySQLCRUD.swift +++ b/Sources/MariaDB/MySQLCRUD.swift @@ -118,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]) ?? [] diff --git a/Tests/MariaDBTests/StatementResultTests.swift b/Tests/MariaDBTests/StatementResultTests.swift index e83f04f..a3ad37e 100644 --- a/Tests/MariaDBTests/StatementResultTests.swift +++ b/Tests/MariaDBTests/StatementResultTests.swift @@ -178,6 +178,25 @@ struct StatementResultTests { } } + // 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 rows are written with SQL here: Perfect-CRUD + // 3.0.0 itself traps encoding a negative [Int8] (fixed in Perfect-CRUD 3.0.1). + @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)) + } + // 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. From 15044f82f3c2696a389e4c69ccd4023b905e7016 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 08:59:57 -0500 Subject: [PATCH 20/32] Refuse listing wildcards that big5 escaping can't make safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On x86_64, libmariadb 3.3 (Ubuntu 24.04's libmariadb3 1:10.11.14-0ubuntu0.24.04.1) escapes the UTF-8 bytes of "中'" (E4 B8 AD 27) under big5 as E4 B8 AD 5C 27; the server reads AD 5C as one big5 character, so the quote ends the string literal. With CLIENT_MULTI_STATEMENTS, ListingWildcardTests.wildcardCannotRunAnotherStatement saw "中'; DROP TABLE wild_victim; -- " drop the table through both listTables and listDatabases: reproduced in swift:6.4-noble --arch amd64 against MariaDB 11.8 (2 issues), and it turned all four Linux CI jobs of PR #26 red. arm64 escapes it as E4 B8 5C AD 5C 27, which is safe. listNames now refuses (returns [], the existing failure value, so the API doesn't change) a wildcard that has a byte >= 0x80 and a `'` or `\` while the connection charset is big5, cp932, gbk, gb18030 or sjis, the charsets where 0x5C can be a multibyte trail byte. SHOW ... LIKE stays: information_schema would change case sensitivity and depend on lower_case_table_names. The guard and listNames are the same code as Perfect-MySQL 1d17314 (handle name aside), and the doc comments on listTables / listDatabases say what's refused. Tests: - wildcardCannotRunAnotherStatement now covers utf8mb4, latin1, gbk, big5, cp932, sjis and gb18030, with two more payloads (`中\'` and a halfwidth katakana before the quote). gbk and utf8mb4 keep their expectations: the DROP never runs and the list is empty. - New unsafeWildcardsAreRefusedOnlyUnderTrailByteCharsets checks the guard's exact conditions, and that `it's` / `it%` still list under big5. MariaDB 11.8 has no gb18030, so the client stays on utf8mb4 there and nothing is refused. ListingWildcardTests pass on Linux --arch amd64 (debug and release) against MariaDB 11.8 and MySQL 8.4; the full suite passes on macOS under TZ=America/Denver against both. Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MariaDB.swift | 42 +++++++++++++---- Tests/MariaDBTests/ListingWildcardTests.swift | 45 +++++++++++++++++-- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/Sources/MariaDB/MariaDB.swift b/Sources/MariaDB/MariaDB.swift index 7734435..0d3384e 100644 --- a/Sources/MariaDB/MariaDB.swift +++ b/Sources/MariaDB/MariaDB.swift @@ -181,14 +181,35 @@ public final class MySQL: @unchecked Sendable { return mysql_select_db(self.ptr!, namd) == 0 } + /// 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 wildcard 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, /// except on a MySQL server in `NO_BACKSLASH_ESCAPES` mode, where it is an ordinary character). + /// Returns [] on an error, and for a wildcard refused by `listingWildcardIsUnsafe`. public func listDatabases(wildcard wild: String? = nil) -> [String] { return listNames("SHOW DATABASES", like: wild) } @@ -202,12 +223,17 @@ public final class MySQL: @unchecked Sendable { // 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. private func listNames(_ show: String, like wild: String?) -> [String] { + guard !listingWildcardIsUnsafe(wild) else { + return [] + } var query = Array(show.utf8) - if let wild = wild { + if let 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 [] } + guard length != UInt.max else { + return [] + } query += Array(" LIKE '".utf8) query += escaped[0.. [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) @@ -90,4 +95,38 @@ 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"]) + } } From 63bc6d0e616ed02558187ea91ee403e61b9850a9 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 09:00:24 -0500 Subject: [PATCH 21/32] README: read errors, DECIMAL, dates, wildcards and [Int8] in "Migrating from 3.x" "Migrating from 3.x" gains the behaviour changes it was missing: errors while reading rows throw and MySQLStmt.Results has failed / errorCode / errorMessage; DECIMAL decodes into Double and Float; dates use the proleptic Gregorian calendar and keep fractions inside their second; listTables / listDatabases refuse wildcards escaping can't make safe under big5, cp932, gbk, gb18030 and sjis; [Int8] reads negative bytes. The CALL item (rows readable; later errors thrown except through untyped db.sql("CALL ...")) was added with the CALL-error fix. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 80b64b1..9691401 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,12 @@ release notes for its changes. 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 was silently dropped. Dates are now parsed as UTC, and `DATETIME(6)` fractions and `DATE` @@ -167,13 +173,18 @@ release notes for its changes. 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). The zero date (`0000-00-00`) reads as nil for an optional `Date`; for a non-optional one it's an error, which Perfect-CRUD's - `select()` currently skips the row on. + `select()` currently skips the row on. Dates use the proleptic Gregorian calendar, as the server does, so + 1582-10-05...14 parse and older dates are no longer 10 or more days off; fractions stay inside their second, and + writing truncates to whole seconds (so `9999-12-31 23:59:59.999999` no longer becomes year 10000). - **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`** escape their wildcard, and under big5, cp932, gbk, gb18030 or sjis refuse (return + `[]` for) a wildcard with a non-ASCII byte and a `'` or `\`, which escaping can't make safe there. +- **`[Int8]` properties** read negative bytes instead of trapping (writing them needs Perfect-CRUD 3.0.1). - **`connect()`** always passes `CLIENT_REMEMBER_OPTIONS`, so options survive a failed connection attempt. ## Further Information From da6117c42dfd20af810864011b2dd8985374c21c Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 09:29:37 -0500 Subject: [PATCH 22/32] Detach only the Results the statement is bound to, with its column count 30bdf04 made Results.deinit rebind the statement to pointer-free binds before freeing its buffers. Two regressions, found by the pre-push verification (F1): - It detached whatever the statement was bound to, including a newer Results. `r = stmt.results()` builds the new Results before the old one is released, so the old one's deinit unbound the new one and every row read 0 / "". Walking a CALL's result sets with one variable failed the same way. - It allocated numFields binds, but mysql_stmt_bind_result copies the statement's *current* field count. When a Results outlived nextResult() to a wider set, or a re-prepare with more columns, that read past the allocation (SIGSEGV under Guard Malloc). Not reachable through Perfect-CRUD; reachable through the public MySQLStmt / Results API. Ported byte-identical from Perfect-MySQL e7e401a: the statement records which Results it's bound to (resultOwner, set in bind()); deinit detaches only if it's still the owner, with Int(stmt.fieldCount()) binds, skips it when that's 0, and clears the owner. bind() no longer calls mysql_stmt_bind_result for a result with no columns. Tests (StatementResultTests, Perfect-MySQL's, adapted to this package's setup; integers compared as any integer type): - reassigningAResultsVariableKeepsTheNewOneBound: on the previous commit 150 issues against both servers. - olderResultsDontDisturbLaterResultSets: on the previous commit it crashes the test process. The verifier's reassign and outlive probes under Guard Malloc (banner checked), against MariaDB 11.8 and MySQL 8.4: clean, 0 failures. Full suite passes under TZ=America/Denver against both. Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MySQLStmt.swift | 34 ++++++--- Tests/MariaDBTests/StatementResultTests.swift | 73 +++++++++++++++++++ 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/Sources/MariaDB/MySQLStmt.swift b/Sources/MariaDB/MySQLStmt.swift index 59189ba..10333da 100644 --- a/Sources/MariaDB/MySQLStmt.swift +++ b/Sources/MariaDB/MySQLStmt.swift @@ -8,6 +8,8 @@ public final class MySQLStmt: @unchecked Sendable { private var paramBinds: UnsafeMutablePointer? private var paramBindsOffset = 0 var meta: UnsafeMutablePointer? + /// The Results whose buffers the client library is currently bound to, if any. + var resultOwner: ObjectIdentifier? public init(_ mysql: MySQL) { connection = mysql @@ -387,17 +389,23 @@ public final class MySQLStmt: @unchecked Sendable { 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. Point it at binds that use its own storage before the - // buffers below are freed; otherwise reusing a statement corrupted the heap. - if numFields > 0 { - let detached = UnsafeMutablePointer.allocate(capacity: numFields) - for i in 0.. 0 { + let detached = UnsafeMutablePointer.allocate(capacity: count) + for i in 0.. 0 else { + return + } mysql_stmt_bind_result(stmt.ptr, binds) + stmt.resultOwner = ObjectIdentifier(self) } private func unbind() { diff --git a/Tests/MariaDBTests/StatementResultTests.swift b/Tests/MariaDBTests/StatementResultTests.swift index a3ad37e..a081b26 100644 --- a/Tests/MariaDBTests/StatementResultTests.swift +++ b/Tests/MariaDBTests/StatementResultTests.swift @@ -197,6 +197,79 @@ struct StatementResultTests { #expect(read.map(\.bytes) == written.map(\.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) + } + // 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. From 48b22daa1f8210d9803e9d6e868ff6ad7e2b2839 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 09:30:50 -0500 Subject: [PATCH 23/32] Refetch result metadata after each execute (Connector/C use-after-free) Re-executing one prepared CALL statement through the raw API read freed memory: MySQLStmt caches mysql_stmt_result_metadata, its field pointers point into storage that libmariadb replaces when it executes the statement again, and Results.bind read them. Found by the pre-push verification (P2). Ported byte-identical from Perfect-MySQL bc8700b: execute() frees the cached metadata after every successful execution, so it's fetched afresh (Results.init and fieldNames() already refresh it); fieldInfo(index:) refreshes it too, and rejects negative indexes. Checks: - A `callrepeat` probe (one prepared CALL executed 20 times with different arguments, reading fieldInfo and every row), under Guard Malloc with the banner checked: SIGSEGV (exit 139) against both MariaDB 11.8 and MySQL 8.4 on the previous commit; clean, 0 failures, with this one. The reassign / outlive / reuse probes stay clean. - New StatementResultTests.aPreparedCallCanBeExecutedRepeatedly (Perfect- MySQL's, adapted): the same loop plus a statement afterwards. It passes with the system allocator before and after; the bug shows under Guard Malloc. Full suite passes under TZ=America/Denver against both. Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MySQLStmt.swift | 21 ++++++++++++-- Tests/MariaDBTests/StatementResultTests.swift | 29 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/Sources/MariaDB/MySQLStmt.swift b/Sources/MariaDB/MySQLStmt.swift index 10333da..eba83e6 100644 --- a/Sources/MariaDB/MySQLStmt.swift +++ b/Sources/MariaDB/MySQLStmt.swift @@ -46,8 +46,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", @@ -136,7 +141,17 @@ public final class MySQLStmt: @unchecked Sendable { 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) } diff --git a/Tests/MariaDBTests/StatementResultTests.swift b/Tests/MariaDBTests/StatementResultTests.swift index a081b26..a65f9a3 100644 --- a/Tests/MariaDBTests/StatementResultTests.swift +++ b/Tests/MariaDBTests/StatementResultTests.swift @@ -270,6 +270,35 @@ struct StatementResultTests { #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())") + } + // 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. From e58e2748a6a982720857d96ca92e6c2eabc53e8a Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 09:31:46 -0500 Subject: [PATCH 24/32] Refuse execute() unless every parameter is bound After reset() the parameter buffers are freed, but the client library keeps the previous execution's MYSQL_BINDs; execute() only rebound when something had been bound, so calling it again without rebinding sent freed memory (a string parameter came back as garbage; SIGSEGV under Guard Malloc). Binding fewer values than the statement has parameters left zeroed binds for the rest. Predates this release; b22a5f0 (freeing blob buffers on reset) extended the window to blobs. Found by the pre-push verification (F2). Ported byte-identical from Perfect-MySQL 75b1359: execute() returns false unless the number of bound values equals paramCount(); errorCode() / errorMessage() then report 2031 CR_PARAMS_NOT_BOUND "No data supplied for parameters in prepared statement" until the next prepare() or execute(). Statements without parameters are unaffected, and Perfect-CRUD always binds every parameter. Checks: - New StatementResultTests.executingWithoutBindingEveryParameterIsRefused (Perfect-MySQL's, adapted): on the previous commit 3 issues. - The verifier's reset-execute probe under Guard Malloc (banner checked): SIGSEGV (exit 139) against MariaDB 11.8 and MySQL 8.4 before; clean after. Full suite passes under TZ=America/Denver against both. Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MySQLStmt.swift | 17 ++++++++- Tests/MariaDBTests/StatementResultTests.swift | 37 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/Sources/MariaDB/MySQLStmt.swift b/Sources/MariaDB/MySQLStmt.swift index eba83e6..7aa70fa 100644 --- a/Sources/MariaDB/MySQLStmt.swift +++ b/Sources/MariaDB/MySQLStmt.swift @@ -10,6 +10,9 @@ public final class MySQLStmt: @unchecked Sendable { 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 @@ -118,10 +121,11 @@ public final class MySQLStmt: @unchecked Sendable { } 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)) guard r == 0 else { return false } @@ -138,6 +142,15 @@ public final class MySQLStmt: @unchecked Sendable { } public func execute() -> 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() + guard paramBindsOffset == count else { + wrapperError = (2031, "No data supplied for parameters in prepared statement") // CR_PARAMS_NOT_BOUND + return false + } if paramBindsOffset > 0 { guard let paramBinds = self.paramBinds, 0 == mysql_stmt_bind_param(ptr, paramBinds) else { return false } } diff --git a/Tests/MariaDBTests/StatementResultTests.swift b/Tests/MariaDBTests/StatementResultTests.swift index a65f9a3..d79d8f3 100644 --- a/Tests/MariaDBTests/StatementResultTests.swift +++ b/Tests/MariaDBTests/StatementResultTests.swift @@ -299,6 +299,43 @@ struct StatementResultTests { #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()) + } + // 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. From 364364c8d72f5255f0c6b9ddddced015e607a8de Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 09:33:14 -0500 Subject: [PATCH 25/32] Accept year 0 so Date.distantPast round-trips; refuse unstorable Dates 071bd83 switched dates to the proleptic Gregorian calendar, where Date.distantPast is 0000-12-30. The writer emitted that and the servers stored it, but the reader rejected year < 1. Perfect-CRUD's SelectIterator ends iteration at the first row that fails to decode, so every row after it vanished silently. And mysqlFormatted() trapped in Int(_:) on a non-finite Date (F4). Found by the pre-push verification. Ported byte-identical from Perfect-MySQL 7221f26 (the date code is again identical to Perfect-MySQL's, checked by diffing with whitespace removed): - The reader accepts year 0 (month and day 0, the zero date, are still rejected). Year 0 is a leap year, as in MySQL. - mysqlFormatted() throws instead of trapping on a Date it can't represent: non-finite, or outside years 0...9999. The .date binding passes the error up, so the insert or update throws. README: the Dates bullet now matches Perfect-MySQL's: dates before 1582-10-15 written by 3.x read back 2 or more days different, year 0 and the thrown error are documented, and "select() skips the row" is corrected: it stops there and drops every later row. Tests (DateTests, Perfect-MySQL's): sentinelDatesRoundTrip and sentinelDatesDontEndASelect (live, adapted); the existing date tests now `try` mysqlFormatted(). A `dates` probe (ids 0...5 with distantPast at 1 and distantFuture at 3, through Perfect-CRUD, TZ=America/Denver) reads back only id 0 on the previous commit and all six with this one, against MariaDB 11.8 and MySQL 8.4. Full suite passes against both. Co-Authored-By: Claude Opus 5.5 --- README.md | 23 ++++++---- Sources/MariaDB/MySQLCRUD.swift | 22 +++++++--- Tests/MariaDBTests/DateTests.swift | 67 +++++++++++++++++++++++------- 3 files changed, 83 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 9691401..b6497e2 100644 --- a/README.md +++ b/README.md @@ -167,15 +167,20 @@ release notes for its changes. 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 was silently dropped. Dates are now parsed as UTC, and `DATETIME(6)` fractions and `DATE` - values are read too (they used to drop the row). Rows written through Perfect-CRUD read back correctly with no - migration. What does shift: apps that compensated for the old offset by hand, and 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). The zero - date (`0000-00-00`) reads as nil for an optional `Date`; for a non-optional one it's an error, which Perfect-CRUD's - `select()` currently skips the row on. Dates use the proleptic Gregorian calendar, as the server does, so - 1582-10-05...14 parse and older dates are no longer 10 or more days off; fractions stay inside their second, and - writing truncates to whole seconds (so `9999-12-31 23:59:59.999999` no longer becomes year 10000). + 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. 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 diff --git a/Sources/MariaDB/MySQLCRUD.swift b/Sources/MariaDB/MySQLCRUD.swift index 38bfec7..fe4ea24 100644 --- a/Sources/MariaDB/MySQLCRUD.swift +++ b/Sources/MariaDB/MySQLCRUD.swift @@ -619,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() @@ -769,11 +769,20 @@ 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() -> String { - let seconds = Int(timeIntervalSinceReferenceDate.rounded(.down)) + mysqlReferenceOffset + 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 @@ -783,8 +792,9 @@ extension Date { } /// 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. Returns nil for anything else, including the - /// zero date and out-of-range parts such as February 30. + /// 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 chars = Array(string.utf8) func number(_ range: Range) -> Int? { @@ -803,7 +813,7 @@ extension Date { 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), - year >= 1, (1...12).contains(month), + (1...12).contains(month), (1...mysqlDaysInMonth(year: year, month: month)).contains(day) else { return nil } diff --git a/Tests/MariaDBTests/DateTests.swift b/Tests/MariaDBTests/DateTests.swift index f77d61d..4d6d0cf 100644 --- a/Tests/MariaDBTests/DateTests.swift +++ b/Tests/MariaDBTests/DateTests.swift @@ -44,7 +44,7 @@ struct DateTests { return calendar.date(from: DateComponents(year: y, month: mo, day: d, hour: h, minute: mi, second: s))! } - @Test func parsesMySQLTextAsUTC() { + @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. @@ -58,26 +58,26 @@ struct DateTests { "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(Self.utc(2026, 3, 8, 2, 30).mysqlFormatted() == "2026-03-08 02:30:00", "\(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() { + @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(lastSecond?.mysqlFormatted() == "9999-12-31 23:59:59") + #expect(try lastSecond?.mysqlFormatted() == "9999-12-31 23:59:59") let beforeEpoch = Date(fromMysqlFormatted: "1969-12-31 23:59:59.999999") - #expect(beforeEpoch?.mysqlFormatted() == "1969-12-31 23:59:59") + #expect(try beforeEpoch?.mysqlFormatted() == "1969-12-31 23:59:59") #expect(beforeEpoch.map { abs($0.timeIntervalSince1970 + 0.000001) < 0.0000005 } == true) - #expect(Date(fromMysqlFormatted: "2023-11-14 22:13:20.000001")?.mysqlFormatted() == "2023-11-14 22:13:20") - #expect(Date(timeIntervalSince1970: -0.5).mysqlFormatted() == "1969-12-31 23:59:59") - #expect(Date(timeIntervalSinceReferenceDate: 721_692_800.9999).mysqlFormatted() == "2023-11-14 22:13:20") + #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() { + @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), @@ -91,20 +91,20 @@ struct DateTests { for (text, unix) in cases { let date = Date(fromMysqlFormatted: text) #expect(date?.timeIntervalSince1970 == unix, "\(text)") - #expect(date?.mysqlFormatted() == text + " 00:00:00", "\(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(Date(fromMysqlFormatted: "1000-03-01 12:34:56")?.mysqlFormatted() == "1000-03-01 12:34:56") + #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 = day.mysqlFormatted() + let text = try day.mysqlFormatted() #expect(Date(fromMysqlFormatted: text) == day, "\(text)") if Date(fromMysqlFormatted: text) != day { break } day = day.addingTimeInterval(86_400) } - #expect(day.mysqlFormatted() == "2000-01-01 00:00:00") + #expect(try day.mysqlFormatted() == "2000-01-01 00:00:00") } // The server agrees: dates written through Perfect-CRUD land on the same calendar day. @@ -118,11 +118,50 @@ struct DateTests { 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 { $0.d.mysqlFormatted() } + 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 From 0b63465a8dbbe0b4c61645856d3874325c7019bd Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 09:37:47 -0500 Subject: [PATCH 26/32] Listing wildcards: double quotes by hand; the server's mode bit can be stale listNames escaped the whole wildcard with mysql_real_escape_string, which picks backslash-escaping or quote-doubling from the NO_BACKSLASH_ESCAPES bit in the server's last status. After a stored routine (procedure or function) changes sql_mode, both MariaDB 11.8 and MySQL 8.4 leave that bit as the routine set it while the session's sql_mode is restored, and it stays stale across later statements. So the client escaped for the wrong mode and, with CLIENT_MULTI_STATEMENTS, `x'; DROP TABLE victim; -- ` (session in NBE, routine clears it) or `x\'; DROP TABLE victim; -- ` (routine sets NBE) ran the DROP. Found by the pre-push verification (F3): its stale-nbe probe injected 6 times and its wildcard probe twice per server, on macOS (Connector/C 3.4.9) and Linux amd64 (libmariadb 3.3). A `'` is no longer left to the library: when the wildcard has one, the pattern is copied with each `'` doubled (`''` is a quote inside a literal in either mode, and 0x27 is never part of a multibyte character), and a wildcard that also has a `\` is refused (returns []), since its escaping depends on the mode. A wildcard without `'` is escaped by mysql_real_escape_string as before; escaping it for the wrong mode can't end the literal. The big5/cp932/gbk/gb18030/sjis guard stays. Doc comments on listTables / listDatabases say what's refused. This goes beyond Perfect-MySQL 7420e31, which only made escaping work under NO_BACKSLASH_ESCAPES with libmysqlclient (on libmariadb, mysql_real_escape_string already did, so that change has no counterpart here) and still trusts the mode bit; Perfect-MySQL needs this change too. Tests (ListingWildcardTests): - wildcardsWorkUnderNoBackslashEscapes (Perfect-MySQL's, adapted; utf8mb4 and big5): vic%, a name with `'` and the schema match under NBE, three payloads stay inert. Passes before and after here. - wildcardsStaySafeWhenARoutineLeavesTheModeBitStale: three routine / payload scenarios. On the previous commit 4 issues (two DROPs ran) on both servers; passes with the fix. The stale-nbe and wildcard probes under Guard Malloc (banner checked): 0 failures on both servers. Full suite passes under TZ=America/Denver. Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MariaDB.swift | 56 +++++++++++++----- Tests/MariaDBTests/ListingWildcardTests.swift | 59 +++++++++++++++++++ 2 files changed, 99 insertions(+), 16 deletions(-) diff --git a/Sources/MariaDB/MariaDB.swift b/Sources/MariaDB/MariaDB.swift index 0d3384e..c4af41b 100644 --- a/Sources/MariaDB/MariaDB.swift +++ b/Sources/MariaDB/MariaDB.swift @@ -202,41 +202,65 @@ public final class MySQL: @unchecked Sendable { /// 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 wildcard refused by `listingWildcardIsUnsafe`. + /// Returns [] on an error, for a wildcard refused by `listingWildcardIsUnsafe`, and for a + /// wildcard containing both `'` and `\`. 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, /// except on a MySQL server in `NO_BACKSLASH_ESCAPES` mode, where it is an ordinary character). - /// Returns [] on an error, and for a wildcard refused by `listingWildcardIsUnsafe`. + /// Returns [] on an error, for a wildcard refused by `listingWildcardIsUnsafe`, and for a + /// wildcard containing both `'` and `\`. 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. + // ended the string literal. The query is built here instead. A `'` is doubled by hand (and a + // wildcard with both `'` and `\` refused); anything else is escaped by mysql_real_escape_string, + // which follows the connection's character set and the NO_BACKSLASH_ESCAPES mode as libmariadb + // last saw them. That mode bit can be stale after a stored routine changes sql_mode, which is + // why quotes aren't left to it. (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. private func listNames(_ show: String, like wild: String?) -> [String] { guard !listingWildcardIsUnsafe(wild) else { return [] } var query = Array(show.utf8) if let 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 [] - } + let bytes = Array(wild.utf8) + let quote = UInt8(ascii: "'"), backslash = UInt8(ascii: "\\") query += Array(" LIKE '".utf8) - query += escaped[0.. Date: Tue, 13 Oct 2026 09:38:01 -0500 Subject: [PATCH 27/32] README: how listing wildcards are made safe; statement reuse and execute() - The listTables / listDatabases item no longer says the wildcard is simply "escaped": a `'` is doubled by hand, the rest is escaped by the client library, and wildcards with both `'` and `\`, or with a non-ASCII byte and `'`/`\` under big5, cp932, gbk, gb18030 or sjis, are refused. Wildcards keep working under NO_BACKSLASH_ESCAPES. - New "Statements can be reused safely" item: replaced or outliving Results, re-executing a prepared CALL, execute() refusing (2031) unless every parameter is bound, and the parameter leaks. The "select() stops, not skips" correction went in with 364364c. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b6497e2..3e6dae1 100644 --- a/README.md +++ b/README.md @@ -187,8 +187,17 @@ release notes for its changes. `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`** escape their wildcard, and under big5, cp932, gbk, gb18030 or sjis refuse (return - `[]` for) a wildcard with a non-ASCII byte and a `'` or `\`, which escaping can't make safe there. +- **`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. + Blob parameters (`[UInt8]`, `[Int8]`, `bindParam(_:length:)`) and the parameter array no longer leak. - **`[Int8]` properties** read negative bytes instead of trapping (writing them needs Perfect-CRUD 3.0.1). - **`connect()`** always passes `CLIENT_REMEMBER_OPTIONS`, so options survive a failed connection attempt. From 870cecd1cdde12fce5f73a60f1569b6ca8b99f95 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 09:45:56 -0500 Subject: [PATCH 28/32] Make listNames identical to Perfect-MySQL's (5c9c5fd, 7420e31) Perfect-MySQL landed the same stale-NO_BACKSLASH_ESCAPES fix as 0b63465 (5c9c5fd), on top of its perfect_mysql_escape_string_quote shim (7420e31). To keep the shared code identical: - mariadb.h gains perfect_mysql_escape_string_quote with Perfect-MySQL's signature. This package only builds against Connector/C, which has no _quote variant, so it calls mysql_real_escape_string, which already doubles quotes under NO_BACKSLASH_ESCAPES. - listNames and its comment are now Perfect-MySQL's, apart from the connection handle (self.ptr! here, mysqlPtr there); checked by diffing with whitespace removed. Behaviour is the same as 0b63465. - The listTables / listDatabases doc comments use Perfect-MySQL's wording for what's refused. Test: Perfect-MySQL's aStaleNoBackslashEscapesBitCannotEndTheLiteral, adapted (utf8mb4, latin1, big5; four stale-bit setups including a stored function and a later DO 1; `x'`, `x\'` and a trailing `\` payloads; the connection stays in sync and vic% / it_s still match). Full suite passes under TZ=America/Denver against MariaDB 11.8 and MySQL 8.4. Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MariaDB.swift | 50 ++++++++++--------- Sources/mariadbclient/mariadb.h | 10 ++++ Tests/MariaDBTests/ListingWildcardTests.swift | 45 +++++++++++++++++ 3 files changed, 81 insertions(+), 24 deletions(-) diff --git a/Sources/MariaDB/MariaDB.swift b/Sources/MariaDB/MariaDB.swift index c4af41b..d1b57e8 100644 --- a/Sources/MariaDB/MariaDB.swift +++ b/Sources/MariaDB/MariaDB.swift @@ -202,30 +202,37 @@ public final class MySQL: @unchecked Sendable { /// 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, for a wildcard refused by `listingWildcardIsUnsafe`, and for a - /// wildcard containing both `'` and `\`. + /// 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, /// except on a MySQL server in `NO_BACKSLASH_ESCAPES` mode, where it is an ordinary character). - /// Returns [] on an error, for a wildcard refused by `listingWildcardIsUnsafe`, and for a - /// wildcard containing both `'` and `\`. + /// 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. A `'` is doubled by hand (and a - // wildcard with both `'` and `\` refused); anything else is escaped by mysql_real_escape_string, - // which follows the connection's character set and the NO_BACKSLASH_ESCAPES mode as libmariadb - // last saw them. That mode bit can be stale after a stored routine changes sql_mode, which is - // why quotes aren't left to it. (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 [] @@ -233,15 +240,10 @@ public final class MySQL: @unchecked Sendable { var query = Array(show.utf8) if let wild { let bytes = Array(wild.utf8) - let quote = UInt8(ascii: "'"), backslash = UInt8(ascii: "\\") + let quote = UInt8(ascii: "'") query += Array(" LIKE '".utf8) if bytes.contains(quote) { - // The client library escapes for the NO_BACKSLASH_ESCAPES mode it last saw in the - // server's status, which a stored routine that changes sql_mode leaves stale, so a - // `'` is never left to it: `''` is a quote in a literal in either mode, and 0x27 is - // never part of a multibyte character. With a `\` as well, the right escaping - // depends on the mode, so such a wildcard is refused. - guard !bytes.contains(backslash) else { + guard !bytes.contains(UInt8(ascii: "\\")) else { return [] } for byte in bytes { @@ -251,10 +253,10 @@ public final class MySQL: @unchecked Sendable { } } } else { - // No quote: escaping for the wrong mode can't end the literal. let from = bytes.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)) + var escaped = [CChar](repeating: 0, count: bytes.count * 2 + 1) + let length = perfect_mysql_escape_string_quote(self.ptr!, &escaped, from, UInt(bytes.count), + CChar(bitPattern: quote)) guard length != UInt.max else { return [] } 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/ListingWildcardTests.swift b/Tests/MariaDBTests/ListingWildcardTests.swift index dd1ca55..ee87e7e 100644 --- a/Tests/MariaDBTests/ListingWildcardTests.swift +++ b/Tests/MariaDBTests/ListingWildcardTests.swift @@ -187,5 +187,50 @@ struct ListingWildcardTests { #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)") + } + } } From bd2ab0655b3a346a6c3051f692faa04c4faf4d8c Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 09:46:38 -0500 Subject: [PATCH 29/32] Depend on Perfect-CRUD from 3.0.1 Perfect-CRUD 3.0.1 is released, and Perfect-MySQL raises its pin to it too. 3.0.1 encodes a negative [Int8] without trapping (3.0.0's CodingBindings used UInt8($0)) and makes DynamicRow keep the first of case-only duplicate keys instead of trapping. - Package.swift, the README / README.zh_CN install snippets and the README requirements line: `from: "3.0.1"`. Package.resolved (not committed) resolves 3.0.1. - StatementResultTests.signedByteArraysRoundTrip now also writes every Int8 value from -128 to 127 through Perfect-CRUD and reads it back with a `where` on the id; the README [Int8] item says 3.0.1 writes them. Full suite passes under TZ=America/Denver against MariaDB 11.8 and MySQL 8.4. Co-Authored-By: Claude Opus 5.5 --- Package.swift | 2 +- README.md | 6 +++--- README.zh_CN.md | 2 +- Tests/MariaDBTests/StatementResultTests.swift | 7 +++++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/Package.swift b/Package.swift index ad5caba..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", from: "3.0.0"), + .package(url: "https://github.com/PerfectlySoft/Perfect-CRUD.git", from: "3.0.1"), ], targets: [ .systemLibrary( diff --git a/README.md b/README.md index 3e6dae1..85b3e22 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ The pre-Swift-6 version of this package is preserved on the [`legacy`](../../tre - 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.0 or newer, which SwiftPM fetches for you. +- [Perfect-CRUD](https://github.com/PerfectlySoft/Perfect-CRUD) 3.0.1 or newer, which SwiftPM fetches for you. ### macOS @@ -65,7 +65,7 @@ Add it to your `Package.swift`: ```swift dependencies: [ .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.0"), + .package(url: "https://github.com/PerfectlySoft/Perfect-CRUD.git", from: "3.0.1"), ], targets: [ .target( @@ -198,7 +198,7 @@ release notes for its changes. 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. Blob parameters (`[UInt8]`, `[Int8]`, `bindParam(_:length:)`) and the parameter array no longer leak. -- **`[Int8]` properties** read negative bytes instead of trapping (writing them needs Perfect-CRUD 3.0.1). +- **`[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 diff --git a/README.zh_CN.md b/README.zh_CN.md index bd0e9e5..f6c3769 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -79,7 +79,7 @@ $ pkg-config libmariadb --cflags --libs ```swift .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.0"), +.package(url: "https://github.com/PerfectlySoft/Perfect-CRUD.git", from: "3.0.1"), ... dependencies: [ .product(name: "PerfectMariaDB", package: "Perfect-MariaDB"), diff --git a/Tests/MariaDBTests/StatementResultTests.swift b/Tests/MariaDBTests/StatementResultTests.swift index d79d8f3..582a47e 100644 --- a/Tests/MariaDBTests/StatementResultTests.swift +++ b/Tests/MariaDBTests/StatementResultTests.swift @@ -179,8 +179,8 @@ struct StatementResultTests { } // 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 rows are written with SQL here: Perfect-CRUD - // 3.0.0 itself traps encoding a negative [Int8] (fixed in Perfect-CRUD 3.0.1). + // 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 { @@ -195,6 +195,9 @@ struct StatementResultTests { 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. From 4d0020c392ff2b8b65fd856c5bc3bc5431bc2654 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 10:18:48 -0500 Subject: [PATCH 30/32] Refuse parameter values past the statement's parameter count Every bindParam wrote `paramBinds?[paramBindsOffset]` with no bound, so binding more values than the prepared statement has parameters wrote past the bind array, and resetBinds() then freed whatever was there. Reachable through Perfect-CRUD: `db.sql("SELECT ? AS x", bindings: [3 values], X.self)` crashed. Pre-existing (main, 3.x and Perfect-MySQL have the same code). Found by the re-verification (M1). - prepare() records the parameter count (paramBindsCapacity). Each bindParam first calls reserveParamBind(): past the count it sets error 2034 (CR_INVALID_PARAMETER_NO, "Invalid parameter number"), counts the value without allocating or storing anything, and returns. execute() then refuses with 2034 (too many) or 2031 (too few) until reset() and a correct rebind. - resetBinds() frees only the array's own entries (min(offset, count)) and zeroes paramBindsOffset unconditionally, so a stray bindParam on a statement without parameters no longer leaves it refused with 2031 until re-prepared (I1). - prepare() clears the binds before checking the result, so after a failed prepare execute() reports the library's error instead of 2031 (I2). Written so it ports byte-for-byte to Perfect-MySQL's identical MySQLStmt. Checks: - New StatementResultTests.bindingTooManyParametersIsRefused: three values for one parameter through Perfect-CRUD (throws, then a correct query works), four through raw MySQLStmt (2034, refused, then reset and rebind works), a stray value on a parameterless statement, and execute() after a failed re-prepare. On the previous commit the test process crashes at the first step. - An `overbind` probe (CRUD and raw, 50 rounds): on the previous commit exit 134 with the system allocator and SIGSEGV (139) under Guard Malloc against MariaDB 11.8 and MySQL 8.4; with the fix 0 failures under Guard Malloc (banner checked), and the reset-execute / reuse / reassign / callrepeat probes stay clean. Full suite passes under TZ=America/Denver against both. Co-Authored-By: Claude Opus 5.5 --- Sources/MariaDB/MySQLStmt.swift | 86 ++++++++++++++++--- Tests/MariaDBTests/StatementResultTests.swift | 47 ++++++++++ 2 files changed, 121 insertions(+), 12 deletions(-) diff --git a/Sources/MariaDB/MySQLStmt.swift b/Sources/MariaDB/MySQLStmt.swift index 7aa70fa..86da456 100644 --- a/Sources/MariaDB/MySQLStmt.swift +++ b/Sources/MariaDB/MySQLStmt.swift @@ -7,6 +7,8 @@ public final class MySQLStmt: @unchecked Sendable { 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? @@ -85,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.. 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) } @@ -128,8 +145,10 @@ public final class MySQLStmt: @unchecked Sendable { 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 reports the library's error. + clearBinds() guard r == 0 else { return false } - clearBinds() // a statement prepared again gets a fresh bind array if let m = meta { mysql_free_result(m) } meta = mysql_stmt_result_metadata(ptr) let count = paramCount() @@ -137,6 +156,7 @@ public final class MySQLStmt: @unchecked Sendable { paramBinds = UnsafeMutablePointer.allocate(capacity: count) let initBind = MYSQL_BIND() for i in 0.. count + ? (2034, "Invalid parameter number") // CR_INVALID_PARAMETER_NO: too many values bound + : (2031, "No data supplied for parameters in prepared statement") // CR_PARAMS_NOT_BOUND return false } if paramBindsOffset > 0 { @@ -266,6 +288,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) @@ -276,6 +299,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) @@ -285,6 +309,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) @@ -316,16 +341,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) @@ -336,6 +394,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) @@ -346,6 +405,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) @@ -356,6 +416,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) @@ -366,6 +427,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 diff --git a/Tests/MariaDBTests/StatementResultTests.swift b/Tests/MariaDBTests/StatementResultTests.swift index 582a47e..d25ddbe 100644 --- a/Tests/MariaDBTests/StatementResultTests.swift +++ b/Tests/MariaDBTests/StatementResultTests.swift @@ -339,6 +339,53 @@ struct StatementResultTests { #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() + stmt.bindParam(5) + #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 a failed prepare, execute() reports the library's error, 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. From acd61a3a49ec484a24c0111e707ebcc1764d31cd Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 10:18:57 -0500 Subject: [PATCH 31/32] README: an unstorable Date in a select()'s where returns no rows The Dates item said such a Date "throws when bound". That holds for insert, update, count() and sql; in a select() whose `where` compares against it, Perfect-CRUD's Select.makeIterator catches the binding error, logs it and yields no rows (re-verification L1, confirmed by its `dates` probe). Say so. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 85b3e22..39b86b0 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,9 @@ release notes for its changes. `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. The zero date (`0000-00-00`) reads + (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. From ce6afa5e1ce12e504ed2e06896def3152be593d4 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 10:30:59 -0500 Subject: [PATCH 32/32] Binding refusals: report 2034 after clearBinds(), clear it on reset() Three low items from the review of 4d0020c, before the Perfect-MySQL port: - L1: after the public clearBinds() on a prepared statement there's no bind array, so every value is refused, yet the offset can still reach paramCount(); execute() then failed with error 0 and an empty message. Its guard now also requires paramBindsOffset <= paramBindsCapacity, and reports 2031 when too few values were bound, otherwise 2034 (values that weren't stored). - L2: a 2034 from binding too many values survived reset() and a correct rebind (errorCode() kept reporting it). resetBinds() now clears the wrapper's error. - L3: README "Statements can be reused safely" says that binding too many values, which crashed, is refused, that bindParam sets errorCode() to 2034, and that execute() refuses until reset(). The prepare() comment now says that after a failed prepare execute() fails with the library's error (2014 "Commands out of sync" on MariaDB Connector/C), not 2031. bindingTooManyParametersIsRefused gains assertions for L1 (2034 after clearBinds() and a bind) and L2 (errorCode() 0 after reset() and after a correct rebind); on the previous commit those 3 fail. Full suite on Xcode 27 debug against MariaDB 11.8 under TZ=America/Denver: 124 pass, 2 skipped. The overbind, reset-execute and reuse probes under Guard Malloc (banner checked) against MariaDB: 0 failures. Same code for Perfect-MySQL. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +++ Sources/MariaDB/MySQLStmt.swift | 14 +++++++++----- Tests/MariaDBTests/StatementResultTests.swift | 14 +++++++++++++- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 39b86b0..4089010 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,9 @@ release notes for its changes. 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. diff --git a/Sources/MariaDB/MySQLStmt.swift b/Sources/MariaDB/MySQLStmt.swift index 86da456..17de8cd 100644 --- a/Sources/MariaDB/MySQLStmt.swift +++ b/Sources/MariaDB/MySQLStmt.swift @@ -113,6 +113,7 @@ public final class MySQLStmt: @unchecked Sendable { } } paramBindsOffset = 0 + wrapperError = nil // a 2034 from binding too many values doesn't outlive the values } public func clearBinds() { @@ -146,7 +147,8 @@ public final class MySQLStmt: @unchecked Sendable { 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 reports the library's error. + // 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) } @@ -167,10 +169,12 @@ public final class MySQLStmt: @unchecked Sendable { // 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() - guard paramBindsOffset == count else { - wrapperError = paramBindsOffset > count - ? (2034, "Invalid parameter number") // CR_INVALID_PARAMETER_NO: too many values bound - : (2031, "No data supplied for parameters in prepared statement") // CR_PARAMS_NOT_BOUND + // 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 { diff --git a/Tests/MariaDBTests/StatementResultTests.swift b/Tests/MariaDBTests/StatementResultTests.swift index d25ddbe..8e34e8d 100644 --- a/Tests/MariaDBTests/StatementResultTests.swift +++ b/Tests/MariaDBTests/StatementResultTests.swift @@ -363,7 +363,9 @@ struct StatementResultTests { #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() @@ -377,7 +379,17 @@ struct StatementResultTests { #expect(plain.execute(), "\(plain.errorMessage())") _ = plain.results().next() - // After a failed prepare, execute() reports the library's error, not 2031. + // 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)