seeInDatabase('users', ['name' => 'Davert', 'email' => 'davert@mail.com']); * * ``` * Will generate: * * ```sql * SELECT COUNT(*) FROM `users` WHERE `name` = 'Davert' AND `email` = 'davert@mail.com' * ``` * Since version 2.1.9 it's possible to use LIKE in a condition, as shown here: * * ```php * seeInDatabase('users', ['name' => 'Davert', 'email like' => 'davert%']); * * ``` * Will generate: * * ```sql * SELECT COUNT(*) FROM `users` WHERE `name` = 'Davert' AND `email` LIKE 'davert%' * ``` * Null comparisons are also available, as shown here: * * ```php * seeInDatabase('users', ['name' => null, 'email !=' => null]); * * ``` * Will generate: * * ```sql * SELECT COUNT(*) FROM `users` WHERE `name` IS NULL AND `email` IS NOT NULL * ``` * ## Public Properties * * dbh - contains the PDO connection * * driver - contains the Connection Driver * */ class Db extends Module implements DbInterface { /** * @var array */ protected array $config = [ 'populate' => false, 'cleanup' => false, 'reconnect' => false, 'waitlock' => 0, 'dump' => null, 'populator' => null, 'skip_cleanup_if_failed' => false, ]; /** * @var string[] */ protected array $requiredFields = ['dsn', 'user', 'password']; /** * @var string */ public const DEFAULT_DATABASE = 'default'; /** * @var Driver[] */ public array $drivers = []; /** * @var PDO[] */ public array $dbhs = []; public array $databasesPopulated = []; public array $databasesSql = []; protected array $insertedRows = []; public string $currentDatabase = self::DEFAULT_DATABASE; protected function getDatabases(): array { $databases = [$this->currentDatabase => $this->config]; if (!empty($this->config['databases'])) { foreach ($this->config['databases'] as $databaseKey => $databaseConfig) { $databases[$databaseKey] = array_merge([ 'populate' => false, 'cleanup' => false, 'reconnect' => false, 'waitlock' => 0, 'dump' => null, 'populator' => null, ], $databaseConfig); } } return $databases; } protected function connectToDatabases(): void { foreach ($this->getDatabases() as $databaseKey => $databaseConfig) { $this->connect($databaseKey, $databaseConfig); } } protected function cleanUpDatabases(): void { foreach ($this->getDatabases() as $databaseKey => $databaseConfig) { $this->_cleanup($databaseKey, $databaseConfig); } } protected function populateDatabases($configKey): void { foreach ($this->getDatabases() as $databaseKey => $databaseConfig) { if ($databaseConfig[$configKey]) { if (!$databaseConfig['populate']) { return; } if (isset($this->databasesPopulated[$databaseKey]) && $this->databasesPopulated[$databaseKey]) { return; } $this->_loadDump($databaseKey, $databaseConfig); } } } protected function readSqlForDatabases(): void { foreach ($this->getDatabases() as $databaseKey => $databaseConfig) { $this->readSql($databaseKey, $databaseConfig); } } protected function removeInsertedForDatabases(): void { foreach (array_keys($this->getDatabases()) as $databaseKey) { $this->amConnectedToDatabase($databaseKey); $this->removeInserted($databaseKey); } } protected function disconnectDatabases(): void { foreach (array_keys($this->getDatabases()) as $databaseKey) { $this->disconnect($databaseKey); } } protected function reconnectDatabases(): void { foreach ($this->getDatabases() as $databaseKey => $databaseConfig) { if ($databaseConfig['reconnect']) { $this->disconnect($databaseKey); $this->connect($databaseKey, $databaseConfig); } } } public function __get($name) { Notification::deprecate("Properties dbh and driver are deprecated in favor of Db::_getDbh and Db::_getDriver", "Db module"); if ($name == 'driver') { return $this->_getDriver(); } if ($name == 'dbh') { return $this->_getDbh(); } } public function _getDriver(): Driver { return $this->drivers[$this->currentDatabase]; } public function _getDbh(): PDO { return $this->dbhs[$this->currentDatabase]; } /** * Make sure you are connected to the right database. * * ```php * seeNumRecords(2, 'users'); //executed on default database * $I->amConnectedToDatabase('db_books'); * $I->seeNumRecords(30, 'books'); //executed on db_books database * //All the next queries will be on db_books * ``` * * @throws ModuleConfigException */ public function amConnectedToDatabase(string $databaseKey): void { if (empty($this->getDatabases()[$databaseKey]) && $databaseKey != self::DEFAULT_DATABASE) { throw new ModuleConfigException( __CLASS__, "\nNo database {$databaseKey} in the key databases.\n" ); } $this->currentDatabase = $databaseKey; } /** * Can be used with a callback if you don't want to change the current database in your test. * * ```php * seeNumRecords(2, 'users'); //executed on default database * $I->performInDatabase('db_books', function($I) { * $I->seeNumRecords(30, 'books'); //executed on db_books database * }); * $I->seeNumRecords(2, 'users'); //executed on default database * ``` * List of actions can be pragmatically built using `Codeception\Util\ActionSequence`: * * ```php * performInDatabase('db_books', ActionSequence::build() * ->seeNumRecords(30, 'books') * ); * ``` * Alternatively an array can be used: * * ```php * $I->performInDatabase('db_books', ['seeNumRecords' => [30, 'books']]); * ``` * * Choose the syntax you like the most and use it, * * Actions executed from array or ActionSequence will print debug output for actions, and adds an action name to * exception on failure. * * @param $databaseKey * @param ActionSequence|array|callable $actions * @throws ModuleConfigException */ public function performInDatabase($databaseKey, $actions): void { $backupDatabase = $this->currentDatabase; $this->amConnectedToDatabase($databaseKey); if (is_callable($actions)) { $actions($this); $this->amConnectedToDatabase($backupDatabase); return; } if (is_array($actions)) { $actions = ActionSequence::build()->fromArray($actions); } if (!$actions instanceof ActionSequence) { throw new InvalidArgumentException("2nd parameter, actions should be callback, ActionSequence or array"); } $actions->run($this); $this->amConnectedToDatabase($backupDatabase); } public function _initialize(): void { $this->connectToDatabases(); } public function __destruct() { $this->disconnectDatabases(); } public function _beforeSuite($settings = []): void { $this->readSqlForDatabases(); $this->connectToDatabases(); $this->cleanUpDatabases(); $this->populateDatabases('populate'); } private function readSql($databaseKey = null, $databaseConfig = null): void { if ($databaseConfig['populator']) { return; } if (!$databaseConfig['cleanup'] && !$databaseConfig['populate']) { return; } if (empty($databaseConfig['dump'])) { return; } if (!is_array($databaseConfig['dump'])) { $databaseConfig['dump'] = [$databaseConfig['dump']]; } $sql = ''; foreach ($databaseConfig['dump'] as $filePath) { $sql .= $this->readSqlFile($filePath); } if (!empty($sql)) { // split SQL dump into lines $this->databasesSql[$databaseKey] = preg_split('#\r\n|\n|\r#', $sql, -1, PREG_SPLIT_NO_EMPTY); } } /** * @throws ModuleConfigException|ModuleException */ private function readSqlFile(string $filePath): ?string { if (!file_exists(Configuration::projectDir() . $filePath)) { throw new ModuleConfigException( __CLASS__, "\nFile with dump doesn't exist.\n" . "Please, check path for sql file: " . $filePath ); } $sql = file_get_contents(Configuration::projectDir() . $filePath); // remove C-style comments (except MySQL directives) $replaced = preg_replace('#/\*(?!!\d+).*?\*/#s', '', $sql); if (!empty($sql) && is_null($replaced)) { throw new ModuleException( __CLASS__, "Please, increase pcre.backtrack_limit value in PHP CLI config" ); } return $replaced; } private function connect($databaseKey, $databaseConfig): void { if (!empty($this->drivers[$databaseKey]) && !empty($this->dbhs[$databaseKey])) { return; } $options = []; if ( array_key_exists('ssl_key', $databaseConfig) && !empty($databaseConfig['ssl_key']) && defined(PDO::class . '::MYSQL_ATTR_SSL_KEY') ) { $options[PDO::MYSQL_ATTR_SSL_KEY] = (string) $databaseConfig['ssl_key']; } if ( array_key_exists('ssl_cert', $databaseConfig) && !empty($databaseConfig['ssl_cert']) && defined(PDO::class . '::MYSQL_ATTR_SSL_CERT') ) { $options[PDO::MYSQL_ATTR_SSL_CERT] = (string) $databaseConfig['ssl_cert']; } if ( array_key_exists('ssl_ca', $databaseConfig) && !empty($databaseConfig['ssl_ca']) && defined(PDO::class . '::MYSQL_ATTR_SSL_CA') ) { $options[PDO::MYSQL_ATTR_SSL_CA] = (string) $databaseConfig['ssl_ca']; } if ( array_key_exists('ssl_cipher', $databaseConfig) && !empty($databaseConfig['ssl_cipher']) && defined(PDO::class . '::MYSQL_ATTR_SSL_CIPHER') ) { $options[PDO::MYSQL_ATTR_SSL_CIPHER] = (string) $databaseConfig['ssl_cipher']; } if ( array_key_exists('ssl_verify_server_cert', $databaseConfig) && defined(PDO::class . '::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT') ) { $options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = (bool) $databaseConfig[ 'ssl_verify_server_cert' ]; } try { $this->debugSection('Connecting To Db', ['config' => $databaseConfig, 'options' => $options]); $this->drivers[$databaseKey] = Driver::create($databaseConfig['dsn'], $databaseConfig['user'], $databaseConfig['password'], $options); } catch (PDOException $exception) { $message = $exception->getMessage(); if ($message === 'could not find driver') { [$missingDriver, ] = explode(':', $databaseConfig['dsn'], 2); $message = sprintf('could not find %s driver', $missingDriver); } throw new ModuleException(__CLASS__, $message . ' while creating PDO connection'); } if ($databaseConfig['waitlock']) { $this->_getDriver()->setWaitLock($databaseConfig['waitlock']); } if (isset($databaseConfig['initial_queries'])) { foreach ($databaseConfig['initial_queries'] as $initialQuery) { $this->drivers[$databaseKey]->executeQuery($initialQuery, []); } } $this->debugSection('Db', 'Connected to ' . $databaseKey . ' ' . $this->drivers[$databaseKey]->getDb()); $this->dbhs[$databaseKey] = $this->drivers[$databaseKey]->getDbh(); } private function disconnect($databaseKey): void { $this->debugSection('Db', 'Disconnected from ' . $databaseKey); $this->dbhs[$databaseKey] = null; $this->drivers[$databaseKey] = null; } public function _before(TestInterface $test): void { $this->reconnectDatabases(); $this->amConnectedToDatabase(self::DEFAULT_DATABASE); $this->cleanUpDatabases(); $this->populateDatabases('cleanup'); parent::_before($test); } public function _failed(TestInterface $test, $fail) { foreach ($this->getDatabases() as $databaseKey => $databaseConfig) { if ($databaseConfig['skip_cleanup_if_failed'] ?? false) { $this->insertedRows[$databaseKey] = []; } } } public function _after(TestInterface $test): void { $this->removeInsertedForDatabases(); parent::_after($test); } protected function removeInserted($databaseKey = null): void { $databaseKey = empty($databaseKey) ? self::DEFAULT_DATABASE : $databaseKey; if (empty($this->insertedRows[$databaseKey])) { return; } foreach (array_reverse($this->insertedRows[$databaseKey]) as $row) { try { $this->_getDriver()->deleteQueryByCriteria($row['table'], $row['primary']); } catch (Exception $e) { $this->debug("Couldn't delete record " . json_encode($row['primary'], JSON_THROW_ON_ERROR) . " from {$row['table']}"); } } $this->insertedRows[$databaseKey] = []; } public function _cleanup(?string $databaseKey = null, ?array $databaseConfig = null): void { $databaseKey = empty($databaseKey) ? self::DEFAULT_DATABASE : $databaseKey; $databaseConfig = empty($databaseConfig) ? $this->config : $databaseConfig; if (!$databaseConfig['populate']) { return; } if (!$databaseConfig['cleanup']) { return; } if (isset($this->databasesPopulated[$databaseKey]) && !$this->databasesPopulated[$databaseKey]) { return; } $dbh = $this->dbhs[$databaseKey]; if (!$dbh) { throw new ModuleConfigException( __CLASS__, "No connection to database. Remove this module from config if you don't need database repopulation" ); } try { if (!$this->shouldCleanup($databaseConfig, $databaseKey)) { return; } $this->drivers[$databaseKey]->cleanup(); $this->databasesPopulated[$databaseKey] = false; } catch (Exception $e) { throw new ModuleException(__CLASS__, $e->getMessage()); } } protected function shouldCleanup(array $databaseConfig, string $databaseKey): bool { // If using populator and it's not empty, clean up regardless if (!empty($databaseConfig['populator'])) { return true; } // If no sql dump for $databaseKey or sql dump is empty, don't clean up return !empty($this->databasesSql[$databaseKey]); } public function _isPopulated() { return $this->databasesPopulated[$this->currentDatabase]; } public function _loadDump(?string $databaseKey = null, ?array $databaseConfig = null): void { $databaseKey = empty($databaseKey) ? self::DEFAULT_DATABASE : $databaseKey; $databaseConfig = empty($databaseConfig) ? $this->config : $databaseConfig; if (!empty($databaseConfig['populator'])) { $this->loadDumpUsingPopulator($databaseKey, $databaseConfig); return; } $this->loadDumpUsingDriver($databaseKey); } protected function loadDumpUsingPopulator(string $databaseKey, array $databaseConfig): void { $populator = new DbPopulator($databaseConfig); $this->databasesPopulated[$databaseKey] = $populator->run(); } protected function loadDumpUsingDriver(string $databaseKey): void { if (!isset($this->databasesSql[$databaseKey])) { return; } if (!$this->databasesSql[$databaseKey]) { $this->debugSection('Db', 'No SQL loaded, loading dump skipped'); return; } $this->drivers[$databaseKey]->load($this->databasesSql[$databaseKey]); $this->databasesPopulated[$databaseKey] = true; } /** * Inserts an SQL record into a database. This record will be erased after the test, * unless you've configured "skip_cleanup_if_failed", and the test fails. * * ```php * haveInDatabase('users', ['name' => 'miles', 'email' => 'miles@davis.com']); * ``` */ public function haveInDatabase(string $table, array $data): int { $lastInsertId = $this->_insertInDatabase($table, $data); $this->addInsertedRow($table, $data, $lastInsertId); return $lastInsertId; } public function _insertInDatabase(string $table, array $data): int { $query = $this->_getDriver()->insert($table, $data); $parameters = array_values($data); $this->debugSection('Query', $query); $this->debugSection('Parameters', $parameters); $this->_getDriver()->executeQuery($query, $parameters); try { $lastInsertId = (int)$this->_getDriver()->lastInsertId($table); } catch (PDOException $e) { // ignore errors due to uncommon DB structure, // such as tables without _id_seq in PGSQL $lastInsertId = 0; $this->debugSection('DB error', $e->getMessage()); } return $lastInsertId; } private function addInsertedRow(string $table, array $row, $id): void { $primaryKey = $this->_getDriver()->getPrimaryKey($table); $primary = []; if ($primaryKey !== []) { $filledKeys = array_intersect($primaryKey, array_keys($row)); $missingPrimaryKeyColumns = array_diff_key($primaryKey, $filledKeys); if (count($missingPrimaryKeyColumns) === 0) { $primary = array_intersect_key($row, array_flip($primaryKey)); } elseif (count($missingPrimaryKeyColumns) === 1) { $primary = array_intersect_key($row, array_flip($primaryKey)); $missingColumn = reset($missingPrimaryKeyColumns); $primary[$missingColumn] = $id; } else { foreach ($primaryKey as $column) { if (isset($row[$column])) { $primary[$column] = $row[$column]; } else { throw new InvalidArgumentException( 'Primary key field ' . $column . ' is not set for table ' . $table ); } } } } else { $primary = $row; } $this->insertedRows[$this->currentDatabase][] = [ 'table' => $table, 'primary' => $primary, ]; } public function seeInDatabase(string $table, array $criteria = []): void { $res = $this->countInDatabase($table, $criteria); $this->assertGreaterThan( 0, $res, 'No matching records found for criteria ' . json_encode($criteria, JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE) . ' in table ' . $table ); } /** * Asserts that the given number of records were found in the database. * * ```php * seeNumRecords(1, 'users', ['name' => 'davert']) * ``` * * @param int $expectedNumber Expected number * @param string $table Table name * @param array $criteria Search criteria [Optional] */ public function seeNumRecords(int $expectedNumber, string $table, array $criteria = []): void { $actualNumber = $this->countInDatabase($table, $criteria); $this->assertSame( $expectedNumber, $actualNumber, sprintf( 'The number of found rows (%d) does not match expected number %d for criteria %s in table %s', $actualNumber, $expectedNumber, json_encode($criteria, JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE), $table ) ); } public function dontSeeInDatabase(string $table, array $criteria = []): void { $count = $this->countInDatabase($table, $criteria); $this->assertLessThan( 1, $count, 'Unexpectedly found matching records for criteria ' . json_encode($criteria, JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE) . ' in table ' . $table ); } /** * Count rows in a database * * @param string $table Table name * @param array $criteria Search criteria [Optional] * @return int */ protected function countInDatabase(string $table, array $criteria = []): int { return (int) $this->proceedSeeInDatabase($table, 'count(*)', $criteria); } /** * Fetches all values from the column in database. * Provide table name, desired column and criteria. * * @return mixed */ protected function proceedSeeInDatabase(string $table, string $column, array $criteria) { $query = $this->_getDriver()->select($column, $table, $criteria); $parameters = array_values($criteria); $this->debugSection('Query', $query); if (!empty($parameters)) { $this->debugSection('Parameters', $parameters); } $sth = $this->_getDriver()->executeQuery($query, $parameters); return $sth->fetchColumn(); } /** * Fetches all values from the column in database. * Provide table name, desired column and criteria. * * ``` php * grabColumnFromDatabase('users', 'email', ['name' => 'RebOOter']); * ``` */ public function grabColumnFromDatabase(string $table, string $column, array $criteria = []): array { $query = $this->_getDriver()->select($column, $table, $criteria); $parameters = array_values($criteria); $this->debugSection('Query', $query); $this->debugSection('Parameters', $parameters); $sth = $this->_getDriver()->executeQuery($query, $parameters); return $sth->fetchAll(PDO::FETCH_COLUMN, 0); } /** * Fetches a single column value from a database. * Provide table name, desired column and criteria. * * ``` php * grabFromDatabase('users', 'email', ['name' => 'Davert']); * ``` * Comparison expressions can be used as well: * * ```php * grabFromDatabase('posts', 'num_comments', ['num_comments >=' => 100]); * $mail = $I->grabFromDatabase('users', 'email', ['email like' => 'miles%']); * ``` * * Supported operators: `<`, `>`, `>=`, `<=`, `!=`, `like`. * * @return mixed Returns a single column value or false */ public function grabFromDatabase(string $table, string $column, array $criteria = []) { return $this->proceedSeeInDatabase($table, $column, $criteria); } /** * Fetches a whole entry from a database. * Make the test fail if the entry is not found. * Provide table name, desired column and criteria. * * ``` php * grabEntryFromDatabase('users', ['name' => 'Davert']); * ``` * Comparison expressions can be used as well: * * ```php * grabEntryFromDatabase('posts', ['num_comments >=' => 100]); * $user = $I->grabEntryFromDatabase('users', ['email like' => 'miles%']); * ``` * * Supported operators: `<`, `>`, `>=`, `<=`, `!=`, `like`. * * @return array Returns a single entry value * @throws PDOException|Exception */ public function grabEntryFromDatabase(string $table, array $criteria = []): array { $query = $this->_getDriver()->select('*', $table, $criteria); $parameters = array_values($criteria); $this->debugSection('Query', $query); $this->debugSection('Parameters', $parameters); $sth = $this->_getDriver()->executeQuery($query, $parameters); $result = $sth->fetch(PDO::FETCH_ASSOC, 0); if ($result === false) { throw new \AssertionError("No matching row found"); } return $result; } /** * Fetches a set of entries from a database. * Provide table name and criteria. * * ``` php * grabEntriesFromDatabase('users', ['name' => 'Davert']); * ``` * Comparison expressions can be used as well: * * ```php * grabEntriesFromDatabase('posts', ['num_comments >=' => 100]); * $user = $I->grabEntriesFromDatabase('users', ['email like' => 'miles%']); * ``` * * Supported operators: `<`, `>`, `>=`, `<=`, `!=`, `like`. * * @return array> Returns an array of all matched rows * @throws PDOException|Exception */ public function grabEntriesFromDatabase(string $table, array $criteria = []): array { $query = $this->_getDriver()->select('*', $table, $criteria); $parameters = array_values($criteria); $this->debugSection('Query', $query); $this->debugSection('Parameters', $parameters); $sth = $this->_getDriver()->executeQuery($query, $parameters); return $sth->fetchAll(PDO::FETCH_ASSOC); } /** * Returns the number of rows in a database * * @param string $table Table name * @param array $criteria Search criteria [Optional] * @return int */ public function grabNumRecords(string $table, array $criteria = []): int { return $this->countInDatabase($table, $criteria); } /** * Update an SQL record into a database. * * ```php * updateInDatabase('users', ['isAdmin' => true], ['email' => 'miles@davis.com']); * ``` */ public function updateInDatabase(string $table, array $data, array $criteria = []): void { $query = $this->_getDriver()->update($table, $data, $criteria); $parameters = [...array_values($data), ...array_values($criteria)]; $this->debugSection('Query', $query); if (!empty($parameters)) { $this->debugSection('Parameters', $parameters); } $this->_getDriver()->executeQuery($query, $parameters); } }