Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,64 @@ 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

- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: pdo_sqlite, mbstring
coverage: none
tools: composer:v2

- name: Cache composer
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
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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) |
Expand All @@ -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
Expand All @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/DbOpsInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,24 @@ public function query(string $sql, array $params = []): array;
* `SQLSTATE[xxxxx]: <backend message>`
*/
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<null|bool|int|float|string> $params positional `?` bindings
*
* @return array{has_rowset: bool, rows: list<array<string, null|int|float|string>>, columns: list<array{name: string, type: ?string}>, affected_rows: int, last_insert_id: int}
*
* @throws \Exception code = MySQL errno (e.g. 1062), message
* `SQLSTATE[xxxxx]: <backend message>`
*/
public function run(string $sql, array $params = []): array;
}
67 changes: 62 additions & 5 deletions src/EphpmConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,17 +176,22 @@ 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) {
if ($this->pretending()) {
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;
});
Expand All @@ -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) {
Expand All @@ -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,
Expand Down
78 changes: 72 additions & 6 deletions src/SapiDbOps.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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.'
);
}
}
Expand All @@ -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<array<string, null|int|float|string>>, columns: list<array{name: string, type: ?string}>, 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<array<string, null|int|float|string>> $rows
*
* @return list<array{name: string, type: ?string}>
*/
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;
}
}
Loading
Loading