Menu

ext/pdo + pdo_sqlite, pdo_pgsql, pdo_mysql

PHP Data Objects with three drivers: SQLite (libsqlite3 over FFI), PostgreSQL and MySQL / MariaDB (the wire protocols in pure PHP over a socket: no libpq, no libmysqlclient). new PDO('sqlite::memory:') works, and so do prepared statements, the array fetch modes, transactions, and php's three error modes. The network drivers are sections pgsql: and mysql: below.

$db = new PDO('sqlite:/tmp/app.db');
$db->exec('CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)');

$ins = $db->prepare('INSERT INTO t (name) VALUES (:name)');
$ins->execute([':name' => 'ann']);
echo $db->lastInsertId();

foreach ($db->query('SELECT id, name FROM t')->fetchAll(PDO::FETCH_ASSOC) as $row) {
    echo $row['id'], ' ', $row['name'], "\n";
}

Where it lives, and why#

Prelude files, all global-namespace, all demand-gated on a program mentioning PDO / PDOStatement / PDOException / PDORow:

filewhat
prelude/pdo.phpthe driver-agnostic facade: PDO, PDOStatement, PDOException, PDORow, every PDO:: constant, the fetch modes, the DSN split, and the __McPdoDrv / __McPdoDrvStmt seam
prelude/pdo_sqlite.phpthe SQLite driver: ~30 libsqlite3 FFI bindings and the two classes that satisfy the seam
prelude/pdo_sql.phpwhat the two network drivers share: the SQL scanner (placeholders, quoting, comments) and the TLS handshake over libssl
prelude/pdo_pgsql.phpthe PostgreSQL driver (protocol 3.0)
prelude/pdo_mysql.phpthe MySQL / MariaDB driver (client/server protocol)
prelude/pdo_pgsql_off.php, prelude/pdo_mysql_off.phpstand-ins that answer could not find driver when the program does not name that driver (see below)

The prelude and not src/Runtime/Stdlib for the same reason as ext/curl: the stdlib .o.sig carries FUNCTIONS ONLY. new PDO(...) hands back an object and PDO::FETCH_ASSOC is a class constant, and a class declared in the stdlib is never registered by the program holding one of its instances — instanceof reads false and its properties come back as raw bits.

Linking. #[\Ffi\Library('sqlite3')] is the whole story: the emitter collects the library off every wrapper it emits and generic_link_flags() resolves it through pkg-config --libs sqlite3. A binary that never mentions PDO never emits a wrapper and never links -lsqlite3. The network drivers add no library of their own: a socket, libssl (already in every binary) and, for mysql caching_sha2_password, openssl_public_encrypt (every PDO program therefore carries the openssl prelude).

The driver seam#

PDO and PDOStatement hold no database knowledge. They delegate to two interfaces, and everything database-shaped lives behind them. pgsql: and mysql: are the opposite shape to sqlite — a socket and byte parsing, no FFI — and implement the same two interfaces without touching the facade.

SQLite deserves saying plainly: it is not a server. There is no wire protocol and nothing to unpack; sqlite3_step is a pread on a local file. That is why this driver is FFI and the network drivers will not be.

Adding a driver means: implement __McPdoDrv + __McPdoDrvStmt, add one arm to the scheme match in PDO::__construct, and add the driver to the gate in Main.php.

The driver gate. A DSN scheme is a runtime string and PreludeDemand is token-based, so the compiler cannot know which driver new PDO($dsn) will open. It reads the construction sites instead (new PDO(..), PDO::connect(..)):

The point is compile time: loading both network drivers (and the openssl prelude they need) into a small sqlite program cost about 40% of its compile time (19.5 s against 13.7 s measured on pdo_sqlite_basic); with the gate it compiles as fast as before the drivers existed. Tests: pdo_gate_sqlite_only, pdo_gate_pgsql_only, pdo_gate_dynamic_dsn.

Why there are no trampolines here#

ext/curl needed four fixed trampolines because libcurl takes callbacks. This driver takes none: sqlite3_exec's row callback is avoidable by driving prepare/step directly, so nothing here runs PHP on a C frame and the "an exception must not escape a callback" hazard never arises.

No placeholder rewriting#

sqlite binds ?, :name, @name and $name natively, and sqlite3_bind_parameter_index() maps a name to its index exactly — including the quoting and comment rules it already had to implement to parse the statement. SELECT ':id' AS lit WHERE id = :id -- :nope binds one parameter, and we did not write a line of SQL scanning to get that. An emulated-prepare driver would need its own scanner; that is the driver's business, not the facade's.

Async#

The API is synchronous, like Zend's. For sqlite the scheduler shows up in one place: SQLITE_BUSY. (The network drivers park the fiber in every socket read; see "Async" under pgsql: and mysql: below.)

