first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View 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);
}
}

View 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',
};
}
}

View 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));
}
}

View 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,
];
}
}
}

View 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;
}
}

View 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);
}
}
}

View 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;
}
}

View 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)),
];
}
}