diff --git a/README.md b/README.md index a001151..329061d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/src/Db.php b/src/Db.php index 2107b62..1f04386 100644 --- a/src/Db.php +++ b/src/Db.php @@ -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. */ @@ -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(); @@ -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 ─────────────── /** @@ -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() { diff --git a/src/DbOpsInterface.php b/src/DbOpsInterface.php index f01de34..054bf7c 100644 --- a/src/DbOpsInterface.php +++ b/src/DbOpsInterface.php @@ -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 $params + * + * @return array{has_rowset: bool, rows: list>, columns: list, affected_rows: int, last_insert_id: int} + * + * @throws \Exception on a database error (code = MySQL errno). + */ + public function run(string $sql, array $params = []): array; } diff --git a/src/PdoSqliteDbOps.php b/src/PdoSqliteDbOps.php index a27a3db..e989ecd 100644 --- a/src/PdoSqliteDbOps.php +++ b/src/PdoSqliteDbOps.php @@ -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(). } @@ -55,7 +55,7 @@ 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(), @@ -63,13 +63,64 @@ public function execute(string $sql, array $params = []): array ]; } + 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|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(), + ]; + } + /** `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)); diff --git a/src/SapiDbOps.php b/src/SapiDbOps.php index 5f01f71..6476ec2 100644 --- a/src/SapiDbOps.php +++ b/src/SapiDbOps.php @@ -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>, 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 + // (< 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> $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/tests/DbTest.php b/tests/DbTest.php index 4fe3449..3a0d398 100644 --- a/tests/DbTest.php +++ b/tests/DbTest.php @@ -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) ──────────────────────────────