sqlite3_busy_timeout is never called — it sleeps inside a C frame, which stalls the whole event loop, netpoller included, so one contended database would freeze every unrelated fiber in an Http\Server process. Instead the driver owns its retry loop and every retry goes through __McPdoSq::backoff(), which parks the fiber when a scheduler is running and falls back to usleep() when one is not. The wait is bounded by PDO::ATTR_TIMEOUT (30 s default).

The hook is \Runtime\AsyncHook and deliberately not Async\: prelude/async.php is demand-gated on a program mentioning Async\, so naming it here would drag the entire scheduler into every synchronous PDO program. AsyncHook lives in src/ and is always present; with no scheduler installed active() is one null check.

sqlite3_progress_handler is deliberately not bound. A fiber here is a stackful fcontext switch, and every suspend point can raise CancelledException — including on resume. That throw would longjmp past sqlite's own half-updated frames, which docs/ffi.md forbids.

Deliberate divergences from php#

getColumnMeta()['table'] is not a divergence: sqlite3_column_table_name() is bound #[\Ffi\Weak] and called only when sqlite3_compileoption_used('ENABLE_COLUMN_METADATA') says this libsqlite3 has it — sqlite's own answer, so no dlsym and no OS branch. A build without the option omits the key, which is also what php does for an expression column.

Cursor orientation is not one either: $cursorOrientation and $cursorOffset are accepted and ignored, exactly as pdo_sqlite does — a sqlite statement is forward-only and php returns the next row whatever is asked for (measured: ORI_PRIOR / FIRST / LAST / ABS / REL all behave as ORI_NEXT). Throwing was stricter than the oracle.

pgsql: (prelude/pdo_pgsql.php)#

Protocol 3.0 over a plain socket. DSN: pgsql:host=..;port=..;dbname=.. plus hostaddr, user, password and the ssl* keys of the TLS section below. user= / password= in the DSN beat the constructor arguments (php does the same); host defaults to localhost, port to 5432, dbname to the user. Authentication: trust, cleartext, md5, SCRAM-SHA-256 (the server must prove its identity, nonce and iteration count are validated). Statements are always server-side (extended protocol, named statements; ATTR_EMULATE_PREPARES is stored and ignored); PDO::PGSQL_ATTR_DISABLE_PREPARES re-parses an unnamed statement at every execute(). Placeholders ? and :name are rewritten to $n by the shared scanner (comments, quotes, dollar quoting, ??). Values are typed like pdo_pgsql (ints, floats, bool, bytea, text). errorInfo carries the server's SQLSTATE and message in libpq's rendering; a connect failure is SQLSTATE[08006] [7] ... Attributes: ATTR_SERVER_VERSION, ATTR_SERVER_INFO (from ParameterStatus), ATTR_CONNECTION_STATUS, ATTR_CLIENT_VERSION (manticore-pgwire 3.0), ATTR_TIMEOUT, PGSQL_ATTR_FETCH_BATCH (superset, see Unbuffered cursors). Further sections: TLS, LISTEN/NOTIFY and COPY, unbuffered cursors, pooling.

Async (shared with mysql). A socket read parks the fiber, so a query never stalls the loop; one connection is one protocol state machine:

mysql: (prelude/pdo_mysql.php)#

The client/server protocol over a plain socket, no libmysqlclient. Text protocol with emulated (client-side) prepares, as pdo_mysql defaults to (ATTR_EMULATE_PREPARES=false selects the binary protocol, below); typed like mysqlnd (ints and floats native, DECIMAL / DATE / YEAR / strings as text, BIT as an integer, an UNSIGNED BIGINT above PHP_INT_MAX as a string). Connection options by number: 1004 FOUND_ROWS, 1002 INIT_COMMAND, 1012 MULTI_STATEMENTS (on by default, as php), ATTR_TIMEOUT.

Native prepared statements (ATTR_EMULATE_PREPARES=false)#

Set in the constructor options or with setAttribute() (read at each prepare(), so the two kinds can be mixed on one connection). prepare() then sends COM_STMT_PREPARE: a syntax error, an unknown table or column (1064 / 1146 / 1054) and the max_prepared_stmt_count limit (1461) are raised by prepare(), not by execute() (emulated statements fail at execute()). execute() sends COM_STMT_EXECUTE with typed parameters and reads binary rows; the statement is released with COM_STMT_CLOSE exactly once when the statement object goes away (or immediately when the connection is busy: queued, sent ahead of the next command), and a close on a dead connection is a no-op. Everything below was measured against php 8.5.

MariaDB#

bash tests/pdo_net/run.sh --server mariadb -k mysql_ runs the same mysql_* cases against mc-maria (mariadb:11, :33061; --server mysql is the default, mc-my). Each case is graded against live php on THAT server. A case header line // servers: mysql (or mariadb) limits it; MySQL-only: the caching_sha2 / TLS / mysql_native_password login cases and the three global-state native-prepare cases (they need mc-my-iso).

