Browse Source

Merge branch '4.next' into 5.x

Corey Taylor 4 years ago
parent
commit
6093e5f2dd

+ 12 - 2
src/Database/Driver/TupleComparisonTranslatorTrait.php

@@ -20,6 +20,7 @@ use Cake\Database\Expression\IdentifierExpression;
 use Cake\Database\Expression\QueryExpression;
 use Cake\Database\Expression\TupleComparison;
 use Cake\Database\Query;
+use RuntimeException;
 
 /**
  * Provides a translator method for tuple comparisons
@@ -55,14 +56,23 @@ trait TupleComparisonTranslatorTrait
             return;
         }
 
+        $operator = strtoupper($expression->getOperator());
+        if (!in_array($operator, ['IN', '='])) {
+            throw new RuntimeException(
+                sprintf(
+                    'Tuple comparison transform only supports the `IN` and `=` operators, `%s` given.',
+                    $operator
+                )
+            );
+        }
+
         $value = $expression->getValue();
-        $op = $expression->getOperator();
         $true = new QueryExpression('1');
 
         if ($value instanceof Query) {
             $selected = array_values($value->clause('select'));
             foreach ($fields as $i => $field) {
-                $value->andWhere([$field . " $op" => new IdentifierExpression($selected[$i])]);
+                $value->andWhere([$field => new IdentifierExpression($selected[$i])]);
             }
             $value->select($true, true);
             $expression->setField($true);

+ 0 - 300
src/Database/Expression/CaseExpressionInterface.php

@@ -1,300 +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         4.3.0
- * @license       https://opensource.org/licenses/mit-license.php MIT License
- */
-namespace Cake\Database\Expression;
-
-use Cake\Database\ExpressionInterface;
-use Cake\Database\TypedResultInterface;
-use Cake\Database\TypeMap;
-
-interface CaseExpressionInterface extends ExpressionInterface, TypedResultInterface
-{
-    /**
-     * Returns the available data for the given clause.
-     *
-     * ### Available clauses
-     *
-     * The following clause names are available:
-     *
-     * * `value` (`\Cake\Database\ExpressionInterface|object|scalar|null`): The case value for a
-     *   `CASE case_value WHEN ...` expression.
-     * * `when (`array<\Cake\Database\Expression\WhenThenExpressionInterface>`)`: An array of self-contained
-     *   `WHEN ... THEN ...` expressions.
-     * * `else` (`\Cake\Database\ExpressionInterface|object|scalar|null`): The `ELSE` result value.
-     *
-     * @param string $clause The name of the clause to obtain.
-     * @return \Cake\Database\ExpressionInterface|scalar|object|array<\Cake\Database\Expression\WhenThenExpressionInterface>|null
-     * @throws \InvalidArgumentException In case the given clause name is invalid.
-     */
-    public function clause(string $clause);
-
-    /**
-     * Sets the `WHEN` value for a `WHEN ... THEN ...` expression, or a
-     * self-contained expression that holds both the value for `WHEN`
-     * and the value for `THEN`.
-     *
-     * ### Order based syntax
-     *
-     * When passing a value other than a self-contained
-     * `\Cake\Database\Expression\WhenThenExpressionInterface`,
-     * instance, the `WHEN ... THEN ...` statement must be closed off with
-     * a call to `then()` before invoking `when()` again or `else()`:
-     *
-     * ```
-     * $queryExpression
-     *     ->case($query->identifier('Table.column'))
-     *     ->when(true)
-     *     ->then('Yes')
-     *     ->when(false)
-     *     ->then('No')
-     *     ->else('Maybe');
-     * ```
-     *
-     * ### Self-contained expressions
-     *
-     * When passing an instance of `\Cake\Database\Expression\WhenThenExpressionInterface`,
-     * being it directly, or via a callable, then there is no need to close
-     * using `then()` on this object, instead the statement will be closed
-     * on the `\Cake\Database\Expression\WhenThenExpressionInterface`
-     * object using
-     * `\Cake\Database\Expression\WhenThenExpressionInterface::then()`.
-     *
-     * Callables will receive an instance of `\Cake\Database\Expression\WhenThenExpressionInterface`,
-     * and must return one, being it the same object, or a custom one:
-     *
-     * ```
-     * $queryExpression
-     *     ->case()
-     *     ->when(function (\Cake\Database\Expression\WhenThenExpressionInterface $whenThen) {
-     *         return $whenThen
-     *             ->when(['Table.column' => true])
-     *             ->then('Yes');
-     *     })
-     *     ->when(function (\Cake\Database\Expression\WhenThenExpressionInterface $whenThen) {
-     *         return $whenThen
-     *             ->when(['Table.column' => false])
-     *             ->then('No');
-     *     })
-     *     ->else('Maybe');
-     * ```
-     *
-     * ### Type handling
-     *
-     * The types provided via the `$type` argument will be merged with the
-     * type map set for this expression. When using callables for `$when`,
-     * the `\Cake\Database\Expression\WhenThenExpressionInterface`
-     * instance received by the callables will inherit that type map, however
-     * the types passed here will _not_ be merged in case of using callables,
-     * instead the types must be passed in
-     * `\Cake\Database\Expression\WhenThenExpressionInterface::when()`:
-     *
-     * ```
-     * $queryExpression
-     *     ->case()
-     *     ->when(function (\Cake\Database\Expression\WhenThenExpressionInterface $whenThen) {
-     *         return $whenThen
-     *             ->when(['unmapped_column' => true], ['unmapped_column' => 'bool'])
-     *             ->then('Yes');
-     *     })
-     *     ->when(function (\Cake\Database\Expression\WhenThenExpressionInterface $whenThen) {
-     *         return $whenThen
-     *             ->when(['unmapped_column' => false], ['unmapped_column' => 'bool'])
-     *             ->then('No');
-     *     })
-     *     ->else('Maybe');
-     * ```
-     *
-     * ### User data safety
-     *
-     * When passing user data, be aware that allowing a user defined array
-     * to be passed, is a potential SQL injection vulnerability, as it
-     * allows for raw SQL to slip in!
-     *
-     * The following is _unsafe_ usage that must be avoided:
-     *
-     * ```
-     * $case
-     *      ->when($userData)
-     * ```
-     *
-     * A safe variant for the above would be to define a single type for
-     * the value:
-     *
-     * ```
-     * $case
-     *      ->when($userData, 'integer')
-     * ```
-     *
-     * This way an exception would be triggered when an array is passed for
-     * the value, thus preventing raw SQL from slipping in, and all other
-     * types of values would be forced to be bound as an integer.
-     *
-     * Another way to safely pass user data is when using a conditions
-     * array, and passing user data only on the value side of the array
-     * entries, which will cause them to be bound:
-     *
-     * ```
-     * $case
-     *      ->when([
-     *          'Table.column' => $userData,
-     *      ])
-     * ```
-     *
-     * Lastly, data can also be bound manually:
-     *
-     * ```
-     * $query
-     *      ->select([
-     *          'val' => $query->newExpr()
-     *              ->case()
-     *              ->when($query->newExpr(':userData'))
-     *              ->then(123)
-     *      ])
-     *      ->bind(':userData', $userData, 'integer')
-     * ```
-     *
-     * @param \Cake\Database\ExpressionInterface|scalar|\Closure|object|array $when The `WHEN` value. When using an
-     *  array of conditions, it must be compatible with `\Cake\Database\Query::where()`. Note that this argument is
-     *  _not_ completely safe for use with user data, as a user supplied array would allow for raw SQL to slip in! If
-     *  you plan to use user data, either pass a single type for the `$type` argument (which forces the `$when` value to
-     *  be a non-array, and then always binds the data), use a conditions array where the user data is only passed on
-     *  the value side of the array entries, or custom bindings!
-     * @param array|string|null $type The when value type. Either an associative array when using array style
-     *  conditions, or else a string. If no type is provided, the type will be tried to be inferred from the value.
-     * @return $this
-     * @throws \LogicException In case this a closing `then()` call is required before calling this method.
-     * @throws \LogicException In case the callable doesn't return an instance of
-     *  `\Cake\Database\Expression\WhenThenExpressionInterface`.
-     * @see then()
-     */
-    public function when($when, $type = []);
-
-    /**
-     * Sets the `THEN` result value for the last `WHEN ... THEN ...`
-     * statement that was opened using `when()`.
-     *
-     * ### Order based syntax
-     *
-     * This method can only be invoked in case `when()` was previously
-     * used with a value other than a closure or an instance of
-     * `\Cake\Database\Expression\WhenThenExpressionInterface`:
-     *
-     * ```
-     * $case
-     *     ->when(['Table.column' => true])
-     *     ->then('Yes')
-     *     ->when(['Table.column' => false])
-     *     ->then('No')
-     *     ->else('Maybe');
-     * ```
-     *
-     * The following would all fail with an exception:
-     *
-     * ```
-     * $case
-     *     ->when(['Table.column' => true])
-     *     ->when(['Table.column' => false])
-     *     // ...
-     * ```
-     *
-     * ```
-     * $case
-     *     ->when(['Table.column' => true])
-     *     ->else('Maybe')
-     *     // ...
-     * ```
-     *
-     * ```
-     * $case
-     *     ->then('Yes')
-     *     // ...
-     * ```
-     *
-     * ```
-     * $case
-     *     ->when(['Table.column' => true])
-     *     ->then('Yes')
-     *     ->then('No')
-     *     // ...
-     * ```
-     *
-     * @param \Cake\Database\ExpressionInterface|scalar|object|null $result The result value.
-     * @param string|null $type The result type. If no type is provided, the type will be tried to be inferred from the
-     *  value.
-     * @return $this
-     * @throws \LogicException In case `when()` wasn't previously called with a value other than a closure or an
-     *  instance of `\Cake\Database\Expression\WhenThenExpressionInterface`.
-     * @see when()
-     */
-    public function then($result, ?string $type = null);
-
-    /**
-     * Sets the `ELSE` result value.
-     *
-     * @param \Cake\Database\ExpressionInterface|scalar|object|null $result The result value.
-     * @param string|null $type The result type. If no type is provided, the type will be tried to be inferred from the
-     *  value.
-     * @return $this
-     * @throws \LogicException In case a closing `then()` call is required before calling this method.
-     * @throws \InvalidArgumentException In case the `$result` argument is neither a scalar value, nor an object, an
-     *  instance of `\Cake\Database\ExpressionInterface`, or `null`.
-     * @see then()
-     */
-    public function else($result, ?string $type = null);
-
-    /**
-     * Returns the abstract type that this expression will return.
-     *
-     * If no type has been explicitly set via `setReturnType()`, this
-     * method will try to obtain the type from the result types of the
-     * `then()` and `else() `calls. All types must be identical in order
-     * for this to work, otherwise the type will default to `string`.
-     *
-     * @return string
-     * @see setReturnType()
-     */
-    public function getReturnType(): string;
-
-    /**
-     * Sets the abstract type that this expression will return.
-     *
-     * If no type is being explicitly set via this method, then the
-     * `getReturnType()` method will try to infer the type from the
-     * result types of the `then()` and `else() `calls.
-     *
-     * @param string $type The type name to use.
-     * @return $this
-     * @see getReturnType()
-     */
-    public function setReturnType(string $type);
-
-    /**
-     * Sets the type map to use when using an array of conditions
-     * for the `WHEN` value.
-     *
-     * @param \Cake\Database\TypeMap|array $typeMap Either an array that is used to create a new
-     *  `\Cake\Database\TypeMap` instance, or an instance of `\Cake\Database\TypeMap`.
-     * @return $this
-     */
-    public function setTypeMap(TypeMap|array $typeMap);
-
-    /**
-     * Returns the type map.
-     *
-     * @return \Cake\Database\TypeMap
-     */
-    public function getTypeMap(): TypeMap;
-}

