Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
976 changes: 976 additions & 0 deletions src/Illuminate/Database/Concerns/BuildsSequentialPeriodQueries.php

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions src/Illuminate/Database/Eloquent/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Illuminate\Database\Eloquent\Concerns\QueriesRelationships;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Query\Aggregate;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\RecordsNotFoundException;
use Illuminate\Database\UniqueConstraintViolationException;
Expand Down Expand Up @@ -459,6 +460,47 @@ public function oldest($column = null)
return $this;
}

/**
* Bucket rows by a date/datetime column, aggregate metrics per period, then add windowed period-over-period columns.
*
* @param \Illuminate\Database\Query\Aggregate|array<int|string, mixed>|string $aggregates [column, function?, comparison?, alias?] positional entry, a list of such entries, or fluent Aggregate instances. See {@see \Illuminate\Database\Query\Builder::withSequentialPeriodMetrics()}.
* @param string|null $dateColumn
* @param string $periodColumnAlias
* @param bool $includePreviousPeriodValues
* @param bool $selectComparisonsOnly
* @param int|null $precision Default precision (number of decimals) applied to aggregate outputs and their comparison columns. Overridden per aggregate via `Aggregate::precision()`.
* @param string|null $thousandsSeparator Default thousands separator used when formatting aggregate / comparison columns in PHP via an after-query callback. Overridden per aggregate via `Aggregate::numberFormat()`.
* @param string|null $decimalSeparator Default decimal separator paired with $thousandsSeparator. Setting either enables the formatting.
* @return $this
*/
public function withSequentialPeriodMetrics(
string $periodFormat,
Aggregate|array|string $aggregates,
?string $dateColumn = null,
string $periodColumnAlias = 'period',
bool $includePreviousPeriodValues = true,
bool $selectComparisonsOnly = true,
?int $precision = null,
?string $thousandsSeparator = null,
?string $decimalSeparator = null,
) {
$dateColumn ??= $this->model->getCreatedAtColumn() ?? 'created_at';

$this->query->withSequentialPeriodMetrics(
$periodFormat,
$aggregates,
$dateColumn,
$periodColumnAlias,
$includePreviousPeriodValues,
$selectComparisonsOnly,
$precision,
$thousandsSeparator,
$decimalSeparator,
);

return $this;
}

