Teren-app/app/Services/ReportQueryBuilder.php
2026-01-02 12:32:20 +01:00

249 lines
7.6 KiB
PHP

<?php
namespace App\Services;
use App\Models\Report;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
class ReportQueryBuilder
{
/**
* Build a query from a database-driven report configuration.
*/
public function build(Report $report, array $filters = []): Builder
{
// Load all required relationships
$report->load(['entities', 'columns', 'conditions', 'orders']);
// 1. Start with base model query
$query = $this->buildBaseQuery($report);
// 2. Apply joins from report_entities
$this->applyJoins($query, $report);
// 3. Select columns from report_columns
$this->applySelects($query, $report);
// 4. Apply GROUP BY if aggregate functions are used
$this->applyGroupBy($query, $report);
// 5. Apply conditions from report_conditions
$this->applyConditions($query, $report, $filters);
// 6. Apply ORDER BY from report_orders
$this->applyOrders($query, $report);
return $query;
}
/**
* Build the base query from the first entity.
*/
protected function buildBaseQuery(Report $report): Builder
{
$baseEntity = $report->entities->firstWhere('join_type', 'base');
if (!$baseEntity) {
throw new \RuntimeException("Report {$report->slug} has no base entity defined.");
}
$modelClass = $baseEntity->model_class;
if (!class_exists($modelClass)) {
throw new \RuntimeException("Model class {$modelClass} does not exist.");
}
return $modelClass::query();
}
/**
* Apply joins from report entities.
*/
protected function applyJoins(Builder $query, Report $report): void
{
$entities = $report->entities->where('join_type', '!=', 'base');
foreach ($entities as $entity) {
$table = $this->getTableFromModel($entity->model_class);
// Use alias if provided
if ($entity->alias) {
$table = "{$table} as {$entity->alias}";
}
$joinMethod = $entity->join_type;
$query->{$joinMethod}(
$table,
$entity->join_first,
$entity->join_operator ?? '=',
$entity->join_second
);
}
}
/**
* Apply column selections.
*/
protected function applySelects(Builder $query, Report $report): void
{
$columns = $report->columns
->where('visible', true)
->map(fn($col) => DB::raw("{$col->expression} as {$col->key}"))
->toArray();
if (!empty($columns)) {
$query->select($columns);
}
}
/**
* Apply GROUP BY clause if aggregate functions are detected.
*/
protected function applyGroupBy(Builder $query, Report $report): void
{
$visibleColumns = $report->columns->where('visible', true);
// Check if any column uses aggregate functions
$hasAggregates = $visibleColumns->contains(function ($col) {
return preg_match('/\b(COUNT|SUM|AVG|MIN|MAX|GROUP_CONCAT)\s*\(/i', $col->expression);
});
if (!$hasAggregates) {
return; // No aggregates, no grouping needed
}
// Find non-aggregate columns that need to be in GROUP BY
$groupByColumns = $visibleColumns
->filter(function ($col) {
// Check if this column does NOT use an aggregate function
return !preg_match('/\b(COUNT|SUM|AVG|MIN|MAX|GROUP_CONCAT)\s*\(/i', $col->expression);
})
->map(function ($col) {
// Extract the actual column expression (before any COALESCE, CAST, etc.)
// For COALESCE(segments.name, 'default'), we need segments.name
if (preg_match('/COALESCE\s*\(\s*([^,]+)\s*,/i', $col->expression, $matches)) {
return trim($matches[1]);
}
// For simple columns, use as-is
return $col->expression;
})
->filter() // Remove empty values
->values()
->toArray();
if (!empty($groupByColumns)) {
foreach ($groupByColumns as $column) {
$query->groupBy(DB::raw($column));
}
}
}
/**
* Apply conditions (WHERE clauses).
*/
protected function applyConditions(Builder $query, Report $report, array $filters): void
{
$conditions = $report->conditions->where('enabled', true);
// Group conditions by group_id
$groups = $conditions->groupBy('group_id');
foreach ($groups as $groupId => $groupConditions) {
$query->where(function ($subQuery) use ($groupConditions, $filters) {
foreach ($groupConditions as $condition) {
$value = $this->resolveConditionValue($condition, $filters);
// Skip if filter-based and no value provided
if ($condition->value_type === 'filter' && $value === null) {
continue;
}
$method = $condition->logical_operator === 'OR' ? 'orWhere' : 'where';
$this->applyCondition($subQuery, $condition, $value, $method);
}
});
}
}
/**
* Apply a single condition to the query.
*/
protected function applyCondition(Builder $query, $condition, $value, string $method): void
{
$column = $condition->column;
$operator = strtoupper($condition->operator);
switch ($operator) {
case 'IS NULL':
$query->{$method . 'Null'}($column);
break;
case 'IS NOT NULL':
$query->{$method . 'NotNull'}($column);
break;
case 'IN':
$values = is_array($value) ? $value : explode(',', $value);
$query->{$method . 'In'}($column, $values);
break;
case 'NOT IN':
$values = is_array($value) ? $value : explode(',', $value);
$query->{$method . 'NotIn'}($column, $values);
break;
case 'BETWEEN':
if (is_array($value) && count($value) === 2) {
$query->{$method . 'Between'}($column, $value);
}
break;
case 'LIKE':
$query->{$method}($column, 'LIKE', $value);
break;
default:
$query->{$method}($column, $operator, $value);
break;
}
}
/**
* Resolve condition value based on value_type.
*/
protected function resolveConditionValue($condition, array $filters)
{
return match ($condition->value_type) {
'static' => $condition->value,
'filter' => $filters[$condition->filter_key] ?? null,
'expression' => DB::raw($condition->value),
default => null,
};
}
/**
* Apply ORDER BY clauses.
*/
protected function applyOrders(Builder $query, Report $report): void
{
foreach ($report->orders as $order) {
$query->orderBy($order->column, $order->direction);
}
}
/**
* Get table name from model class.
*/
protected function getTableFromModel(string $modelClass): string
{
if (!class_exists($modelClass)) {
throw new \RuntimeException("Model class {$modelClass} does not exist.");
}
return (new $modelClass)->getTable();
}
}