diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e098d9..95d8a46 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,12 +11,9 @@ jobs: strategy: fail-fast: false matrix: - php: [8.1, 8.2, 8.3] - laravel: [10.*, 11.*] + php: [8.2, 8.3, 8.4] + laravel: [10.*, 11.*, 12.*] dependency-version: [prefer-lowest, prefer-stable] - exclude: - - php: 8.1 - laravel: 11.* name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} diff --git a/.gitignore b/.gitignore index 506b9d0..096dc0f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ composer.phar composer.lock vendor .php_cs.cache -.phpunit.result.cache \ No newline at end of file +.phpunit.result.cache +.phpunit.cache +.claude \ No newline at end of file diff --git a/composer.json b/composer.json index d6e2726..2e99916 100644 --- a/composer.json +++ b/composer.json @@ -7,17 +7,17 @@ } }, "require": { - "php": "^8.1", - "illuminate/container": "^10.0|^11.0", - "illuminate/database": "^10.0|^11.0", - "illuminate/support": "^10.0|^11.0", - "spatie/laravel-query-builder": "^5.0" + "php": "^8.2|^8.3|^8.4", + "illuminate/container": "^10.0|^11.0|^12.0", + "illuminate/database": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "spatie/laravel-query-builder": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^9.0", - "laravel/laravel": "^10.0|^11.0", + "phpunit/phpunit": "^10.0|^11.0", + "laravel/laravel": "^10.0|^11.0|^12.0", "mockery/mockery": "^1.5", - "makeabledk/laravel-factory-enhanced": "^5.0", + "makeabledk/laravel-factory-enhanced": "^6.0", "fakerphp/faker": "^1.18" }, "autoload-dev": { diff --git a/phpunit.xml b/phpunit.xml index 4666281..e46e0c6 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,25 +1,15 @@ - - - - ./tests/ - - - - - - - - - + + + + ./tests/ + + + + + + + + + diff --git a/src/Endpoint.php b/src/Endpoint.php index a5d7e0f..7756b0c 100644 --- a/src/Endpoint.php +++ b/src/Endpoint.php @@ -7,8 +7,10 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use Makeable\ApiEndpoints\Concerns\NormalizesRelationNames; +use Spatie\QueryBuilder\AllowedInclude; class Endpoint { @@ -203,7 +205,7 @@ public function getQuery() * @param Request|null $request * @return \Makeable\ApiEndpoints\QueryBuilder */ - public function toQueryBuilder(Request $request = null) + public function toQueryBuilder(?Request $request = null) { $builder = call_user_func([static::$queryBuilderClass, 'for'], $this->model, $request); @@ -342,6 +344,9 @@ protected function buildNamespacedConstraintArrays($relations) }; } + // NOTE: In order to support AllowedInclude::relationship() we'd need to do + // additional normalization here since it returns a Collection of AllowedInclude. + // Furthermore we'll allow for multiple constraints on the same relation. // Later on we'll apply all of the constraints into the same query. return [$relation => Arr::wrap($constraint)]; diff --git a/src/QueryBuilder.php b/src/QueryBuilder.php index 4ab8547..5a9c671 100644 --- a/src/QueryBuilder.php +++ b/src/QueryBuilder.php @@ -5,13 +5,16 @@ use Closure; use Illuminate\Contracts\Pagination\CursorPaginator; use Illuminate\Contracts\Pagination\Paginator; +use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Str; use Makeable\ApiEndpoints\Concerns\AddsAppendsToQuery; use Makeable\ApiEndpoints\Concerns\NormalizesRelationNames; +use Spatie\QueryBuilder\AllowedInclude; use Spatie\QueryBuilder\QueryBuilder as SpatieBuilder; class QueryBuilder extends SpatieBuilder @@ -21,6 +24,15 @@ class QueryBuilder extends SpatieBuilder allowedAppends as originalAllowedAppends; } + public function __construct( + protected Relation|EloquentBuilder $subject, + ?Request $request = null + ) { + $this->request = $request + ? QueryBuilderRequest::fromRequest($request) + : app(QueryBuilderRequest::class); + } + /** * @var array */ @@ -47,19 +59,6 @@ public function __call($name, $arguments) return $result; } - /** - * @param Request|null $request - * @return QueryBuilder - */ - protected function initializeRequest(?Request $request = null): static - { - $this->request = $request - ? QueryBuilderRequest::fromRequest($request) - : app(QueryBuilderRequest::class); - - return $this; - } - /** * @param $appends * @return QueryBuilder @@ -67,7 +66,7 @@ protected function initializeRequest(?Request $request = null): static public function allowedAppends($appends): static { collect($appends) - ->mapWithKeys(Closure::fromCallable([$this, 'normalizeRelationQueries'])) + ->flatMap(fn ($constraints, $relation) => $this->normalizeRelationQueries($constraints, $relation)) ->tap(function (Collection $appends) { $this->originalAllowedAppends($appends->keys()->all()); }) @@ -94,7 +93,7 @@ public function allowedAppends($appends): static * @param Collection|null $appends * @return mixed */ - protected function addAppendsToResults(Collection $results, Collection $appends = null) + protected function addAppendsToResults(Collection $results, ?Collection $appends = null) { $appends = collect($appends ?: $this->request->appends()); @@ -129,7 +128,7 @@ protected function addAppendsToResults(Collection $results, Collection $appends public function allowedIncludes($includes): static { collect($includes) - ->mapWithKeys(Closure::fromCallable([$this, 'normalizeRelationQueries'])) + ->flatMap(fn ($constraints, $relation) => $this->normalizeRelationQueries($constraints, $relation)) ->mapWithKeys(fn ($constraints, $relation) => [$this->normalizeRelationName($relation) => $constraints]) ->tap(function (Collection $includes) { $this->queueConstraints($includes); @@ -244,12 +243,20 @@ protected function mergeConstraints(...$constraints): Closure * @param $relation * @return array */ - protected function normalizeRelationQueries($constraints, $relation): array + protected function normalizeRelationQueries($constraints, $relation): Collection { - if (is_numeric($relation)) { +// Currently not working as intended +// // Support AllowedInclude::relationship() which returns a Collection of AllowedInclude +// if (is_numeric($relation) && is_array($constraints) && count($constraints) === 1 && $constraints[0] instanceof Collection) { +// return $constraints[0]->mapWithKeys(function (AllowedInclude $include) { +// return [$include->getName() => [fn ($query) => $include->include($query)]]; +// }); +// } + + if (is_numeric($relation) && is_string($constraints)) { [$constraints, $relation] = [[], $constraints]; } - return [$relation => $constraints]; + return collect([$relation => $constraints]); } } diff --git a/tests/Feature/EndpointHttpTest.php b/tests/Feature/EndpointHttpTest.php index 5b97361..b4554b8 100644 --- a/tests/Feature/EndpointHttpTest.php +++ b/tests/Feature/EndpointHttpTest.php @@ -12,8 +12,7 @@ class EndpointHttpTest extends TestCase { use RefreshDatabase; - /** @test */ - public function it_can_load_model_with_nested_endpoint_relations() + public function test_it_can_load_model_with_nested_endpoint_relations() { $user = factory(User::class) ->with(1, 'servers.databases') @@ -30,8 +29,7 @@ public function it_can_load_model_with_nested_endpoint_relations() ]]); } - /** @test **/ - public function any_allowed_relation_is_also_countable() + public function test_any_allowed_relation_is_also_countable() { $user = factory(User::class)->with(2, 'servers')->create(); @@ -45,8 +43,7 @@ public function any_allowed_relation_is_also_countable() ]]); } - /** @test **/ - public function it_appends_attributes() + public function test_it_appends_attributes() { $server = factory(Server::class)->create(); @@ -60,8 +57,7 @@ public function it_appends_attributes() ]]); } - /** @test **/ - public function it_accepts_custom_queries_for_appends() + public function test_it_accepts_custom_queries_for_appends() { $user = factory(User::class)->with(1, 'servers')->create(); @@ -77,8 +73,7 @@ public function it_accepts_custom_queries_for_appends() ]]); } - /** @test */ - public function filters_may_be_applied() + public function test_filters_may_be_applied() { $notFavorite = factory(Server::class)->create(['is_favorite' => false]); $favorite = factory(Server::class)->create(['is_favorite' => true]); @@ -93,8 +88,7 @@ public function filters_may_be_applied() ]]); } - /** @test **/ - public function it_normalizes_snake_case_to_camel_case() + public function test_it_normalizes_snake_case_to_camel_case() { factory(Team::class) ->with(1, 'users') @@ -111,8 +105,7 @@ public function it_normalizes_snake_case_to_camel_case() ->assertJsonCount(1, '0.users.0.favorite_servers.0.databases'); } - /** @test **/ - public function it_supports_circular_includes() + public function test_it_supports_circular_includes() { $server = factory(Server::class) ->with(1, 'databases') @@ -132,4 +125,24 @@ public function it_supports_circular_includes() ]], ]]); } + +// Currently not working as intended +// public function test_it_supports_allowed_includes_syntax() +// { +// $server = factory(Server::class) +// ->with(1, 'users.teams') +// ->create(); +// +// $this +// ->withoutExceptionHandling() +// ->getJson('/servers?include=users.teams,users.teams_count') +// ->assertSuccessful() +// ->assertJson([[ +// 'id' => $server->id, +// 'users' => [[ +// 'teams' => [ +// ], +// ]], +// ]]); +// } } diff --git a/tests/Feature/EndpointUnitTest.php b/tests/Feature/EndpointUnitTest.php index ecde055..70a4811 100644 --- a/tests/Feature/EndpointUnitTest.php +++ b/tests/Feature/EndpointUnitTest.php @@ -12,8 +12,7 @@ class EndpointUnitTest extends TestCase { - /** @test **/ - public function it_accepts_constraints_when_defining_includes() + public function test_it_accepts_constraints_when_defining_includes() { $endpoint = Endpoint::for(User::class)->allowedIncludes([ 'servers' => $this->invokable(), @@ -23,8 +22,7 @@ public function it_accepts_constraints_when_defining_includes() $this->request($endpoint, ['include' => 'servers']); } - /** @test **/ - public function it_adapts_namespaced_appends_and_includes_when_adding_another_endpoint() + public function test_it_adapts_namespaced_appends_and_includes_when_adding_another_endpoint() { $endpoint = Endpoint::for(User::class) ->allowedAppends(['full_name']) @@ -45,8 +43,7 @@ public function it_adapts_namespaced_appends_and_includes_when_adding_another_en $this->assertArrayHasKey('servers.databases', $query->getEagerLoads()); } - /** @test **/ - public function regression_it_supports_deeply_nested_endpoints() + public function test_regression_it_supports_deeply_nested_endpoints() { $endpoint = Endpoint::for(User::class) ->tap(function ($q) { @@ -75,8 +72,7 @@ public function regression_it_supports_deeply_nested_endpoints() $this->assertArrayHasKey('servers.databases', $query->getEagerLoads()); } - /** @test **/ - public function regression_it_protects_against_infinite_recursion_on_circular_referenced_endpoints() + public function test_regression_it_protects_against_infinite_recursion_on_circular_referenced_endpoints() { $userEndpoint = Endpoint::for(User::class); $serverEndpoint = Endpoint::for(Server::class)->allowedIncludes(['user' => $userEndpoint]); @@ -105,8 +101,7 @@ public function regression_it_protects_against_infinite_recursion_on_circular_re }); } - /** @test **/ - public function it_merges_relational_append_constraints_into_include_constraints() + public function test_it_merges_relational_append_constraints_into_include_constraints() { $invoked = []; @@ -127,8 +122,7 @@ public function it_merges_relational_append_constraints_into_include_constraints $this->assertArrayHasKey('includes', $invoked); } - /** @test **/ - public function it_only_applies_relational_appends_when_relation_is_included() + public function test_it_only_applies_relational_appends_when_relation_is_included() { $invoked = []; @@ -147,8 +141,7 @@ public function it_only_applies_relational_appends_when_relation_is_included() $this->assertEquals([], $invoked); } - /** @test **/ - public function it_only_applies_endpoint_append_constraints_when_appended() + public function test_it_only_applies_endpoint_append_constraints_when_appended() { $endpoint = Endpoint::for(User::class) ->allowedIncludes([ @@ -166,8 +159,7 @@ public function it_only_applies_endpoint_append_constraints_when_appended() $this->assertTrue(true); // If reached this point without exceptions, we've succeeded } - /** @test **/ - public function any_append_may_have_a_custom_constraint_defined() + public function test_any_append_may_have_a_custom_constraint_defined() { $endpoint = Endpoint::for(User::class)->allowedAppends([ 'full_name' => $this->invokable(), @@ -179,8 +171,7 @@ public function any_append_may_have_a_custom_constraint_defined() $this->request($endpoint, ['append' => 'full_name']); } - /** @test **/ - public function it_applies_endpoint_taps_when_relation_is_included() + public function test_it_applies_endpoint_taps_when_relation_is_included() { $endpoint = Endpoint::for(User::class) ->allowedIncludes([ @@ -193,8 +184,7 @@ public function it_applies_endpoint_taps_when_relation_is_included() $this->request($endpoint, ['include' => 'servers']); } - /** @test **/ - public function it_invokes_when_including_count() + public function test_it_invokes_when_including_count() { $endpoint = Endpoint::for(User::class) ->allowedIncludes(['servers', 'serversCount']) @@ -206,8 +196,7 @@ public function it_invokes_when_including_count() $this->request($endpoint, ['include' => 'serversCount']); } - /** @test **/ - public function includes_works_with_snake_case() + public function test_includes_works_with_snake_case() { $endpoint = Endpoint::for(User::class) ->allowedIncludes(['servers', 'favorite_servers']) diff --git a/tests/Stubs/Endpoints/ServerEndpoint.php b/tests/Stubs/Endpoints/ServerEndpoint.php index 48b4bff..a440992 100644 --- a/tests/Stubs/Endpoints/ServerEndpoint.php +++ b/tests/Stubs/Endpoints/ServerEndpoint.php @@ -24,6 +24,7 @@ public function __invoke() ]) ->allowedIncludes([ 'databases' => DatabaseEndpoint::make(), + 'users' => UserEndpoint::make(), ]) ->defaultSort('sort_order'); } diff --git a/tests/Stubs/Endpoints/UserEndpoint.php b/tests/Stubs/Endpoints/UserEndpoint.php index 4f77d27..5d1828d 100644 --- a/tests/Stubs/Endpoints/UserEndpoint.php +++ b/tests/Stubs/Endpoints/UserEndpoint.php @@ -4,6 +4,7 @@ use Makeable\ApiEndpoints\Endpoint; use Makeable\ApiEndpoints\Tests\Stubs\User; +use Spatie\QueryBuilder\AllowedInclude; class UserEndpoint extends Endpoint { @@ -15,6 +16,8 @@ public function __invoke() ->allowedIncludes([ 'servers' => ServerEndpoint::make(), 'favoriteServers' => ServerEndpoint::make(), + // Newer Spatie syntax is currently not supported. + // AllowedInclude::relationship('teams'), ]); } }