Browse Source

Merge pull request #14192 from cakephp/4.1-window-expression

4.1 - Implemented partition() and order() for WindowExpression
othercorey 6 years ago
parent
commit
5909305f39

+ 136 - 2
src/Database/Expression/WindowExpression.php

@@ -23,14 +23,138 @@ use Closure;
 /**
  * This represents a SQL window expression used by aggregate and window functions.
  */
-class WindowExpression implements ExpressionInterface
+class WindowExpression implements ExpressionInterface, WindowInterface
 {
     /**
+     * @var \Cake\Database\ExpressionInterface[]
+     */
+    protected $partitions = [];
+
+    /**
+     * @var \Cake\Database\Expression\OrderByExpression|null
+     */
+    protected $order;
+
+    /**
+     * @inheritDoc
+     */
+    public function partition($partitions)
+    {
+        if (!$partitions) {
+            return $this;
+        }
+
+        if (!is_array($partitions)) {
+            $partitions = [$partitions];
+        }
+
+        foreach ($partitions as &$partition) {
+            if (is_string($partition)) {
+                $partition = new IdentifierExpression($partition);
+            }
+        }
+
+        /** @psalm-suppress InvalidPropertyAssignmentValue */
+        $this->partitions = array_merge($this->partitions, $partitions);
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function order($fields)
+    {
+        if (!$fields) {
+            return $this;
+        }
+
+        if ($this->order === null) {
+            $this->order = new OrderByExpression();
+        }
+
+        $this->order->add($fields);
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function range(?int $start, ?int $end = 0)
+    {
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function rows(?int $start, ?int $end = 0)
+    {
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function groups(?int $start, ?int $end = 0)
+    {
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function excludeCurrent()
+    {
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function excludeGroup()
+    {
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function excludeTies()
+    {
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function excludeNoOthers()
+    {
+        return $this;
+    }
+
+    /**
      * @inheritDoc
      */
     public function sql(ValueBinder $generator): string
     {
-        return 'OVER ()';
+        $partitionSql = '';
+        if ($this->partitions) {
+            $expressions = [];
+            foreach ($this->partitions as $partition) {
+                $expressions[] = $partition->sql($generator);
+            }
+
+            $partitionSql = 'PARTITION BY ' . implode(', ', $expressions);
+        }
+
+        return sprintf(
+            'OVER (%s%s%s)',
+            $partitionSql,
+            $partitionSql && $this->order ? ' ' : '',
+            $this->order ? $this->order->sql($generator) : ''
+        );
     }
 
     /**
@@ -38,6 +162,16 @@ class WindowExpression implements ExpressionInterface
      */
     public function traverse(Closure $visitor)
     {
+        foreach ($this->partitions as $partition) {
+            $visitor($partition);
+            $partition->traverse($visitor);
+        }
+
+        if ($this->order) {
+            $visitor($this->order);
+            $this->order->traverse($visitor);
+        }
+
         return $this;
     }
 }

+ 48 - 72
src/Database/Expression/WindowInterface.php

@@ -22,46 +22,14 @@ namespace Cake\Database\Expression;
 interface WindowInterface
 {
     /**
-     * 'CURRENT ROW' frame start, end or exclusion
-     *
-     * @var int
-     */
-    public const CURRENT_ROW = 0;
-
-    /**
-     * 'UNBOUNDED PRECEDING' and '(offset) PRECEDING' frame start or end
-     *
-     * @var int
-     */
-    public const PRECEDING = 1;
-
-    /**
-     * 'UNBOUNDED FOLLOWING' and '(offset) FOLLOWING' frame start or end
-     *
-     * @var int
-     */
-    public const FOLLOWING = 2;
-
-    /**
-     * 'GROUP' frame exclusion
-     *
-     * @var int
-     */
-    public const GROUP = 1;
-
-    /**
-     * 'TIES' frame exclusion
-     *
      * @var int
      */
-    public const TIES = 2;
+    public const PRECEDING = 0;
 
     /**
-     * 'NO OTHERS' frame exclusion
-     *
      * @var int
      */
-    public const NO_OTHERS = 3;
+    public const FOLLOWING = 1;
 
     /**
      * Adds one or more partition expressions to the window.
@@ -80,69 +48,77 @@ interface WindowInterface
     public function order($fields);
 
     /**
-     * Adds a range frame clause to the window. Only one frame clause can be
-     * specified per window.
+     * Adds a simple range frame to the window.
      *
-     * `$start` assumes `PRECEDING`, and `$end` assumes `FOLLOWING`. Both can be
-     * overriden by passing an array with the order as the key. The SQL standard
-     * for ordering must be followed.
+     * `$start`:
+     *  - `0` - 'CURRENT ROW'
+     *  - `null` - 'UNBOUNDED PRECEDING'
+     *  - offset - 'offset PRECEDING'
      *
-     * ```
-     * // this is produces 'ROWS BETWEEN 1 PRECEDING AND 2 FOLLOWING'
-     * $window->rows(1, 2);
+     * `$end`:
+     *  - `0` - 'CURRENT ROW'
+     *  - `null` - 'UNBOUNDED FOLLOWING'
+     *  - offset - 'offset FOLLOWING'
      *
-     * // this is the same as 'ROWS 1 FOLLOWING`
-     * $window->rows([WindowInterface::FOLLOWING => 1]);
-     * ```
+     * If you need to use 'FOLLOWING' with frame start or
+     * 'PRECEDING' with frame end, use `frame()` instead.
      *
-     * You can use `null` for 'UNBOUNDED' and `0` for 'CURRENT ROW'.
-     *
-     * ```
-     * // this is produces 'ROWS CURRENT ROW'
-     * $window->rows(0);
+     * @param int|null $start Frame start
+     * @param int|null $end Frame end
+     *  If not passed in, only frame start SQL will be generated.
+     * @return $this
+     */
+    public function range(?int $start, ?int $end = 0);
+
+    /**
+     * Adds a simple rows frame to the window.
      *
-     * // this is produces 'ROWS UNBOUNDED PRECEDING'
-     * $window->rows(null)
-     * ```
+     * See `range()` for details.
      *
-     * @param array|int|null $start Frame start
-     * @param array|int|null $end Frame end
+     * @param int|null $start Frame start
+     * @param int|null $end Frame end
      *  If not passed in, only frame start SQL will be generated.
      * @return $this
      */
-    public function range($start, $end = 0);
+    public function rows(?int $start, ?int $end = 0);
 
     /**
-     * Adds a rows frame clause to the window. Only one frame clause can be
-     * specified per window.
+     * Adds a simple groups frame to the window.
      *
-     * See `range()` for details on `$start` and `$end` format.
+     * See `range()` for details.
      *
-     * @param array|int|null $start Frame start
-     * @param array|int|null $end Frame end
+     * @param int|null $start Frame start
+     * @param int|null $end Frame end
      *  If not passed in, only frame start SQL will be generated.
      * @return $this
      */
-    public function rows($start, $end = 0);
+    public function groups(?int $start, ?int $end = 0);
 
     /**
-     * Adds a groups frame clause to the window. Only one frame clause can be
-     * specified per window.
+     * Adds current row frame exclusion.
      *
-     * See `range()` for details on `$start` and `$end` format.
+     * @return $this
+     */
+    public function excludeCurrent();
+
+    /**
+     * Adds group frame exclusion.
+     *
+     * @return $this
+     */
+    public function excludeGroup();
+
+    /**
+     * Adds ties frame exclusion.
      *
-     * @param array|int|string|null $start Frame start
-     * @param array|int|string|null $end Frame end
-     *  If not passed in, only frame start SQL will be generated.
      * @return $this
      */
-    public function groups($start, $end = 0);
+    public function excludeTies();
 
     /**
-     * Adds a frame exclusion to the window.
+     * Adds no others frame exclusion.
      *
-     * @param int $exclusion Frame exclusion
      * @return $this
      */
-    public function exclude(int $exclusion);
+    public function excludeNoOthers();
 }

+ 94 - 0
tests/TestCase/Database/Expression/WindowExpressionTest.php

@@ -0,0 +1,94 @@
+<?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 Open Group Test Suite License
+ * 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\Expression\AggregateExpression;
+use Cake\Database\Expression\IdentifierExpression;
+use Cake\Database\Expression\WindowExpression;
+use Cake\Database\ValueBinder;
+use Cake\TestSuite\TestCase;
+
+/**
+ * Tests WindowExpression class
+ */
+class WindowExpressionTest extends TestCase
+{
+    /**
+     * Tests an empty window expression
+     *
+     * @return void
+     */
+    public function testEmptyWindow()
+    {
+        $w = new WindowExpression();
+        $this->assertSame('OVER ()', $w->sql(new ValueBinder()));
+
+        $w->partition('')->order([]);
+        $this->assertSame('OVER ()', $w->sql(new ValueBinder()));
+    }
+
+    /**
+     * Tests windows with partitions
+     *
+     * @return void
+     */
+    public function testPartitions()
+    {
+        $w = (new WindowExpression())->partition('test');
+        $this->assertEquals(
+            'OVER (PARTITION BY test)',
+            preg_replace('/[`"\[\]]/', '', $w->sql(new ValueBinder()))
+        );
+
+        $w->partition(new IdentifierExpression('identifier'));
+        $this->assertEquals(
+            'OVER (PARTITION BY test, identifier)',
+            preg_replace('/[`"\[\]]/', '', $w->sql(new ValueBinder()))
+        );
+
+        $w = (new WindowExpression())->partition(new AggregateExpression('MyAggregate', ['param']));
+        $this->assertEquals(
+            'OVER (PARTITION BY MyAggregate(:param0))',
+            preg_replace('/[`"\[\]]/', '', $w->sql(new ValueBinder()))
+        );
+    }
+
+    /**
+     * Tests windows with order by
+     *
+     * @return void
+     */
+    public function testOrder()
+    {
+        $w = (new WindowExpression())->order('test');
+        $this->assertEquals(
+            'OVER (ORDER BY test)',
+            preg_replace('/[`"\[\]]/', '', $w->sql(new ValueBinder()))
+        );
+
+        $w->order(['test2' => 'DESC']);
+        $this->assertEquals(
+            'OVER (ORDER BY test, test2 DESC)',
+            preg_replace('/[`"\[\]]/', '', $w->sql(new ValueBinder()))
+        );
+
+        $w->partition('test');
+        $this->assertEquals(
+            'OVER (PARTITION BY test ORDER BY test, test2 DESC)',
+            preg_replace('/[`"\[\]]/', '', $w->sql(new ValueBinder()))
+        );
+    }
+}