Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/row grouping #7

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to `ag-grid-laravel` will be documented in this file.

## Unreleased

### Improved

- Added support for nested relations in filters

## 0.2.0 (2023-08-24)

- Rename `$params` to `$filters` in `AgGridCustomFilterable`
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ function onFilterChanged(event: FilterChangedEvent) {
- Only works with PostgreSQL as a storage backend due to some special SQL operators being used in set and json queries.
- Does not support multiple conditions per filter (AND, OR)
- Does not support server-side grouping for AG Grid's pivot mode
- Filtering for values in relations is only supported one level deep. E.g you can filter for `relation.value` but not `relation.otherRelation.value`
- ~~Filtering for values in relations is only supported one level deep. E.g you can filter for `relation.value` but not `relation.otherRelation.value`~~

## TODOs

Expand Down
2 changes: 1 addition & 1 deletion src/AgGridExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
use Maatwebsite\Excel\Concerns\WithMapping;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;

class AgGridExport implements FromQuery, WithHeadings, WithColumnFormatting, ShouldAutoSize, WithMapping
class AgGridExport implements FromQuery, ShouldAutoSize, WithColumnFormatting, WithHeadings, WithMapping
{
/**
* @var Collection<string, AgGridColumnDefinition>
Expand Down
196 changes: 148 additions & 48 deletions src/AgGridQueryBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,22 @@
use Clickbar\AgGrid\Enums\AgGridNumberFilterType;
use Clickbar\AgGrid\Enums\AgGridRowModel;
use Clickbar\AgGrid\Enums\AgGridTextFilterType;
use Clickbar\AgGrid\Exceptions\InvalidSetValueOperation;
use Clickbar\AgGrid\Exceptions\UnauthorizedSetFilterColumn;
use Clickbar\AgGrid\Requests\AgGridGetRowsRequest;
use Clickbar\AgGrid\Requests\AgGridSetValuesRequest;
use Clickbar\AgGrid\Support\ColumnMetadata;
use Clickbar\AgGrid\Support\RowGroupMetadata;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Support\Str;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Traits\ForwardsCalls;
use Maatwebsite\Excel\Facades\Excel;

Expand All @@ -35,6 +42,8 @@ class AgGridQueryBuilder implements Responsable
/** @var class-string<JsonResource> | null */
protected ?string $resourceClass = null;

protected RowGroupMetadata $rowGroupMetadata;

/**
* @param EloquentBuilder|Relation|Model|class-string<Model> $subject
*/
Expand All @@ -52,10 +61,13 @@ public function __construct(array $params, EloquentBuilder|Relation|Model|string
$model->applyAgGridCustomFilters($this->subject, $this->params['customFilters'] ?? []);
}

$this->rowGroupMetadata = RowGroupMetadata::fromParams($params);

$this->addFiltersToQuery();
$this->addToggledFilterToQuery();
$this->addSortsToQuery();
$this->addLimitAndOffsetToQuery();
$this->addRowGrouping();
}

/**
Expand All @@ -68,6 +80,16 @@ public static function forRequest(AgGridGetRowsRequest $request, EloquentBuilder
return new AgGridQueryBuilder($request->validated(), $subject);
}

/**
* Returns a new AgGridQueryBuilder for an AgGridGetRowsRequest.
*
* @param EloquentBuilder|Relation|Model|class-string<Model> $subject
*/
public static function forSetValuesRequest(AgGridSetValuesRequest $request, EloquentBuilder|Relation|Model|string $subject): AgGridQueryBuilder
{
return new AgGridQueryBuilder($request->validated(), $subject);
}

/**
* Returns a new AgGridQueryBuilder for a selection.
*
Expand Down Expand Up @@ -102,6 +124,40 @@ public function resource(string $resourceClass): self
return $this;
}

public function toSetValues(array $allowedColumns = []): Collection
{
$column = Arr::get($this->params, 'column');
if (empty($column)) {
throw InvalidSetValueOperation::make();
}

if (collect($allowedColumns)->first() !== '*' && ! in_array($column, $allowedColumns)) {
throw UnauthorizedSetFilterColumn::make($column);
}

$columnMetadata = ColumnMetadata::fromString($this->subject, $column);

if ($columnMetadata->hasRelations()) {

$dottedRelation = $columnMetadata->getDottedRelation();

return $this->subject->with($dottedRelation)
->get()
->map(fn (Model $model) => Arr::get($this->traverse($model, $dottedRelation)->toArray(), $columnMetadata->getColumn()))
->unique()
->sort()
->values();
}

$column = $columnMetadata->isJsonColumn() ? $columnMetadata->getColumnAsJsonPath() : $columnMetadata->getColumn();

return $this->subject
->select($column)
->distinct()
->orderBy($column)
->pluck($column);
}

public function __call($name, $arguments)
{
$result = $this->forwardCallTo($this->subject, $name, $arguments);
Expand Down Expand Up @@ -141,7 +197,15 @@ public function toResponse($request): mixed
$query->limit = $query->offset = $query->orders = null;
$query->cleanBindings(['order']);
});
$total = $clone->count();

if ($this->isGrouped()) {
// TODO: Check for better way
$total = DB::query()
->fromSub($clone, 'rows')
->count();
} else {
$total = $clone->count();
}

$data = $this->get();

Expand Down Expand Up @@ -191,6 +255,11 @@ protected function addServerSideToggledFilterToQuery(): void
}
}

protected function addRowGrouping(): void
{
$this->rowGroupMetadata->appendQueryBuilderMethods($this->subject);
}

protected function addFiltersToQuery(): void
{
if (! isset($this->params['filterModel'])) {
Expand All @@ -199,16 +268,22 @@ protected function addFiltersToQuery(): void

$filters = collect($this->params['filterModel']);

// Check if we are in set values mode and exclude the filter for the given set value column
$column = Arr::get($this->params, 'column');
if ($column) {
$filters = $filters->filter(fn ($value, $key) => $key !== $column);
}

foreach ($filters as $column => $filter) {

[$relation, $column] = $this->getRelation($column);
$columnInformation = ColumnMetadata::fromString($this->subject, $column);

if ($relation !== null) {
$this->subject->whereHas($relation, function (EloquentBuilder $builder) use ($column, $filter) {
$this->addFilterToQuery($builder, $column, $filter);
if ($columnInformation->hasRelations()) {
$this->subject->whereHas($columnInformation->getDottedRelation(), function (EloquentBuilder $builder) use ($columnInformation, $filter) {
$this->addFilterToQuery($builder, $columnInformation, $filter);
});
} else {
$this->addFilterToQuery($this->subject, $column, $filter);
$this->addFilterToQuery($this->subject, $columnInformation, $filter);
}
}
}
Expand All @@ -227,11 +302,23 @@ protected function addSortsToQuery(): void
}

foreach ($sorts as $sort) {
$this->subject->orderBy($this->toJsonPath($sort['colId']), $sort['sort']);

// Check if the sort field is included in the current grouping
if ($this->rowGroupMetadata->isColumnAvailable($sort['colId'])) {
$this->subject->orderBy($this->toJsonPath($sort['colId']), $sort['sort']);
}
}

if ($this->rowGroupMetadata->isGrouped()) {
// TODO: Check for current grouping level

// TODO: Add more context for better accessibility of groups
$this->subject->orderBy($this->rowGroupMetadata->getCurrentRowGroupCol()['field']);
} else {
// we need an additional sort condition so that the order is stable in all cases
$this->subject->orderBy($this->subject->getModel()->getKeyName());
}

// we need an additional sort condition so that the order is stable in all cases
$this->subject->orderBy($this->subject->getModel()->getKeyName());
}

protected function addLimitAndOffsetToQuery(): void
Expand All @@ -246,21 +333,21 @@ protected function addLimitAndOffsetToQuery(): void
$this->subject->offset($startRow)->limit($endRow - $startRow);
}

protected function addFilterToQuery(EloquentBuilder|Relation $subject, string $column, array $filter): void
protected function addFilterToQuery(EloquentBuilder|Relation $subject, ColumnMetadata $columnInformation, array $filter): void
{
$filterType = AgGridFilterType::from($filter['filterType']);
match ($filterType) {
AgGridFilterType::Set => $this->addSetFilterToQuery($subject, $column, $filter),
AgGridFilterType::Text => $this->addTextFilterToQuery($subject, $column, $filter),
AgGridFilterType::Number => $this->addNumberFilterToQuery($subject, $column, $filter),
AgGridFilterType::Date => $this->addDateFilterToQuery($subject, $column, $filter),
AgGridFilterType::Set => $this->addSetFilterToQuery($subject, $columnInformation, $filter),
AgGridFilterType::Text => $this->addTextFilterToQuery($subject, $columnInformation, $filter),
AgGridFilterType::Number => $this->addNumberFilterToQuery($subject, $columnInformation, $filter),
AgGridFilterType::Date => $this->addDateFilterToQuery($subject, $columnInformation, $filter),
};
}

protected function addSetFilterToQuery(EloquentBuilder|Relation $subject, string $column, array $filter): void
protected function addSetFilterToQuery(EloquentBuilder|Relation $subject, ColumnMetadata $columnInformation, array $filter): void
{
$isJsonColumn = $this->isJsonColumn($column);
$column = $this->toJsonPath($column);
$isJsonColumn = $columnInformation->isJsonColumn();
$column = $columnInformation->getColumnAsJsonPath();
$values = $filter['values'];
$all = $filter['all'] ?? false;
$filteredValues = array_filter($values, fn ($value) => $value !== null);
Expand All @@ -284,9 +371,9 @@ protected function addSetFilterToQuery(EloquentBuilder|Relation $subject, string
});
}

protected function addTextFilterToQuery(EloquentBuilder|Relation $subject, string $column, array $filter): void
protected function addTextFilterToQuery(EloquentBuilder|Relation $subject, ColumnMetadata $columnInformation, array $filter): void
{
$column = $this->toJsonPath($column);
$column = $columnInformation->getColumnAsJsonPath();
$value = $filter['filter'] ?? null;
$type = AgGridTextFilterType::from($filter['type']);

Expand All @@ -302,9 +389,9 @@ protected function addTextFilterToQuery(EloquentBuilder|Relation $subject, strin
};
}

protected function addNumberFilterToQuery(EloquentBuilder|Relation $subject, string $column, array $filter): void
protected function addNumberFilterToQuery(EloquentBuilder|Relation $subject, ColumnMetadata $columnInformation, array $filter): void
{
$column = $this->toJsonPath($column);
$column = $columnInformation->getColumnAsJsonPath();
$value = $filter['filter'];
$type = AgGridNumberFilterType::from($filter['type']);

Expand All @@ -321,9 +408,9 @@ protected function addNumberFilterToQuery(EloquentBuilder|Relation $subject, str
};
}

protected function addDateFilterToQuery(EloquentBuilder|Relation $subject, string $column, array $filter): void
protected function addDateFilterToQuery(EloquentBuilder|Relation $subject, ColumnMetadata $columnInformation, array $filter): void
{
$column = $this->toJsonPath($column);
$column = $columnInformation->getColumnAsJsonPath();
$dateFrom = isset($filter['dateFrom']) ? new \DateTime($filter['dateFrom']) : null;
$dateTo = isset($filter['dateTo']) ? new \DateTime($filter['dateTo']) : null;

Expand All @@ -338,37 +425,50 @@ protected function addDateFilterToQuery(EloquentBuilder|Relation $subject, strin
};
}

protected function getRelation(string $column): array
protected function toJsonPath(string $key): string
{
$pos = strpos($column, '.');
if ($pos === false) {
return [null, $column];
return str_replace('.', '->', $key);
}

protected function traverse($model, $key, $default = null): Model
{
if (is_array($model)) {
return Arr::get($model, $key, $default);
}
// guess the name of the relation
$relationName = Str::camel(substr($column, 0, $pos));
if ($this->subject->getModel()->isRelation($relationName)) {
return [$relationName, substr($column, $pos + 1)];

if (is_null($key)) {
return $model;
}

return [null, $column];
}
if (isset($model[$key])) {
return $model[$key];
}

protected function isJsonColumn(string $column): bool
{
return str_contains($column, '.') || $this->subject->getModel()->hasCast($column, [
'array',
'json',
'object',
'collection',
'encrypted:array',
'encrypted:collection',
'encrypted:json',
'encrypted:object',
]);
foreach (explode('.', $key) as $segment) {
try {
$model = $model->$segment;
} catch (\Exception $e) { // @phpstan-ignore-line
return value($default);
}
}

return $model;
}

protected function toJsonPath(string $key): string
protected function isGrouped(): bool
{
return str_replace('.', '->', $key);
if (! isset($this->params['rowGroupCols']) || empty($this->params['rowGroupCols'])) {
return false;
}

// --> rowGroupCols available and not empty

if (! isset($this->params['groupKeys']) || empty($this->params['groupKeys'])) {
return true;
}

// --> groupKeys available and not empty

return count($this->params['rowGroupCols']) !== count($this->params['groupKeys']);
}
}
13 changes: 13 additions & 0 deletions src/Exceptions/InvalidSetValueOperation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Clickbar\AgGrid\Exceptions;

use InvalidArgumentException;

class InvalidSetValueOperation extends InvalidArgumentException
{
public static function make(): self
{
return new self("toSetValues can only be called from AgGridSetValueRequest or when params contains a 'column' key (with an actual value)");
}
}
18 changes: 18 additions & 0 deletions src/Exceptions/UnauthorizedSetFilterColumn.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace Clickbar\AgGrid\Exceptions;

use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;

class UnauthorizedSetFilterColumn extends UnauthorizedHttpException
{
public static function make(string $column): self
{
return new self(
sprintf(
'Set value for column %s is not available or cannot be accessed',
$column
)
);
}
}
Loading