Skip to content

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

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

taplin merged 16 commits into
mainfrom
release-prep

Conversation

@taplin

@taplin taplin commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

Release prep for Perfect-MySQL 4.0.0, the first release since 3.5.1 (2019). 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", so version-pinned dependents can resolve.
  • Adds CI (.github/workflows/ci.yml), with warnings as errors everywhere:
    • Linux: Swift 6.2 and 6.4 with libmysqlclient, plus Swift 6.4 with MariaDB Connector/C, debug and release. Runs against a MySQL 8.4 service container with the live tests required (MYSQL_REQUIRE_LIVE), in non-UTC time zones.
    • macOS: Xcode 26.0.1 and 26.6, server-independent tests.
    • This is its first run on GitHub.
  • README updated for 4.0.0: install snippet, behaviour changes, legacy pointer.

Fixes found during release prep

  • Dates are read as UTC (705e9db). Perfect-CRUD writes dates as UTC, but 3.x read them in local time. Outside UTC, dates came back shifted, and rows in a DST gap, DATETIME(6) values and DATE values were silently dropped from select(). Fractions and DATE are now read, and a zero date reads as nil for an optional Date. No migration is needed for data written through CRUD; apps that compensated by hand should remove the compensation.
  • Perfect-CRUD's Dynamic API: affected rows are read right after execute, which fixes a Connector/C trap (c456044). One dynamic column is kept per name (5b19235). Row-returning statements report a row count and never an insert ID (11541d0).
  • CALL rows are read, and leftover result sets are drained so a pooled connection stays in sync (9f3056b).
  • DECIMAL columns decode into Double/Float properties; before, they silently read as 0.0 (f2c011c).
  • No more traps or crashes:
    • numberAffectedRows() / lastInsertId() no longer trap (d4f75d6);
    • a result whose store failed now throws instead of crashing Connector/C (dda4fec, e0b8c0c);
    • the parameter bind array is freed and reset on re-prepare (22e9fed).

Tested (2026-10-13, macOS, clean export of this branch, warnings as errors, TZ=America/Denver): against MySQL 8.4, 72 XCTest + 40 Swift Testing pass in debug and release, with 3 expected skips. On 2026-10-06 it also passed on Xcode 26.3, on Linux with Swift 6.2/6.4 using both client libraries, and under Asia/Kolkata.

Commits

  • d02d76b Depend on Perfect-CRUD from 3.0.0; fix a Swift 6.4 test warning
  • c456044 Read affected rows right after execute; fixes a Connector/C trap
  • 260cd9a Add CI; README for 4.0.0
  • 5b19235 Keep one dynamic column per name; fixes a trap on case-only duplicates
  • 2979974 Test Dynamic execute, unsigned bindings and multi-statement batches
  • 705e9db Read dates as UTC; accept fractions and DATE; zero dates read as nil
  • d4f75d6 Don't trap in numberAffectedRows() / lastInsertId()
  • 9f3056b Read CALL rows; drain leftover result sets so the connection stays in sync
  • 11541d0 Row-returning statements: report the row count, never an insert ID
  • f2c011c Decode DECIMAL columns into Double and Float properties
  • d03e37e CI: a Connector/C job, non-UTC time zones, fail when the server is missing
  • a6600cb README: list the remaining 4.0.0 behaviour changes
  • dda4fec Stop reading a result whose store failed; throw instead (Connector/C SIGSEGV)
  • 22e9fed Free the parameter bind array whenever it exists; reset it on re-prepare
  • b0493e0 README: errors while reading rows now throw
  • e0b8c0c Report a failed result's error on Connector/C too

🤖 Generated with Claude Code

taplin and others added 16 commits October 6, 2026 07:24
Pin Perfect-CRUD to `from: "3.0.0"` instead of `branch: "main"`, so this
package can be consumed by version. `swift package resolve` picks 3.0.0
(Package.resolved is git-ignored in this repo, so none is committed).

Swift 6.4 reported "no calls to throwing functions occur within 'try'"
for a one-line helper in SubTableCreateOrderTests that does throw; it is
split into two statements, which silences it on 6.4 and 6.2.

Verified with -warnings-as-errors, debug and release, Xcode 27 (Swift
6.4) and Xcode 26.3 (Swift 6.2.4), MYSQL_TESTS=1 MYSQL_FIXTURE_TESTS=1
against MySQL 8.4: 72 XCTest passed + 3 skipped, 21 Swift Testing passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Built against MariaDB Connector/C, every DynamicMutation trapped with
"Not enough bits to represent the passed value". Database.mutate() reads
affectedRowCount() after hasNext(), and hasNext() resets the statement
once its rows run out. Connector/C's mysql_stmt_reset sets the
affected-row count to (my_ulonglong)-1, and Int(_:) trapped on it.
libmysqlclient keeps the count across the reset, so it wasn't seen there.

