Skip to content

Commit 2dd4bdb

Browse files
Merge pull request #23 from modus-digital/bugfix/sorting-with-nested-relations
Bugfix/sorting with nested relations
2 parents 270bec5 + 57865fa commit 2dd4bdb

File tree

13 files changed

+1514
-56
lines changed

13 files changed

+1514
-56
lines changed

src/Columns/Column.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ class Column
1717

1818
protected ?string $sortField = null;
1919

20+
protected ?Closure $sortCallback = null;
21+
2022
protected bool $sortable = false;
2123

2224
protected bool $searchable = false;
@@ -63,6 +65,13 @@ public function sortField(string $sortField): self
6365
return $this;
6466
}
6567

68+
public function sortUsing(Closure $callback): self
69+
{
70+
$this->sortCallback = $callback;
71+
72+
return $this;
73+
}
74+
6675
public function sortable(bool $sortable = true): self
6776
{
6877
$this->sortable = $sortable;
@@ -195,4 +204,14 @@ public function getSortField(): string
195204

196205
return $this->getField();
197206
}
207+
208+
public function getSortCallback(): ?Closure
209+
{
210+
return $this->sortCallback;
211+
}
212+
213+
public function hasSortCallback(): bool
214+
{
215+
return $this->sortCallback !== null;
216+
}
198217
}

src/Concerns/HasColumns.php

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,15 @@ public function getSortableColumns(): Collection
6666
*/
6767
public function getColumn(string $field): ?Column
6868
{
69-
return $this->getColumns()->first(fn (Column $column) => $column->getField() === $field);
69+
// First try to find by exact field match
70+
$column = $this->getColumns()->first(fn (Column $column) => $column->getField() === $field);
71+
72+
if ($column) {
73+
return $column;
74+
}
75+
76+
// If not found, try to find by relationship match
77+
return $this->getColumns()->first(fn (Column $column) => $column->getRelationship() === $field);
7078
}
7179

7280
/**
@@ -116,4 +124,94 @@ public function renderCell(Column $column, mixed $record): mixed
116124

117125
return $value;
118126
}
127+
128+
/**
129+
* Check if a field is a model attribute (accessor) rather than a database column.
130+
*/
131+
protected function isModelAttribute(\Illuminate\Database\Eloquent\Model $model, string $field): bool
132+
{
133+
// Check if it's an accessor method (old Laravel syntax)
134+
$accessorMethod = 'get' . \Illuminate\Support\Str::studly($field) . 'Attribute';
135+
if (method_exists($model, $accessorMethod)) {
136+
return true;
137+
}
138+
139+
// Check if it's defined in the model's $appends array
140+
if (in_array($field, $model->getAppends())) {
141+
return true;
142+
}
143+
144+
// Check if it's a cast attribute
145+
if (array_key_exists($field, $model->getCasts())) {
146+
return true;
147+
}
148+
149+
// Check if it's a Laravel 9+ Attribute (new syntax)
150+
if (method_exists($model, $field)) {
151+
$reflection = new \ReflectionClass($model);
152+
if ($reflection->hasMethod($field)) {
153+
$method = $reflection->getMethod($field);
154+
$returnType = $method->getReturnType();
155+
156+
if ($returnType && $returnType->getName() === 'Illuminate\Database\Eloquent\Casts\Attribute') {
157+
return true;
158+
}
159+
}
160+
}
161+
162+
return false;
163+
}
164+
165+
/**
166+
* Check if model has specific database columns.
167+
*/
168+
protected function hasModelColumns(\Illuminate\Database\Eloquent\Model $model, array $columns): bool
169+
{
170+
$schema = \Illuminate\Support\Facades\Schema::connection($model->getConnectionName());
171+
$tableColumns = $schema->getColumnListing($model->getTable());
172+
173+
return empty(array_diff($columns, $tableColumns));
174+
}
175+
176+
/**
177+
* Get the related model for a relationship field.
178+
*/
179+
protected function getRelatedModel(string $relationshipPath): ?\Illuminate\Database\Eloquent\Model
180+
{
181+
$parts = explode('.', $relationshipPath);
182+
if (count($parts) < 2) {
183+
return null;
184+
}
185+
186+
$model = $this->getModel();
187+
$relationName = $parts[0];
188+
189+
if (! method_exists($model, $relationName)) {
190+
return null;
191+
}
192+
193+
$relation = $model->{$relationName}();
194+
195+
return $relation->getRelated();
196+
}
197+
198+
/**
199+
* Get the value of a model attribute dynamically.
200+
* This works for any Laravel model attribute (accessor, appended, cast, etc.).
201+
*/
202+
protected function getModelAttributeValue(\Illuminate\Database\Eloquent\Model $model, string $attribute): mixed
203+
{
204+
return $model->getAttribute($attribute);
205+
}
206+
207+
/**
208+
* Check if a field is a database column (not an attribute).
209+
*/
210+
protected function isDatabaseColumn(\Illuminate\Database\Eloquent\Model $model, string $field): bool
211+
{
212+
$schema = \Illuminate\Support\Facades\Schema::connection($model->getConnectionName());
213+
$tableColumns = $schema->getColumnListing($model->getTable());
214+
215+
return in_array($field, $tableColumns);
216+
}
119217
}

