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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ translation the MySQL wire frontend serves, without the TCP round trip.
`SHOW TABLES`, `DESCRIBE`, `information_schema` queries, `SET NAMES`,
and `BEGIN`/`COMMIT`/`ROLLBACK` behave exactly as they do over the wire.

> **Requires ePHPm v0.6.3 or newer** (current release: v0.8.6). The
> **Requires ePHPm v0.6.3 or newer** (current release: v0.10.2). The
> `ephpm_db_*` SAPI functions (merged in
> [ephpm#257](https://github.com/ephpm/ephpm/pull/257)) first shipped in
> the v0.6.3 release, and they are only registered when `[db.sqlite]` is
Expand Down Expand Up @@ -55,7 +55,7 @@ and `BEGIN`/`COMMIT`/`ROLLBACK` behave exactly as they do over the wire.
core is pulled in as a **dev dependency only** (the test suite runs
against the real `class-wpdb.php`); the shipped package has no
WordPress Composer dependency.
- **ePHPm v0.6.3 or newer** (current release: v0.8.6), with
- **ePHPm v0.6.3 or newer** (current release: v0.10.2), with
`[db.sqlite]` configured.
The global `ephpm_db_query()` / `ephpm_db_execute()` functions are
registered by ePHPm's embedded PHP; under PHP-FPM, Apache mod_php, or
Expand Down
60 changes: 28 additions & 32 deletions src/Db.php
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,15 @@ public function query($query)
* private `_do_query()` (which performs the mysqli_query() call).
* Same SAVEQUERIES/timer/num_queries behavior as core.
*
* Statements that can produce a rowset route through
* `ephpm_db_query()`; everything else routes through
* `ephpm_db_execute()` so affected-rows/insert-id metadata is
* captured. Bridge exceptions are staged, not rethrown — wpdb's
* contract is error-in-band via `$last_error`.
* A single `ephpm_db_run()` executes the statement and reports what it
* actually did: `has_rowset` (read from the executed statement, never
* guessed from the first keyword), the rows, the column metadata (present
* even for a zero-row result set — issue #262), and the affected-rows /
* insert-id metadata. This replaces the previous hand-rolled keyword
* routing between `ephpm_db_query()` and `ephpm_db_execute()`; the ops
* backend preserves the older ePHPm floor by falling back to those two
* when `ephpm_db_run()` is absent. Bridge exceptions are staged, not
* rethrown — wpdb's contract is error-in-band via `$last_error`.
*
* @param string $query The query to run.
*/
Expand All @@ -278,14 +282,19 @@ private function _do_query($query)
$bridgeSql = self::translateMysqlToBridge($query);

try {
if ($this->is_rowset_query($bridgeSql)) {
$rows = $this->dbOps->query($bridgeSql);
$this->bridgeRows = $rows;
if (isset($rows[0])) {
$this->bridgeColNames = array_keys($rows[0]);
}
$result = $this->dbOps->run($bridgeSql);

// Column names come from the executed statement's metadata, so
// they survive a zero-row result set (issue #262).
$this->bridgeColNames = array_column($result['columns'] ?? [], 'name');

if (!empty($result['has_rowset'])) {
$this->bridgeRows = $result['rows'] ?? [];
} else {
$this->bridgeOk = $this->dbOps->execute($bridgeSql);
$this->bridgeOk = [
'affected_rows' => (int) ($result['affected_rows'] ?? 0),
'last_insert_id' => (int) ($result['last_insert_id'] ?? 0),
];
}
} catch (\Throwable $e) {
$this->bridgeError = $e->getMessage();
Expand All @@ -305,21 +314,6 @@ private function _do_query($query)
}
}

/**
* Whether a statement should route through `ephpm_db_query()`
* (rowset-shaped) rather than `ephpm_db_execute()` (OK-shaped).
*/
protected function is_rowset_query(string $query): bool
{
$q = ltrim($query, " \t\r\n(");
// Strip leading comments so /* hints */ don't confuse routing.
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);
}

// ── MySQL-dialect fix-ups the embedded engine can't do ───────────────

/**
Expand Down Expand Up @@ -738,11 +732,13 @@ public function get_col_charset($table, $column)

/**
* Synthesizes column metadata from the last rowset. The bridge
* returns rows keyed by column name but no field metadata, so only
* `name`/`orgname` are meaningful; the other mysqli field properties
* are present (so `get_col_info()` calls don't error) but carry
* placeholder values. An empty rowset has no column names at all —
* a documented bridge limitation.
* reports each column's name (and declared type, unused here) via
* `ephpm_db_run()`/`ephpm_db_columns()`, so only `name`/`orgname` are
* meaningful; the other mysqli field properties are present (so
* `get_col_info()` calls don't error) but carry placeholder values.
* The column names are read from the executed statement's metadata, so
* they are available even for a zero-row result set (issue #262) — the
* former "empty rowset has no column names" limitation is gone.
*/
protected function load_col_info()
{
Expand Down
20 changes: 20 additions & 0 deletions src/DbOpsInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,24 @@ public function query(string $sql, array $params = []): array;
* @throws \Exception on a database error (code = MySQL errno).
*/
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 the authoritative discriminator, read from the
* executed statement rather than inferred from the SQL's first keyword.
* `rows` is always an array (empty when `has_rowset` is false).
* `columns` carries the column metadata as a list of
* `['name' => string, 'type' => ?string]`, present even for a zero-row
* result set (ePHPm issue #262) — which the rows alone cannot supply.
* `affected_rows`/`last_insert_id` are zero for a result set.
*
* @param list<mixed> $params
*
* @return array{has_rowset: bool, rows: list<array<string, int|float|string|null>>, columns: list<array{name: string, type: ?string}>, affected_rows: int, last_insert_id: int}
*
* @throws \Exception on a database error (code = MySQL errno).
*/
public function run(string $sql, array $params = []): array;
}
57 changes: 54 additions & 3 deletions src/PdoSqliteDbOps.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public function query(string $sql, array $params = []): array
if ($this->isNoOp($sql)) {
return [];
}
$stmt = $this->run($sql, $params);
$stmt = $this->exec($sql, $params);
if ($stmt->columnCount() === 0) {
return []; // No-rowset statement routed through query().
}
Expand All @@ -55,21 +55,72 @@ public function execute(string $sql, array $params = []): array
if ($this->isNoOp($sql)) {
return ['affected_rows' => 0, 'last_insert_id' => 0];
}
$stmt = $this->run($sql, $params);
$stmt = $this->exec($sql, $params);

return [
'affected_rows' => $stmt->rowCount(),
'last_insert_id' => (int) $this->pdo->lastInsertId(),
];
}

public function run(string $sql, array $params = []): array
{
if ($this->isNoOp($sql)) {
return [
'has_rowset' => false,
'rows' => [],
'columns' => [],
'affected_rows' => 0,
'last_insert_id' => 0,
];
}

$stmt = $this->exec($sql, $params);
$ncols = $stmt->columnCount();
$hasRowset = $ncols > 0;

// Column metadata is read straight from the statement, so it is
// present even when the result set matched zero rows (issue #262).
$columns = [];
for ($i = 0; $i < $ncols; $i++) {
/** @var array<string, mixed>|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<array<string, int|float|string|null>> $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(),
];
}

/** `SET NAMES` and friends are dialect no-ops in litewire. */
private function isNoOp(string $sql): bool
{
return (bool) preg_match('/^\s*SET\s/i', $sql);
}

private function run(string $sql, array $params): \PDOStatement
private function exec(string $sql, array $params): \PDOStatement
{
try {
$stmt = $this->pdo->prepare(self::mysqlToSqlite($sql));
Expand Down
76 changes: 76 additions & 0 deletions src/SapiDbOps.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,80 @@ 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, int|float|string|null>>, 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
// (< v0.6.3 introspection surface): classify by keyword, then
// synthesize the unified shape. Column names for a zero-row result
// come from ephpm_db_columns() when the build has it (also newer),
// else from the first row — the pre-#262 best effort.
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 when the
* native `ephpm_db_run()` is absent. When the native is present it does
* this classification authoritatively from the executed statement.
*/
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);
}

/**
* Best-effort column metadata from the first row, for the fallback path
* on a build lacking ephpm_db_columns(). A zero-row result yields no
* names here — the very gap #262 closes on a current ePHPm.
*
* @param list<array<string, int|float|string|null>> $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;
}
}
9 changes: 5 additions & 4 deletions tests/DbTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,11 @@ public function testGetColInfoReturnsColumnNames(): void
$db->get_results('SELECT id, name AS label FROM wp_items');
$this->assertSame(['id', 'label'], $db->get_col_info('name'));

// Documented bridge limitation: an empty rowset carries no column
// metadata, so get_col_info() has nothing to report.
$db->get_results('SELECT id FROM wp_items WHERE id = -1');
$this->assertNull($db->get_col_info('name'));
// A zero-row result set still carries its column names now that the
// metadata comes from the executed statement, not the rows
// (ephpm_db_run()/ephpm_db_columns(), issue #262).
$db->get_results('SELECT id, name AS label FROM wp_items WHERE id = -1');
$this->assertSame(['id', 'label'], $db->get_col_info('name'));
}

// ── Errors are in-band (wpdb contract) ──────────────────────────────
Expand Down
Loading