diff --git a/src/Filters/Filter.php b/src/Filters/Filter.php index 414ca3d..374385a 100644 --- a/src/Filters/Filter.php +++ b/src/Filters/Filter.php @@ -16,8 +16,6 @@ abstract class Filter implements IFilter { use HasPropertyRelationship; - protected ?JoinType $joinType = null; - protected const ARRAY_OPERATORS = []; /** @@ -39,10 +37,12 @@ abstract protected function handle( /** * Filter constructor. */ - public function __construct(bool $addRelationConstraint = true, JoinType $joinType = null) - { + public function __construct( + bool $addRelationConstraint = true, + private readonly ?JoinType $joinType = null, + private readonly array|string|null $joinAliases = null, + ) { $this->addRelationConstraint = $addRelationConstraint; - $this->joinType = $joinType; } /** @@ -116,9 +116,13 @@ protected function withJoinConstraint(Builder $query, $value, string $property): $relation = $relation->getRelation($partial); } - $query->joinRelationship($relationName, joinType: $this->joinType->value); + $query->joinRelationship($relationName, $this->joinAliases, $this->joinType->value); - $this->relationConstraints[] = $property = $relation->qualifyColumn($property); + if (is_string($this->joinAliases)) { + $this->relationConstraints[] = $property = $this->joinAliases.'.'.$property; + } else { + $this->relationConstraints[] = $property = $relation->qualifyColumn($property); + } $this->__invoke($query, $value, $property); } diff --git a/src/Filters/GlobalFilter.php b/src/Filters/GlobalFilter.php index 54ef94d..1f0f316 100644 --- a/src/Filters/GlobalFilter.php +++ b/src/Filters/GlobalFilter.php @@ -23,16 +23,23 @@ class GlobalFilter implements Filter protected readonly array $fields; + protected readonly array|string|null $joinAliases; + protected ?JoinType $joinType = null; /** * @param array $fields */ - public function __construct(array $fields, bool $addRelationConstraint = true, JoinType $joinType = null) - { + public function __construct( + array $fields, + bool $addRelationConstraint = true, + ?JoinType $joinType = null, + array|string|null $joinAliases = null + ) { $this->fields = $fields; $this->addRelationConstraint = $addRelationConstraint; $this->joinType = $joinType; + $this->joinAliases = $joinAliases; } /** @@ -41,7 +48,7 @@ public function __construct(array $fields, bool $addRelationConstraint = true, J public static function allowed( array $fields, bool $addRelationConstraint = true, - JoinType $joinType = null, + ?JoinType $joinType = null, string $name = 'global', ): AllowedFilter { return AllowedFilter::custom($name, new static($fields, $addRelationConstraint, $joinType)); @@ -123,9 +130,13 @@ protected function setJoinsRelationship(Builder $query, string $property): void $relation = $relation->getRelation($partial); } - $query->joinRelationship($relationName, joinType: $this->joinType->value); + $query->joinRelationship($relationName, $this->joinAliases, $this->joinType->value); - $this->relationConstraints[] = $relation->qualifyColumn($property); + if (is_string($this->joinAliases)) { + $this->relationConstraints[] = $this->joinAliases.'.'.$property; + } else { + $this->relationConstraints[] = $relation->qualifyColumn($property); + } } /** diff --git a/src/Sorts/RelationSort.php b/src/Sorts/RelationSort.php index a25e48f..b0438ba 100644 --- a/src/Sorts/RelationSort.php +++ b/src/Sorts/RelationSort.php @@ -22,6 +22,7 @@ class RelationSort implements Sort public function __construct( private readonly JoinType $joinType = JoinType::Inner, private readonly ?AggregationType $aggregationType = null, + private readonly array|string|null $joinAliases = null, ) { } @@ -39,7 +40,8 @@ public function __invoke(Builder $query, bool $descending, string $property): vo $property, $descending ? 'desc' : 'asc', $this->aggregationType?->value, - $this->joinType->value + $this->joinType->value, + $this->joinAliases, ); } } diff --git a/tests/Filters/PowerJoins/TextFilterTest.php b/tests/Filters/PowerJoins/TextFilterTest.php new file mode 100644 index 0000000..eeac24c --- /dev/null +++ b/tests/Filters/PowerJoins/TextFilterTest.php @@ -0,0 +1,63 @@ +request = new Illuminate\Http\Request(); + $this->request->setMethod(Request::METHOD_GET); + + $belgium = Country::factory()->create(['name' => 'Belgium', 'code' => 'BE']); + $ecuador = Country::factory()->create(['name' => 'Ecuador', 'code' => 'EC']); + + $this->firstFlight = Flight::factory() + ->for($belgium, 'departure') + ->for($ecuador, 'arrival') + ->create([ + 'code' => '7485', + ]); + + $this->secondFlight = Flight::factory() + ->for($ecuador, 'departure') + ->for($belgium, 'arrival') + ->create([ + 'code' => '8596', + ]); +}); + +it('apply relationships using aliases that point to the same table', function () { + $this->request->query->add([ + 'filter' => [ + 'departure.name' => [ + 'value' => 'gium', + 'operator' => Comparators\Text::EndWith->value, + ], + 'arrival.name' => [ + 'value' => 'cuado', + 'operator' => Comparators\Text::Contains->value, + ], + ], + ]); + + $queryBuilder = QueryBuilder::for(Flight::class, $this->request) + ->allowedFilters([ + AllowedFilter::custom('departure.name', new TextFilter(false, JoinType::Inner, 'departure')), + AllowedFilter::custom('arrival.name', new TextFilter(false, JoinType::Inner, 'arrival')), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select `flights`.* from `flights` inner join `countries` as `departure` on `flights`.`departure_id` = `departure`.`id` inner join `countries` as `arrival` on `flights`.`arrival_id` = `arrival`.`id` where lower(`departure`.`name`) like ? and lower(`arrival`.`name`) like ?' + ) + ->and($queryBuilder->get()) + ->toHaveCount(1) + ->sequence( + fn ($flight) => $flight->code->toBe('7485') + ); +}); diff --git a/tests/Mocks/Database/Factories/FlightFactory.php b/tests/Mocks/Database/Factories/FlightFactory.php new file mode 100644 index 0000000..ff57b43 --- /dev/null +++ b/tests/Mocks/Database/Factories/FlightFactory.php @@ -0,0 +1,26 @@ + + */ + public function definition(): array + { + return [ + 'departure_id' => Country::factory(), + 'arrival_id' => Country::factory(), + 'code' => fake()->colorName(), + ]; + } +} diff --git a/tests/Mocks/Database/migrations/2024_04_09_094126_create_flights_table.php b/tests/Mocks/Database/migrations/2024_04_09_094126_create_flights_table.php new file mode 100644 index 0000000..da5702b --- /dev/null +++ b/tests/Mocks/Database/migrations/2024_04_09_094126_create_flights_table.php @@ -0,0 +1,24 @@ +id(); + $table->foreignId('departure_id')->constrained('countries'); + $table->foreignId('arrival_id')->constrained('countries'); + $table->string('code'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('flights'); + } +}; diff --git a/tests/Mocks/Models/Author.php b/tests/Mocks/Models/Author.php index ed6bb7f..59d204a 100644 --- a/tests/Mocks/Models/Author.php +++ b/tests/Mocks/Models/Author.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Tests\Mocks\Database\Factories\AuthorFactory; use Tests\Mocks\Enums\AuthorTypeEnum; @@ -27,4 +28,9 @@ public function country(): BelongsTo { return $this->belongsTo(Country::class); } + + public function books(): HasMany + { + return $this->hasMany(Book::class); + } } diff --git a/tests/Mocks/Models/Country.php b/tests/Mocks/Models/Country.php index 8b190fe..9165d1d 100644 --- a/tests/Mocks/Models/Country.php +++ b/tests/Mocks/Models/Country.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Tests\Mocks\Database\Factories\CountryFactory; class Country extends Model @@ -16,4 +17,9 @@ protected static function newFactory(): CountryFactory { return CountryFactory::new(); } + + public function authors(): HasMany + { + return $this->hasMany(Author::class); + } } diff --git a/tests/Mocks/Models/Flight.php b/tests/Mocks/Models/Flight.php new file mode 100644 index 0000000..2224cf1 --- /dev/null +++ b/tests/Mocks/Models/Flight.php @@ -0,0 +1,30 @@ +belongsTo(Country::class); + } + + public function arrival(): BelongsTo + { + return $this->belongsTo(Country::class); + } +} diff --git a/tests/Sorts/PowerJoins/RelationSortTest.php b/tests/Sorts/PowerJoins/RelationSortTest.php new file mode 100644 index 0000000..4856d47 --- /dev/null +++ b/tests/Sorts/PowerJoins/RelationSortTest.php @@ -0,0 +1,53 @@ +request = new Illuminate\Http\Request(); + $this->request->setMethod(Request::METHOD_GET); + + $belgium = Country::factory()->create(['name' => 'Belgium', 'code' => 'BE']); + $ecuador = Country::factory()->create(['name' => 'Ecuador', 'code' => 'EC']); + + $this->firstFlight = Flight::factory() + ->for($belgium, 'departure') + ->for($ecuador, 'arrival') + ->create([ + 'code' => '7485', + ]); + + $this->secondFlight = Flight::factory() + ->for($ecuador, 'departure') + ->for($belgium, 'arrival') + ->create([ + 'code' => '8596', + ]); +}); + +it('apply relationships using aliases that point to the same table', function () { + $this->request->query->add([ + 'sort' => 'departure.code', + ]); + + $queryBuilder = QueryBuilder::for(Flight::class, $this->request) + ->allowedSorts([ + AllowedSort::custom('departure.code', new RelationSort(JoinType::Inner, joinAliases: 'departure')), + AllowedSort::custom('arrival.code', new RelationSort(JoinType::Inner, joinAliases: 'arrival')), + ]); + + expect($queryBuilder->toSql()) + ->toBe( + 'select `flights`.* from `flights` inner join `countries` as `departure` on `flights`.`departure_id` = `departure`.`id` order by `departure`.`code` asc' + ) + ->and($queryBuilder->get()) + ->sequence( + fn ($flight) => $flight->code->toBe('7485'), + fn ($flight) => $flight->code->toBe('8596'), + ); +}); diff --git a/tests/Sorts/RelationSortTest.php b/tests/Sorts/RelationSortTest.php index 3f12cd5..267994d 100644 --- a/tests/Sorts/RelationSortTest.php +++ b/tests/Sorts/RelationSortTest.php @@ -32,6 +32,7 @@ 'isbn' => '758952123', 'pages' => 38, 'classification' => BookClassificationEnum::OverTwelveYearsOld, + 'order' => 5, ]); $this->secondBook = Book::factory() @@ -50,6 +51,7 @@ 'isbn' => '5895421369', 'pages' => 45, 'classification' => BookClassificationEnum::Adults, + 'order' => 3, ]); }); @@ -154,3 +156,48 @@ fn ($book) => $book->title->toBe('Domain Driven Design for Laravel'), ); }); + +it('sort the records in descending order using the "HasMany" relationship with aliases', function () { + $this->request->query->add([ + 'sort' => '-chapters.number', + ]); + + $queryBuilder = QueryBuilder::for(Book::class, $this->request) + ->allowedSorts([ + AllowedSort::custom('chapters.number', + new RelationSort(JoinType::Left, AggregationType::Sum, 'chapters_alias') + ), + ]); + + expect($queryBuilder->toSql()) + ->toBe('select `books`.*, sum(chapters_alias.number) as chapters_alias_number_sum from `books` left join `chapters` as `chapters_alias` on `chapters_alias`.`book_id` = `books`.`id` group by `books`.`id` order by chapters_alias_number_sum desc') + ->and($queryBuilder->get()) + ->sequence( + fn ($book) => $book->title->toBe('Laravel Beyond Crud'), + fn ($book) => $book->title->toBe('Domain Driven Design for Laravel'), + ); +}); + +it('sort the records in descending order using the multiple relationship aliases', function () { + $this->request->query->add([ + 'sort' => 'authors.books.order', + ]); + + $queryBuilder = QueryBuilder::for(Country::class, $this->request) + ->allowedSorts([ + AllowedSort::custom('authors.books.order', + new RelationSort(JoinType::Inner, joinAliases: [ + 'authors' => fn ($join) => $join->as('authors_alias'), + 'books' => fn ($join) => $join->as('books_alias'), + ]) + ), + ]); + + expect($queryBuilder->toSql()) + ->toBe('select `countries`.* from `countries` inner join `authors` as `authors_alias` on `authors_alias`.`country_id` = `countries`.`id` inner join `books` as `books_alias` on `books_alias`.`author_id` = `authors_alias`.`id` order by `books_alias`.`order` asc') + ->and($queryBuilder->get()) + ->sequence( + fn ($country) => $country->code->toBe('US'), + fn ($country) => $country->code->toBe('BE'), + ); +});