MySQLStmtExeDelegate now records the affected-row count and insert ID
right after execute(), and converts with Int(exactly:) / Int64(exactly:)
so a -1 "no count" can't trap.

New test DynamicMySQLTests.mutationsReportAffectedRowsAndInsertedID
(insert, update, delete). Against origin/main built with Ubuntu 24.04's
libmariadb-dev-compat (Swift 6.4) it crashes the test process; with the
fix, the full suite passes there in debug and release against MySQL 8.4:
74 XCTest passed + 1 skipped, 22 Swift Testing passed. It also passes
with libmysqlclient on macOS and Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI (.github/workflows/ci.yml), modeled on Perfect-CRUD's:
- Linux: Swift 6.2 and 6.4 x debug/release in swift:X-noble, with
  libmysqlclient-dev and a mysql:8.4 service (health-checked), running
  the live MYSQL_TESTS and MYSQL_FIXTURE_TESTS suites.
- macOS 26: Xcode 26.0.1 and 26.6 x debug/release with Homebrew's
  keg-only mysql-client (PKG_CONFIG_PATH set); the live tests skip.
- -warnings-as-errors everywhere; release adds -enable-testing.
  No iOS job: the client library isn't available for iOS.
The workflow has not run on GitHub yet; the YAML was only parsed locally.

README: install snippet `from: "4.0.0"`, accurate requirements (Swift
6.2+, macOS 12+ / Linux, libmysqlclient 8.0+ or MariaDB Connector/C,
Perfect-CRUD 3), a full legacy branch link, the Connector/C build note,
and a "Migrating from 3.x" section listing the breaking and behaviour
changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Perfect-CRUD 3.0.0's DynamicRow.init lowercases its keys and uses
Dictionary(uniqueKeysWithValues:), so a Dynamic select/execute whose
result had columns differing only by case (`SELECT 1 AS a, 2 AS A`, or a
join with `p.Name, c.name`) crashed the process with "Duplicate values
for key". Columns with exactly the same name (`p.id, c.id`) went through
a [name: index] map built by iterating a dictionary, so which one was
kept was arbitrary.

MySQLStmtExeDelegate now walks the columns in result order: the column
map used for Codable decoding keeps the first column of each exact name,
and dynamic rows keep the first column of each name ignoring case (the
same rule Perfect-CRUD 3.0.1 is adopting), so 3.0.0 can't trap.

