MysqlRepository
MySQL document-store {@see RepositoryInterface}: the production root — the same six-method contract as {@see FileRepository} and {@see SqliteRepository}, over a MySQL server, and still zero migrations. Each entity class gets one InnoDB table of two payload columns — `id VARCHAR(191)` plus a native `doc JSON` column holding the entity's `toArray()` — created lazily with `CREATE TABLE IF NOT EXISTS` on first use. The `toArray()`/`fromArray()` pair IS the schema: adding a field to an entity never touches the database. The table name derives from the entity class by the exact rule {@see SqliteRepository} uses — snake_case of the short class name plus a crc32 suffix of the fully-qualified name (`App\Blog\ Post` becomes `post_2f1f1893`) — so an entity keeps one table name across backends and two classes with the same short name never silently share a table. Tables are utf8mb4 with the binary collation, so id matching is exact — never case-folded or trailing-space-trimmed — and insertion order of {@see self::all()} rides an internal `seq BIGINT UNSIGNED AUTO_INCREMENT` column, exactly as in the SQLite backend. Concurrency is InnoDB row locking. Every fresh-id save runs a locking read (`SELECT … FOR UPDATE`) inside a transaction — the InnoDB equivalent of SqliteRepository's `BEGIN IMMEDIATE` — and re-saves ride the row lock of the single-statement upsert, so concurrent processes can neither mint the same fresh id nor lose each other's rows; {@see self::save()} documents the reasoning. Takes a DSN, not an injected PDO, because the package's construction idiom is a location plus an entity class ({@see FileRepository} a path, {@see SqliteRepository} a database file): the repository owns its connection and opens it lazily on first use, so construction never touches the network — and when the server cannot be reached, the failure is {@see MysqlRepositoryException}, an error that teaches what broke, why it matters, and the ways out, instead of a bare driver error. Needs `ext-pdo_mysql` (suggested, not required, by the package).
MysqlRepository::__construct()
public function __construct(string $dsn, string $entityClass, ?string $user = null, ?string $password = null):Parameters
| Name | Type | Description |
|---|---|---|
| $dsn | string | PDO DSN of the server and database (e.g. `mysql:host=127.0.0.1;port=3306;dbname=app`); when it names no `charset`, `utf8mb4` is appended so unicode documents survive byte-for-byte |
| $entityClass | class-string<T> | the entity class rows are rehydrated into via `fromArray()` |
| $user | (string | null) | the user to connect as, when not carried by the DSN |
| $password | (string | null) | the password to connect with, when not carried by the DSN |
MysqlRepository::find()
public function find(string|int $id): ?Milpa\Data\EntityInterfaceThe entity stored under `$id`, or `null` when no entity is stored under it.
Parameters
| Name | Type | Description |
|---|---|---|
| $id | string|int |
MysqlRepository::save()
public function save(Milpa\Data\EntityInterface $entity): string|intPersists `$entity` as one JSON document row. When `$entity->id()` is `null`, the fresh id is computed from a locking read (`SELECT … FOR UPDATE`) inside the transaction — the InnoDB equivalent of SqliteRepository's `BEGIN IMMEDIATE`. Under InnoDB's default REPEATABLE READ a plain SELECT reads a snapshot and takes no locks, so two concurrent savers would compute the same fresh id and the second upsert would silently swallow the first row; `FOR UPDATE` takes exclusive next-key locks over every id it scans plus the gap above them, so a concurrent minting saver blocks until this transaction commits and then sees the row it must not collide with. Re-saves with a preset id ride the upsert itself: one `INSERT … ON DUPLICATE KEY UPDATE` statement is atomic under the unique `id` index's row lock, updating the row in place so the entity's `seq` — its position in {@see self::all()} — never moves. The stored document always carries the id actually used under the key `'id'`, regardless of what `$entity->toArray()` returned for it.
Parameters
| Name | Type | Description |
|---|---|---|
| $entity | T |
MysqlRepository::delete()
public function delete(string|int $id): voidRemoves the row stored under `$id`. A no-op when no entity is stored under it.
Parameters
| Name | Type | Description |
|---|---|---|
| $id | string|int |
MysqlRepository::all()
public function all(): arrayEvery stored entity, in insertion order — rows come back ordered by the internal auto-increment `seq` column, which records the order ids were first saved regardless of the ids themselves.
MysqlRepository::nextId()
public function nextId(): intThe id to assign to the next entity saved without one of its own — one past the highest integer id currently stored, or `1` when the store holds no integer id. Ids live in a VARCHAR column, so the maximum is computed in PHP over the fetched ids, exactly like `FileRepository` does over its array keys — a stored `'42'` counts as the integer it names, `'custom-id'` does not.
MysqlRepository::query()
public function query(array $criteria): arrayStored entities matching every `$criteria` pair by strict equality. Deliberately filtered in PHP after fetching every row — not pushed into SQL — so equality semantics stay exactly {@see FileRepository}'s (`===` over the decoded `toArray()` values, JSON types intact), at the cost of reading the whole table. For the collection sizes this backend targets that is the right trade; a SQL-side filter could come later without touching the contract.
Parameters
| Name | Type | Description |
|---|---|---|
| $criteria | array<string, mixed> |