Repository navigation
Release prep for 4.0.0: Perfect-CRUD 3.0.0 pin, CI, UTC dates and fixes - #72
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
from: "3.0.0"instead ofbranch: "main", so version-pinned dependents can resolve..github/workflows/ci.yml), with warnings as errors everywhere:MYSQL_REQUIRE_LIVE), in non-UTC time zones.legacypointer.Fixes found during release prep
DATETIME(6)values andDATEvalues were silently dropped fromselect(). Fractions andDATEare now read, and a zero date reads as nil for an optionalDate. No migration is needed for data written through CRUD; apps that compensated by hand should remove the compensation.Double/Floatproperties; before, they silently read as 0.0 (f2c011c).numberAffectedRows()/lastInsertId()no longer trap (d4f75d6);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
🤖 Generated with Claude Code