Skip to content
Open
Show file tree
Hide file tree
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
7 changes: 7 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ Compare your `lighthouse.php` against the latest [default configuration](src/lig

## v6 to v7

### Nested mutations only act on related models

The config option `nested_mutations_scope_to_parent` was removed.
Nested mutations now always scope to the parent.
The nested operations `delete`, `disconnect`, `update` and `upsert` throw an error when given the ID of a model that is not related to the parent.
To move a model to another parent, use `connect`.

### Leverage automatic test trait setup

Methods you need to explicitly call to set up test traits were removed in favor of automatic setup.
Expand Down
11 changes: 11 additions & 0 deletions docs/6/eloquent/nested-mutations.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ Field directives such as the [@can\* family of directives](../api-reference/dire

Make sure that fields with nested mutations are only available to users who are allowed to execute all reachable nested mutations.

By default, the nested operations `delete`, `disconnect`, `update` and `upsert` accept IDs of models that are not related to the parent.
For example, `updateUser(input: { id: 2, tasks: { delete: [1] } })` deletes task 1 even if it belongs to user 1.
Set `nested_mutations_scope_to_parent` to `true` in `config/lighthouse.php` to limit them to related models:

- An ID that is not related to the parent causes an error, even if no model with that ID exists.
- `upsert` creates a new model if no model with the given ID exists.
- `upsert` with `identifyingColumns` causes an error if the matching model is not related to the parent.

`connect`, `sync`, `syncWithoutDetaching` and the `update` and `upsert` operations on [BelongsTo](#belongsto) relations are not limited, because their purpose is to establish new relationships.
Validate or authorize the IDs passed to them yourself.

## BelongsTo

We will start of by defining a mutation to create a post.
Expand Down
11 changes: 11 additions & 0 deletions docs/master/eloquent/nested-mutations.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ Field directives such as the [@can\* family of directives](../api-reference/dire

Make sure that fields with nested mutations are only available to users who are allowed to execute all reachable nested mutations.

By default, the nested operations `delete`, `disconnect`, `update` and `upsert` accept IDs of models that are not related to the parent.
For example, `updateUser(input: { id: 2, tasks: { delete: [1] } })` deletes task 1 even if it belongs to user 1.
Set `nested_mutations_scope_to_parent` to `true` in `config/lighthouse.php` to limit them to related models:

- An ID that is not related to the parent causes an error, even if no model with that ID exists.
- `upsert` creates a new model if no model with the given ID exists.
- `upsert` with `identifyingColumns` causes an error if the matching model is not related to the parent.

`connect`, `sync`, `syncWithoutDetaching` and the `update` and `upsert` operations on [BelongsTo](#belongsto) relations are not limited, because their purpose is to establish new relationships.
Validate or authorize the IDs passed to them yourself.

## BelongsTo

We will start of by defining a mutation to create a post.
Expand Down
9 changes: 3 additions & 6 deletions src/Execution/Arguments/NestedManyToMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,26 +42,23 @@ public function __invoke($model, $args): void
}

if ($args->has('update')) {
$updateModel = new ResolveNested(new UpdateModel(new SaveModel($relation)));
$updateModel = new ResolveNested(new UpdateModel(new SaveModel($relation), $relation));

foreach ($args->arguments['update']->value as $childArgs) {
$updateModel($relation->make(), $childArgs);
}
}

if ($args->has('upsert')) {
$upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation)));
$upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation), parentRelation: $relation));

foreach ($args->arguments['upsert']->value as $childArgs) {
$upsertModel($relation->make(), $childArgs);
}
}

if ($args->has('delete')) {
$ids = $args->arguments['delete']->toPlain();

$relation->detach($ids);
$relation->getRelated()::destroy($ids);
ParentRelationScope::delete($relation, Arr::wrap($args->arguments['delete']->toPlain()));
}

if ($args->has('connect')) {
Expand Down
17 changes: 5 additions & 12 deletions src/Execution/Arguments/NestedOneToMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Arr;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;

class NestedOneToMany implements ArgResolver
Expand All @@ -30,15 +31,15 @@ public function __invoke($model, $args): void
}

if ($args->has('update')) {
$updateModel = new ResolveNested(new UpdateModel(new SaveModel($relation)));
$updateModel = new ResolveNested(new UpdateModel(new SaveModel($relation), $relation));

foreach ($args->arguments['update']->value as $childArgs) {
$updateModel($relation->make(), $childArgs);
}
}

