2026-06-22 19:29:31 +03:00
<? php
2026-09-13 14:22:55 +03:00
use XcVm\Core\Database\DatabaseHandler ;
2026-06-22 19:29:31 +03:00
/**
2026-10-04 16:12:23 +03:00
* TestDb — MariaDB/MySQL test double for the XC_VM Database wrapper.
2026-06-22 19:29:31 +03:00
*
* Mirrors the subset of Database's public API used by repositories/services
* (query / get_rows / get_row / get_col / get_column / num_rows /
2026-10-04 16:12:23 +03:00
* last_insert_id / escape), over the database engine production runs.
* Inject via the service's setDb() (the DI seam present on every domain
* repository/service) or DatabaseFactory::set().
2026-09-13 21:17:06 +03:00
*
2026-10-04 16:12:23 +03:00
* Connection: XCVM_TEST_DB_DSN (a PDO MySQL DSN), XCVM_TEST_DB_USER and
* XCVM_TEST_DB_PASS; unset, root over the local server's socket (a panel
* host), or without one over 127.0.0.1:3306 (`make test-db`). CI sets its own.
2026-09-13 21:17:06 +03:00
*
2026-10-04 16:12:23 +03:00
* Every TestDb gets an empty database of its own: a throwaway schema
* (`xcvm_t<pid>_<n>`), dropped with the instance. The session runs the
* sql_mode the installer configures (install: NO_ENGINE_SUBSTITUTION), so
* tests see the coercions and truncations a live panel sees.
2026-06-22 19:29:31 +03:00
*
2026-09-13 14:22:55 +03:00
* Extends DatabaseHandler so it satisfies the setDb(DatabaseHandler) DI seam
2026-10-04 16:12:23 +03:00
* as a real subtype; the parent constructor is deliberately not invoked —
* our own constructor wires the connection instead.
2026-09-13 14:22:55 +03:00
*
2026-06-22 19:29:31 +03:00
* @package XC_VM_Tests_Support
*/
2026-09-13 14:22:55 +03:00
final class TestDb extends DatabaseHandler {
2026-06-22 19:29:31 +03:00
2026-10-04 16:12:23 +03:00
/** sql_mode of a live panel: the installer writes it to mariadb.cnf. */
public const SQL_MODE = 'NO_ENGINE_SUBSTITUTION' ;
2026-06-22 19:29:31 +03:00
2026-10-04 16:12:23 +03:00
/** Without the variables: root over the local server's socket (a panel host), else `make test-db`'s port. */
private const SOCKET = '/run/mysqld/mysqld.sock' ;
public PDO $pdo ;
2026-09-13 21:17:06 +03:00
2026-06-22 19:29:31 +03:00
/** @var array<int,array<string,mixed>> Buffered rows from the last SELECT. */
private array $rows = [];
2026-09-27 02:34:24 +00:00
/** @var int Rows the last statement returned (a SELECT) or changed (a write), as PDOStatement::rowCount() in Database::num_rows(). */
private int $count = 0 ;
2026-06-22 19:29:31 +03:00
private int $lastInsertId = 0 ;
2026-10-04 16:12:23 +03:00
/** This instance's throwaway schema. */
private string $schema ;
2026-09-13 21:17:06 +03:00
2026-10-04 16:12:23 +03:00
private static int $schemas = 0 ;
2026-10-07 21:38:57 +00:00
/** The schema is several processes' ($rShared): no instance drops it. */
private bool $shared = false ;
/**
* $rShared names one schema several processes share, created when missing
* and never dropped by an instance: XC_VM_Fanout's interop harness runs
* MAIN's PHP as many processes over one database. Named
* `xcvm_t<pid>_<n>` after a process that outlives them (the Go test), the
* first TestDb after that process ends drops it as an orphan.
*/
public function __construct ( ? string $rShared = null ) {
2026-10-04 16:12:23 +03:00
$this -> pdo = self :: connect ();
2026-10-07 21:38:57 +00:00
if ( $rShared !== null ) {
if ( ! preg_match ( '/^xcvm_t\d+_\d+$/' , $rShared )) {
throw new InvalidArgumentException ( 'A shared test schema is named xcvm_t<pid>_<n>: ' . $rShared );
}
$this -> schema = $rShared ;
$this -> shared = true ;
$this -> pdo -> exec ( 'CREATE DATABASE IF NOT EXISTS `' . $rShared . '`' );
$this -> pdo -> exec ( 'USE `' . $rShared . '`' );
return ;
}
2026-10-04 16:12:23 +03:00
if ( self :: $schemas === 0 ) {
self :: dropOrphanSchemas ( $this -> pdo );
2026-09-13 21:17:06 +03:00
}
2026-10-04 16:12:23 +03:00
$this -> schema = 'xcvm_t' . getmypid () . '_' . ++ self :: $schemas ;
$this -> pdo -> exec ( 'CREATE DATABASE `' . $this -> schema . '`' );
$this -> pdo -> exec ( 'USE `' . $this -> schema . '`' );
2026-09-13 21:17:06 +03:00
}
2026-10-04 16:12:23 +03:00
public function __destruct () {
2026-10-07 21:38:57 +00:00
if ( ! $this -> shared ) {
$this -> pdo -> exec ( 'DROP DATABASE IF EXISTS `' . $this -> schema . '`' );
}
2026-10-04 16:12:23 +03:00
}
/** A new connection to the test server, in production's sql_mode; $rSchema selects a database. */
public static function connect ( ? string $rSchema = null ) : PDO {
[ 'XCVM_TEST_DB_DSN' => $rDsn , 'XCVM_TEST_DB_USER' => $rUser ] = self :: env ();
if ( $rSchema !== null ) {
$rDsn = preg_replace ( '/;?dbname=[^;]*/' , '' , $rDsn ) . ';dbname=' . $rSchema ;
2026-09-13 21:17:06 +03:00
}
2026-10-04 16:12:23 +03:00
// An explicit connect timeout: a boot under test (AdminGlobalsStage) can leave
// default_socket_timeout at 0, which mysqlnd takes for a TCP connect's.
2026-10-06 16:59:14 +00:00
try {
$rPdo = new PDO ( $rDsn , $rUser , self :: env ()[ 'XCVM_TEST_DB_PASS' ] ?? null , [ PDO :: ATTR_TIMEOUT => 10 ]);
} catch ( PDOException $e ) {
throw new PDOException ( $e -> getMessage () . ' (no test database at ' . $rDsn . ' as ' . $rUser . ': without a local server `make test-db` starts one; a server that refuses this user needs XCVM_TEST_DB_DSN, XCVM_TEST_DB_USER and XCVM_TEST_DB_PASS, see docs/en/guides/phpunit-phar.md section 2)' , ( int ) $e -> getCode (), $e );
}
2026-10-04 16:12:23 +03:00
$rPdo -> setAttribute ( PDO :: ATTR_ERRMODE , PDO :: ERRMODE_EXCEPTION );
$rPdo -> exec ( "SET SESSION sql_mode = '" . self :: SQL_MODE . "'" );
return $rPdo ;
2026-09-13 21:17:06 +03:00
}
/**
2026-10-04 16:12:23 +03:00
* The connection variables, defaults filled in: also for a child process
* started with an environment of its own (proc_open's $env replaces the parent's).
*
* @return array{XCVM_TEST_DB_DSN: string, XCVM_TEST_DB_USER: string, XCVM_TEST_DB_PASS?: string}
2026-09-13 21:17:06 +03:00
*/
2026-10-04 16:12:23 +03:00
public static function env () : array {
$rOut = [ 'XCVM_TEST_DB_DSN' => file_exists ( self :: SOCKET ) ? 'mysql:unix_socket=' . self :: SOCKET : 'mysql:host=127.0.0.1;port=3306' , 'XCVM_TEST_DB_USER' => 'root' ];
foreach ([ 'XCVM_TEST_DB_DSN' , 'XCVM_TEST_DB_USER' , 'XCVM_TEST_DB_PASS' ] as $rName ) {
if (( $rValue = getenv ( $rName )) !== false && $rValue !== '' ) {
$rOut [ $rName ] = $rValue ;
}
2026-09-13 21:17:06 +03:00
}
2026-10-04 16:12:23 +03:00
return $rOut ;
}
/** This instance's schema, for a child process that opens its own connection to it (connect()). */
public function schema () : string {
return $this -> schema ;
}
2026-09-13 21:17:06 +03:00
2026-10-04 16:12:23 +03:00
/** Schemas a run left behind when its process died (a segfault skips __destruct). */
private static function dropOrphanSchemas ( PDO $pdo ) : void {
foreach ( $pdo -> query ( "SHOW DATABASES LIKE 'xcvm \\ _t%'" ) -> fetchAll ( PDO :: FETCH_COLUMN ) as $name ) {
if ( preg_match ( '/^xcvm_t(\d+)_\d+$/' , $name , $m ) && ! posix_kill (( int ) $m [ 1 ], 0 )) {
$pdo -> exec ( 'DROP DATABASE IF EXISTS `' . $name . '`' );
}
}
2026-09-13 21:17:06 +03:00
}
2026-10-04 16:12:23 +03:00
/** True when the statement is schema DDL (a test may send several in one string). */
2026-09-13 21:17:06 +03:00
private static function isDdl ( string $sql ) : bool {
return ( bool ) preg_match ( '/^\s*(CREATE|ALTER|DROP)\s+TABLE/i' , $sql );
2026-06-22 19:29:31 +03:00
}
/**
* Execute raw schema/seed SQL (one or more `;`-separated statements).
*/
public function exec ( string $sql ) : void {
2026-10-04 16:12:23 +03:00
$this -> pdo -> exec ( $sql );
2026-06-22 19:29:31 +03:00
}
/**
* Run a prepared query. Bind values follow $query (as in Database::query()).
2026-10-04 16:12:23 +03:00
* SELECT/WITH/SHOW results are buffered for get_rows()/get_row()/num_rows().
2026-06-22 19:29:31 +03:00
*/
public function query ( $query , ... $args ) : bool {
2026-09-13 21:17:06 +03:00
if ( self :: isDdl ( $query )) {
2026-10-04 16:12:23 +03:00
$this -> pdo -> exec ( $query );
2026-09-13 21:17:06 +03:00
$this -> rows = array ();
2026-09-27 02:34:24 +00:00
$this -> count = 0 ;
2026-09-13 21:17:06 +03:00
return true ;
}
2026-06-22 19:29:31 +03:00
// Mirror Database: the literal string 'null' and PHP null bind as SQL NULL.
$binds = array ();
foreach ( $args as $a ) {
$binds [] = ( is_string ( $a ) && strtolower ( $a ) === 'null' ) ? null : $a ;
}
$stmt = $this -> pdo -> prepare ( $query );
$stmt -> execute ( $binds );
2026-10-04 16:12:23 +03:00
if ( preg_match ( '/^\s*(SELECT|WITH|SHOW)/i' , $query )) {
2026-06-22 19:29:31 +03:00
$this -> rows = $stmt -> fetchAll ( PDO :: FETCH_ASSOC ) ?: array ();
2026-09-27 02:34:24 +00:00
$this -> count = count ( $this -> rows );
2026-06-22 19:29:31 +03:00
} else {
$this -> rows = array ();
2026-09-27 02:34:24 +00:00
$this -> count = $stmt -> rowCount ();
2026-06-22 19:29:31 +03:00
$id = $this -> pdo -> lastInsertId ();
if ( $id ) {
$this -> lastInsertId = ( int ) $id ;
}
}
return true ;
}
/**
* Return buffered rows, optionally keyed by a column (mirrors Database).
*/
public function get_rows ( $use_id = false , $column_as_id = '' , $unique_row = true , $sub_row_id = '' ) {
2026-09-30 15:52:28 +00:00
$rows = array_map ( fn ( $row ) => $this -> clean_row ( $row ), $this -> rows );
2026-06-22 19:29:31 +03:00
if ( ! $use_id ) {
2026-09-30 15:52:28 +00:00
return $rows ;
2026-06-22 19:29:31 +03:00
}
$out = array ();
2026-09-30 15:52:28 +00:00
foreach ( $rows as $row ) {
2026-06-22 19:29:31 +03:00
if ( $column_as_id !== '' && array_key_exists ( $column_as_id , $row )) {
if ( $unique_row ) {
$out [ $row [ $column_as_id ]] = $row ;
} elseif ( ! empty ( $sub_row_id ) && array_key_exists ( $sub_row_id , $row )) {
$out [ $row [ $column_as_id ]][ $row [ $sub_row_id ]] = $row ;
} else {
$out [ $row [ $column_as_id ]][] = $row ;
}
} else {
$out [] = $row ;
}
}
return $out ;
}
public function get_row () {
2026-09-30 15:52:28 +00:00
return $this -> clean_row ( $this -> rows [ 0 ] ?? array ());
}
public function get_raw_rows () : array {
return $this -> rows ;
}
public function get_raw_row () : ? array {
return $this -> rows [ 0 ] ?? null ;
2026-06-22 19:29:31 +03:00
}
public function get_col () {
$row = $this -> rows [ 0 ] ?? null ;
return $row ? array_values ( $row )[ 0 ] : false ;
}
public function get_column () : array {
$col = array ();
foreach ( $this -> rows as $row ) {
$col [] = array_values ( $row )[ 0 ] ?? null ;
}
return $col ;
}
2026-09-27 02:34:24 +00:00
/** Rows of the last SELECT, or rows the last write changed (Database::num_rows(), PDOStatement::rowCount()). */
2026-06-22 19:29:31 +03:00
public function num_rows () : int {
2026-09-27 02:34:24 +00:00
return $this -> count ;
2026-06-22 19:29:31 +03:00
}
public function last_insert_id () {
return $this -> lastInsertId ;
}
public function escape ( $string ) {
return $this -> pdo -> quote (( string ) $string );
}
public function close_mysql () : bool {
return true ;
}
2026-10-04 16:12:23 +03:00
/** The test connection is always up (DatabaseFactory::connectLazy() asks). */
2026-09-25 16:34:58 +00:00
public function ping () : bool {
return true ;
}
2026-09-30 15:52:28 +00:00
/** Database's escaping, on the strings a MySQL fetch would return; get_raw_rows() skips it as Database's does. */
2026-06-22 19:29:31 +03:00
public function clean_row ( $row ) {
2026-09-30 15:52:28 +00:00
foreach ( $row as $key => $value ) {
if ( is_string ( $value ) && $value !== '' ) {
$row [ $key ] = self :: parseCleanValue ( $value );
}
}
2026-06-22 19:29:31 +03:00
return $row ;
}
2026-09-27 05:43:13 +00:00
/**
* Transactions over the backing PDO, with DatabaseHandler's semantics: one
* at a time (a nested begin is refused, false), commit and rollback false
2026-10-04 16:12:23 +03:00
* outside one.
2026-09-27 05:43:13 +00:00
*/
public function beginTransaction () {
if ( $this -> inTransaction ) {
return false ;
}
$this -> inTransaction = $this -> pdo -> beginTransaction ();
return $this -> inTransaction ;
}
public function commit () {
if ( ! $this -> inTransaction ) {
return false ;
}
$this -> inTransaction = false ;
2026-10-10 16:13:41 +00:00
return $this -> pdo -> inTransaction () ? $this -> pdo -> commit () : true ;
2026-09-27 05:43:13 +00:00
}
public function rollback () {
if ( ! $this -> inTransaction ) {
return false ;
}
$this -> inTransaction = false ;
2026-10-10 16:13:41 +00:00
return $this -> pdo -> inTransaction () && $this -> pdo -> rollBack ();
2026-09-27 05:43:13 +00:00
}
2026-06-22 19:29:31 +03:00
}