diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8132514..2579f4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,12 +7,17 @@ on: jobs: test: - name: Tests (PHP ${{ matrix.php }}) + name: Tests (PHP ${{ matrix.php }}, Laravel ${{ matrix.laravel }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: php: ["8.2", "8.3", "8.4"] + laravel: ["^10.0", "^11.0", "^12.0"] + exclude: + # Laravel 10 predates PHP 8.4 support. + - php: "8.4" + laravel: "^10.0" steps: - uses: actions/checkout@v4 @@ -20,6 +25,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} + extensions: pdo_sqlite, mbstring coverage: none tools: composer:v2 @@ -27,14 +33,38 @@ jobs: uses: actions/cache@v4 with: path: ~/.composer/cache - key: composer-${{ matrix.php }}-${{ hashFiles('composer.json') }} - restore-keys: composer-${{ matrix.php }}- + key: composer-${{ matrix.php }}-${{ matrix.laravel }}-${{ hashFiles('composer.json') }} + restore-keys: composer-${{ matrix.php }}-${{ matrix.laravel }}- + + - name: Pin the Illuminate version for this leg + run: > + composer require --no-update --no-interaction + "illuminate/database:${{ matrix.laravel }}" + "illuminate/support:${{ matrix.laravel }}" - name: Install dependencies - run: composer install --prefer-dist --no-progress --no-interaction + run: composer update --prefer-dist --no-progress --no-interaction - name: Run PHPUnit run: vendor/bin/phpunit --testdox + validate: + name: Validate composer.json + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + coverage: none + tools: composer:v2 + + # Validate the committed composer.json on a pristine checkout. This + # library ships no composer.lock, so validation runs on the manifest + # alone — deliberately separate from the test matrix, whose per-leg + # `composer update` writes a version-pinned lock that would otherwise + # trip the lock content-hash check. - name: Validate composer.json run: composer validate --strict diff --git a/README.md b/README.md index b803068..7ab6481 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ expects. - **PHP 8.2+** - **Laravel 10.x, 11.x, or 12.x** (`illuminate/database` constraint is `^10.0 || ^11.0 || ^12.0`). -- **ePHPm v0.6.3 or newer** (current release: v0.8.6) — the +- **ePHPm v0.6.3 or newer** (current release: v0.10.2) — the `ephpm_db_query()` / `ephpm_db_execute()` SAPI functions this driver calls first shipped in the v0.6.3 release. You need an embedded SQLite backend configured: @@ -147,7 +147,9 @@ ignored if present. |---------|--------| | `DB::select` / query builder `get()`, `first()`, `count()`, `pluck()` | yes — arrays of `stdClass`, native int/float/null column types | | `insert()` | yes — returns `bool` | -| `insertGetId()` | yes — id wired from `ephpm_db_execute()`'s `last_insert_id` | +| `insertGetId()` | yes — id wired from the bridge's `last_insert_id` | +| `insertOrIgnore()` | yes — the compiled `INSERT IGNORE` is translated to SQLite `INSERT OR IGNORE` | +| `upsert()` | **no** — rejected with a clear error; see [What is not supported](#what-is-not-supported) | | `update()` / `delete()` affected-row counts | yes | | `DB::transaction()` commit, and rollback + rethrow on exception | yes | | Nested transactions (`SAVEPOINT transN`) | yes — see [Transactions](#transactions) | @@ -160,7 +162,7 @@ ignored if present. | `pretend()` / query logging | yes | | Schema builder / migrations | emits MySQL DDL through the MySQL schema grammar (verified via `pretend()`); execution depends on litewire's DDL translation — see below | -The suite (28 tests) runs the driver against a `pdo_sqlite` polyfill of +The suite (30 tests) runs the driver against a `pdo_sqlite` polyfill of the bridge (`SqlitePdoDbOps`) that reproduces the native result and error shapes documented in ePHPm's `ephpm_wrapper.c`, including litewire's errno mapping. What the polyfill does **not** reproduce is @@ -170,6 +172,15 @@ of that SQL is ePHPm's (tested) responsibility. ## What is not supported +- **`upsert()`** — the MySQL query grammar compiles it to + `INSERT … ON DUPLICATE KEY UPDATE`, which the embedded engine + (litewire → Turso) rejects. Turso does not honour + `INSERT … ON CONFLICT … DO UPDATE` either, and rewriting to + `INSERT OR REPLACE` would silently overwrite columns you did not name in + the update set (and reassign `AUTO_INCREMENT` ids), so there is no safe + automatic translation. The driver throws a `RuntimeException` explaining + this rather than running it wrong. Use `insertOrIgnore()` followed by an + explicit `update()`, or perform the insert-or-update in application code. - **`selectResultSets()`** — the bridge stages exactly one result set per statement; this method throws `RuntimeException`. - **`cursor()` is not lazy.** `ephpm_db_query()` buffers the complete diff --git a/src/DbOpsInterface.php b/src/DbOpsInterface.php index 5a072ee..a140a8a 100644 --- a/src/DbOpsInterface.php +++ b/src/DbOpsInterface.php @@ -52,4 +52,24 @@ public function query(string $sql, array $params = []): array; * `SQLSTATE[xxxxx]: ` */ public function execute(string $sql, array $params = []): array; + + /** + * Execute SQL once and report what it actually did — the unified entry + * point mirroring the native `ephpm_db_run()` (ePHPm issue #263). + * + * `has_rowset` is read from the executed statement, not guessed from the + * SQL's first keyword, so a statement run through {@see \Ephpm\Db\Laravel\EphpmConnection::statement()} + * or `unprepared()` that turns out to produce a rowset is handled + * correctly instead of being mis-routed through `execute()` (which would + * discard the rows). `rows`/`columns` are empty for an OK outcome; + * `affected_rows`/`last_insert_id` are zero for a result set. + * + * @param list $params positional `?` bindings + * + * @return array{has_rowset: bool, rows: list>, columns: list, affected_rows: int, last_insert_id: int} + * + * @throws \Exception code = MySQL errno (e.g. 1062), message + * `SQLSTATE[xxxxx]: ` + */ + public function run(string $sql, array $params = []): array; } diff --git a/src/EphpmConnection.php b/src/EphpmConnection.php index 5a46430..d12cf7d 100644 --- a/src/EphpmConnection.php +++ b/src/EphpmConnection.php @@ -176,6 +176,7 @@ public function insert($query, $bindings = [], $sequence = null): bool */ public function statement($query, $bindings = []): bool { + $query = $this->translateForBridge($query); $params = $this->bridgeBindings($bindings); return $this->run($query, $bindings, function ($query) use ($params) { @@ -183,10 +184,14 @@ public function statement($query, $bindings = []): bool return true; } - $result = $this->ops->execute($query, $params); + // Route through the unified ephpm_db_run(): a statement that + // turns out to produce a rowset (e.g. INSERT ... RETURNING) is + // executed correctly rather than mis-routed through execute(), + // and the last_insert_id is still captured for insertGetId(). + $result = $this->ops->run($query, $params); $this->recordsHaveBeenModified(); - $this->rememberLastInsertId($result['last_insert_id']); + $this->rememberLastInsertId((int) ($result['last_insert_id'] ?? 0)); return true; }); @@ -200,6 +205,7 @@ public function statement($query, $bindings = []): bool */ public function affectingStatement($query, $bindings = []): int { + $query = $this->translateForBridge($query); $params = $this->bridgeBindings($bindings); return $this->run($query, $bindings, function ($query) use ($params) { @@ -223,20 +229,71 @@ public function affectingStatement($query, $bindings = []): int */ public function unprepared($query): bool { + $query = $this->translateForBridge($query); + return $this->run($query, [], function ($query) { if ($this->pretending()) { return true; } - $result = $this->ops->execute($query); + // Unified entry point: an unprepared SELECT is no longer + // mis-routed through execute() (which would discard its rows). + $result = $this->ops->run($query); - $this->recordsHaveBeenModified($result['affected_rows'] > 0); - $this->rememberLastInsertId($result['last_insert_id']); + $this->recordsHaveBeenModified(); + $this->rememberLastInsertId((int) ($result['last_insert_id'] ?? 0)); return true; }); } + /** + * Rewrite (or reject) the MySQL-only DML the query grammar compiles that + * the embedded engine (litewire → Turso) cannot run. + * + * - `insertOrIgnore()` compiles to `INSERT IGNORE INTO …`; MySQL's IGNORE + * modifier maps exactly to SQLite's `INSERT OR IGNORE` (skip rows that + * violate a constraint), so it is rewritten transparently. + * - `upsert()` compiles to `INSERT … ON DUPLICATE KEY UPDATE …`, which the + * embedded engine rejects. Turso does not honour `ON CONFLICT … DO + * UPDATE` either, and `INSERT OR REPLACE` would silently overwrite + * columns the caller did not list in the upsert's update set (and + * reassign AUTOINCREMENT ids), so there is no automatic rewrite that + * preserves upsert semantics. It is rejected with an actionable error + * rather than run wrong. + * + * @param string $query + */ + protected function translateForBridge($query): string + { + $query = (string) $query; + + if ( + preg_match('/^\s*INSERT\b/i', $query) + && preg_match('/\bON\s+DUPLICATE\s+KEY\s+UPDATE\b/i', $query) + ) { + throw new \RuntimeException( + 'ephpm-db: upsert() is not supported on the embedded database. ' + . 'It compiles to MySQL "ON DUPLICATE KEY UPDATE", which ' + . 'litewire/Turso rejects, and no automatic rewrite preserves ' + . 'its column-level update semantics (INSERT OR REPLACE would ' + . 'overwrite unlisted columns and reassign AUTOINCREMENT ids). ' + . 'Use insertOrIgnore() followed by an explicit update(), or ' + . 'perform the insert-or-update in application code.' + ); + } + + $rewritten = preg_replace( + '/^(\s*)INSERT\s+IGNORE\s+INTO\b/i', + '${1}INSERT OR IGNORE INTO', + $query, + 1, + $count + ); + + return (\is_string($rewritten) && $count > 0) ? $rewritten : $query; + } + /** * Prepare bindings for the bridge: run Laravel's normal * prepareBindings() (DateTimeInterface → grammar-formatted string, diff --git a/src/SapiDbOps.php b/src/SapiDbOps.php index 6ab2d70..7d05b2c 100644 --- a/src/SapiDbOps.php +++ b/src/SapiDbOps.php @@ -10,9 +10,8 @@ * * Refuses to construct if those functions aren't present so we fail fast * outside the runtime instead of producing "Call to undefined function" - * errors at request time. The functions exist only inside an ePHPm build - * from current `main` (they are not in any tagged release yet) with an - * active `[db.sqlite]` backend. + * errors at request time. The functions ship in every ePHPm release from + * v0.6.3 onward, and are only registered when `[db.sqlite]` is active. */ final class SapiDbOps implements DbOpsInterface { @@ -21,9 +20,8 @@ public function __construct() if (!\function_exists('ephpm_db_query')) { throw new \RuntimeException( 'ephpm DB bridge functions are not available. This driver only ' - . 'works inside the ePHPm runtime (unreleased main, with ' - . '[db.sqlite] configured); use Ephpm\\Db\\Laravel\\SqlitePdoDbOps ' - . 'in tests.' + . 'works inside the ePHPm runtime (v0.6.3+, with [db.sqlite] ' + . 'configured); use Ephpm\\Db\\Laravel\\SqlitePdoDbOps in tests.' ); } } @@ -39,4 +37,72 @@ public function execute(string $sql, array $params = []): array /** @var array{affected_rows: int, last_insert_id: int} */ return \ephpm_db_execute($sql, $params); } + + public function run(string $sql, array $params = []): array + { + if (\function_exists('ephpm_db_run')) { + /** @var array{has_rowset: bool, rows: list>, columns: list, affected_rows: int, last_insert_id: int} */ + return \ephpm_db_run($sql, $params); + } + + // Floor-preserving fallback for an ePHPm predating ephpm_db_run: + // route the way the connection used to. A statement with a rowset run + // through here still returns its rows; an OK statement returns zeros + // for rows/columns. Column names, when present, come from + // ephpm_db_columns() (also newer) else the first row. + if (self::mayReturnRows($sql)) { + $rows = $this->query($sql, $params); + $columns = \function_exists('ephpm_db_columns') + ? \ephpm_db_columns() + : self::columnsFromRows($rows); + + return [ + 'has_rowset' => true, + 'rows' => $rows, + 'columns' => $columns, + 'affected_rows' => 0, + 'last_insert_id' => 0, + ]; + } + + $ok = $this->execute($sql, $params); + + return [ + 'has_rowset' => false, + 'rows' => [], + 'columns' => [], + 'affected_rows' => (int) ($ok['affected_rows'] ?? 0), + 'last_insert_id' => (int) ($ok['last_insert_id'] ?? 0), + ]; + } + + /** Keyword classifier used ONLY by the {@see run()} fallback. */ + private static function mayReturnRows(string $sql): bool + { + $q = ltrim($sql, " \t\r\n("); + while (preg_match('/^(?:\/\*.*?\*\/|--[^\n]*(?:\n|$)|#[^\n]*(?:\n|$))\s*/s', $q, $m)) { + $q = substr($q, \strlen($m[0])); + } + + return (bool) preg_match('/^(?:SELECT|SHOW|DESCRIBE|DESC|EXPLAIN|WITH|VALUES|TABLE)\b/i', $q); + } + + /** + * @param list> $rows + * + * @return list + */ + private static function columnsFromRows(array $rows): array + { + if (!isset($rows[0])) { + return []; + } + + $columns = []; + foreach (array_keys($rows[0]) as $name) { + $columns[] = ['name' => (string) $name, 'type' => null]; + } + + return $columns; + } } diff --git a/src/SqlitePdoDbOps.php b/src/SqlitePdoDbOps.php index 55b4218..61ebc95 100644 --- a/src/SqlitePdoDbOps.php +++ b/src/SqlitePdoDbOps.php @@ -50,7 +50,7 @@ public function pdo(): \PDO public function query(string $sql, array $params = []): array { - $stmt = $this->run($sql, $params); + $stmt = $this->exec($sql, $params); if ($stmt->columnCount() === 0) { // OK result (no rowset) — empty array, matching the bridge. @@ -63,7 +63,7 @@ public function query(string $sql, array $params = []): array public function execute(string $sql, array $params = []): array { - $stmt = $this->run($sql, $params); + $stmt = $this->exec($sql, $params); return [ 'affected_rows' => $stmt->rowCount(), @@ -71,10 +71,51 @@ public function execute(string $sql, array $params = []): array ]; } + public function run(string $sql, array $params = []): array + { + $stmt = $this->exec($sql, $params); + $ncols = $stmt->columnCount(); + $hasRowset = $ncols > 0; + + // Column metadata is read from the statement, so it is present even + // for a zero-row result set (issue #262). + $columns = []; + for ($i = 0; $i < $ncols; $i++) { + /** @var array|false $meta */ + $meta = $stmt->getColumnMeta($i); + $decl = \is_array($meta) ? ($meta['sqlite:decl_type'] ?? null) : null; + $columns[] = [ + 'name' => \is_array($meta) ? (string) ($meta['name'] ?? '') : '', + 'type' => \is_string($decl) && $decl !== '' ? $decl : null, + ]; + } + + if ($hasRowset) { + /** @var list> $rows */ + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + return [ + 'has_rowset' => true, + 'rows' => $rows, + 'columns' => $columns, + 'affected_rows' => 0, + 'last_insert_id' => 0, + ]; + } + + return [ + 'has_rowset' => false, + 'rows' => [], + 'columns' => [], + 'affected_rows' => $stmt->rowCount(), + 'last_insert_id' => (int) $this->pdo->lastInsertId(), + ]; + } + /** * @param list $params */ - private function run(string $sql, array $params): \PDOStatement + private function exec(string $sql, array $params): \PDOStatement { try { $stmt = $this->pdo->prepare($sql); diff --git a/tests/EphpmConnectionTest.php b/tests/EphpmConnectionTest.php index 83163c3..685898f 100644 --- a/tests/EphpmConnectionTest.php +++ b/tests/EphpmConnectionTest.php @@ -320,6 +320,63 @@ public function unprepared_statements_run_through_the_bridge(): void $this->assertContains("insert into `users` (`name`) values ('Raw')", $ops->executed); } + #[Test] + public function insert_or_ignore_is_translated_to_sqlite_and_skips_conflicts(): void + { + $db = $this->connection($ops); + $db->table('users')->insert(['name' => 'Alice', 'email' => 'same@example.com']); + + // insertOrIgnore compiles to MySQL `insert ignore into`; the driver + // rewrites it to `insert or ignore into`, which the engine accepts. + $affected = $db->table('users')->insertOrIgnore([ + ['name' => 'Clone', 'email' => 'same@example.com'], // conflicts -> ignored + ['name' => 'Bob', 'email' => 'bob@example.com'], // new + ]); + + $this->assertSame(1, $affected); + $this->assertSame(2, $db->table('users')->count()); + $this->assertSame( + ['Alice', 'Bob'], + $db->table('users')->orderBy('id')->pluck('name')->all() + ); + + // What reached the bridge was the SQLite-compatible form. + $ranInsertOrIgnore = array_filter( + $ops->executed, + static fn (string $sql): bool => stripos($sql, 'insert or ignore into') !== false + ); + $this->assertNotEmpty($ranInsertOrIgnore); + $this->assertEmpty(array_filter( + $ops->executed, + static fn (string $sql): bool => stripos($sql, 'insert ignore') !== false + )); + } + + #[Test] + public function upsert_is_rejected_with_an_actionable_error(): void + { + $db = $this->connection($ops); + $db->table('users')->insert(['name' => 'Alice', 'email' => 'a@example.com']); + + try { + $db->table('users')->upsert( + [['name' => 'Alice II', 'email' => 'a@example.com', 'visits' => 5]], + ['email'], + ['visits'] + ); + $this->fail('expected upsert() to be rejected'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('upsert() is not supported', $e->getMessage()); + $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $e->getMessage()); + } + + // The ON DUPLICATE KEY UPDATE statement never reached the bridge. + $this->assertEmpty(array_filter( + $ops->executed, + static fn (string $sql): bool => stripos($sql, 'on duplicate key update') !== false + )); + } + #[Test] public function select_result_sets_is_unsupported(): void { diff --git a/tests/RecordingOps.php b/tests/RecordingOps.php index 671d011..491ba1d 100644 --- a/tests/RecordingOps.php +++ b/tests/RecordingOps.php @@ -36,4 +36,14 @@ public function execute(string $sql, array $params = []): array return $this->inner->execute($sql, $params); } + + public function run(string $sql, array $params = []): array + { + // run() is an execute-path call from the connection's point of view; + // record it in the same log so existing assertions on `executed` + // still see statements routed through the unified entry point. + $this->executed[] = $sql; + + return $this->inner->run($sql, $params); + } }