if ($args->has('upsert')) {
$upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation)));
$upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation), parentRelation: $relation));

foreach ($args->arguments['upsert']->value as $childArgs) {
$upsertModel($relation->make(), $childArgs);
Expand All @@ -57,23 +58,15 @@ public function __invoke($model, $args): void
}

if ($args->has('disconnect')) {
$children = $relation
->make()
->whereIn(
$relation->make()->getKeyName(),
$args->arguments['disconnect']->value,
)
->get();
$children = ParentRelationScope::findMany($relation, $args->arguments['disconnect']->value);
foreach ($children as $child) {
$child->setAttribute($relation->getForeignKeyName(), null);
$child->save();
}
}

if ($args->has('delete')) {
$relation->getRelated()::destroy(
$args->arguments['delete']->toPlain(),
);
ParentRelationScope::delete($relation, Arr::wrap($args->arguments['delete']->toPlain()));
}
}
}
9 changes: 4 additions & 5 deletions src/Execution/Arguments/NestedOneToOne.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use GraphQL\Error\Error;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Support\Arr;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;

class NestedOneToOne implements ArgResolver
Expand Down Expand Up @@ -36,21 +37,19 @@ public function __invoke($model, $args): void
}

if ($args->has('update')) {
$updateModel = new ResolveNested(new UpdateModel(new SaveModel($relation)));
$updateModel = new ResolveNested(new UpdateModel(new SaveModel($relation), $relation));

$updateModel($relation->make(), $args->arguments['update']->value);
}

if ($args->has('upsert')) {
$upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation)));
$upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation), parentRelation: $relation));

$upsertModel($relation->first() ?? $relation->make(), $args->arguments['upsert']->value);
}

if ($args->has('delete')) {
$relation->getRelated()::destroy(
$args->arguments['delete']->toPlain(),
);
ParentRelationScope::delete($relation, Arr::wrap($args->arguments['delete']->toPlain()));
}
}
}
133 changes: 133 additions & 0 deletions src/Execution/Arguments/ParentRelationScope.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php declare(strict_types=1);

namespace Nuwave\Lighthouse\Execution\Arguments;

use GraphQL\Error\Error;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\Relation;

use function Safe\json_encode;

class ParentRelationScope
{
public const UNRELATED_MODEL = 'Cannot modify a model that is not related to the given parent';

/** @param array<mixed> $values */
public static function unrelatedModel(array $values): string
{
$valueList = implode(', ', array_map(
static fn (mixed $value): mixed => is_scalar($value) ? $value : json_encode($value),
$values,
));

return self::UNRELATED_MODEL . ": {$valueList}.";
}

/**
* @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $relation
*
* @phpstan-assert-if-true !null $relation
*/
public static function applies(?Relation $relation): bool
{
// TODO remove the config check with v7
return $relation !== null
// BelongsTo update and upsert attach unrelated models by design
&& ! $relation instanceof BelongsTo
&& config('lighthouse.nested_mutations_scope_to_parent');
}

/**
* @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $relation
* @param int|string $id
*/
public static function findOrFail(?Relation $relation, Model $model, mixed $id): Model
{
if (! self::applies($relation)) {
return $model->newQuery()->findOrFail($id);
}

return self::findMany($relation, [$id])->firstOrFail();
}

/**
* @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model> $relation
* @param array<mixed> $ids
*
* @return \Illuminate\Database\Eloquent\Collection<int, \Illuminate\Database\Eloquent\Model>
*/
public static function findMany(Relation $relation, array $ids): EloquentCollection
{
if (! self::applies($relation)) {
$related = $relation->getRelated();

return $related->whereIn($related->getKeyName(), $ids)->get();
}

$models = (clone $relation)->whereKey($ids)->get();

$unrelatedIDs = array_diff($ids, $models->modelKeys());
if ($unrelatedIDs !== []) {
throw new Error(self::unrelatedModel($unrelatedIDs));
}

return $models;
}

/**
* @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model> $relation
* @param array<mixed> $ids
*/
public static function delete(Relation $relation, array $ids): void
{
$models = self::findMany($relation, $ids);

if ($relation instanceof BelongsToMany) {
$relation->detach($ids);
}

foreach ($models as $model) {
$model->delete();
}
}

/**
* @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $relation
* @param int|string $id
*/
public static function findForUpsert(?Relation $relation, Model $model, mixed $id): ?Model
{
if (! self::applies($relation)) {
return $model->newQuery()->find($id);
}

return self::firstForUpsert($relation, $model, [$model->getKeyName() => $id]);
}

/**
* @param \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null $relation
* @param array<string, mixed> $columns
*/
public static function firstForUpsert(?Relation $relation, Model $model, array $columns): ?Model
{
if (! self::applies($relation)) {
return $model->newQuery()->firstWhere($columns);
}

$qualifiedColumns = [];
foreach ($columns as $column => $value) {
$qualifiedColumns[$model->qualifyColumn($column)] = $value;
}

$scopedModel = (clone $relation)->where($qualifiedColumns)->first();

if ($scopedModel === null && $model->newQuery()->where($columns)->exists()) {
throw new Error(self::unrelatedModel($columns));
}

return $scopedModel;
}
}
10 changes: 7 additions & 3 deletions src/Execution/Arguments/UpdateModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Nuwave\Lighthouse\Execution\Arguments;

