bleksak / mago-pdo-extension
Mago PDO extension for validating SQL queries and inferring their return type.
Requires
- bleksak/sqlite-toolkit: ^1.0
- carthage-software/mago: ^1.47
- phpmyadmin/sql-parser: ^6.0
Requires (Dev)
- phpunit/phpunit: ^13.3
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A Mago extension that verifies PDO queries are runnable in their current form by
executing EXPLAIN against a configured database (lint rule pdo/unrunnable-query), and
refines the return types of PDO query and fetch calls with the exact row shapes of the
configured schema (analyzer plugin pdo/query-analyzer).
How it works
The pdo/unrunnable-query lint rule targets ->query(), ->prepare(), and ->exec()
calls. For every call whose first argument is a literal SQL string, the rule:
- extracts the first top-level statement (only it executes anyway),
- skips statements
EXPLAINcannot handle (DDL,PRAGMA,SET, …), - normalizes PDO placeholders (
?,:name) when the driver needs it, - runs
EXPLAIN <statement>against the configured database and reportspdo/unrunnable-querywhen it fails.
Linter rules only see syntax, so calls are matched by method name on any receiver, and the query must be a plain string literal: dynamically built queries (variables, interpolation, concatenation) are skipped silently, as are unexplainable statements.
Both SQLite and MySQL accept EXPLAIN for SELECT, INSERT, UPDATE, DELETE, REPLACE, and
CTEs, so all of them are checked on either driver. The only driver difference is placeholder
normalization: SQLite accepts ? and :name inline, while other drivers (MySQL, …) need them
replaced with 1 because EXPLAIN runs through PDO::query(), not a prepared statement.
Configuration
The extension reads its verification connection from the worker environment (it never touches the host environment):
| Variable | Description |
|---|---|
MAGO_PDO_EXTENSION_SQLITE_PATH |
Path to a SQLite database file. Takes priority. |
MAGO_PDO_EXTENSION_MYSQL_HOST |
MySQL host. Enables MySQL when set. |
MAGO_PDO_EXTENSION_MYSQL_PORT |
MySQL port. |
MAGO_PDO_EXTENSION_MYSQL_USER |
MySQL user. |
MAGO_PDO_EXTENSION_MYSQL_PASSWORD |
MySQL password. |
MAGO_PDO_EXTENSION_MYSQL_DATABASE |
MySQL database (schema). |
Without a valid configuration the extension stays silent.
Return type inference
When a database is configured, the plugin also registers return type providers for
PDO::query(), PDO::prepare(), PDOStatement::fetch(), PDOStatement::fetchColumn(), and
PDOStatement::fetchAll(). A literal query that is verified as runnable (via EXPLAIN)
refines PDO::query() and PDO::prepare() to a non-falsy PDOStatement: since PHP 8.1
these methods throw a PDOException on failure instead of returning false, so once the
query is verified to run, no false check is needed. For SELECT statements the parser can
understand, the statement is a parameterized PDOStatement carrying the exact row shape:
$statement = $pdo->query('SELECT id, name, email FROM users'); // $statement: PDOStatement<array{id: int, name: string, email: string|null}> $row = $statement->fetch(); // array{id: int, name: string, email: string|null}|false
DML (INSERT/UPDATE/DELETE) refines to a plain PDOStatement (no row shape). On PHP < 8.1,
where query()/prepare() can still return false, the refined type keeps |false.
How it works:
- the query is verified against the configured database with
EXPLAIN— only runnable statements are refined, anything that fails or cannot be explained is left untouched, - the
SELECTis parsed into a table list and column list — withbleksak/sqlite-toolkitfor SQLite andphpmyadmin/sql-parserfor MySQL. Single-table statements, as well asJOINchains (INNER, CROSS and LEFT [OUTER]) with table aliases, get a row shape; anything else (unions, CTEs, comma joins, derived tables, subqueries inFROM) does not, - the table schema is resolved against the configured database — for
SQLite the objects are read from
sqlite_masterand theirCREATEstatements are parsed withbleksak/sqlite-toolkit(view andCREATE TABLE ... AS SELECTcolumns inherit the types of the columns they reference), for MySQL it queriesinformation_schema.COLUMNS— and memoized for the whole worker, - column types are mapped to the PHP types PDO actually returns: MySQL follows the declared type, SQLite follows its column affinity rules,
- the row shape is encoded into a named object parameter on the statement's return type, and
decoded again when
fetch()/fetchColumn()/fetchAll()is called on that statement.
SELECT * is expanded through the schema, COUNT(*) becomes int, CONCAT(...) becomes
string, CASE ... END becomes the common type of its branches (nullable without ELSE),
columns from a LEFT JOINed table are nullable, fetch(PDO::FETCH_OBJ) and
fetch(PDO::FETCH_CLASS) (which hydrates stdClass) return the object shape, and
fetchAll() returns list<row>.
The inference is an over-approximation by design: WHERE clauses are not evaluated, so a row
always contains every column of the table, null only where the schema allows it, and
false/empty outcomes are included wherever PDO can return them. Anything unrecognized falls
back to the native (unrefined) types, so the extension never reports a wrong type.
Using it in your own project
The extension is a regular Composer library (bleksak/mago-pdo-extension). A consuming project
needs two things:
-
Require the package from the GitHub repository:
composer require bleksak/mago-pdo-extension --dev
or in
composer.json:{ "require-dev": { "bleksak/mago-pdo-extension": "^0.0.1" } }For local development against a checkout, use a path repository instead:
{ "repositories": [ { "type": "path", "url": "/path/to/mago-pdo-extension" } ], "require-dev": { "bleksak/mago-pdo-extension": "@dev" } } -
An extension host in
mago.toml, plus the database connection for the worker. The package ships a ready-made worker entrypoint, so there is nothing to create — just point the host atvendor/bleksak/mago-pdo-extension/.mago/pdo-worker.php:[extension-hosts.pdo] command = ["php", "vendor/bleksak/mago-pdo-extension/.mago/pdo-worker.php"] environment = { MAGO_PDO_EXTENSION_SQLITE_PATH = "db/analysis.sqlite" }
For MySQL, use the
MAGO_PDO_EXTENSION_MYSQL_*variables instead (note thatenvironmentmap values are strings, so the port is quoted):[extension-hosts.pdo] command = ["php", "vendor/bleksak/mago-pdo-extension/.mago/pdo-worker.php"] environment = { MAGO_PDO_EXTENSION_MYSQL_HOST = "127.0.0.1", MAGO_PDO_EXTENSION_MYSQL_PORT = "3306", MAGO_PDO_EXTENSION_MYSQL_USER = "analyzer", MAGO_PDO_EXTENSION_MYSQL_PASSWORD = "secret", MAGO_PDO_EXTENSION_MYSQL_DATABASE = "app", }
The variables can also live in the shell environment instead of the map — the worker inherits it. The SQLite path may be relative (resolved against the worker's working directory) or absolute. The connection is only ever used for
EXPLAINand schema introspection, so point it at a read-only replica or a scratch copy of your schema.
No [analyzer] configuration is needed: the pdo/query-analyzer plugin is enabled by default
(unless your config sets disable-default-plugins = true). mago extension list --json shows
whether the host and extension are up. Without a valid database configuration the extension
stays completely silent.
Testing
Two layers, mirroring the mago-extension-template:
-
Unit tests (
tests/Unit/, PHPUnit) — cover the statement extraction/normalization logic, the connection provider, and plugin registration.just test -
Corpus (
tests/corpus/) — a small PHP project linted and analyzed by the realmagobinary with the extension host attached (tests/corpus/worker.php). Fixtures declare expected diagnostics with@mago-expect lint:pdo/unrunnable-query; runnable and skipped queries assert silence. The corpus also exercises return type inference:TypedQueries.phpasserts the inferred row shapes with typed expect helpers (plus one deliberate@mago-expect analysis:invalid-argumentcontrol), so an inference regression surfaces as a missing or wrong type. The corpus database is seeded bytests/corpus/seed.php(SQLite) andtests/corpus/seed-mysql.php(MySQL).just corpus # against the local SQLite database just corpus-mysql # against a local MySQL 8.0 podman container
just corpus-mysqlmanages the container itself (just mysqlstarts/reuses it,just mysql-downremoves it). Everything in one command:just check # SQLite corpus just check-mysql # also runs the MySQL corpus (requires the container)
The mago binary is taken from the local dev checkout
(../mago/target/release/mago); override with MAGO_BIN.