New test DynamicMySQLTests.duplicateColumnNamesKeepTheFirstColumn. On
the previous commit it crashes the test process ("Duplicate values for
key: 'a'"); it passes with the fix against MySQL 8.4 (macOS, Swift 6.4).
The old exact-duplicate behaviour was established by reading the code,
not reproduced separately (the test traps on the case-only part first).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Coverage for the Dynamic API paths the Perfect-SQLite review found
crashing there. On MySQL they already behaved; these tests pin that down:

- executeReturnsRowsAndBindsValues: DynamicSQL with int, string, bytes
  and NULL bindings returns one row with those values.
- unsignedValuesRoundTrip: .uint(UInt64.max) and 2^63 bind as unsigned
  (MySQLStmt sets is_unsigned), insert, filter and read back as .uint
  through mutate/select/execute; nothing converts through Int.
- multipleStatementsThrowWithoutRunningAny: with allowsMultipleStatements
  a batch (plain INSERTs, BEGIN/INSERT/COMMIT, two SELECTs) throws: MySQL
  can't prepare several statements, and the direct path's text query
  fails because CRUD connections don't set CLIENT_MULTI_STATEMENTS. No
  row is inserted. Without the flag Perfect-CRUD refuses it first.

All pass against MySQL 8.4 before and after the previous commit.

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. Unchanged since 3.5.1. Outside UTC that meant:
- every date came back shifted by the local UTC offset (+7h in Denver);
- a UTC wall time inside the local DST spring-forward gap (2026-03-08
  02:30:00 under America/Denver) didn't parse, the decode threw, and
  Perfect-CRUD's SelectIterator swallowed the error, so select()
  returned 1 of 2 rows;
- DATETIME(6) fractions and DATE-only values never parsed (any zone), so
  those rows were dropped the same way.

The parser now reads YYYY-MM-DD, YYYY-MM-DD HH:MM:SS (space or T) and up
to six fractional digits, always as UTC through a fixed gregorian/UTC
calendar, and rejects out-of-range parts. The writer's formatter also
gets an explicit UTC calendar and zone. Neither depends on the process
time zone any more.

Zero dates ('0000-00-00...'): decodeNil reports nil for a date-typed
column holding one, so an optional Date reads nil. A non-optional Date
throws a "zero date ... use an optional property" error. Perfect-CRUD's
select() still skips rows whose decode throws, so that row is dropped
rather than surfaced; that's a Perfect-CRUD limitation.

Rows written through Perfect-CRUD (always UTC text) read back correctly
with no migration; only hand-compensated apps and local-time values
written outside CRUD (NOW() on a non-UTC server, TIMESTAMP columns with
a non-UTC session time_zone) shift. README "Migrating from 3.x" says so
and mentions SET time_zone as an option; the connector doesn't set it.

Tests (DateTests): parser cases, a CRUD round trip including the Denver
gap date, DATETIME(6), DATE, NULL and zero dates. TimeZone.current
can't be changed in-process on macOS, so the zone comes from the TZ
environment variable. On the previous commit: TZ=America/Denver loses
the gap row and shifts dates, TZ=Asia/Kolkata shifts dates, and TZ=UTC
passes the round trip (the parser cases and the DATE/fraction test fail
in every zone). With the fix, all pass under UTC, America/Denver and
Asia/Kolkata against MySQL 8.4.

This commit is self-contained so it can be dropped if 4.0.0 should ship
without the behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both converted the C API's unsigned result with Int64(_:), so
numberAffectedRows() trapped on the (my_ulonglong)-1 the C API returns
after a failed query, or after query("SELECT 1") before storeResults(),
and lastInsertId() trapped on a BIGINT UNSIGNED AUTO_INCREMENT value
above Int64.max. Both now use Int64(bitPattern:): the sentinel reads as
-1, as in the C API, and a large insert ID keeps its bits (recover it
with UInt64(bitPattern:)). Doc comments say so.

New RawCountTests (live, MYSQL_TESTS): -1 after an error and after an
unstored SELECT, 3 after a 3-row INSERT; an insert ID of Int64.max + 5
round-trips through UInt64(bitPattern:). On the previous commit the
first test crashes the test process ("Not enough bits to represent the
passed value"); with the fix both pass against MySQL 8.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… sync

Two problems with stored procedures through Perfect-CRUD:
- MySQLStmtExeDelegate built its column maps in init, before execute().
  A CALL has no result metadata until it runs, so the maps were empty and
  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.
- When the rows ran out, the delegate reset the statement without
  reading the result sets that follow (a CALL always ends with a status
  result). libmysqlclient then failed every later statement on that
  connection with "Commands out of sync", which also poisons a pooled
  connection. The same happened when a caller stopped early
  (Database.sql without a type, first(), a decoding error), since the
  delegate was released with results pending.

The maps are now built after each execute(). When the rows run out, and
in deinit if they didn't, MySQLStmt.discardRemainingResults() frees the
current result and stores/frees every remaining one before the reset.
Only the first result set of a multi-result CALL is read; the rest are
discarded.

New StoredProcedureTests (live, fixture schema): a CALL with a bound
argument returns its rows via db.sql(_:_:) and DynamicSQL, a multi-set
procedure returns the first set, CALL run without reading rows, and a
statement after each one on the same connection, plus repeated CALLs in
a transaction. On the previous commit they fail with "Could not convert
nil into an Int for key: id" and "Commands out of sync; you can't run
this command now". With the fix the full suite passes against MySQL 8.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c456044 read the affected-row count and insert ID right after execute()
for every statement. For a statement that returns rows, the count isn't
known until the result is stored, so libmysqlclient reports -1 there and
a 2-row DynamicSQL SELECT reported 0 affected rows (origin/main, reading
after the reset, reported 2). On Connector/C the insert ID is the
connection's, so a SELECT run after an INSERT could report that stale ID.

Now, when the statement has no result columns, the count and insert ID
are read right after execute() as before. When it returns rows, the
count is the number of rows stored (what origin/main reported with
libmysqlclient) and the insert ID is nil.

New test DynamicMySQLTests.rowReturningStatementsReportRowCountAndNoInsertedID:
INSERT (2 rows, ID 1), SELECT (2 rows, no ID), empty SELECT (0), UPDATE
(1, no ID). On the previous commit the SELECT reports 0 affected rows
with libmysqlclient; with the fix it passes against MySQL 8.4.

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
(unchanged since 3.5.1). 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.

New test DynamicMySQLTests.decimalColumnsDecodeIntoFloatingPoint: Double,
Float and Double? properties over DECIMAL columns, including negative,
fractional and NULL values. On the previous commit all three
expectations fail (zeros); with the fix it passes against MySQL 8.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssing

- Linux gains two jobs built against MariaDB Connector/C
  (libmariadb-dev-compat, Swift 6.4, debug and release) with the same
  mysql:8.4 service. Its affected-row behaviour differs (c456044), and
  the regression test for that passes on libmysqlclient even without
  the fix, so only this job guards it.
- TZ is America/Denver for the libmysqlclient and macOS jobs and
  Asia/Kolkata for the Connector/C jobs, so the Date tests run in
  non-UTC zones (swift:noble ships tzdata; checked).
- MYSQL_REQUIRE_LIVE=1 on Linux. New LiveServerRequirementTests fails
  when it's set and the live tests would skip or can't connect, so a
  broken service configuration can't pass as a run of skipped tests.
  Checked locally: required with no server fails (3 issues), required
  with a server passes, not required skips.

Still not run on GitHub; the YAML was only parsed locally (Ruby).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Migrating from 3.x" gains the duplicate-column rule, CALL results,
numberAffectedRows()/lastInsertId() and Dynamic row counts, and DECIMAL
decoding. (The Date change is described in its own commit, 705e9db, so
it goes away with that commit if it's dropped.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…SIGSEGV)

A statement can fail after it starts returning rows, e.g. a scalar
subquery that returns two rows for one outer row (error 1242):
  SELECT id, (SELECT x FROM b WHERE b.k = a.id) FROM a
MySQLStmt.Results ignored mysql_stmt_store_result's return value and
fetchRow() went on to call mysql_stmt_fetch on the failed statement.
- Built against MariaDB Connector/C, that crashed the process with
  SIGSEGV inside the client library (reproduced: Linux, Swift 6.4,
  libmariadb-dev-compat, MySQL 8.4 server).
- With libmysqlclient it didn't crash, but the error was lost: the query
  returned 0 rows and CRUD's db.sql(_:_:) / DynamicSQL returned an empty
  result without throwing; errorCode() reported the fetch's error
  instead of 1242.

Results now records a failed store (and a fetch returning 1) in a new
public `failed` property and stops fetching; `next()` returns nil,
`forEachRow` returns false, and errorCode()/errorMessage() keep the
server's error. Results with no columns (INSERT, UPDATE...) don't fetch
at all. MySQLStmtExeDelegate.hasNext() drains and resets the statement
and then throws MySQLCRUDError("Error reading results. ...").

New ResultErrorTests: through CRUD, db.sql(_:_:) and DynamicSQL throw
and the connection stays usable; raw MySQLStmt iteration ends with
`failed` set and errorCode() 1242. On the previous commit: SIGSEGV on
Connector/C; with libmysqlclient the throws and the error code fail.
With the fix the full suite passes against MySQL 8.4 (macOS, Swift 6.4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MySQLStmt.clearBinds() (also what deinit calls) freed paramBinds only
when paramBindsOffset > 0, but reset() (resetBinds) zeroes the offset
after every execution. So every parameterized statement that ran leaked
its MYSQL_BIND array: Perfect-MariaDB's identical code showed 1000 x
256 B over 500 iterations under `leaks`. It now frees the array whenever
it's non-nil.

prepare() also allocated a new array without freeing the old one, and
kept the old offset, so preparing a statement again with binds pending
leaked the old array and then trapped in resetBinds ("Unhandled MySQL
type enum_field_types(rawValue: 0)"), reading past the binds that were
set. prepare() now clears the previous binds first.

paramBinds becomes private(set) (readable internally) for the tests.

New ParameterBindTests (live, MYSQL_TESTS): after execute and reset,
clearBinds() leaves no array; preparing again with a bind pending, then
binding three values, executes and returns 6. On the previous commit the
first fails (array still allocated) and the second crashes the test
process with the trap above. With the fix both pass against MySQL 8.4.
`leaks` wasn't run on this package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On MariaDB Connector/C a failed mysql_stmt_store_result leaves the
statement's errno at 0 and reports the error on the connection, so after
dda4fec the thrown MySQLCRUDError said only "Error reading results." and
the raw test's errorCode() == 1242 check failed there (it passed with
libmysqlclient). Found in the full Connector/C run.

Results now captures the error at the moment of failure, from the
statement or, if that has none, the connection, in new public
`errorCode` / `errorMessage` properties, and hasNext() throws with that
message. The tests check for 1242 and "more than 1 row" there and in the
thrown error; they pass with libmysqlclient (macOS) and Connector/C
(Linux, Swift 6.4) against MySQL 8.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@taplin
taplin merged commit e6a4b8a into main Oct 13, 2026
20 checks passed
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