Supported, byte-identical to php/mysqlnd on mariadb:11 in the suite: mysql_native_password, text and binary protocol, INSERT ... RETURNING / DELETE ... RETURNING through query() and prepare()/execute() (rowCount, columnCount, rows; lastInsertId stays 0, as php), the JSON (a BLOB-flagged text), UUID and INET6 column types (getColumnMeta), MariaDB error texts and codes (always the server's own, never a hard-coded MySQL string), the 5.5.5- replication prefix of ATTR_SERVER_VERSION (stripped as mysqlnd does), the optional Flush tables: of ATTR_SERVER_INFO. A PARAM_BOOL / bool-typed parameter of the binary protocol travels as TINY (MariaDB reports that column type back, MySQL widens it). The driver sends no MariaDB extended capability flags and reads none.

Not supported: ed25519 / parsec logins ([2054], as php). COM_RESET_CONNECTION is sent by resetSession() (see Pooling).

Where the driver deliberately differs from php (both servers): exec() of a statement that returns rows (SELECT, ... RETURNING) answers 0 and the connection stays usable; php (measured, 8.5, both servers) leaves the result pending and fails EVERY later command on that handle with 2014 Cannot execute queries while other unbuffered queries are active. Reproducing that wedge was judged a footgun, not a feature.

pgsql: TLS (prelude/pdo_pgsql.php)#

DSN keys, graded against libpq: sslmode = disable | allow | prefer (default) | require | verify-ca | verify-full, sslrootcert, sslcert, sslkey, sslcrl; sslsni is honoured (0 stops SNI), sslcompression / sslcrldir / sslpassword are accepted and ignored. An unknown ssl* key is invalid connection option "x", a bad mode invalid sslmode value: "x" (an EMPTY value swallows the next token like php's connect string does: invalid sslmode value: "sslrootcert=/x", or "connect_timeout=30" when last). hostaddr connects to the address while host is what the certificate is checked against.

pgsql: LISTEN/NOTIFY and COPY (prelude/pdo_pgsql.php)#

getNotify(int $fetchMode = FETCH_DEFAULT, int $timeoutMilliseconds = 0): array|false, getPid(), copyFromArray(), copyFromFile(), copyToArray(), copyToFile() (php 8.4 names) and their pgsql* aliases are methods of PDO itself (php puts them on Pdo\Pgsql; there is no such subclass here, so PDO::connect() returns a PDO). On a connection that is not pgsql they throw Error: Call to undefined method PDO::getPid(), as php does for the aliases. The aliases raise no deprecation (the superset throws instead of warning).

Unbuffered cursors#

mysql (MYSQL_ATTR_USE_BUFFERED_QUERY = false, at connect or setAttribute; byte parity with php, both protocols, both servers). query() / execute() read the column definitions and stop; every fetch() reads one row packet off the socket, so memory stays flat whatever the result size (300 000 rows are tested). While the cursor is open the connection belongs to it:

pgsql (superset: php has no cursor attribute, so no oracle; tests are hand-written). PDO::PGSQL_ATTR_FETCH_BATCH = <rows> (1300; 0, the default, buffers as before; negative or above 2^31-1 is a ValueError) executes the portal with a row limit. The server answers PortalSuspended after each batch and fetch() asks for the next one with another Execute; the Sync is sent only when the portal finishes (or fails), so memory holds one batch. Rows are identical to the buffered run for every batch size (1, 7, 999, 1000, 1001, 5000 against 1000 rows).

Pooling and session reset#

Superset (no Zend oracle). PDO::resetSession(): void returns the session to a fresh-connection state; PDO::ping(): bool is true while the server answers (a lost connection is false, never an exception). They are the reset() / ping() methods of the driver seam and what Async\Pool::pdo() calls.

driverresetSession()ping()
sqliterolls back an open transactionalways true
pgsqlcloses an open batched portal, ROLLBACK if a transaction is open, then DISCARD ALL (session settings, temp tables, prepared statements, advisory locks, LISTENs)an empty query (one round trip)
mysql / MariaDBdrains an unbuffered result (bounded by the connection timeout), drops the COM_STMT_CLOSEs still owed, COM_RESET_CONNECTION (rollback, session variables, temp tables, prepared statements), then restores the construction-time session: SET NAMES <DSN charset>, ATTR_AUTOCOMMIT (re-applied when the constructor option was false; the driver flag follows) and MYSQL_ATTR_INIT_COMMANDCOM_PING

resetSession() throws PDOException in every error mode (silent and warning included): a caller that pools connections must learn that a connection could not be reset. Both honour the busy gate (another fiber's statement finishes first) and an open cursor of the calling fiber (ping() answers false, resetSession() closes it); an open cursor that belongs to ANOTHER fiber makes resetSession() throw (the owner's rows are not ours to drop). What the program changed on the handle since the constructor is undone as well: ATTR_ERRMODE, ATTR_DEFAULT_FETCH_MODE, ATTR_CASE, ATTR_ORACLE_NULLS, ATTR_STRINGIFY_FETCHES, ATTR_STATEMENT_CLASS, the recorded error state, and the driver's own attributes (PGSQL_ATTR_FETCH_BATCH, PGSQL_ATTR_DISABLE_PREPARES, ATTR_EMULATE_PREPARES, MYSQL_ATTR_USE_BUFFERED_QUERY, ATTR_TIMEOUT and whether it bounds the idle wait, ATTR_DEFAULT_STR_PARAM, ATTR_FETCH_TABLE_NAMES) return to their construction-time values; unread pgsql notifications are dropped. Statements prepared before a reset are invalid afterwards (mysql ids carry a generation, so a stale statement object never closes a newer statement of the same id). A server that does not know COM_RESET_CONNECTION (MySQL < 5.7.3, MariaDB < 10.2) makes resetSession() throw, which a pool treats as "discard".

Tests: async_pool_basic, async_pool_cancel, pdo_sqlite_reset (AOT), pgsql_pool, mysql_pool, pgsql_reset_attrs, mysql_reset_attrs (pdo_net, both mysql servers).

Limitations#

nextRowset() reports IM001 on sqlite and pgsql, as php does, and works on the mysql: driver. Out of scope: the pgsql_* procedural API (mysqli is in mysqli.md), LOAD DATA LOCAL INFILE.

Regression notes: default fetch mode and variadic calls#

$stmt->fetch() with no argument (and so foreach ($stmt as $row)) must return an array, as in php. The cause of a past regression was in call lowering, not in PDO. PDO::query() is declared

public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs)

and the compiler packed the variadic into the first OMITTED parameter's slot, so $db->query($sql) passed the empty pack as $fetchMode. Fixed in LowerFns::defaultFillArgs: an omitted fixed parameter keeps its own slot and its own default. Telltale symptom of this class of bug: prepare() + execute() + fetch() is correct while query() is not, because prepare() has no optional-before-variadic parameter.

Four related spread-call bugs are fixed too, with PDO-free regression cases in tests/aot/cases/spread_call_defaults.php:

tests/aot/cases/pdo_sqlite_fetch_default.php covers the PDO surface (bare fetch(), fetch($var), foreach, setFetchMode, ATTR_DEFAULT_FETCH_MODE, fetchAll), and pdo_sqlite_fetch_obj.php the object modes.

Object fetch modes#

FETCH_OBJ, FETCH_CLASS (with constructor arguments), FETCH_INTO, FETCH_LAZY and fetchObject() all work and are byte-exact against php. They were blocked on the erasure root — an untyped property is mixed, and new $cls() never boxed its result — which is fixed in this tree.

Tests#

tests/aot/cases/pdo_sqlite_*.php — thirteen cases, every one graded against the real php interpreter, since this host's php has pdo_sqlite. No expected output was hand-written.

bash tests/aot/run.sh -k pdo      # the family
bash tools/difftest.sh            # byte-exact parity vs php
casecovers
pdo_sqlite_basicopen, exec, query, lastInsertId, multi-statement exec
pdo_sqlite_prepare:name and ? binding, rebinding, a placeholder inside a literal / comment
pdo_sqlite_typesINTEGER / REAL / TEXT / BLOB / NULL round trip, NUL bytes, PARAM_*
pdo_sqlite_fetchevery working fetch mode, ATTR_CASE, ATTR_STRINGIFY_FETCHES
pdo_sqlite_txnbegin/commit/rollBack, rowCount, columnCount, quote
pdo_sqlite_errorsall three ERRMODEs, SQLSTATE mapping, errorInfo, connect failures
pdo_sqlite_filefile DSN, persistence across handles, the anonymous temp database

Network driver tests#

tests/pdo_net/ runs the pgsql_* and mysql_* cases against live servers, graded against php's own pdo_pgsql / pdo_mysql on the same server (a sibling <case>.expected replaces the oracle for Manticore-only behaviour). bash tests/pdo_net/up.sh starts the servers in docker (postgres 16, mysql 8.4 plain / TLS / native-password / isolated, mariadb 11), bash tests/pdo_net/run.sh [-k substr] [--server mysql|mariadb] runs them, down.sh removes them. A case whose server is unreachable is skipped; a run in which nothing ran fails. The oracle php needs pdo_pgsql and pdo_mysql. CI runs the job pdo-net on arm64 and amd64 against mysql and mariadb.

Host requirement#

libsqlite3 with pkg-config sqlite3, and only to compile a program that uses the sqlite driver; the network drivers need nothing beyond libssl. See docs/install.md.

Edit this page on GitHub