diff --git a/src/Illuminate/Database/Concerns/BuildsSequentialPeriodQueries.php b/src/Illuminate/Database/Concerns/BuildsSequentialPeriodQueries.php new file mode 100644 index 000000000000..45fd51de51cd --- /dev/null +++ b/src/Illuminate/Database/Concerns/BuildsSequentialPeriodQueries.php @@ -0,0 +1,976 @@ + ['sum', 'revenue']] legacy keyed form; uses default Percent + * - Aggregate::sum('revenue')->as('total_revenue')->comparison(...) fluent form, may be mixed with any other shape in a list + * - Aggregate::sum(DB::raw('revenue * quantity'))->as('gross') raw Expression column; alias required + * - Aggregate::sum(fn ($q) => $q->from('items')->selectRaw('sum(price)'))->as('gross') Closure sub-query as column; alias required + * + * @param string $periodFormat PHP date()-style format (e.g. Y-m, Y-m-d) translated per database driver. + * @param \Illuminate\Database\Query\Aggregate|array|string $aggregates Aggregate definitions; see supported shapes above. + * @param string $dateColumn The date or datetime column to bucket (default created_at). + * @param string $periodColumnAlias Alias for the bucket column in the result set. + * @param bool $includePreviousPeriodValues When true, each aggregate gets a "{alias}_previous_period" column via LAG. + * @param bool $selectComparisonsOnly When true (default), the outer query returns only the period column and comparison columns; aggregates and *_previous_period remain in subqueries for the formulas but are omitted from the selected result set. + * @param int|null $precision Default number of decimals applied to aggregate, previous-period and comparison columns. Per-aggregate Aggregate::precision() overrides this default. When null (the default), aggregate and difference columns stay unrounded and percent-change columns keep their historical 2-decimal rounding. + * @param string|null $thousandsSeparator Default thousands separator used when formatting aggregate and comparison columns as strings in PHP (e.g. "." for "13.868.830,91"). Null disables formatting. Per-aggregate Aggregate::numberFormat() overrides this default. + * @param string|null $decimalSeparator Default decimal separator used together with $thousandsSeparator. Either argument enables the post-query formatting. + * @return $this + */ + public function withSequentialPeriodMetrics( + string $periodFormat, + Aggregate|array|string $aggregates, + string $dateColumn = 'created_at', + string $periodColumnAlias = 'period', + bool $includePreviousPeriodValues = true, + bool $selectComparisonsOnly = true, + ?int $precision = null, + ?string $thousandsSeparator = null, + ?string $decimalSeparator = null, + ) { + if ($precision !== null && $precision < 0) { + throw new InvalidArgumentException('Default precision must be a non-negative integer or null.'); + } + + if (is_string($aggregates) || $aggregates instanceof Aggregate) { + $aggregates = [$aggregates]; + } + + if ($aggregates === []) { + throw new InvalidArgumentException('At least one aggregate definition is required.'); + } + + $this->validateSequentialPeriodDateColumn($dateColumn); + + [$aggregates, $normalizedComparisons, $aggregateFormatMap] = $this->normalizeSequentialPeriodAggregates( + $aggregates, + $precision, + $thousandsSeparator, + $decimalSeparator, + ); + + if ($normalizedComparisons !== []) { + $includePreviousPeriodValues = true; + } + + $grammar = $this->grammar; + + $periodExpressionSql = $grammar->compileGroupedDate($dateColumn, $periodFormat); + + $inner = $this->cloneWithoutSelectState(); + + $inner->selectRaw($periodExpressionSql.' as '.$grammar->wrap($periodColumnAlias)); + + foreach ($aggregates as $alias => [$function, $column, $aggregatePrecision]) { + [$expressionSql, $expressionBindings] = $this->compileSequentialPeriodAggregateExpression( + $function, + $column, + $aggregatePrecision, + ); + + $inner->selectRaw( + $expressionSql.' as '.$grammar->wrap($alias), + $expressionBindings, + ); + } + + $inner->groupBy(new Expression($periodExpressionSql)); + + if ($normalizedComparisons === [] && ! $includePreviousPeriodValues) { + $this->attachSequentialPeriodFormattingCallback( + $inner, + $aggregateFormatMap, + [], + includeAggregates: true, + includePreviousPeriodValues: false, + ); + + return $this->replaceBuilderQueryState($inner); + } + + if ($normalizedComparisons === []) { + $windowedOnly = $this->buildSequentialPeriodWindowQuery($inner, $aggregates, $periodColumnAlias, $includePreviousPeriodValues, 'laravel_seq_period_metrics') + ->tap(function ($query) use ($grammar, $periodColumnAlias) { + $query->orderByRaw($grammar->wrapTable('laravel_seq_period_metrics').'.'.$grammar->wrap($periodColumnAlias)); + }); + + $this->attachSequentialPeriodFormattingCallback( + $windowedOnly, + $aggregateFormatMap, + [], + includeAggregates: true, + includePreviousPeriodValues: $includePreviousPeriodValues, + ); + + return $this->replaceBuilderQueryState($windowedOnly); + } + + $aggregatedSubqueryAlias = 'laravel_seq_period_agg'; + + $windowed = $this->buildSequentialPeriodWindowQuery( + $inner, + $aggregates, + $periodColumnAlias, + $includePreviousPeriodValues, + $aggregatedSubqueryAlias, + ); + + $outerAlias = 'laravel_seq_period_metrics'; + + $final = $this->newQuery()->fromSub($windowed, $outerAlias); + + $tableWrapped = $grammar->wrapTable($outerAlias); + $periodWrapped = $grammar->wrap($periodColumnAlias); + + $final->selectRaw($tableWrapped.'.'.$periodWrapped); + + if (! $selectComparisonsOnly) { + foreach (array_keys($aggregates) as $alias) { + $aliasWrapped = $grammar->wrap($alias); + + $final->selectRaw($tableWrapped.'.'.$aliasWrapped); + + if ($includePreviousPeriodValues) { + $final->selectRaw($tableWrapped.'.'.$grammar->wrap($alias.'_previous_period')); + } + } + } + + foreach ($normalizedComparisons as $comparison) { + $column = $comparison['column']; + $type = SequentialPeriodComparison::from($comparison['type']); + $as = $comparison['as'] ?? $this->defaultSequentialPeriodComparisonAlias($column, $type->value); + $comparisonPrecision = $comparison['precision'] ?? null; + + $current = $tableWrapped.'.'.$grammar->wrap($column); + $previous = $tableWrapped.'.'.$grammar->wrap($column.'_previous_period'); + + match ($type) { + SequentialPeriodComparison::Percent => $final->selectRaw( + 'round((('.$current.' - '.$previous.') / nullif('.$previous.', 0)) * 100, '.($comparisonPrecision ?? 2).') as '.$grammar->wrap($as) + ), + SequentialPeriodComparison::Difference => $final->selectRaw( + ($comparisonPrecision === null + ? '('.$current.' - '.$previous.')' + : 'round('.$current.' - '.$previous.', '.$comparisonPrecision.')' + ).' as '.$grammar->wrap($as) + ), + }; + } + + $final->orderByRaw($tableWrapped.'.'.$periodWrapped); + + $this->attachSequentialPeriodFormattingCallback( + $final, + $aggregateFormatMap, + $normalizedComparisons, + includeAggregates: ! $selectComparisonsOnly, + includePreviousPeriodValues: ! $selectComparisonsOnly && $includePreviousPeriodValues, + ); + + return $this->replaceBuilderQueryState($final); + } + + /** + * @param array $aggregates + * @return \Illuminate\Database\Query\Builder + */ + protected function buildSequentialPeriodWindowQuery($inner, array $aggregates, string $periodColumnAlias, bool $includePreviousPeriodValues, string $subqueryAlias) + { + $grammar = $this->grammar; + + $query = $this->newQuery()->fromSub($inner, $subqueryAlias); + + $tableWrapped = $grammar->wrapTable($subqueryAlias); + $periodWrapped = $grammar->wrap($periodColumnAlias); + + $query->selectRaw($tableWrapped.'.'.$periodWrapped); + + foreach (array_keys($aggregates) as $alias) { + $aliasWrapped = $grammar->wrap($alias); + + $query->selectRaw($tableWrapped.'.'.$aliasWrapped); + + if ($includePreviousPeriodValues) { + $previousAlias = $alias.'_previous_period'; + + $query->selectRaw( + 'lag('.$tableWrapped.'.'.$aliasWrapped.') over (order by '.$tableWrapped.'.'.$periodWrapped.') as '.$grammar->wrap($previousAlias) + ); + } + } + + return $query; + } + + /** + * @return $this + */ + protected function cloneWithoutSelectState() + { + $inner = $this->clone(); + + $inner->aggregate = null; + $inner->columns = []; + $inner->distinct = false; + $inner->groups = null; + $inner->havings = null; + $inner->orders = null; + $inner->limit = null; + $inner->offset = null; + $inner->unions = null; + $inner->unionLimit = null; + $inner->unionOffset = null; + $inner->unionOrders = null; + $inner->groupLimit = null; + $inner->bindings['select'] = []; + $inner->bindings['groupBy'] = []; + $inner->bindings['having'] = []; + $inner->bindings['order'] = []; + $inner->bindings['union'] = []; + $inner->bindings['unionOrder'] = []; + + return $inner; + } + + /** + * @return $this + */ + protected function replaceBuilderQueryState(self $source) + { + $this->aggregate = $source->aggregate; + $this->columns = $source->columns; + $this->distinct = $source->distinct; + $this->from = $source->from; + $this->indexHint = $source->indexHint; + $this->joins = $source->joins; + $this->wheres = $source->wheres; + $this->groups = $source->groups; + $this->havings = $source->havings; + $this->orders = $source->orders; + $this->limit = $source->limit; + $this->offset = $source->offset; + $this->unions = $source->unions; + $this->unionLimit = $source->unionLimit; + $this->unionOffset = $source->unionOffset; + $this->unionOrders = $source->unionOrders; + $this->lock = $source->lock; + $this->timeout = $source->timeout; + $this->bindings = $source->bindings; + $this->beforeQueryCallbacks = $source->beforeQueryCallbacks; + $this->afterQueryCallbacks = $source->afterQueryCallbacks; + $this->groupLimit = $source->groupLimit; + $this->useWritePdo = $source->useWritePdo; + $this->fetchUsing = $source->fetchUsing; + + return $this; + } + + /** + * Normalize the user-provided aggregate definitions. + * + * @param array $aggregates + * @return array{0: array, 1: list, 2: array} + */ + protected function normalizeSequentialPeriodAggregates( + array $aggregates, + ?int $defaultPrecision = null, + ?string $defaultThousandsSeparator = null, + ?string $defaultDecimalSeparator = null, + ): array { + $entries = $this->isSinglePositionalAggregate($aggregates) + ? [$this->parseSequentialPeriodPositionalAggregate($aggregates, $defaultPrecision, $defaultThousandsSeparator, $defaultDecimalSeparator)] + : array_map( + fn ($definition, $key) => $this->parseSequentialPeriodAggregateEntry( + $key, + $definition, + $defaultPrecision, + $defaultThousandsSeparator, + $defaultDecimalSeparator, + ), + $aggregates, + array_keys($aggregates), + ); + + $normalizedAggregates = []; + $normalizedComparisons = []; + $aggregateFormatMap = []; + + foreach ($entries as $entry) { + $alias = $entry['alias']; + + if (isset($normalizedAggregates[$alias])) { + throw new InvalidArgumentException("Duplicate aggregate alias [{$alias}]."); + } + + $normalizedAggregates[$alias] = [$entry['function'], $entry['column'], $entry['precision']]; + + $aggregateFormatMap[$alias] = [ + 'precision' => $entry['precision'], + 'thousands' => $entry['thousands_separator'] ?? null, + 'decimal' => $entry['decimal_separator'] ?? null, + ]; + + foreach ($entry['comparisons'] as $type) { + $normalizedComparisons[] = [ + 'column' => $alias, + 'type' => $type, + 'as' => null, + 'precision' => $entry['precision'], + ]; + } + } + + return [$normalizedAggregates, $normalizedComparisons, $aggregateFormatMap]; + } + + /** + * Determine if the outer aggregates array represents a single positional entry. + * + * A single positional entry starts with a column (string, Expression, or + * Closure) at index 0 (e.g. ['revenue', 'sum', ...]). When the outer array + * begins with a nested array or uses string keys, it is treated as a list + * of entries instead. + * + * As a convenience, when the array consists entirely of strings (e.g. + * ['revenue', 'cost']) it is treated as a list of column-only shorthands + * unless the second element is one of the recognised aggregate function + * names (sum, avg, min, max, count), which would otherwise be ambiguous + * with the [column, function, ...] positional form. + * + * @param array $aggregates + */ + protected function isSinglePositionalAggregate(array $aggregates): bool + { + if (! array_key_exists(0, $aggregates)) { + return false; + } + + foreach ($aggregates as $key => $value) { + if (is_string($key)) { + return false; + } + + if ($value instanceof Aggregate) { + return false; + } + } + + $first = $aggregates[0]; + + if (! is_string($first) && ! ($first instanceof Expression) && ! ($first instanceof Closure)) { + return false; + } + + if (count($aggregates) === 1) { + return true; + } + + if ($first instanceof Expression || $first instanceof Closure) { + return true; + } + + $second = $aggregates[1]; + + if (is_string($second)) { + return in_array(strtolower($second), ['sum', 'avg', 'min', 'max', 'count'], true); + } + + return true; + } + + /** + * Parse one aggregate entry within a multi-form list. + * + * @param int|string $key + * @return array{column: \Closure|\Illuminate\Database\Query\Expression|string, function: string, alias: string, comparisons: list, precision: ?int, thousands_separator: ?string, decimal_separator: ?string} + */ + protected function parseSequentialPeriodAggregateEntry( + int|string $key, + mixed $definition, + ?int $defaultPrecision = null, + ?string $defaultThousandsSeparator = null, + ?string $defaultDecimalSeparator = null, + ): array { + if (is_string($key)) { + return $this->parseSequentialPeriodLegacyKeyedAggregate($key, $definition, $defaultPrecision, $defaultThousandsSeparator, $defaultDecimalSeparator); + } + + if ($definition instanceof Aggregate) { + return $this->parseSequentialPeriodFluentAggregate($definition, $defaultPrecision, $defaultThousandsSeparator, $defaultDecimalSeparator); + } + + if (is_string($definition) || $definition instanceof Expression || $definition instanceof Closure) { + $definition = [$definition]; + } + + if (! is_array($definition)) { + throw new InvalidArgumentException('Aggregate entry must be a column, an Aggregate instance, or a [column, function?, comparison?, alias?] array.'); + } + + return $this->parseSequentialPeriodPositionalAggregate($definition, $defaultPrecision, $defaultThousandsSeparator, $defaultDecimalSeparator); + } + + /** + * Convert a fluent Aggregate instance into the normalized internal shape. + * + * @return array{column: \Closure|\Illuminate\Database\Query\Expression|string, function: string, alias: string, comparisons: list, precision: ?int, thousands_separator: ?string, decimal_separator: ?string} + */ + protected function parseSequentialPeriodFluentAggregate( + Aggregate $aggregate, + ?int $defaultPrecision = null, + ?string $defaultThousandsSeparator = null, + ?string $defaultDecimalSeparator = null, + ): array { + $column = $aggregate->column; + $function = strtolower($aggregate->function); + + $this->ensureSequentialPeriodAggregateColumnIsUsable($column); + + $alias = $aggregate->alias ?? $this->defaultSequentialPeriodAggregateAlias($column, $function); + + if ($alias === '') { + throw new InvalidArgumentException('Aggregate alias must be a non-empty string.'); + } + + return [ + 'column' => $column, + 'function' => $function, + 'alias' => $alias, + 'comparisons' => $this->normalizeSequentialPeriodEntryComparisons($aggregate->comparisons), + 'precision' => $aggregate->precision ?? $defaultPrecision, + 'thousands_separator' => $aggregate->thousandsSeparator ?? $defaultThousandsSeparator, + 'decimal_separator' => $aggregate->decimalSeparator ?? $defaultDecimalSeparator, + ]; + } + + /** + * Parse a positional aggregate definition: [column, function?, comparison?, alias?]. + * + * @param array $definition + * @return array{column: \Closure|\Illuminate\Database\Query\Expression|string, function: string, alias: string, comparisons: list, precision: ?int, thousands_separator: ?string, decimal_separator: ?string} + */ + protected function parseSequentialPeriodPositionalAggregate( + array $definition, + ?int $defaultPrecision = null, + ?string $defaultThousandsSeparator = null, + ?string $defaultDecimalSeparator = null, + ): array { + $values = array_values($definition); + $count = count($values); + + if ($count < 1 || $count > 4) { + throw new InvalidArgumentException('Aggregate definition must be [column, function?, comparison?, alias?].'); + } + + $column = $values[0]; + + $this->ensureSequentialPeriodAggregateColumnIsUsable($column); + + $function = $values[1] ?? 'sum'; + + if (! is_string($function)) { + throw new InvalidArgumentException('Aggregate function must be a string.'); + } + + $function = strtolower($function); + + $slot2 = array_key_exists(2, $values) ? $values[2] : null; + $slot3 = array_key_exists(3, $values) ? $values[3] : null; + + if ($slot3 !== null) { + $comparison = $slot2; + $alias = $slot3; + } elseif ($count < 3) { + $comparison = SequentialPeriodComparison::Percent; + $alias = null; + } elseif ($this->looksLikeSequentialPeriodComparisonValue($slot2)) { + $comparison = $slot2; + $alias = null; + } else { + $comparison = SequentialPeriodComparison::Percent; + $alias = $slot2; + } + + if ($alias !== null && ! is_string($alias)) { + throw new InvalidArgumentException('Aggregate alias must be a string.'); + } + + $alias ??= $this->defaultSequentialPeriodAggregateAlias($column, $function); + + if ($alias === '') { + throw new InvalidArgumentException('Aggregate alias must be a non-empty string.'); + } + + return [ + 'column' => $column, + 'function' => $function, + 'alias' => $alias, + 'comparisons' => $this->normalizeSequentialPeriodEntryComparisons($comparison), + 'precision' => $defaultPrecision, + 'thousands_separator' => $defaultThousandsSeparator, + 'decimal_separator' => $defaultDecimalSeparator, + ]; + } + + /** + * Parse the legacy 'alias' => [function, column] aggregate entry. + * + * @return array{column: \Closure|\Illuminate\Database\Query\Expression|string, function: string, alias: string, comparisons: list, precision: ?int, thousands_separator: ?string, decimal_separator: ?string} + */ + protected function parseSequentialPeriodLegacyKeyedAggregate( + string $alias, + mixed $definition, + ?int $defaultPrecision = null, + ?string $defaultThousandsSeparator = null, + ?string $defaultDecimalSeparator = null, + ): array { + if (! is_array($definition)) { + throw new InvalidArgumentException("Aggregate [{$alias}] must be a [function, column] pair."); + } + + $values = array_values($definition); + + if (count($values) !== 2 || ! is_string($values[0])) { + throw new InvalidArgumentException("Aggregate [{$alias}] must be a [function, column] pair."); + } + + $column = $values[1]; + + if (! is_string($column) && ! ($column instanceof Expression) && ! ($column instanceof Closure)) { + throw new InvalidArgumentException("Aggregate [{$alias}] must be a [function, column] pair."); + } + + $this->ensureSequentialPeriodAggregateColumnIsUsable($column); + + return [ + 'column' => $column, + 'function' => strtolower($values[0]), + 'alias' => $alias, + 'comparisons' => [SequentialPeriodComparison::Percent->value], + 'precision' => $defaultPrecision, + 'thousands_separator' => $defaultThousandsSeparator, + 'decimal_separator' => $defaultDecimalSeparator, + ]; + } + + /** + * Decide whether the value in positional slot 2 looks like a comparison specifier + * (as opposed to an explicit alias string). + */ + protected function looksLikeSequentialPeriodComparisonValue(mixed $value): bool + { + if ($value === false || $value === null || $value === []) { + return true; + } + + if ($value instanceof SequentialPeriodComparison) { + return true; + } + + if (is_string($value)) { + return SequentialPeriodComparison::tryFrom(strtolower($value)) !== null; + } + + if (is_array($value)) { + foreach ($value as $item) { + if (! ($item instanceof SequentialPeriodComparison) && ! is_string($item)) { + return false; + } + } + + return true; + } + + return false; + } + + /** + * Normalize a single aggregate's comparison input into a list of enum string values. + * + * @return list + */ + protected function normalizeSequentialPeriodEntryComparisons(mixed $comparison): array + { + if ($comparison === false || $comparison === null || $comparison === []) { + return []; + } + + if (is_string($comparison) || $comparison instanceof SequentialPeriodComparison) { + return [$this->parseSequentialPeriodComparisonType($comparison)]; + } + + if (is_array($comparison)) { + return array_map( + fn ($type) => $this->parseSequentialPeriodComparisonType($type), + array_values($comparison), + ); + } + + throw new InvalidArgumentException('Invalid aggregate comparison value.'); + } + + /** + * @param string|SequentialPeriodComparison $type + */ + protected function parseSequentialPeriodComparisonType(string|SequentialPeriodComparison $type): string + { + if ($type instanceof SequentialPeriodComparison) { + return $type->value; + } + + $enum = SequentialPeriodComparison::tryFrom(strtolower($type)); + + if ($enum === null) { + throw new InvalidArgumentException("Unsupported sequential period comparison type [{$type}]."); + } + + return $enum->value; + } + + /** + * Ensure the given aggregate column value is a supported type and, when + * provided as a string, non-empty. + */ + protected function ensureSequentialPeriodAggregateColumnIsUsable(mixed $column): void + { + if ($column instanceof Expression || $column instanceof Closure) { + return; + } + + if (! is_string($column) || $column === '') { + throw new InvalidArgumentException('Aggregate column must be a non-empty string, an Expression, or a Closure.'); + } + } + + /** + * @param \Closure|\Illuminate\Database\Query\Expression|string $column + */ + protected function defaultSequentialPeriodAggregateAlias(Closure|Expression|string $column, string $function): string + { + $function = strtolower($function); + + if ($column instanceof Closure || $column instanceof Expression) { + throw new InvalidArgumentException( + 'An explicit alias is required when the aggregate column is an Expression or Closure.' + ); + } + + return $column === '*' ? $function : $column.'_'.$function; + } + + /** + * Compile an aggregate expression into raw SQL plus any bindings produced + * by a Closure sub-query column. + * + * @param \Closure|\Illuminate\Database\Query\Expression|string $column + * @return array{0: string, 1: array} + */ + protected function compileSequentialPeriodAggregateExpression(string $function, Closure|Expression|string $column, ?int $precision = null): array + { + if (! in_array($function, ['sum', 'avg', 'min', 'max', 'count'], true)) { + throw new InvalidArgumentException("Unsupported aggregate function [{$function}]."); + } + + [$columnSql, $bindings] = $this->resolveSequentialPeriodAggregateColumn($function, $column); + + if ($function === 'count' && is_string($column) && $column === '*') { + $expression = 'count(*)'; + $bindings = []; + } else { + $expression = $function.'('.$columnSql.')'; + } + + if ($precision !== null) { + $expression = 'round('.$expression.', '.$precision.')'; + } + + return [$expression, $bindings]; + } + + /** + * Resolve an aggregate column into its SQL fragment and bindings. + * + * @param \Closure|\Illuminate\Database\Query\Expression|string $column + * @return array{0: string, 1: array} + */ + protected function resolveSequentialPeriodAggregateColumn(string $function, Closure|Expression|string $column): array + { + if ($column instanceof Expression) { + return [(string) $this->grammar->getValue($column), []]; + } + + if ($column instanceof Closure) { + $subQuery = $this->forSubQuery(); + + $column($subQuery); + + return ['('.$subQuery->toSql().')', $subQuery->getBindings()]; + } + + if ($column === '*' && $function !== 'count') { + throw new InvalidArgumentException("Aggregate column [*] is only supported with the count() function, got [{$function}]."); + } + + return [$this->grammar->wrap($column), []]; + } + + /** + * Attach a post-query callback to $builder that rewrites the configured + * output columns using PHP's {@see number_format()}. Uses the per-aggregate + * thousands / decimal separators captured during normalization. If none of + * the aggregates request formatting, no callback is attached. + * + * @param array $aggregateFormatMap + * @param list $normalizedComparisons + */ + protected function attachSequentialPeriodFormattingCallback( + $builder, + array $aggregateFormatMap, + array $normalizedComparisons, + bool $includeAggregates, + bool $includePreviousPeriodValues, + ): void { + $columnFormats = []; + + foreach ($aggregateFormatMap as $alias => $format) { + if (! $this->sequentialPeriodFormatIsActive($format)) { + continue; + } + + if ($includeAggregates) { + $columnFormats[$alias] = $format; + } + + if ($includePreviousPeriodValues) { + $columnFormats[$alias.'_previous_period'] = $format; + } + } + + foreach ($normalizedComparisons as $comparison) { + $alias = $comparison['column']; + $format = $aggregateFormatMap[$alias] ?? null; + + if ($format === null || ! $this->sequentialPeriodFormatIsActive($format)) { + continue; + } + + $type = SequentialPeriodComparison::from($comparison['type']); + $columnName = $comparison['as'] ?? $this->defaultSequentialPeriodComparisonAlias($alias, $type->value); + + $columnFormats[$columnName] = [ + 'precision' => $comparison['precision'] ?? $format['precision'], + 'thousands' => $format['thousands'], + 'decimal' => $format['decimal'], + ]; + } + + if ($columnFormats === []) { + return; + } + + $builder->afterQuery(static function ($results) use ($columnFormats) { + return static::formatSequentialPeriodResults($results, $columnFormats); + }); + } + + /** + * Whether the given format spec should trigger PHP post-processing. + * + * @param array{precision: ?int, thousands: ?string, decimal: ?string} $format + */ + protected function sequentialPeriodFormatIsActive(array $format): bool + { + return $format['thousands'] !== null || $format['decimal'] !== null; + } + + /** + * Apply the configured column formats to a result set returned from the + * query builder. Accepts Collections of either stdClass rows or associative + * arrays; any other shape (scalars, null, already paginated payloads) is + * returned unchanged. + * + * @param array $columnFormats + */ + protected static function formatSequentialPeriodResults(mixed $results, array $columnFormats): mixed + { + if (! $results instanceof Collection) { + return $results; + } + + return $results->map(function ($row) use ($columnFormats) { + if (is_object($row)) { + foreach ($columnFormats as $column => $format) { + if (! property_exists($row, $column)) { + continue; + } + + $row->{$column} = static::formatSequentialPeriodValue($row->{$column}, $format); + } + + return $row; + } + + if (is_array($row)) { + foreach ($columnFormats as $column => $format) { + if (! array_key_exists($column, $row)) { + continue; + } + + $row[$column] = static::formatSequentialPeriodValue($row[$column], $format); + } + + return $row; + } + + return $row; + }); + } + + /** + * Format a single numeric value using the supplied format spec. Non-numeric + * and null values are returned unchanged. + * + * @param array{precision: ?int, thousands: ?string, decimal: ?string} $format + */ + protected static function formatSequentialPeriodValue(mixed $value, array $format): mixed + { + if ($value === null || $value === '') { + return $value; + } + + if (! is_numeric($value)) { + return $value; + } + + $thousands = $format['thousands'] ?? ''; + $decimal = $format['decimal'] ?? '.'; + + $decimals = $format['precision']; + + if ($decimals === null) { + $stringValue = (string) $value; + + $decimals = str_contains($stringValue, '.') + ? strlen(explode('.', $stringValue)[1]) + : 0; + } + + return number_format((float) $value, $decimals, $decimal, $thousands); + } + + /** + * Ensure the configured bucket column is actually a date/datetime type in the + * underlying table. When the schema type cannot be resolved (e.g. mocked + * connection, sub-query source, virtual tables) the check is skipped. + */ + protected function validateSequentialPeriodDateColumn(string $dateColumn): void + { + $table = $this->resolveSequentialPeriodTableName(); + + if ($table === null) { + return; + } + + $column = $dateColumn; + + if (str_contains($column, '.')) { + [, $column] = explode('.', $column, 2); + } + + $type = $this->resolveSequentialPeriodColumnType($table, $column); + + if ($type === null) { + return; + } + + if (! $this->isSequentialPeriodDateLikeColumnType($type)) { + throw new InvalidArgumentException( + "Date column [{$dateColumn}] on table [{$table}] must be a date or datetime type, got [{$type}]." + ); + } + } + + /** + * Extract the raw table name (without alias) from the current "from" clause, + * or null when the source is dynamic (Expression, sub-query, etc.). + */ + protected function resolveSequentialPeriodTableName(): ?string + { + $from = $this->from; + + if (! is_string($from) || $from === '') { + return null; + } + + $table = preg_replace('/\s+as\s+.+$/i', '', $from); + + return is_string($table) && $table !== '' ? trim($table) : null; + } + + /** + * Best-effort lookup of a column's schema type for the given table. Any + * schema/connection failure is swallowed so the feature stays usable in + * environments where introspection is unavailable. + */ + protected function resolveSequentialPeriodColumnType(string $table, string $column): ?string + { + try { + $type = $this->connection->getSchemaBuilder()->getColumnType($table, $column); + } catch (Throwable) { + return null; + } + + return is_string($type) && $type !== '' ? strtolower($type) : null; + } + + /** + * Whitelist of column types that may legitimately be bucketed into periods. + */ + protected function isSequentialPeriodDateLikeColumnType(string $type): bool + { + return in_array($type, [ + 'date', + 'datetime', + 'datetimetz', + 'timestamp', + 'timestamptz', + ], true); + } + + protected function defaultSequentialPeriodComparisonAlias(string $column, string $type): string + { + return match (SequentialPeriodComparison::from($type)) { + SequentialPeriodComparison::Percent => $column.'_change_percent', + SequentialPeriodComparison::Difference => $column.'_change', + }; + } +} diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 4de139b1cebf..ef65c1b005e3 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -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; @@ -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|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. * diff --git a/src/Illuminate/Database/Query/Aggregate.php b/src/Illuminate/Database/Query/Aggregate.php new file mode 100644 index 000000000000..37ac3be92352 --- /dev/null +++ b/src/Illuminate/Database/Query/Aggregate.php @@ -0,0 +1,205 @@ +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 $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 $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; + } +} diff --git a/src/Illuminate/Database/Query/Builder.php b/src/Illuminate/Database/Query/Builder.php index 129c63b3e9b8..d6f7ca98deff 100755 --- a/src/Illuminate/Database/Query/Builder.php +++ b/src/Illuminate/Database/Query/Builder.php @@ -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; @@ -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; } diff --git a/src/Illuminate/Database/Query/Grammars/Grammar.php b/src/Illuminate/Database/Query/Grammars/Grammar.php index 429ae1da4fd2..471738e17908 100755 --- a/src/Illuminate/Database/Query/Grammars/Grammar.php +++ b/src/Illuminate/Database/Query/Grammars/Grammar.php @@ -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 @@ -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.'); + } + } } diff --git a/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php b/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php index 7a8410973628..dd49f88ee75d 100755 --- a/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/MySqlGrammar.php @@ -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; + } } diff --git a/src/Illuminate/Database/Query/Grammars/PostgresGrammar.php b/src/Illuminate/Database/Query/Grammars/PostgresGrammar.php index f12b4d225ee9..162b8a0d42fe 100755 --- a/src/Illuminate/Database/Query/Grammars/PostgresGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/PostgresGrammar.php @@ -873,4 +873,47 @@ public static function cascadeOnTrucate(bool $value = true) { self::cascadeOnTruncate($value); } + + /** + * {@inheritdoc} + */ + public function compileGroupedDate($column, $format) + { + $this->ensurePhpDateFormatIsSafeForSqlGrouping($format); + + $pattern = $this->compilePostgresToCharPatternFromPhpDateFormat($format); + + return 'to_char('.$this->wrap($column).', '.$this->quoteString($pattern).')'; + } + + /** + * Translate a PHP date format into PostgreSQL to_char() template characters. + * + * @param string $format + * @return string + */ + protected function compilePostgresToCharPatternFromPhpDateFormat($format) + { + $result = ''; + $length = strlen($format); + + for ($i = 0; $i < $length; $i++) { + $char = $format[$i]; + + $result .= match ($char) { + 'Y' => 'YYYY', + 'y' => 'YY', + 'm', 'n' => 'MM', + 'd', 'j' => 'DD', + 'H' => 'HH24', + 'h' => 'HH12', + 'G' => 'FMHH24', + 'i' => 'MI', + 's' => 'SS', + default => $char, + }; + } + + return $result; + } } diff --git a/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php b/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php index 97a704080b6f..0515b0f6457b 100755 --- a/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php @@ -508,4 +508,49 @@ protected function wrapJsonSelector($value) return 'json_extract('.$field.$path.')'; } + + /** + * {@inheritdoc} + */ + public function compileGroupedDate($column, $format) + { + $this->ensurePhpDateFormatIsSafeForSqlGrouping($format); + + $pattern = $this->compileSqliteStrftimeFormatPatternFromPhpDateFormat($format); + + return 'strftime('.$this->quoteString($pattern).', '.$this->wrap($column).')'; + } + + /** + * Translate a PHP date format into SQLite strftime() format characters. + * + * @param string $format + * @return string + */ + protected function compileSqliteStrftimeFormatPatternFromPhpDateFormat($format) + { + $result = ''; + $length = strlen($format); + + for ($i = 0; $i < $length; $i++) { + $char = $format[$i]; + + $result .= match ($char) { + 'Y' => '%Y', + 'y' => '%y', + 'm' => '%m', + 'n' => '%m', + 'd' => '%d', + 'j' => '%d', + 'H' => '%H', + 'h' => '%H', + 'G' => '%H', + 'i' => '%M', + 's' => '%S', + default => $char, + }; + } + + return $result; + } } diff --git a/src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php b/src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php index 73aa8ccda213..23be606f565e 100755 --- a/src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php +++ b/src/Illuminate/Database/Query/Grammars/SqlServerGrammar.php @@ -609,4 +609,49 @@ protected function wrapTableValuedFunction($table) return $table; } + + /** + * {@inheritdoc} + */ + public function compileGroupedDate($column, $format) + { + $this->ensurePhpDateFormatIsSafeForSqlGrouping($format); + + $pattern = $this->compileSqlServerFormatPatternFromPhpDateFormat($format); + + return 'format('.$this->wrap($column).', '.$this->quoteString($pattern).', \'en-US\')'; + } + + /** + * Translate a PHP date format into SQL Server FORMAT() template characters. + * + * @param string $format + * @return string + */ + protected function compileSqlServerFormatPatternFromPhpDateFormat($format) + { + $result = ''; + $length = strlen($format); + + for ($i = 0; $i < $length; $i++) { + $char = $format[$i]; + + $result .= match ($char) { + 'Y' => 'yyyy', + 'y' => 'yy', + 'm' => 'MM', + 'n' => 'MM', + 'd' => 'dd', + 'j' => 'dd', + 'H' => 'HH', + 'h' => 'hh', + 'G' => 'H', + 'i' => 'mm', + 's' => 'ss', + default => $char, + }; + } + + return $result; + } } diff --git a/src/Illuminate/Database/Query/SequentialPeriodComparison.php b/src/Illuminate/Database/Query/SequentialPeriodComparison.php new file mode 100644 index 000000000000..a46a6621338f --- /dev/null +++ b/src/Illuminate/Database/Query/SequentialPeriodComparison.php @@ -0,0 +1,9 @@ +makePartial(); } + + public function testWithSequentialPeriodMetricsThrowsWhenGrammarUnsupported() + { + $this->expectException(RuntimeException::class); + + $this->getBuilder()->from('orders')->withSequentialPeriodMetrics( + 'Y-m', + ['total_revenue' => ['sum', 'revenue']], + ); + } + + public function testWithSequentialPeriodMetricsMysql() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + ['total_revenue' => ['sum', 'revenue']], + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('date_format', $sql); + $this->assertStringContainsString('lag(', $sql); + $this->assertStringContainsString('total_revenue_change_percent', $sql); + $this->assertStringContainsString('from (', $sql); + } + + public function testWithSequentialPeriodMetricsMysqlSelectComparisonsOnlyOmitsAggregateColumnsFromOuterSelect() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + ['total_revenue' => ['sum', 'revenue']], + ); + + $this->assertCount(2, $builder->columns); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('total_revenue_change_percent', $sql); + $this->assertStringContainsString('lag(', $sql); + } + + public function testWithSequentialPeriodMetricsMysqlAppliesMultipleComparisonsPerAggregate() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + [ + ['revenue', 'sum', [SequentialPeriodComparison::Percent, 'difference'], 'total_revenue'], + ['*', 'count', [SequentialPeriodComparison::Percent, 'difference'], 'order_count'], + ], + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('total_revenue_change_percent', $sql); + $this->assertStringContainsString('total_revenue_change', $sql); + $this->assertStringContainsString('order_count_change_percent', $sql); + $this->assertStringContainsString('order_count_change', $sql); + } + + public function testWithSequentialPeriodMetricsMysqlAcceptsColumnNameOnlyAggregateDefaultsToSum() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + ['revenue'], + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('sum(`revenue`) as `revenue_sum`', $sql); + $this->assertStringContainsString('revenue_sum_change_percent', $sql); + } + + public function testWithSequentialPeriodMetricsMysqlAcceptsFlatStringListAsMultipleColumns() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + ['revenue', 'cost'], + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('sum(`revenue`) as `revenue_sum`', $sql); + $this->assertStringContainsString('sum(`cost`) as `cost_sum`', $sql); + $this->assertStringContainsString('revenue_sum_change_percent', $sql); + $this->assertStringContainsString('cost_sum_change_percent', $sql); + } + + public function testWithSequentialPeriodMetricsMysqlAcceptsSingleStringAggregate() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + 'revenue', + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('sum(`revenue`) as `revenue_sum`', $sql); + $this->assertStringContainsString('revenue_sum_change_percent', $sql); + } + + public function testWithSequentialPeriodMetricsMysqlAcceptsListShorthandAggregatesWithDefaultAlias() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + [ + ['revenue', 'sum'], + ['*', 'count'], + ], + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('sum(`revenue`) as `revenue_sum`', $sql); + $this->assertStringContainsString('count(*) as `count`', $sql); + $this->assertStringContainsString('revenue_sum_change_percent', $sql); + $this->assertStringContainsString('count_change_percent', $sql); + } + + public function testWithSequentialPeriodMetricsMysqlPositionalAliasInSlot3() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + ['revenue', 'sum', 'total_revenue'], + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('sum(`revenue`) as `total_revenue`', $sql); + $this->assertStringContainsString('total_revenue_change_percent', $sql); + } + + public function testWithSequentialPeriodMetricsMysqlAcceptsSingleComparisonEnum() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m-d', + ['revenue', 'sum', SequentialPeriodComparison::Difference, 'total_revenue'], + ); + + $sql = $builder->toSql(); + + $this->assertStringContainsString('total_revenue_change', $sql); + $this->assertStringNotContainsString('total_revenue_change_percent', $sql); + } + + public function testWithSequentialPeriodMetricsMysqlAcceptsSingleComparisonString() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m-d', + ['revenue', 'sum', 'difference', 'total_revenue'], + ); + + $sql = $builder->toSql(); + + $this->assertStringContainsString('total_revenue_change', $sql); + $this->assertStringNotContainsString('total_revenue_change_percent', $sql); + } + + public function testWithSequentialPeriodMetricsMysqlDisablesComparisonWhenFalse() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m-d', + ['revenue', 'sum', false, 'total_revenue'], + selectComparisonsOnly: false, + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('total_revenue', $sql); + $this->assertStringNotContainsString('change_percent', $sql); + $this->assertStringNotContainsString('change', $sql); + } + + public function testWithSequentialPeriodMetricsSqliteWithoutWindowing() + { + $builder = $this->getSQLiteBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + ['*', 'count', false, 'order_count'], + periodColumnAlias: 'period', + includePreviousPeriodValues: false, + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('strftime', $sql); + $this->assertStringNotContainsString('lag(', $sql); + } + + public function testWithSequentialPeriodMetricsMysqlAcceptsStringComparisonTypes() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + ['revenue', 'sum', ['percent', 'difference'], 'total_revenue'], + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('total_revenue_change_percent', $sql); + $this->assertStringContainsString('total_revenue_change', $sql); + } + + public function testWithSequentialPeriodMetricsAcceptsFluentAggregateSingle() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + Aggregate::sum('revenue')->as('total_revenue'), + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('sum(`revenue`) as `total_revenue`', $sql); + $this->assertStringContainsString('total_revenue_change_percent', $sql); + } + + public function testWithSequentialPeriodMetricsAcceptsFluentAggregateList() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + [ + Aggregate::sum('revenue')->as('total_revenue'), + Aggregate::avg('cost')->comparisons([SequentialPeriodComparison::Percent, SequentialPeriodComparison::Difference]), + Aggregate::count('*')->as('order_count')->comparison(SequentialPeriodComparison::Difference), + Aggregate::min('price')->withoutComparison(), + ], + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('sum(`revenue`) as `total_revenue`', $sql); + $this->assertStringContainsString('avg(`cost`) as `cost_avg`', $sql); + $this->assertStringContainsString('count(*) as `order_count`', $sql); + $this->assertStringContainsString('min(`price`) as `price_min`', $sql); + + $this->assertStringContainsString('total_revenue_change_percent', $sql); + $this->assertStringContainsString('cost_avg_change_percent', $sql); + $this->assertStringContainsString('cost_avg_change', $sql); + $this->assertStringContainsString('order_count_change', $sql); + $this->assertStringNotContainsString('order_count_change_percent', $sql); + $this->assertStringNotContainsString('price_min_change', $sql); + } + + public function testWithSequentialPeriodMetricsAcceptsMixedFluentAndArrayAggregates() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + [ + Aggregate::sum('revenue')->as('total_revenue'), + ['cost', 'avg'], + 'profit', + ], + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('sum(`revenue`) as `total_revenue`', $sql); + $this->assertStringContainsString('avg(`cost`) as `cost_avg`', $sql); + $this->assertStringContainsString('sum(`profit`) as `profit_sum`', $sql); + } + + public function testAggregateFluentColumnDefaultsToSumAndSupportsUsing() + { + $aggregate = Aggregate::column('revenue')->using('avg')->as('rev_avg'); + + $this->assertSame('revenue', $aggregate->column); + $this->assertSame('avg', $aggregate->function); + $this->assertSame('rev_avg', $aggregate->alias); + $this->assertSame([SequentialPeriodComparison::Percent], $aggregate->comparisons); + } + + public function testWithSequentialPeriodMetricsRejectsNonDateColumnType() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Date column [profit] on table [orders] must be a date or datetime type, got [decimal].'); + + $schema = m::mock(\Illuminate\Database\Schema\Builder::class); + $schema->shouldReceive('getColumnType')->with('orders', 'profit')->andReturn('decimal'); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('getSchemaBuilder')->andReturn($schema); + + $builder->from('orders')->withSequentialPeriodMetrics( + 'Y-m', + ['revenue'], + dateColumn: 'profit', + ); + } + + public function testWithSequentialPeriodMetricsAllowsKnownDateColumnTypes() + { + $schema = m::mock(\Illuminate\Database\Schema\Builder::class); + $schema->shouldReceive('getColumnType')->with('orders', 'created_at')->andReturn('datetime'); + + $builder = $this->getMySqlBuilder(); + $builder->getConnection()->shouldReceive('getSchemaBuilder')->andReturn($schema); + + $builder->from('orders')->withSequentialPeriodMetrics( + 'Y-m', + ['revenue'], + ); + + $this->assertStringContainsString('revenue_sum_change_percent', strtolower($builder->toSql())); + } + + public function testWithSequentialPeriodMetricsRejectsStarColumnWithNonCountFunction() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Aggregate column [*] is only supported with the count() function, got [sum].'); + + $this->getMySqlBuilder() + ->from('orders') + ->withSequentialPeriodMetrics( + 'Y-m', + [['*', 'sum']], + ); + } + + public function testWithSequentialPeriodMetricsRejectsInvalidComparisonType() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported sequential period comparison type [ratio].'); + + $this->getMySqlBuilder() + ->from('orders') + ->withSequentialPeriodMetrics( + 'Y-m', + ['revenue', 'sum', ['ratio']], + ); + } + + public function testWithSequentialPeriodMetricsAcceptsExpressionColumnViaFluentAggregate() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + Aggregate::sum(new Raw('`revenue` * `quantity`'))->as('gross_revenue'), + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('sum(`revenue` * `quantity`) as `gross_revenue`', $sql); + $this->assertStringContainsString('gross_revenue_change_percent', $sql); + } + + public function testWithSequentialPeriodMetricsAcceptsExpressionColumnViaPositionalArray() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + [new Raw('`revenue` * `quantity`'), 'sum', 'gross_revenue'], + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('sum(`revenue` * `quantity`) as `gross_revenue`', $sql); + $this->assertStringContainsString('gross_revenue_change_percent', $sql); + } + + public function testWithSequentialPeriodMetricsAcceptsClosureSubQueryColumn() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + Aggregate::sum(function ($query) { + $query->from('order_items') + ->selectRaw('sum(`price` * `qty`)') + ->whereColumn('order_items.order_id', 'orders.id') + ->where('order_items.status', 'paid'); + })->as('gross_revenue'), + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('sum((select sum(`price` * `qty`) from `order_items`', $sql); + $this->assertStringContainsString('gross_revenue_change_percent', $sql); + $this->assertContains('paid', $builder->getBindings()); + } + + public function testWithSequentialPeriodMetricsAcceptsMixedExpressionAndStringAggregates() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + [ + 'revenue', + Aggregate::sum(new Raw('`revenue` - `cost`'))->as('profit'), + ['*', 'count', 'order_count'], + ], + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('sum(`revenue`) as `revenue_sum`', $sql); + $this->assertStringContainsString('sum(`revenue` - `cost`) as `profit`', $sql); + $this->assertStringContainsString('count(*) as `order_count`', $sql); + } + + public function testWithSequentialPeriodMetricsRejectsExpressionColumnWithoutAlias() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('An explicit alias is required when the aggregate column is an Expression or Closure.'); + + $this->getMySqlBuilder() + ->from('orders') + ->withSequentialPeriodMetrics( + 'Y-m', + Aggregate::sum(new Raw('`revenue` * `quantity`')), + ); + } + + public function testWithSequentialPeriodMetricsRejectsClosureColumnWithoutAlias() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('An explicit alias is required when the aggregate column is an Expression or Closure.'); + + $this->getMySqlBuilder() + ->from('orders') + ->withSequentialPeriodMetrics( + 'Y-m', + Aggregate::sum(function ($query) { + $query->from('order_items')->selectRaw('sum(`price`)'); + }), + ); + } + + public function testWithSequentialPeriodMetricsSkipsStarColumnCheckForExpression() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + Aggregate::avg(new Raw('coalesce(`price`, 0)'))->as('avg_price'), + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('avg(coalesce(`price`, 0)) as `avg_price`', $sql); + } + + public function testWithSequentialPeriodMetricsAcceptsExpressionViaLegacyKeyedForm() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + ['gross_revenue' => ['sum', new Raw('`revenue` * `quantity`')]], + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('sum(`revenue` * `quantity`) as `gross_revenue`', $sql); + $this->assertStringContainsString('gross_revenue_change_percent', $sql); + } + + public function testWithSequentialPeriodMetricsDefaultPercentPrecisionIsTwoAndDifferenceIsUnrounded() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + ['revenue', 'sum', [SequentialPeriodComparison::Percent, SequentialPeriodComparison::Difference], 'total_revenue'], + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('* 100, 2) as `total_revenue_change_percent`', $sql); + $this->assertStringNotContainsString('round(`laravel_seq_period_metrics`', $sql); + $this->assertStringContainsString('(`laravel_seq_period_metrics`.`total_revenue` - `laravel_seq_period_metrics`.`total_revenue_previous_period`) as `total_revenue_change`', $sql); + } + + public function testWithSequentialPeriodMetricsGlobalPrecisionAppliesToAggregatePercentAndDifference() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + ['revenue', 'sum', [SequentialPeriodComparison::Percent, SequentialPeriodComparison::Difference], 'total_revenue'], + precision: 4, + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('round(sum(`revenue`), 4) as `total_revenue`', $sql); + $this->assertStringContainsString('* 100, 4) as `total_revenue_change_percent`', $sql); + $this->assertStringContainsString('round(`laravel_seq_period_metrics`.`total_revenue` - `laravel_seq_period_metrics`.`total_revenue_previous_period`, 4) as `total_revenue_change`', $sql); + } + + public function testWithSequentialPeriodMetricsPerAggregatePrecisionOverridesGlobal() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + [ + Aggregate::sum('revenue')->as('total_revenue')->precision(2), + Aggregate::avg('cost')->as('avg_cost'), + ], + precision: 4, + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('round(sum(`revenue`), 2) as `total_revenue`', $sql); + $this->assertStringContainsString('* 100, 2) as `total_revenue_change_percent`', $sql); + $this->assertStringContainsString('round(avg(`cost`), 4) as `avg_cost`', $sql); + $this->assertStringContainsString('* 100, 4) as `avg_cost_change_percent`', $sql); + } + + public function testWithSequentialPeriodMetricsPrecisionZeroProducesIntegerRounding() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + Aggregate::sum('revenue')->as('total_revenue')->precision(0), + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('round(sum(`revenue`), 0) as `total_revenue`', $sql); + $this->assertStringContainsString('* 100, 0) as `total_revenue_change_percent`', $sql); + } + + public function testWithSequentialPeriodMetricsPrecisionAppliesToCountAggregate() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + Aggregate::count('*')->as('order_count')->precision(0), + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('round(count(*), 0) as `order_count`', $sql); + } + + public function testWithSequentialPeriodMetricsRejectsNegativeGlobalPrecision() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Default precision must be a non-negative integer or null.'); + + $this->getMySqlBuilder() + ->from('orders') + ->withSequentialPeriodMetrics( + 'Y-m', + ['revenue'], + precision: -1, + ); + } + + public function testAggregatePrecisionFluentSetterRejectsNegativeValues() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Aggregate precision must be a non-negative integer or null.'); + + Aggregate::sum('revenue')->precision(-1); + } + + public function testAggregatePrecisionFluentSetterAcceptsNullToReset() + { + $aggregate = Aggregate::sum('revenue')->precision(3)->precision(null); + + $this->assertNull($aggregate->precision); + } + + public function testWithSequentialPeriodMetricsPrecisionWithoutComparisonsStillRoundsAggregate() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + Aggregate::sum('revenue')->as('total_revenue')->withoutComparison()->precision(2), + selectComparisonsOnly: false, + ); + + $sql = strtolower($builder->toSql()); + + $this->assertStringContainsString('round(sum(`revenue`), 2) as `total_revenue`', $sql); + $this->assertStringNotContainsString('change_percent', $sql); + } + + public function testWithSequentialPeriodMetricsWithoutNumberFormatLeavesResultsUntouched() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + Aggregate::sum('revenue')->as('total_revenue')->precision(2), + ); + + $results = new Collection([ + (object) ['period' => '2024-03', 'total_revenue_change_percent' => '9.12'], + ]); + + $formatted = $builder->applyAfterQueryCallbacks($results); + + $this->assertSame('9.12', $formatted[0]->total_revenue_change_percent); + } + + public function testWithSequentialPeriodMetricsMethodLevelNumberFormatFormatsEveryOutputColumn() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + Aggregate::sum('revenue')->as('total_revenue'), + selectComparisonsOnly: false, + precision: 2, + thousandsSeparator: '.', + decimalSeparator: ',', + ); + + $results = new Collection([ + (object) [ + 'period' => '2024-03', + 'total_revenue' => '13868830.91', + 'total_revenue_previous_period' => '12719568.88', + 'total_revenue_change_percent' => '9.04', + ], + ]); + + $formatted = $builder->applyAfterQueryCallbacks($results); + $row = $formatted[0]; + + $this->assertSame('13.868.830,91', $row->total_revenue); + $this->assertSame('12.719.568,88', $row->total_revenue_previous_period); + $this->assertSame('9,04', $row->total_revenue_change_percent); + $this->assertSame('2024-03', $row->period); + } + + public function testWithSequentialPeriodMetricsSelectComparisonsOnlyFormatsOnlyComparisonColumns() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + Aggregate::sum('revenue')->as('total_revenue')->precision(2)->numberFormat('.', ','), + ); + + $results = new Collection([ + (object) [ + 'period' => '2024-03', + 'total_revenue_change_percent' => '9.04', + ], + ]); + + $formatted = $builder->applyAfterQueryCallbacks($results); + + $this->assertSame('9,04', $formatted[0]->total_revenue_change_percent); + } + + public function testWithSequentialPeriodMetricsPerAggregateNumberFormatOverridesMethodLevel() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + [ + Aggregate::sum('revenue')->as('total_revenue')->precision(2)->numberFormat('.', ','), + Aggregate::avg('cost')->as('avg_cost')->precision(2), + ], + precision: 2, + thousandsSeparator: ',', + decimalSeparator: '.', + ); + + $results = new Collection([ + (object) [ + 'period' => '2024-03', + 'total_revenue_change_percent' => '9.04', + 'avg_cost_change_percent' => '3200.75', + ], + ]); + + $formatted = $builder->applyAfterQueryCallbacks($results); + $row = $formatted[0]; + + $this->assertSame('9,04', $row->total_revenue_change_percent); + $this->assertSame('3,200.75', $row->avg_cost_change_percent); + } + + public function testWithSequentialPeriodMetricsNumberFormatHandlesArrayRows() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + Aggregate::sum('revenue')->as('total_revenue')->precision(2)->numberFormat('.', ','), + ); + + $results = new Collection([ + ['period' => '2024-03', 'total_revenue_change_percent' => '9.04'], + ]); + + $formatted = $builder->applyAfterQueryCallbacks($results); + + $this->assertSame('9,04', $formatted[0]['total_revenue_change_percent']); + } + + public function testWithSequentialPeriodMetricsNumberFormatLeavesNullAndNonNumericValuesUntouched() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + Aggregate::sum('revenue')->as('total_revenue')->precision(2)->numberFormat('.', ','), + ); + + $results = new Collection([ + (object) ['period' => '2024-03', 'total_revenue_change_percent' => null], + (object) ['period' => '2024-04', 'total_revenue_change_percent' => 'not-a-number'], + ]); + + $formatted = $builder->applyAfterQueryCallbacks($results); + + $this->assertNull($formatted[0]->total_revenue_change_percent); + $this->assertSame('not-a-number', $formatted[1]->total_revenue_change_percent); + } + + public function testWithSequentialPeriodMetricsNumberFormatWithoutPrecisionPreservesNaturalDecimals() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + Aggregate::sum('revenue')->as('total_revenue')->numberFormat('.', ','), + selectComparisonsOnly: false, + ); + + $results = new Collection([ + (object) [ + 'period' => '2024-03', + 'total_revenue' => '13868830.9100', + 'total_revenue_previous_period' => '12719568', + 'total_revenue_change_percent' => '9.04', + ], + ]); + + $formatted = $builder->applyAfterQueryCallbacks($results); + $row = $formatted[0]; + + $this->assertSame('13.868.830,9100', $row->total_revenue); + $this->assertSame('12.719.568', $row->total_revenue_previous_period); + $this->assertSame('9,04', $row->total_revenue_change_percent); + } + + public function testWithSequentialPeriodMetricsNumberFormatOnlyDecimalSeparator() + { + $builder = $this->getMySqlBuilder(); + $builder->from('orders'); + $builder->withSequentialPeriodMetrics( + 'Y-m', + Aggregate::sum('revenue')->as('total_revenue')->precision(2)->numberFormat(decimalSeparator: ','), + ); + + $results = new Collection([ + (object) ['period' => '2024-03', 'total_revenue_change_percent' => '9.04'], + ]); + + $formatted = $builder->applyAfterQueryCallbacks($results); + + $this->assertSame('9,04', $formatted[0]->total_revenue_change_percent); + } + + public function testAggregateNumberFormatFluentSetter() + { + $aggregate = Aggregate::sum('revenue')->numberFormat('.', ','); + + $this->assertSame('.', $aggregate->thousandsSeparator); + $this->assertSame(',', $aggregate->decimalSeparator); + + $aggregate->numberFormat(null, null); + + $this->assertNull($aggregate->thousandsSeparator); + $this->assertNull($aggregate->decimalSeparator); + } }