+ 0 - 21
src/Database/Expression/CaseExpressionTrait.php

@@ -22,13 +22,10 @@ use Cake\Database\ExpressionInterface;
 use Cake\Database\Query;
 use Cake\Database\ValueBinder;
 use DateTimeInterface;
-use InvalidArgumentException;
 
 /**
  * Trait that holds shared functionality for case related expressions.
  *
- * @property array<string> $validClauseNames The names of the clauses that are valid for use with the
- * `clause()` method.
  * @property \Cake\Database\TypeMap $_typeMap The type map to use when using an array of conditions for the `WHEN`
  *  value.
  * @internal
@@ -36,24 +33,6 @@ use InvalidArgumentException;
 trait CaseExpressionTrait
 {
     /**
-     * @inheritDoc
-     */
-    public function clause(string $clause)
-    {
-        if (!in_array($clause, $this->validClauseNames, true)) {
-            throw new InvalidArgumentException(
-                sprintf(
-                    'The `$clause` argument must be one of `%s`, the given value `%s` is invalid.',
-                    implode('`, `', $this->validClauseNames),
-                    $clause
-                )
-            );
-        }
-
-        return $this->{$clause};
-    }
-
-    /**
      * Infers the abstract type for the given value.
      *
      * @param mixed $value The value for which to infer the type.

+ 261 - 49
src/Database/Expression/CaseStatementExpression.php

@@ -18,13 +18,14 @@ namespace Cake\Database\Expression;
 
 use Cake\Database\ExpressionInterface;
 use Cake\Database\Type\ExpressionTypeCasterTrait;
+use Cake\Database\TypedResultInterface;
 use Cake\Database\TypeMapTrait;
 use Cake\Database\ValueBinder;
 use Closure;
 use InvalidArgumentException;
 use LogicException;
 
-class CaseStatementExpression implements CaseExpressionInterface
+class CaseStatementExpression implements ExpressionInterface, TypedResultInterface
 {
     use CaseExpressionTrait;
     use ExpressionTypeCasterTrait;
@@ -66,7 +67,7 @@ class CaseStatementExpression implements CaseExpressionInterface
     /**
      * The `WHEN ... THEN ...` expressions.
      *
-     * @var array<\Cake\Database\Expression\WhenThenExpressionInterface>
+     * @var array<\Cake\Database\Expression\WhenThenExpression>
      */
     protected $when = [];
 