/**
* Create a collection of models from plain arrays.
*
Expand Down
205 changes: 205 additions & 0 deletions src/Illuminate/Database/Query/Aggregate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<?php

namespace Illuminate\Database\Query;

use Closure;
use InvalidArgumentException;

/**
* Fluent definition for a single aggregate used by
* {@see \Illuminate\Database\Query\Builder::withSequentialPeriodMetrics()}.
*
* The `$column` may be:
*
* - a string column name (e.g. `'revenue'` or `'*'`)
* - an {@see \Illuminate\Database\Query\Expression} for raw SQL
* (e.g. `new Expression('revenue * quantity')`, typically via `DB::raw(...)`)
* - a {@see \Closure} that receives a fresh sub-query builder and lets you
* compose a correlated sub-query used as the aggregate input.
*
* Typical usage:
*
* Aggregate::sum('revenue')->as('total_revenue')->comparison(SequentialPeriodComparison::Percent)
* Aggregate::count('*')->as('order_count')->comparisons([SequentialPeriodComparison::Percent, 'difference'])
* Aggregate::avg('cost')->withoutComparison()
* Aggregate::sum(DB::raw('revenue * quantity'))->as('gross_revenue')
* Aggregate::sum(fn ($query) => $query->from('order_items')->selectRaw('sum(price * qty)')->whereColumn('order_items.order_id', 'orders.id'))->as('gross_revenue')
*/
class Aggregate
{
/**
* @param \Closure|\Illuminate\Database\Query\Expression|string $column
* @param list<SequentialPeriodComparison|string> $comparisons
* @param int|null $precision Number of decimals for the aggregate value and
* its comparison columns. Null leaves aggregate and
* difference unrounded and uses the default 2
* decimals for the percent change.
* @param string|null $thousandsSeparator When set, the aggregate, previous-period
* and comparison columns are returned as
* a locale-formatted string (e.g. "1.234,56")
* applied in PHP via a post-query callback.
* @param string|null $decimalSeparator Decimal separator used together with
* $thousandsSeparator. Either separator
* enables the formatting; the other one
* defaults to "" (no grouping) or "." (dot decimal).
*/
public function __construct(
public Closure|Expression|string $column,
public string $function = 'sum',
public ?string $alias = null,
public array $comparisons = [SequentialPeriodComparison::Percent],
public ?int $precision = null,
public ?string $thousandsSeparator = null,
public ?string $decimalSeparator = null,
) {
if ($this->precision !== null && $this->precision < 0) {
throw new InvalidArgumentException('Aggregate precision must be a non-negative integer or null.');
}
}

/**
* Start a new aggregate against the given column. Function defaults to "sum".
*
* @param \Closure|\Illuminate\Database\Query\Expression|string $column
*/
public static function column(Closure|Expression|string $column): static
{
return new static($column);
}

/**
* @param \Closure|\Illuminate\Database\Query\Expression|string $column
*/
public static function sum(Closure|Expression|string $column): static
{
return new static($column, 'sum');
}

/**
* @param \Closure|\Illuminate\Database\Query\Expression|string $column
*/
public static function avg(Closure|Expression|string $column): static
{
return new static($column, 'avg');
}

/**
* @param \Closure|\Illuminate\Database\Query\Expression|string $column
*/
public static function min(Closure|Expression|string $column): static
{
return new static($column, 'min');
}

/**
* @param \Closure|\Illuminate\Database\Query\Expression|string $column
*/
public static function max(Closure|Expression|string $column): static
{
return new static($column, 'max');
}

/**
* @param \Closure|\Illuminate\Database\Query\Expression|string $column
*/
public static function count(Closure|Expression|string $column = '*'): static
{
return new static($column, 'count');
}

/**
* Override the aggregate function after the fact (e.g. when starting from column()).
*/
public function using(string $function): static
{
$this->function = strtolower($function);

return $this;
}

/**
* Set the alias for this aggregate (also controls the comparison column names).
*/
public function as(string $alias): static
{
$this->alias = $alias;

return $this;
}

/**
* Alias of {@see self::as()}.
*/
public function alias(string $alias): static
{
return $this->as($alias);
}

/**
* Use a single comparison type for this aggregate.
*/
public function comparison(SequentialPeriodComparison|string $type): static
{
$this->comparisons = [$type];

return $this;
}

/**
* Use multiple comparison types for this aggregate.
*
* @param list<SequentialPeriodComparison|string> $types
*/
public function comparisons(array $types): static
{
$this->comparisons = array_values($types);

return $this;
}

/**
* Disable period-over-period comparison columns for this aggregate.
*/
public function withoutComparison(): static
{
$this->comparisons = [];

return $this;
}

/**
* Set the number of decimals used to round the aggregate output and its
* comparison columns. Pass `null` to restore the default behaviour (no
* rounding for aggregates and differences, 2 decimals for percent changes).
*/
public function precision(?int $decimals): static
{
if ($decimals !== null && $decimals < 0) {
throw new InvalidArgumentException('Aggregate precision must be a non-negative integer or null.');
}

$this->precision = $decimals;

return $this;
}

/**
* Format the aggregate, previous-period and comparison columns as locale
* style number strings via PHP's `number_format()`. Setting either
* separator enables the formatting; omit both (or pass null) to disable it.
*
* Examples:
* ->numberFormat('.', ',') -> "13.868.830,91" (European / TR)
* ->numberFormat(',', '.') -> "13,868,830.91" (US / EN)
* ->numberFormat(' ', ',') -> "13 868 830,91" (FR)
* ->numberFormat(decimalSeparator: ',') -> "13868830,91" (no grouping, comma decimal)
* ->numberFormat(null, null) -> disable (default)
*/
public function numberFormat(?string $thousandsSeparator = null, ?string $decimalSeparator = null): static
{
$this->thousandsSeparator = $thousandsSeparator;
$this->decimalSeparator = $decimalSeparator;

return $this;
}
}
3 changes: 2 additions & 1 deletion src/Illuminate/Database/Query/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Illuminate\Contracts\Database\Query\Expression as ExpressionContract;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Database\Concerns\BuildsQueries;
use Illuminate\Database\Concerns\BuildsSequentialPeriodQueries;
use Illuminate\Database\Concerns\BuildsWhereDateClauses;
use Illuminate\Database\Concerns\ExplainsQueries;
use Illuminate\Database\ConnectionInterface;
Expand All @@ -36,7 +37,7 @@
class Builder implements BuilderContract
{
/** @use \Illuminate\Database\Concerns\BuildsQueries<\stdClass> */
use BuildsWhereDateClauses, BuildsQueries, ExplainsQueries, ForwardsCalls, Macroable {
use BuildsSequentialPeriodQueries, BuildsWhereDateClauses, BuildsQueries, ExplainsQueries, ForwardsCalls, Macroable {
__call as macroCall;
}

Expand Down
35 changes: 35 additions & 0 deletions src/Illuminate/Database/Query/Grammars/Grammar.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Illuminate\Database\Query\JoinLateralClause;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use InvalidArgumentException;
use RuntimeException;

class Grammar extends BaseGrammar
Expand Down Expand Up @@ -1667,4 +1668,38 @@ public function getBitwiseOperators()
{
return $this->bitwiseOperators;
}

/**
* Compile SQL that buckets a date/datetime column using a PHP date()-style format string.
*
* @param string $column
* @param string $format
* @return string
*/
public function compileGroupedDate($column, $format)
{
throw new RuntimeException(sprintf(
'This database grammar does not support compileGroupedDate(). Driver [%s] must override this method.',
static::class
));
}

/**
* Ensure the given PHP date format only contains characters safe for SQL date grouping translations.
*
* @param string $format
* @return void
*
* @throws \InvalidArgumentException
*/
protected function ensurePhpDateFormatIsSafeForSqlGrouping($format)
{
if ($format === '') {
throw new InvalidArgumentException('The period format must be a non-empty string.');
}

if (! preg_match('/^[YymndjHhGis\-\/:.,_ ]+$/D', $format)) {
throw new InvalidArgumentException('The period format contains unsupported characters.');
}
}
}
46 changes: 46 additions & 0 deletions src/Illuminate/Database/Query/Grammars/MySqlGrammar.php
Original file line number Diff line number Diff line change
Expand Up @@ -616,4 +616,50 @@ protected function wrapJsonBooleanSelector($value)

return 'json_extract('.$field.$path.')';
}

/**
* {@inheritdoc}
*/
public function compileGroupedDate($column, $format)
{
$this->ensurePhpDateFormatIsSafeForSqlGrouping($format);

$pattern = $this->compileMysqlDateFormatPatternFromPhpDateFormat($format);

return 'date_format('.$this->wrap($column).', '.$this->quoteString($pattern).')';
}

/**
* Translate a PHP date format into MySQL date_format() pattern characters.
*
* @param string $format
* @return string
*/
protected function compileMysqlDateFormatPatternFromPhpDateFormat($format)
{
$result = '';
$length = strlen($format);

for ($i = 0; $i < $length; $i++) {
$char = $format[$i];

$result .= match ($char) {
'Y' => '%Y',
'y' => '%y',
'm' => '%m',
'n' => '%c',
'd' => '%d',
'j' => '%e',
'H' => '%H',
'h' => '%h',
'G' => '%k',
'i' => '%i',
's' => '%s',
'%' => '%%',
default => $char,
};
}

return $result;
}
}
Loading
Loading