Skip to content

Release prep for 4.0.0: Perfect-CRUD 3.0.0 pin, CI, Dynamic API, UTC dates and fixes - #26

Merged
taplin merged 32 commits into
mainfrom
release-prep
Oct 13, 2026
Merged

taplin merged 32 commits into
mainfrom
release-prep

Conversation

@taplin

@taplin taplin commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Release prep for Perfect-MariaDB 4.0.0, the first release since 3.0.4 (2020). The tag is 4.0.0 with no v prefix, matching 3.0.0–3.0.4. The tag and GitHub release follow after this merges and CI is green.

Release mechanics

  • Depends on Perfect-CRUD from: "3.0.0" instead of branch: "main".
  • Drops the test target's unsafeFlags linker setting (8a7ebec). It was a leftover from the old MariaDB product name, and SwiftPM rejects unsafe flags in version-resolved packages.
  • Adds CI (.github/workflows/ci.yml):
    • Linux, against MariaDB 11.8 and MySQL 8.4 service containers, in America/Denver;
    • macOS;
    • warnings as errors everywhere.
    • This is its first run on GitHub.
  • README updated for 4.0.0.

New and fixed

  • Perfect-CRUD's Dynamic API works (eb69043). Dynamic selects, mutations and execute return rows, affected-row counts and insert IDs. Before, any Dynamic query that returned rows threw.
  • Dates are read as UTC (9aa070d), matching Perfect-MySQL 4.0.0. 3.x read them in local time, so dates came back shifted and some rows were silently dropped. No migration is needed for data written through CRUD.
  • Reading rows:
    • a failure while reading throws instead of crashing libmariadb (0e39522);
    • MySQLStmt.Results.forEachRow returns false when reading failed (d42da2a);
    • CALL and RETURNING rows are read, and leftover result sets are drained (da62aef).
  • Decoding:
    • Codable decoding: the first of several same-named columns wins (472b076);
    • DECIMAL decodes into Double/Float; before, it read as 0.0 (b48903d).
  • Leak: a statement's bind array is freed; it leaked on every parameterized statement (d13f4eb).

Tested (2026-10-13, macOS, clean export of this branch, warnings as errors, TZ=America/Denver): 106/106 pass against MariaDB 11.8 (debug and release) and against MySQL 8.4. Two TLS tests skip because they need a non-TLS server or a non-loopback host. On 2026-10-06 it also passed on Xcode 26.3 and on Linux with Swift 6.2/6.4.

Commits

  • 8a7ebec Depend on Perfect-CRUD 3.0.0; drop the test target's unsafe linker flag
  • ec92735 SSL test: expect fingerprint verification only with Connector/C 3.4
  • 674b918 Add CI; README for 4.0.0
  • eb69043 Support Perfect-CRUD's Dynamic API: rows, affected rows, insert ID
  • 8af75e4 README: Dynamic API notes; Connector/C 3.4 refuses servers without TLS
  • 472b076 Codable decoding: the first of several same-named columns wins
  • 0e39522 Throw when reading rows fails instead of crashing in libmariadb
  • da62aef Read CALL and RETURNING rows; drain leftover result sets
  • 9aa070d Read dates as UTC; accept fractions and DATE; zero dates read as nil
  • d13f4eb Free a statement's bind array; it leaked on every parameterized statement
  • b295a42 README: Dynamic API limits, the new MySQLOpt case, PKG_CONFIG_PATH wording
  • d42da2a MySQLStmt.Results.forEachRow returns false when reading failed
  • b48903d Decode DECIMAL columns into Double and Float properties

🤖 Generated with Claude Code

taplin and others added 30 commits October 6, 2026 07:24
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- .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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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. <message>"
  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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ment

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 <noreply@anthropic.com>
…rding

- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
'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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ng 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…e 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 <noreply@anthropic.com>
…ute()

- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
taplin and others added 2 commits October 13, 2026 10:18
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@taplin
taplin merged commit 5146ccd into main Oct 13, 2026
36 of 40 checks passed
@taplin
taplin deleted the release-prep branch October 13, 2026 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant