Browse Source

Add Database\Statement and Database\Query::all().

Statements no longer use a nested decorator pattern. Callbacks were removed.
Corey Taylor 4 years ago
parent
commit
bc69476b30

+ 0 - 10
phpstan-baseline.neon

@@ -141,16 +141,6 @@ parameters:
 			path: src/Database/Expression/QueryExpression.php
 
 		-
-			message: "#^Access to an undefined property Exception\\:\\:\\$queryString\\.$#"
-			count: 1
-			path: src/Database/Log/LoggingStatement.php
-
-		-
-			message: "#^PHPDoc type PDOStatement of property Cake\\\\Database\\\\Statement\\\\PDOStatement\\:\\:\\$_statement is not covariant with PHPDoc type Cake\\\\Database\\\\StatementInterface of overridden property Cake\\\\Database\\\\Statement\\\\StatementDecorator\\:\\:\\$_statement\\.$#"
-			count: 1
-			path: src/Database/Statement/PDOStatement.php
-
-		-
 			message: "#^Cannot unset offset 'args' on array\\{path\\: string, reference\\: mixed\\}\\.$#"
 			count: 1
 			path: src/Error/Debugger.php

+ 0 - 5
psalm-baseline.xml

@@ -47,11 +47,6 @@
       <code>$request</code>
     </ArgumentTypeCoercion>
   </file>
-  <file src="src/Database/Statement/PDOStatement.php">
-    <NonInvariantDocblockPropertyType occurrences="1">
-      <code>$_statement</code>
-    </NonInvariantDocblockPropertyType>
-  </file>
   <file src="src/Http/BaseApplication.php">
     <ArgumentTypeCoercion occurrences="1">
       <code>$request</code>

+ 1 - 18
src/Database/Connection.php

@@ -24,7 +24,6 @@ use Cake\Database\Exception\MissingDriverException;
 use Cake\Database\Exception\MissingExtensionException;
 use Cake\Database\Exception\NestedTransactionRollbackException;
 use Cake\Database\Log\LoggedQuery;
-use Cake\Database\Log\LoggingStatement;
 use Cake\Database\Log\QueryLogger;
 use Cake\Database\Retry\ReconnectStrategy;
 use Cake\Database\Schema\CachedCollection;
@@ -280,9 +279,8 @@ class Connection implements ConnectionInterface
     {
         return $this->getDisconnectRetry()->run(function () use ($query) {
             $statement = $this->_driver->prepare($query);
-
             if ($this->_logQueries) {
-                $statement = $this->_newLogger($statement);
+                $statement->setLogger($this->getLogger());
             }
 
             return $statement;
@@ -908,21 +906,6 @@ class Connection implements ConnectionInterface
     }
 
     /**
-     * Returns a new statement object that will log the activity
-     * for the passed original statement instance.
-     *
-     * @param \Cake\Database\StatementInterface $statement the instance to be decorated
-     * @return \Cake\Database\Log\LoggingStatement
-     */
-    protected function _newLogger(StatementInterface $statement): LoggingStatement
-    {
-        $log = new LoggingStatement($statement, $this->_driver);
-        $log->setLogger($this->getLogger());
-
-        return $log;
-    }
-
-    /**
      * Returns an array that can be used to describe the internal state of this
      * object.
      *

+ 13 - 2
src/Database/Driver.php

@@ -23,7 +23,7 @@ use Cake\Database\Retry\ErrorCodeWaitStrategy;
 use Cake\Database\Schema\SchemaDialect;
 use Cake\Database\Schema\TableSchema;
 use Cake\Database\Schema\TableSchemaInterface;
-use Cake\Database\Statement\PDOStatement;
+use Cake\Database\Statement\Statement;
 use Closure;
 use InvalidArgumentException;
 use PDO;
@@ -46,6 +46,11 @@ abstract class Driver implements DriverInterface
     protected const RETRY_ERROR_CODES = [];
 
     /**
+     * @var class-string<\Cake\Database\Statement\Statement>
+     */
+    protected const STATEMENT_CLASS = Statement::class;
+
+    /**
      * Instance of PDO.
      *
      * @var \PDO
@@ -210,7 +215,13 @@ abstract class Driver implements DriverInterface
         $this->connect();
         $statement = $this->_connection->prepare($query instanceof Query ? $query->sql() : $query);
 
-        return new PDOStatement($statement, $this);
+        $typeMap = null;
+        if ($query instanceof Query && $query->isResultsCastingEnabled() && $query->type() === Query::TYPE_SELECT) {
+            $typeMap = $query->getSelectTypeMap();
+        }
+
+        /** @var \Cake\Database\StatementInterface */
+        return new (static::STATEMENT_CLASS)($statement, $this, $typeMap);
     }
 
     /**

+ 5 - 16
src/Database/Driver/Sqlite.php

@@ -19,13 +19,11 @@ namespace Cake\Database\Driver;
 use Cake\Database\Driver;
 use Cake\Database\Expression\FunctionExpression;
 use Cake\Database\Expression\TupleComparison;
-use Cake\Database\Query;
 use Cake\Database\QueryCompiler;
 use Cake\Database\Schema\SchemaDialect;
 use Cake\Database\Schema\SqliteSchemaDialect;
 use Cake\Database\SqliteCompiler;
 use Cake\Database\Statement\SqliteStatement;
-use Cake\Database\StatementInterface;
 use InvalidArgumentException;
 use PDO;
 use RuntimeException;
@@ -39,6 +37,11 @@ class Sqlite extends Driver
     use TupleComparisonTranslatorTrait;
 
     /**
+     * @inheritDoc
+     */
+    protected const STATEMENT_CLASS = SqliteStatement::class;
+
+    /**
      * Base configuration settings for Sqlite driver
      *
      * - `mask` The mask used for created database
@@ -172,20 +175,6 @@ class Sqlite extends Driver
     }
 
     /**
-     * Prepares a sql statement to be executed
-     *
-     * @param \Cake\Database\Query|string $query The query to prepare.
-     * @return \Cake\Database\StatementInterface
-     */
-    public function prepare(Query|string $query): StatementInterface
-    {
-        $this->connect();
-        $statement = $this->_connection->prepare($query instanceof Query ? $query->sql() : $query);
-
-        return new SqliteStatement($statement, $this);
-    }
-
-    /**
      * @inheritDoc
      */
     public function disableForeignKeySQL(): string

+ 13 - 5
src/Database/Driver/Sqlserver.php

@@ -54,6 +54,11 @@ class Sqlserver extends Driver
     ];
 
     /**
+     * @inheritDoc
+     */
+    protected const STATEMENT_CLASS = SqlserverStatement::class;
+
+    /**
      * Base configuration settings for Sqlserver driver
      *
      * @var array<string, mixed>
@@ -174,10 +179,7 @@ class Sqlserver extends Driver
     }
 
     /**
-     * Prepares a sql statement to be executed
-     *
-     * @param \Cake\Database\Query|string $query The query to prepare.
-     * @return \Cake\Database\StatementInterface
+     * @inheritDoc
      */
     public function prepare(Query|string $query): StatementInterface
     {
@@ -205,7 +207,13 @@ class Sqlserver extends Driver
             ]
         );
 
