diff --git a/composer.json b/composer.json index 466dd107..669254e0 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,8 @@ "phpstan/phpstan-phpunit": "^1.3", "phpstan/extension-installer": "^1.3", "roots/wordpress": "6.4.3", - "rector/rector": "1.0.2" + "rector/rector": "1.0.2", + "szepeviktor/phpstan-wordpress": "^1.3" }, "config": { "allow-plugins": { diff --git a/phpstan.neon b/phpstan.neon index b6b9ba0b..46b0f477 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,5 +3,3 @@ parameters: paths: - src - tests - excludePaths: - - src/Orm/Database.php diff --git a/phpunit.xml b/phpunit.xml index 7bc65e8d..9d44ce8e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,5 +1,5 @@ - + tests/ diff --git a/src/Orm/Builder.php b/src/Orm/Builder.php index 673b465a..54b418f4 100644 --- a/src/Orm/Builder.php +++ b/src/Orm/Builder.php @@ -8,6 +8,7 @@ namespace Dbout\WpOrm\Orm; +use Illuminate\Database\ConnectionInterface; use Illuminate\Database\Query\Builder as EloquentBuilder; class Builder extends EloquentBuilder @@ -31,9 +32,9 @@ public function addWhereExistsQuery(EloquentBuilder $query, $boolean = 'and', $n } /** - * @return Database|\Illuminate\Database\ConnectionInterface + * @inheritDoc */ - public function getConnection() + public function getConnection(): ConnectionInterface { return Database::getInstance(); } diff --git a/src/Orm/Database.php b/src/Orm/Database.php index 6b685f43..49da7803 100644 --- a/src/Orm/Database.php +++ b/src/Orm/Database.php @@ -8,24 +8,27 @@ namespace Dbout\WpOrm\Orm; +use Dbout\WpOrm\Exceptions\WpOrmException; use Illuminate\Database\ConnectionInterface; +use Illuminate\Database\DetectsLostConnections; +use Illuminate\Database\LostConnectionException; use Illuminate\Database\Query\Expression; use Illuminate\Database\Query\Grammars\Grammar; use Illuminate\Database\Query\Processors\Processor; use Illuminate\Database\QueryException; use Illuminate\Support\Arr; +/** + * @see https://developer.wordpress.org/reference/classes/wpdb/ + */ class Database implements ConnectionInterface { - /** - * @var bool - */ - public $loggingQueries; + use DetectsLostConnections; /** * @var \wpdb */ - public $db; + protected \wpdb $db; /** * Count of active transactions @@ -37,9 +40,7 @@ class Database implements ConnectionInterface * The database connection configuration options. * @var array */ - protected array $config = [ - 'name' => 'wp-eloquent-mysql2', - ]; + protected array $config = []; /** * @var string|null @@ -64,40 +65,45 @@ public static function getInstance(): Database } /** - * Database constructor. + * @throws \Exception */ public function __construct() { global $wpdb; - - if ($wpdb) { - $this->tablePrefix = $wpdb->prefix; + if (!$wpdb instanceof \wpdb) { + throw new \Exception('The global variable $wpdb must be instance of \wpdb.'); } - if (!$this->tablePrefix && defined('DB_PREFIX')) { - $this->tablePrefix = DB_PREFIX; - } + $this->config = [ + 'connection_name' => 'wp-eloquent-mysql2', + 'name' => defined('DB_NAME') ? DB_NAME : '', + ]; + $this->tablePrefix = $wpdb->prefix; $this->db = $wpdb; } /** * @inheritDoc */ - public function getDatabaseName() + public function getDatabaseName(): string { return $this->getConfig('name'); } /** - * @return mixed|string + * Get the database connection name. + * + * @return string */ - public function getName() + public function getName(): string { - return $this->getDatabaseName(); + return $this->getConfig('connection_name'); } /** + * Get the table prefix for the connection. + * * @return string|null */ public function getTablePrefix(): ?string @@ -108,29 +114,16 @@ public function getTablePrefix(): ?string /** * @inheritDoc */ - public function table($table, $as = null) + public function table($table, $as = null): Builder { - $processor = $this->getPostProcessor(); $table = $this->getTablePrefix() . $table; - $query = new Builder($this, $this->getQueryGrammar(), $processor); - - return $query->from($table); + return $this->query()->from($table, $as); } /** - * @inheritDoc - */ - public function raw($value) - { - return new Expression($value); - } - - /** - * Get a new query builder instance. - * - * @return \Illuminate\Database\Query\Builder + * @return Builder */ - public function query() + public function query(): Builder { return new Builder( $this, @@ -144,56 +137,45 @@ public function query() */ public function selectOne($query, $bindings = [], $useReadPdo = true) { - $query = $this->bind_params($query, $bindings); - $result = $this->db->get_row($query); - if ($result === false || $this->db->last_error) { - throw new QueryException($this->getName(), $query, $bindings, new \Exception($this->db->last_error)); - } - - return $result; + return $this->run($query, $bindings, function (string $query, array $bindings) { + $query = $this->bindParams($query, $bindings); + return $this->db->get_row($query); + }); } /** * @inheritDoc */ - public function select($query, $bindings = [], $useReadPdo = true) + public function select($query, $bindings = [], $useReadPdo = true): array { - $query = $this->bind_params($query, $bindings); - $result = $this->db->get_results($query); - if ($result === false || $this->db->last_error) { - throw new QueryException($this->getName(), $query, $bindings, new \Exception($this->db->last_error)); - } - - return $result; + return $this->run($query, $bindings, function (string $query, array $bindings) { + $query = $this->bindParams($query, $bindings); + return $this->db->get_results($query); + }); } /** - * Run a select statement against the database and returns a generator. - * TODO: Implement cursor and all the related sub-methods. - * - * @param string $query - * @param array $bindings - * @param bool $useReadPdo - * @return \Generator + * @inheritDoc */ - public function cursor($query, $bindings = [], $useReadPdo = true) + public function cursor($query, $bindings = [], $useReadPdo = true): \Generator { - + $results = $this->select($query, $bindings, $useReadPdo); + foreach ($results as $result) { + yield $result; + } } /** - * A hacky way to emulate bind parameters into SQL query - * - * @param $query - * @param $bindings + * A hacky way to emulate bind parameters into SQL query. * - * @return mixed + * @param string|null $query + * @param array $bindings + * @return string */ - private function bind_params($query, $bindings, $update = false) + private function bindParams(?string $query, array $bindings): string { - $query = \str_replace('"', '`', (string) $query); + $query = \str_replace('"', '`', (string)$query); $bindings = $this->prepareBindings($bindings); - if ($bindings === []) { return $query; } @@ -209,114 +191,94 @@ private function bind_params($query, $bindings, $update = false) }, $bindings); $query = \str_replace(['%', '?'], ['%%', '%s'], $query); - return \vsprintf($query, $bindings); } /** - * Bind and run the query - * - * @param string $query - * @param array $bindings - * @throws QueryException - * - * @return array + * @param $query + * @param $bindings + * @throws \Exception + * @return never + * @deprecated Remove in next version. */ - public function bind_and_run($query, $bindings = []) + public function bind_and_run($query, $bindings = []): never { - $new_query = $this->bind_params($query, $bindings); - $result = $this->db->query($new_query); - if ($result === false || $this->db->last_error) { - throw new QueryException($new_query, $bindings, new \Exception($this->db->last_error)); - } - - return (array) $result; + throw new \Exception('This function is no longer usable, it will be removed in a future version.'); } /** - * @param string $query - * @param array $bindings - * @return bool + * @inheritDoc */ - public function insert($query, $bindings = []) + public function insert($query, $bindings = []): bool { return $this->statement($query, $bindings); } /** - * @param string $query - * @param array $bindings - * @return int + * @inheritDoc */ - public function update($query, $bindings = []) + public function update($query, $bindings = []): int { return $this->affectingStatement($query, $bindings); } /** - * @param string $query - * @param array $bindings - * @return int + * @inheritDoc */ - public function delete($query, $bindings = []) + public function delete($query, $bindings = []): int { return $this->affectingStatement($query, $bindings); } /** - * @param string $query - * @param array $bindings - * @return bool + * @inheritDoc */ - public function statement($query, $bindings = []) + public function statement($query, $bindings = []): bool { - $newQuery = $this->bind_params($query, $bindings, true); - return $this->unprepared($newQuery); + return $this->run($query, $bindings, function (string $query, array $bindings) { + $query = $this->bindParams($query, $bindings); + return $this->db->query($query); + }); } /** - * @param string $query - * @param array $bindings - * @return int + * @inheritDoc */ - public function affectingStatement($query, $bindings = []) + public function affectingStatement($query, $bindings = []): int { - $newQuery = $this->bind_params($query, $bindings, true); - $result = $this->db->query($newQuery); - - if ($result === false || $this->db->last_error) { - throw new QueryException($this->getName(), $newQuery, $bindings, new \Exception($this->db->last_error)); - } + return $this->run($query, $bindings, function (string $query, array $bindings) { + $newQuery = $this->bindParams($query, $bindings); + $result = $this->db->query($newQuery); + if (!is_numeric($result)) { + return $result; + } - return (int) $result; + return (int) $result; + }); } /** * @inheritDoc */ - public function unprepared($query) + public function unprepared($query): bool { - $result = $this->db->query($query); - return ($result === false || $this->db->last_error); + return $this->run($query, [], function (string $query) { + return $this->db->query($query); + }); } /** * @inheritDoc */ - public function prepareBindings(array $bindings) + public function prepareBindings(array $bindings): array { $grammar = $this->getQueryGrammar(); foreach ($bindings as $key => $value) { - - // Micro-optimization: check for scalar values before instances - if (\is_bool($value)) { + if (is_bool($value)) { $bindings[$key] = (int) $value; } elseif (is_scalar($value)) { continue; - } elseif ($value instanceof \DateTime) { - // We need to transform all instances of the DateTime class into an actual - // date string. Each query grammar maintains its own date string format - // so we'll just ask the grammar for the format to get from the date. + } elseif ($value instanceof \DateTimeInterface) { $bindings[$key] = $value->format($grammar->getDateFormat()); } } @@ -343,9 +305,9 @@ public function transaction(\Closure $callback, $attempts = 1) /** * @inheritDoc */ - public function beginTransaction() + public function beginTransaction(): void { - $transaction = $this->unprepared("START TRANSACTION;"); + $transaction = $this->unprepared('START TRANSACTION;'); if ($transaction) { $this->transactionCount++; } @@ -354,12 +316,13 @@ public function beginTransaction() /** * @inheritDoc */ - public function commit() + public function commit(): void { if ($this->transactionCount < 1) { return; } - $transaction = $this->unprepared("COMMIT;"); + + $transaction = $this->unprepared('COMMIT;'); if ($transaction) { $this->transactionCount--; } @@ -368,12 +331,13 @@ public function commit() /** * @inheritDoc */ - public function rollBack() + public function rollBack(): void { if ($this->transactionCount < 1) { return; } - $transaction = $this->unprepared("ROLLBACK;"); + + $transaction = $this->unprepared('ROLLBACK;'); if ($transaction) { $this->transactionCount--; } @@ -382,36 +346,47 @@ public function rollBack() /** * @inheritDoc */ - public function transactionLevel() + public function transactionLevel(): int { return $this->transactionCount; } /** * @inheritDoc + * @throws WpOrmException + * @see https://laravel.com/docs/10.x/eloquent#pruning-models */ - public function pretend(\Closure $callback) + public function pretend(\Closure $callback): array { - // TODO: Implement pretend() method. + throw new WpOrmException('pretend feature not supported.'); } - public function getPostProcessor() + /** + * Get the query post processor used by the connection. + * + * @return Processor + */ + public function getPostProcessor(): Processor { return new Processor(); } - public function getQueryGrammar() + /** + * Get the query grammar used by the connection. + * + * @return Grammar + */ + public function getQueryGrammar(): Grammar { return new Grammar(); } /** - * Return the last insert id + * Return the last insert id. * - * @param $args - * @return int + * @return int|null */ - public function lastInsertId($args) + public function lastInsertId(): ?int { return $this->db->insert_id; } @@ -419,33 +394,162 @@ public function lastInsertId($args) /** * Get an option from the configuration options. * - * @param string|null $option + * @param string $option * @return mixed */ - public function getConfig($option = null) + public function getConfig(string $option): mixed { return Arr::get($this->config, $option); } - protected function exception($exception) + /** + * Get the current connection. + * + * @return $this + */ + public function getPdo(): static { + return $this; } /** - * @return $this + * Returns true if the last query has error. + * + * @return bool + * @see \wpdb::print_error() */ - public function getPdo() + public function lastRequestHasError(): bool { - return $this; + return $this->db->last_error !== null && $this->db->last_error !== ''; + } + + /** + * @inheritDoc + */ + public function raw($value): Expression + { + return new Expression($value); } /** - * Enable the query log on the connection. + * Run a SQL statement and log its execution context. * + * @param string $query + * @param array $binding + * @param \Closure $callback + * @return mixed + */ + protected function run(string $query, array $binding, \Closure $callback): mixed + { + $start = microtime(true); + try { + $result = $this->runQueryCallback( + $query, + $binding, + $callback + ); + } catch (QueryException $exception) { + $result = $this->handleQueryException( + $exception, + $query, + $binding, + $callback + ); + } + + $this->logQuery($query, $binding, $this->getElapsedTime($start)); + return $result; + } + + /** + * Run a SQL statement. + * + * @param string $query + * @param array $bindings + * @param \Closure $callback + * @return mixed + */ + protected function runQueryCallback(string $query, array $bindings, \Closure $callback): mixed + { + try { + // Disable display WP error and save previous state + $suppressionError = $this->db->suppress_errors(); + + $result = $callback($query, $bindings); + + // Restore the state + $this->db->suppress_errors($suppressionError); + + if ($result === false || $this->lastRequestHasError()) { + throw new \Exception($this->db->last_error); + } + + return $result; + } catch (\Exception $exception) { + throw new QueryException( + $this->getName(), + $query, + $this->prepareBindings($bindings), + $exception + ); + } + } + + /** + * Handle a query exception. + * + * @param QueryException $exception + * @param string $query + * @param array $bindings + * @param \Closure $callback + * @return mixed + */ + protected function handleQueryException( + QueryException $exception, + string $query, + array $bindings, + \Closure $callback + ): mixed { + if ($this->transactionCount >= 1) { + throw $exception; + } + + if ($this->causedByLostConnection($exception->getPrevious())) { + if (!$this->db->db_connect()) { + throw new LostConnectionException('Lost connection.'); + } + + return $this->runQueryCallback($query, $bindings, $callback); + } + + throw $exception; + } + + /** + * Get the elapsed time since a given starting point. + * + * @param float $start + * @return float + */ + protected function getElapsedTime(float $start): float + { + return round((microtime(true) - $start) * 1000, 2); + } + + /** + * Log a query in the connection's query log. + * + * @param string $query + * @param array $bindings + * @param float|null $queryDuration * @return void + * @see \wpdb::log_query */ - public function enableQueryLog() + public function logQuery(string $query, array $bindings, float $queryDuration = null): void { - $this->loggingQueries = true; + /** + * If you want to log queries, you must enable the constant SAVEQUERIES + * @see https://developer.wordpress.org/advanced-administration/debug/debug-wordpress/#savequeries + */ } } diff --git a/src/Orm/Resolver.php b/src/Orm/Resolver.php index c47b31c5..cf315072 100644 --- a/src/Orm/Resolver.php +++ b/src/Orm/Resolver.php @@ -8,6 +8,7 @@ namespace Dbout\WpOrm\Orm; +use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionResolverInterface; class Resolver implements ConnectionResolverInterface @@ -21,7 +22,7 @@ class Resolver implements ConnectionResolverInterface /** * @inheritDoc */ - public function connection($name = null) + public function connection($name = null): ConnectionInterface { return Database::getInstance(); } diff --git a/tests/Bootstrap.php b/tests/Bootstrap.php new file mode 100644 index 00000000..1b50ab65 --- /dev/null +++ b/tests/Bootstrap.php @@ -0,0 +1,72 @@ + + */ + +namespace Dbout\WpOrm\Tests; + +class Bootstrap +{ + private static ?self $instance = null; + + protected string $wpDirectory = ''; + + public function __construct() + { + $this->wpDirectory = __DIR__ . '/../web/wordpress'; + $this->initConstants(); + $this->loadFiles(); + } + + /** + * @return self + */ + public static function run(): self + { + if (!self::$instance instanceof Bootstrap) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * @return void + */ + protected function initConstants(): void + { + define('ABSPATH', sprintf('%s/', $this->wpDirectory)); + define('WP_DEBUG', false); + define('WP_CONTENT_DIR', '/'); + define('WP_DEBUG_LOG', false); + define('WP_PLUGIN_DIR', './'); + define('WPMU_PLUGIN_DIR', './'); + define('EMPTY_TRASH_DAYS', 30 * 86400); + define('SCRIPT_DEBUG', false); + define('WP_LANG_DIR', './'); + define('WPINC', 'wp-includes'); + } + + /** + * @return void + */ + protected function loadFiles(): void + { + $paths = [ + 'load.php', + 'functions.php', + 'plugin.php', + 'class-wpdb.php', + 'class-wp-error.php', + ]; + + foreach ($paths as $path) { + require sprintf('%s/wp-includes/%s', $this->wpDirectory, $path); + } + } +} + +Bootstrap::run(); diff --git a/tests/Builders/WithMetaBuilderTest.php b/tests/Builders/WithMetaBuilderTest.php index 5d1c57d8..26021a4a 100644 --- a/tests/Builders/WithMetaBuilderTest.php +++ b/tests/Builders/WithMetaBuilderTest.php @@ -11,6 +11,7 @@ use Dbout\WpOrm\Builders\PostBuilder; use Dbout\WpOrm\Exceptions\WpOrmException; use Dbout\WpOrm\Models\Post; +use Dbout\WpOrm\Tests\WpDatabaseInstanceCreator; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -20,6 +21,8 @@ */ class WithMetaBuilderTest extends TestCase { + use WpDatabaseInstanceCreator; + private PostBuilder $builder; private Post&MockObject $post; @@ -31,6 +34,7 @@ class WithMetaBuilderTest extends TestCase */ protected function setUp(): void { + $this->initWpDatabaseInstance(); $queryBuilder = new \Illuminate\Database\Query\Builder( $this->createMock(\Illuminate\Database\MySqlConnection::class), new \Illuminate\Database\Query\Grammars\Grammar(), @@ -71,6 +75,7 @@ public static function providerTestAddMetaToSelect(): \Generator null, 'select "posts".*, "my_meta"."meta_value" as "my_meta_value" from "posts" inner join "postmeta" as "my_meta" on "my_meta"."meta_key" = \'my_meta\' and "my_meta"."post_id" = "posts"."ID"', ]; + yield 'With alias' => [ 'first_name', 'my_custom_alias', diff --git a/tests/Orm/DatabaseTest.php b/tests/Orm/DatabaseTest.php new file mode 100644 index 00000000..34c675e9 --- /dev/null +++ b/tests/Orm/DatabaseTest.php @@ -0,0 +1,33 @@ + + */ + +namespace Dbout\WpOrm\Tests\Orm; + +use Dbout\WpOrm\Orm\Database; +use Dbout\WpOrm\Tests\WpDatabaseInstanceCreator; +use PHPUnit\Framework\TestCase; + +/** + * @coversDefaultClass \Dbout\WpOrm\Orm\Database + */ + +class DatabaseTest extends TestCase +{ + use WpDatabaseInstanceCreator; + + /** + * @return void + * @covers ::getInstance + */ + public function testInvalidWPInstance(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('The global variable $wpdb must be instance of \wpdb.'); + Database::getInstance(); + } +} diff --git a/tests/WpDatabaseInstanceCreator.php b/tests/WpDatabaseInstanceCreator.php new file mode 100644 index 00000000..33f1819f --- /dev/null +++ b/tests/WpDatabaseInstanceCreator.php @@ -0,0 +1,20 @@ + + */ + +namespace Dbout\WpOrm\Tests; + +trait WpDatabaseInstanceCreator +{ + /** + * @return void + */ + protected function initWpDatabaseInstance(): void + { + $GLOBALS['wpdb'] = new \wpdb('db_user', 'db_password', 'test_database', '127.0.0.0'); + } +}