Browse Source

4.next - Adds basic support for common table expressions. (#14525)

Adds basic support for common table expressions.

refs #10666
Oliver Nowak 6 years ago
parent
commit
b1436bf69b

+ 17 - 0
src/Database/Driver.php

@@ -68,6 +68,13 @@ abstract class Driver implements DriverInterface
     protected $_autoQuoting = false;
 
     /**
+     * Whether or not the server supports common table expressions.
+     *
+     * @var bool|null
+     */
+    protected $supportsCTEs = null;
+
+    /**
      * Constructor
      *
      * @param array $config The configuration for the driver.
@@ -257,6 +264,16 @@ abstract class Driver implements DriverInterface
     }
 
     /**
+     * Returns true if the server supports common table expressions.
+     *
+     * @return bool
+     */
+    public function supportsCTEs(): bool
+    {
+        return $this->supportsCTEs === true;
+    }
+
+    /**
      * {@inheritDoc}
      *
      * @param mixed $value The value to quote.

+ 22 - 0
src/Database/Driver/Mysql.php

@@ -246,6 +246,28 @@ class Mysql extends Driver
     }
 
     /**
+     * Returns true if the server supports common table expressions.
+     *
+     * @return bool
+     */
+    public function supportsCTEs(): bool
+    {
+        if ($this->supportsCTEs === null) {
+            $version = $this->getVersion();
+            if (strpos($version, 'MariaDB') !== false) {
+                preg_match('/(\d+\.\d+.\d+)-MariaDB/i', $version, $matches);
+                $version = $matches[1];
+
+                $this->supportsCTEs = version_compare($version, '10.2.2', '>=');
+            } else {
+                $this->supportsCTEs = version_compare($version, '8.0.0', '>=');
+            }
+        }
+
+        return $this->supportsCTEs;
+    }
+
+    /**
      * Returns true if the server supports native JSON columns
      *
      * @return bool

+ 5 - 0
src/Database/Driver/Postgres.php

@@ -78,6 +78,11 @@ class Postgres extends Driver
     protected $_endQuote = '"';
 
     /**
+     * @inheritDoc
+     */
+    protected $supportsCTEs = true;
+
+    /**
      * Establishes a connection to the database server
      *
      * @return bool true on success

+ 14 - 0
src/Database/Driver/Sqlite.php

@@ -323,6 +323,20 @@ class Sqlite extends Driver
     }
 
     /**
+     * Returns true if the server supports common table expressions.
+     *
+     * @return bool
+     */
+    public function supportsCTEs(): bool
+    {
+        if ($this->supportsCTEs === null) {
+            $this->supportsCTEs = version_compare($this->getVersion(), '3.8.3', '>=');
+        }
+
+        return $this->supportsCTEs;
+    }
+
+    /**
      * Returns true if the connected server supports window functions.
      *
      * @return bool

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

@@ -92,6 +92,11 @@ class Sqlserver extends Driver
     protected $_endQuote = ']';
 
     /**
+     * @inheritDoc
+     */
+    protected $supportsCTEs = true;
+
+    /**
      * Establishes a connection to the database server.
      *
      * Please note that the PDO::ATTR_PERSISTENT attribute is not supported by

+ 311 - 0
src/Database/Expression/CommonTableExpression.php

@@ -0,0 +1,311 @@
+<?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.1.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Database\Expression;
+
+use Cake\Database\Exception as DatabaseException;
+use Cake\Database\ExpressionInterface;
+use Cake\Database\ValueBinder;
+use Closure;
+use InvalidArgumentException;
+
+/**
+ * An expression that represents a common table expression definition.
+ */
+class CommonTableExpression implements ExpressionInterface
+{
+    /**
+     * The CTE name.
+     *
+     * @var string|null
+     */
+    protected $name;
+
+    /**
+     * The field names to use for the CTE.
+     *
+     * @var \Cake\Database\ExpressionInterface[]|string[]
+     */
+    protected $fields = [];
+
+    /**
+     * The modifiers to use for the CTE.
+     *
+     * @var \Cake\Database\ExpressionInterface[]|string[]
+     */
+    protected $modifiers = [];
+
+    /**
+     * The CTE query definition.
+     *
+     * @var \Cake\Database\ExpressionInterface|null
+     */
+    protected $query;
+
+    /**
+     * Whether the CTE operates recursively.
+     *
+     * @var bool
+     */
+    protected $recursive = false;
+
+    /**
+     * Constructor.
+     *
+     * @param string $name The CTE name.
+     * @param \Cake\Database\ExpressionInterface $query The CTE query definition.
+     */
+    public function __construct(?string $name = null, ?ExpressionInterface $query = null)
+    {
+        $this->name = $name;
+        $this->query = $query;
+    }
+
+    /**
+     * Returns the CTE name.
+     *
+     * @return string|null
+     */
+    public function getName(): ?string
+    {
+        return $this->name;
+    }
+
+    /**
+     * Sets the CTE name.
+     *
+     * @param string $name The CTE name.
+     * @return $this
+     */
+    public function setName(string $name)
+    {
+        $this->name = $name;
+
+        return $this;
+    }
+
+    /**
+     * Returns the field names to use for the CTE.
+     *
+     * @return \Cake\Database\ExpressionInterface[]|string[]
+     */
+    public function getFields(): array
+    {
+        return $this->fields;
+    }
+
+    /**
+     * Sets the field names to use for the CTE.
+     *
+     * @param \Cake\Database\ExpressionInterface[]|string[] $fields The field names to use for the CTE.
+     * @return $this
+     * @throws \InvalidArgumentException When one or more fields are of an invalid type.
+     */
+    public function setFields(array $fields)
+    {
+        foreach ($fields as $index => $field) {
+            if (is_string($field)) {
+                $fields[$index] = $field = new IdentifierExpression($field);
+            }
+
+            if (!($field instanceof ExpressionInterface)) {
+                throw new InvalidArgumentException(sprintf(
+                    'The `$fields` argument must contain only instances of `%s`, or strings, `%s` given at index `%d`.',
+                    ExpressionInterface::class,
+                    getTypeName($field),
+                    $index
+                ));
+            }
+        }
+
+        $this->fields = $fields;
+
+        return $this;
+    }
+
+    /**
+     * Returns the modifiers to use for the CTE.
+     *
+     * @return \Cake\Database\ExpressionInterface[]|string[]
+     */
+    public function getModifiers(): array
+    {
+        return $this->modifiers;
+    }
+
+    /**
+     * Sets the modifiers to use for the CTE.
+     *
+     * @param \Cake\Database\ExpressionInterface[]|string[] $modifiers The modifiers to use for the CTE.
+     * @return $this
+     * @throws \InvalidArgumentException When one or more modifiers are of an invalid type.
+     */
+    public function setModifiers(array $modifiers)
+    {
+        foreach ($modifiers as $index => $modifier) {
+            if (
+                !($modifier instanceof ExpressionInterface) &&
+                !is_string($modifier)
+            ) {
+                throw new InvalidArgumentException(sprintf(
+                    'The `$modifiers` argument must contain only instances of `%s`, or strings, ' .
+                        '`%s` given at index `%d`.',
+                    ExpressionInterface::class,
+                    getTypeName($modifier),
+                    $index
+                ));
+            }
+        }
+
+        $this->modifiers = $modifiers;
+
+        return $this;
+    }
+
+    /**
+     * Returns the CTE query definition.
+     *
+     * @return \Cake\Database\ExpressionInterface|null
+     */
+    public function getQuery(): ?ExpressionInterface
+    {
+        return $this->query;
+    }
+
+    /**
+     * Sets the CTE query definition.
+     *
+     * @param \Cake\Database\ExpressionInterface $query The CTE query definition.
+     * @return $this
+     */
+    public function setQuery(ExpressionInterface $query)
+    {
+        $this->query = $query;
+
+        return $this;
+    }
+
+    /**
+     * Returns whether the CTE operates recursively.
+     *
+     * @return bool
+     */
+    public function isRecursive(): bool
+    {
+        return $this->recursive;
+    }
+
+    /**
+     * Sets whether the CTE operates recursively.
+     *
+     * @param bool $recursive Indicates whether the CTE query operates recursively.
+     * @return $this
+     */
+    public function setRecursive(bool $recursive)
+    {
+        $this->recursive = $recursive;
+
+        return $this;
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * @throws \Cake\Database\Exception When not name has been set.
+     * @throws \Cake\Database\Exception When not query has been set.
+     */
+    public function sql(ValueBinder $generator): string
+    {
+        if (empty($this->name)) {
+            throw new DatabaseException(
+                'Cannot generate SQL for common table expressions that have no name.'
+            );
+        }
+
+        if (empty($this->query)) {
+            throw new DatabaseException(
+                'Cannot generate SQL for common table expressions that have no query.'
+            );
+        }
+
+        $fields = '';
+        if (!empty($this->fields)) {
+            $fields = [];
+            foreach ($this->fields as $field) {
+                if ($field instanceof ExpressionInterface) {
+                    $field = $field->sql($generator);
+                }
+                $fields[] = $field;
+            }
+
+            $fields = sprintf('(%s)', implode(', ', $fields));
+        }
+
+        $modifiers = '';
+        if (!empty($this->modifiers)) {
+            $modifiers = [];
+            foreach ($this->modifiers as $modifier) {
+                if ($modifier instanceof ExpressionInterface) {
+                    $modifier = $modifier->sql($generator);
+                }
+                $modifiers[] = $modifier;
+            }
+
+            $modifiers = ' ' . implode(' ', $modifiers);
+        }
+
+        return sprintf('%s%s AS%s (%s)', $this->name, $fields, $modifiers, $this->query->sql($generator));
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function traverse(Closure $visitor)
+    {
+        foreach (array_merge($this->fields, $this->modifiers, [$this->query]) as $part) {
+            if ($part instanceof ExpressionInterface) {
+                $visitor($part);
+                $part->traverse($visitor);
+            }
+        }
+
+        return $this;
+    }
+
+    /**
+     * Clones the inner expression objects.
+     *
+     * @return void
+     */
+    public function __clone()
+    {
+        if ($this->query instanceof ExpressionInterface) {
+            $this->query = clone $this->query;
+        }
+
+        foreach ($this->fields as $key => $field) {
+            if ($this->fields[$key] instanceof ExpressionInterface) {
+                $this->fields[$key] = clone $this->fields[$key];
+            }
+        }
+
+        foreach ($this->modifiers as $key => $modifier) {
+            if ($this->modifiers[$key] instanceof ExpressionInterface) {
+                $this->modifiers[$key] = clone $this->modifiers[$key];
+            }
+        }
+    }
+}

+ 115 - 4
src/Database/Query.php

@@ -16,6 +16,7 @@ declare(strict_types=1);
  */
 namespace Cake\Database;
 
+use Cake\Database\Expression\CommonTableExpression;
 use Cake\Database\Expression\IdentifierExpression;
 use Cake\Database\Expression\OrderByExpression;
 use Cake\Database\Expression\OrderClauseExpression;
@@ -78,6 +79,7 @@ class Query implements ExpressionInterface, IteratorAggregate
         'set' => [],
         'insert' => [],
         'values' => [],
+        'with' => [],
         'select' => [],
         'distinct' => false,
         'modifier' => [],
@@ -100,7 +102,7 @@ class Query implements ExpressionInterface, IteratorAggregate
      * @var string[]
      */
     protected $_selectParts = [
-        'select', 'from', 'join', 'where', 'group', 'having', 'order', 'limit',
+        'with', 'select', 'from', 'join', 'where', 'group', 'having', 'order', 'limit',
         'offset', 'union', 'epilog',
     ];
 
@@ -109,21 +111,21 @@ class Query implements ExpressionInterface, IteratorAggregate
      *
      * @var string[]
      */
-    protected $_updateParts = ['update', 'set', 'where', 'epilog'];
+    protected $_updateParts = ['with', 'update', 'set', 'where', 'epilog'];
 
     /**
      * The list of query clauses to traverse for generating a DELETE statement
      *
      * @var string[]
      */
-    protected $_deleteParts = ['delete', 'modifier', 'from', 'where', 'epilog'];
+    protected $_deleteParts = ['with', 'delete', 'modifier', 'from', 'where', 'epilog'];
 
     /**
      * The list of query clauses to traverse for generating an INSERT statement
      *
      * @var string[]
      */
-    protected $_insertParts = ['insert', 'values', 'epilog'];
+    protected $_insertParts = ['with', 'insert', 'values', 'epilog'];
 
     /**
      * Indicates whether internal state of this query was changed, this is used to
@@ -372,6 +374,115 @@ class Query implements ExpressionInterface, IteratorAggregate
     }
 
     /**
+     * Adds a new common table expression (CTE) to the query.
+     *
+     * ### Examples:
+     *
+     * Common table expressions can either be passed as preconstructed expression
+     * objects:
+     *
+     * ```
+     * $cte = new \Cake\Database\Expression\CommonTableExpression(
+     *     'cte',
+     *     $connection
+     *         ->newQuery()
+     *         ->select('*')
+     *         ->from('articles')
+     * );
+     *
+     * $query->with($cte);
+     * ```
+     *
+     * or returned from a closure, which will receive a new common table expression
+     * object as the first argument, and a reference to the current query object as
+     * the second argument:
+     *
+     * ```
+     * $query->with(function (
+     *     \Cake\Database\Expression\CommonTableExpression $cte,
+     *     \Cake\Database\Query $query
+     *  ) {
+     *     $cteQuery = $query->getConnection()
+     *         ->newQuery()
+     *         ->select('*')
+     *         ->from('articles');
+     *
+     *     return $cte
+     *         ->setName('cte')
+     *         ->setQuery($cteQuery);
+     * });
+     * ```
+     *
+     * The list of expressions can be reset by overwriting and passing `null` for the
+     * expression:
+     *
+     * ```
+     * $query->with(null, true);
+     * ```
+     *
+     * @param \Cake\Database\Expression\CommonTableExpression|\Closure|null $expression The CTE to add.
+     * @param bool $overwrite Whether to reset the list of CTEs.
+     * @return $this
+     * @throws \InvalidArgumentException When passing `null` for the `$expression` argument but not enabling
+     *  `$overwrite`.
+     * @throws \InvalidArgumentException When an invalid type is passed or returned for the `$expression` argument.
+     * @throws \InvalidArgumentException When the given CTE object has no name set.
+     * @throws \InvalidArgumentException When the given CTE object has no query set.
+     * @throws \InvalidArgumentException When a CTE object with the same name is already attached to this query.
+     */
+    public function with($expression, $overwrite = false)
+    {
+        if ($overwrite) {
+            $this->_parts['with'] = [];
+        }
+
+        if ($expression === null) {
+            if (!$overwrite) {
+                throw new \InvalidArgumentException(
+                    'Resetting the WITH clause only works when overwriting is enabled.'
+                );
+            }
+
+            return $this;
+        }
+
+        if ($expression instanceof Closure) {
+            $expression = $expression(new CommonTableExpression(), $this);
+        }
+
+        if (!($expression instanceof CommonTableExpression)) {
+            throw new InvalidArgumentException(sprintf(
+                'The common table expression must be an instance of `%s`, `%s` given.',
+                CommonTableExpression::class,
+                getTypeName($expression)
+            ));
+        }
+
+        $name = $expression->getName();
+        if (empty($name)) {
+            throw new InvalidArgumentException('The common table expression must have a name.');
+        }
+
+        if (empty($expression->getQuery())) {
+            throw new InvalidArgumentException('The common table expression must have a query.');
+        }
+
+        foreach ($this->_parts['with'] as $existing) {
+            /** @var \Cake\Database\Expression\CommonTableExpression $existing */
+            if ($existing->getName() === $name) {
+                throw new InvalidArgumentException(sprintf(
+                    'A common table expression with the name `%s` is already attached to this query.',
+                    $name
+                ));
+            }
+        }
+
+        $this->_parts['with'][] = $expression;
+
+        return $this;
+    }
+
+    /**
      * Adds new fields to be returned by a `SELECT` statement when this query is
      * executed. Fields can be passed as an array of strings, array of expression
      * objects, a single expression or a single string.

+ 35 - 5
src/Database/QueryCompiler.php

@@ -51,8 +51,8 @@ class QueryCompiler
      * @var array
      */
     protected $_selectParts = [
-        'select', 'from', 'join', 'where', 'group', 'having', 'window', 'order', 'limit',
-        'offset', 'union', 'epilog',
+        'with', 'select', 'from', 'join', 'where', 'group', 'having', 'window', 'order',
+        'limit', 'offset', 'union', 'epilog',
     ];
 
     /**
@@ -60,21 +60,21 @@ class QueryCompiler
      *
      * @var array
      */
-    protected $_updateParts = ['update', 'set', 'where', 'epilog'];
+    protected $_updateParts = ['with', 'update', 'set', 'where', 'epilog'];
 
     /**
      * The list of query clauses to traverse for generating a DELETE statement
      *
      * @var array
      */
-    protected $_deleteParts = ['delete', 'modifier', 'from', 'where', 'epilog'];
+    protected $_deleteParts = ['with', 'delete', 'modifier', 'from', 'where', 'epilog'];
 
     /**
      * The list of query clauses to traverse for generating an INSERT statement
      *
      * @var array
      */
-    protected $_insertParts = ['insert', 'values', 'epilog'];
+    protected $_insertParts = ['with', 'insert', 'values', 'epilog'];
 
     /**
      * Indicate whether or not this query dialect supports ordered unions.
@@ -158,6 +158,36 @@ class QueryCompiler
     }
 
     /**
+     * Helper function used to build the string representation of a `WITH` clause,
+     * it constructs the CTE definitions list and generates the `RECURSIVE`
+     * keyword when required.
+     *
+     * @param \Cake\Database\Expression\CommonTableExpression[] $parts List of CTEs to be transformed to string
+     * @param \Cake\Database\Query $query The query that is being compiled
+     * @param \Cake\Database\ValueBinder $generator The placeholder generator to be used in expressions
+     * @return string
+     */
+    protected function _buildWithPart(array $parts, Query $query, ValueBinder $generator): string
+    {
+        $hasRecursiveExpressions = false;
+
+        $expressions = [];
+        foreach ($parts as $expression) {
+            if ($expression->isRecursive()) {
+                $hasRecursiveExpressions = true;
+            }
+            $expressions[] = $expression->sql($generator);
+        }
+
+        $keywords = '';
+        if ($hasRecursiveExpressions) {
+            $keywords = 'RECURSIVE ';
+        }
+
+        return sprintf('WITH %s%s ', $keywords, implode(', ', $expressions));
+    }
+
+    /**
      * Helper function used to build the string representation of a SELECT clause,
      * it constructs the field list taking care of aliasing and
      * converting expression objects to string. This function also constructs the

+ 22 - 2
src/Database/SqlserverCompiler.php

@@ -49,11 +49,31 @@ class SqlserverCompiler extends QueryCompiler
      * @inheritDoc
      */
     protected $_selectParts = [
-        'select', 'from', 'join', 'where', 'group', 'having', 'window', 'order', 'offset',
-        'limit', 'union', 'epilog',
+        'with', 'select', 'from', 'join', 'where', 'group', 'having', 'window', 'order',
+        'offset', 'limit', 'union', 'epilog',
     ];
 
     /**
+     * Helper function used to build the string representation of a `WITH` clause,
+     * it constructs the CTE definitions list without generating the `RECURSIVE`
+     * keyword that is neither required nor valid.
+     *
+     * @param \Cake\Database\Expression\CommonTableExpression[] $parts List of CTEs to be transformed to string
+     * @param \Cake\Database\Query $query The query that is being compiled
+     * @param \Cake\Database\ValueBinder $generator The placeholder generator to be used in expressions
+     * @return string
+     */
+    protected function _buildWithPart(array $parts, Query $query, ValueBinder $generator): string
+    {
+        $expressions = [];
+        foreach ($parts as $expression) {
+            $expressions[] = $expression->sql($generator);
+        }
+
+        return sprintf('WITH %s ', implode(', ', $expressions));
+    }
+
+    /**
      * Generates the INSERT part of a SQL query
      *
      * To better handle concurrency and low transaction isolation levels,

File diff suppressed because it is too large
+ 1027 - 0
tests/TestCase/Database/CommonTableExpressionIntegrationTest.php


+ 296 - 0
tests/TestCase/Database/Expression/CommonTableExpressionTest.php

@@ -0,0 +1,296 @@
+<?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.1.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Database\Expression;
+
+use Cake\Database\Exception as DatabaseException;
+use Cake\Database\Expression\CommonTableExpression;
+use Cake\Database\Expression\IdentifierExpression;
+use Cake\Database\Expression\QueryExpression;
+use Cake\Database\Query;
+use Cake\Database\ValueBinder;
+use Cake\Datasource\ConnectionManager;
+use Cake\TestSuite\TestCase;
+use InvalidArgumentException;
+
+class CommonTableExpressionTest extends TestCase
+{
+    /**
+     * @var \Cake\Database\Connection
+     */
+    protected $connection;
+
+    public function setUp(): void
+    {
+        parent::setUp();
+        $this->connection = ConnectionManager::get('test');
+    }
+
+    public function tearDown(): void
+    {
+        parent::tearDown();
+        unset($this->connection);
+    }
+
+    public function testConstructWithNoArguments()
+    {
+        $expression = new CommonTableExpression();
+
+        $this->assertNull($expression->getName());
+        $this->assertEmpty($expression->getFields());
+        $this->assertEmpty($expression->getModifiers());
+        $this->assertNull($expression->getQuery());
+    }
+
+    public function testGetSetName(): void
+    {
+        $expression = new CommonTableExpression('cte', $this->connection->newQuery()->select(1));
+        $this->assertEquals('cte', $expression->getName());
+
+        $expression->setName('other');
+        $this->assertEquals('other', $expression->getName());
+    }
+
+    public function testGetSetFields(): void
+    {
+        $expression = new CommonTableExpression('cte', $this->connection->newQuery()->select(1));
+        $this->assertEmpty($expression->getFields());
+
+        $expression->setFields(['col1', 'col2']);
+        $this->assertEquals(
+            [new IdentifierExpression('col1'), new IdentifierExpression('col2')],
+            $expression->getFields()
+        );
+    }
+
+    public function testSetFieldsWithInvalidType(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage(
+            'The `$fields` argument must contain only instances of `Cake\Database\ExpressionInterface`, ' .
+            'or strings, `integer` given at index `1`.'
+        );
+
+        $expression = new CommonTableExpression('cte', $this->connection->newQuery()->select(1));
+        $expression->setFields(['col1', 123]);
+    }
+
+    public function testGetSetModifiers(): void
+    {
+        $expression = new CommonTableExpression('cte', $this->connection->newQuery()->select(1));
+        $this->assertEmpty($expression->getModifiers());
+
+        $expression->setModifiers(['FOO', 'BAR']);
+        $this->assertEquals(['FOO', 'BAR'], $expression->getModifiers());
+    }
+
+    public function testModifiersFieldsWithInvalidType(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage(
+            'The `$modifiers` argument must contain only instances of `Cake\Database\ExpressionInterface`, ' .
+            'or strings, `integer` given at index `1`.'
+        );
+
+        $expression = new CommonTableExpression('cte', $this->connection->newQuery()->select(1));
+        $expression->setModifiers(['FOO', 123]);
+    }
+
+    public function testGetSetQuery(): void
+    {
+        $connection = ConnectionManager::get('test');
+
+        $query = $this->connection->newQuery()->select(1);
+        $expression = new CommonTableExpression('cte', $query);
+        $this->assertSame($query, $expression->getQuery());
+
+        $query = $connection->newQuery()->select([1, 2]);
+        $expression->setQuery($query);
+        $this->assertSame($query, $expression->getQuery());
+    }
+
+    public function testGetSetRecursive(): void
+    {
+        $expression = new CommonTableExpression('cte', $this->connection->newQuery()->select(1));
+        $this->assertFalse($expression->isRecursive());
+
+        $expression->setRecursive(true);
+        $this->assertTrue($expression->isRecursive());
+    }
+
+    public function testSqlWithNoName()
+    {
+        $this->expectException(DatabaseException::class);
+        $this->expectExceptionMessage('Cannot generate SQL for common table expressions that have no name.');
+
+        $expression = new CommonTableExpression();
+        $expression->sql(new ValueBinder());
+    }
+
+    public function testSqlWithNoQuery()
+    {
+        $this->expectException(DatabaseException::class);
+        $this->expectExceptionMessage('Cannot generate SQL for common table expressions that have no query.');
+
+        $expression = new CommonTableExpression('cte');
+        $expression->sql(new ValueBinder());
+    }
+
+    public function testSqlWithQueryAsExpression(): void
+    {
+        $expression = new CommonTableExpression('cte', $this->connection->newQuery()->select(1));
+
+        $this->assertEqualsSql(
+            'cte AS (SELECT 1)',
+            $expression->sql(new ValueBinder())
+        );
+    }
+
+    public function testSqlWithQueryAsCustomExpression(): void
+    {
+        $expression = new CommonTableExpression('cte', new QueryExpression('SELECT 1'));
+
+        $this->assertEqualsSql(
+            'cte AS (SELECT 1)',
+            $expression->sql(new ValueBinder())
+        );
+    }
+
+    public function testSqlWithFieldsAsStrings(): void
+    {
+        $expression = (new CommonTableExpression('cte', $this->connection->newQuery()->select([1, 2])))
+            ->setFields(['col1', 'col2']);
+
+        $this->assertEquals(
+            'cte(col1, col2) AS (SELECT 1, 2)',
+            $expression->sql(new ValueBinder())
+        );
+    }
+
+    public function testSqlWithFieldsAsExpressions(): void
+    {
+        $expression = (new CommonTableExpression('cte', $this->connection->newQuery()->select([1, 2])))
+            ->setFields([
+                new IdentifierExpression('col1'),
+                new IdentifierExpression('col2'),
+            ]);
+
+        $this->assertEquals(
+            'cte(col1, col2) AS (SELECT 1, 2)',
+            $expression->sql(new ValueBinder())
+        );
+    }
+
+    public function testSqlWithModifiersAsStrings(): void
+    {
+        $expression = (new CommonTableExpression('cte', $this->connection->newQuery()->select(1)))
+            ->setModifiers(['NOT MATERIALIZED']);
+
+        $this->assertEquals(
+            'cte AS NOT MATERIALIZED (SELECT 1)',
+            $expression->sql(new ValueBinder())
+        );
+    }
+
+    public function testSqlWithModifiersAsExpressions(): void
+    {
+        $expression = (new CommonTableExpression('cte', $this->connection->newQuery()->select(1)))
+            ->setModifiers([new QueryExpression('NOT MATERIALIZED')]);
+
+        $this->assertEquals(
+            'cte AS NOT MATERIALIZED (SELECT 1)',
+            $expression->sql(new ValueBinder())
+        );
+    }
+
+    public function testTraverse(): void
+    {
+        $query = new QueryExpression('SELECT 1');
+        $identifier = new IdentifierExpression('col');
+        $modifier = new QueryExpression('NOT MATERIALIZED');
+        $modifierWrapper = new QueryExpression($modifier);
+
+        $expression = (new CommonTableExpression('cte', $query))
+            ->setFields([$identifier])
+            ->setModifiers([$modifierWrapper]);
+
+        $expressions = [];
+        $expression->traverse(function ($expression) use (&$expressions) {
+            $expressions[] = $expression;
+        });
+
+        $this->assertSame(
+            [$identifier, $modifierWrapper, $modifier, $query],
+            $expressions
+        );
+    }
+
+    public function testClone(): void
+    {
+        $connection = ConnectionManager::get('test');
+
+        $query = $connection->newQuery()->select(1);
+        $fieldExpression = new IdentifierExpression('col2');
+        $modifierExpression = new QueryExpression('BAR');
+
+        $expression = (new CommonTableExpression('cte', $query))
+            ->setFields([
+                'col1',
+                $fieldExpression,
+            ])
+            ->setModifiers([
+                'FOO',
+                $modifierExpression,
+            ])
+            ->setRecursive(true);
+
+        $clone = clone $expression;
+
+        $this->assertInstanceOf(CommonTableExpression::class, $clone);
+        $this->assertNotSame($clone, $expression);
+
+        $this->assertEquals('cte', $clone->getName());
+
+        $this->assertCount(2, $clone->getFields());
+        $this->assertInstanceOf(IdentifierExpression::class, $clone->getFields()[0]);
+        $this->assertEquals('col1', $clone->getFields()[0]->getIdentifier());
+        $this->assertInstanceOf(IdentifierExpression::class, $clone->getFields()[1]);
+        $this->assertNotSame($fieldExpression, $clone->getFields()[1]);
+        $this->assertEquals('col2', $clone->getFields()[1]->getIdentifier());
+
+        $this->assertCount(2, $clone->getModifiers());
+        $this->assertEquals('FOO', $clone->getModifiers()[0]);
+        $this->assertInstanceOf(QueryExpression::class, $clone->getModifiers()[1]);
+        $this->assertNotSame($fieldExpression, $clone->getModifiers()[1]);
+        $this->assertEquals('BAR', $clone->getModifiers()[1]->sql(new ValueBinder()));
+
+        $this->assertInstanceOf(Query::class, $clone->getQuery());
+        $this->assertNotSame($query, $clone->getQuery());
+        $this->assertEquals('SELECT 1', $clone->getQuery()->sql(new ValueBinder()));
+    }
+
+    public function testCloneEmpty(): void
+    {
+        $expression = new CommonTableExpression();
+        $clone = clone $expression;
+
+        $this->assertNotSame($expression, $clone);
+        $this->assertEquals($expression->getName(), $clone->getName());
+        $this->assertEquals($expression->getFields(), $clone->getFields());
+        $this->assertEquals($expression->getModifiers(), $clone->getModifiers());
+        $this->assertEquals($expression->getQuery(), $clone->getQuery());
+    }
+}

+ 73 - 1
tests/TestCase/ORM/QueryTest.php

@@ -17,7 +17,9 @@ declare(strict_types=1);
 namespace Cake\Test\TestCase\ORM;
 
 use Cake\Collection\Collection;
+use Cake\Database\Driver\Mysql;
 use Cake\Database\Driver\Sqlite;
+use Cake\Database\Expression\CommonTableExpression;
 use Cake\Database\Expression\IdentifierExpression;
 use Cake\Database\Expression\OrderByExpression;
 use Cake\Database\Expression\QueryExpression;
@@ -1743,7 +1745,7 @@ class QueryTest extends TestCase
      */
     public function testUpdateWithTableExpression()
     {
-        $this->skipIf(!$this->connection->getDriver() instanceof \Cake\Database\Driver\Mysql);
+        $this->skipIf(!$this->connection->getDriver() instanceof Mysql);
         $table = $this->getTableLocator()->get('articles');
 
         $query = $table->query();
@@ -3886,4 +3888,74 @@ class QueryTest extends TestCase
 
         $this->assertEquals($expected, $results);
     }
+
+    public function testWith(): void
+    {
+        $this->skipIf(
+            !$this->connection->getDriver()->supportsCTEs(),
+            'The current driver does not support common table expressions.'
+        );
+        $this->skipIf(
+            (
+                $this->connection->getDriver() instanceof Mysql ||
+                $this->connection->getDriver() instanceof Sqlite
+            ) &&
+            !$this->connection->getDriver()->supportsWindowFunctions(),
+            'The current driver does not support window functions.'
+        );
+
+        $this->loadFixtures('Articles');
+
+        $table = $this->getTableLocator()->get('Articles');
+
+        $cteQuery = $table
+            ->find()
+            ->select(function (Query $query) use ($table) {
+                $columns = $table->getSchema()->columns();
+
+                return array_combine($columns, $columns) + [
+                        'row_num' => $query
+                            ->func()
+                            ->rowNumber()
+                            ->over()
+                            ->partition('author_id')
+                            ->order(['id' => 'ASC']),
+                    ];
+            });
+
+        $query = $table
+            ->find()
+            ->with(function (CommonTableExpression $cte) use ($cteQuery) {
+                return $cte
+                    ->setName('cte')
+                    ->setQuery($cteQuery);
+            })
+            ->select(['row_num'])
+            ->enableAutoFields()
+            ->from([$table->getAlias() => 'cte'])
+            ->where(['row_num' => 1], ['row_num' => 'integer'])
+            ->order(['id' => 'ASC'])
+            ->disableHydration();
+
+        $expected = [
+            [
+                'id' => 1,
+                'author_id' => 1,
+                'title' => 'First Article',
+                'body' => 'First Article Body',
+                'published' => 'Y',
+                'row_num' => '1',
+            ],
+            [
+                'id' => 2,
+                'author_id' => 3,
+                'title' => 'Second Article',
+                'body' => 'Second Article Body',
+                'published' => 'Y',
+                'row_num' => '1',
+            ],
+        ];
+
+        $this->assertEquals($expected, $query->toArray());
+    }
 }