src/Concerns/HasFilters.php

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,7 @@ public function getFilters(): Collection
5656
public function applyFilters(Builder $query): Builder
5757
{
5858
foreach ($this->getFilters() as $filter) {
59-
$field = $filter->getField();
60-
61-
// Handle dotted field names (e.g., 'client.status') by using data_get
62-
// which can access nested array values like $filters['client']['status']
63-
$value = data_get($this->filters, $field);
59+
$value = $this->getFilterValue($filter->getField());
6460

6561
if ($value !== null && $value !== '' && $value !== []) {
6662
$query = $filter->apply($query, $value);
@@ -70,6 +66,64 @@ public function applyFilters(Builder $query): Builder
7066
return $query;
7167
}
7268

69+
/**
70+
* Get the filter value for a given field.
71+
*/
72+
protected function getFilterValue(string $field): mixed
73+
{
74+
// Handle dotted field names (e.g., 'client.status') by using data_get
75+
// which can access nested array values like $filters['client']['status']
76+
return data_get($this->filters, $field);
77+
}
78+
79+
/**
80+
* Check if any filter requires attribute-based filtering.
81+
*/
82+
public function requiresAttributeFiltering(): bool
83+
{
84+
foreach ($this->getFilters() as $filter) {
85+
$value = $this->getFilterValue($filter->getField());
86+
87+
if ($value !== null && $value !== '' && $value !== []) {
88+
// Apply the filter to check if it requires attribute filtering
89+
$dummyQuery = $this->getModel()->newQuery();
90+
$filter->apply($dummyQuery, $value);
91+
92+
if (method_exists($filter, 'requiresAttributeFiltering') && $filter->requiresAttributeFiltering()) {
93+
return true;
94+
}
95+
}
96+
}
97+
98+
return false;
99+
}
100+
101+
/**
102+
* Get all active attribute filters.
103+
*/
104+
public function getActiveAttributeFilters(): array
105+
{
106+
$attributeFilters = [];
107+
108+
foreach ($this->getFilters() as $filter) {
109+
$value = $this->getFilterValue($filter->getField());
110+
111+
if ($value !== null && $value !== '' && $value !== []) {
112+
// Apply the filter to check if it requires attribute filtering
113+
$dummyQuery = $this->getModel()->newQuery();
114+
$filter->apply($dummyQuery, $value);
115+
116+
if (method_exists($filter, 'requiresAttributeFiltering') && $filter->requiresAttributeFiltering()) {
117+
$details = $filter->getAttributeFilterDetails();
118+
$details['filter_instance'] = $filter;
119+
$attributeFilters[] = $details;
120+
}
121+
}
122+
}
123+
124+
return $attributeFilters;
125+
}
126+
73127
/**
74128
* Reset all filters.
75129
*/

src/Concerns/HasSorting.php

Lines changed: 89 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ trait HasSorting
1515
#[Url(as: 'dir')]
1616
public string $sortDirection = 'asc';
1717

18+
/** @var array<string, bool> */
19+
protected array $joinedTables = [];
20+
21+
/**
22+
* Flag to indicate if current sorting requires attribute-based sorting.
23+
*/
24+
protected bool $requiresAttributeSorting = false;
25+
1826
/**
1927
* Default sort field.
2028
*/
@@ -52,11 +60,23 @@ public function sortBy(string $field): void
5260
*/
5361
public function applySorting(Builder $query): Builder
5462
{
63+
// Reset the attribute sorting flag
64+
$this->requiresAttributeSorting = false;
65+
5566
$sortField = $this->sortField ?: $this->defaultSortField;
5667
$sortDirection = $this->sortDirection ?: $this->defaultSortDirection;
5768

69+
$column = $this->getColumn($sortField);
70+
71+
// Handle custom sort callback
72+
if ($column && $column->hasSortCallback()) {
73+
$callback = $column->getSortCallback();
74+
75+
return $callback($query, $sortDirection);
76+
}
77+
5878
$relationship = null;
59-
if ($column = $this->getColumn($sortField)) {
79+
if ($column) {
6080
$sortField = $column->getSortField();
6181
$relationship = $column->getRelationship();
6282
}
@@ -66,14 +86,45 @@ public function applySorting(Builder $query): Builder
6686
}
6787

6888
if ($relationship) {
69-
$parts = explode('.', $relationship);
89+
return $this->applySortingWithRelationship($query, $relationship, $sortDirection);
90+
}
91+
92+
if (! str_contains($sortField, '.')) {
93+
$sortField = $query->getModel()->getTable() . '.' . $sortField;
94+
}
95+
96+
return $query->orderBy($sortField, $sortDirection);
97+
}
98+
99+
/**
100+
* Apply sorting with relationship handling.
101+
*
102+
* @param Builder<\Illuminate\Database\Eloquent\Model> $query
103+
* @return Builder<\Illuminate\Database\Eloquent\Model>
104+
*/
105+
protected function applySortingWithRelationship(Builder $query, string $relationship, string $sortDirection): Builder
106+
{
107+
$parts = explode('.', $relationship);
108+
109+
if (count($parts) === 2) {
110+
[$relationName, $relationField] = $parts;
111+
$model = $this->getModel();
70112

71-
if (count($parts) === 2) {
72-
[$relationName, $relationField] = $parts;
73-
$model = $this->getModel();
74-
$relationInstance = $model->{$relationName}();
75-
$relationTable = $relationInstance->getRelated()->getTable();
113+
// Check if the relation method exists
114+
if (! method_exists($model, $relationName)) {
115+
return $query;
116+
}
76117

118+
$relationInstance = $model->{$relationName}();
119+
$relationTable = $relationInstance->getRelated()->getTable();
120+
121+
// Check if the field is a model attribute instead of a database column
122+
if ($this->isModelAttribute($relationInstance->getRelated(), $relationField)) {
123+
return $this->applySortingWithAttribute($query, $relationInstance, $relationField, $sortDirection);
124+
}
125+
126+
// Only add JOIN if it hasn't been added already
127+
if (! isset($this->joinedTables[$relationTable])) {
77128
if ($relationInstance instanceof \Illuminate\Database\Eloquent\Relations\BelongsTo) {
78129
$foreignKey = $relationInstance->getForeignKeyName();
79130
$ownerKey = $relationInstance->getOwnerKeyName();
@@ -83,19 +134,42 @@ public function applySorting(Builder $query): Builder
83134
$localKey = $relationInstance->getLocalKeyName();
84135
$query->leftJoin($relationTable, $relationTable . '.' . $foreignKey, '=', $model->getTable() . '.' . $localKey);
85136
}
137+
$this->joinedTables[$relationTable] = true;
138+
}
86139

87-
$query->orderBy("{$relationTable}.{$relationField}", $sortDirection)
88-
->select($model->getTable() . '.*');
140+
$query->orderBy("{$relationTable}.{$relationField}", $sortDirection)
141+
->select($model->getTable() . '.*');
89142

90-
return $query;
91-
}
143+
return $query;
92144
}
93145

94-
if (! str_contains($sortField, '.')) {
95-
$sortField = $query->getModel()->getTable() . '.' . $sortField;
96-
}
146+
return $query;
147+
}
97148

98-
return $query->orderBy($sortField, $sortDirection);
149+
/**
150+
* Apply sorting for model attributes.
151+
* Since attributes are computed in PHP, we can't sort them in SQL.
152+
* We'll set a flag to indicate that attribute sorting is needed.
153+
*
154+
* @param Builder<\Illuminate\Database\Eloquent\Model> $query
155+
* @return Builder<\Illuminate\Database\Eloquent\Model>
156+
*/
157+
protected function applySortingWithAttribute(Builder $query, \Illuminate\Database\Eloquent\Relations\Relation $relationInstance, string $attributeField, string $sortDirection): Builder
158+
{
159+
// Set flag to indicate that attribute sorting is needed
160+
$this->requiresAttributeSorting = true;
161+
162+
// For model attributes, we can't sort in SQL since they're computed in PHP
163+
// We return the query unchanged - the Table class will handle the sorting
164+
return $query;
165+
}
166+
167+
/**
168+
* Check if current sorting requires attribute-based sorting.
169+
*/
170+
public function requiresAttributeSorting(): bool
171+
{
172+
return $this->requiresAttributeSorting;
99173
}
100174

101175
/**

0 commit comments

Comments
 (0)