diff --git a/packages/support/src/Services/RelationshipJoiner.php b/packages/support/src/Services/RelationshipJoiner.php index 6f6fade8b0..441d53562b 100644 --- a/packages/support/src/Services/RelationshipJoiner.php +++ b/packages/support/src/Services/RelationshipJoiner.php @@ -5,6 +5,7 @@ namespace Filament\Support\Services; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Database\Query\Expression; use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Arr; use Illuminate\Support\Str; @@ -91,15 +92,32 @@ class RelationshipJoiner /** @phpstan-ignore-next-line */ foreach (($relationshipQuery->getQuery()->orders ?? []) as $order) { - if (! array_key_exists('column', $order)) { + // Regular orders: { column: string, direction: 'asc' | 'desc' } + // Sub-query orders: { column: Illuminate\Database\Query\Expression, direction: 'asc' | 'desc' } + // Raw orders: { type: 'Raw', sql: string } + if (! array_key_exists('column', $order) && ! array_key_exists('sql', $order)) { continue; } - if (str($order['column'])->startsWith("{$relationshipQuery->getModel()->getTable()}.")) { + $columnValue = $order['column'] ?? new Expression($order['sql']); + + if ( + $columnValue instanceof Expression + && str($columnValue->getValue($relationship->getGrammar()))->contains('?') + ) { + // Heuristic to determine if the expression contains (a) binding(s), if so, as of + // yet we cannot reliably determine (which) bindings are used in the expression. continue; } - $relationshipQuery->addSelect($order['column']); + if ( + str($columnValue instanceof Expression ? $columnValue->getValue($relationship->getGrammar()) : $columnValue) + ->startsWith("{$relationshipQuery->getModel()->getTable()}.") + ) { + continue; + } + + $relationshipQuery->addSelect($columnValue); } } diff --git a/tests/database/migrations/create_team_user_table.php b/tests/database/migrations/create_team_user_table.php new file mode 100644 index 0000000000..9565ec19e0 --- /dev/null +++ b/tests/database/migrations/create_team_user_table.php @@ -0,0 +1,24 @@ +id(); + $table->foreignId('team_id')->constrained(); + $table->foreignId('user_id')->constrained(); + $table->string('role')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('team_user'); + } +}; diff --git a/tests/src/Support/Services/RelationshipJoinerTest.php b/tests/src/Support/Services/RelationshipJoinerTest.php new file mode 100644 index 0000000000..bae862a727 --- /dev/null +++ b/tests/src/Support/Services/RelationshipJoinerTest.php @@ -0,0 +1,88 @@ +create(); + + expect($user->teams()->toBase()) + ->distinct->toBeFalse() + ->getColumns()->toBe([]) + ->orders->toBeNull(); + + $preparedQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints($user->teams()); + + expect($preparedQuery->toBase()) + ->distinct->toBeTrue() + ->getColumns()->toBe(['teams.*']) + ->orders->toBeNull(); + + $preparedQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints( + $user + ->teams() + ->orderBy('id') + ->orderBy((new Team)->qualifyColumn('name')) + ->orderBy('team_user.role') + ); + + expect($preparedQuery->toBase()) + ->distinct->toBeTrue() + ->getColumns()->toBe([ + (new Team)->qualifyColumn('*'), // Default select... + 'id', // Select without a qualified table also included just to be sure... + // Select for `team.name` not included as that is already included in the `team.*`... + 'team_user.role', // Select for a qualitified other table included... + ]) + ->orders->toBe([ + [ + 'column' => 'id', + 'direction' => 'asc', + ], + [ + 'column' => 'teams.name', + 'direction' => 'asc', + ], + [ + 'column' => 'team_user.role', + 'direction' => 'asc', + ], + ]); + + $preparedQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints( + $user->teams()->orderByRaw("CASE WHEN role = 'admin' THEN 1 ELSE 2 END") + ); + + expect($preparedQuery->toBase()) + ->distinct->toBeTrue() + ->getColumns()->toBe([ + (new Team)->qualifyColumn('*'), + "CASE WHEN role = 'admin' THEN 1 ELSE 2 END", // Select added from `orderByRaw`... + ]) + ->orders->toBe([ + [ + 'type' => 'Raw', + 'sql' => "CASE WHEN role = 'admin' THEN 1 ELSE 2 END", + ], + ]); + + $preparedQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints( + $user->teams()->orderBy(new Expression("CASE WHEN role = 'some_other_role' THEN 1 ELSE 2 END")) + ); + + expect($preparedQuery->toBase()) + ->distinct->toBeTrue() + ->getColumns()->toBe([ + (new Team)->qualifyColumn('*'), + "CASE WHEN role = 'some_other_role' THEN 1 ELSE 2 END", // Select added from `orderByRaw`... + ]) + ->orders->toHaveCount(1) + ->and($preparedQuery->toBase()->orders[0]) + ->column->getValue($user->teams()->getGrammar())->toBe("CASE WHEN role = 'some_other_role' THEN 1 ELSE 2 END") + ->direction->toBe('asc'); +});