Skip to content

Commit

Permalink
Merge 30ef35a into 53c1ca8
Browse files Browse the repository at this point in the history
  • Loading branch information
othercorey committed Jan 23, 2020
2 parents 53c1ca8 + 30ef35a commit c17ca72
Show file tree
Hide file tree
Showing 5 changed files with 564 additions and 17 deletions.
10 changes: 10 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,16 @@ parameters:
count: 1
path: src/Database/Expression/QueryExpression.php

-
message: "#^Result of && is always false\\.$#"
count: 3
path: src/Database/Expression/WindowExpression.php

-
message: "#^Result of || is always false\\.$#"
count: 1
path: src/Database/Expression/WindowExpression.php

-
message: "#^Access to an undefined property Exception\\:\\:\\$queryString\\.$#"
count: 1
Expand Down
175 changes: 170 additions & 5 deletions src/Database/Expression/WindowExpression.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Cake\Database\ExpressionInterface;
use Cake\Database\ValueBinder;
use Closure;
use InvalidArgumentException;

/**
* This represents a SQL window expression used by aggregate and window functions.
Expand All @@ -35,6 +36,16 @@ class WindowExpression implements ExpressionInterface, WindowInterface
*/
protected $order;

/**
* @var array
*/
protected $frame;

/**
* @var string
*/
protected $exclusion;

/**
* @inheritDoc
*/
Expand Down Expand Up @@ -83,22 +94,102 @@ public function order($fields)
*/
public function range(?int $start, ?int $end = 0)
{
return $this;
if (func_num_args() === 1) {
return $this->frame(self::RANGE, $start, self::PRECEDING);
}

return $this->frame(self::RANGE, $start, self::PRECEDING, $end, self::FOLLOWING);
}

/**
* @inheritDoc
*/
public function rows(?int $start, ?int $end = 0)
{
return $this;
if (func_num_args() === 1) {
return $this->frame(self::ROWS, $start, self::PRECEDING);
}

return $this->frame(self::ROWS, $start, self::PRECEDING, $end, self::FOLLOWING);
}

/**
* @inheritDoc
*/
public function groups(?int $start, ?int $end = 0)
{
if (func_num_args() === 1) {
return $this->frame(self::GROUPS, $start, self::PRECEDING);
}

return $this->frame(self::GROUPS, $start, self::PRECEDING, $end, self::FOLLOWING);
}

/**
* @inheritDoc
*/
public function frame(
int $type,
$startOffset,
int $startDirection,
$endOffset = null,
int $endDirection = self::FOLLOWING
) {
$validTypes = [self::RANGE, self::ROWS, self::GROUPS];
if (!in_array($type, $validTypes)) {
throw new InvalidArgumentException('Frame type must be RANGE, ROWS or GROUP.');
}

$validDirections = [self::PRECEDING, self::FOLLOWING];
if (
!in_array($startDirection, $validDirections) ||
!in_array($endDirection, $validDirections)
) {
throw new InvalidArgumentException('Frame directions must be PRECEDING or FOLLOWING.');
}

if (
(is_int($startOffset) && $startOffset < 0) ||
(is_int($endOffset) && $endOffset < 0)
) {
throw new InvalidArgumentException('Frame offsets must be non-negative.');
}

if (
in_array($type, [self::ROWS, self::GROUPS]) &&
(
($startOffset !== null && !is_int($startOffset)) ||
($endOffset !== null && !is_int($endOffset))
)
) {
throw new InvalidArgumentException('Frame offsets for ROWS and GROUPS must be null or integers.');
}

if (
$type === self::RANGE &&
(
($startOffset !== null && !is_int($startOffset) && !is_string($startOffset)) ||
($endOffset !== null && !is_int($endOffset) && !is_string($endOffset))
)
) {
throw new InvalidArgumentException('Frame offsets for RANGE must be null, integers or interval strings.');
}

$this->frame = [
'type' => $type,
'start' => [
'offset' => $startOffset,
'direction' => $startDirection,
],
];

if (func_num_args() > 3) {
$this->frame['end'] = [
'offset' => $endOffset,
'direction' => $endDirection,
];
}

return $this;
}

Expand All @@ -107,6 +198,8 @@ public function groups(?int $start, ?int $end = 0)
*/
public function excludeCurrent()
{
$this->exclusion = 'CURRENT ROW';

return $this;
}

