Skip to content

Commit 211fd2b

Browse files
authored
MySQL Proxy for SQLite (#272)
Implements a basic **MySQL proxy** that bridges the MySQL wire protocol to the SQLite driver, based on adamziel/mysql-sqlite-network-proxy#1. This enables the usage of [phpMyAdmin](https://www.phpmyadmin.net/), [Adminer](https://github.com/vrana/adminer), and other tools on top of SQLite. The PR includes tests for `PDO` and `mysqli` connections and query commands. See also [the MySQL proxy package README](https://github.com/WordPress/sqlite-database-integration/blob/mysql-server/packages/wp-mysql-proxy/README.md). **CLI usage:** ```bash $ php bin/wp-mysql-proxy.php [--port <port>] [--database <path/to/db.sqlite>] [--log-level <log_level>] Options: -h, --help Show this help message and exit. -p, --port=<port> The port to listen on. Default: 3306 -d, --database=<path> The path to the SQLite database file. Default: :memory: -l, --log-level=<level> The log level to use. One of 'error', 'warning', 'info', 'debug'. Default: info ``` **PHP usage:** ```php use WP_MySQL_Proxy\MySQL_Proxy; use WP_MySQL_Proxy\Adapter\SQLite_Adapter; require_once __DIR__ . '/vendor/autoload.php'; $proxy = new MySQL_Proxy( new SQLite_Adapter( $db_path ), array( 'port' => $port, 'log_level' => $log_level ) ); $proxy->start(); ```
2 parents e46ad75 + c0f27bb commit 211fd2b

20 files changed

+1671
-0
lines changed

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ phpunit.xml.dist export-ignore
88
wp-setup.sh export-ignore
99
/.github export-ignore
1010
/grammar-tools export-ignore
11+
/packages export-ignore
1112
/tests export-ignore
1213
/wp-includes/sqlite/class-wp-sqlite-crosscheck-db.php export-ignore
1314
/wordpress export-ignore
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: MySQL Proxy Tests
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
9+
jobs:
10+
test:
11+
name: MySQL Proxy Tests
12+
runs-on: ubuntu-latest
13+
timeout-minutes: 20
14+
15+
steps:
16+
- name: Checkout repository
17+
uses: actions/checkout@v4
18+
19+
- name: Set up PHP
20+
uses: shivammathur/setup-php@v2
21+
with:
22+
php-version: '7.4'
23+
24+
- name: Install Composer dependencies
25+
uses: ramsey/composer-install@v3
26+
with:
27+
ignore-cache: "yes"
28+
composer-options: "--optimize-autoloader"
29+
working-directory: packages/wp-mysql-proxy
30+
31+
- name: Run MySQL Proxy tests
32+
run: composer run test
33+
working-directory: packages/wp-mysql-proxy

packages/wp-mysql-proxy/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# WP MySQL Proxy
2+
A MySQL proxy that bridges the MySQL wire protocol to a PDO-like interface.
3+
4+
This is a zero-dependency, pure PHP implementation of a MySQL proxy that acts as
5+
a MySQL server, accepts MySQL-native commands, and executes them using a configurable
6+
PDO-like driver. This allows MySQL-compatible clients to connect and run queries
7+
against alternative database backends over the MySQL wire protocol.
8+
9+
Combined with the **WP SQLite Driver**, this allows MySQL-based projects to run
10+
on SQLite.
11+
12+
## Usage
13+
14+
### CLI:
15+
16+
```bash
17+
$ php bin/wp-mysql-proxy.php [--port <port>] [--database <path/to/db.sqlite>] [--log-level <log_level>]
18+
19+
Options:
20+
-h, --help Show this help message and exit.
21+
-p, --port=<port> The port to listen on. Default: 3306
22+
-d, --database=<path> The path to the SQLite database file. Default: :memory:
23+
-l, --log-level=<level> The log level to use. One of 'error', 'warning', 'info', 'debug'. Default: info
24+
```
25+
26+
### PHP:
27+
```php
28+
use WP_MySQL_Proxy\MySQL_Proxy;
29+
use WP_MySQL_Proxy\Adapter\SQLite_Adapter;
30+
31+
require_once __DIR__ . '/vendor/autoload.php';
32+
33+
$proxy = new MySQL_Proxy(
34+
new SQLite_Adapter( $db_path ),
35+
array( 'port' => $port, 'log_level' => $log_level )
36+
);
37+
$proxy->start();
38+
```
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php declare( strict_types = 1 );
2+
3+
use WP_MySQL_Proxy\MySQL_Proxy;
4+
use WP_MySQL_Proxy\Adapter\SQLite_Adapter;
5+
use WP_MySQL_Proxy\Logger;
6+
7+
require_once __DIR__ . '/../vendor/autoload.php';
8+
9+
define( 'WP_SQLITE_AST_DRIVER', true );
10+
11+
// Process CLI arguments:
12+
$shortopts = 'h:d:p:l:';
13+
$longopts = array( 'help', 'database:', 'port:', 'log-level:' );
14+
$opts = getopt( $shortopts, $longopts );
15+
16+
$help = <<<USAGE
17+
Usage: php bin/wp-mysql-proxy.php [--port <port>] [--database <path/to/db.sqlite>] [--log-level <log_level>]
18+
19+
Options:
20+
-h, --help Show this help message and exit.
21+
-p, --port=<port> The port to listen on. Default: 3306
22+
-d, --database=<path> The path to the SQLite database file. Default: :memory:
23+
-l, --log-level=<level> The log level to use. One of 'error', 'warning', 'info', 'debug'. Default: info
24+
25+
USAGE;
26+
27+
// Help.
28+
if ( isset( $opts['h'] ) || isset( $opts['help'] ) ) {
29+
fwrite( STDERR, $help );
30+
exit( 0 );
31+
}
32+
33+
// Database path.
34+
$db_path = $opts['d'] ?? $opts['database'] ?? ':memory:';
35+
36+
// Port.
37+
$port = (int) ( $opts['p'] ?? $opts['port'] ?? 3306 );
38+
if ( $port < 1 || $port > 65535 ) {
39+
fwrite( STDERR, "Error: --port must be an integer between 1 and 65535. Use --help for more information.\n" );
40+
exit( 1 );
41+
}
42+
43+
// Log level.
44+
$log_level = $opts['l'] ?? $opts['log-level'] ?? 'info';
45+
if ( ! in_array( $log_level, Logger::LEVELS, true ) ) {
46+
fwrite( STDERR, 'Error: --log-level must be one of: ' . implode( ', ', Logger::LEVELS ) . ". Use --help for more information.\n" );
47+
exit( 1 );
48+
}
49+
50+
// Start the MySQL proxy.
51+
$proxy = new MySQL_Proxy(
52+
new SQLite_Adapter( $db_path ),
53+
array(
54+
'port' => $port,
55+
'log_level' => $log_level,
56+
)
57+
);
58+
$proxy->start();
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "wordpress/wp-mysql-proxy",
3+
"type": "library",
4+
"bin": [
5+
"bin/wp-mysql-proxy.php"
6+
],
7+
"scripts": {
8+
"test": "phpunit"
9+
},
10+
"require-dev": {
11+
"phpunit/phpunit": "^8.5",
12+
"symfony/process": "^5.4"
13+
},
14+
"autoload": {
15+
"classmap": [
16+
"src/"
17+
],
18+
"files": [
19+
"../../php-polyfills.php"
20+
]
21+
}
22+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<phpunit bootstrap="tests/bootstrap/bootstrap.php" colors="true" stopOnFailure="false">
3+
<testsuites>
4+
<testsuite name="WP MySQL Proxy Test Suite">
5+
<directory>tests/</directory>
6+
</testsuite>
7+
</testsuites>
8+
</phpunit>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php declare( strict_types = 1 );
2+
3+
namespace WP_MySQL_Proxy\Adapter;
4+
5+
use WP_MySQL_Proxy\MySQL_Result;
6+
7+
interface Adapter {
8+
public function handle_query( string $query ): MySQL_Result;
9+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php declare( strict_types = 1 );
2+
3+
namespace WP_MySQL_Proxy\Adapter;
4+
5+
use PDOException;
6+
use Throwable;
7+
use WP_MySQL_Proxy\MySQL_Result;
8+
use WP_SQLite_Connection;
9+
use WP_SQLite_Driver;
10+
use WP_MySQL_Proxy\MySQL_Protocol;
11+
12+
define( 'SQLITE_DRIVER_PATH', __DIR__ . '/../../../..' );
13+
14+
require_once SQLITE_DRIVER_PATH . '/version.php';
15+
require_once SQLITE_DRIVER_PATH . '/wp-includes/parser/class-wp-parser-grammar.php';
16+
require_once SQLITE_DRIVER_PATH . '/wp-includes/parser/class-wp-parser.php';
17+
require_once SQLITE_DRIVER_PATH . '/wp-includes/parser/class-wp-parser-node.php';
18+
require_once SQLITE_DRIVER_PATH . '/wp-includes/parser/class-wp-parser-token.php';
19+
require_once SQLITE_DRIVER_PATH . '/wp-includes/mysql/class-wp-mysql-token.php';
20+
require_once SQLITE_DRIVER_PATH . '/wp-includes/mysql/class-wp-mysql-lexer.php';
21+
require_once SQLITE_DRIVER_PATH . '/wp-includes/mysql/class-wp-mysql-parser.php';
22+
require_once SQLITE_DRIVER_PATH . '/wp-includes/sqlite/class-wp-sqlite-pdo-user-defined-functions.php';
23+
require_once SQLITE_DRIVER_PATH . '/wp-includes/sqlite-ast/class-wp-sqlite-connection.php';
24+
require_once SQLITE_DRIVER_PATH . '/wp-includes/sqlite-ast/class-wp-sqlite-configurator.php';
25+
require_once SQLITE_DRIVER_PATH . '/wp-includes/sqlite-ast/class-wp-sqlite-driver.php';
26+
require_once SQLITE_DRIVER_PATH . '/wp-includes/sqlite-ast/class-wp-sqlite-driver-exception.php';
27+
require_once SQLITE_DRIVER_PATH . '/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php';
28+
require_once SQLITE_DRIVER_PATH . '/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-exception.php';
29+
require_once SQLITE_DRIVER_PATH . '/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-reconstructor.php';
30+
31+
class SQLite_Adapter implements Adapter {
32+
/** @var WP_SQLite_Driver */
33+
private $sqlite_driver;
34+
35+
public function __construct( $sqlite_database_path ) {
36+
define( 'FQDB', $sqlite_database_path );
37+
define( 'FQDBDIR', dirname( FQDB ) . '/' );
38+
39+
$this->sqlite_driver = new WP_SQLite_Driver(
40+
new WP_SQLite_Connection( array( 'path' => $sqlite_database_path ) ),
41+
'sqlite_database'
42+
);
43+
}
44+
45+
public function handle_query( string $query ): MySQL_Result {
46+
$affected_rows = 0;
47+
$last_insert_id = null;
48+
$columns = array();
49+
$rows = array();
50+
51+
try {
52+
$return_value = $this->sqlite_driver->query( $query );
53+
$last_insert_id = $this->sqlite_driver->get_insert_id() ?? null;
54+
if ( is_numeric( $return_value ) ) {
55+
$affected_rows = (int) $return_value;
56+
} elseif ( is_array( $return_value ) ) {
57+
$rows = $return_value;
58+
}
59+
if ( $this->sqlite_driver->get_last_column_count() > 0 ) {
60+
$columns = $this->computeColumnInfo();
61+
}
62+
return MySQL_Result::from_data( $affected_rows, $last_insert_id, $columns, $rows ?? array() );
63+
} catch ( Throwable $e ) {
64+
$error_info = $e->errorInfo ?? null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
65+
if ( $e instanceof PDOException && $error_info ) {
66+
return MySQL_Result::from_error( $error_info[0], $error_info[1], $error_info[2] );
67+
}
68+
return MySQL_Result::from_error( 'HY000', 1105, $e->getMessage() ?? 'Unknown error' );
69+
}
70+
}
71+
72+
public function computeColumnInfo() {
73+
$columns = array();
74+
75+
$column_meta = $this->sqlite_driver->get_last_column_meta();
76+
77+
$types = array(
78+
'DECIMAL' => MySQL_Protocol::FIELD_TYPE_DECIMAL,
79+
'TINY' => MySQL_Protocol::FIELD_TYPE_TINY,
80+
'SHORT' => MySQL_Protocol::FIELD_TYPE_SHORT,
81+
'LONG' => MySQL_Protocol::FIELD_TYPE_LONG,
82+
'FLOAT' => MySQL_Protocol::FIELD_TYPE_FLOAT,
83+
'DOUBLE' => MySQL_Protocol::FIELD_TYPE_DOUBLE,
84+
'NULL' => MySQL_Protocol::FIELD_TYPE_NULL,
85+
'TIMESTAMP' => MySQL_Protocol::FIELD_TYPE_TIMESTAMP,
86+
'LONGLONG' => MySQL_Protocol::FIELD_TYPE_LONGLONG,
87+
'INT24' => MySQL_Protocol::FIELD_TYPE_INT24,
88+
'DATE' => MySQL_Protocol::FIELD_TYPE_DATE,
89+
'TIME' => MySQL_Protocol::FIELD_TYPE_TIME,
90+
'DATETIME' => MySQL_Protocol::FIELD_TYPE_DATETIME,
91+
'YEAR' => MySQL_Protocol::FIELD_TYPE_YEAR,
92+
'NEWDATE' => MySQL_Protocol::FIELD_TYPE_NEWDATE,
93+
'VARCHAR' => MySQL_Protocol::FIELD_TYPE_VARCHAR,
94+
'BIT' => MySQL_Protocol::FIELD_TYPE_BIT,
95+
'NEWDECIMAL' => MySQL_Protocol::FIELD_TYPE_NEWDECIMAL,
96+
'ENUM' => MySQL_Protocol::FIELD_TYPE_ENUM,
97+
'SET' => MySQL_Protocol::FIELD_TYPE_SET,
98+
'TINY_BLOB' => MySQL_Protocol::FIELD_TYPE_TINY_BLOB,
99+
'MEDIUM_BLOB' => MySQL_Protocol::FIELD_TYPE_MEDIUM_BLOB,
100+
'LONG_BLOB' => MySQL_Protocol::FIELD_TYPE_LONG_BLOB,
101+
'BLOB' => MySQL_Protocol::FIELD_TYPE_BLOB,
102+
'VAR_STRING' => MySQL_Protocol::FIELD_TYPE_VAR_STRING,
103+
'STRING' => MySQL_Protocol::FIELD_TYPE_STRING,
104+
'GEOMETRY' => MySQL_Protocol::FIELD_TYPE_GEOMETRY,
105+
);
106+
107+
foreach ( $column_meta as $column ) {
108+
$type = $types[ $column['native_type'] ] ?? null;
109+
if ( null === $type ) {
110+
throw new Exception( 'Unknown column type: ' . $column['native_type'] );
111+
}
112+
$columns[] = array(
113+
'name' => $column['name'],
114+
'length' => $column['len'],
115+
'type' => $type,
116+
'flags' => 129,
117+
'decimals' => $column['precision'],
118+
);
119+
}
120+
return $columns;
121+
}
122+
}

0 commit comments

Comments
 (0)