@@ -150,29 +151,161 @@ class CaseStatementExpression implements CaseExpressionInterface
     }
 
     /**
-     * @inheritDoc
+     * Sets the `WHEN` value for a `WHEN ... THEN ...` expression, or a
+     * self-contained expression that holds both the value for `WHEN`
+     * and the value for `THEN`.
+     *
+     * ### Order based syntax
+     *
+     * When passing a value other than a self-contained
+     * `\Cake\Database\Expression\WhenThenExpression`,
+     * instance, the `WHEN ... THEN ...` statement must be closed off with
+     * a call to `then()` before invoking `when()` again or `else()`:
+     *
+     * ```
+     * $queryExpression
+     *     ->case($query->identifier('Table.column'))
+     *     ->when(true)
+     *     ->then('Yes')
+     *     ->when(false)
+     *     ->then('No')
+     *     ->else('Maybe');
+     * ```
+     *
+     * ### Self-contained expressions
+     *
+     * When passing an instance of `\Cake\Database\Expression\WhenThenExpression`,
+     * being it directly, or via a callable, then there is no need to close
+     * using `then()` on this object, instead the statement will be closed
+     * on the `\Cake\Database\Expression\WhenThenExpression`
+     * object using
+     * `\Cake\Database\Expression\WhenThenExpression::then()`.
+     *
+     * Callables will receive an instance of `\Cake\Database\Expression\WhenThenExpression`,
+     * and must return one, being it the same object, or a custom one:
+     *
+     * ```
+     * $queryExpression
+     *     ->case()
+     *     ->when(function (\Cake\Database\Expression\WhenThenExpression $whenThen) {
+     *         return $whenThen
+     *             ->when(['Table.column' => true])
+     *             ->then('Yes');
+     *     })
+     *     ->when(function (\Cake\Database\Expression\WhenThenExpression $whenThen) {
+     *         return $whenThen
+     *             ->when(['Table.column' => false])
+     *             ->then('No');
+     *     })
+     *     ->else('Maybe');
+     * ```
+     *
+     * ### Type handling
+     *
+     * The types provided via the `$type` argument will be merged with the
+     * type map set for this expression. When using callables for `$when`,
+     * the `\Cake\Database\Expression\WhenThenExpression`
+     * instance received by the callables will inherit that type map, however
+     * the types passed here will _not_ be merged in case of using callables,
+     * instead the types must be passed in
+     * `\Cake\Database\Expression\WhenThenExpression::when()`:
+     *
+     * ```
+     * $queryExpression
+     *     ->case()
+     *     ->when(function (\Cake\Database\Expression\WhenThenExpression $whenThen) {
+     *         return $whenThen
+     *             ->when(['unmapped_column' => true], ['unmapped_column' => 'bool'])
+     *             ->then('Yes');
+     *     })
+     *     ->when(function (\Cake\Database\Expression\WhenThenExpression $whenThen) {
+     *         return $whenThen
+     *             ->when(['unmapped_column' => false], ['unmapped_column' => 'bool'])
+     *             ->then('No');
+     *     })
+     *     ->else('Maybe');
+     * ```
+     *
+     * ### User data safety
+     *
+     * When passing user data, be aware that allowing a user defined array
+     * to be passed, is a potential SQL injection vulnerability, as it
+     * allows for raw SQL to slip in!
+     *
+     * The following is _unsafe_ usage that must be avoided:
+     *
+     * ```
+     * $case
+     *      ->when($userData)
+     * ```
+     *
+     * A safe variant for the above would be to define a single type for
+     * the value:
+     *
+     * ```
+     * $case
+     *      ->when($userData, 'integer')
+     * ```
+     *
+     * This way an exception would be triggered when an array is passed for
+     * the value, thus preventing raw SQL from slipping in, and all other
+     * types of values would be forced to be bound as an integer.
+     *
+     * Another way to safely pass user data is when using a conditions
+     * array, and passing user data only on the value side of the array
+     * entries, which will cause them to be bound:
+     *
+     * ```
+     * $case
+     *      ->when([
+     *          'Table.column' => $userData,
+     *      ])
+     * ```
+     *
+     * Lastly, data can also be bound manually:
+     *
+     * ```
+     * $query
+     *      ->select([
+     *          'val' => $query->newExpr()
+     *              ->case()
+     *              ->when($query->newExpr(':userData'))
+     *              ->then(123)
+     *      ])
+     *      ->bind(':userData', $userData, 'integer')
+     * ```
+     *
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|object|scalar $when The `WHEN` value. When using an
+     *  array of conditions, it must be compatible with `\Cake\Database\Query::where()`. Note that this argument is
+     *  _not_ completely safe for use with user data, as a user supplied array would allow for raw SQL to slip in! If
+     *  you plan to use user data, either pass a single type for the `$type` argument (which forces the `$when` value to
+     *  be a non-array, and then always binds the data), use a conditions array where the user data is only passed on
+     *  the value side of the array entries, or custom bindings!
+     * @param array|string|null $type The when value type. Either an associative array when using array style
+     *  conditions, or else a string. If no type is provided, the type will be tried to be inferred from the value.
+     * @return $this
+     * @throws \LogicException In case this a closing `then()` call is required before calling this method.
+     * @throws \LogicException In case the callable doesn't return an instance of
+     *  `\Cake\Database\Expression\WhenThenExpression`.
      */
     public function when($when, $type = null)
     {
         if ($this->whenBuffer !== null) {
-            throw new LogicException(
-                'Cannot add new `WHEN` value while an open `when()` buffer is present, ' .
-                'it must first be closed using `then()`.'
-            );
+            throw new LogicException('Cannot call `when()` between `when()` and `then()`.');
         }
 
         if ($when instanceof Closure) {
             $when = $when(new WhenThenExpression($this->getTypeMap()));
-            if (!($when instanceof WhenThenExpressionInterface)) {
+            if (!($when instanceof WhenThenExpression)) {
                 throw new LogicException(sprintf(
                     '`when()` callables must return an instance of `\%s`, `%s` given.',
-                    WhenThenExpressionInterface::class,
+                    WhenThenExpression::class,
                     get_debug_type($when)
                 ));
             }
         }
 
-        if ($when instanceof WhenThenExpressionInterface) {
+        if ($when instanceof WhenThenExpression) {
             $this->when[] = $when;
         } else {
             $this->whenBuffer = ['when' => $when, 'type' => $type];
@@ -182,15 +315,65 @@ class CaseStatementExpression implements CaseExpressionInterface
     }
 
     /**
-     * @inheritDoc
+     * Sets the `THEN` result value for the last `WHEN ... THEN ...`
+     * statement that was opened using `when()`.
+     *
+     * ### Order based syntax
+     *
+     * This method can only be invoked in case `when()` was previously
+     * used with a value other than a closure or an instance of
+     * `\Cake\Database\Expression\WhenThenExpression`:
+     *
+     * ```
+     * $case
+     *     ->when(['Table.column' => true])
+     *     ->then('Yes')
+     *     ->when(['Table.column' => false])
+     *     ->then('No')
+     *     ->else('Maybe');
+     * ```
+     *
+     * The following would all fail with an exception:
+     *
+     * ```
+     * $case
+     *     ->when(['Table.column' => true])
+     *     ->when(['Table.column' => false])
+     *     // ...
+     * ```
+     *
+     * ```
+     * $case
+     *     ->when(['Table.column' => true])
+     *     ->else('Maybe')
+     *     // ...
+     * ```
+     *
+     * ```
+     * $case
+     *     ->then('Yes')
+     *     // ...
+     * ```
+     *
+     * ```
+     * $case
+     *     ->when(['Table.column' => true])
+     *     ->then('Yes')
+     *     ->then('No')
+     *     // ...
+     * ```
+     *
+     * @param \Cake\Database\ExpressionInterface|object|scalar|null $result The result value.
+     * @param string|null $type The result type. If no type is provided, the type will be tried to be inferred from the
+     *  value.
+     * @return $this
+     * @throws \LogicException In case `when()` wasn't previously called with a value other than a closure or an
+     *  instance of `\Cake\Database\Expression\WhenThenExpression`.
      */
     public function then($result, ?string $type = null)
     {
         if ($this->whenBuffer === null) {
-            throw new LogicException(
-                'There is no `when()` buffer present, ' .
-                'you must first open one before calling `then()`.'
-            );
+            throw new LogicException('Cannot call `then()` before `when()`.');
         }
 
         $whenThen = (new WhenThenExpression($this->getTypeMap()))
@@ -205,15 +388,20 @@ class CaseStatementExpression implements CaseExpressionInterface
     }
 
     /**
-     * @inheritDoc
+     * Sets the `ELSE` result value.
+     *
+     * @param \Cake\Database\ExpressionInterface|object|scalar|null $result The result value.
+     * @param string|null $type The result type. If no type is provided, the type will be tried to be inferred from the
+     *  value.
+     * @return $this
+     * @throws \LogicException In case a closing `then()` call is required before calling this method.
+     * @throws \InvalidArgumentException In case the `$result` argument is neither a scalar value, nor an object, an
+     *  instance of `\Cake\Database\ExpressionInterface`, or `null`.
      */
     public function else($result, ?string $type = null)
     {
         if ($this->whenBuffer !== null) {
-            throw new LogicException(
-                'Cannot set `ELSE` value when an open `when()` buffer is present, ' .
-                'it must first be closed using `then()`.'
-            );
+            throw new LogicException('Cannot call `else()` between `when()` and `then()`.');
         }
 
         if (
@@ -240,7 +428,15 @@ class CaseStatementExpression implements CaseExpressionInterface
     }
 
     /**
-     * @inheritDoc
+     * Returns the abstract type that this expression will return.
+     *
+     * If no type has been explicitly set via `setReturnType()`, this
+     * method will try to obtain the type from the result types of the
+     * `then()` and `else() `calls. All types must be identical in order
+     * for this to work, otherwise the type will default to `string`.
+     *
+     * @return string
+     * @see CaseStatementExpression::then()
      */
     public function getReturnType(): string
     {
@@ -269,7 +465,14 @@ class CaseStatementExpression implements CaseExpressionInterface
     }
 
     /**
-     * @inheritDoc
+     * Sets the abstract type that this expression will return.
+     *
+     * If no type is being explicitly set via this method, then the
+     * `getReturnType()` method will try to infer the type from the
+     * result types of the `then()` and `else() `calls.
+     *
+     * @param string $type The type name to use.
+     * @return $this
      */
     public function setReturnType(string $type)
     {
@@ -279,27 +482,48 @@ class CaseStatementExpression implements CaseExpressionInterface
     }
 
     /**
-     * @inheritDoc
+     * Returns the available data for the given clause.
+     *
+     * ### Available clauses
+     *
+     * The following clause names are available:
+     *
+     * * `value` (`\Cake\Database\ExpressionInterface|object|scalar|null`): The case value for a
+     *   `CASE case_value WHEN ...` expression.
+     * * `when (`array<\Cake\Database\Expression\WhenThenExpression>`)`: An array of self-contained
+     *   `WHEN ... THEN ...` expressions.
+     * * `else` (`\Cake\Database\ExpressionInterface|object|scalar|null`): The `ELSE` result value.
+     *
+     * @param string $clause The name of the clause to obtain.
+     * @return array<\Cake\Database\Expression\WhenThenExpression>|\Cake\Database\ExpressionInterface|object|scalar|null
+     * @throws \InvalidArgumentException In case the given clause name is invalid.
      */
-    public function sql(ValueBinder $binder): string
+    public function clause(string $clause)
     {
-        if ($this->whenBuffer !== null) {
-            throw new LogicException(
+        if (!in_array($clause, $this->validClauseNames, true)) {
+            throw new InvalidArgumentException(
                 sprintf(
-                    'Cannot compile incomplete `\%s` expression, there is an open `when()` buffer present ' .
-                    'that must be closed using `then()`.',
-                    CaseExpressionInterface::class
+                    'The `$clause` argument must be one of `%s`, the given value `%s` is invalid.',
+                    implode('`, `', $this->validClauseNames),
+                    $clause
                 )
             );
         }
 
+        return $this->{$clause};
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function sql(ValueBinder $binder): string
+    {
+        if ($this->whenBuffer !== null) {
+            throw new LogicException('Case expression has incomplete when clause. Missing `then()` after `when()`.');
+        }
+
         if (empty($this->when)) {
-            throw new LogicException(
-                sprintf(
-                    'Cannot compile incomplete `\%s` expression, there are no `WHEN ... THEN ...` statements.',
-                    CaseExpressionInterface::class
-                )
-            );
+            throw new LogicException('Case expression must have at least one when statement.');
         }
 
         $value = '';
@@ -324,13 +548,7 @@ class CaseStatementExpression implements CaseExpressionInterface
     public function traverse(Closure $callback)
     {
         if ($this->whenBuffer !== null) {
-            throw new LogicException(
-                sprintf(
-                    'Cannot traverse incomplete `\%s` expression, there is an open `when()` buffer present ' .
-                    'that must be closed using `then()`.',
-                    CaseExpressionInterface::class
-                )
-            );
+            throw new LogicException('Case expression has incomplete when clause. Missing `then()` after `when()`.');
         }
 
         if ($this->value instanceof ExpressionInterface) {
@@ -359,13 +577,7 @@ class CaseStatementExpression implements CaseExpressionInterface
     public function __clone()
     {
         if ($this->whenBuffer !== null) {
-            throw new LogicException(
-                sprintf(
-                    'Cannot clone incomplete `\%s` expression, there is an open `when()` buffer present ' .
-                    'that must be closed using `then()`.',
-                    CaseExpressionInterface::class
-                )
-            );
+            throw new LogicException('Case expression has incomplete when clause. Missing `then()` after `when()`.');
         }
 
         if ($this->value instanceof ExpressionInterface) {

+ 2 - 2
src/Database/Expression/QueryExpression.php

@@ -376,9 +376,9 @@ class QueryExpression implements ExpressionInterface, Countable
      * @param \Cake\Database\ExpressionInterface|scalar|object|null $value The case value.
      * @param string|null $type The case value type. If no type is provided, the type will be tried to be inferred
      *  from the value.
-     * @return \Cake\Database\Expression\CaseExpressionInterface
+     * @return \Cake\Database\Expression\CaseStatementExpression
      */
-    public function case($value = null, ?string $type = null): CaseExpressionInterface
+    public function case($value = null, ?string $type = null): CaseStatementExpression
     {
         if (func_num_args() > 0) {
             $expression = new CaseStatementExpression($value, $type);

+ 16 - 1
src/Database/Expression/TupleComparison.php

@@ -19,6 +19,7 @@ namespace Cake\Database\Expression;
 use Cake\Database\ExpressionInterface;
 use Cake\Database\ValueBinder;
 use Closure;
+use InvalidArgumentException;
 
 /**
  * This expression represents SQL fragments that are used for comparing one tuple
@@ -51,8 +52,8 @@ class TupleComparison extends ComparisonExpression
     ) {
         $this->types = $types;
         $this->setField($fields);
-        $this->setValue($values);
         $this->_operator = $conjunction;
+        $this->setValue($values);
     }
 
     /**
@@ -73,6 +74,20 @@ class TupleComparison extends ComparisonExpression
      */
     public function setValue(mixed $value): void
     {
+        if ($this->isMulti()) {
+            if (is_array($value) && !is_array(current($value))) {
+                throw new InvalidArgumentException(
+                    'Multi-tuple comparisons require a multi-tuple value, single-tuple given.'
+                );
+            }
+        } else {
+            if (is_array($value) && is_array(current($value))) {
+                throw new InvalidArgumentException(
+                    'Single-tuple comparisons require a single-tuple value, multi-tuple given.'
+                );
+            }
+        }
+
         $this->_value = $value;
     }
 

+ 61 - 16
src/Database/Expression/WhenThenExpression.php

@@ -25,7 +25,7 @@ use Closure;
 use InvalidArgumentException;
 use LogicException;
 
-class WhenThenExpression implements WhenThenExpressionInterface
+class WhenThenExpression implements ExpressionInterface
 {
     use CaseExpressionTrait;
     use ExpressionTypeCasterTrait;
@@ -100,7 +100,25 @@ class WhenThenExpression implements WhenThenExpressionInterface
     }
 
     /**
-     * @inheritDoc
+     * Sets the `WHEN` value.
+     *
+     * @param \Cake\Database\ExpressionInterface|array|object|scalar $when The `WHEN` value. When using an array of
+     *  conditions, it must be compatible with `\Cake\Database\Query::where()`. Note that this argument is _not_
+     *  completely safe for use with user data, as a user supplied array would allow for raw SQL to slip in! If you
+     *  plan to use user data, either pass a single type for the `$type` argument (which forces the `$when` value to be
+     *  a non-array, and then always binds the data), use a conditions array where the user data is only passed on the
+     *  value side of the array entries, or custom bindings!
+     * @param array|string|null $type The when value type. Either an associative array when using array style
+     *  conditions, or else a string. If no type is provided, the type will be tried to be inferred from the value.
+     * @return $this
+     * @throws \InvalidArgumentException In case the `$when` argument is neither a non-empty array, nor a scalar value,
+     *  an object, or an instance of `\Cake\Database\ExpressionInterface`.
+     * @throws \InvalidArgumentException In case the `$type` argument is neither an array, a string, nor null.
+     * @throws \InvalidArgumentException In case the `$when` argument is an array, and the `$type` argument is neither
+     * an array, nor null.
+     * @throws \InvalidArgumentException In case the `$when` argument is a non-array value, and the `$type` argument is
+     * neither a string, nor null.
+     * @see CaseStatementExpression::when() for a more detailed usage explanation.
      */
     public function when($when, $type = null)
     {
@@ -177,7 +195,12 @@ class WhenThenExpression implements WhenThenExpressionInterface
     }
 
     /**
-     * @inheritDoc
+     * Sets the `THEN` result value.
+     *
+     * @param \Cake\Database\ExpressionInterface|object|scalar|null $result The result value.
+     * @param string|null $type The result type. If no type is provided, the type will be inferred from the given
+     *  result value.
+     * @return $this
      */
     public function then($result, ?string $type = null)
     {
@@ -208,7 +231,10 @@ class WhenThenExpression implements WhenThenExpressionInterface
     }
 
     /**
-     * @inheritDoc
+     * Returns the expression's result value type.
+     *
+     * @return string|null
+     * @see WhenThenExpression::then()
      */
     public function getResultType(): ?string
     {
@@ -216,26 +242,45 @@ class WhenThenExpression implements WhenThenExpressionInterface
     }
 
     /**
-     * @inheritDoc
+     * Returns the available data for the given clause.
+     *
+     * ### Available clauses
+     *
+     * The following clause names are available:
+     *
+     * * `when` (`\Cake\Database\ExpressionInterface|object|scalar|null`): The `WHEN` value.
+     * * `then` (`\Cake\Database\ExpressionInterface|object|scalar|null`): The `THEN` result value.
+     *
+     * @param string $clause The name of the clause to obtain.
+     * @return \Cake\Database\ExpressionInterface|object|scalar|null
+     * @throws \InvalidArgumentException In case the given clause name is invalid.
      */
-    public function sql(ValueBinder $binder): string
+    public function clause(string $clause)
     {
-        if ($this->when === null) {
-            throw new LogicException(
+        if (!in_array($clause, $this->validClauseNames, true)) {
+            throw new InvalidArgumentException(
                 sprintf(
-                    'Cannot compile incomplete `\%s`, the value for `WHEN` is missing.',
-                    WhenThenExpressionInterface::class
+                    'The `$clause` argument must be one of `%s`, the given value `%s` is invalid.',
+                    implode('`, `', $this->validClauseNames),
+                    $clause
                 )
             );
         }
 
+        return $this->{$clause};
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function sql(ValueBinder $binder): string
+    {
+        if ($this->when === null) {
+            throw new LogicException('Case expression has incomplete when clause. Missing `when()`.');
+        }
+
         if (!$this->hasThenBeenDefined) {
-            throw new LogicException(
-                sprintf(
-                    'Cannot compile incomplete `\%s`, the value for `THEN` is missing.',
-                    WhenThenExpressionInterface::class
-                )
-            );
+            throw new LogicException('Case expression has incomplete when clause. Missing `then()` after `when()`.');
         }
 
         $when = $this->when;

+ 0 - 79
src/Database/Expression/WhenThenExpressionInterface.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         4.3.0
- * @license       https://opensource.org/licenses/mit-license.php MIT License
- */
-namespace Cake\Database\Expression;
-
-use Cake\Database\ExpressionInterface;
-
-interface WhenThenExpressionInterface extends ExpressionInterface
-{
-    /**
-     * Returns the available data for the given clause.
-     *
-     * ### Available clauses
-     *
-     * The following clause names are available:
-     *
-     * * `when` (`\Cake\Database\ExpressionInterface|object|scalar|null`): The `WHEN` value.
-     * * `then` (`\Cake\Database\ExpressionInterface|object|scalar|null`): The `THEN` result value.
-     *
-     * @param string $clause The name of the clause to obtain.
-     * @return \Cake\Database\ExpressionInterface|scalar|object|null
-     * @throws \InvalidArgumentException In case the given clause name is invalid.
-     */
-    public function clause(string $clause);
-
-    /**
-     * Sets the `WHEN` value.
-     *
-     * @param \Cake\Database\ExpressionInterface|scalar|object|array $when The `WHEN` value. When using an array of
-     *  conditions, it must be compatible with `\Cake\Database\Query::where()`. Note that this argument is _not_
-     *  completely safe for use with user data, as a user supplied array would allow for raw SQL to slip in! If you
-     *  plan to use user data, either pass a single type for the `$type` argument (which forces the `$when` value to be
-     *  a non-array, and then always binds the data), use a conditions array where the user data is only passed on the
-     *  value side of the array entries, or custom bindings!
-     * @param array|string|null $type The when value type. Either an associative array when using array style
-     *  conditions, or else a string. If no type is provided, the type will be tried to be inferred from the value.
-     * @return $this
-     * @throws \InvalidArgumentException In case the `$when` argument is neither a non-empty array, nor a scalar value,
-     *  an object, or an instance of `\Cake\Database\ExpressionInterface`.
-     * @throws \InvalidArgumentException In case the `$type` argument is neither an array, a string, nor null.
-     * @throws \InvalidArgumentException In case the `$when` argument is an array, and the `$type` argument is neither
-     * an array, nor null.
-     * @throws \InvalidArgumentException In case the `$when` argument is a non-array value, and the `$type` argument is
-     * neither a string, nor null.
-     * @see CaseExpressionInterface::when() for a more detailed usage explanation.
-     */
-    public function when($when, $type = null);
-
-    /**
-     * Sets the `THEN` result value.
-     *
-     * @param \Cake\Database\ExpressionInterface|scalar|object|null $result The result value.
-     * @param string|null $type The result type. If no type is provided, the type will be inferred from the given
-     *  result value.
-     * @return $this
-     */
-    public function then($result, ?string $type = null);
-
-    /**
-     * Returns the expression's result value type.
-     *
-     * @return string|null
-     * @see then()
-     */
-    public function getResultType(): ?string;
-}

+ 7 - 0
src/Database/QueryCompiler.php

@@ -270,6 +270,13 @@ class QueryCompiler
     {
         $joins = '';
         foreach ($parts as $join) {
+            if (!isset($join['table'])) {
+                throw new DatabaseException(sprintf(
+                    'Could not compile join clause for alias `%s`. No table was specified. ' .
+                    'Use the `table` key to define a table.',
+                    $join['alias']
+                ));
+            }
             if ($join['table'] instanceof ExpressionInterface) {
                 $join['table'] = '(' . $join['table']->sql($binder) . ')';
             }

+ 5 - 5
src/TestSuite/ConnectionHelper.php

@@ -59,12 +59,12 @@ class ConnectionHelper
     /**
      * Enables query logging for all database connections.
      *
-     * @param array<int, string> $connections Connection names or empty for all.
+     * @param array<int, string>|null $connections Connection names or null for all.
      * @return void
      */
-    public function enableQueryLogging(array $connections = []): void
+    public function enableQueryLogging(?array $connections = null): void
     {
-        $connections = $connections ? $connections : ConnectionManager::configured();
+        $connections = $connections ?? ConnectionManager::configured();
         foreach ($connections as $connection) {
             $connection = ConnectionManager::get($connection);
             if ($connection instanceof Connection) {
@@ -87,7 +87,7 @@ class ConnectionHelper
         $collection = $connection->getSchemaCollection();
 
         $allTables = $collection->listTables();
-        $tables = $tables ? array_intersect($tables, $allTables) : $allTables;
+        $tables = $tables !== null ? array_intersect($tables, $allTables) : $allTables;
         $schemas = array_map(function ($table) use ($collection) {
             return $collection->describe($table);
         }, $tables);
@@ -121,7 +121,7 @@ class ConnectionHelper
         $collection = $connection->getSchemaCollection();
 
         $allTables = $collection->listTables();
-        $tables = $tables ? array_intersect($tables, $allTables) : $allTables;
+        $tables = $tables !== null ? array_intersect($tables, $allTables) : $allTables;
         $schemas = array_map(function ($table) use ($collection) {
             return $collection->describe($table);
         }, $tables);

+ 8 - 2
tests/TestCase/Database/ConnectionTest.php

@@ -1314,7 +1314,13 @@ class ConnectionTest extends TestCase
             ->method('prepare')
             ->will($this->throwException(new Exception('server gone away')));
 
-        $this->expectException(Exception::class);
-        $conn->query('SELECT 1');
+        try {
+            $conn->query('SELECT 1');
+        } catch (Exception $e) {
+        }
+        $this->assertInstanceOf(Exception::class, $e ?? null);
+
+        $prop->setValue($conn, $oldDriver);
+        $conn->rollback();
     }
 }

+ 23 - 51
tests/TestCase/Database/Expression/CaseStatementExpressionTest.php

@@ -23,7 +23,6 @@ use Cake\Database\Expression\ComparisonExpression;
 use Cake\Database\Expression\IdentifierExpression;
 use Cake\Database\Expression\QueryExpression;
 use Cake\Database\Expression\WhenThenExpression;
-use Cake\Database\Expression\WhenThenExpressionInterface;
 use Cake\Database\TypeFactory;
 use Cake\Database\TypeMap;
 use Cake\Database\ValueBinder;
@@ -299,7 +298,7 @@ class CaseStatementExpressionTest extends TestCase
     {
         $expression = (new CaseStatementExpressionStub())
             ->setTypeMap(new TypeMap(['Table.column' => 'boolean']))
-            ->when(function (WhenThenExpressionInterface $whenThen) {
+            ->when(function (WhenThenExpression $whenThen) {
                 return $whenThen;
             });
 
@@ -448,12 +447,12 @@ class CaseStatementExpressionTest extends TestCase
 
         $expression = (new CaseStatementExpression())
             ->setTypeMap($typeMap)
-            ->when(function (WhenThenExpressionInterface $whenThen) {
+            ->when(function (WhenThenExpression $whenThen) {
                 return $whenThen
                     ->when(['Table.column_a' => true])
                     ->then(1);
             })
-            ->when(function (WhenThenExpressionInterface $whenThen) {
+            ->when(function (WhenThenExpression $whenThen) {
                 return $whenThen
                     ->when(['Table.column_b' => 'foo'])
                     ->then(2);
@@ -507,12 +506,12 @@ class CaseStatementExpressionTest extends TestCase
 
         $expression = (new CaseStatementExpression())
             ->setTypeMap($typeMap)
-            ->when(function (WhenThenExpressionInterface $whenThen) {
+            ->when(function (WhenThenExpression $whenThen) {
                 return $whenThen
                     ->when(['Table.column_a' => 123], ['Table.column_a' => 'integer'])
                     ->then(1);
             })
-            ->when(function (WhenThenExpressionInterface $whenThen) {
+            ->when(function (WhenThenExpression $whenThen) {
                 return $whenThen
                     ->when(['Table.column_b' => 'foo'])
                     ->then(2);
@@ -1008,7 +1007,7 @@ class CaseStatementExpressionTest extends TestCase
             ->then(1);
 
         $this->assertCount(1, $expression->clause('when'));
-        $this->assertInstanceOf(WhenThenExpressionInterface::class, $expression->clause('when')[0]);
+        $this->assertInstanceOf(WhenThenExpression::class, $expression->clause('when')[0]);
     }
 
     public function testWhenArrayValueGetWhenClause(): void
@@ -1043,7 +1042,7 @@ class CaseStatementExpressionTest extends TestCase
     public function testWhenGetThenClause(): void
     {
         $expression = (new CaseStatementExpression())
-            ->when(function (WhenThenExpressionInterface $whenThen) {
+            ->when(function (WhenThenExpression $whenThen) {
                 return $whenThen;
             });
 
@@ -1096,10 +1095,7 @@ class CaseStatementExpressionTest extends TestCase
     public function testWhenBeforeClosingThenFails(): void
     {
         $this->expectException(LogicException::class);
-        $this->expectExceptionMessage(
-            'Cannot add new `WHEN` value while an open `when()` buffer is present, ' .
-            'it must first be closed using `then()`.'
-        );
+        $this->expectExceptionMessage('Cannot call `when()` between `when()` and `then()`.');
 
         (new CaseStatementExpression())
             ->when(['Table.column_a' => true])
@@ -1109,10 +1105,7 @@ class CaseStatementExpressionTest extends TestCase
     public function testElseBeforeClosingThenFails(): void
     {
         $this->expectException(LogicException::class);
-        $this->expectExceptionMessage(
-            'Cannot set `ELSE` value when an open `when()` buffer is present, ' .
-            'it must first be closed using `then()`.'
-        );
+        $this->expectExceptionMessage('Cannot call `else()` between `when()` and `then()`.');
 
         (new CaseStatementExpression())
             ->when(['Table.column' => true])
@@ -1122,10 +1115,7 @@ class CaseStatementExpressionTest extends TestCase
     public function testThenBeforeOpeningWhenFails(): void
     {
         $this->expectException(LogicException::class);
-        $this->expectExceptionMessage(
-            'There is no `when()` buffer present, ' .
-            'you must first open one before calling `then()`.'
-        );
+        $this->expectExceptionMessage('Cannot call `then()` before `when()`.');
 
         (new CaseStatementExpression())
             ->then(1);
@@ -1138,7 +1128,7 @@ class CaseStatementExpressionTest extends TestCase
     public function testWhenCallables(): void
     {
         $expression = (new CaseStatementExpression())
-            ->when(function (WhenThenExpressionInterface $whenThen) {
+            ->when(function (WhenThenExpression $whenThen) {
                 return $whenThen
                     ->when([
                         'Table.column_a' => true,
@@ -1146,7 +1136,7 @@ class CaseStatementExpressionTest extends TestCase
                     ])
                     ->then(1);
             })
-            ->when(function (WhenThenExpressionInterface $whenThen) {
+            ->when(function (WhenThenExpression $whenThen) {
                 return $whenThen
                     ->when([
                         'Table.column_c' => true,
@@ -1206,7 +1196,7 @@ class CaseStatementExpressionTest extends TestCase
         $this->expectException(LogicException::class);
         $this->expectExceptionMessage(
             '`when()` callables must return an instance of ' .
-            '`\Cake\Database\Expression\WhenThenExpressionInterface`, `null` given.'
+            '`\Cake\Database\Expression\WhenThenExpression`, `null` given.'
         );
 
         $this->deprecated(function () {
@@ -1294,10 +1284,7 @@ class CaseStatementExpressionTest extends TestCase
     public function testCompilingEmptyCaseExpressionFails(): void
     {
         $this->expectException(LogicException::class);
-        $this->expectExceptionMessage(
-            'Cannot compile incomplete `\Cake\Database\Expression\CaseExpressionInterface` ' .
-            'expression, there are no `WHEN ... THEN ...` statements.'
-        );
+        $this->expectExceptionMessage('Case expression must have at least one when statement.');
 
         $this->deprecated(function () {
             (new CaseStatementExpression())->sql(new ValueBinder());
@@ -1307,10 +1294,7 @@ class CaseStatementExpressionTest extends TestCase
     public function testCompilingNonClosedWhenFails(): void
     {
         $this->expectException(LogicException::class);
-        $this->expectExceptionMessage(
-            'Cannot compile incomplete `\Cake\Database\Expression\CaseExpressionInterface` ' .
-            'expression, there is an open `when()` buffer present that must be closed using `then()`.'
-        );
+        $this->expectExceptionMessage('Case expression has incomplete when clause. Missing `then()` after `when()`.');
 
         $this->deprecated(function () {
             (new CaseStatementExpression())
@@ -1322,14 +1306,11 @@ class CaseStatementExpressionTest extends TestCase
     public function testCompilingWhenThenExpressionWithMissingWhenFails(): void
     {
         $this->expectException(LogicException::class);
-        $this->expectExceptionMessage(
-            'Cannot compile incomplete `\Cake\Database\Expression\WhenThenExpressionInterface`, ' .
-            'the value for `WHEN` is missing.'
-        );
+        $this->expectExceptionMessage('Case expression has incomplete when clause. Missing `when()`.');
 
         $this->deprecated(function () {
             (new CaseStatementExpression())
-                ->when(function (WhenThenExpressionInterface $whenThen) {
+                ->when(function (WhenThenExpression $whenThen) {
                     return $whenThen->then(1);
                 })
                 ->sql(new ValueBinder());
@@ -1339,14 +1320,11 @@ class CaseStatementExpressionTest extends TestCase
     public function testCompilingWhenThenExpressionWithMissingThenFails(): void
     {
         $this->expectException(LogicException::class);
-        $this->expectExceptionMessage(
-            'Cannot compile incomplete `\Cake\Database\Expression\WhenThenExpressionInterface`, ' .
-            'the value for `THEN` is missing.'
-        );
+        $this->expectExceptionMessage('Case expression has incomplete when clause. Missing `then()` after `when()`.');
 
         $this->deprecated(function () {
             (new CaseStatementExpression())
-                ->when(function (WhenThenExpressionInterface $whenThen) {
+                ->when(function (WhenThenExpression $whenThen) {
                     return $whenThen->when(1);
                 })
                 ->sql(new ValueBinder());
@@ -2125,11 +2103,11 @@ class CaseStatementExpressionTest extends TestCase
         $this->assertCount(14, $expressions);
         $this->assertInstanceOf(IdentifierExpression::class, $expressions[0]);
         $this->assertSame($value, $expressions[0]);
-        $this->assertInstanceOf(WhenThenExpressionInterface::class, $expressions[1]);
+        $this->assertInstanceOf(WhenThenExpression::class, $expressions[1]);
         $this->assertEquals(new QueryExpression($conditionsA), $expressions[2]);
         $this->assertEquals(new ComparisonExpression('Table.column_a', true), $expressions[3]);
         $this->assertSame($resultA, $expressions[6]);
-        $this->assertInstanceOf(WhenThenExpressionInterface::class, $expressions[7]);
+        $this->assertInstanceOf(WhenThenExpression::class, $expressions[7]);
         $this->assertEquals(new QueryExpression($conditionsB), $expressions[8]);
         $this->assertEquals(new ComparisonExpression('Table.column_c', true), $expressions[9]);
         $this->assertSame($resultB, $expressions[12]);
@@ -2139,10 +2117,7 @@ class CaseStatementExpressionTest extends TestCase
     public function testTraverseBeforeClosingThenFails(): void
     {
         $this->expectException(LogicException::class);
-        $this->expectExceptionMessage(
-            'Cannot traverse incomplete `\Cake\Database\Expression\CaseExpressionInterface` ' .
-            'expression, there is an open `when()` buffer present that must be closed using `then()`.'
-        );
+        $this->expectExceptionMessage('Case expression has incomplete when clause. Missing `then()` after `when()`.');
 
         $this->deprecated(function () {
             $expression = (new CaseStatementExpression())
@@ -2183,10 +2158,7 @@ class CaseStatementExpressionTest extends TestCase
     public function testCloneBeforeClosingThenFails(): void
     {
         $this->expectException(LogicException::class);
-        $this->expectExceptionMessage(
-            'Cannot clone incomplete `\Cake\Database\Expression\CaseExpressionInterface` ' .
-            'expression, there is an open `when()` buffer present that must be closed using `then()`.'
-        );
+        $this->expectExceptionMessage('Case expression has incomplete when clause. Missing `then()` after `when()`.');
 
         $this->deprecated(function () {
             $expression = (new CaseStatementExpression())

+ 27 - 0
tests/TestCase/Database/Expression/TupleComparisonTest.php

@@ -20,6 +20,7 @@ use Cake\Database\Expression\QueryExpression;
 use Cake\Database\Expression\TupleComparison;
 use Cake\Database\ValueBinder;
 use Cake\TestSuite\TestCase;
+use InvalidArgumentException;
 
 /**
  * Tests TupleComparison class
@@ -142,4 +143,30 @@ class TupleComparisonTest extends TestCase
         $binder = new ValueBinder();
         $this->assertSame('(a, b) = (:tuple0, :tuple1)', $f->sql($binder));
     }
+
+    public function testMultiTupleComparisonRequiresMultiTupleValue(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Multi-tuple comparisons require a multi-tuple value, single-tuple given.');
+
+        new TupleComparison(
+            ['field1', 'field2'],
+            [1, 1],
+            ['integer', 'integer'],
+            'IN'
+        );
+    }
+
+    public function testSingleTupleComparisonRequiresSingleTupleValue(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Single-tuple comparisons require a single-tuple value, multi-tuple given.');
+
+        new TupleComparison(
+            ['field1', 'field2'],
+            [[1, 1], [2, 2]],
+            ['integer', 'integer'],
+            '='
+        );
+    }
 }

+ 275 - 0
tests/TestCase/Database/QueryTests/TupleComparisonQueryTest.php

@@ -0,0 +1,275 @@
+<?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         4.3.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+
+namespace Cake\Test\TestCase\Database\QueryTests;
+
+use Cake\Database\Driver\Mysql;
+use Cake\Database\Driver\Postgres;
+use Cake\Database\Driver\Sqlite;
+use Cake\Database\Driver\Sqlserver;
+use Cake\Database\Expression\TupleComparison;
+use Cake\TestSuite\TestCase;
+use PDOException;
+use RuntimeException;
+
+/**
+ * Tuple comparison query tests.
+ *
+ * These tests are specifically relevant in the context of Sqlite and
+ * Sqlserver, for which the tuple comparison will be transformed when
+ * composite fields are used.
+ *
+ * @see \Cake\Database\Driver\TupleComparisonTranslatorTrait::_transformTupleComparison()
+ */
+class TupleComparisonQueryTest extends TestCase
+{
+    /**
+     * @inheritDoc
+     */
+    protected array $fixtures = [
+        'core.Articles',
+    ];
+
+    public function testTransformWithInvalidOperator(): void
+    {
+        $articles = $this->getTableLocator()->get('Articles');
+
+        $driver = $articles->getConnection()->getDriver();
+        if (
+            $driver instanceof Sqlite ||
+            $driver instanceof Sqlserver
+        ) {
+            $this->expectException(RuntimeException::class);
+            $this->expectExceptionMessage(
+                'Tuple comparison transform only supports the `IN` and `=` operators, `NOT IN` given.'
+            );
+        } else {
+            $this->markTestSkipped('Tuple comparisons are only being transformed for Sqlite and Sqlserver.');
+        }
+
+        $articles
+            ->find()
+            ->select(['Articles.id', 'Articles.author_id'])
+            ->where([
+                new TupleComparison(
+                    ['Articles.id', 'Articles.author_id'],
+                    $articles
+                        ->subquery()
+                        ->select(['ArticlesAlias.id', 'ArticlesAlias.author_id'])
+                        ->from(['ArticlesAlias' => $articles->getTable()])
+                        ->where(['ArticlesAlias.author_id' => 1]),
+                    [],
+                    'NOT IN'
+                ),
+            ])
+            ->orderAsc('Articles.id')
+            ->disableHydration()
+            ->toArray();
+    }
+
+    public function testInWithMultiResultSubquery(): void
+    {
+        $articles = $this->getTableLocator()->get('Articles');
+
+        $query = $articles
+            ->find()
+            ->select(['Articles.id', 'Articles.author_id'])
+            ->where([
+                new TupleComparison(
+                    ['Articles.id', 'Articles.author_id'],
+                    $articles
+                        ->subquery()
+                        ->select(['ArticlesAlias.id', 'ArticlesAlias.author_id'])
+                        ->from(['ArticlesAlias' => $articles->getTable()])
+                        ->where(['ArticlesAlias.author_id' => 1]),
+                    [],
+                    'IN'
+                ),
+            ])
+            ->orderAsc('Articles.id')
+            ->disableHydration();
+
+        $expected = [
+            [
+                'id' => 1,
+                'author_id' => 1,
+            ],
+            [
+                'id' => 3,
+                'author_id' => 1,
+            ],
+        ];
+        $this->assertSame($expected, $query->toArray());
+    }
+
+    public function testInWithSingleResultSubquery(): void
+    {
+        $articles = $this->getTableLocator()->get('Articles');
+
+        $query = $articles
+            ->find()
+            ->select(['Articles.id', 'Articles.author_id'])
+            ->where([
+                new TupleComparison(
+                    ['Articles.id', 'Articles.author_id'],
+                    $articles
+                        ->subquery()
+                        ->select(['ArticlesAlias.id', 'ArticlesAlias.author_id'])
+                        ->from(['ArticlesAlias' => $articles->getTable()])
+                        ->where(['ArticlesAlias.id' => 1]),
+                    [],
+                    'IN'
+                ),
+            ])
+            ->disableHydration();
+
+        $expected = [
+            [
+                'id' => 1,
+                'author_id' => 1,
+            ],
+        ];
+        $this->assertSame($expected, $query->toArray());
+    }
+
+    public function testInWithMultiArrayValues(): void
+    {
+        $articles = $this->getTableLocator()->get('Articles');
+
+        $query = $articles
+            ->find()
+            ->select(['Articles.id', 'Articles.author_id'])
+            ->where([
+                new TupleComparison(
+                    ['Articles.id', 'Articles.author_id'],
+                    [[1, 1], [3, 1]],
+                    ['integer', 'integer'],
+                    'IN'
+                ),
+            ])
+            ->orderAsc('Articles.id')
+            ->disableHydration();
+
+        $expected = [
+            [
+                'id' => 1,
+                'author_id' => 1,
+            ],
+            [
+                'id' => 3,
+                'author_id' => 1,
+            ],
+        ];
+        $this->assertSame($expected, $query->toArray());
+    }
+
+    public function testEqualWithMultiResultSubquery(): void
+    {
+        $articles = $this->getTableLocator()->get('Articles');
+
+        $driver = $articles->getConnection()->getDriver();
+        if (
+            $driver instanceof Mysql ||
+            $driver instanceof Postgres
+        ) {
+            $this->expectException(PDOException::class);
+            $this->expectExceptionMessageMatches('/cardinality violation/i');
+        } else {
+            // Due to the way tuple comparisons are being translated, the DBMS will
+            // not run into a cardinality violation scenario.
+            $this->markTestSkipped(
+                'Sqlite and Sqlserver currently do not fail with subqueries returning incompatible results.'
+            );
+        }
+
+        $articles
+            ->find()
+            ->select(['Articles.id', 'Articles.author_id'])
+            ->where([
+                new TupleComparison(
+                    ['Articles.id', 'Articles.author_id'],
+                    $articles
+                        ->subquery()
+                        ->select(['ArticlesAlias.id', 'ArticlesAlias.author_id'])
+                        ->from(['ArticlesAlias' => $articles->getTable()])
+                        ->where(['ArticlesAlias.author_id' => 1]),
+                    [],
+                    '='
+                ),
+            ])
+            ->orderAsc('Articles.id')
+            ->disableHydration()
+            ->toArray();
+    }
+
+    public function testEqualWithSingleResultSubquery(): void
+    {
+        $articles = $this->getTableLocator()->get('Articles');
+
+        $query = $articles
+            ->find()
+            ->select(['Articles.id', 'Articles.author_id'])
+            ->where([
+                new TupleComparison(
+                    ['Articles.id', 'Articles.author_id'],
+                    $articles
+                        ->subquery()
+                        ->select(['ArticlesAlias.id', 'ArticlesAlias.author_id'])
+                        ->from(['ArticlesAlias' => $articles->getTable()])
+                        ->where(['ArticlesAlias.id' => 1]),
+                    [],
+                    '='
+                ),
+            ])
+            ->disableHydration();
+
+        $expected = [
+            [
+                'id' => 1,
+                'author_id' => 1,
+            ],
+        ];
+        $this->assertSame($expected, $query->toArray());
+    }
+
+    public function testEqualWithSingleArrayValue(): void
+    {
+        $articles = $this->getTableLocator()->get('Articles');
+
+        $query = $articles
+            ->find()
+            ->select(['Articles.id', 'Articles.author_id'])
+            ->where([
+                new TupleComparison(
+                    ['Articles.id', 'Articles.author_id'],
+                    [1, 1],
+                    ['integer', 'integer'],
+                    '='
+                ),
+            ])
+            ->orderAsc('Articles.id')
+            ->disableHydration();
+
+        $expected = [
+            [
+                'id' => 1,
+                'author_id' => 1,
+            ],
+        ];
+        $this->assertSame($expected, $query->toArray());
+    }
+}