Skip to content

Conversation

@JanJakes
Copy link
Member

@JanJakes JanJakes commented Dec 5, 2025

This PR implements foundational PDO APIs and uses them in the existing WP_SQLite_Driver class.

The changes are best understood commit-by-commit, and they include the following:

  1. Make WP_PDO_MySQL_On_SQLite extend PDO.
  2. Implement PDO::exec() API.
  3. Implement PDO::query() API.
  4. Implement PDOStatement API for in-memory data (operating on PHP arrays) as WP_PDO_Synthetic_Statement. This is required for PDO::query() that must return a PDOStatement instance.
  5. Implement PDOStatement::fetch() and PDOStatement::fetchAll() with the most common PDO fetch modes.
  6. Implement PDO and PDOStatement getAttribute() and setAttribute() methods.
  7. Implement PDOStatement::setFetchMode().
  8. Fix some PDO compatibility issues across all supported PHP versions.

Synthetic vs. proxy PDO statement

This initial work implements only a WP_PDO_Synthetic_Statement that requires the full PDO statement result set to be loaded in memory. This makes it easier to implement a gradual transition to the PDO API (the current driver loads all result sets in memory as well) and it can support all statement types, including those that are composed on the PHP side.

In a follow-up work, it should be possible to transition most statement types to a proxy-like PDO statement that would internally use PDO SQLite statements directly, without eagerly fetching all data. The proxy will be required to address incompatibilities between SQLite and MySQL statements.

Next steps

Subsequent PRs will focus on the following:

  • Implement more of the PDO and PDOStatement APIs.
  • Implement a WP_PDO_Proxy_Statement as described above.
  • Simplify the SQLite driver state to only store a "last result statement" instead of the current list of properties.
  • Use the PDO API directly and remove the temporary proxy to the "old" driver API.

@JanJakes JanJakes force-pushed the pdo-api branch 8 times, most recently from fd4cc59 to c02c987 Compare December 10, 2025 19:48
@JanJakes JanJakes force-pushed the pdo-api branch 9 times, most recently from 12df654 to 9e8e453 Compare December 17, 2025 12:54
adamziel added a commit that referenced this pull request Dec 18, 2025
Extracting smaller chunks from
#291 so we
can review and merge them independently.

This PR includes the following changes:
- Removed support for nested transactions. MySQL doesn't support them.
- Implemented PDO-like APIs for start transaction, commit, and rollback.
- The PDO-like APIs differ from an equivalent SQL command in that they
always throw an exception in scenarios when a transaction was already
started or not yet started (depending on the method), irrespective of
the `ATTR_ERRMODE` setting.
- Polyfill `PDO::inTransaction()` on PHP < 8.4, where it is not reliable
([issue](https://bugs.php.net/bug.php?id=81227),
[PR](php/php-src#14268)).
adamziel added a commit that referenced this pull request Dec 18, 2025
Extracting smaller chunks from
#291 so we
can review and merge them independently.

This PR adds `Table` column to `SHOW CREATE TABLE` statements, [as per
MySQL behavior](https://onecompiler.com/mysql/447x6gea7).
adamziel added a commit that referenced this pull request Dec 18, 2025
Extracting smaller chunks from
#291 so we
can review and merge them independently.

This PR renames the current `WP_SQLite_Driver` to
`WP_PDO_MySQL_On_SQLite` and reintroduces the `WP_SQLite_Driver` as a
simple proxy over the new renamed class. It's done in these two steps
(first rename, then reintroduce) so that Git understands the rename and
presents history (hopefully) accurately.

The changes are better understood in a commit-by-commit view.

#### `WP_PDO_MySQL_On_SQLite` vs `WP_SQLite_Driver`
The "reintroduced" `WP_SQLite_Driver` is not meant to be permanent. It
is a temporary proxy so we can gradually modify `WP_PDO_MySQL_On_SQLite`
to support PDO APIs while not touching the driver API just yet. Once the
basics of PDO API are in place, we can make all dependencies use the new
class directly and then remove the `WP_SQLite_Driver`.

That is, in the future, the `WP_SQLite_DB extends wpdb` will use
directly the new `WP_PDO_MySQL_On_SQLite` class.
@JanJakes JanJakes force-pushed the pdo-api branch 2 times, most recently from 5fe6619 to 4aadd4e Compare December 18, 2025 16:05
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implies we're always loading the full result set into memory. Do we have to do that? I worry we could blow up memory on some large queries.

Copy link
Member Author

@JanJakes JanJakes Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have to do that?

@adamziel In most cases, fortunately not. But in some, when we construct the result in PHP (things like SHOW GRANTS, etc.), we need to have a "synthetic" statement that is implementing the PDO API on top of PHP arrays.

For all statements where a MySQL statement corresponds to an SQLite statement, it will be better to use a "proxy" statement. We can't use the SQLite statement directly because some things like column metadata formats differ, but we can use a proxy to avoid loading all results into memory. I actually have a class-wp-pdo-proxy-statement.php‎ in a WIP commit, but I'll probably extract it to a follow-up PR. Here's why I think it's fine:

In the first step, we can use the synthetic statement for everything because 1) the current driver already stores everything in memory ($this->last_result, etc.), and 2) $wpdb loads everything in memory as well. So I think I can complete this PR with the "synthetic" statement, and then in the next PR, I would replace all the $this->last_something props with a single $this->last_result_statement that we would use as the proxy target.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(things like SHOW GRANTS, etc.),

I don't suppose we would ever see a 100 megabyte result set in those so that seems fine.

In the first step, we can use the synthetic statement for everything because 1) the current driver already stores everything in memory ($this->last_result, etc.), and 2) $wpdb loads everything in memory as well. So I think I can complete this PR with the "synthetic" statement, and then in the next PR, I would replace all the $this->last_something props with a single $this->last_result_statement that we would use as the proxy target.