use GraphQL\Error\Error;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Arr;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
use Nuwave\Lighthouse\Support\Contracts\PreSaveArgumentsAware;
Expand All @@ -17,8 +18,11 @@ class UpdateModel implements ArgResolver, PreSaveArgumentsAware
protected $previous;

/** @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous */
public function __construct(callable $previous)
{
public function __construct(
callable $previous,
/** @var \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null */
protected ?Relation $parentRelation = null,
) {
$this->previous = $previous;
}

Expand All @@ -33,7 +37,7 @@ public function __invoke($model, $args): mixed
?? throw new Error(self::MISSING_PRIMARY_KEY_FOR_UPDATE);
assert($id instanceof Argument);

$model = $model->newQuery()->findOrFail($id->value);
$model = ParentRelationScope::findOrFail($this->parentRelation, $model, $id->value);

return ($this->previous)($model, $args);
}
Expand Down
7 changes: 5 additions & 2 deletions src/Execution/Arguments/UpsertModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use GraphQL\Error\Error;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
use Nuwave\Lighthouse\Support\Contracts\PreSaveArgumentsAware;

Expand All @@ -23,6 +24,8 @@ public function __construct(
callable $previous,
/** @var array<string>|null */
protected ?array $identifyingColumns = null,
/** @var \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model>|null */
protected ?Relation $parentRelation = null,
) {
$this->previous = $previous;
}
Expand All @@ -40,7 +43,7 @@ public function __invoke($model, $args): mixed
$identifyingColumns = $this->identifyingColumnValues($args, $this->identifyingColumns)
?? throw new Error(self::MISSING_IDENTIFYING_COLUMNS_FOR_UPSERT);

$existingModel = $model->newQuery()->firstWhere($identifyingColumns);
$existingModel = ParentRelationScope::firstForUpsert($this->parentRelation, $model, $identifyingColumns);

if ($existingModel !== null) {
$model = $existingModel;
Expand All @@ -50,7 +53,7 @@ public function __invoke($model, $args): mixed
if ($existingModel === null) {
$id = $this->retrieveID($model, $args);
if ($id) {
$existingModel = $model->newQuery()->find($id);
$existingModel = ParentRelationScope::findForUpsert($this->parentRelation, $model, $id);
if ($existingModel !== null) {
$model = $existingModel;
}
Expand Down
7 changes: 5 additions & 2 deletions src/Schema/Directives/DeleteDirective.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Arr;
use Nuwave\Lighthouse\Exceptions\DefinitionException;
use Nuwave\Lighthouse\Execution\Arguments\ParentRelationScope;
use Nuwave\Lighthouse\Schema\AST\DocumentAST;
use Nuwave\Lighthouse\Support\Contracts\ArgManipulator;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
Expand Down Expand Up @@ -93,8 +95,9 @@ public function __invoke($model, $idOrIds): void
$relation->delete();
}
} else {
$related = $relation->make();
$related::destroy($idOrIds);
foreach (ParentRelationScope::findMany($relation, Arr::wrap($idOrIds)) as $related) {
$related->delete();
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/Schema/Directives/UpdateDirective.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@ public static function definition(): string

protected function makeExecutionFunction(?Relation $parentRelation = null): callable
{
return new UpdateModel(new SaveModel($parentRelation));
return new UpdateModel(new SaveModel($parentRelation), $parentRelation);
}
}
Loading
Loading