78
common/Database/Metrics/BaseMetric.php
Executable file
78
common/Database/Metrics/BaseMetric.php
Executable file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Database\Metrics;
|
||||
|
||||
use Common\Database\Metrics\Traits\RoundingPrecision;
|
||||
use Illuminate\Contracts\Database\Query\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Query\Expression;
|
||||
|
||||
abstract class BaseMetric
|
||||
{
|
||||
use RoundingPrecision;
|
||||
|
||||
protected Builder $query;
|
||||
|
||||
public function __construct(
|
||||
public Builder|string|Model $model,
|
||||
public MetricDateRange $dateRange,
|
||||
public string|Expression|null $column = null,
|
||||
public string|null $dateColumn = null,
|
||||
protected int $limit = 100,
|
||||
) {
|
||||
$this->query =
|
||||
$this->model instanceof Builder
|
||||
? $this->model->clone()
|
||||
: (new $this->model())->newQuery();
|
||||
|
||||
if (!$this->dateColumn) {
|
||||
$this->dateColumn = $this->query
|
||||
->getModel()
|
||||
->getQualifiedCreatedAtColumn();
|
||||
}
|
||||
}
|
||||
|
||||
public function count(): array
|
||||
{
|
||||
return $this->aggregate('count');
|
||||
}
|
||||
|
||||
public function average(): array
|
||||
{
|
||||
return $this->aggregate('avg');
|
||||
}
|
||||
|
||||
public function sum(): array
|
||||
{
|
||||
return $this->aggregate('sum');
|
||||
}
|
||||
|
||||
public function max(): array
|
||||
{
|
||||
return $this->aggregate('max');
|
||||
}
|
||||
|
||||
public function min(): array
|
||||
{
|
||||
return $this->aggregate('min');
|
||||
}
|
||||
|
||||
abstract protected function aggregate(string $function);
|
||||
|
||||
protected function getWrappedColumn(): string
|
||||
{
|
||||
$column =
|
||||
$this->column ?: $this->query->getModel()->getQualifiedKeyName();
|
||||
return $column instanceof Expression
|
||||
? (string) $column
|
||||
: $this->query
|
||||
->getQuery()
|
||||
->getGrammar()
|
||||
->wrap($column);
|
||||
}
|
||||
|
||||
protected function round(int|float $value): float
|
||||
{
|
||||
return round($value, $this->roundingPrecision, $this->roundingMode);
|
||||
}
|
||||
}
|
||||
102
common/Database/Metrics/MetricDateRange.php
Executable file
102
common/Database/Metrics/MetricDateRange.php
Executable file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Database\Metrics;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\CarbonInterval;
|
||||
use Carbon\CarbonPeriod;
|
||||
|
||||
class MetricDateRange
|
||||
{
|
||||
const YEAR = 'year';
|
||||
const MONTH = 'month';
|
||||
const WEEK = 'week';
|
||||
const DAY = 'day';
|
||||
const HOUR = 'hour';
|
||||
const MINUTE = 'minute';
|
||||
|
||||
public CarbonImmutable $start;
|
||||
public CarbonImmutable $end;
|
||||
public string $timezone;
|
||||
public string $granularity;
|
||||
public CarbonPeriod $period;
|
||||
|
||||
public function __construct(
|
||||
string $start = null,
|
||||
string $end = null,
|
||||
string $timezone = null,
|
||||
string $granularity = null,
|
||||
) {
|
||||
$this->start = $start
|
||||
? CarbonImmutable::parse($start)->timezone($timezone)
|
||||
: CarbonImmutable::today()->startOfWeek();
|
||||
$this->end = $end
|
||||
? CarbonImmutable::parse($end)->timezone($timezone)
|
||||
: CarbonImmutable::today()->endOfWeek();
|
||||
|
||||
$this->timezone = $timezone ?: config('app.timezone');
|
||||
$this->setGranularity($granularity);
|
||||
|
||||
$this->period = CarbonPeriod::create(
|
||||
$this->start,
|
||||
$this->end,
|
||||
);
|
||||
$this->period->setDateInterval(
|
||||
CarbonInterval::make(1, $this->granularity),
|
||||
);
|
||||
}
|
||||
|
||||
public function getPreviousPeriod(): self
|
||||
{
|
||||
return new self(
|
||||
$this->start->sub($this->end->diffAsCarbonInterval($this->start)),
|
||||
$this->start->subSecond(),
|
||||
);
|
||||
}
|
||||
|
||||
public function getCacheKey(): string
|
||||
{
|
||||
return sprintf(
|
||||
'%s-%s-%s',
|
||||
$this->start->timestamp,
|
||||
$this->end->timestamp,
|
||||
$this->timezone,
|
||||
);
|
||||
}
|
||||
|
||||
protected function setGranularity(string $granularity = null): void {
|
||||
// set unit specified by user
|
||||
if ($granularity) {
|
||||
$this->granularity = $granularity;
|
||||
// set the smallest supported unit based on start and end date
|
||||
} else {
|
||||
if ($this->start->diffInYears($this->end) >= 3) {
|
||||
$this->granularity = self::YEAR;
|
||||
} elseif ($this->start->diffInMonths($this->end) >= 4) {
|
||||
$this->granularity = self::MONTH;
|
||||
} elseif ($this->start->diffInDays($this->end) > 14) {
|
||||
$this->granularity = self::WEEK;
|
||||
} elseif ($this->start->diffInDays($this->end) > 1) {
|
||||
$this->granularity = self::DAY;
|
||||
} elseif ($this->start->diffInHours($this->end) > 1) {
|
||||
$this->granularity = self::HOUR;
|
||||
} else {
|
||||
$this->granularity = self::MINUTE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return format by which metric values should be grouped based on granularity.
|
||||
*/
|
||||
public function getGroupingFormat(): string {
|
||||
return match ($this->granularity) {
|
||||
$this::YEAR => 'Y',
|
||||
$this::MONTH => 'Y-m',
|
||||
$this::WEEK => 'o-W',
|
||||
$this::DAY => 'Y-m-d',
|
||||
$this::HOUR => 'Y-m-d H:00',
|
||||
$this::MINUTE => 'Y-m-d H:i:00',
|
||||
};
|
||||
}
|
||||
}
|
||||
76
common/Database/Metrics/Partition.php
Executable file
76
common/Database/Metrics/Partition.php
Executable file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Database\Metrics;
|
||||
|
||||
use Illuminate\Contracts\Database\Query\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Query\Expression;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Partition extends BaseMetric
|
||||
{
|
||||
public function __construct(
|
||||
Model|Builder|string $model,
|
||||
public string $groupBy,
|
||||
MetricDateRange $dateRange,
|
||||
string|Expression|null $column = null,
|
||||
?string $dateColumn = null,
|
||||
int $limit = 50,
|
||||
protected array $additionalColumns = [],
|
||||
) {
|
||||
parent::__construct($model, $dateRange, $column, $dateColumn, $limit);
|
||||
}
|
||||
|
||||
protected function aggregate(string $function): array
|
||||
{
|
||||
$select = [
|
||||
$this->groupBy,
|
||||
DB::raw("{$function}({$this->getWrappedColumn()}) as aggregate"),
|
||||
...$this->additionalColumns,
|
||||
];
|
||||
|
||||
$results = $this->query
|
||||
->select($select)
|
||||
->groupBy($this->groupBy, ...$this->additionalColumns)
|
||||
->when(
|
||||
$this->dateRange,
|
||||
fn($query) => $query->whereBetween($this->dateColumn, [
|
||||
$this->dateRange->start,
|
||||
$this->dateRange->end,
|
||||
]),
|
||||
)
|
||||
->limit($this->limit)
|
||||
->get();
|
||||
|
||||
$data = $results->map(function ($result) {
|
||||
$finalResult = [
|
||||
'label' => $this->getLabel($result),
|
||||
'value' => $this->round($result->aggregate),
|
||||
];
|
||||
foreach ($this->additionalColumns as $column) {
|
||||
$finalResult[$column] = $result->{$column};
|
||||
}
|
||||
return $finalResult;
|
||||
});
|
||||
$total = $data->sum('value');
|
||||
$data = $data
|
||||
->map(function ($item) use ($total) {
|
||||
$item['percentage'] = round((100 * $item['value']) / $total, 1);
|
||||
return $item;
|
||||
})
|
||||
->sortByDesc('value')
|
||||
->values();
|
||||
|
||||
return $data->all();
|
||||
}
|
||||
|
||||
protected function getLabel(Model $result): string
|
||||
{
|
||||
$label = with(
|
||||
$result->{last(explode('.', $this->groupBy))},
|
||||
fn($key) => $key,
|
||||
);
|
||||
|
||||
return __(ucfirst($label));
|
||||
}
|
||||
}
|
||||
44
common/Database/Metrics/Traits/GeneratesTrendResults.php
Executable file
44
common/Database/Metrics/Traits/GeneratesTrendResults.php
Executable file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Database\Metrics\Traits;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Common\Database\Metrics\MetricDateRange;
|
||||
|
||||
trait GeneratesTrendResults
|
||||
{
|
||||
protected function getAllPossibleDateResults(
|
||||
MetricDateRange $dateRange,
|
||||
): array {
|
||||
$format = $dateRange->getGroupingFormat();
|
||||
|
||||
// dates in range will already be in correct timezone
|
||||
$possibleDateResults = [];
|
||||
foreach ($dateRange->period as $date) {
|
||||
$possibleDateResults[
|
||||
(string) $date->format($format)
|
||||
] = $this->formatTrendResult($dateRange->granularity, $date);
|
||||
}
|
||||
|
||||
return $possibleDateResults;
|
||||
}
|
||||
|
||||
protected function formatTrendResult(
|
||||
string $granularity,
|
||||
CarbonInterface $date,
|
||||
int $value = 0,
|
||||
): array {
|
||||
if ($granularity === $this->dateRange::WEEK) {
|
||||
return [
|
||||
'date' => $date->startOfWeek()->toISOString(),
|
||||
'endDate' => $date->endOfWeek()->toISOString(),
|
||||
'value' => $value,
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'date' => $date->toISOString(),
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
32
common/Database/Metrics/Traits/RoundingPrecision.php
Executable file
32
common/Database/Metrics/Traits/RoundingPrecision.php
Executable file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Database\Metrics\Traits;
|
||||
|
||||
trait RoundingPrecision
|
||||
{
|
||||
public int $roundingPrecision = 0;
|
||||
public int $roundingMode = PHP_ROUND_HALF_UP;
|
||||
|
||||
/**
|
||||
* Set the precision level used when rounding the value.
|
||||
*/
|
||||
public function precision(
|
||||
int $precision = 0,
|
||||
int $mode = PHP_ROUND_HALF_UP,
|
||||
): static {
|
||||
$this->roundingPrecision = $precision;
|
||||
|
||||
if (
|
||||
in_array($mode, [
|
||||
PHP_ROUND_HALF_UP,
|
||||
PHP_ROUND_HALF_DOWN,
|
||||
PHP_ROUND_HALF_EVEN,
|
||||
PHP_ROUND_HALF_ODD,
|
||||
])
|
||||
) {
|
||||
$this->roundingMode = $mode;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
71
common/Database/Metrics/Trend.php
Executable file
71
common/Database/Metrics/Trend.php
Executable file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Database\Metrics;
|
||||
|
||||
use Common\Database\Metrics\Traits\GeneratesTrendResults;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Trend extends BaseMetric
|
||||
{
|
||||
use GeneratesTrendResults;
|
||||
|
||||
protected function aggregate(string $function): array
|
||||
{
|
||||
$expression = new TrendDateExpression(
|
||||
$this->query,
|
||||
$this->dateColumn,
|
||||
$this->dateRange->granularity,
|
||||
$this->dateRange->timezone,
|
||||
);
|
||||
|
||||
$results = $this->query
|
||||
->select(
|
||||
DB::raw(
|
||||
"{$expression->getValue(
|
||||
DB::connection()->getQueryGrammar(),
|
||||
)} as date_result, {$function}({$this->getWrappedColumn()}) as aggregate",
|
||||
),
|
||||
)
|
||||
->whereBetween($this->dateColumn, [
|
||||
$this->dateRange->start,
|
||||
$this->dateRange->end,
|
||||
])
|
||||
->groupBy(DB::raw($expression))
|
||||
->orderBy('date_result')
|
||||
->limit($this->limit)
|
||||
->get();
|
||||
|
||||
$mergedResults = array_replace(
|
||||
$this->getAllPossibleDateResults($this->dateRange),
|
||||
$results
|
||||
->mapWithKeys(function ($result) {
|
||||
return [
|
||||
(string) $result->date_result => $this->formatTrendResult(
|
||||
$this->dateRange->granularity,
|
||||
$this->parseMysqlDate($result->date_result),
|
||||
$result->aggregate,
|
||||
),
|
||||
];
|
||||
})
|
||||
->all(),
|
||||
);
|
||||
|
||||
return array_values($mergedResults);
|
||||
}
|
||||
|
||||
protected function parseMysqlDate(string $mysqlDate): Carbon
|
||||
{
|
||||
// dates coming from mysql will be in user's timezone (due to + Interval x HOUR),
|
||||
// set user's timezone on carbon as well, so that "toIsoString" will return correct date.
|
||||
if ($this->dateRange->granularity === $this->dateRange::WEEK) {
|
||||
[$year, $week] = explode('-', $mysqlDate);
|
||||
return Carbon::today($this->dateRange->timezone)->setISODate(
|
||||
(int) $year,
|
||||
(int) $week,
|
||||
);
|
||||
} else {
|
||||
return Carbon::parse($mysqlDate, $this->dateRange->timezone);
|
||||
}
|
||||
}
|
||||
}
|
||||
87
common/Database/Metrics/TrendDateExpression.php
Executable file
87
common/Database/Metrics/TrendDateExpression.php
Executable file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Database\Metrics;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Grammar;
|
||||
use Illuminate\Database\Query\Expression;
|
||||
|
||||
class TrendDateExpression extends Expression
|
||||
{
|
||||
public function __construct(
|
||||
protected Builder $query,
|
||||
protected string $column,
|
||||
protected string $unit,
|
||||
protected string $timezone,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getValue(Grammar $grammar): string
|
||||
{
|
||||
// dates in database are stored in UTC, and date_format will not return hour in many cases, so we need
|
||||
// to convert date_format result to specified timezone in mysql because it can use the full timestamp
|
||||
$offset = $this->offset();
|
||||
if ($offset > 0) {
|
||||
$interval = '+ INTERVAL ' . $offset . ' HOUR';
|
||||
} elseif ($offset === 0) {
|
||||
$interval = '';
|
||||
} else {
|
||||
$interval = '- INTERVAL ' . $offset * -1 . ' HOUR';
|
||||
}
|
||||
|
||||
switch ($this->unit) {
|
||||
case 'year':
|
||||
return "date_format({$this->wrap(
|
||||
$this->column,
|
||||
)} {$interval}, '%Y')";
|
||||
case 'month':
|
||||
return "date_format({$this->wrap(
|
||||
$this->column,
|
||||
)} {$interval}, '%Y-%m')";
|
||||
case 'week':
|
||||
return "date_format({$this->wrap(
|
||||
$this->column,
|
||||
)} {$interval}, '%x-%v')";
|
||||
case 'day':
|
||||
return "date_format({$this->wrap(
|
||||
$this->column,
|
||||
)} {$interval}, '%Y-%m-%d')";
|
||||
case 'hour':
|
||||
return "date_format({$this->wrap(
|
||||
$this->column,
|
||||
)} {$interval}, '%Y-%m-%d %H:00')";
|
||||
case 'minute':
|
||||
return "date_format({$this->wrap(
|
||||
$this->column,
|
||||
)} {$interval}, '%Y-%m-%d %H:%i:00')";
|
||||
}
|
||||
}
|
||||
|
||||
protected function wrap(string $value): string
|
||||
{
|
||||
return $this->query
|
||||
->getQuery()
|
||||
->getGrammar()
|
||||
->wrap($value);
|
||||
}
|
||||
|
||||
protected function offset(): int
|
||||
{
|
||||
$timezoneOffset = function ($timezone) {
|
||||
return (new DateTime(
|
||||
CarbonImmutable::now()->format('Y-m-d H:i:s'),
|
||||
new DateTimeZone($timezone),
|
||||
))->getOffset() /
|
||||
60 /
|
||||
60;
|
||||
};
|
||||
|
||||
$appOffset = $timezoneOffset(config('app.timezone'));
|
||||
$userOffset = $timezoneOffset($this->timezone);
|
||||
|
||||
return $userOffset - $appOffset;
|
||||
}
|
||||
}
|
||||
68
common/Database/Metrics/ValueMetric.php
Executable file
68
common/Database/Metrics/ValueMetric.php
Executable file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Database\Metrics;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Query\Expression;
|
||||
|
||||
class ValueMetric extends BaseMetric
|
||||
{
|
||||
public function __construct(
|
||||
Model|Builder|string $model,
|
||||
MetricDateRange $dateRange,
|
||||
string|Expression|null $column = null,
|
||||
?string $dateColumn = null,
|
||||
) {
|
||||
parent::__construct($model, $dateRange, $column, $dateColumn);
|
||||
}
|
||||
|
||||
protected function aggregate(string $function): array
|
||||
{
|
||||
$column =
|
||||
$this->column ?: $this->query->getModel()->getQualifiedKeyName();
|
||||
|
||||
$previousValue = round(
|
||||
(clone $this->query)
|
||||
->when($this->dateRange, function ($query) {
|
||||
$previous = $this->dateRange->getPreviousPeriod();
|
||||
$query->whereBetween($this->dateColumn, [
|
||||
$previous->start,
|
||||
$previous->end,
|
||||
]);
|
||||
})
|
||||
->{$function}($column) ?? 0,
|
||||
$this->roundingPrecision,
|
||||
$this->roundingMode,
|
||||
);
|
||||
|
||||
$currentValue = round(
|
||||
(clone $this->query)
|
||||
->when(
|
||||
$this->dateRange,
|
||||
fn($query) => $query->whereBetween($this->dateColumn, [
|
||||
$this->dateRange->start,
|
||||
$this->dateRange->end,
|
||||
]),
|
||||
)
|
||||
->{$function}($column) ?? 0,
|
||||
$this->roundingPrecision,
|
||||
$this->roundingMode,
|
||||
);
|
||||
|
||||
if (!$currentValue && !$previousValue) {
|
||||
$percentageChange = 0; // no change
|
||||
} elseif (!$previousValue) {
|
||||
$percentageChange = 100; // 100% increase
|
||||
} else {
|
||||
$percentageChange =
|
||||
(($currentValue - $previousValue) / $previousValue) * 100;
|
||||
}
|
||||
|
||||
return [
|
||||
'previousValue' => $previousValue,
|
||||
'currentValue' => $currentValue,
|
||||
'percentageChange' => min(300, round($percentageChange, 2)),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user