├── LICENSE ├── composer.json └── src └── Codeception ├── Lib ├── DbPopulator.php ├── Driver │ ├── Db.php │ ├── MySql.php │ ├── Oci.php │ ├── PostgreSql.php │ ├── SqlSrv.php │ └── Sqlite.php └── Interfaces │ └── Db.php └── Module └── Db.php /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2011 Michael Bodnarchuk and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name":"codeception/module-db", 3 | "description":"DB module for Codeception", 4 | "keywords":["codeception", "db-testing", "database-testing"], 5 | "homepage":"https://codeception.com/", 6 | "type":"library", 7 | "license":"MIT", 8 | "authors":[ 9 | { 10 | "name": "Michael Bodnarchuk" 11 | }, 12 | { 13 | "name": "Gintautas Miselis" 14 | } 15 | ], 16 | "minimum-stability": "dev", 17 | "require": { 18 | "php": "^8.0", 19 | "ext-json": "*", 20 | "ext-mbstring": "*", 21 | "ext-pdo": "*", 22 | "codeception/codeception": "*@dev" 23 | }, 24 | "require-dev": { 25 | "behat/gherkin": "~4.10.0", 26 | "squizlabs/php_codesniffer": "*" 27 | }, 28 | "conflict": { 29 | "codeception/codeception": "<5.0" 30 | }, 31 | "autoload":{ 32 | "classmap": ["src/"] 33 | }, 34 | "autoload-dev": { 35 | "classmap": ["tests/"] 36 | }, 37 | "scripts": { 38 | "cs-prod": "phpcs src/", 39 | "cs-tests": "phpcs tests/ --standard=tests/phpcs.xml" 40 | }, 41 | "scripts-descriptions": { 42 | "cs-prod": "Check production code style", 43 | "cs-tests": "Check test code style" 44 | }, 45 | "config": { 46 | "classmap-authoritative": true, 47 | "sort-packages": true 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Codeception/Lib/DbPopulator.php: -------------------------------------------------------------------------------- 1 | config = $config; 25 | //Convert To Array Format 26 | if (!isset($this->config['dump'])) { 27 | return; 28 | } 29 | 30 | if (is_array($this->config['dump'])) { 31 | return; 32 | } 33 | 34 | $this->config['dump'] = [$this->config['dump']]; 35 | } 36 | 37 | /** 38 | * Builds out a command replacing any found `$key` with its value if found in the given configuration. 39 | * 40 | * Process any $key found in the configuration array as a key of the array and replaces it with 41 | * the found value for the key. Example: 42 | * 43 | * ```php 44 | * 'Mauro']; 48 | * 49 | * // With the above parameters it will return `'Hello Mauro'`. 50 | * ``` 51 | * 52 | * @param string $command The command to be evaluated using the given config 53 | * @param string|null $dumpFile The dump file to build the command with. 54 | * @return string The resulting command string after evaluating any configuration's key 55 | */ 56 | protected function buildCommand(string $command, ?string $dumpFile = null): string 57 | { 58 | $dsn = $this->config['dsn'] ?? ''; 59 | $dsnVars = []; 60 | $dsnWithoutDriver = preg_replace('#^[a-z]+:#i', '', $dsn); 61 | foreach (explode(';', $dsnWithoutDriver) as $item) { 62 | $keyValueTuple = explode('=', $item); 63 | if (count($keyValueTuple) > 1) { 64 | [$k, $v] = array_values($keyValueTuple); 65 | $dsnVars[$k] = $v; 66 | } 67 | } 68 | 69 | $vars = array_merge($dsnVars, $this->config); 70 | 71 | if ($dumpFile !== null) { 72 | $vars['dump'] = $dumpFile; 73 | } 74 | 75 | foreach ($vars as $key => $value) { 76 | if (!is_array($value)) { 77 | $vars['$' . $key] = $value; 78 | } 79 | 80 | unset($vars[$key]); 81 | } 82 | 83 | return str_replace(array_keys($vars), $vars, $command); 84 | } 85 | 86 | /** 87 | * Executes the command built using the Db module configuration. 88 | * 89 | * Uses the PHP `exec` to spin off a child process for the built command. 90 | */ 91 | public function run(): bool 92 | { 93 | foreach ($this->buildCommands() as $command) { 94 | $this->runCommand($command); 95 | } 96 | 97 | return true; 98 | } 99 | 100 | private function runCommand($command): void 101 | { 102 | codecept_debug("[Db] Executing Populator: `{$command}`"); 103 | 104 | exec($command, $output, $exitCode); 105 | 106 | if (0 !== $exitCode) { 107 | throw new \RuntimeException( 108 | "The populator command did not end successfully: \n" . 109 | " Exit code: {$exitCode} \n" . 110 | " Output:" . implode("\n", $output) 111 | ); 112 | } 113 | 114 | codecept_debug("[Db] Populator Finished."); 115 | } 116 | 117 | public function buildCommands(): array 118 | { 119 | if ($this->commands !== []) { 120 | return $this->commands; 121 | } elseif (!isset($this->config['dump']) || $this->config['dump'] === false) { 122 | return [$this->buildCommand($this->config['populator'])]; 123 | } 124 | 125 | $this->commands = []; 126 | 127 | foreach ($this->config['dump'] as $dumpFile) { 128 | $this->commands[] = $this->buildCommand($this->config['populator'], $dumpFile); 129 | } 130 | 131 | return $this->commands; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Codeception/Lib/Driver/Db.php: -------------------------------------------------------------------------------- 1 | primary-key 31 | */ 32 | protected array $primaryKeys = []; 33 | 34 | public static function connect(string $dsn, ?string $user = null, ?string $password = null, ?array $options = null): PDO 35 | { 36 | $dbh = new PDO($dsn, $user, $password, $options); 37 | $dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); 38 | 39 | return $dbh; 40 | } 41 | 42 | /** 43 | * @static 44 | * 45 | * @see https://www.php.net/manual/en/pdo.construct.php 46 | * @see https://www.php.net/manual/de/ref.pdo-mysql.php#pdo-mysql.constants 47 | * 48 | * @return Db|SqlSrv|MySql|Oci|PostgreSql|Sqlite 49 | */ 50 | public static function create(string $dsn, ?string $user = null, ?string $password = null, ?array $options = null): Db 51 | { 52 | $provider = self::getProvider($dsn); 53 | 54 | switch ($provider) { 55 | case 'sqlite': 56 | return new Sqlite($dsn, $user, $password, $options); 57 | case 'mysql': 58 | return new MySql($dsn, $user, $password, $options); 59 | case 'pgsql': 60 | return new PostgreSql($dsn, $user, $password, $options); 61 | case 'mssql': 62 | case 'dblib': 63 | case 'sqlsrv': 64 | return new SqlSrv($dsn, $user, $password, $options); 65 | case 'oci': 66 | return new Oci($dsn, $user, $password, $options); 67 | default: 68 | return new Db($dsn, $user, $password, $options); 69 | } 70 | } 71 | 72 | public static function getProvider($dsn): string 73 | { 74 | return substr($dsn, 0, strpos($dsn, ':')); 75 | } 76 | 77 | /** 78 | * @see https://www.php.net/manual/en/pdo.construct.php 79 | * @see https://www.php.net/manual/de/ref.pdo-mysql.php#pdo-mysql.constants 80 | */ 81 | public function __construct(string $dsn, ?string $user = null, ?string $password = null, ?array $options = null) 82 | { 83 | $this->dbh = new PDO($dsn, $user, $password, $options); 84 | $this->dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); 85 | 86 | $this->dsn = $dsn; 87 | $this->user = $user; 88 | $this->password = $password; 89 | $this->options = $options; 90 | } 91 | 92 | public function __destruct() 93 | { 94 | if ($this->dbh !== null && $this->dbh->inTransaction()) { 95 | $this->dbh->rollBack(); 96 | } 97 | 98 | $this->dbh = null; 99 | } 100 | 101 | public function getDbh(): PDO 102 | { 103 | return $this->dbh; 104 | } 105 | 106 | public function getDb() 107 | { 108 | $matches = []; 109 | $matched = preg_match('#dbname=(\w+)#s', $this->dsn, $matches); 110 | if (!$matched) { 111 | return false; 112 | } 113 | 114 | return $matches[1]; 115 | } 116 | 117 | public function cleanup(): void 118 | { 119 | } 120 | 121 | /** 122 | * Set the lock waiting interval for the database session 123 | */ 124 | public function setWaitLock(int $seconds): void 125 | { 126 | } 127 | 128 | /** 129 | * @param string[] $sql 130 | */ 131 | public function load(array $sql): void 132 | { 133 | $query = ''; 134 | $delimiter = ';'; 135 | $delimiterLength = 1; 136 | 137 | foreach ($sql as $singleSql) { 138 | if (preg_match('#DELIMITER ([\;\$\|\\\]+)#i', $singleSql, $match)) { 139 | $delimiter = $match[1]; 140 | $delimiterLength = strlen($delimiter); 141 | continue; 142 | } 143 | 144 | $parsed = $this->sqlLine($singleSql); 145 | if ($parsed) { 146 | continue; 147 | } 148 | 149 | $query .= "\n" . rtrim($singleSql); 150 | 151 | if (substr($query, -1 * $delimiterLength, $delimiterLength) == $delimiter) { 152 | $this->sqlQuery(substr($query, 0, -1 * $delimiterLength)); 153 | $query = ''; 154 | } 155 | } 156 | 157 | if ($query !== '') { 158 | $this->sqlQuery($query); 159 | } 160 | } 161 | 162 | public function insert(string $tableName, array &$data): string 163 | { 164 | $columns = array_map( 165 | fn($name): string => $this->getQuotedName($name), 166 | array_keys($data) 167 | ); 168 | 169 | return sprintf( 170 | "INSERT INTO %s (%s) VALUES (%s)", 171 | $this->getQuotedName($tableName), 172 | implode(', ', $columns), 173 | implode(', ', array_fill(0, count($data), '?')) 174 | ); 175 | } 176 | 177 | public function select(string $column, string $tableName, array &$criteria): string 178 | { 179 | $where = $this->generateWhereClause($criteria); 180 | 181 | $query = "SELECT %s FROM %s %s"; 182 | return sprintf($query, $column, $this->getQuotedName($tableName), $where); 183 | } 184 | 185 | /** 186 | * @return string[] 187 | */ 188 | private function getSupportedOperators(): array 189 | { 190 | return [ 191 | 'like', 192 | '!=', 193 | '<=', 194 | '>=', 195 | '<', 196 | '>', 197 | ]; 198 | } 199 | 200 | protected function generateWhereClause(array &$criteria): string 201 | { 202 | if (empty($criteria)) { 203 | return ''; 204 | } 205 | 206 | $operands = $this->getSupportedOperators(); 207 | 208 | $params = []; 209 | foreach ($criteria as $k => $v) { 210 | if ($v === null) { 211 | if (strpos($k, ' !=') > 0) { 212 | $params[] = $this->getQuotedName(str_replace(" !=", '', $k)) . " IS NOT NULL "; 213 | } else { 214 | $params[] = $this->getQuotedName($k) . " IS NULL "; 215 | } 216 | 217 | unset($criteria[$k]); 218 | continue; 219 | } 220 | 221 | $hasOperand = false; // search for equals - no additional operand given 222 | 223 | foreach ($operands as $operand) { 224 | if (!stripos($k, " {$operand}") > 0) { 225 | continue; 226 | } 227 | 228 | $hasOperand = true; 229 | $k = str_ireplace(" {$operand}", '', $k); 230 | $operand = strtoupper($operand); 231 | $params[] = $this->getQuotedName($k) . " {$operand} ? "; 232 | break; 233 | } 234 | 235 | if (!$hasOperand) { 236 | $params[] = $this->getQuotedName($k) . " = ? "; 237 | } 238 | } 239 | 240 | return 'WHERE ' . implode('AND ', $params); 241 | } 242 | 243 | public function deleteQueryByCriteria(string $tableName, array $criteria): void 244 | { 245 | $where = $this->generateWhereClause($criteria); 246 | 247 | $query = 'DELETE FROM ' . $this->getQuotedName($tableName) . ' ' . $where; 248 | $this->executeQuery($query, array_values($criteria)); 249 | } 250 | 251 | public function lastInsertId(string $tableName): string 252 | { 253 | return (string)$this->getDbh()->lastInsertId(); 254 | } 255 | 256 | public function getQuotedName(string $name): string 257 | { 258 | return '"' . str_replace('.', '"."', $name) . '"'; 259 | } 260 | 261 | protected function sqlLine(string $sql): bool 262 | { 263 | $sql = trim($sql); 264 | return ( 265 | $sql === '' 266 | || $sql === ';' 267 | || preg_match('#^((--.*?)|(\#))#s', $sql) 268 | ); 269 | } 270 | 271 | protected function sqlQuery(string $query): void 272 | { 273 | try { 274 | $this->dbh->exec($query); 275 | } catch (PDOException $exception) { 276 | throw new ModuleException( 277 | \Codeception\Module\Db::class, 278 | $exception->getMessage() . "\nSQL query being executed: " . $query 279 | ); 280 | } 281 | } 282 | 283 | public function executeQuery($query, array $params): PDOStatement 284 | { 285 | $pdoStatement = $this->dbh->prepare($query); 286 | if (!$pdoStatement) { 287 | throw new Exception("Query '{$query}' can't be prepared."); 288 | } 289 | 290 | $i = 0; 291 | foreach ($params as $param) { 292 | ++$i; 293 | if (is_null($param)) { 294 | $type = PDO::PARAM_NULL; 295 | } elseif (is_bool($param)) { 296 | $type = PDO::PARAM_BOOL; 297 | } elseif (is_int($param)) { 298 | $type = PDO::PARAM_INT; 299 | } elseif (is_string($param) && $this->isBinary($param)) { 300 | $type = PDO::PARAM_LOB; 301 | } else { 302 | $type = PDO::PARAM_STR; 303 | } 304 | 305 | $pdoStatement->bindValue($i, $param, $type); 306 | } 307 | 308 | $pdoStatement->execute(); 309 | return $pdoStatement; 310 | } 311 | 312 | /** 313 | * @return string[] 314 | */ 315 | public function getPrimaryKey(string $tableName): array 316 | { 317 | return []; 318 | } 319 | 320 | protected function flushPrimaryColumnCache(): bool 321 | { 322 | $this->primaryKeys = []; 323 | 324 | return empty($this->primaryKeys); 325 | } 326 | 327 | public function update(string $tableName, array $data, array $criteria): string 328 | { 329 | if (empty($data)) { 330 | throw new InvalidArgumentException( 331 | "Query update can't be prepared without data." 332 | ); 333 | } 334 | 335 | $set = []; 336 | foreach (array_keys($data) as $column) { 337 | $set[] = $this->getQuotedName($column) . " = ?"; 338 | } 339 | 340 | $where = $this->generateWhereClause($criteria); 341 | 342 | return sprintf('UPDATE %s SET %s %s', $this->getQuotedName($tableName), implode(', ', $set), $where); 343 | } 344 | 345 | public function getOptions(): array 346 | { 347 | return $this->options; 348 | } 349 | 350 | protected function isBinary(string $string): bool 351 | { 352 | return false === mb_detect_encoding($string, null, true); 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /src/Codeception/Lib/Driver/MySql.php: -------------------------------------------------------------------------------- 1 | dbh->exec('SET FOREIGN_KEY_CHECKS=0;'); 14 | $res = $this->dbh->query("SHOW FULL TABLES WHERE TABLE_TYPE LIKE '%TABLE';")->fetchAll(); 15 | foreach ($res as $row) { 16 | $this->dbh->exec('drop table `' . $row[0] . '`'); 17 | } 18 | $this->dbh->exec('SET FOREIGN_KEY_CHECKS=1;'); 19 | } 20 | 21 | protected function sqlQuery(string $query): void 22 | { 23 | $this->dbh->exec('SET FOREIGN_KEY_CHECKS=0;'); 24 | parent::sqlQuery($query); 25 | $this->dbh->exec('SET FOREIGN_KEY_CHECKS=1;'); 26 | } 27 | 28 | public function getQuotedName(string $name): string 29 | { 30 | return '`' . str_replace('.', '`.`', $name) . '`'; 31 | } 32 | 33 | /** 34 | * @return string[] 35 | */ 36 | public function getPrimaryKey(string $tableName): array 37 | { 38 | if (!isset($this->primaryKeys[$tableName])) { 39 | $primaryKey = []; 40 | $stmt = $this->getDbh()->query( 41 | 'SHOW KEYS FROM ' . $this->getQuotedName($tableName) . " WHERE Key_name = 'PRIMARY'" 42 | ); 43 | $columns = $stmt->fetchAll(PDO::FETCH_ASSOC); 44 | 45 | foreach ($columns as $column) { 46 | $primaryKey[] = $column['Column_name']; 47 | } 48 | $this->primaryKeys[$tableName] = $primaryKey; 49 | } 50 | 51 | return $this->primaryKeys[$tableName]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Codeception/Lib/Driver/Oci.php: -------------------------------------------------------------------------------- 1 | dbh->exec('ALTER SESSION SET ddl_lock_timeout = ' . $seconds); 12 | } 13 | 14 | public function cleanup(): void 15 | { 16 | $this->dbh->exec( 17 | "BEGIN 18 | FOR i IN (SELECT trigger_name FROM user_triggers) 19 | LOOP 20 | EXECUTE IMMEDIATE('DROP TRIGGER ' || user || '.\"' || i.trigger_name || '\"'); 21 | END LOOP; 22 | END;" 23 | ); 24 | $this->dbh->exec( 25 | "BEGIN 26 | FOR i IN (SELECT table_name FROM user_tables) 27 | LOOP 28 | EXECUTE IMMEDIATE('DROP TABLE ' || user || '.\"' || i.table_name || '\" CASCADE CONSTRAINTS'); 29 | END LOOP; 30 | END;" 31 | ); 32 | $this->dbh->exec( 33 | "BEGIN 34 | FOR i IN (SELECT sequence_name FROM user_sequences) 35 | LOOP 36 | EXECUTE IMMEDIATE('DROP SEQUENCE ' || user || '.\"' || i.sequence_name || '\"'); 37 | END LOOP; 38 | END;" 39 | ); 40 | $this->dbh->exec( 41 | "BEGIN 42 | FOR i IN (SELECT view_name FROM user_views) 43 | LOOP 44 | EXECUTE IMMEDIATE('DROP VIEW ' || user || '.\"' || i.view_name || '\"'); 45 | END LOOP; 46 | END;" 47 | ); 48 | } 49 | 50 | /** 51 | * SQL commands should ends with `//` in the dump file 52 | * IF you want to load triggers too. 53 | * IF you do not want to load triggers you can use the `;` characters 54 | * but in this case you need to change the $delimiter from `//` to `;` 55 | * 56 | * @param string[] $sql 57 | */ 58 | public function load(array $sql): void 59 | { 60 | $query = ''; 61 | $delimiter = '//'; 62 | $delimiterLength = 2; 63 | 64 | foreach ($sql as $singleSql) { 65 | if (preg_match('#DELIMITER ([\;\$\|\\\]+)#i', $singleSql, $match)) { 66 | $delimiter = $match[1]; 67 | $delimiterLength = strlen($delimiter); 68 | continue; 69 | } 70 | 71 | $parsed = $this->sqlLine($singleSql); 72 | if ($parsed) { 73 | continue; 74 | } 75 | 76 | $query .= "\n" . rtrim($singleSql); 77 | 78 | if (substr($query, -1 * $delimiterLength, $delimiterLength) == $delimiter) { 79 | $this->sqlQuery(substr($query, 0, -1 * $delimiterLength)); 80 | $query = ""; 81 | } 82 | } 83 | 84 | if ($query !== '') { 85 | $this->sqlQuery($query); 86 | } 87 | } 88 | 89 | /** 90 | * @return string[] 91 | */ 92 | public function getPrimaryKey(string $tableName): array 93 | { 94 | if (!isset($this->primaryKeys[$tableName])) { 95 | $primaryKey = []; 96 | $query = "SELECT cols.column_name 97 | FROM all_constraints cons, all_cons_columns cols 98 | WHERE cols.table_name = ? 99 | AND cons.constraint_type = 'P' 100 | AND cons.constraint_name = cols.constraint_name 101 | AND cons.owner = cols.owner 102 | ORDER BY cols.table_name, cols.position"; 103 | $stmt = $this->executeQuery($query, [$tableName]); 104 | $columns = $stmt->fetchAll(\PDO::FETCH_ASSOC); 105 | 106 | foreach ($columns as $column) { 107 | $primaryKey[] = $column['COLUMN_NAME']; 108 | } 109 | 110 | $this->primaryKeys[$tableName] = $primaryKey; 111 | } 112 | 113 | return $this->primaryKeys[$tableName]; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Codeception/Lib/Driver/PostgreSql.php: -------------------------------------------------------------------------------- 1 | sqlLine($singleSql); 45 | if ($parsed) { 46 | continue; 47 | } 48 | 49 | // Ignore $$ inside SQL standard string syntax such as in INSERT statements. 50 | if (!preg_match('#\'.*\$\$.*\'#', $singleSql)) { 51 | $pos = strpos($singleSql, '$$'); 52 | if (($pos !== false) && ($pos >= 0)) { 53 | $dollarsOpen = !$dollarsOpen; 54 | } 55 | } 56 | 57 | if (preg_match('#SET search_path = .*#i', $singleSql, $match)) { 58 | $this->searchPath = $match[0]; 59 | } 60 | 61 | $query .= "\n" . rtrim($singleSql); 62 | 63 | if (!$dollarsOpen && substr($query, -1 * $delimiterLength, $delimiterLength) == $delimiter) { 64 | $this->sqlQuery(substr($query, 0, -1 * $delimiterLength)); 65 | $query = ''; 66 | } 67 | } 68 | 69 | if ($query !== '') { 70 | $this->sqlQuery($query); 71 | } 72 | } 73 | 74 | public function cleanup(): void 75 | { 76 | $this->dbh->exec('DROP SCHEMA IF EXISTS public CASCADE;'); 77 | $this->dbh->exec('CREATE SCHEMA public;'); 78 | } 79 | 80 | public function sqlLine(string $sql): bool 81 | { 82 | if (!$this->putline) { 83 | return parent::sqlLine($sql); 84 | } 85 | 86 | if ($sql == '\.') { 87 | $this->putline = false; 88 | pg_put_line($this->connection, $sql . "\n"); 89 | pg_end_copy($this->connection); 90 | pg_close($this->connection); 91 | } else { 92 | pg_put_line($this->connection, $sql . "\n"); 93 | } 94 | 95 | return true; 96 | } 97 | 98 | public function sqlQuery(string $query): void 99 | { 100 | if (strpos(trim($query), 'COPY ') === 0) { 101 | if (!extension_loaded('pgsql')) { 102 | throw new ModuleException( 103 | \Codeception\Module\Db::class, 104 | "To run 'COPY' commands 'pgsql' extension should be installed" 105 | ); 106 | } 107 | 108 | $strConn = str_replace(';', ' ', substr($this->dsn, 6)); 109 | $strConn .= ' user=' . $this->user; 110 | $strConn .= ' password=' . $this->password; 111 | $this->connection = pg_connect($strConn); 112 | 113 | if ($this->searchPath !== null) { 114 | pg_query($this->connection, $this->searchPath); 115 | } 116 | 117 | pg_query($this->connection, $query); 118 | $this->putline = true; 119 | } else { 120 | $this->dbh->exec($query); 121 | } 122 | } 123 | 124 | /** 125 | * Get the last inserted ID of table. 126 | */ 127 | public function lastInsertId(string $tableName): string 128 | { 129 | /** 130 | * We make an assumption that the sequence name for this table 131 | * is based on how postgres names sequences for SERIAL columns 132 | */ 133 | $sequenceName = $this->getQuotedName($tableName . '_id_seq'); 134 | $lastSequence = null; 135 | 136 | try { 137 | $lastSequence = $this->getDbh()->lastInsertId($sequenceName); 138 | } catch (PDOException $exception) { 139 | // in this case, the sequence name might be combined with the primary key name 140 | } 141 | 142 | // here we check if for instance, it's something like table_primary_key_seq instead of table_id_seq 143 | // this could occur when you use some kind of import tool like pgloader 144 | if (!$lastSequence) { 145 | $primaryKeys = $this->getPrimaryKey($tableName); 146 | $pkName = array_shift($primaryKeys); 147 | $lastSequence = $this->getDbh()->lastInsertId($this->getQuotedName($tableName . '_' . $pkName . '_seq')); 148 | } 149 | 150 | return $lastSequence; 151 | } 152 | 153 | /** 154 | * Returns the primary key(s) of the table, based on: 155 | * https://wiki.postgresql.org/wiki/Retrieve_primary_key_columns. 156 | * 157 | * @return string[] 158 | */ 159 | public function getPrimaryKey(string $tableName): array 160 | { 161 | if (!isset($this->primaryKeys[$tableName])) { 162 | $primaryKey = []; 163 | $query = "SELECT a.attname 164 | FROM pg_index i 165 | JOIN pg_attribute a ON a.attrelid = i.indrelid 166 | AND a.attnum = ANY(i.indkey) 167 | WHERE i.indrelid = '" . $this->getQuotedName($tableName) . "'::regclass 168 | AND i.indisprimary"; 169 | $stmt = $this->executeQuery($query, []); 170 | $columns = $stmt->fetchAll(PDO::FETCH_ASSOC); 171 | foreach ($columns as $column) { 172 | $primaryKey[] = $column['attname']; 173 | } 174 | 175 | $this->primaryKeys[$tableName] = $primaryKey; 176 | } 177 | 178 | return $this->primaryKeys[$tableName]; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/Codeception/Lib/Driver/SqlSrv.php: -------------------------------------------------------------------------------- 1 | dsn, $matches); 15 | 16 | if (!$matched) { 17 | return false; 18 | } 19 | 20 | return $matches[1]; 21 | } 22 | 23 | public function cleanup(): void 24 | { 25 | $this->dbh->exec( 26 | " 27 | DECLARE constraints_cursor CURSOR FOR SELECT name, parent_object_id FROM sys.foreign_keys; 28 | OPEN constraints_cursor 29 | DECLARE @constraint sysname; 30 | DECLARE @parent int; 31 | DECLARE @table nvarchar(128); 32 | FETCH NEXT FROM constraints_cursor INTO @constraint, @parent; 33 | WHILE (@@FETCH_STATUS <> -1) 34 | BEGIN 35 | SET @table = OBJECT_NAME(@parent) 36 | EXEC ('ALTER TABLE [' + @table + '] DROP CONSTRAINT [' + @constraint + ']') 37 | FETCH NEXT FROM constraints_cursor INTO @constraint, @parent; 38 | END 39 | DEALLOCATE constraints_cursor;" 40 | ); 41 | 42 | $this->dbh->exec( 43 | " 44 | DECLARE tables_cursor CURSOR FOR SELECT name FROM sysobjects WHERE type = 'U'; 45 | OPEN tables_cursor DECLARE @tablename sysname; 46 | FETCH NEXT FROM tables_cursor INTO @tablename; 47 | WHILE (@@FETCH_STATUS <> -1) 48 | BEGIN 49 | EXEC ('DROP TABLE [' + @tablename + ']') 50 | FETCH NEXT FROM tables_cursor INTO @tablename; 51 | END 52 | DEALLOCATE tables_cursor;" 53 | ); 54 | } 55 | 56 | public function getQuotedName(string $name): string 57 | { 58 | return '[' . str_replace('.', '].[', $name) . ']'; 59 | } 60 | 61 | /** 62 | * @return string[] 63 | */ 64 | public function getPrimaryKey(string $tableName): array 65 | { 66 | if (!isset($this->primaryKeys[$tableName])) { 67 | $primaryKey = []; 68 | $query = " 69 | SELECT Col.Column_Name from 70 | INFORMATION_SCHEMA.TABLE_CONSTRAINTS Tab, 71 | INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE Col 72 | WHERE 73 | Col.Constraint_Name = Tab.Constraint_Name 74 | AND Col.Table_Name = Tab.Table_Name 75 | AND Constraint_Type = 'PRIMARY KEY' AND Col.Table_Name = ?"; 76 | $stmt = $this->executeQuery($query, [$tableName]); 77 | $columns = $stmt->fetchAll(PDO::FETCH_ASSOC); 78 | 79 | foreach ($columns as $column) { 80 | $primaryKey[] = $column['Column_Name']; 81 | } 82 | 83 | $this->primaryKeys[$tableName] = $primaryKey; 84 | } 85 | 86 | return $this->primaryKeys[$tableName]; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Codeception/Lib/Driver/Sqlite.php: -------------------------------------------------------------------------------- 1 | filename = Configuration::projectDir() . $filename; 25 | $this->dsn = 'sqlite:' . $this->filename; 26 | parent::__construct($this->dsn, $user, $password, $options); 27 | } 28 | 29 | public function cleanup(): void 30 | { 31 | $this->dbh = null; 32 | gc_collect_cycles(); 33 | file_put_contents($this->filename, ''); 34 | $this->dbh = self::connect($this->dsn, $this->user, $this->password); 35 | } 36 | 37 | /** 38 | * @param string[] $sql 39 | */ 40 | public function load(array $sql): void 41 | { 42 | if ($this->hasSnapshot) { 43 | $this->dbh = null; 44 | copy($this->filename . '_snapshot', $this->filename); 45 | $this->dbh = new PDO($this->dsn, $this->user, $this->password); 46 | } else { 47 | if (file_exists($this->filename . '_snapshot')) { 48 | unlink($this->filename . '_snapshot'); 49 | } 50 | 51 | parent::load($sql); 52 | copy($this->filename, $this->filename . '_snapshot'); 53 | $this->hasSnapshot = true; 54 | } 55 | } 56 | 57 | /** 58 | * @return string[] 59 | */ 60 | public function getPrimaryKey(string $tableName): array 61 | { 62 | if (!isset($this->primaryKeys[$tableName])) { 63 | if ($this->hasRowId($tableName)) { 64 | return $this->primaryKeys[$tableName] = ['_ROWID_']; 65 | } 66 | 67 | $primaryKey = []; 68 | $query = 'PRAGMA table_info(' . $this->getQuotedName($tableName) . ')'; 69 | $stmt = $this->executeQuery($query, []); 70 | $columns = $stmt->fetchAll(PDO::FETCH_ASSOC); 71 | 72 | foreach ($columns as $column) { 73 | if ($column['pk'] !== '0' && $column['pk'] !== 0) { 74 | $primaryKey[] = $column['name']; 75 | } 76 | } 77 | 78 | $this->primaryKeys[$tableName] = $primaryKey; 79 | } 80 | 81 | return $this->primaryKeys[$tableName]; 82 | } 83 | 84 | private function hasRowId($tableName): bool 85 | { 86 | $params = ['type' => 'table', 'name' => $tableName]; 87 | $select = $this->select('sql', 'sqlite_master', $params); 88 | $result = $this->executeQuery($select, $params); 89 | $sql = $result->fetchColumn(); 90 | return strpos($sql, ') WITHOUT ROWID') === false; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Codeception/Lib/Interfaces/Db.php: -------------------------------------------------------------------------------- 1 | seeInDatabase('users', ['name' => 'Davert', 'email' => 'davert@mail.com']); 14 | * ``` 15 | * Fails if no such user found. 16 | * 17 | * Comparison expressions can be used as well: 18 | * 19 | * ```php 20 | * seeInDatabase('posts', ['num_comments >=' => '0']); 22 | * $I->seeInDatabase('users', ['email like' => 'miles@davis.com']); 23 | * ``` 24 | * 25 | * Supported operators: `<`, `>`, `>=`, `<=`, `!=`, `like`. 26 | */ 27 | public function seeInDatabase(string $table, array $criteria = []): void; 28 | 29 | /** 30 | * Effect is opposite to ->seeInDatabase 31 | * 32 | * Asserts that there is no record with the given column values in a database. 33 | * Provide table name and column values. 34 | * 35 | * ``` php 36 | * dontSeeInDatabase('users', ['name' => 'Davert', 'email' => 'davert@mail.com']); 38 | * ``` 39 | * Fails if such user was found. 40 | * 41 | * Comparison expressions can be used as well: 42 | * 43 | * ```php 44 | * dontSeeInDatabase('posts', ['num_comments >=' => '0']); 46 | * $I->dontSeeInDatabase('users', ['email like' => 'miles%']); 47 | * ``` 48 | * 49 | * Supported operators: `<`, `>`, `>=`, `<=`, `!=`, `like`. 50 | */ 51 | public function dontSeeInDatabase(string $table, array $criteria = []): void; 52 | 53 | /** 54 | * Fetches a single column value from a database. 55 | * Provide table name, desired column and criteria. 56 | * 57 | * ``` php 58 | * grabFromDatabase('users', 'email', ['name' => 'Davert']); 60 | * ``` 61 | * Comparison expressions can be used as well: 62 | * 63 | * ```php 64 | * grabFromDatabase('posts', ['num_comments >=' => 100]); 66 | * $user = $I->grabFromDatabase('users', ['email like' => 'miles%']); 67 | * ``` 68 | * 69 | * Supported operators: `<`, `>`, `>=`, `<=`, `!=`, `like`. 70 | * 71 | * @return mixed 72 | */ 73 | public function grabFromDatabase(string $table, string $column, array $criteria = []); 74 | } 75 | -------------------------------------------------------------------------------- /src/Codeception/Module/Db.php: -------------------------------------------------------------------------------- 1 | seeInDatabase('users', ['name' => 'Davert', 'email' => 'davert@mail.com']); 203 | * 204 | * ``` 205 | * Will generate: 206 | * 207 | * ```sql 208 | * SELECT COUNT(*) FROM `users` WHERE `name` = 'Davert' AND `email` = 'davert@mail.com' 209 | * ``` 210 | * Since version 2.1.9 it's possible to use LIKE in a condition, as shown here: 211 | * 212 | * ```php 213 | * seeInDatabase('users', ['name' => 'Davert', 'email like' => 'davert%']); 215 | * 216 | * ``` 217 | * Will generate: 218 | * 219 | * ```sql 220 | * SELECT COUNT(*) FROM `users` WHERE `name` = 'Davert' AND `email` LIKE 'davert%' 221 | * ``` 222 | * Null comparisons are also available, as shown here: 223 | * 224 | * ```php 225 | * seeInDatabase('users', ['name' => null, 'email !=' => null]); 227 | * 228 | * ``` 229 | * Will generate: 230 | * 231 | * ```sql 232 | * SELECT COUNT(*) FROM `users` WHERE `name` IS NULL AND `email` IS NOT NULL 233 | * ``` 234 | * ## Public Properties 235 | * * dbh - contains the PDO connection 236 | * * driver - contains the Connection Driver 237 | * 238 | */ 239 | class Db extends Module implements DbInterface 240 | { 241 | /** 242 | * @var array 243 | */ 244 | protected array $config = [ 245 | 'populate' => false, 246 | 'cleanup' => false, 247 | 'reconnect' => false, 248 | 'waitlock' => 0, 249 | 'dump' => null, 250 | 'populator' => null, 251 | 'skip_cleanup_if_failed' => false, 252 | ]; 253 | 254 | /** 255 | * @var string[] 256 | */ 257 | protected array $requiredFields = ['dsn', 'user', 'password']; 258 | 259 | /** 260 | * @var string 261 | */ 262 | public const DEFAULT_DATABASE = 'default'; 263 | 264 | /** 265 | * @var Driver[] 266 | */ 267 | public array $drivers = []; 268 | 269 | /** 270 | * @var PDO[] 271 | */ 272 | public array $dbhs = []; 273 | 274 | public array $databasesPopulated = []; 275 | 276 | public array $databasesSql = []; 277 | 278 | protected array $insertedRows = []; 279 | 280 | public string $currentDatabase = self::DEFAULT_DATABASE; 281 | 282 | protected function getDatabases(): array 283 | { 284 | $databases = [$this->currentDatabase => $this->config]; 285 | 286 | if (!empty($this->config['databases'])) { 287 | foreach ($this->config['databases'] as $databaseKey => $databaseConfig) { 288 | $databases[$databaseKey] = array_merge([ 289 | 'populate' => false, 290 | 'cleanup' => false, 291 | 'reconnect' => false, 292 | 'waitlock' => 0, 293 | 'dump' => null, 294 | 'populator' => null, 295 | ], $databaseConfig); 296 | } 297 | } 298 | 299 | return $databases; 300 | } 301 | 302 | protected function connectToDatabases(): void 303 | { 304 | foreach ($this->getDatabases() as $databaseKey => $databaseConfig) { 305 | $this->connect($databaseKey, $databaseConfig); 306 | } 307 | } 308 | 309 | protected function cleanUpDatabases(): void 310 | { 311 | foreach ($this->getDatabases() as $databaseKey => $databaseConfig) { 312 | $this->_cleanup($databaseKey, $databaseConfig); 313 | } 314 | } 315 | 316 | protected function populateDatabases($configKey): void 317 | { 318 | foreach ($this->getDatabases() as $databaseKey => $databaseConfig) { 319 | if ($databaseConfig[$configKey]) { 320 | if (!$databaseConfig['populate']) { 321 | return; 322 | } 323 | 324 | if (isset($this->databasesPopulated[$databaseKey]) && $this->databasesPopulated[$databaseKey]) { 325 | return; 326 | } 327 | 328 | $this->_loadDump($databaseKey, $databaseConfig); 329 | } 330 | } 331 | } 332 | 333 | protected function readSqlForDatabases(): void 334 | { 335 | foreach ($this->getDatabases() as $databaseKey => $databaseConfig) { 336 | $this->readSql($databaseKey, $databaseConfig); 337 | } 338 | } 339 | 340 | protected function removeInsertedForDatabases(): void 341 | { 342 | foreach (array_keys($this->getDatabases()) as $databaseKey) { 343 | $this->amConnectedToDatabase($databaseKey); 344 | $this->removeInserted($databaseKey); 345 | } 346 | } 347 | 348 | protected function disconnectDatabases(): void 349 | { 350 | foreach (array_keys($this->getDatabases()) as $databaseKey) { 351 | $this->disconnect($databaseKey); 352 | } 353 | } 354 | 355 | protected function reconnectDatabases(): void 356 | { 357 | foreach ($this->getDatabases() as $databaseKey => $databaseConfig) { 358 | if ($databaseConfig['reconnect']) { 359 | $this->disconnect($databaseKey); 360 | $this->connect($databaseKey, $databaseConfig); 361 | } 362 | } 363 | } 364 | 365 | public function __get($name) 366 | { 367 | Notification::deprecate("Properties dbh and driver are deprecated in favor of Db::_getDbh and Db::_getDriver", "Db module"); 368 | 369 | if ($name == 'driver') { 370 | return $this->_getDriver(); 371 | } 372 | 373 | if ($name == 'dbh') { 374 | return $this->_getDbh(); 375 | } 376 | } 377 | 378 | public function _getDriver(): Driver 379 | { 380 | return $this->drivers[$this->currentDatabase]; 381 | } 382 | 383 | public function _getDbh(): PDO 384 | { 385 | return $this->dbhs[$this->currentDatabase]; 386 | } 387 | 388 | /** 389 | * Make sure you are connected to the right database. 390 | * 391 | * ```php 392 | * seeNumRecords(2, 'users'); //executed on default database 394 | * $I->amConnectedToDatabase('db_books'); 395 | * $I->seeNumRecords(30, 'books'); //executed on db_books database 396 | * //All the next queries will be on db_books 397 | * ``` 398 | * 399 | * @throws ModuleConfigException 400 | */ 401 | public function amConnectedToDatabase(string $databaseKey): void 402 | { 403 | if (empty($this->getDatabases()[$databaseKey]) && $databaseKey != self::DEFAULT_DATABASE) { 404 | throw new ModuleConfigException( 405 | __CLASS__, 406 | "\nNo database {$databaseKey} in the key databases.\n" 407 | ); 408 | } 409 | 410 | $this->currentDatabase = $databaseKey; 411 | } 412 | 413 | /** 414 | * Can be used with a callback if you don't want to change the current database in your test. 415 | * 416 | * ```php 417 | * seeNumRecords(2, 'users'); //executed on default database 419 | * $I->performInDatabase('db_books', function($I) { 420 | * $I->seeNumRecords(30, 'books'); //executed on db_books database 421 | * }); 422 | * $I->seeNumRecords(2, 'users'); //executed on default database 423 | * ``` 424 | * List of actions can be pragmatically built using `Codeception\Util\ActionSequence`: 425 | * 426 | * ```php 427 | * performInDatabase('db_books', ActionSequence::build() 429 | * ->seeNumRecords(30, 'books') 430 | * ); 431 | * ``` 432 | * Alternatively an array can be used: 433 | * 434 | * ```php 435 | * $I->performInDatabase('db_books', ['seeNumRecords' => [30, 'books']]); 436 | * ``` 437 | * 438 | * Choose the syntax you like the most and use it, 439 | * 440 | * Actions executed from array or ActionSequence will print debug output for actions, and adds an action name to 441 | * exception on failure. 442 | * 443 | * @param $databaseKey 444 | * @param ActionSequence|array|callable $actions 445 | * @throws ModuleConfigException 446 | */ 447 | public function performInDatabase($databaseKey, $actions): void 448 | { 449 | $backupDatabase = $this->currentDatabase; 450 | $this->amConnectedToDatabase($databaseKey); 451 | 452 | if (is_callable($actions)) { 453 | $actions($this); 454 | $this->amConnectedToDatabase($backupDatabase); 455 | return; 456 | } 457 | 458 | if (is_array($actions)) { 459 | $actions = ActionSequence::build()->fromArray($actions); 460 | } 461 | 462 | if (!$actions instanceof ActionSequence) { 463 | throw new InvalidArgumentException("2nd parameter, actions should be callback, ActionSequence or array"); 464 | } 465 | 466 | $actions->run($this); 467 | $this->amConnectedToDatabase($backupDatabase); 468 | } 469 | 470 | public function _initialize(): void 471 | { 472 | $this->connectToDatabases(); 473 | } 474 | 475 | public function __destruct() 476 | { 477 | $this->disconnectDatabases(); 478 | } 479 | 480 | public function _beforeSuite($settings = []): void 481 | { 482 | $this->readSqlForDatabases(); 483 | $this->connectToDatabases(); 484 | $this->cleanUpDatabases(); 485 | $this->populateDatabases('populate'); 486 | } 487 | 488 | private function readSql($databaseKey = null, $databaseConfig = null): void 489 | { 490 | if ($databaseConfig['populator']) { 491 | return; 492 | } 493 | 494 | if (!$databaseConfig['cleanup'] && !$databaseConfig['populate']) { 495 | return; 496 | } 497 | 498 | if (empty($databaseConfig['dump'])) { 499 | return; 500 | } 501 | 502 | if (!is_array($databaseConfig['dump'])) { 503 | $databaseConfig['dump'] = [$databaseConfig['dump']]; 504 | } 505 | 506 | $sql = ''; 507 | 508 | foreach ($databaseConfig['dump'] as $filePath) { 509 | $sql .= $this->readSqlFile($filePath); 510 | } 511 | 512 | if (!empty($sql)) { 513 | // split SQL dump into lines 514 | $this->databasesSql[$databaseKey] = preg_split('#\r\n|\n|\r#', $sql, -1, PREG_SPLIT_NO_EMPTY); 515 | } 516 | } 517 | 518 | /** 519 | * @throws ModuleConfigException|ModuleException 520 | */ 521 | private function readSqlFile(string $filePath): ?string 522 | { 523 | if (!file_exists(Configuration::projectDir() . $filePath)) { 524 | throw new ModuleConfigException( 525 | __CLASS__, 526 | "\nFile with dump doesn't exist.\n" 527 | . "Please, check path for sql file: " 528 | . $filePath 529 | ); 530 | } 531 | 532 | $sql = file_get_contents(Configuration::projectDir() . $filePath); 533 | 534 | // remove C-style comments (except MySQL directives) 535 | $replaced = preg_replace('#/\*(?!!\d+).*?\*/#s', '', $sql); 536 | 537 | if (!empty($sql) && is_null($replaced)) { 538 | throw new ModuleException( 539 | __CLASS__, 540 | "Please, increase pcre.backtrack_limit value in PHP CLI config" 541 | ); 542 | } 543 | 544 | return $replaced; 545 | } 546 | 547 | private function connect($databaseKey, $databaseConfig): void 548 | { 549 | if (!empty($this->drivers[$databaseKey]) && !empty($this->dbhs[$databaseKey])) { 550 | return; 551 | } 552 | 553 | $options = []; 554 | 555 | if ( 556 | array_key_exists('ssl_key', $databaseConfig) 557 | && !empty($databaseConfig['ssl_key']) 558 | && defined(PDO::class . '::MYSQL_ATTR_SSL_KEY') 559 | ) { 560 | $options[PDO::MYSQL_ATTR_SSL_KEY] = (string) $databaseConfig['ssl_key']; 561 | } 562 | 563 | if ( 564 | array_key_exists('ssl_cert', $databaseConfig) 565 | && !empty($databaseConfig['ssl_cert']) 566 | && defined(PDO::class . '::MYSQL_ATTR_SSL_CERT') 567 | ) { 568 | $options[PDO::MYSQL_ATTR_SSL_CERT] = (string) $databaseConfig['ssl_cert']; 569 | } 570 | 571 | if ( 572 | array_key_exists('ssl_ca', $databaseConfig) 573 | && !empty($databaseConfig['ssl_ca']) 574 | && defined(PDO::class . '::MYSQL_ATTR_SSL_CA') 575 | ) { 576 | $options[PDO::MYSQL_ATTR_SSL_CA] = (string) $databaseConfig['ssl_ca']; 577 | } 578 | 579 | if ( 580 | array_key_exists('ssl_cipher', $databaseConfig) 581 | && !empty($databaseConfig['ssl_cipher']) 582 | && defined(PDO::class . '::MYSQL_ATTR_SSL_CIPHER') 583 | ) { 584 | $options[PDO::MYSQL_ATTR_SSL_CIPHER] = (string) $databaseConfig['ssl_cipher']; 585 | } 586 | 587 | if ( 588 | array_key_exists('ssl_verify_server_cert', $databaseConfig) 589 | && defined(PDO::class . '::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT') 590 | ) { 591 | $options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = (bool) $databaseConfig[ 'ssl_verify_server_cert' ]; 592 | } 593 | 594 | try { 595 | $this->debugSection('Connecting To Db', ['config' => $databaseConfig, 'options' => $options]); 596 | $this->drivers[$databaseKey] = Driver::create($databaseConfig['dsn'], $databaseConfig['user'], $databaseConfig['password'], $options); 597 | } catch (PDOException $exception) { 598 | $message = $exception->getMessage(); 599 | if ($message === 'could not find driver') { 600 | [$missingDriver, ] = explode(':', $databaseConfig['dsn'], 2); 601 | $message = sprintf('could not find %s driver', $missingDriver); 602 | } 603 | 604 | throw new ModuleException(__CLASS__, $message . ' while creating PDO connection'); 605 | } 606 | 607 | if ($databaseConfig['waitlock']) { 608 | $this->_getDriver()->setWaitLock($databaseConfig['waitlock']); 609 | } 610 | 611 | if (isset($databaseConfig['initial_queries'])) { 612 | foreach ($databaseConfig['initial_queries'] as $initialQuery) { 613 | $this->drivers[$databaseKey]->executeQuery($initialQuery, []); 614 | } 615 | } 616 | 617 | $this->debugSection('Db', 'Connected to ' . $databaseKey . ' ' . $this->drivers[$databaseKey]->getDb()); 618 | $this->dbhs[$databaseKey] = $this->drivers[$databaseKey]->getDbh(); 619 | } 620 | 621 | private function disconnect($databaseKey): void 622 | { 623 | $this->debugSection('Db', 'Disconnected from ' . $databaseKey); 624 | $this->dbhs[$databaseKey] = null; 625 | $this->drivers[$databaseKey] = null; 626 | } 627 | 628 | public function _before(TestInterface $test): void 629 | { 630 | $this->reconnectDatabases(); 631 | $this->amConnectedToDatabase(self::DEFAULT_DATABASE); 632 | 633 | $this->cleanUpDatabases(); 634 | 635 | $this->populateDatabases('cleanup'); 636 | 637 | parent::_before($test); 638 | } 639 | 640 | public function _failed(TestInterface $test, $fail) 641 | { 642 | foreach ($this->getDatabases() as $databaseKey => $databaseConfig) { 643 | if ($databaseConfig['skip_cleanup_if_failed'] ?? false) { 644 | $this->insertedRows[$databaseKey] = []; 645 | } 646 | } 647 | } 648 | 649 | public function _after(TestInterface $test): void 650 | { 651 | $this->removeInsertedForDatabases(); 652 | parent::_after($test); 653 | } 654 | 655 | protected function removeInserted($databaseKey = null): void 656 | { 657 | $databaseKey = empty($databaseKey) ? self::DEFAULT_DATABASE : $databaseKey; 658 | 659 | if (empty($this->insertedRows[$databaseKey])) { 660 | return; 661 | } 662 | 663 | foreach (array_reverse($this->insertedRows[$databaseKey]) as $row) { 664 | try { 665 | $this->_getDriver()->deleteQueryByCriteria($row['table'], $row['primary']); 666 | } catch (Exception $e) { 667 | $this->debug("Couldn't delete record " . json_encode($row['primary'], JSON_THROW_ON_ERROR) . " from {$row['table']}"); 668 | } 669 | } 670 | 671 | $this->insertedRows[$databaseKey] = []; 672 | } 673 | 674 | public function _cleanup(?string $databaseKey = null, ?array $databaseConfig = null): void 675 | { 676 | $databaseKey = empty($databaseKey) ? self::DEFAULT_DATABASE : $databaseKey; 677 | $databaseConfig = empty($databaseConfig) ? $this->config : $databaseConfig; 678 | 679 | if (!$databaseConfig['populate']) { 680 | return; 681 | } 682 | 683 | if (!$databaseConfig['cleanup']) { 684 | return; 685 | } 686 | 687 | if (isset($this->databasesPopulated[$databaseKey]) && !$this->databasesPopulated[$databaseKey]) { 688 | return; 689 | } 690 | 691 | $dbh = $this->dbhs[$databaseKey]; 692 | if (!$dbh) { 693 | throw new ModuleConfigException( 694 | __CLASS__, 695 | "No connection to database. Remove this module from config if you don't need database repopulation" 696 | ); 697 | } 698 | 699 | try { 700 | if (!$this->shouldCleanup($databaseConfig, $databaseKey)) { 701 | return; 702 | } 703 | 704 | $this->drivers[$databaseKey]->cleanup(); 705 | $this->databasesPopulated[$databaseKey] = false; 706 | } catch (Exception $e) { 707 | throw new ModuleException(__CLASS__, $e->getMessage()); 708 | } 709 | } 710 | 711 | protected function shouldCleanup(array $databaseConfig, string $databaseKey): bool 712 | { 713 | // If using populator and it's not empty, clean up regardless 714 | if (!empty($databaseConfig['populator'])) { 715 | return true; 716 | } 717 | 718 | // If no sql dump for $databaseKey or sql dump is empty, don't clean up 719 | return !empty($this->databasesSql[$databaseKey]); 720 | } 721 | 722 | public function _isPopulated() 723 | { 724 | return $this->databasesPopulated[$this->currentDatabase]; 725 | } 726 | 727 | public function _loadDump(?string $databaseKey = null, ?array $databaseConfig = null): void 728 | { 729 | $databaseKey = empty($databaseKey) ? self::DEFAULT_DATABASE : $databaseKey; 730 | $databaseConfig = empty($databaseConfig) ? $this->config : $databaseConfig; 731 | 732 | if (!empty($databaseConfig['populator'])) { 733 | $this->loadDumpUsingPopulator($databaseKey, $databaseConfig); 734 | return; 735 | } 736 | 737 | $this->loadDumpUsingDriver($databaseKey); 738 | } 739 | 740 | protected function loadDumpUsingPopulator(string $databaseKey, array $databaseConfig): void 741 | { 742 | $populator = new DbPopulator($databaseConfig); 743 | $this->databasesPopulated[$databaseKey] = $populator->run(); 744 | } 745 | 746 | protected function loadDumpUsingDriver(string $databaseKey): void 747 | { 748 | if (!isset($this->databasesSql[$databaseKey])) { 749 | return; 750 | } 751 | 752 | if (!$this->databasesSql[$databaseKey]) { 753 | $this->debugSection('Db', 'No SQL loaded, loading dump skipped'); 754 | return; 755 | } 756 | 757 | $this->drivers[$databaseKey]->load($this->databasesSql[$databaseKey]); 758 | $this->databasesPopulated[$databaseKey] = true; 759 | } 760 | 761 | /** 762 | * Inserts an SQL record into a database. This record will be erased after the test, 763 | * unless you've configured "skip_cleanup_if_failed", and the test fails. 764 | * 765 | * ```php 766 | * haveInDatabase('users', ['name' => 'miles', 'email' => 'miles@davis.com']); 768 | * ``` 769 | */ 770 | public function haveInDatabase(string $table, array $data): int 771 | { 772 | $lastInsertId = $this->_insertInDatabase($table, $data); 773 | 774 | $this->addInsertedRow($table, $data, $lastInsertId); 775 | 776 | return $lastInsertId; 777 | } 778 | 779 | public function _insertInDatabase(string $table, array $data): int 780 | { 781 | $query = $this->_getDriver()->insert($table, $data); 782 | $parameters = array_values($data); 783 | $this->debugSection('Query', $query); 784 | $this->debugSection('Parameters', $parameters); 785 | $this->_getDriver()->executeQuery($query, $parameters); 786 | 787 | try { 788 | $lastInsertId = (int)$this->_getDriver()->lastInsertId($table); 789 | } catch (PDOException $e) { 790 | // ignore errors due to uncommon DB structure, 791 | // such as tables without _id_seq in PGSQL 792 | $lastInsertId = 0; 793 | $this->debugSection('DB error', $e->getMessage()); 794 | } 795 | 796 | return $lastInsertId; 797 | } 798 | 799 | private function addInsertedRow(string $table, array $row, $id): void 800 | { 801 | $primaryKey = $this->_getDriver()->getPrimaryKey($table); 802 | $primary = []; 803 | if ($primaryKey !== []) { 804 | $filledKeys = array_intersect($primaryKey, array_keys($row)); 805 | $missingPrimaryKeyColumns = array_diff_key($primaryKey, $filledKeys); 806 | 807 | if (count($missingPrimaryKeyColumns) === 0) { 808 | $primary = array_intersect_key($row, array_flip($primaryKey)); 809 | } elseif (count($missingPrimaryKeyColumns) === 1) { 810 | $primary = array_intersect_key($row, array_flip($primaryKey)); 811 | $missingColumn = reset($missingPrimaryKeyColumns); 812 | $primary[$missingColumn] = $id; 813 | } else { 814 | foreach ($primaryKey as $column) { 815 | if (isset($row[$column])) { 816 | $primary[$column] = $row[$column]; 817 | } else { 818 | throw new InvalidArgumentException( 819 | 'Primary key field ' . $column . ' is not set for table ' . $table 820 | ); 821 | } 822 | } 823 | } 824 | } else { 825 | $primary = $row; 826 | } 827 | 828 | $this->insertedRows[$this->currentDatabase][] = [ 829 | 'table' => $table, 830 | 'primary' => $primary, 831 | ]; 832 | } 833 | 834 | public function seeInDatabase(string $table, array $criteria = []): void 835 | { 836 | $res = $this->countInDatabase($table, $criteria); 837 | $this->assertGreaterThan( 838 | 0, 839 | $res, 840 | 'No matching records found for criteria ' . json_encode($criteria, JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE) . ' in table ' . $table 841 | ); 842 | } 843 | 844 | /** 845 | * Asserts that the given number of records were found in the database. 846 | * 847 | * ```php 848 | * seeNumRecords(1, 'users', ['name' => 'davert']) 850 | * ``` 851 | * 852 | * @param int $expectedNumber Expected number 853 | * @param string $table Table name 854 | * @param array $criteria Search criteria [Optional] 855 | */ 856 | public function seeNumRecords(int $expectedNumber, string $table, array $criteria = []): void 857 | { 858 | $actualNumber = $this->countInDatabase($table, $criteria); 859 | $this->assertSame( 860 | $expectedNumber, 861 | $actualNumber, 862 | sprintf( 863 | 'The number of found rows (%d) does not match expected number %d for criteria %s in table %s', 864 | $actualNumber, 865 | $expectedNumber, 866 | json_encode($criteria, JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE), 867 | $table 868 | ) 869 | ); 870 | } 871 | 872 | public function dontSeeInDatabase(string $table, array $criteria = []): void 873 | { 874 | $count = $this->countInDatabase($table, $criteria); 875 | $this->assertLessThan( 876 | 1, 877 | $count, 878 | 'Unexpectedly found matching records for criteria ' . json_encode($criteria, JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE) . ' in table ' . $table 879 | ); 880 | } 881 | 882 | /** 883 | * Count rows in a database 884 | * 885 | * @param string $table Table name 886 | * @param array $criteria Search criteria [Optional] 887 | * @return int 888 | */ 889 | protected function countInDatabase(string $table, array $criteria = []): int 890 | { 891 | return (int) $this->proceedSeeInDatabase($table, 'count(*)', $criteria); 892 | } 893 | 894 | /** 895 | * Fetches all values from the column in database. 896 | * Provide table name, desired column and criteria. 897 | * 898 | * @return mixed 899 | */ 900 | protected function proceedSeeInDatabase(string $table, string $column, array $criteria) 901 | { 902 | $query = $this->_getDriver()->select($column, $table, $criteria); 903 | $parameters = array_values($criteria); 904 | $this->debugSection('Query', $query); 905 | if (!empty($parameters)) { 906 | $this->debugSection('Parameters', $parameters); 907 | } 908 | 909 | $sth = $this->_getDriver()->executeQuery($query, $parameters); 910 | 911 | return $sth->fetchColumn(); 912 | } 913 | 914 | /** 915 | * Fetches all values from the column in database. 916 | * Provide table name, desired column and criteria. 917 | * 918 | * ``` php 919 | * grabColumnFromDatabase('users', 'email', ['name' => 'RebOOter']); 921 | * ``` 922 | */ 923 | public function grabColumnFromDatabase(string $table, string $column, array $criteria = []): array 924 | { 925 | $query = $this->_getDriver()->select($column, $table, $criteria); 926 | $parameters = array_values($criteria); 927 | $this->debugSection('Query', $query); 928 | $this->debugSection('Parameters', $parameters); 929 | $sth = $this->_getDriver()->executeQuery($query, $parameters); 930 | 931 | return $sth->fetchAll(PDO::FETCH_COLUMN, 0); 932 | } 933 | 934 | /** 935 | * Fetches a single column value from a database. 936 | * Provide table name, desired column and criteria. 937 | * 938 | * ``` php 939 | * grabFromDatabase('users', 'email', ['name' => 'Davert']); 941 | * ``` 942 | * Comparison expressions can be used as well: 943 | * 944 | * ```php 945 | * grabFromDatabase('posts', 'num_comments', ['num_comments >=' => 100]); 947 | * $mail = $I->grabFromDatabase('users', 'email', ['email like' => 'miles%']); 948 | * ``` 949 | * 950 | * Supported operators: `<`, `>`, `>=`, `<=`, `!=`, `like`. 951 | * 952 | * @return mixed Returns a single column value or false 953 | */ 954 | public function grabFromDatabase(string $table, string $column, array $criteria = []) 955 | { 956 | return $this->proceedSeeInDatabase($table, $column, $criteria); 957 | } 958 | 959 | /** 960 | * Fetches a whole entry from a database. 961 | * Make the test fail if the entry is not found. 962 | * Provide table name, desired column and criteria. 963 | * 964 | * ``` php 965 | * grabEntryFromDatabase('users', ['name' => 'Davert']); 967 | * ``` 968 | * Comparison expressions can be used as well: 969 | * 970 | * ```php 971 | * grabEntryFromDatabase('posts', ['num_comments >=' => 100]); 973 | * $user = $I->grabEntryFromDatabase('users', ['email like' => 'miles%']); 974 | * ``` 975 | * 976 | * Supported operators: `<`, `>`, `>=`, `<=`, `!=`, `like`. 977 | * 978 | * @return array Returns a single entry value 979 | * @throws PDOException|Exception 980 | */ 981 | public function grabEntryFromDatabase(string $table, array $criteria = []): array 982 | { 983 | $query = $this->_getDriver()->select('*', $table, $criteria); 984 | $parameters = array_values($criteria); 985 | $this->debugSection('Query', $query); 986 | $this->debugSection('Parameters', $parameters); 987 | $sth = $this->_getDriver()->executeQuery($query, $parameters); 988 | 989 | $result = $sth->fetch(PDO::FETCH_ASSOC, 0); 990 | 991 | if ($result === false) { 992 | throw new \AssertionError("No matching row found"); 993 | } 994 | 995 | return $result; 996 | } 997 | 998 | /** 999 | * Fetches a set of entries from a database. 1000 | * Provide table name and criteria. 1001 | * 1002 | * ``` php 1003 | * grabEntriesFromDatabase('users', ['name' => 'Davert']); 1005 | * ``` 1006 | * Comparison expressions can be used as well: 1007 | * 1008 | * ```php 1009 | * grabEntriesFromDatabase('posts', ['num_comments >=' => 100]); 1011 | * $user = $I->grabEntriesFromDatabase('users', ['email like' => 'miles%']); 1012 | * ``` 1013 | * 1014 | * Supported operators: `<`, `>`, `>=`, `<=`, `!=`, `like`. 1015 | * 1016 | * @return array> Returns an array of all matched rows 1017 | * @throws PDOException|Exception 1018 | */ 1019 | public function grabEntriesFromDatabase(string $table, array $criteria = []): array 1020 | { 1021 | $query = $this->_getDriver()->select('*', $table, $criteria); 1022 | $parameters = array_values($criteria); 1023 | $this->debugSection('Query', $query); 1024 | $this->debugSection('Parameters', $parameters); 1025 | $sth = $this->_getDriver()->executeQuery($query, $parameters); 1026 | 1027 | return $sth->fetchAll(PDO::FETCH_ASSOC); 1028 | } 1029 | 1030 | /** 1031 | * Returns the number of rows in a database 1032 | * 1033 | * @param string $table Table name 1034 | * @param array $criteria Search criteria [Optional] 1035 | * @return int 1036 | */ 1037 | public function grabNumRecords(string $table, array $criteria = []): int 1038 | { 1039 | return $this->countInDatabase($table, $criteria); 1040 | } 1041 | 1042 | /** 1043 | * Update an SQL record into a database. 1044 | * 1045 | * ```php 1046 | * updateInDatabase('users', ['isAdmin' => true], ['email' => 'miles@davis.com']); 1048 | * ``` 1049 | */ 1050 | public function updateInDatabase(string $table, array $data, array $criteria = []): void 1051 | { 1052 | $query = $this->_getDriver()->update($table, $data, $criteria); 1053 | $parameters = [...array_values($data), ...array_values($criteria)]; 1054 | $this->debugSection('Query', $query); 1055 | if (!empty($parameters)) { 1056 | $this->debugSection('Parameters', $parameters); 1057 | } 1058 | 1059 | $this->_getDriver()->executeQuery($query, $parameters); 1060 | } 1061 | } 1062 | --------------------------------------------------------------------------------