diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2027cce..dc9c5aa 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,12 @@ "Bash(vendor/bin/pest:*)", "Bash(vendor/bin/pint:*)", "Bash(git push:*)", - "Bash(gh pr:*)" + "Bash(gh pr:*)", + "Bash(composer analyse:*)", + "Bash(composer test:*)", + "Bash(composer format:*)", + "Bash(git stash:*)", + "Bash(git commit:*)" ] } } diff --git a/README.md b/README.md index c995a34..6080b5c 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,62 @@ -# A drop in query builder for Laravel models. +# Query Builder for Laravel -Add a query builder and report table for youir models. +Drop-in Livewire components for building query builders, tables, and reports on Eloquent models. ## Installation -You can install the package via composer: - ```bash composer require act-training/query-builder ``` -Optionally, you can publish the views using +Optionally, publish the views: ```bash php artisan vendor:publish --tag="query-builder-views" ``` -## Usage -Models should use the AppliesCriteria trait. +## Model Setup + +Models should use the `AppliesCriteria` trait: ```php -class User extends Authenticatable +use ACTTraining\QueryBuilder\Support\Criteria\AppliesCriteria; + +class Employee extends Model { use AppliesCriteria; - - ... - } ``` -Create a Livewire component and make sure it extends QueryBuilder. +## Components + +The package provides three main abstract Livewire components to extend: + +| Component | Purpose | +|---|---| +| `QueryBuilder` | Full query builder with criteria-based AND/OR filtering | +| `TableBuilder` | Simpler table with URL-persisted filters and search | +| `ReportBuilder` | Dynamic column selection, save/load/export, optional groupBy | + +### QueryBuilder + +Create a Livewire component that extends `QueryBuilder`. Define `query()`, `columns()`, and `conditions()`: ```php rowClickable(false); } + public function query(): Builder + { + return Employee::query()->with(['contract', 'contract.job', 'contract.location']); + } + public function columns(): array { return [ @@ -74,20 +97,218 @@ class EmployeesReport extends QueryBuilder DateCondition::make('Start Date', 'contract.start_date'), ]; } +} +``` + +### TableBuilder + +A simpler alternative using filters instead of criteria. Define `query()`, `columns()`, and `filters()`: + +```php +sortable()->searchable(), + Column::make('Department', 'department')->sortable(), + ]; + } + + public function filters(): array + { + return [ + TextFilter::make('Name', 'full_name'), + SelectFilter::make('Department', 'department') + ->options(['HR' => 'HR', 'IT' => 'IT', 'Finance' => 'Finance']), + ]; + } +} +``` + +### ReportBuilder + +Extends `QueryBuilder` with dynamic column selection. Users pick columns at runtime from `availableColumns()`, and the component builds columns and conditions automatically: + +```php +with(['contract', 'contract.job', 'contract.location']); } - public function rowClick($row): void + public function availableColumns(): array { - $this->dispatchBrowserEvent('notify', ['content' => 'The row was clicked', 'type' => 'success']); + return [ + 'Employee' => [ + ['label' => 'Name', 'key' => 'full_name'], + ['label' => 'Email', 'key' => 'email'], + ], + 'Contract' => [ + ['label' => 'Job Title', 'key' => 'contract.job.name'], + ['label' => 'Location', 'key' => 'contract.location.name'], + ['label' => 'Start Date', 'key' => 'contract.start_date', 'type' => 'date'], + ['label' => 'Salary', 'key' => 'contract.salary', 'type' => 'number'], + ['label' => 'Line Manager', 'key' => 'contract.line_manager', 'type' => 'boolean'], + ], + ]; + } +} +``` + +Column definitions in `availableColumns()` support these keys: + +| Key | Description | +|---|---| +| `label` | Display label (required) | +| `key` | Column key, supports dot-notation for relationships (required) | +| `type` | Column type: `text` (default), `number`, `float`, `boolean`, `date`, `enum`, `null`, `view` | +| `sortable` | Enable sorting on this column | +| `justify` | Alignment: `left`, `center`, `right` | +| `view` | Custom Blade component for rendering | +| `options` | Options array for `enum` type | +| `skipCondition` | Exclude from query builder conditions | + +### ReportBuilder with GroupBy + +Enable groupBy to allow users to group results by a column with aggregate functions (COUNT, SUM, AVG, MIN, MAX). Set `$enableGroupBy = true`: + +```php +with(['contract', 'contract.job', 'contract.location']); } + public function availableColumns(): array + { + return [ + 'Employee' => [ + ['label' => 'Name', 'key' => 'full_name'], + ['label' => 'Department', 'key' => 'department'], + ], + 'Contract' => [ + ['label' => 'Job Title', 'key' => 'contract.job.name'], + ['label' => 'Location', 'key' => 'contract.location.name'], + ['label' => 'Salary', 'key' => 'contract.salary', 'type' => 'number'], + ], + ]; + } } ``` +When `enableGroupBy` is true, the report editor UI shows additional controls: + +- **Group By Column** - select which column to group by +- **Function** - aggregate function (COUNT, SUM, AVG, MIN, MAX) +- **Aggregate Column** - which column to aggregate (defaults to `id`, numeric columns from `availableColumns()` are included automatically) + +An "Aggregate" column is automatically appended to the table when grouping is active. + +You can customise the available aggregate functions and groupable/aggregatable columns by overriding: + +```php +public function aggregateFunctions(): array +{ + return ['COUNT', 'SUM', 'AVG']; +} + +public function groupableColumns(): array +{ + // Return a flat array of ['label' => ..., 'key' => ...] items + return [ + ['label' => 'Department', 'key' => 'department'], + ['label' => 'Location', 'key' => 'contract.location.name'], + ]; +} + +public function aggregatableColumns(): array +{ + return [ + ['label' => 'ID', 'key' => 'id'], + ['label' => 'Salary', 'key' => 'contract.salary'], + ]; +} +``` + +## Columns + +All column types use a fluent `make($label, $key)` constructor. If `$key` is omitted, it defaults to `Str::snake($label)`. + +| Column Type | Description | +|---|---| +| `Column` | General text column | +| `BooleanColumn` | Boolean/checkbox display | +| `DateColumn` | Date formatting with `->format()` and `->humanDiff()` | +| `ViewColumn` | Custom Blade view rendering | + +Columns support: `->sortable()`, `->searchable()`, `->justify('right')`, `->component('view.name')`, `->reformatUsing(callable)`, `->withSubTitle(callable)`, `->hideIf(condition)`, `->hideHeader()`. + +## Conditions (QueryBuilder) + +Used with `QueryBuilder` and `ReportBuilder` for criteria-based filtering: + +| Condition Type | Operations | +|---|---| +| `TextCondition` | equals, not_equals, contains, starts_with, ends_with | +| `NumberCondition` | equals, not_equals, greater_than, less_than, is_between | +| `FloatCondition` | equals, not_equals, greater_than, less_than, is_between | +| `BooleanCondition` | is_true, is_false | +| `DateCondition` | equals, before, after, is_between | +| `EnumCondition` | equals, not_equals (with defined options) | +| `NullCondition` | is_set, is_not_set | + +## Filters (TableBuilder) + +Used with `TableBuilder` for simpler key/operator/value filtering: + +`TextFilter`, `NumberFilter`, `DateFilter`, `BooleanFilter`, `SelectFilter`, `NullFilter` + +## Relationship Support + +Dot-notation keys work throughout columns, conditions, and filters to traverse Eloquent relationships: + +```php +Column::make('Job Title', 'contract.job.name') +TextCondition::make('Location', 'contract.location.name') +``` + ## Testing ```bash diff --git a/resources/views/group-table.blade.php b/resources/views/group-table.blade.php index 5959b7c..efc313e 100644 --- a/resources/views/group-table.blade.php +++ b/resources/views/group-table.blade.php @@ -1,220 +1,2 @@ -
- - @includeIf($this->headerView()) - - @if($this->availableColumns()) - @include('query-builder::report-editor') - @endif - - @if($this->columns()) - -
- @if ($this->showQueryBuilder()) -
- @include('query-builder::editor') - - @if(! $this->rows->count()) - - @endif -
- @endif - - @if($this->isToolbarVisible()) -
- -
- @if($this->isSearchVisible()) - @include('query-builder::components.search') - @endif -
- -
- - @if($this->areActionsVisible()) - @include('query-builder::components.actions') - @endif - - @if($this->isColumnSelectorVisible()) - @include('query-builder::components.columns-selector') - @endif - - @if($this->isRowSelectorVisible()) - @include('query-builder::components.rows-selector') - @endif -
-
- @endif - - @if($this->rows->count()) - -
- - - - - @if($selectable) - - @endif - - @foreach ($this->columns() as $column) - @if(in_array($column->key, $displayColumns)) - - @endif - @endforeach - - - useLoadingIndicator()) wire:loading.delay.longest.class="{{ $this->loadingClass }}" - wire:target.except="exportReportBuilder, saveReportBuilder, loadReportBuilder" @endif> - @if($selectable && $selectPage && $this->rows->count() < $this->rows->total()) - - - - @endif - @foreach ($this->rows as $row) - - @if($this->rowPreview($row)) - {!! $this->injectRow($row) !!} - @endif - - isClickable()) - {!! $this->renderRowClick($row->id) !!} - @endif - wire:key="row-{{ $row->id }}" - @class([ - 'bg-white border-b', - 'hover:bg-gray-50 cursor-pointer' => $this->isClickable(), - ])> - - @if($selectable) - - @endif - - @foreach ($this->columns() as $column) - @if(in_array($column->key, $displayColumns)) - - @endif - @endforeach - - @endforeach - -
-
- - -
-
isSortable()) wire:click="sort('{{ $column->key }}')" @endif> - @if ($column->showHeader) -
justify, - 'cursor-pointer' => $column->isSortable(), - ])> - {{ $column->label }} - - @if ($sortBy === $column->key) - @if ($sortDirection === 'desc') - - - - @else - - - - @endif - @endif - - @if($this->isSearchableIconVisible() && $column->isSearchable()) - - @endif - -
- @endif -
- @unless($selectAll) -
- You have selected {{ count($selectedRows) }} {{ Str::of('row')->plural(count($selectedRows)) }}. Do you want to select all {{ $this->rows->total() }}? - -
- @else - You have selected all {{ $this->rows->total() }} {{ Str::of('row')->plural(count($selectedRows)) }}. - @endif -
-
- - -
-
-
- - -
-
-
- - @if($this->isPaginated() && $this->rows->hasPages()) -
- @if($this->scroll() === true) -
{{ $this->rows->links() }}
- @else -
{{ $this->rows->links(data: ['scrollTo' => $this->scroll()]) }}
- @endif -
- @endif - @endif - - @if($this->useLoadingIndicator()) - {{-- Table loading spinners... --}} - @if($this->showOverlay) -
-
- @endif - -
- - - - -
- @endif - - -
- - @includeIf($this->footerView()) - @endif -
\ No newline at end of file +{{-- @deprecated Use report-table view instead --}} +@include('query-builder::report-table') diff --git a/resources/views/report-editor.blade.php b/resources/views/report-editor.blade.php index 47fbe1e..c54627b 100644 --- a/resources/views/report-editor.blade.php +++ b/resources/views/report-editor.blade.php @@ -22,6 +22,44 @@ class="ms-2 text-sm font-medium text-gray-900 dark:text-gray-300">{{$column['lab @endforeach @endforeach + + @if($this->enableGroupBy) +
Group By
+
+
+ + +
+ + @if($this->groupBy) +
+ + +
+ +
+ + +
+ @endif +
+ @endif
diff --git a/src/GroupsBuilder.php b/src/GroupsBuilder.php index 0574add..11e8a74 100755 --- a/src/GroupsBuilder.php +++ b/src/GroupsBuilder.php @@ -4,15 +4,15 @@ namespace ACTTraining\QueryBuilder; -use ACTTraining\QueryBuilder\Support\Concerns\WithGroupBuilder; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; -abstract class GroupsBuilder extends QueryBuilder +/** + * @deprecated Use ReportBuilder with $enableGroupBy = true instead. + */ +abstract class GroupsBuilder extends ReportBuilder { - use WithGroupBuilder; - - public bool $selectable = false; + public bool $enableGroupBy = true; public function render(): Factory|View { diff --git a/src/ReportBuilder.php b/src/ReportBuilder.php index 15b5a95..ae61737 100755 --- a/src/ReportBuilder.php +++ b/src/ReportBuilder.php @@ -7,6 +7,11 @@ use ACTTraining\QueryBuilder\Support\Concerns\WithReportBuilder; use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; +use Livewire\Attributes\Computed; abstract class ReportBuilder extends QueryBuilder { @@ -14,6 +19,89 @@ abstract class ReportBuilder extends QueryBuilder public bool $selectable = false; + #[Computed] + public function rowsQuery() + { + $query = parent::rowsQuery(); + + if (! $this->hasGroupBy()) { + return $query; + } + + if (! array_key_exists($this->aggregateFunction, $this->aggregateFunctions())) { + return $query; + } + + $groupByColumn = $this->resolveColumnWithJoins($query, $this->groupBy); + $baseTable = $query->getModel()->getTable(); + + if ($this->aggregateFunction === 'COUNT') { + $aggregateColumn = "{$baseTable}.id"; + } else { + $aggregateColumn = str_contains($this->aggregateColumn, '.') + ? $this->resolveColumnWithJoins($query, $this->aggregateColumn) + : "{$baseTable}.{$this->aggregateColumn}"; + } + + $query->withoutGlobalScope('order') + ->reorder() + ->selectRaw("{$groupByColumn} as group_value, {$this->aggregateFunction}({$aggregateColumn}) as aggregate") + ->groupBy($groupByColumn) + ->orderBy($groupByColumn); + + return $query; + } + + protected function resolveColumnWithJoins(Builder $query, string $key): string + { + if (! str_contains($key, '.')) { + return $query->getModel()->getTable().'.'.$key; + } + + $parts = explode('.', $key); + $columnName = array_pop($parts); + $currentModel = $query->getModel(); + $existingJoins = collect($query->getQuery()->joins ?? []) + ->pluck('table') + ->all(); + + foreach ($parts as $relationName) { + if (! method_exists($currentModel, $relationName)) { + return $key; + } + + $relation = $currentModel->{$relationName}(); + $relatedTable = $relation->getRelated()->getTable(); + + if (in_array($relatedTable, $existingJoins, true)) { + $currentModel = $relation->getRelated(); + + continue; + } + + if ($relation instanceof BelongsTo) { + $query->join( + $relatedTable, + $currentModel->getTable().'.'.$relation->getForeignKeyName(), + '=', + $relatedTable.'.'.$relation->getOwnerKeyName() + ); + } elseif ($relation instanceof HasOne || $relation instanceof HasMany) { + $query->join( + $relatedTable, + $currentModel->getTable().'.'.$currentModel->getKeyName(), + '=', + $relatedTable.'.'.$relation->getForeignKeyName() + ); + } + + $existingJoins[] = $relatedTable; + $currentModel = $relation->getRelated(); + } + + return $currentModel->getTable().'.'.$columnName; + } + public function render(): Factory|View { return view('query-builder::report-table'); diff --git a/src/Support/Concerns/WithGroupBuilder.php b/src/Support/Concerns/WithGroupBuilder.php index dfc60a4..b59ab05 100644 --- a/src/Support/Concerns/WithGroupBuilder.php +++ b/src/Support/Concerns/WithGroupBuilder.php @@ -16,6 +16,9 @@ use Livewire\Attributes\Validate; use Symfony\Component\HttpFoundation\BinaryFileResponse; +/** + * @deprecated Use WithReportBuilder with $enableGroupBy = true instead. + */ trait WithGroupBuilder { public string $aggregateColumn = 'id'; // Default column to aggregate diff --git a/src/Support/Concerns/WithReportBuilder.php b/src/Support/Concerns/WithReportBuilder.php index 722c06d..54ea3ea 100644 --- a/src/Support/Concerns/WithReportBuilder.php +++ b/src/Support/Concerns/WithReportBuilder.php @@ -22,17 +22,22 @@ trait WithReportBuilder #[Validate('required|array')] public array $selectedColumns = []; + public ?string $groupBy = null; + + public string $aggregateColumn = 'id'; + + public string $aggregateFunction = 'COUNT'; + + public bool $enableGroupBy = false; + private function findElementByKey(array $array, $targetValue): ?array { foreach ($array as $value) { - // Check if the current item is an array if (is_array($value)) { - // Check if it contains the 'key' element with the target value if (isset($value['key']) && $value['key'] === $targetValue) { - return $value; // Return the found item + return $value; } - // Recursively search the sub-array $result = $this->findElementByKey($value, $targetValue); if ($result !== null) { return $result; @@ -40,7 +45,7 @@ private function findElementByKey(array $array, $targetValue): ?array } } - return null; // Return null if no match is found + return null; } public function updatedSelectedColumns(): void @@ -49,6 +54,86 @@ public function updatedSelectedColumns(): void $this->displayColumns = $this->resolveColumns()->pluck('key')->toArray(); } + public function updatedGroupBy(): void + { + $this->resetPage(); + $this->displayColumns = $this->resolveColumns()->pluck('key')->toArray(); + } + + public function updatedAggregateFunction(): void + { + if ($this->aggregateFunction === 'COUNT') { + $this->aggregateColumn = 'id'; + } + + $this->resetPage(); + $this->displayColumns = $this->resolveColumns()->pluck('key')->toArray(); + } + + public function updatedAggregateColumn(): void + { + $this->resetPage(); + $this->displayColumns = $this->resolveColumns()->pluck('key')->toArray(); + } + + public function hasGroupBy(): bool + { + return $this->enableGroupBy && filled($this->groupBy); + } + + public function aggregateFunctions(): array + { + return [ + 'COUNT' => 'Count', + 'SUM' => 'Sum', + 'AVG' => 'Average', + 'MIN' => 'Minimum', + 'MAX' => 'Maximum', + ]; + } + + public function availableGroupByColumns(): array + { + return $this->availableColumns(); + } + + public function groupableColumns(): array + { + $columns = []; + + foreach ($this->availableGroupByColumns() as $section => $sectionColumns) { + foreach ($sectionColumns as $column) { + if (! is_array($column) || ! isset($column['label'], $column['key'])) { + continue; + } + + $columns[] = $column; + } + } + + return $columns; + } + + public function aggregatableColumns(): array + { + $columns = []; + + foreach ($this->availableColumns() as $section => $sectionColumns) { + foreach ($sectionColumns as $column) { + if (! is_array($column) || ! isset($column['label'], $column['key'])) { + continue; + } + + $type = $column['type'] ?? null; + if (in_array($type, ['number', 'float'], true)) { + $columns[] = $column; + } + } + } + + return $columns; + } + public function availableColumns(): array { return []; @@ -71,7 +156,6 @@ public function buildColumns(): array $counter = 0; foreach (($this->configuredColumns() ?? []) as $column) { - // Skip nulls/invalid items early if (! is_array($column) || ! isset($column['label'], $column['key'])) { continue; } @@ -106,6 +190,45 @@ public function buildColumns(): array $counter++; } + if ($this->hasGroupBy()) { + $groupByConfig = $this->findElementByKey($this->availableGroupByColumns(), $this->groupBy); + $groupByLabel = $groupByConfig['label'] ?? $this->groupBy; + + $groupColumn = Column::make($groupByLabel, 'group_value')->sortable(); + + if (! empty($groupByConfig['view'])) { + $groupColumn->component($groupByConfig['view']); + } + + $options = $groupByConfig['options'] ?? []; + if (! empty($options)) { + $groupColumn->reformatUsing(fn ($value) => $options[$value] ?? $value); + } + + $functionLabel = $this->aggregateFunctions()[$this->aggregateFunction] ?? $this->aggregateFunction; + $aggregateConfig = $this->aggregateFunction !== 'COUNT' + ? $this->findElementByKey($this->availableColumns(), $this->aggregateColumn) + : null; + $aggregateColumnLabel = $aggregateConfig['label'] ?? $this->aggregateColumn; + + $aggregateLabel = $this->aggregateFunction === 'COUNT' + ? $functionLabel + : "{$functionLabel} of {$aggregateColumnLabel}"; + + $aggregateCol = Column::make($aggregateLabel, 'aggregate') + ->justify('right') + ->sortable(); + + if ($aggregateConfig && ! empty($aggregateConfig['view'])) { + $aggregateCol->component($aggregateConfig['view']); + } + + return [ + $groupColumn, + $aggregateCol, + ]; + } + return $columns; } @@ -114,7 +237,6 @@ public function buildConditions(): array $conditions = []; foreach (($this->configuredColumns() ?? []) as $column) { - // Skip nulls/garbage early if (! is_array($column) || ! isset($column['label'], $column['key'])) { continue; } @@ -125,7 +247,6 @@ public function buildConditions(): array $type = $column['type'] ?? null; - // Normalise enum options $options = $column['options'] ?? []; if (! is_array($options)) { $options = []; @@ -157,6 +278,9 @@ public function resetReportBuilder(): void { $this->criteria = []; $this->selectedColumns = []; + $this->groupBy = null; + $this->aggregateColumn = 'id'; + $this->aggregateFunction = 'COUNT'; $this->saveToSession(); }