This commit is contained in:
Your Name
2021-07-26 19:46:18 +02:00
parent e7a49138bb
commit aae17f10a6
818 changed files with 70695 additions and 16408 deletions
@@ -0,0 +1,209 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Nuwave\Lighthouse\Exceptions\DefinitionException;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
use Nuwave\Lighthouse\Support\Utils;
use ReflectionClass;
use ReflectionNamedType;
class ArgPartitioner
{
/**
* Partition the arguments into nested and regular.
*
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $argumentSet
* @return array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet>
*/
public static function nestedArgResolvers(ArgumentSet $argumentSet, $root): array
{
$model = $root instanceof Model
? new \ReflectionClass($root)
: null;
foreach ($argumentSet->arguments as $name => $argument) {
static::attachNestedArgResolver($name, $argument, $model);
}
return static::partition(
$argumentSet,
static function (string $name, Argument $argument): bool {
return $argument->resolver !== null;
}
);
}
/**
* Extract all the arguments that correspond to a relation of a certain type on the model.
*
* For example, if the args input looks like this:
*
* [
* 'name' => 'Ralf',
* 'comments' =>
* ['foo' => 'Bar'],
* ]
*
* and the model has a method "comments" that returns a HasMany relationship,
* the result will be:
* [
* [
* 'comments' =>
* ['foo' => 'Bar'],
* ],
* [
* 'name' => 'Ralf',
* ]
* ]
*
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $argumentSet
* @return array{0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet}
*/
public static function relationMethods(
ArgumentSet $argumentSet,
Model $model,
string $relationClass
): array {
$modelReflection = new ReflectionClass($model);
[$relations, $remaining] = static::partition(
$argumentSet,
static function (string $name) use ($modelReflection, $relationClass): bool {
return static::methodReturnsRelation($modelReflection, $name, $relationClass);
}
);
$nonNullRelations = new ArgumentSet();
$nonNullRelations->arguments = array_filter(
$relations->arguments,
static function (Argument $argument): bool {
return null !== $argument->value;
}
);
return [$nonNullRelations, $remaining];
}
/**
* Attach a nested argument resolver to an argument.
*
* @param \Nuwave\Lighthouse\Execution\Arguments\Argument $argument
*/
protected static function attachNestedArgResolver(string $name, Argument &$argument, ?ReflectionClass $model): void
{
$resolverDirective = $argument->directives->first(
Utils::instanceofMatcher(ArgResolver::class)
);
if ($resolverDirective) {
$argument->resolver = $resolverDirective;
return;
}
if (isset($model)) {
$isRelation = static function (string $relationClass) use ($model, $name): bool {
return static::methodReturnsRelation($model, $name, $relationClass);
};
if (
$isRelation(HasOne::class)
|| $isRelation(MorphOne::class)
) {
$argument->resolver = new ResolveNested(new NestedOneToOne($name));
return;
}
if (
$isRelation(HasMany::class)
|| $isRelation(MorphMany::class)
) {
$argument->resolver = new ResolveNested(new NestedOneToMany($name));
return;
}
if (
$isRelation(BelongsToMany::class)
|| $isRelation(MorphToMany::class)
) {
$argument->resolver = new ResolveNested(new NestedManyToMany($name));
return;
}
}
}
/**
* Partition arguments based on a predicate.
*
* The predicate will be called for each argument within the ArgumentSet
* with the following parameters:
* 1. The name of the argument
* 2. The argument itself
*
* Returns an array of two new ArgumentSet instances:
* - the first one contains all arguments for which the predicate matched
* - the second one contains all arguments for which the predicate did not match
*
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $argumentSet
* @return array{0: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet, 1: \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet}
*/
public static function partition(ArgumentSet $argumentSet, \Closure $predicate): array
{
$matched = new ArgumentSet();
$notMatched = new ArgumentSet();
foreach ($argumentSet->arguments as $name => $argument) {
if ($predicate($name, $argument)) {
$matched->arguments[$name] = $argument;
} else {
$notMatched->arguments[$name] = $argument;
}
}
return [
$matched,
$notMatched,
];
}
/**
* Does a method on the model return a relation of the given class?
*/
public static function methodReturnsRelation(
ReflectionClass $modelReflection,
string $name,
string $relationClass
): bool {
if (! $modelReflection->hasMethod($name)) {
return false;
}
$relationMethodCandidate = $modelReflection->getMethod($name);
$returnType = $relationMethodCandidate->getReturnType();
if ($returnType === null) {
return false;
}
if (! $returnType instanceof ReflectionNamedType) {
return false;
}
if (! class_exists($returnType->getName())) {
throw new DefinitionException('Class '.$returnType->getName().' does not exist, did you forget to import the Eloquent relation class?');
}
return is_a($returnType->getName(), $relationClass, true);
}
}
@@ -0,0 +1,70 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Illuminate\Support\Collection;
class Argument
{
/**
* The value given by the client.
*
* @var \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet>|mixed|array<mixed>
*/
public $value;
/**
* The type of the argument.
*
* @var \Nuwave\Lighthouse\Execution\Arguments\ListType|\Nuwave\Lighthouse\Execution\Arguments\NamedType|null
*/
public $type;
/**
* A list of directives associated with that argument.
*
* @var \Illuminate\Support\Collection<\Nuwave\Lighthouse\Support\Contracts\Directive>
*/
public $directives;
/**
* An argument may have a resolver that handles it's given value.
*
* @var \Nuwave\Lighthouse\Support\Contracts\ArgResolver|null
*/
public $resolver;
public function __construct()
{
$this->directives = new Collection();
}
/**
* Get the plain PHP value of this argument.
*
* @return mixed The plain PHP value.
*/
public function toPlain()
{
return static::toPlainRecursive($this->value);
}
/**
* Convert the given value to plain PHP values recursively.
*
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|array<\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet>|mixed|array<mixed> $value
* @return mixed|array<mixed>
*/
protected static function toPlainRecursive($value)
{
if ($value instanceof ArgumentSet) {
return $value->toArray();
}
if (is_array($value)) {
return array_map([static::class, 'toPlainRecursive'], $value);
}
return $value;
}
}
@@ -0,0 +1,275 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Closure;
use Nuwave\Lighthouse\Schema\Directives\RenameDirective;
use Nuwave\Lighthouse\Schema\Directives\SpreadDirective;
use Nuwave\Lighthouse\Scout\ScoutEnhancer;
use Nuwave\Lighthouse\Support\Contracts\ArgBuilderDirective;
use Nuwave\Lighthouse\Support\Contracts\FieldBuilderDirective;
use Nuwave\Lighthouse\Support\Utils;
class ArgumentSet
{
/**
* An associative array from argument names to arguments.
*
* @var array<string, \Nuwave\Lighthouse\Execution\Arguments\Argument>
*/
public $arguments = [];
/**
* An associative array of arguments that were not given.
*
* @var array<string, \Nuwave\Lighthouse\Execution\Arguments\Argument>
*/
public $undefined = [];
/**
* A list of directives.
*
* This may be coming from
* - the field the arguments are a part of
* - the parent argument when in a tree of nested inputs.
*
* @var \Illuminate\Support\Collection<\Nuwave\Lighthouse\Support\Contracts\Directive>
*/
public $directives;
/**
* Get a plain array representation of this ArgumentSet.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
$plainArguments = [];
foreach ($this->arguments as $name => $argument) {
$plainArguments[$name] = $argument->toPlain();
}
return $plainArguments;
}
/**
* Check if the ArgumentSet has a non-null value with the given key.
*/
public function has(string $key): bool
{
$argument = $this->arguments[$key] ?? null;
if ($argument === null) {
return false;
}
return $argument->value !== null;
}
/**
* Apply the @spread directive and return a new, modified instance.
*
* @noRector \Rector\DeadCode\Rector\ClassMethod\RemoveDeadRecursiveClassMethodRector
*/
public function spread(): self
{
$argumentSet = new self();
$argumentSet->directives = $this->directives;
foreach ($this->arguments as $name => $argument) {
$value = $argument->value;
// In this case, we do not care about argument sets nested within
// lists, spreading only makes sense for single nested inputs.
if ($value instanceof self) {
// Recurse down first, as that resolves the more deeply nested spreads first
$value = $value->spread();
if ($argument->directives->contains(
Utils::instanceofMatcher(SpreadDirective::class)
)) {
$argumentSet->arguments += $value->arguments;
continue;
}
}
$argumentSet->arguments[$name] = $argument;
}
return $argumentSet;
}
/**
* Apply the @rename directive and return a new, modified instance.
*
* @noRector \Rector\DeadCode\Rector\ClassMethod\RemoveDeadRecursiveClassMethodRector
*/
public function rename(): self
{
$argumentSet = new self();
$argumentSet->directives = $this->directives;
foreach ($this->arguments as $name => $argument) {
// Recursively apply the renaming to nested inputs.
// We look for further ArgumentSet instances, they
// might be contained within an array.
$argument->value = Utils::applyEach(
function ($value) {
if ($value instanceof self) {
return $value->rename();
}
return $value;
},
$argument->value
);
/** @var \Nuwave\Lighthouse\Schema\Directives\RenameDirective|null $renameDirective */
$renameDirective = $argument->directives->first(function ($directive) {
return $directive instanceof RenameDirective;
});
if ($renameDirective !== null) {
$argumentSet->arguments[$renameDirective->attributeArgValue()] = $argument;
} else {
$argumentSet->arguments[$name] = $argument;
}
}
return $argumentSet;
}
/**
* Apply ArgBuilderDirectives and scopes to the builder.
*
* @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder $builder
* @param array<string> $scopes
* @param \Closure $directiveFilter
*
* @return \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|\Laravel\Scout\Builder
*/
public function enhanceBuilder(object $builder, array $scopes, Closure $directiveFilter = null): object
{
$scoutEnhancer = new ScoutEnhancer($this, $builder);
if ($scoutEnhancer->hasSearchArguments()) {
return $scoutEnhancer->enhanceBuilder();
}
self::applyArgBuilderDirectives($this, $builder, $directiveFilter);
self::applyFieldBuilderDirectives($this, $builder);
foreach ($scopes as $scope) {
$builder->{$scope}($this->toArray());
}
return $builder;
}
/**
* Recursively apply the ArgBuilderDirectives onto the builder.
*
* TODO get rid of the reference passing in here. The issue is that @search makes a new builder instance,
* but we must special case that in some way anyhow, as only eq filters can be added on top of search.
*
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $argumentSet
* @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder $builder
* @param (\Closure(\Nuwave\Lighthouse\Support\Contracts\ArgBuilderDirective): bool)|null $directiveFilter
*/
protected static function applyArgBuilderDirectives(self $argumentSet, object &$builder, Closure $directiveFilter = null): void
{
foreach ($argumentSet->arguments as $argument) {
$value = $argument->toPlain();
// TODO switch to instanceof when we require bensampo/laravel-enum
// Unbox Enum values to ensure their underlying value is used for queries
if (is_a($value, '\BenSampo\Enum\Enum')) {
$value = $value->value;
}
$filteredDirectives = $argument
->directives
->filter(Utils::instanceofMatcher(ArgBuilderDirective::class));
if (null !== $directiveFilter) {
$filteredDirectives = $filteredDirectives->filter($directiveFilter);
}
$filteredDirectives->each(static function (ArgBuilderDirective $argBuilderDirective) use (&$builder, $value): void {
$builder = $argBuilderDirective->handleBuilder($builder, $value);
});
Utils::applyEach(
static function ($value) use (&$builder, $directiveFilter) {
if ($value instanceof self) {
self::applyArgBuilderDirectives($value, $builder, $directiveFilter);
}
},
$argument->value
);
}
}
/**
* Apply the FieldBuilderDirectives onto the builder.
*
* TODO get rid of the reference passing in here. The issue is that @search makes a new builder instance,
* but we must special case that in some way anyhow, as only eq filters can be added on top of search.
*
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $argumentSet
* @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder $builder
*/
protected static function applyFieldBuilderDirectives(self $argumentSet, object &$builder): void
{
$argumentSet->directives
->filter(Utils::instanceofMatcher(FieldBuilderDirective::class))
->each(static function (FieldBuilderDirective $fieldBuilderDirective) use (&$builder): void {
$builder = $fieldBuilderDirective->handleFieldBuilder($builder);
});
}
/**
* Add a value at the dot-separated path.
*
* Works just like @see \Illuminate\Support\Arr::add().
*
* @param mixed $value Any value to inject.
* @return $this
*/
public function addValue(string $path, $value): self
{
$argumentSet = $this;
$keys = explode('.', $path);
while (count($keys) > 1) {
$key = array_shift($keys);
// If the key doesn't exist at this depth, we will just create an empty ArgumentSet
// to hold the next value, allowing us to create the ArgumentSet to hold a final
// value at the correct depth. Then we'll keep digging into the ArgumentSet.
if (! isset($argumentSet->arguments[$key])) {
$argument = new Argument();
$argument->value = new self();
$argumentSet->arguments[$key] = $argument;
}
$argumentSet = $argumentSet->arguments[$key]->value;
}
$argument = new Argument();
$argument->value = $value;
$argumentSet->arguments[array_shift($keys)] = $argument;
return $this;
}
/**
* The contained arguments, including all that were not passed.
*
* @return array<string, \Nuwave\Lighthouse\Execution\Arguments\Argument>
*/
public function argumentsWithUndefined(): array
{
return array_merge($this->arguments, $this->undefined);
}
}
@@ -0,0 +1,179 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use GraphQL\Language\AST\FieldDefinitionNode;
use GraphQL\Language\AST\InputObjectTypeDefinitionNode;
use GraphQL\Language\AST\InputValueDefinitionNode;
use GraphQL\Language\AST\Node;
use GraphQL\Type\Definition\ResolveInfo;
use InvalidArgumentException;
use Nuwave\Lighthouse\Schema\AST\ASTBuilder;
use Nuwave\Lighthouse\Schema\DirectiveLocator;
class ArgumentSetFactory
{
/**
* @var \Nuwave\Lighthouse\Schema\AST\DocumentAST
*/
protected $documentAST;
/**
* @var \Nuwave\Lighthouse\Execution\Arguments\ArgumentTypeNodeConverter
*/
protected $argumentTypeNodeConverter;
/**
* @var \Nuwave\Lighthouse\Schema\DirectiveLocator
*/
protected $directiveLocator;
public function __construct(
ASTBuilder $astBuilder,
ArgumentTypeNodeConverter $argumentTypeNodeConverter,
DirectiveLocator $directiveLocator
) {
$this->documentAST = $astBuilder->documentAST();
$this->argumentTypeNodeConverter = $argumentTypeNodeConverter;
$this->directiveLocator = $directiveLocator;
}
/**
* Wrap client-given args with type information.
*
* @param array<mixed> $args
* @return \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet
*/
public function fromResolveInfo(array $args, ResolveInfo $resolveInfo): ArgumentSet
{
/**
* TODO handle programmatic types without an AST gracefully.
*
* @var \GraphQL\Language\AST\FieldDefinitionNode $definition
*/
$definition = $resolveInfo->fieldDefinition->astNode;
return $this->wrapArgs($definition, $args);
}
/**
* Wrap client-given args with type information.
*
* @param \GraphQL\Language\AST\FieldDefinitionNode|\GraphQL\Language\AST\InputObjectTypeDefinitionNode $definition
* @param array<mixed> $args
* @return \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet
*/
public function wrapArgs(Node $definition, array $args): ArgumentSet
{
$argumentSet = new ArgumentSet();
$argumentSet->directives = $this->directiveLocator->associated($definition);
if ($definition instanceof FieldDefinitionNode) {
$argDefinitions = $definition->arguments;
} elseif ($definition instanceof InputObjectTypeDefinitionNode) {
$argDefinitions = $definition->fields;
} else {
throw new InvalidArgumentException('Got unexpected node of type '.get_class($definition));
}
$argumentDefinitionMap = $this->makeDefinitionMap($argDefinitions);
foreach ($argumentDefinitionMap as $name => $definition) {
if (array_key_exists($name, $args)) {
$argumentSet->arguments[$name] = $this->wrapInArgument($args[$name], $definition);
} else {
$argumentSet->undefined[$name] = $this->wrapInArgument(null, $definition);
}
}
return $argumentSet;
}
/**
* Make a map with the name as keys.
*
* @param iterable<\GraphQL\Language\AST\InputValueDefinitionNode> $argumentDefinitions
* @return array<string, \GraphQL\Language\AST\InputValueDefinitionNode>
*/
protected function makeDefinitionMap($argumentDefinitions): array
{
$argumentDefinitionMap = [];
foreach ($argumentDefinitions as $definition) {
$argumentDefinitionMap[$definition->name->value] = $definition;
}
return $argumentDefinitionMap;
}
/**
* Wrap a single client-given argument with type information.
*
* @param mixed $value The client given value.
* @return \Nuwave\Lighthouse\Execution\Arguments\Argument
*/
protected function wrapInArgument($value, InputValueDefinitionNode $definition): Argument
{
$type = $this->argumentTypeNodeConverter->convert($definition->type);
$argument = new Argument();
$argument->directives = $this->directiveLocator->associated($definition);
$argument->type = $type;
$argument->value = $this->wrapWithType($value, $type);
return $argument;
}
/**
* Wrap a client-given value with information from a type.
*
* @param mixed|array<mixed> $valueOrValues
* @param \Nuwave\Lighthouse\Execution\Arguments\ListType|\Nuwave\Lighthouse\Execution\Arguments\NamedType $type
* @return array|mixed|\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet
*/
protected function wrapWithType($valueOrValues, $type)
{
// No need to recurse down further if the value is null
if ($valueOrValues === null) {
return null;
}
// We have to do this conversion as we are resolving a client query
// because the incoming arguments put a bound on recursion depth
if ($type instanceof ListType) {
$typeInList = $type->type;
$values = [];
foreach ($valueOrValues as $singleValue) {
$values [] = $this->wrapWithType($singleValue, $typeInList);
}
return $values;
}
return $this->wrapWithNamedType($valueOrValues, $type);
}
/**
* Wrap a client-given value with information from a named type.
*
* @param mixed $value The client given value.
* @param \Nuwave\Lighthouse\Execution\Arguments\NamedType $namedType
* @return \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|mixed
*/
protected function wrapWithNamedType($value, NamedType $namedType)
{
// This might be null if the type is
// - created outside of the schema string
// - one of the built in types
$typeDef = $this->documentAST->types[$namedType->name] ?? null;
// We recurse down only if the type is an Input
if ($typeDef instanceof InputObjectTypeDefinitionNode) {
return $this->wrapArgs($typeDef, $value);
}
// Otherwise, we just return the value as is and are done with that subtree
return $value;
}
}
@@ -0,0 +1,36 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Nuwave\Lighthouse\Schema\AST\TypeNodeConverter;
class ArgumentTypeNodeConverter extends TypeNodeConverter
{
/**
* @param \Nuwave\Lighthouse\Execution\Arguments\ListType|\Nuwave\Lighthouse\Execution\Arguments\NamedType $type
* @return \Nuwave\Lighthouse\Execution\Arguments\ListType|\Nuwave\Lighthouse\Execution\Arguments\NamedType
*/
protected function nonNull($type): object
{
$type->nonNull = true;
return $type;
}
/**
* @param \Nuwave\Lighthouse\Execution\Arguments\NamedType $type
* @return \Nuwave\Lighthouse\Execution\Arguments\ListType
*/
protected function listOf($type): object
{
return new ListType($type);
}
/**
* @return \Nuwave\Lighthouse\Execution\Arguments\NamedType
*/
protected function namedType(string $nodeName): NamedType
{
return new NamedType($nodeName);
}
}
@@ -0,0 +1,28 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
class ListType
{
/**
* The type contained within the list.
*
* @var \Nuwave\Lighthouse\Execution\Arguments\NamedType|\Nuwave\Lighthouse\Execution\Arguments\ListType
*/
public $type;
/**
* Is the list itself defined to be non-nullable?
*
* @var bool
*/
public $nonNull = false;
/**
* @param \Nuwave\Lighthouse\Execution\Arguments\NamedType|\Nuwave\Lighthouse\Execution\Arguments\ListType $type
*/
public function __construct($type)
{
$this->type = $type;
}
}
@@ -0,0 +1,25 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
class NamedType
{
/**
* The name of the type as defined in the schema.
*
* @var string
*/
public $name;
/**
* Is this type defined to be non-nullable?
*
* @var bool
*/
public $nonNull = false;
public function __construct(string $name)
{
$this->name = $name;
}
}
@@ -0,0 +1,88 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class NestedBelongsTo implements ArgResolver
{
/**
* @var \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
protected $relation;
public function __construct(BelongsTo $relation)
{
$this->relation = $relation;
}
/**
* @param \Illuminate\Database\Eloquent\Model $parent
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($parent, $args): void
{
if ($args->has('create')) {
$saveModel = new ResolveNested(new SaveModel($this->relation));
$related = $saveModel(
// @phpstan-ignore-next-line Unrecognized mixin
$this->relation->make(),
$args->arguments['create']->value
);
$this->relation->associate($related);
}
if ($args->has('connect')) {
$this->relation->associate($args->arguments['connect']->value);
}
if ($args->has('update')) {
$updateModel = new ResolveNested(new UpdateModel(new SaveModel($this->relation)));
$related = $updateModel(
// @phpstan-ignore-next-line Unrecognized mixin
$this->relation->make(),
$args->arguments['update']->value
);
$this->relation->associate($related);
}
if ($args->has('upsert')) {
$upsertModel = new ResolveNested(new UpsertModel(new SaveModel($this->relation)));
$related = $upsertModel(
// @phpstan-ignore-next-line Unrecognized mixin
$this->relation->make(),
$args->arguments['upsert']->value
);
$this->relation->associate($related);
}
self::disconnectOrDelete($this->relation, $args);
}
public static function disconnectOrDelete(BelongsTo $relation, ArgumentSet $args): void
{
// We proceed with disconnecting/deleting only if the given $values is truthy.
// There is no other information to be passed when issuing those operations,
// but GraphQL forces us to pass some value. It would be unintuitive for
// the end user if the given value had no effect on the execution.
if (
$args->has('disconnect')
&& $args->arguments['disconnect']->value
) {
$relation->dissociate();
}
if (
$args->has('delete')
&& $args->arguments['delete']->value
) {
$relation->dissociate();
// @phpstan-ignore-next-line Unrecognized mixin
$relation->delete();
}
}
}
@@ -0,0 +1,99 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Illuminate\Support\Arr;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class NestedManyToMany implements ArgResolver
{
/**
* @var string
*/
protected $relationName;
public function __construct(string $relationName)
{
$this->relationName = $relationName;
}
/**
* @param \Illuminate\Database\Eloquent\Model $parent
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($parent, $args): void
{
/** @var \Illuminate\Database\Eloquent\Relations\BelongsToMany|\Illuminate\Database\Eloquent\Relations\MorphToMany $relation */
$relation = $parent->{$this->relationName}();
if ($args->has('sync')) {
$relation->sync(
$this->generateRelationArray($args->arguments['sync'])
);
}
if ($args->has('syncWithoutDetaching')) {
$relation->syncWithoutDetaching(
$this->generateRelationArray($args->arguments['syncWithoutDetaching'])
);
}
NestedOneToMany::createUpdateUpsert($args, $relation);
if ($args->has('delete')) {
$ids = $args->arguments['delete']->toPlain();
$relation->detach($ids);
$relation->getRelated()::destroy($ids);
}
if ($args->has('connect')) {
$relation->attach(
$this->generateRelationArray($args->arguments['connect'])
);
}
if ($args->has('disconnect')) {
$relation->detach(
$args->arguments['disconnect']->toPlain()
);
}
}
/**
* Generate an array for passing into sync, syncWithoutDetaching or connect method.
*
* Those functions natively have the capability of passing additional
* data to store in the pivot table. That array expects passing the id's
* as keys, so we transform the passed arguments to match that.
*
* @param \Nuwave\Lighthouse\Execution\Arguments\Argument $args
* @return array<mixed>
*/
protected function generateRelationArray(Argument $args): array
{
$values = $args->toPlain();
if (empty($values)) {
return [];
}
// Since GraphQL inputs are monomorphic, we can just look at the first
// given value and can deduce the value of all given args.
$exemplaryValue = $values[0];
// We assume that the values contain pivot information
if (is_array($exemplaryValue)) {
$relationArray = [];
foreach ($values as $value) {
$id = Arr::pull($value, 'id');
$relationArray[$id] = $value;
}
return $relationArray;
}
// The default case is simply a flat array of IDs which we don't have to transform
return $values;
}
}
@@ -0,0 +1,44 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class NestedMorphTo implements ArgResolver
{
/**
* @var \Illuminate\Database\Eloquent\Relations\MorphTo
*/
protected $relation;
public function __construct(MorphTo $relation)
{
$this->relation = $relation;
}
/**
* @param \Illuminate\Database\Eloquent\Model $parent
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($parent, $args): void
{
// TODO implement create and update once we figure out how to do polymorphic input types https://github.com/nuwave/lighthouse/issues/900
if ($args->has('connect')) {
$connectArgs = $args->arguments['connect']->value;
$morphToModel = $this->relation->createModelByType(
(string) $connectArgs->arguments['type']->value
);
$morphToModel->setAttribute(
$morphToModel->getKeyName(),
$connectArgs->arguments['id']->value
);
$this->relation->associate($morphToModel);
}
NestedBelongsTo::disconnectOrDelete($this->relation, $args);
}
}
@@ -0,0 +1,119 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Closure;
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
use Illuminate\Database\Eloquent\Relations\Relation;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class NestedOneToMany implements ArgResolver
{
/**
* @var string
*/
protected $relationName;
public function __construct(string $relationName)
{
$this->relationName = $relationName;
}
/**
* @param \Illuminate\Database\Eloquent\Model $parent
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($parent, $args): void
{
/** @var \Illuminate\Database\Eloquent\Relations\HasMany|\Illuminate\Database\Eloquent\Relations\MorphMany $relation */
$relation = $parent->{$this->relationName}();
static::createUpdateUpsert($args, $relation);
static::connectDisconnect($args, $relation);
if ($args->has('delete')) {
$relation->getRelated()::destroy(
$args->arguments['delete']->toPlain()
);
}
}
/**
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public static function createUpdateUpsert(ArgumentSet $args, Relation $relation): void
{
if ($args->has('create')) {
$saveModel = new ResolveNested(new SaveModel($relation));
foreach ($args->arguments['create']->value as $childArgs) {
// @phpstan-ignore-next-line Relation&Builder mixin not recognized
$saveModel($relation->make(), $childArgs);
}
}
if ($args->has('update')) {
$updateModel = new ResolveNested(new UpdateModel(new SaveModel($relation)));
foreach ($args->arguments['update']->value as $childArgs) {
// @phpstan-ignore-next-line Relation&Builder mixin not recognized
$updateModel($relation->make(), $childArgs);
}
}
if ($args->has('upsert')) {
$upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation)));
foreach ($args->arguments['upsert']->value as $childArgs) {
// @phpstan-ignore-next-line Relation&Builder mixin not recognized
$upsertModel($relation->make(), $childArgs);
}
}
}
public static function connectDisconnect(ArgumentSet $args, HasOneOrMany $relation): void
{
if ($args->has('connect')) {
// @phpstan-ignore-next-line Relation&Builder mixin not recognized
$children = $relation
->make()
->whereIn(
self::getLocalKeyName($relation),
$args->arguments['connect']->value
)
->get();
// @phpstan-ignore-next-line Relation&Builder mixin not recognized
$relation->saveMany($children);
}
if ($args->has('disconnect')) {
// @phpstan-ignore-next-line Relation&Builder mixin not recognized
$relation
->make()
->whereIn(
self::getLocalKeyName($relation),
$args->arguments['disconnect']->value
)
->update([$relation->getForeignKeyName() => null]);
}
}
/**
* TODO remove this horrible hack when we no longer support Laravel 5.6.
*/
protected static function getLocalKeyName(HasOneOrMany $relation): string
{
$getLocalKeyName = Closure::bind(
function () {
/** @psalm-suppress InvalidScope */
// @phpstan-ignore-next-line This is a dirty hack
return $this->localKey;
},
$relation,
get_class($relation)
);
return $getLocalKeyName();
}
}
@@ -0,0 +1,52 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class NestedOneToOne implements ArgResolver
{
/**
* @var string
*/
protected $relationName;
public function __construct(string $relationName)
{
$this->relationName = $relationName;
}
/**
* @param \Illuminate\Database\Eloquent\Model $parent
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($parent, $args): void
{
/** @var \Illuminate\Database\Eloquent\Relations\HasOne|\Illuminate\Database\Eloquent\Relations\MorphOne $relation */
$relation = $parent->{$this->relationName}();
if ($args->has('create')) {
$saveModel = new ResolveNested(new SaveModel($relation));
$saveModel($relation->make(), $args->arguments['create']->value);
}
if ($args->has('update')) {
$updateModel = new ResolveNested(new UpdateModel(new SaveModel($relation)));
$updateModel($relation->make(), $args->arguments['update']->value);
}
if ($args->has('upsert')) {
$upsertModel = new ResolveNested(new UpsertModel(new SaveModel($relation)));
$upsertModel($relation->make(), $args->arguments['upsert']->value);
}
if ($args->has('delete')) {
$relation->getRelated()::destroy(
$args->arguments['delete']->toPlain()
);
}
}
}
@@ -0,0 +1,47 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class ResolveNested implements ArgResolver
{
/**
* @var callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver|null
*/
protected $previous;
/**
* @var callable
*/
protected $argPartitioner;
/**
* @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver|null $previous
*/
public function __construct(callable $previous = null, callable $argPartitioner = null)
{
$this->previous = $previous;
$this->argPartitioner = $argPartitioner ?? [ArgPartitioner::class, 'nestedArgResolvers'];
}
/**
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($root, $args)
{
[$nestedArgs, $regularArgs] = ($this->argPartitioner)($args, $root);
if ($this->previous) {
$root = ($this->previous)($root, $regularArgs);
}
/** @var \Nuwave\Lighthouse\Execution\Arguments\Argument $nested */
foreach ($nestedArgs->arguments as $nested) {
// @phpstan-ignore-next-line we know the resolver is there because we partitioned for it
($nested->resolver)($root, $nested->value);
}
return $root;
}
}
@@ -0,0 +1,95 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class SaveModel implements ArgResolver
{
/**
* @var \Illuminate\Database\Eloquent\Relations\Relation|null
*/
protected $parentRelation;
public function __construct(?Relation $parentRelation = null)
{
$this->parentRelation = $parentRelation;
}
/**
* @param \Illuminate\Database\Eloquent\Model $model
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($model, $args): Model
{
// Extract $morphTo first, as MorphTo extends BelongsTo
[$morphTo, $remaining] = ArgPartitioner::relationMethods(
$args,
$model,
MorphTo::class
);
[$belongsTo, $remaining] = ArgPartitioner::relationMethods(
$remaining,
$model,
BelongsTo::class
);
$argsToFill = $remaining->toArray();
// Use all the remaining attributes and fill the model
if (config('lighthouse.force_fill')) {
$model->forceFill($argsToFill);
} else {
$model->fill($argsToFill);
}
foreach ($belongsTo->arguments as $relationName => $nestedOperations) {
/** @var \Illuminate\Database\Eloquent\Relations\BelongsTo $belongsTo */
$belongsTo = $model->{$relationName}();
$belongsToResolver = new ResolveNested(new NestedBelongsTo($belongsTo));
$belongsToResolver($model, $nestedOperations->value);
}
foreach ($morphTo->arguments as $relationName => $nestedOperations) {
/** @var \Illuminate\Database\Eloquent\Relations\MorphTo $morphTo */
$morphTo = $model->{$relationName}();
$morphToResolver = new ResolveNested(new NestedMorphTo($morphTo));
$morphToResolver($model, $nestedOperations->value);
}
if ($this->parentRelation instanceof HasOneOrMany) {
// If we are already resolving a nested create, we might
// already have an instance of the parent relation available.
// In that case, use it to set the current model as a child.
$this->parentRelation->save($model);
return $model;
}
$model->save();
if ($this->parentRelation instanceof BelongsTo) {
$parentModel = $this->parentRelation->associate($model);
// If the parent Model does not exist (still to be saved),
// a save could break any pending belongsTo relations that still
// needs to be created and associated with the parent model
if ($parentModel->exists) {
$parentModel->save();
}
}
if ($this->parentRelation instanceof BelongsToMany) {
$this->parentRelation->syncWithoutDetaching($model);
}
return $model;
}
}
@@ -0,0 +1,45 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use GraphQL\Error\Error;
use Illuminate\Support\Arr;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class UpdateModel implements ArgResolver
{
const MISSING_PRIMARY_KEY_FOR_UPDATE = 'Missing primary key for update.';
/**
* @var callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver
*/
protected $previous;
/**
* @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous
*/
public function __construct(callable $previous)
{
$this->previous = $previous;
}
/**
* @param \Illuminate\Database\Eloquent\Model $model
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($model, $args)
{
/** @var \Nuwave\Lighthouse\Execution\Arguments\Argument|null $id */
$id = Arr::pull($args->arguments, 'id')
?? Arr::pull($args->arguments, $model->getKeyName())
?? null;
if ($id === null) {
throw new Error(self::MISSING_PRIMARY_KEY_FOR_UPDATE);
}
$model = $model->newQuery()->findOrFail($id->value);
return ($this->previous)($model, $args);
}
}
@@ -0,0 +1,45 @@
<?php
namespace Nuwave\Lighthouse\Execution\Arguments;
use Nuwave\Lighthouse\Support\Contracts\ArgResolver;
class UpsertModel implements ArgResolver
{
/**
* @var callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver
*/
protected $previous;
/**
* @param callable|\Nuwave\Lighthouse\Support\Contracts\ArgResolver $previous
*/
public function __construct(callable $previous)
{
$this->previous = $previous;
}
/**
* @param \Illuminate\Database\Eloquent\Model $model
* @param \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet $args
*/
public function __invoke($model, $args)
{
// TODO consider Laravel native ->upsert(), available from 8.10
$id = $args->arguments['id']
?? $args->arguments[$model->getKeyName()]
?? null;
if ($id !== null) {
$existingModel = $model
->newQuery()
->find($id->value);
if ($existingModel !== null) {
$model = $existingModel;
}
}
return ($this->previous)($model, $args);
}
}