That sounds good. For Playground, the database is in memory anyway so it's not a big deal. It will matter when a larger site wants to adapt this driver, and perhaps we're reasonably close to having a technical ability to do it with all the MySQL queries we support. Not a huge priority, but it would be nice to briefly document in a docstring that yes, this is possible and here's what needs to happen technically.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in f7248a3, see also #291 (comment)

@JanJakes JanJakes force-pushed the pdo-api branch 2 times, most recently from 67a8782 to 3ce6a6c Compare January 16, 2026 12:52
@JanJakes
Copy link
Member Author

@adamziel I think this one is ready for a final round. In the end, it only needs a WP_PDO_Proxy_Statement that always wraps a real or a "dummy select" PDO SQLite statement.

I left many methods with throw new RuntimeException( 'Not implemented' );. This is on purpose—I know that just proxying to the underlying statement can implement most of them, but I want to add that with a test coverage and in a follow-up PR so that it's easier to review.

@JanJakes JanJakes requested a review from adamziel January 16, 2026 14:12
public function setUp(): void {
$connection = new WP_SQLite_Connection( array( 'path' => ':memory:' ) );
$this->driver = new WP_PDO_MySQL_On_SQLite( $connection, 'wp' );
$this->driver = new WP_PDO_MySQL_On_SQLite( 'mysql-on-sqlite:path=:memory:;dbname=wp;' );
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized PHP probably doesn't support paths such as ./mydb/one,two,three.sqlite (and so doesn't this implementation, which is absolutely fine).


// Run all tests with stringified fetch mode results, so we can use
// assertions that are consistent across all tested PHP versions.
// The "PDO::ATTR_STRINGIFY_FETCHES" mode is tested in separately.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick

Suggested change
// The "PDO::ATTR_STRINGIFY_FETCHES" mode is tested in separately.
// The "PDO::ATTR_STRINGIFY_FETCHES" mode is tested separately.

$this->assertInstanceOf( PDO::class, $driver );
}

public function test_dsn_parsing(): void {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you for these tests!


public function test_query_fetch_default_mode_allow_any_args(): void {
if ( PHP_VERSION_ID < 80100 ) {
// On PHP < 8.1, fetch mode value of NULL is not allowed.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

til

}

public function test_exec(): void {
$result = $this->driver->exec( 'SELECT 1' );
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's really cool how easy to use this is becoming.

* TODO: Consider supporting non-string values on PHP < 8.1 when both
* "PDO::ATTR_STRINGIFY_FETCHES" and "PDO::ATTR_EMULATE_PREPARES"
* are set to "false". This would require emulating the behavior,
* as PDO SQLite on PHP < 8.1 seems to always return strings.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would that be a real problem? It seems to only affect programs that perform this exact PHP_VERSION check to react differently to PDO outputs in different PHP versions.

);
}

public function data_pdo_fetch_methods(): Generator {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh! you can use a Generator as a data provider. This. Is. Amazing. Hey, @dmsnell @sirreal, I think Jan has found a solution to all our PHPCS problems in large data providers.

throw new PDOException( 'could not find driver' );
}

// PDO DSN supports semicolon quoting using double semicolon sequences.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did you mean semicolon escaping?

}

// PDO DSN supports semicolon quoting using double semicolon sequences.
// Replace ";;" with "\0" to preserve quoted semicolons in "explode()".
Copy link
Collaborator

@adamziel adamziel Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would conflate double semicolons with legitimate \0-s in the data. Replacing explode with strspn/strcspn would take more effort but yield more reliable results in those corner cases.

Also, is ;;; treated as ;<end of data> or <end of data>;?

// Restore quoted semicolons that were replaced with "\0".
$arg = str_replace( "\0", ';', $arg );

// PDO DSN allows whitespace before argument name.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any specific whitespace characters? do they overlap with what ltrim() removes, or do we need to list them all explicitly?


// Create a new SQLite connection.
if ( isset( $options['pdo'] ) ) {
$this->connection = new WP_SQLite_Connection( array( 'pdo' => $options['pdo'] ) );
Copy link
Collaborator

@adamziel adamziel Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, so we can pass both the dsn and $options['pdo']? And the dsn is still validated and parsed in that case? Shouldn't we throw an error when both are passed here?

return $type;
};

if ( null === $fetch_mode ) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sooo, now that we delegate the logic to PDO, do we need to validate all of that here? Or could we also rely on PDO validation?

*
* From MySQL documentation:
*
* In the absence of the SQL_CALC_FOUND_ROWS option in the most
Copy link
Collaborator

@adamziel adamziel Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hope noone out there actually relies on that behavior, but some plugins probably do. Obligatory xkcd: xkcd

* ...
*/

// Construct column header row ("SELECT <column-list> WHERE FALSE").
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

very clever!

* returns "0" (zero) with the SQLite driver at all times.
* See: https://www.php.net/manual/en/pdostatement.rowcount.php
* With 0 columns, we need to create a PDO statement that has no columns.
* This can be done using a noop INSERT statement that modifies no data.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow, clever

Copy link
Collaborator

@adamziel adamziel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only have nitpicks. Really good work here, it's exciting to see how this is coming together. If you asked me a few years ago, I would have never guessed we'd be doing anything like this and yet here we are ❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants