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

Add IncludedExists #891

Merged
merged 2 commits into from
Sep 12, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config/query-builder.php
Original file line number Diff line number Diff line change
@@ -26,6 +26,12 @@
*/
'count_suffix' => 'Count',

/*
* Related model exists are included using the relationship name suffixed with this string.
* For example: GET /users?include=postsExists
*/
'exists_suffix' => 'Exists',

/*
* By default the package will throw an `InvalidFilterQuery` exception when a filter in the
* URL is not allowed in the `allowedFilters()` method.
20 changes: 19 additions & 1 deletion docs/features/including-relationships.md
Original file line number Diff line number Diff line change
@@ -38,6 +38,7 @@ $users = QueryBuilder::for(User::class)
->allowedIncludes(['friends'])
->with('posts') // posts will always by included, friends can be requested
->withCount('posts')
->withExists('posts')
->get();
```

@@ -70,12 +71,29 @@ Under the hood this uses Laravel's `withCount method`. [Read more about the `wit

$users = QueryBuilder::for(User::class)
->allowedIncludes([
'posts', // allows including `posts` or `postsCount`
'posts', // allows including `posts` or `postsCount` or `postsExists`
AllowedInclude::count('friendsCount'), // only allows include the number of `friends()` related models
]);
// every user in $users will contain a `posts_count` and `friends_count` property
```

## Including related model exists

Every allowed include will automatically allow requesting its related model exists using a `Exists` suffix. On top of that it's also possible to specifically allow requesting and querying the related model exists (and not include the entire relationship).

Under the hood this uses Laravel's `withExists method`. [Read more about the `withExists` method here](https://laravel.com/docs/master/eloquent-relationships#other-aggregate-functions).

```php
// GET /users?include=postsExists,friendsExists

$users = QueryBuilder::for(User::class)
->allowedIncludes([
'posts', // allows including `posts` or `postsCount` or `postsExists`
AllowedInclude::exists('friendsExists'), // only allows include the existence of `friends()` related models
]);
// every user in $users will contain a `posts_exists` and `friends_exists` property
```

## Include aliases

It can be useful to specify an alias for an include to enable friendly relationship names. For example, your users table might have a `userProfile` relationship, which might be neater just specified as `profile`. Using aliases you can specify a new, shorter name for this include:
6 changes: 6 additions & 0 deletions docs/installation-setup.md
Original file line number Diff line number Diff line change
@@ -44,6 +44,12 @@ return [
* For example: GET /users?include=postsCount
*/
'count_suffix' => 'Count',

/*
* Related model exists are included using the relationship name suffixed with this string.
* For example: GET /users?include=postsExists
*/
'exists_suffix' => 'Exists',

/*
* By default the package will throw an `InvalidFilterQuery` exception when a filter in the
26 changes: 20 additions & 6 deletions src/AllowedInclude.php
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Spatie\QueryBuilder\Includes\IncludedCount;
use Spatie\QueryBuilder\Includes\IncludedExists;
use Spatie\QueryBuilder\Includes\IncludedRelationship;
use Spatie\QueryBuilder\Includes\IncludeInterface;

@@ -40,12 +41,18 @@ public static function relationship(string $name, ?string $internalName = null):
]);

if (! Str::contains($relationship, '.')) {
$suffix = config('query-builder.count_suffix');

$includes = $includes->merge(self::count(
$alias.$suffix,
$relationship.$suffix
));
$countSuffix = config('query-builder.count_suffix');
$existsSuffix = config('query-builder.exists_suffix');

$includes = $includes
->merge(self::count(
$alias.$countSuffix,
$relationship.$countSuffix
))
->merge(self::exists(
$alias.$existsSuffix,
$relationship.$existsSuffix
));
}

return $includes;
@@ -59,6 +66,13 @@ public static function count(string $name, ?string $internalName = null): Collec
]);
}

public static function exists(string $name, ?string $internalName = null): Collection
{
return collect([
new static($name, new IncludedExists(), $internalName),
]);
}

public static function custom(string $name, IncludeInterface $includeClass, ?string $internalName = null): Collection
{
return collect([
4 changes: 4 additions & 0 deletions src/Concerns/AddsIncludesToQuery.php
Original file line number Diff line number Diff line change
@@ -33,6 +33,10 @@ public function allowedIncludes($includes): static
return AllowedInclude::count($include);
}

if (Str::endsWith($include, config('query-builder.exists_suffix'))) {
return AllowedInclude::exists($include);
}

return AllowedInclude::relationship($include);
})
->unique(function (AllowedInclude $allowedInclude) {
20 changes: 20 additions & 0 deletions src/Includes/IncludedExists.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Spatie\QueryBuilder\Includes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;

class IncludedExists implements IncludeInterface
{
public function __invoke(Builder $query, string $exists)
{
$exists = Str::before($exists, config('query-builder.exists_suffix'));

$query
->withExists($exists)
->withCasts([
"{$exists}_exists" => 'boolean',
]);
}
}
37 changes: 37 additions & 0 deletions tests/IncludeTest.php
Original file line number Diff line number Diff line change
@@ -82,6 +82,23 @@
$this->assertNotNull($model->related_models_count);
});

it('can include an includes exists', function () {
$model = createQueryFromIncludeRequest('relatedModelsExists')
->allowedIncludes('relatedModelsExists')
->first();

$this->assertNotNull($model->related_models_exists);
$this->assertIsBool($model->related_models_exists);
});

test('allowing an include also allows the include exists', function () {
$model = createQueryFromIncludeRequest('relatedModelsExists')
->allowedIncludes('relatedModels')
->first();

$this->assertNotNull($model->related_models_exists);
});

it('can include nested model relations', function () {
$models = createQueryFromIncludeRequest('relatedModels.nestedRelatedModels')
->allowedIncludes('relatedModels.nestedRelatedModels')
@@ -134,6 +151,26 @@
->first();
});

test('allowing a nested include only allows the include exists for the first level', function () {
$model = createQueryFromIncludeRequest('relatedModelsExists')
->allowedIncludes('relatedModels.nestedRelatedModels')
->first();

$this->assertNotNull($model->related_models_exists);

$this->expectException(InvalidIncludeQuery::class);

createQueryFromIncludeRequest('nestedRelatedModelsExists')
->allowedIncludes('relatedModels.nestedRelatedModels')
->first();

$this->expectException(InvalidIncludeQuery::class);

createQueryFromIncludeRequest('related-models.nestedRelatedModelsExists')
->allowedIncludes('relatedModels.nestedRelatedModels')
->first();
});

it('can include morph model relations', function () {
$models = createQueryFromIncludeRequest('morphModels')
->allowedIncludes('morphModels')