Expand All @@ -115,6 +208,8 @@ public function excludeCurrent()
*/
public function excludeGroup()
{
$this->exclusion = 'GROUP';

return $this;
}

Expand All @@ -123,6 +218,8 @@ public function excludeGroup()
*/
public function excludeTies()
{
$this->exclusion = 'TIES';

return $this;
}

Expand All @@ -131,6 +228,8 @@ public function excludeTies()
*/
public function excludeNoOthers()
{
$this->exclusion = 'NO OTHERS';

return $this;
}

Expand All @@ -149,11 +248,47 @@ public function sql(ValueBinder $generator): string
$partitionSql = 'PARTITION BY ' . implode(', ', $expressions);
}

$orderSql = $this->order ? $orderSql = $this->order->sql($generator) : '';

$frameSql = '';
if ($this->frame) {
$types = ['RANGE', 'ROWS', 'GROUPS'];

$offset = $this->buildOffsetSql(
$generator,
$this->frame['start']['offset'],
$this->frame['start']['direction']
);

$frameSql = sprintf(
'%s %s%s',
$types[$this->frame['type']],
isset($this->frame['end']) ? 'BETWEEN ' : '',
$offset
);

if (isset($this->frame['end'])) {
$offset = $this->buildOffsetSql(
$generator,
$this->frame['end']['offset'],
$this->frame['end']['direction']
);

$frameSql .= ' AND ' . $offset;
}

if ($this->exclusion !== null) {
$frameSql .= ' EXCLUDE ' . $this->exclusion;
}
}

return sprintf(
'OVER (%s%s%s)',
'OVER (%s%s%s%s%s)',
$partitionSql,
$partitionSql && $this->order ? ' ' : '',
$this->order ? $this->order->sql($generator) : ''
$partitionSql && $orderSql ? ' ' : '',
$orderSql,
($partitionSql || $orderSql) && $frameSql ? ' ' : '',
$frameSql
);
}

Expand All @@ -174,4 +309,34 @@ public function traverse(Closure $visitor)

return $this;
}

/**
* Builds frame offset sql.
*
* @param \Cake\Database\ValueBinder $generator Value binder
* @param string|int|null $offset Frame offset
* @param int $direction Frame offset direction
* @return string
*/
protected function buildOffsetSql(ValueBinder $generator, $offset, int $direction): string
{
if ($offset === 0) {
return 'CURRENT ROW';
}

if (is_string($offset)) {
$placeholder = $generator->placeholder('param');
$generator->bind($placeholder, $offset, 'string');
$offset = $placeholder;
}

$directions = ['PRECEDING', 'FOLLOWING'];
$sql = sprintf(
'%s %s',
$offset ?? 'UNBOUNDED',
$directions[$direction]
);

return $sql;
}
}
32 changes: 32 additions & 0 deletions src/Database/Expression/WindowInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ interface WindowInterface
*/
public const FOLLOWING = 1;

/**
* @var int
*/
public const RANGE = 0;

/**
* @var int
*/
public const ROWS = 1;

/**
* @var int
*/
public const GROUPS = 2;

/**
* Adds one or more partition expressions to the window.
*
Expand Down Expand Up @@ -94,6 +109,23 @@ public function rows(?int $start, ?int $end = 0);
*/
public function groups(?int $start, ?int $end = 0);

/**
* @param int $type Frame type
* @param string|int|null $startOffset Frame start offset
* @param int $startDirection Frame start direction
* @param string|int|null $endOffset Frame end offset
* @param int $endDirection Frame end direction
* @return $this
* @throws \InvalidArgumentException When wrong types are used or offsets are negative.
*/
public function frame(
int $type,
$startOffset,
int $startDirection,
$endOffset = null,
int $endDirection = self::FOLLOWING
);

/**
* Adds current row frame exclusion.
*
Expand Down
16 changes: 16 additions & 0 deletions src/TestSuite/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,22 @@ public function assertTextNotContains(
}
}

/**
* Assert that a string matches SQL with db-specific characters like quotes removed.
*
* @param string $needle The string to compare
* @param string $haystack The SQL to filter
* @param string $message The message to display on failure
* @return void
*/
public function assertEqualsSql(
string $needle,
string $haystack,
string $message = ''
): void {
$this->assertEquals($needle, preg_replace('/[`"\[\]]/', '', $haystack), $message);
}

/**
* Asserts HTML tags.
*
Expand Down

0 comments on commit c17ca72

Please sign in to comment.