-        return new SqlserverStatement($statement, $this);
+        $typeMap = null;
+        if ($query instanceof Query && $query->isResultsCastingEnabled() && $query->type() === Query::TYPE_SELECT) {
+            $typeMap = $query->getSelectTypeMap();
+        }
+
+        /** @var \Cake\Database\StatementInterface */
+        return new (static::STATEMENT_CLASS)($statement, $this, $typeMap);
     }
 
     /**

+ 9 - 3
src/Database/FieldTypeConverter.php

@@ -22,6 +22,8 @@ use Cake\Database\Type\OptionalConvertInterface;
 /**
  * A callable class to be used for processing each of the rows in a statement
  * result, so that the values are converted to the right PHP types.
+ *
+ * @internal
  */
 class FieldTypeConverter
 {
@@ -117,11 +119,15 @@ class FieldTypeConverter
      * Converts each of the fields in the array that are present in the type map
      * using the corresponding Type class.
      *
-     * @param array $row The array with the fields to be casted
-     * @return array
+     * @param mixed $row The array with the fields to be casted
+     * @return mixed
      */
-    public function __invoke(array $row): array
+    public function __invoke(mixed $row): mixed
     {
+        if (!is_array($row)) {
+            return $row;
+        }
+
         if (!empty($this->_typeMap)) {
             foreach ($this->_typeMap as $field => $type) {
                 $row[$field] = $type->toPHP($row[$field], $this->_driver);

+ 0 - 165
src/Database/Log/LoggingStatement.php

@@ -1,165 +0,0 @@
-<?php
-declare(strict_types=1);
-
-/**
- * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
- *
- * Licensed under The MIT License
- * For full copyright and license information, please see the LICENSE.txt
- * Redistributions of files must retain the above copyright notice.
- *
- * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
- * @link          https://cakephp.org CakePHP(tm) Project
- * @since         3.0.0
- * @license       https://opensource.org/licenses/mit-license.php MIT License
- */
-namespace Cake\Database\Log;
-
-use Cake\Database\Statement\StatementDecorator;
-use Exception;
-use Psr\Log\LoggerInterface;
-
-/**
- * Statement decorator used to
- *
- * @internal
- */
-class LoggingStatement extends StatementDecorator
-{
-    /**
-     * Logger instance responsible for actually doing the logging task
-     *
-     * @var \Psr\Log\LoggerInterface
-     */
-    protected LoggerInterface $_logger;
-
-    /**
-     * Holds bound params
-     *
-     * @var array<array>
-     */
-    protected array $_compiledParams = [];
-
-    /**
-     * Query execution start time.
-     *
-     * @var float
-     */
-    protected float $startTime = 0.0;
-
-    /**
-     * Logged query
-     *
-     * @var \Cake\Database\Log\LoggedQuery|null
-     */
-    protected ?LoggedQuery $loggedQuery = null;
-
-    /**
-     * Wrapper for the execute function to calculate time spent
-     * and log the query afterwards.
-     *
-     * @param array|null $params List of values to be bound to query
-     * @return bool True on success, false otherwise
-     * @throws \Exception Re-throws any exception raised during query execution.
-     */
-    public function execute(?array $params = null): bool
-    {
-        $this->startTime = microtime(true);
-
-        $this->loggedQuery = new LoggedQuery();
-        $this->loggedQuery->driver = $this->_driver;
-        $this->loggedQuery->params = $params ?: $this->_compiledParams;
-
-        try {
-            $result = parent::execute($params);
-            $this->loggedQuery->took = (int)round((microtime(true) - $this->startTime) * 1000, 0);
-        } catch (Exception $e) {
-            /** @psalm-suppress UndefinedPropertyAssignment */
-            $e->queryString = $this->queryString;
-            $this->loggedQuery->error = $e;
-            $this->_log();
-            throw $e;
-        }
-
-        $this->rowCount();
-
-        return $result;
-    }
-
-    /**
-     * @inheritDoc
-     */
-    public function rowCount(): int
-    {
-        $result = parent::rowCount();
-
-        if ($this->loggedQuery) {
-            $this->loggedQuery->numRows = $result;
-            $this->_log();
-        }
-
-        return $result;
-    }
-
-    /**
-     * Copies the logging data to the passed LoggedQuery and sends it
-     * to the logging system.
-     *
-     * @return void
-     */
-    protected function _log(): void
-    {
-        if ($this->loggedQuery === null) {
-            return;
-        }
-
-        $this->loggedQuery->query = $this->queryString;
-        $this->getLogger()->debug((string)$this->loggedQuery, ['query' => $this->loggedQuery]);
-
-        $this->loggedQuery = null;
-    }
-
-    /**
-     * Wrapper for bindValue function to gather each parameter to be later used
-     * in the logger function.
-     *
-     * @param string|int $column Name or param position to be bound
-     * @param mixed $value The value to bind to variable in query
-     * @param string|int|null $type PDO type or name of configured Type class
-     * @return void
-     */
-    public function bindValue(string|int $column, mixed $value, string|int|null $type = 'string'): void
-    {
-        parent::bindValue($column, $value, $type);
-
-        if ($type === null) {
-            $type = 'string';
-        }
-        if (!ctype_digit($type)) {
-            $value = $this->cast($value, $type)[0];
-        }
-        $this->_compiledParams[$column] = $value;
-    }
-
-    /**
-     * Sets a logger
-     *
-     * @param \Psr\Log\LoggerInterface $logger Logger object
-     * @return void
-     */
-    public function setLogger(LoggerInterface $logger): void
-    {
-        $this->_logger = $logger;
-    }
-
-    /**
-     * Gets the logger object
-     *
-     * @return \Psr\Log\LoggerInterface logger instance
-     */
-    public function getLogger(): LoggerInterface
-    {
-        return $this->_logger;
-    }
-}

+ 69 - 39
src/Database/Query.php

@@ -16,6 +16,7 @@ declare(strict_types=1);
  */
 namespace Cake\Database;
 
+use ArrayIterator;
 use Cake\Database\Exception\DatabaseException;
 use Cake\Database\Expression\CommonTableExpression;
 use Cake\Database\Expression\IdentifierExpression;
@@ -24,7 +25,6 @@ use Cake\Database\Expression\OrderClauseExpression;
 use Cake\Database\Expression\QueryExpression;
 use Cake\Database\Expression\ValuesExpression;
 use Cake\Database\Expression\WindowExpression;
-use Cake\Database\Statement\CallbackStatement;
 use Closure;
 use InvalidArgumentException;
 use IteratorAggregate;
@@ -58,6 +58,26 @@ class Query implements ExpressionInterface, IteratorAggregate, Stringable
     public const JOIN_TYPE_RIGHT = 'RIGHT';
 
     /**
+     * @var string
+     */
+    public const TYPE_SELECT = 'select';
+
+    /**
+     * @var string
+     */
+    public const TYPE_INSERT = 'insert';
+
+    /**
+     * @var string
+     */
+    public const TYPE_UPDATE = 'update';
+
+    /**
+     * @var string
+     */
+    public const TYPE_DELETE = 'delete';
+
+    /**
      * Connection instance to be used to execute this query.
      *
      * @var \Cake\Database\Connection
@@ -149,11 +169,16 @@ class Query implements ExpressionInterface, IteratorAggregate, Stringable
     protected array $_resultDecorators = [];
 
     /**
-     * Statement object resulting from executing this query.
-     *
      * @var \Cake\Database\StatementInterface|null
      */
-    protected ?StatementInterface $_iterator = null;
+    protected ?StatementInterface $_statement = null;
+
+    /**
+     * Result set from exeuted SELCT query.
+     *
+     * @var iterable|null
+     */
+    protected ?iterable $_results = null;
 
     /**
      * The object responsible for generating query placeholders and temporarily store values
@@ -241,11 +266,36 @@ class Query implements ExpressionInterface, IteratorAggregate, Stringable
      */
     public function execute(): StatementInterface
     {
-        $statement = $this->_connection->run($this);
-        $this->_iterator = $this->_decorateStatement($statement);
+        $this->_statement = $this->_connection->run($this);
         $this->_dirty = false;
 
-        return $this->_iterator;
+        return $this->_statement;
+    }
+
+    /**
+     * Executes query and returns set of decorated results.
+     *
+     * @return iterable
+     * @thows \RuntimeException When query is not a SELECT query.
+     */
+    public function all(): iterable
+    {
+        if ($this->_type !== Query::TYPE_SELECT) {
+            throw new RuntimeException(
+                '`all()` supports SELECT queries only. Use `execute()` to run all other queries.'
+            );
+        }
+
+        $this->_results = $this->execute()->fetchAll(StatementInterface::FETCH_TYPE_ASSOC);
+        if ($this->_resultDecorators) {
+            foreach ($this->_results as &$row) {
+                foreach ($this->_resultDecorators as $decorator) {
+                    $row = $decorator($row);
+                }
+            }
+        }
+
+        return $this->_results;
     }
 
     /**
@@ -1951,19 +2001,20 @@ class Query implements ExpressionInterface, IteratorAggregate, Stringable
     /**
      * Executes this query and returns a results iterator. This function is required
      * for implementing the IteratorAggregate interface and allows the query to be
-     * iterated without having to call execute() manually, thus making it look like
+     * iterated without having to call all() manually, thus making it look like
      * a result set instead of the query itself.
      *
-     * @return \Cake\Database\StatementInterface
-     * @psalm-suppress ImplementedReturnTypeMismatch
+     * @return \Traversable
      */
     public function getIterator(): Traversable
     {
-        if ($this->_iterator === null || $this->_dirty) {
-            $this->_iterator = $this->execute();
+        $results = $this->_results;
+        if ($results === null || $this->_dirty) {
+            $results = $this->all();
         }
 
-        return $this->_iterator;
+        /** @var array $results */
+        return new ArrayIterator($results);
     }
 
     /**
@@ -2038,6 +2089,7 @@ class Query implements ExpressionInterface, IteratorAggregate, Stringable
      */
     public function decorateResults(?callable $callback, bool $overwrite = false)
     {
+        $this->_dirty();
         if ($overwrite) {
             $this->_resultDecorators = [];
         }
@@ -2239,29 +2291,6 @@ class Query implements ExpressionInterface, IteratorAggregate, Stringable
     }
 
     /**
-     * Auxiliary function used to wrap the original statement from the driver with
-     * any registered callbacks.
-     *
-     * @param \Cake\Database\StatementInterface $statement to be decorated
-     * @return \Cake\Database\Statement\CallbackStatement|\Cake\Database\StatementInterface
-     */
-    protected function _decorateStatement(StatementInterface $statement): CallbackStatement|StatementInterface
-    {
-        $typeMap = $this->getSelectTypeMap();
-        $driver = $this->getConnection()->getDriver();
-
-        if ($this->typeCastEnabled && $typeMap->toArray()) {
-            $statement = new CallbackStatement($statement, $driver, new FieldTypeConverter($typeMap, $driver));
-        }
-
-        foreach ($this->_resultDecorators as $f) {
-            $statement = new CallbackStatement($statement, $driver, $f);
-        }
-
-        return $statement;
-    }
-
-    /**
      * Helper function used to build conditions by composing QueryExpression objects.
      *
      * @param string $part Name of the query part to append the new part to
@@ -2310,7 +2339,7 @@ class Query implements ExpressionInterface, IteratorAggregate, Stringable
     {
         $this->_dirty = true;
 
-        if ($this->_iterator && $this->_valueBinder) {
+        if ($this->_statement && $this->_valueBinder) {
             $this->getValueBinder()->reset();
         }
     }
@@ -2322,7 +2351,8 @@ class Query implements ExpressionInterface, IteratorAggregate, Stringable
      */
     public function __clone()
     {
-        $this->_iterator = null;
+        $this->_statement = null;
+        $this->_results = null;
         if ($this->_valueBinder !== null) {
             $this->_valueBinder = clone $this->_valueBinder;
         }
@@ -2395,7 +2425,7 @@ class Query implements ExpressionInterface, IteratorAggregate, Stringable
             'params' => $params,
             'defaultTypes' => $this->getDefaultTypes(),
             'decorators' => count($this->_resultDecorators),
-            'executed' => $this->_iterator ? true : false,
+            'executed' => $this->_statement ? true : false,
         ];
     }
 }

+ 0 - 77
src/Database/Statement/CallbackStatement.php

@@ -1,77 +0,0 @@
-<?php
-declare(strict_types=1);
-
-/**
- * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
- *
- * Licensed under The MIT License
- * For full copyright and license information, please see the LICENSE.txt
- * Redistributions of files must retain the above copyright notice.
- *
- * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
- * @link          https://cakephp.org CakePHP(tm) Project
- * @since         3.0.0
- * @license       https://opensource.org/licenses/mit-license.php MIT License
- */
-namespace Cake\Database\Statement;
-
-use Cake\Database\DriverInterface;
-use Cake\Database\StatementInterface;
-
-/**
- * Wraps a statement in a callback that allows row results
- * to be modified when being fetched.
- *
- * This is used by CakePHP to eagerly load association data.
- */
-class CallbackStatement extends StatementDecorator
-{
-    /**
-     * A callback function to be applied to results.
-     *
-     * @var callable
-     */
-    protected $_callback;
-
-    /**
-     * Constructor
-     *
-     * @param \Cake\Database\StatementInterface $statement The statement to decorate.
-     * @param \Cake\Database\DriverInterface $driver The driver instance used by the statement.
-     * @param callable $callback The callback to apply to results before they are returned.
-     */
-    public function __construct(StatementInterface $statement, DriverInterface $driver, callable $callback)
-    {
-        parent::__construct($statement, $driver);
-        $this->_callback = $callback;
-    }
-
-    /**
-     * Fetch a row from the statement.
-     *
-     * The result will be processed by the callback when it is not `false`.
-     *
-     * @param string|int $type Either 'num' or 'assoc' to indicate the result format you would like.
-     * @return mixed
-     */
-    public function fetch(string|int $type = parent::FETCH_TYPE_NUM): mixed
-    {
-        $callback = $this->_callback;
-        $row = $this->_statement->fetch($type);
-
-        return $row === false ? $row : $callback($row);
-    }
-
-    /**
-     * {@inheritDoc}
-     *
-     * Each row in the result will be processed by the callback when it is not `false.
-     */
-    public function fetchAll(string|int $type = parent::FETCH_TYPE_NUM): array
-    {
-        $results = $this->_statement->fetchAll($type);
-
-        return array_map($this->_callback, $results);
-    }
-}

+ 0 - 178
src/Database/Statement/PDOStatement.php

@@ -1,178 +0,0 @@
-<?php
-declare(strict_types=1);
-
-/**
- * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
- *
- * Licensed under The MIT License
- * For full copyright and license information, please see the LICENSE.txt
- * Redistributions of files must retain the above copyright notice.
- *
- * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
- * @link          https://cakephp.org CakePHP(tm) Project
- * @since         3.0.0
- * @license       https://opensource.org/licenses/mit-license.php MIT License
- */
-namespace Cake\Database\Statement;
-
-use Cake\Core\Exception\CakeException;
-use Cake\Database\DriverInterface;
-use PDO;
-use PDOStatement as Statement;
-use RuntimeException;
-
-/**
- * Decorator for \PDOStatement class mainly used for converting human readable
- * fetch modes into PDO constants.
- */
-class PDOStatement extends StatementDecorator
-{
-    /**
-     * PDOStatement instance
-     *
-     * @var \PDOStatement
-     * @phpcsSuppress SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingNativeTypeHint
-     */
-    protected $_statement;
-
-    /**
-     * Constructor
-     *
-     * @param \PDOStatement $statement Original statement to be decorated.
-     * @param \Cake\Database\DriverInterface $driver Driver instance.
-     */
-    public function __construct(Statement $statement, DriverInterface $driver)
-    {
-        $this->_statement = $statement;
-        $this->_driver = $driver;
-    }
-
-    /**
-     * Magic getter to return PDOStatement::$queryString as read-only.
-     *
-     * @param string $property internal property to get
-     * @return string|null
-     */
-    public function __get(string $property): mixed
-    {
-        if ($property === 'queryString') {
-            /** @psalm-suppress NoInterfaceProperties */
-            return $this->_statement->queryString;
-        }
-
-        throw new RuntimeException("Cannot access undefined property `$property`.");
-    }
-
-    /**
-     * Assign a value to a positional or named variable in prepared query. If using
-     * positional variables you need to start with index one, if using named params then
-     * just use the name in any order.
-     *
-     * You can pass PDO compatible constants for binding values with a type or optionally
-     * any type name registered in the Type class. Any value will be converted to the valid type
-     * representation if needed.
-     *
-     * It is not allowed to combine positional and named variables in the same statement
-     *
-     * ### Examples:
-     *
-     * ```
-     * $statement->bindValue(1, 'a title');
-     * $statement->bindValue(2, 5, PDO::INT);
-     * $statement->bindValue('active', true, 'boolean');
-     * $statement->bindValue(5, new \DateTime(), 'date');
-     * ```
-     *
-     * @param string|int $column name or param position to be bound
-     * @param mixed $value The value to bind to variable in query
-     * @param string|int|null $type PDO type or name of configured Type class
-     * @return void
-     */
-    public function bindValue(string|int $column, mixed $value, string|int|null $type = 'string'): void
-    {
-        if ($type === null) {
-            $type = 'string';
-        }
-        if (!is_int($type)) {
-            [$value, $type] = $this->cast($value, $type);
-        }
-        $this->_statement->bindValue($column, $value, $type);
-    }
-
-    /**
-     * Returns the next row for the result set after executing this statement.
-     * Rows can be fetched to contain columns as names or positions. If no
-     * rows are left in result set, this method will return false
-     *
-     * ### Example:
-     *
-     * ```
-     *  $statement = $connection->prepare('SELECT id, title from articles');
-     *  $statement->execute();
-     *  print_r($statement->fetch('assoc')); // will show ['id' => 1, 'title' => 'a title']
-     * ```
-     *
-     * @param string|int $type 'num' for positional columns, assoc for named columns
-     * @return mixed Result array containing columns and values or false if no results
-     * are left
-     */
-    public function fetch(string|int $type = parent::FETCH_TYPE_NUM): mixed
-    {
-        if ($type === static::FETCH_TYPE_NUM) {
-            return $this->_statement->fetch(PDO::FETCH_NUM);
-        }
-        if ($type === static::FETCH_TYPE_ASSOC) {
-            return $this->_statement->fetch(PDO::FETCH_ASSOC);
-        }
-        if ($type === static::FETCH_TYPE_OBJ) {
-            return $this->_statement->fetch(PDO::FETCH_OBJ);
-        }
-
-        if (!is_int($type)) {
-            throw new CakeException(sprintf(
-                'Fetch type for PDOStatement must be an integer, found `%s` instead',
-                get_debug_type($type)
-            ));
-        }
-
-        return $this->_statement->fetch($type);
-    }
-
-    /**
-     * Returns an array with all rows resulting from executing this statement
-     *
-     * ### Example:
-     *
-     * ```
-     *  $statement = $connection->prepare('SELECT id, title from articles');
-     *  $statement->execute();
-     *  print_r($statement->fetchAll('assoc')); // will show [0 => ['id' => 1, 'title' => 'a title']]
-     * ```
-     *
-     * @param string|int $type `num` for fetching columns as positional keys or `assoc` for column names as keys.
-     * @return array List of all results from database for this statement.
-     * @psalm-assert string $type
-     */
-    public function fetchAll(string|int $type = parent::FETCH_TYPE_NUM): array
-    {
-        if ($type === static::FETCH_TYPE_NUM) {
-            return $this->_statement->fetchAll(PDO::FETCH_NUM);
-        }
-        if ($type === static::FETCH_TYPE_ASSOC) {
-            return $this->_statement->fetchAll(PDO::FETCH_ASSOC);
-        }
-        if ($type === static::FETCH_TYPE_OBJ) {
-            return $this->_statement->fetchAll(PDO::FETCH_OBJ);
-        }
-
-        if (!is_int($type)) {
-            throw new CakeException(sprintf(
-                'Fetch type for PDOStatement must be an integer, found `%s` instead',
-                get_debug_type($type)
-            ));
-        }
-
-        return $this->_statement->fetchAll($type);
-    }
-}

+ 27 - 13
src/Database/Statement/SqliteStatement.php

@@ -21,32 +21,46 @@ namespace Cake\Database\Statement;
  *
  * @internal
  */
-class SqliteStatement extends PDOStatement
+class SqliteStatement extends Statement
 {
     /**
-     * Returns the number of rows returned of affected by last execution
-     *
-     * @return int
+     * @var int|null
+     */
+    protected ?int $affectedRows = null;
+
+    /**
+     * @inheritDoc
+     */
+    public function execute(?array $params = null): bool
+    {
+        $this->affectedRows = null;
+
+        return parent::execute($params);
+    }
+
+    /**
+     * @inheritDoc
      */
     public function rowCount(): int
     {
-        /** @psalm-suppress NoInterfaceProperties */
+        if ($this->affectedRows !== null) {
+            return $this->affectedRows;
+        }
+
         if (
-            $this->_statement->queryString &&
-            preg_match('/^(?:DELETE|UPDATE|INSERT)/i', $this->_statement->queryString)
+            $this->statement->queryString &&
+            preg_match('/^(?:DELETE|UPDATE|INSERT)/i', $this->statement->queryString)
         ) {
             $changes = $this->_driver->prepare('SELECT CHANGES()');
             $changes->execute();
             $row = $changes->fetch();
             $changes->closeCursor();
 
-            if (!$row) {
-                return 0;
-            }
-
-            return (int)$row[0];
+            $this->affectedRows = $row ? (int)$row[0] : 0;
+        } else {
+            $this->affectedRows = parent::rowCount();
         }
 
-        return parent::rowCount();
+        return $this->affectedRows;
     }
 }

+ 5 - 19
src/Database/Statement/SqlserverStatement.php

@@ -23,32 +23,18 @@ use PDO;
  *
  * @internal
  */
-class SqlserverStatement extends PDOStatement
+class SqlserverStatement extends Statement
 {
     /**
-     * {@inheritDoc}
-     *
-     * The SQL Server PDO driver requires that binary parameters be bound with the SQLSRV_ENCODING_BINARY attribute.
-     * This overrides the PDOStatement::bindValue method in order to bind binary columns using the required attribute.
-     *
-     * @param string|int $column name or param position to be bound
-     * @param mixed $value The value to bind to variable in query
-     * @param string|int|null $type PDO type or name of configured Type class
-     * @return void
+     * @inheritDoc
      */
-    public function bindValue($column, $value, $type = 'string'): void
+    protected function performBind(string|int $column, mixed $value, int $type): void
     {
-        if ($type === null) {
-            $type = 'string';
-        }
-        if (!is_int($type)) {
-            [$value, $type] = $this->cast($value, $type);
-        }
         if ($type === PDO::PARAM_LOB) {
             /** @psalm-suppress UndefinedConstant */
-            $this->_statement->bindParam($column, $value, $type, 0, PDO::SQLSRV_ENCODING_BINARY);
+            $this->statement->bindParam($column, $value, $type, 0, PDO::SQLSRV_ENCODING_BINARY);
         } else {
-            $this->_statement->bindValue($column, $value, $type);
+            parent::performBind($column, $value, $type);
         }
     }
 }

+ 328 - 0
src/Database/Statement/Statement.php

@@ -0,0 +1,328 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         5.0.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Database\Statement;
+
+use Cake\Database\DriverInterface;
+use Cake\Database\FieldTypeConverter;
+use Cake\Database\Log\LoggedQuery;
+use Cake\Database\StatementInterface;
+use Cake\Database\TypeConverterTrait;
+use Cake\Database\TypeMap;
+use InvalidArgumentException;
+use PDO;
+use PDOException;
+use PDOStatement;
+use Psr\Log\LoggerInterface;
+
+class Statement implements StatementInterface
+{
+    use TypeConverterTrait {
+        cast as protected;
+        matchTypes as protected;
+    }
+
+    /**
+     * @var array<string, int>
+     */
+    protected const MODE_NAME_MAP = [
+        self::FETCH_TYPE_ASSOC => PDO::FETCH_ASSOC,
+        self::FETCH_TYPE_NUM => PDO::FETCH_NUM,
+        self::FETCH_TYPE_OBJ => PDO::FETCH_OBJ,
+    ];
+
+    /**
+     * @var \Cake\Database\DriverInterface
+     */
+    protected DriverInterface $_driver;
+
+    /**
+     * @var \PDOStatement
+     */
+    protected PDOStatement $statement;
+
+    /**
+     * @var \Cake\Database\FieldTypeConverter|null
+     */
+    protected ?FieldTypeConverter $typeConverter;
+
+    /**
+     * @var \Psr\Log\LoggerInterface|null
+     */
+    protected ?LoggerInterface $logger = null;
+
+    /**
+     * Cached bound parameters used for logging
+     *
+     * @var array<mixed>
+     */
+    protected array $params = [];
+
+    /**
+     * @var float
+     */
+    protected float $took = 0.0;
+
+    /**
+     * @param \PDOStatement $statement PDO statement
+     * @param \Cake\Database\DriverInterface $driver Database driver
+     * @param \Cake\Database\TypeMap|null $typeMap Results type map
+     */
+    public function __construct(
+        PDOStatement $statement,
+        DriverInterface $driver,
+        ?TypeMap $typeMap = null,
+    ) {
+        $this->_driver = $driver;
+        $this->statement = $statement;
+        $this->typeConverter = $typeMap !== null ? new FieldTypeConverter($typeMap, $driver) : null;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function setLogger(LoggerInterface $logger)
+    {
+        $this->logger = $logger;
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function bind(array $params, array $types): void
+    {
+        if (empty($params)) {
+            return;
+        }
+
+        $anonymousParams = is_int(key($params));
+        $offset = 1;
+        foreach ($params as $index => $value) {
+            $type = $types[$index] ?? null;
+            if ($anonymousParams) {
+                /** @psalm-suppress InvalidOperand */
+                $index += $offset;
+            }
+            /** @psalm-suppress PossiblyInvalidArgument */
+            $this->bindValue($index, $value, $type);
+        }
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function bindValue(string|int $column, mixed $value, string|int|null $type = 'string'): void
+    {
+        if ($type === null) {
+            $type = 'string';
+        }
+        if (!is_int($type)) {
+            [$value, $type] = $this->cast($value, $type);
+        }
+
+        $this->params[$column] = $value;
+        $this->performBind($column, $value, $type);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function performBind(string|int $column, mixed $value, int $type): void
+    {
+        $this->statement->bindValue($column, $value, $type);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function execute(?array $params = null): bool
+    {
+        $success = false;
+        $exception = null;
+
+        try {
+            $start = microtime(true);
+            $success = $this->statement->execute($params);
+            $this->took = (microtime(true) - $start) * 1000;
+        } catch (PDOException $e) {
+            $exception = $e;
+            $this->took = 0.0;
+        }
+
+        if ($this->logger) {
+            $loggedQuery = new LoggedQuery();
+            $loggedQuery->driver = $this->_driver;
+            $loggedQuery->query = $this->queryString();
+            $loggedQuery->params = $params ?? $this->params;
+            $loggedQuery->error = $exception;
+            if (!$exception) {
+                $loggedQuery->numRows = $this->rowCount();
+                $loggedQuery->took = (int)round($this->took);
+            }
+            $this->logger->debug((string)$loggedQuery, ['query' => $loggedQuery]);
+        }
+
+        if ($exception) {
+            throw $exception;
+        }
+
+        return $success;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function fetch(string|int $mode = PDO::FETCH_NUM): mixed
+    {
+        $mode = $this->convertMode($mode);
+        $row = $this->statement->fetch($mode);
+        if ($row === false) {
+            return false;
+        }
+
+        if ($this->typeConverter !== null) {
+            return ($this->typeConverter)($row);
+        }
+
+        return $row;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function fetchAssoc(): array
+    {
+        return $this->fetch(PDO::FETCH_ASSOC) ?: [];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function fetchColumn(int $position): mixed
+    {
+        $row = $this->fetch(PDO::FETCH_NUM);
+        if ($row && isset($row[$position])) {
+            return $row[$position];
+        }
+
+        return false;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function fetchAll(string|int $mode = PDO::FETCH_NUM): array
+    {
+        $mode = $this->convertMode($mode);
+        $rows = $this->statement->fetchAll($mode);
+
+        if ($this->typeConverter !== null) {
+            return array_map($this->typeConverter, $rows);
+        }
+
+        return $rows;
+    }
+
+    /**
+     * Converts mode name to PDO constant.
+     *
+     * @param string|int $mode Mode name or PDO constant
+     * @return int
+     * @throws \InvalidArgumentException
+     */
+    protected function convertMode(string|int $mode): int
+    {
+        if (is_int($mode)) {
+            // We don't try to validate the PDO constants
+            return $mode;
+        }
+
+        $mode = static::MODE_NAME_MAP[$mode] ?? null;
+        if ($mode !== null) {
+            return $mode;
+        }
+
+        throw new InvalidArgumentException("Invalid fetch mode requested. Expected 'assoc', 'num' or 'obj'.");
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function closeCursor(): void
+    {
+        $this->statement->closeCursor();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function rowCount(): int
+    {
+        return $this->statement->rowCount();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function columnCount(): int
+    {
+        return $this->statement->columnCount();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function errorCode(): string
+    {
+        return $this->statement->errorCode() ?: '';
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function errorInfo(): array
+    {
+        return $this->statement->errorInfo();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function lastInsertId(?string $table = null, ?string $column = null): string|int
+    {
+        if ($column && $this->columnCount()) {
+            $row = $this->fetch(static::FETCH_TYPE_ASSOC);
+
+            if ($row && isset($row[$column])) {
+                return $row[$column];
+            }
+        }
+
+        return $this->_driver->lastInsertId($table);
+    }
+
+    /**
+     * Returns prepared query string stored in PDOStatement.
+     *
+     * @return string
+     */
+    public function queryString(): string
+    {
+        return $this->statement->queryString;
+    }
+}

+ 0 - 345
src/Database/Statement/StatementDecorator.php

@@ -1,345 +0,0 @@
-<?php
-declare(strict_types=1);
-
-/**
- * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
- *
- * Licensed under The MIT License
- * For full copyright and license information, please see the LICENSE.txt
- * Redistributions of files must retain the above copyright notice.
- *
- * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
- * @link          https://cakephp.org CakePHP(tm) Project
- * @since         3.0.0
- * @license       https://opensource.org/licenses/mit-license.php MIT License
- */
-namespace Cake\Database\Statement;
-
-use Cake\Database\DriverInterface;
-use Cake\Database\StatementInterface;
-use Cake\Database\TypeConverterTrait;
-use IteratorAggregate;
-use RuntimeException;
-
-/**
- * Represents a database statement. Statements contains queries that can be
- * executed multiple times by binding different values on each call. This class
- * also helps convert values to their valid representation for the corresponding
- * types.
- *
- * This class is but a decorator of an actual statement implementation, such as
- * PDOStatement.
- *
- * @property-read string $queryString
- */
-class StatementDecorator implements StatementInterface, IteratorAggregate
-{
-    use TypeConverterTrait;
-
-    /**
-     * Statement instance implementation, such as PDOStatement
-     * or any other custom implementation.
-     *
-     * @var \Cake\Database\StatementInterface
-     * @phpcsSuppress SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingNativeTypeHint
-     */
-    protected $_statement;
-
-    /**
-     * Reference to the driver object associated to this statement.
-     *
-     * @var \Cake\Database\DriverInterface
-     */
-    protected DriverInterface $_driver;
-
-    /**
-     * Whether this statement has already been executed
-     *
-     * @var bool
-     */
-    protected bool $_hasExecuted = false;
-
-    /**
-     * Constructor
-     *
-     * @param \Cake\Database\StatementInterface $statement Statement implementation
-     *  such as PDOStatement.
-     * @param \Cake\Database\DriverInterface $driver Driver instance
-     */
-    public function __construct(StatementInterface $statement, DriverInterface $driver)
-    {
-        $this->_statement = $statement;
-        $this->_driver = $driver;
-    }
-
-    /**
-     * Magic getter to return $queryString as read-only.
-     *
-     * @param string $property internal property to get
-     * @return string|null
-     */
-    public function __get(string $property): mixed
-    {
-        if ($property === 'queryString') {
-            /** @psalm-suppress NoInterfaceProperties */
-            return $this->_statement->queryString;
-        }
-
-        throw new RuntimeException("Cannot access undefined property `$property`.");
-    }
-
-    /**
-     * Assign a value to a positional or named variable in prepared query. If using
-     * positional variables you need to start with index one, if using named params then
-     * just use the name in any order.
-     *
-     * It is not allowed to combine positional and named variables in the same statement.
-     *
-     * ### Examples:
-     *
-     * ```
-     * $statement->bindValue(1, 'a title');
-     * $statement->bindValue('active', true, 'boolean');
-     * $statement->bindValue(5, new \DateTime(), 'date');
-     * ```
-     *
-     * @param string|int $column name or param position to be bound
-     * @param mixed $value The value to bind to variable in query
-     * @param string|int|null $type name of configured Type class
-     * @return void
-     */
-    public function bindValue(string|int $column, mixed $value, string|int|null $type = 'string'): void
-    {
-        $this->_statement->bindValue($column, $value, $type);
-    }
-
-    /**
-     * Closes a cursor in the database, freeing up any resources and memory
-     * allocated to it. In most cases you don't need to call this method, as it is
-     * automatically called after fetching all results from the result set.
-     *
-     * @return void
-     */
-    public function closeCursor(): void
-    {
-        $this->_statement->closeCursor();
-    }
-
-    /**
-     * Returns the number of columns this statement's results will contain.
-     *
-     * ### Example:
-     *
-     * ```
-     * $statement = $connection->prepare('SELECT id, title from articles');
-     * $statement->execute();
-     * echo $statement->columnCount(); // outputs 2
-     * ```
-     *
-     * @return int
-     */
-    public function columnCount(): int
-    {
-        return $this->_statement->columnCount();
-    }
-
-    /**
-     * Returns the error code for the last error that occurred when executing this statement.
-     *
-     * @return string|int
-     */
-    public function errorCode(): string|int
-    {
-        return $this->_statement->errorCode();
-    }
-
-    /**
-     * Returns the error information for the last error that occurred when executing
-     * this statement.
-     *
-     * @return array
-     */
-    public function errorInfo(): array
-    {
-        return $this->_statement->errorInfo();
-    }
-
-    /**
-     * Executes the statement by sending the SQL query to the database. It can optionally
-     * take an array or arguments to be bound to the query variables. Please note
-     * that binding parameters from this method will not perform any custom type conversion
-     * as it would normally happen when calling `bindValue`.
-     *
-     * @param array|null $params list of values to be bound to query
-     * @return bool true on success, false otherwise
-     */
-    public function execute(?array $params = null): bool
-    {
-        $this->_hasExecuted = true;
-
-        return $this->_statement->execute($params);
-    }
-
-    /**
-     * Returns the next row for the result set after executing this statement.
-     * Rows can be fetched to contain columns as names or positions. If no
-     * rows are left in result set, this method will return false.
-     *
-     * ### Example:
-     *
-     * ```
-     * $statement = $connection->prepare('SELECT id, title from articles');
-     * $statement->execute();
-     * print_r($statement->fetch('assoc')); // will show ['id' => 1, 'title' => 'a title']
-     * ```
-     *
-     * @param string|int $type 'num' for positional columns, assoc for named columns
-     * @return mixed Result array containing columns and values or false if no results
-     * are left
-     */
-    public function fetch(string|int $type = self::FETCH_TYPE_NUM): mixed
-    {
-        return $this->_statement->fetch($type);
-    }
-
-    /**
-     * Returns the next row in a result set as an associative array. Calling this function is the same as calling
-     * $statement->fetch(StatementDecorator::FETCH_TYPE_ASSOC). If no results are found an empty array is returned.
-     *
-     * @return array
-     */
-    public function fetchAssoc(): array
-    {
-        $result = $this->fetch(static::FETCH_TYPE_ASSOC);
-
-        return $result ?: [];
-    }
-
-    /**
-     * Returns the value of the result at position.
-     *
-     * @param int $position The numeric position of the column to retrieve in the result
-     * @return mixed Returns the specific value of the column designated at $position
-     */
-    public function fetchColumn(int $position): mixed
-    {
-        $result = $this->fetch(static::FETCH_TYPE_NUM);
-        if ($result && isset($result[$position])) {
-            return $result[$position];
-        }
-
-        return false;
-    }
-
-    /**
-     * Returns an array with all rows resulting from executing this statement.
-     *
-     * ### Example:
-     *
-     * ```
-     * $statement = $connection->prepare('SELECT id, title from articles');
-     * $statement->execute();
-     * print_r($statement->fetchAll('assoc')); // will show [0 => ['id' => 1, 'title' => 'a title']]
-     * ```
-     *
-     * @param string|int $type `num` for fetching columns as positional keys or `assoc` for column names as keys.
-     * @return array List of all results from database for this statement.
-     */
-    public function fetchAll(string|int $type = self::FETCH_TYPE_NUM): array
-    {
-        return $this->_statement->fetchAll($type);
-    }
-
-    /**
-     * Returns the number of rows affected by this SQL statement for INSERT,
-     * UPDATE and DELETE queries.
-     *
-     * @return int
-     */
-    public function rowCount(): int
-    {
-        return $this->_statement->rowCount();
-    }
-
-    /**
-     * Statements are iterable as arrays, this method will return
-     * the iterator object for traversing all items in the result.
-     *
-     * ### Example:
-     *
-     * ```
-     * $statement = $connection->prepare('SELECT id, title from articles');
-     * foreach ($statement as $row) {
-     *   //do stuff
-     * }
-     * ```
-     *
-     * @return \Cake\Database\StatementInterface
-     * @psalm-suppress ImplementedReturnTypeMismatch
-     */
-    public function getIterator(): StatementInterface
-    {
-        if (!$this->_hasExecuted) {
-            $this->execute();
-        }
-
-        return $this->_statement;
-    }
-
-    /**
-     * Binds a set of values to statement object with corresponding type.
-     *
-     * @param array $params list of values to be bound
-     * @param array $types list of types to be used, keys should match those in $params
-     * @return void
-     */
-    public function bind(array $params, array $types): void
-    {
-        if (empty($params)) {
-            return;
-        }
-
-        $anonymousParams = is_int(key($params));
-        $offset = 1;
-        foreach ($params as $index => $value) {
-            $type = $types[$index] ?? null;
-            if ($anonymousParams) {
-                /** @psalm-suppress InvalidOperand */
-                $index += $offset;
-            }
-            /** @psalm-suppress PossiblyInvalidArgument */
-            $this->bindValue($index, $value, $type);
-        }
-    }
-
-    /**
-     * Returns the latest primary inserted using this statement.
-     *
-     * @param string|null $table table name or sequence to get last insert value from
-     * @param string|null $column the name of the column representing the primary key
-     * @return string|int
-     */
-    public function lastInsertId(?string $table = null, ?string $column = null): string|int
-    {
-        if ($column && $this->columnCount()) {
-            $row = $this->fetch(static::FETCH_TYPE_ASSOC);
-
-            if ($row && isset($row[$column])) {
-                return $row[$column];
-            }
-        }
-
-        return $this->_driver->lastInsertId($table);
-    }
-
-    /**
-     * Returns the statement object that was decorated by this class.
-     *
-     * @return \Cake\Database\StatementInterface
-     */
-    public function getInnerStatement(): StatementInterface
-    {
-        return $this->_statement;
-    }
-}

+ 78 - 62
src/Database/StatementInterface.php

@@ -16,34 +16,32 @@ declare(strict_types=1);
  */
 namespace Cake\Database;
 
-use Traversable;
+use PDO;
+use Psr\Log\LoggerInterface;
 
-/**
- * Represents a database statement. Concrete implementations
- * can either use PDOStatement or a native driver
- *
- * @property-read string $queryString
- */
-interface StatementInterface extends Traversable
+interface StatementInterface
 {
     /**
-     * Used to designate that numeric indexes be returned in a result when calling fetch methods
+     * Maps to PDO::FETCH_NUM.
      *
      * @var string
+     * @link https://www.php.net/manual/en/pdo.constants.php
      */
     public const FETCH_TYPE_NUM = 'num';
 
     /**
-     * Used to designate that an associated array be returned in a result when calling fetch methods
+     * Maps to PDO::FETCH_ASSOC.
      *
      * @var string
+     * @link https://www.php.net/manual/en/pdo.constants.php
      */
     public const FETCH_TYPE_ASSOC = 'assoc';
 
     /**
-     * Used to designate that a stdClass object be returned in a result when calling fetch methods
+     * Maps to PDO::FETCH_OBJ.
      *
      * @var string
+     * @link https://www.php.net/manual/en/pdo.constants.php
      */
     public const FETCH_TYPE_OBJ = 'obj';
 
@@ -52,7 +50,7 @@ interface StatementInterface extends Traversable
      * positional variables you need to start with index one, if using named params then
      * just use the name in any order.
      *
-     * It is not allowed to combine positional and named variables in the same statement
+     * It is not allowed to combine positional and named variables in the same statement.
      *
      * ### Examples:
      *
@@ -64,47 +62,47 @@ interface StatementInterface extends Traversable
      *
      * @param string|int $column name or param position to be bound
      * @param mixed $value The value to bind to variable in query
-     * @param string|int|null $type name of configured Type class, or PDO type constant.
+     * @param string|int|null $type name of configured Type class
      * @return void
      */
     public function bindValue(string|int $column, mixed $value, string|int|null $type = 'string'): void;
 
     /**
-     * Closes a cursor in the database, freeing up any resources and memory
-     * allocated to it. In most cases you don't need to call this method, as it is
-     * automatically called after fetching all results from the result set.
+     * Closes the cursor, enabling the statement to be executed again.
+     *
+     * This behaves the same as `PDOStatement::closeCursor()`.
      *
      * @return void
      */
     public function closeCursor(): void;
 
     /**
-     * Returns the number of columns this statement's results will contain
+     * Returns the number of columns in the result set.
      *
-     * ### Example:
-     *
-     * ```
-     *  $statement = $connection->prepare('SELECT id, title from articles');
-     *  $statement->execute();
-     *  echo $statement->columnCount(); // outputs 2
-     * ```
+     * This behaves the same as `PDOStatement::columnCount()`.
      *
      * @return int
+     * @link https://php.net/manual/en/pdostatement.columncount.php
      */
     public function columnCount(): int;
 
     /**
-     * Returns the error code for the last error that occurred when executing this statement
+     * Fetch the SQLSTATE associated with the last operation on the statement handle.
      *
-     * @return string|int
+     * This behaves the same as `PDOStatement::errorCode()`.
+     *
+     * @return string
+     * @link https://www.php.net/manual/en/pdostatement.errorcode.php
      */
-    public function errorCode(): string|int;
+    public function errorCode(): string;
 
     /**
-     * Returns the error information for the last error that occurred when executing
-     * this statement
+     * Fetch extended error information associated with the last operation on the statement handle.
+     *
+     * This behaves the same as `PDOStatement::errorInfo()`.
      *
      * @return array
+     * @link https://www.php.net/manual/en/pdostatement.errorinfo.php
      */
     public function errorInfo(): array;
 
@@ -112,7 +110,7 @@ interface StatementInterface extends Traversable
      * Executes the statement by sending the SQL query to the database. It can optionally
      * take an array or arguments to be bound to the query variables. Please note
      * that binding parameters from this method will not perform any custom type conversion
-     * as it would normally happen when calling `bindValue`
+     * as it would normally happen when calling `bindValue`.
      *
      * @param array|null $params list of values to be bound to query
      * @return bool true on success, false otherwise
@@ -120,58 +118,68 @@ interface StatementInterface extends Traversable
     public function execute(?array $params = null): bool;
 
     /**
-     * Returns the next row for the result set after executing this statement.
-     * Rows can be fetched to contain columns as names or positions. If no
-     * rows are left in result set, this method will return false
+     * Fetches the next row from a result set
+     * and converts fields to types based on TypeMap.
      *
-     * ### Example:
+     * This behaves the same as `PDOStatement::fetch()`.
      *
-     * ```
-     *  $statement = $connection->prepare('SELECT id, title from articles');
-     *  $statement->execute();
-     *  print_r($statement->fetch('assoc')); // will show ['id' => 1, 'title' => 'a title']
-     * ```
-     *
-     * @param string|int $type 'num' for positional columns, assoc for named columns, or PDO fetch mode constants.
-     * @return mixed Result array containing columns and values or false if no results
-     * are left
+     * @param string|int $mode PDO::FETCH_* constant or fetch mode name.
+     *   Valid names are 'assoc', 'num' or 'obj'.
+     * @return mixed
+     * @throws \InvalidArgumentException
+     * @link https://www.php.net/manual/en/pdo.constants.php
      */
-    public function fetch(string|int $type = 'num'): mixed;
+    public function fetch(string|int $mode = PDO::FETCH_NUM): mixed;
 
     /**
-     * Returns an array with all rows resulting from executing this statement
+     * Fetches the remaining rows from a result set
+     * and converts fields to types based on TypeMap.
      *
-     * ### Example:
+     * This behaves the same as `PDOStatement::fetchAll()`.
      *
-     * ```
-     *  $statement = $connection->prepare('SELECT id, title from articles');
-     *  $statement->execute();
-     *  print_r($statement->fetchAll('assoc')); // will show [0 => ['id' => 1, 'title' => 'a title']]
-     * ```
-     *
-     * @param string|int $type `num` for fetching columns as positional keys or `assoc` for column names as keys.
-     * @return array List of all results from database for this statement.
+     * @param string|int $mode PDO::FETCH_* constant or fetch mode name.
+     *   Valid names are 'assoc', 'num' or 'obj'.
+     * @return array
+     * @throws \InvalidArgumentException
+     * @link https://www.php.net/manual/en/pdo.constants.php
      */
-    public function fetchAll(string|int $type = self::FETCH_TYPE_NUM): array;
+    public function fetchAll(string|int $mode = PDO::FETCH_NUM): array;
 
     /**
-     * Returns the value of the result at position.
+     * Fetches the next row from a result set using PDO::FETCH_NUM
+     * and converts fields to types based on TypeMap.
      *
-     * @param int $position The numeric position of the column to retrieve in the result
-     * @return mixed Returns the specific value of the column designated at $position
+     * This behaves the same as `PDOStatement::fetch()` except only
+     * a specific column from the row is returned.
+     *
+     * @param int $position Column index in result row.
+     * @return mixed
      */
     public function fetchColumn(int $position): mixed;
 
     /**
-     * Returns the number of rows affected by this SQL statement for INSERT,
-     * UPDATE and DELETE queries.
+     * Fetches the next row from a result set using PDO::FETCH_ASSOC
+     * and converts fields to types based on TypeMap.
+     *
+     * This behaves the same as `PDOStatement::fetch()` except an
+     * empty array is returned instead of false.
+     *
+     * @return array
+     */
+    public function fetchAssoc(): array;
+
+    /**
+     * Returns the number of rows affected by the last SQL statement.
+     *
+     * This behaves the same as `PDOStatement::rowCount()`.
      *
      * @return int
+     * @link https://www.php.net/manual/en/pdostatement.rowcount.php
      */
     public function rowCount(): int;
 
     /**
-     * Binds a set of values to statement object with corresponding type
+     * Binds a set of values to statement object with corresponding type.
      *
      * @param array $params list of values to be bound
      * @param array $types list of types to be used, keys should match those in $params
@@ -180,11 +188,19 @@ interface StatementInterface extends Traversable
     public function bind(array $params, array $types): void;
 
     /**
-     * Returns the latest primary inserted using this statement
+     * Returns the latest primary inserted using this statement.
      *
      * @param string|null $table table name or sequence to get last insert value from
      * @param string|null $column the name of the column representing the primary key
      * @return string|int
      */
     public function lastInsertId(?string $table = null, ?string $column = null): string|int;
+
+    /**
+     * Sets query logger to use when calling execute().
+     *
+     * @param \Psr\Log\LoggerInterface $logger Query logger
+     * @return $this
+     */
+    public function setLogger(LoggerInterface $logger);
 }

+ 1 - 2
src/Datasource/QueryTrait.php

@@ -133,8 +133,7 @@ trait QueryTrait
      * iterated without having to call execute() manually, thus making it look like
      * a result set instead of the query itself.
      *
-     * @return \Cake\Datasource\ResultSetInterface
-     * @psalm-suppress ImplementedReturnTypeMismatch
+     * @return \Traversable
      */
     public function getIterator(): Traversable
     {

+ 8 - 6
src/ORM/Query.php

@@ -20,7 +20,6 @@ use ArrayObject;
 use Cake\Database\Connection;
 use Cake\Database\ExpressionInterface;
 use Cake\Database\Query as DatabaseQuery;
-use Cake\Database\StatementInterface;
 use Cake\Database\TypedResultInterface;
 use Cake\Database\TypeMap;
 use Cake\Database\ValueBinder;
@@ -1064,7 +1063,7 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      */
     public function cache($key, $config = 'default')
     {
-        if ($this->_type !== 'select') {
+        if ($this->_type !== self::TYPE_SELECT) {
             throw new RuntimeException('You cannot cache the results of non-select queries.');
         }
 
@@ -1079,7 +1078,7 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      */
     public function all(): ResultSetInterface
     {
-        if ($this->_type !== 'select') {
+        if ($this->_type !== self::TYPE_SELECT) {
             throw new RuntimeException(
                 'You cannot call all() on a non-select query. Use execute() instead.'
             );
@@ -1097,7 +1096,7 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      */
     public function triggerBeforeFind(): void
     {
-        if (!$this->_beforeFindFired && $this->_type === 'select') {
+        if (!$this->_beforeFindFired && $this->_type === self::TYPE_SELECT) {
             $this->_beforeFindFired = true;
 
             $repository = $this->getRepository();
@@ -1133,7 +1132,10 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
             return $this->_results;
         }
 
-        $results = $this->execute()->fetchAll(StatementInterface::FETCH_TYPE_ASSOC);
+        $results = parent::all();
+        if (!is_array($results)) {
+            $results = iterator_to_array($results);
+        }
         $results = $this->getEagerLoader()->loadExternal($this, $results);
 
         return $this->resultSetFactory()->createResultSet($this, $results);
@@ -1167,7 +1169,7 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      */
     protected function _transformQuery(): void
     {
-        if (!$this->_dirty || $this->_type !== 'select') {
+        if (!$this->_dirty || $this->_type !== self::TYPE_SELECT) {
             return;
         }
 

+ 4 - 25
tests/TestCase/Database/ConnectionTest.php

@@ -26,7 +26,6 @@ use Cake\Database\Exception\MissingConnectionException;
 use Cake\Database\Exception\MissingDriverException;
 use Cake\Database\Exception\MissingExtensionException;
 use Cake\Database\Exception\NestedTransactionRollbackException;
-use Cake\Database\Log\LoggingStatement;
 use Cake\Database\Log\QueryLogger;
 use Cake\Database\Schema\CachedCollection;
 use Cake\Database\StatementInterface;
@@ -223,14 +222,11 @@ class ConnectionTest extends TestCase
     {
         $sql = 'SELECT 1 + 1';
         $result = $this->connection->prepare($sql);
-        $this->assertInstanceOf('Cake\Database\StatementInterface', $result);
-        $this->assertEquals($sql, $result->queryString);
+        $this->assertInstanceOf(StatementInterface::class, $result);
 
         $query = $this->connection->newQuery()->select('1 + 1');
         $result = $this->connection->prepare($query);
-        $this->assertInstanceOf('Cake\Database\StatementInterface', $result);
-        $sql = '#SELECT [`"\[]?1 \+ 1[`"\]]?#';
-        $this->assertMatchesRegularExpression($sql, $result->queryString);
+        $this->assertInstanceOf(StatementInterface::class, $result);
     }
 
     /**
@@ -306,7 +302,7 @@ class ConnectionTest extends TestCase
             $data,
             ['id' => 'integer', 'title' => 'string', 'body' => 'string']
         );
-        $this->assertInstanceOf('Cake\Database\StatementInterface', $result);
+        $this->assertInstanceOf(StatementInterface::class, $result);
         $result->closeCursor();
         $result = $this->connection->execute('SELECT * from things where id = 3');
         $rows = $result->fetchAll('assoc');
@@ -327,7 +323,7 @@ class ConnectionTest extends TestCase
             ['integer', 'string', 'string']
         );
         $result->closeCursor();
-        $this->assertInstanceOf('Cake\Database\StatementInterface', $result);
+        $this->assertInstanceOf(StatementInterface::class, $result);
         $result = $this->connection->execute('SELECT * from things where id  = 3');
         $rows = $result->fetchAll('assoc');
         $result->closeCursor();
@@ -853,23 +849,6 @@ class ConnectionTest extends TestCase
     }
 
     /**
-     * Tests that statements are decorated with a logger when logQueries is set to true
-     */
-    public function testLoggerDecorator(): void
-    {
-        $logger = new QueryLogger();
-        $this->connection->enableQueryLogging(true);
-        $this->connection->setLogger($logger);
-        $st = $this->connection->prepare('SELECT 1');
-        $this->assertInstanceOf(LoggingStatement::class, $st);
-        $this->assertSame($logger, $st->getLogger());
-
-        $this->connection->enableQueryLogging(false);
-        $st = $this->connection->prepare('SELECT 1');
-        $this->assertNotInstanceOf('Cake\Database\Log\LoggingStatement', $st);
-    }
-
-    /**
      * test enableQueryLogging method
      */
     public function testEnableQueryLogging(): void

+ 0 - 184
tests/TestCase/Database/Log/LoggingStatementTest.php

@@ -1,184 +0,0 @@
-<?php
-declare(strict_types=1);
-
-/**
- * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
- *
- * Licensed under The MIT License
- * For full copyright and license information, please see the LICENSE.txt
- * Redistributions of files must retain the above copyright notice.
- *
- * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
- * @link          https://cakephp.org CakePHP(tm) Project
- * @since         3.0.0
- * @license       https://opensource.org/licenses/mit-license.php MIT License
- */
-namespace Cake\Test\TestCase\Database\Log;
-
-use Cake\Database\DriverInterface;
-use Cake\Database\Log\LoggingStatement;
-use Cake\Database\Log\QueryLogger;
-use Cake\Database\StatementInterface;
-use Cake\Log\Log;
-use Cake\TestSuite\TestCase;
-use DateTime;
-use TestApp\Error\Exception\MyPDOException;
-
-/**
- * Tests LoggingStatement class
- */
-class LoggingStatementTest extends TestCase
-{
-    public function setUp(): void
-    {
-        parent::setUp();
-        Log::setConfig('queries', [
-            'className' => 'Array',
-            'scopes' => ['queriesLog'],
-        ]);
-    }
-
-    public function tearDown(): void
-    {
-        parent::tearDown();
-        Log::drop('queries');
-    }
-
-    /**
-     * Tests that queries are logged when executed without params
-     */
-    public function testExecuteNoParams(): void
-    {
-        $inner = $this->getMockBuilder(StatementInterface::class)->getMock();
-        $inner->method('rowCount')->will($this->returnValue(3));
-        $inner->method('execute')->will($this->returnValue(true));
-
-        $driver = $this->getMockBuilder(DriverInterface::class)->getMock();
-        $st = $this->getMockBuilder(LoggingStatement::class)
-            ->onlyMethods(['__get'])
-            ->setConstructorArgs([$inner, $driver])
-            ->getMock();
-        $st->expects($this->any())
-            ->method('__get')
-            ->willReturn('SELECT bar FROM foo');
-        $st->setLogger(new QueryLogger(['connection' => 'test']));
-        $st->execute();
-        $st->fetchAll();
-
-        $messages = Log::engine('queries')->read();
-        $this->assertCount(1, $messages);
-        $this->assertMatchesRegularExpression('/^debug: connection=test duration=\d+ rows=3 SELECT bar FROM foo$/', $messages[0]);
-    }
-
-    /**
-     * Tests that queries are logged when executed with params
-     */
-    public function testExecuteWithParams(): void
-    {
-        $inner = $this->getMockBuilder(StatementInterface::class)->getMock();
-        $inner->method('rowCount')->will($this->returnValue(4));
-        $inner->method('execute')->will($this->returnValue(true));
-
-        $driver = $this->getMockBuilder(DriverInterface::class)->getMock();
-        $st = $this->getMockBuilder(LoggingStatement::class)
-            ->onlyMethods(['__get'])
-            ->setConstructorArgs([$inner, $driver])
-            ->getMock();
-        $st->expects($this->any())
-            ->method('__get')
-            ->willReturn('SELECT bar FROM foo WHERE x=:a AND y=:b');
-        $st->setLogger(new QueryLogger(['connection' => 'test']));
-        $st->execute(['a' => 1, 'b' => 2]);
-        $st->fetchAll();
-
-        $messages = Log::engine('queries')->read();
-        $this->assertCount(1, $messages);
-        $this->assertMatchesRegularExpression('/^debug: connection=test duration=\d+ rows=4 SELECT bar FROM foo WHERE x=1 AND y=2$/', $messages[0]);
-    }
-
-    /**
-     * Tests that queries are logged when executed with bound params
-     */
-    public function testExecuteWithBinding(): void
-    {
-        $inner = $this->getMockBuilder(StatementInterface::class)->getMock();
-        $inner->method('rowCount')->will($this->returnValue(4));
-        $inner->method('execute')->will($this->returnValue(true));
-
-        $date = new DateTime('2013-01-01');
-        $inner->expects($this->atLeast(2))
-              ->method('bindValue')
-              ->withConsecutive(['a', 1], ['b', $date]);
-
-        $driver = $this->getMockBuilder('Cake\Database\Driver')->getMock();
-        $st = $this->getMockBuilder(LoggingStatement::class)
-            ->onlyMethods(['__get'])
-            ->setConstructorArgs([$inner, $driver])
-            ->getMock();
-        $st->expects($this->any())
-            ->method('__get')
-            ->willReturn('SELECT bar FROM foo WHERE a=:a AND b=:b');
-        $st->setLogger(new QueryLogger(['connection' => 'test']));
-        $st->bindValue('a', 1);
-        $st->bindValue('b', $date, 'date');
-        $st->execute();
-        $st->fetchAll();
-
-        $st->bindValue('b', new DateTime('2014-01-01'), 'date');
-        $st->execute();
-        $st->fetchAll();
-
-        $messages = Log::engine('queries')->read();
-        $this->assertCount(2, $messages);
-        $this->assertMatchesRegularExpression("/^debug: connection=test duration=\d+ rows=4 SELECT bar FROM foo WHERE a='1' AND b='2013-01-01'$/", $messages[0]);
-        $this->assertMatchesRegularExpression("/^debug: connection=test duration=\d+ rows=4 SELECT bar FROM foo WHERE a='1' AND b='2014-01-01'$/", $messages[1]);
-    }
-
-    /**
-     * Tests that queries are logged despite database errors
-     */
-    public function testExecuteWithError(): void
-    {
-        $exception = new MyPDOException('This is bad');
-        $inner = $this->getMockBuilder(StatementInterface::class)->getMock();
-        $inner->expects($this->once())
-            ->method('execute')
-            ->will($this->throwException($exception));
-
-        $driver = $this->getMockBuilder(DriverInterface::class)->getMock();
-        $st = $this->getMockBuilder(LoggingStatement::class)
-            ->onlyMethods(['__get'])
-            ->setConstructorArgs([$inner, $driver])
-            ->getMock();
-        $st->expects($this->any())
-            ->method('__get')
-            ->willReturn('SELECT bar FROM foo');
-        $st->setLogger(new QueryLogger(['connection' => 'test']));
-        try {
-            $st->execute();
-        } catch (MyPDOException $e) {
-            $this->assertSame('This is bad', $e->getMessage());
-            $this->assertSame($st->queryString, $e->queryString);
-        }
-
-        $messages = Log::engine('queries')->read();
-        $this->assertCount(1, $messages);
-        $this->assertMatchesRegularExpression("/^debug: connection=test duration=\d+ rows=0 SELECT bar FROM foo$/", $messages[0]);
-    }
-
-    /**
-     * Tests setting and getting the logger
-     */
-    public function testSetAndGetLogger(): void
-    {
-        $logger = new QueryLogger(['connection' => 'test']);
-        $st = new LoggingStatement(
-            $this->getMockBuilder(StatementInterface::class)->getMock(),
-            $this->getMockBuilder(DriverInterface::class)->getMock()
-        );
-
-        $st->setLogger($logger);
-        $this->assertSame($logger, $st->getLogger());
-    }
-}

+ 26 - 33
tests/TestCase/Database/QueryTest.php

@@ -29,7 +29,6 @@ use Cake\Database\Expression\TupleComparison;
 use Cake\Database\Expression\WindowExpression;
 use Cake\Database\ExpressionInterface;
 use Cake\Database\Query;
-use Cake\Database\Statement\StatementDecorator;
 use Cake\Database\StatementInterface;
 use Cake\Database\TypeFactory;
 use Cake\Database\TypeMap;
@@ -113,13 +112,13 @@ class QueryTest extends TestCase
         $this->connection->getDriver()->enableAutoQuoting(false);
         $query = new Query($this->connection);
         $result = $query->select('1 + 1')->execute();
-        $this->assertInstanceOf('Cake\Database\StatementInterface', $result);
+        $this->assertInstanceOf(StatementInterface::class, $result);
         $this->assertEquals([2], $result->fetch());
         $result->closeCursor();
 
         //This new field should be appended
         $result = $query->select(['1 + 3'])->execute();
-        $this->assertInstanceOf('Cake\Database\StatementInterface', $result);
+        $this->assertInstanceOf(StatementInterface::class, $result);
         $this->assertEquals([2, 4], $result->fetch());
         $result->closeCursor();
 
@@ -2683,22 +2682,23 @@ class QueryTest extends TestCase
 
                 return $row;
             })
-            ->execute();
+            ->all();
 
-        while ($row = $result->fetch('assoc')) {
+        foreach ($result as $row) {
             $this->assertEquals($row['id'] + 1, $row['modified_id']);
         }
 
-        $result = $query->decorateResults(function ($row) {
-            $row['modified_id']--;
+        $result = $query
+            ->decorateResults(function ($row) {
+                $row['modified_id']--;
 
-            return $row;
-        })->execute();
+                return $row;
+            })
+            ->all();
 
-        while ($row = $result->fetch('assoc')) {
+        foreach ($result as $row) {
             $this->assertEquals($row['id'], $row['modified_id']);
         }
-        $result->closeCursor();
 
         $result = $query
             ->decorateResults(function ($row) {
@@ -2706,19 +2706,18 @@ class QueryTest extends TestCase
 
                 return $row;
             }, true)
-            ->execute();
+            ->all();
 
-        while ($row = $result->fetch('assoc')) {
+        foreach ($result as $row) {
             $this->assertSame('bar', $row['foo']);
             $this->assertArrayNotHasKey('modified_id', $row);
         }
 
-        $results = $query->decorateResults(null, true)->execute();
-        while ($row = $results->fetch('assoc')) {
+        $result = $query->decorateResults(null, true)->all();
+        foreach ($result as $row) {
             $this->assertArrayNotHasKey('foo', $row);
             $this->assertArrayNotHasKey('modified_id', $row);
         }
-        $results->closeCursor();
     }
 
     /**
@@ -2736,7 +2735,7 @@ class QueryTest extends TestCase
         $this->assertQuotedQuery('DELETE FROM <authors>', $result, !$this->autoQuote);
 
         $result = $query->execute();
-        $this->assertInstanceOf('Cake\Database\StatementInterface', $result);
+        $this->assertInstanceOf(StatementInterface::class, $result);
         $this->assertSame(self::AUTHOR_COUNT, $result->rowCount());
         $result->closeCursor();
     }
@@ -2756,7 +2755,7 @@ class QueryTest extends TestCase
         $this->assertQuotedQuery('DELETE FROM <authors> WHERE <id> != :c0', $result, !$this->autoQuote);
 
         $result = $query->execute();
-        $this->assertInstanceOf('Cake\Database\StatementInterface', $result);
+        $this->assertInstanceOf(StatementInterface::class, $result);
         $this->assertSame(self::AUTHOR_COUNT, $result->rowCount());
         $result->closeCursor();
     }
@@ -2775,7 +2774,7 @@ class QueryTest extends TestCase
         $this->assertQuotedQuery('DELETE FROM <authors>', $result, !$this->autoQuote);
 
         $result = $query->execute();
-        $this->assertInstanceOf('Cake\Database\StatementInterface', $result);
+        $this->assertInstanceOf(StatementInterface::class, $result);
         $this->assertSame(self::AUTHOR_COUNT, $result->rowCount());
         $result->closeCursor();
     }
@@ -4565,24 +4564,18 @@ class QueryTest extends TestCase
     public function testSymmetricJsonType(): void
     {
         $query = new Query($this->connection);
-        $insert = $query
-            ->insert(['comment', 'article_id', 'user_id'], ['comment' => 'json'])
-            ->into('comments')
-            ->values([
-                'comment' => ['a' => 'b', 'c' => true],
-                'article_id' => 1,
-                'user_id' => 1,
-            ])
-            ->execute();
-
-        $id = $insert->lastInsertId('comments', 'id');
-        $insert->closeCursor();
+        $update = $query
+            ->update('comments')
+            ->set('comment', ['a' => 'b', 'c' => true], 'json')
+            ->where(['id' => 1])
+            ->getSelectTypeMap()->setTypes(['comment' => 'json']);
+        $query->execute()->closeCursor();
 
         $query = new Query($this->connection);
         $query
             ->select(['comment'])
             ->from('comments')
-            ->where(['id' => $id])
+            ->where(['id' => 1])
             ->getSelectTypeMap()->setTypes(['comment' => 'json']);
 
         $result = $query->execute();
@@ -4933,7 +4926,7 @@ class QueryTest extends TestCase
             ->from('profiles')
             ->limit(1)
             ->execute();
-        $results = $stmt->fetch(StatementDecorator::FETCH_TYPE_OBJ);
+        $results = $stmt->fetch('obj');
         $stmt->closeCursor();
 
         $this->assertInstanceOf(stdClass::class, $results);

+ 0 - 79
tests/TestCase/Database/Statement/StatementDecoratorTest.php

@@ -1,79 +0,0 @@
-<?php
-declare(strict_types=1);
-
-/**
- * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
- *
- * Licensed under The MIT License
- * For full copyright and license information, please see the LICENSE.txt
- * Redistributions of files must retain the above copyright notice.
- *
- * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
- * @link          https://cakephp.org CakePHP(tm) Project
- * @since         3.0.0
- * @license       https://opensource.org/licenses/mit-license.php MIT License
- */
-namespace Cake\Test\TestCase\Database\Statement;
-
-use Cake\Database\Statement\StatementDecorator;
-use Cake\Database\StatementInterface;
-use Cake\TestSuite\TestCase;
-
-/**
- * Tests StatementDecorator class
- */
-class StatementDecoratorTest extends TestCase
-{
-    /**
-     * Tests that calling lastInsertId will proxy it to
-     * the driver's lastInsertId method
-     */
-    public function testLastInsertId(): void
-    {
-        $statement = $this->getMockBuilder(StatementInterface::class)->getMock();
-        $driver = $this->getMockBuilder('Cake\Database\Driver')->getMock();
-        $statement = new StatementDecorator($statement, $driver);
-
-        $driver->expects($this->once())->method('lastInsertId')
-            ->with('users')
-            ->will($this->returnValue('2'));
-        $this->assertSame('2', $statement->lastInsertId('users'));
-    }
-
-    /**
-     * Tests that calling lastInsertId will get the last insert id by
-     * column name
-     */
-    public function testLastInsertIdWithReturning(): void
-    {
-        $internal = $this->getMockBuilder(StatementInterface::class)->getMock();
-        $driver = $this->getMockBuilder('Cake\Database\Driver')->getMock();
-        $statement = new StatementDecorator($internal, $driver);
-
-        $internal->expects($this->once())->method('columnCount')
-            ->will($this->returnValue(1));
-        $internal->expects($this->once())->method('fetch')
-            ->with('assoc')
-            ->will($this->returnValue(['id' => 2]));
-        $driver->expects($this->never())->method('lastInsertId');
-        $this->assertSame(2, $statement->lastInsertId('users', 'id'));
-    }
-
-    /**
-     * Tests that the statement will not be executed twice if the iterator
-     * is requested more than once
-     */
-    public function testNoDoubleExecution(): void
-    {
-        $inner = $this->getMockBuilder(StatementInterface::class)->getMock();
-        $driver = $this->getMockBuilder('Cake\Database\DriverInterface')->getMock();
-        $statement = new StatementDecorator($inner, $driver);
-
-        $inner->expects($this->once())
-            ->method('execute')
-            ->will($this->returnValue(true));
-        $this->assertSame($inner, $statement->getIterator());
-        $this->assertSame($inner, $statement->getIterator());
-    }
-}

+ 124 - 0
tests/TestCase/Database/Statement/StatementTest.php

@@ -0,0 +1,124 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         5.0.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Database\Statement;
+
+use Cake\Database\DriverInterface;
+use Cake\Database\Log\QueryLogger;
+use Cake\Database\Statement\Statement;
+use Cake\Log\Log;
+use Cake\TestSuite\TestCase;
+use DateTime;
+use PDOException;
+use PDOStatement;
+
+class StatementTest extends TestCase
+{
+    public function setUp(): void
+    {
+        parent::setUp();
+        Log::setConfig('queries', [
+            'className' => 'Array',
+            'scopes' => ['queriesLog'],
+        ]);
+    }
+
+    public function tearDown(): void
+    {
+        parent::tearDown();
+        Log::drop('queries');
+    }
+
+    /**
+     * Tests that queries are logged when executed without params
+     */
+    public function testExecuteNoParams(): void
+    {
+        $inner = $this->getMockBuilder(PDOStatement::class)->getMock();
+        $inner->method('rowCount')->will($this->returnValue(3));
+        $inner->method('execute')->will($this->returnValue(true));
+
+        $driver = $this->getMockBuilder(DriverInterface::class)->getMock();
+        $statement = $this->getMockBuilder(Statement::class)
+            ->setConstructorArgs([$inner, $driver])
+            ->onlyMethods(['queryString'])
+            ->getMock();
+        $statement->expects($this->any())->method('queryString')->will($this->returnValue('SELECT bar FROM foo'));
+        $statement->setLogger(new QueryLogger(['connection' => 'test']));
+        $statement->execute();
+
+        $messages = Log::engine('queries')->read();
+        $this->assertCount(1, $messages);
+        $this->assertMatchesRegularExpression('/^debug: connection=test duration=\d+ rows=3 SELECT bar FROM foo$/', $messages[0]);
+    }
+
+    /**
+     * Tests that queries are logged when executed with bound params
+     */
+    public function testExecuteWithBinding(): void
+    {
+        $inner = $this->getMockBuilder(PDOStatement::class)->getMock();
+        $inner->method('rowCount')->will($this->returnValue(3));
+        $inner->method('execute')->will($this->returnValue(true));
+
+        $driver = $this->getMockBuilder(DriverInterface::class)->getMock();
+        $statement = $this->getMockBuilder(Statement::class)
+            ->setConstructorArgs([$inner, $driver])
+            ->onlyMethods(['queryString'])
+            ->getMock();
+        $statement->expects($this->any())->method('queryString')->will($this->returnValue('SELECT bar FROM foo WHERE a=:a AND b=:b'));
+        $statement->setLogger(new QueryLogger(['connection' => 'test']));
+
+        $statement->bindValue('a', 1);
+        $statement->bindValue('b', new DateTime('2013-01-01'), 'date');
+        $statement->execute();
+
+        $statement->bindValue('b', new DateTime('2014-01-01'), 'date');
+        $statement->execute();
+
+        $messages = Log::engine('queries')->read();
+        $this->assertCount(2, $messages);
+        $this->assertMatchesRegularExpression("/^debug: connection=test duration=\d+ rows=3 SELECT bar FROM foo WHERE a='1' AND b='2013-01-01'$/", $messages[0]);
+        $this->assertMatchesRegularExpression("/^debug: connection=test duration=\d+ rows=3 SELECT bar FROM foo WHERE a='1' AND b='2014-01-01'$/", $messages[1]);
+    }
+
+    /**
+     * Tests that queries are logged despite database errors
+     */
+    public function testExecuteWithError(): void
+    {
+        $inner = $this->getMockBuilder(PDOStatement::class)->getMock();
+        $inner->method('rowCount')->will($this->returnValue(3));
+        $inner->method('execute')->will($this->throwException(new PDOException()));
+
+        $driver = $this->getMockBuilder(DriverInterface::class)->getMock();
+        $statement = $this->getMockBuilder(Statement::class)
+            ->setConstructorArgs([$inner, $driver])
+            ->onlyMethods(['queryString'])
+            ->getMock();
+        $statement->expects($this->any())->method('queryString')->will($this->returnValue('SELECT bar FROM foo'));
+        $statement->setLogger(new QueryLogger(['connection' => 'test']));
+
+        try {
+            $statement->execute();
+        } catch (PDOException $e) {
+        }
+
+        $messages = Log::engine('queries')->read();
+        $this->assertCount(1, $messages);
+        $this->assertMatchesRegularExpression('/^debug: connection=test duration=\d+ rows=0 SELECT bar FROM foo$/', $messages[0]);
+    }
+}

+ 2 - 5
tests/TestCase/Database/ValueBinderTest.php

@@ -16,6 +16,7 @@ declare(strict_types=1);
  */
 namespace Cake\Test\TestCase\Database;
 
+use Cake\Database\StatementInterface;
 use Cake\Database\ValueBinder;
 use Cake\TestSuite\TestCase;
 
@@ -144,11 +145,7 @@ class ValueBinderTest extends TestCase
     public function testAttachTo(): void
     {
         $valueBinder = new ValueBinder();
-        $statementMock = $this->getMockBuilder('Cake\Database\Statement\StatementDecorator')
-            ->disableOriginalConstructor()
-            ->onlyMethods(['bindValue'])
-            ->getMock();
-
+        $statementMock = $this->createMock(StatementInterface::class);
         $statementMock->expects($this->exactly(2))
             ->method('bindValue')
             ->withConsecutive(['c0', 'value0', 'string'], ['c1', 'value1', 'string']);

+ 5 - 4
tests/TestCase/ORM/QueryTest.php

@@ -24,6 +24,7 @@ use Cake\Database\Expression\CommonTableExpression;
 use Cake\Database\Expression\IdentifierExpression;
 use Cake\Database\Expression\OrderByExpression;
 use Cake\Database\Expression\QueryExpression;
+use Cake\Database\StatementInterface;
 use Cake\Database\TypeMap;
 use Cake\Database\ValueBinder;
 use Cake\Datasource\ConnectionManager;
@@ -1689,7 +1690,7 @@ class QueryTest extends TestCase
             ->set(['title' => 'First'])
             ->execute();
 
-        $this->assertInstanceOf('Cake\Database\StatementInterface', $result);
+        $this->assertInstanceOf(StatementInterface::class, $result);
         $this->assertGreaterThan(0, $result->rowCount());
     }
 
@@ -1708,7 +1709,7 @@ class QueryTest extends TestCase
             ->andWhere(['authors.name' => 'mariano'])
             ->execute();
 
-        $this->assertInstanceOf('Cake\Database\StatementInterface', $result);
+        $this->assertInstanceOf(StatementInterface::class, $result);
         $this->assertGreaterThan(0, $result->rowCount());
     }
 
@@ -1727,7 +1728,7 @@ class QueryTest extends TestCase
 
         $result->closeCursor();
 
-        $this->assertInstanceOf('Cake\Database\StatementInterface', $result);
+        $this->assertInstanceOf(StatementInterface::class, $result);
         $this->assertEquals(2, $result->rowCount());
     }
 
@@ -1743,7 +1744,7 @@ class QueryTest extends TestCase
             ->where(['id >=' => 1])
             ->execute();
 
-        $this->assertInstanceOf('Cake\Database\StatementInterface', $result);
+        $this->assertInstanceOf(StatementInterface::class, $result);
         $this->assertGreaterThan(0, $result->rowCount());
     }
 

+ 2 - 1
tests/TestCase/ORM/ResultSetFactoryTest.php

@@ -17,6 +17,7 @@ declare(strict_types=1);
 namespace Cake\Test\TestCase\ORM;
 
 use Cake\Database\Log\QueryLogger;
+use Cake\Database\StatementInterface;
 use Cake\Datasource\ConnectionManager;
 use Cake\Log\Log;
 use Cake\ORM\ResultSetFactory;
@@ -178,7 +179,7 @@ class ResultSetFactoryTest extends TestCase
         $query->disableAutoFields();
 
         $row = ['Other__field' => 'test'];
-        $statement = $this->getMockBuilder('Cake\Database\StatementInterface')->getMock();
+        $statement = $this->createMock(StatementInterface::class);
         $statement->method('fetchAll')
             ->will($this->returnValue([$row]));
 

+ 3 - 3
tests/TestCase/ORM/TableTest.php

@@ -2246,7 +2246,7 @@ class TableTest extends TestCase
             ->onlyMethods(['execute', 'addDefaultTypes'])
             ->setConstructorArgs([$this->connection, $table])
             ->getMock();
-        $statement = $this->getMockBuilder(StatementInterface::class)->getMock();
+        $statement = $this->createMock(StatementInterface::class);
         $data = new Entity([
             'username' => 'superuser',
             'created' => new DateTime('2013-10-10 00:00'),
@@ -2437,7 +2437,7 @@ class TableTest extends TestCase
         $table->expects($this->once())->method('query')
             ->will($this->returnValue($query));
 
-        $statement = $this->getMockBuilder(StatementInterface::class)->getMock();
+        $statement = $this->createMock(StatementInterface::class);
         $statement->expects($this->once())
             ->method('rowCount')
             ->will($this->returnValue(0));
@@ -2612,7 +2612,7 @@ class TableTest extends TestCase
         $table->expects($this->once())->method('query')
             ->will($this->returnValue($query));
 
-        $statement = $this->getMockBuilder(StatementInterface::class)->getMock();
+        $statement = $this->createMock(StatementInterface::class);
         $statement->expects($this->once())
             ->method('errorCode')
             ->will($this->returnValue('00000'));