Files
IPB/system/3rd_party/graphql-php/Validator/Rules/OverlappingFieldsCanBeMerged.php
2025-12-19 16:05:19 -08:00

898 lines
31 KiB
PHP

<?php
declare(strict_types=1);
namespace GraphQL\Validator\Rules;
use GraphQL\Error\Error;
use GraphQL\Language\AST\ArgumentNode;
use GraphQL\Language\AST\FieldNode;
use GraphQL\Language\AST\FragmentDefinitionNode;
use GraphQL\Language\AST\FragmentSpreadNode;
use GraphQL\Language\AST\InlineFragmentNode;
use GraphQL\Language\AST\Node;
use GraphQL\Language\AST\NodeKind;
use GraphQL\Language\AST\SelectionSetNode;
use GraphQL\Language\Printer;
use GraphQL\Type\Definition\CompositeType;
use GraphQL\Type\Definition\InterfaceType;
use GraphQL\Type\Definition\ListOfType;
use GraphQL\Type\Definition\NonNull;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Utils\PairSet;
use GraphQL\Utils\TypeInfo;
use GraphQL\Validator\ValidationContext;
use SplObjectStorage;
use function array_keys;
use function array_map;
use function array_merge;
use function array_reduce;
use function count;
use function implode;
use function is_array;
use function sprintf;
class OverlappingFieldsCanBeMerged extends ValidationRule
{
/**
* A memoization for when two fragments are compared "between" each other for
* conflicts. Two fragments may be compared many times, so memoizing this can
* dramatically improve the performance of this validator.
*
* @var PairSet
*/
private $comparedFragmentPairs;
/**
* A cache for the "field map" and list of fragment names found in any given
* selection set. Selection sets may be asked for this information multiple
* times, so this improves the performance of this validator.
*
* @var SplObjectStorage
*/
private $cachedFieldsAndFragmentNames;
public function getVisitor(ValidationContext $context)
{
$this->comparedFragmentPairs = new PairSet();
$this->cachedFieldsAndFragmentNames = new SplObjectStorage();
return [
NodeKind::SELECTION_SET => function (SelectionSetNode $selectionSet) use ($context) : void {
$conflicts = $this->findConflictsWithinSelectionSet(
$context,
$context->getParentType(),
$selectionSet
);
foreach ($conflicts as $conflict) {
[[$responseName, $reason], $fields1, $fields2] = $conflict;
$context->reportError(new Error(
self::fieldsConflictMessage($responseName, $reason),
array_merge($fields1, $fields2)
));
}
},
];
}
/**
* Find all conflicts found "within" a selection set, including those found
* via spreading in fragments. Called when visiting each SelectionSet in the
* GraphQL Document.
*
* @param CompositeType $parentType
*
* @return mixed[]
*/
private function findConflictsWithinSelectionSet(
ValidationContext $context,
$parentType,
SelectionSetNode $selectionSet
) {
[$fieldMap, $fragmentNames] = $this->getFieldsAndFragmentNames(
$context,
$parentType,
$selectionSet
);
$conflicts = [];
// (A) Find find all conflicts "within" the fields of this selection set.
// Note: this is the *only place* `collectConflictsWithin` is called.
$this->collectConflictsWithin(
$context,
$conflicts,
$fieldMap
);
$fragmentNamesLength = count($fragmentNames);
if ($fragmentNamesLength !== 0) {
// (B) Then collect conflicts between these fields and those represented by
// each spread fragment name found.
$comparedFragments = [];
for ($i = 0; $i < $fragmentNamesLength; $i++) {
$this->collectConflictsBetweenFieldsAndFragment(
$context,
$conflicts,
$comparedFragments,
false,
$fieldMap,
$fragmentNames[$i]
);
// (C) Then compare this fragment with all other fragments found in this
// selection set to collect conflicts between fragments spread together.
// This compares each item in the list of fragment names to every other item
// in that same list (except for itself).
for ($j = $i + 1; $j < $fragmentNamesLength; $j++) {
$this->collectConflictsBetweenFragments(
$context,
$conflicts,
false,
$fragmentNames[$i],
$fragmentNames[$j]
);
}
}
}
return $conflicts;
}
/**
* Given a selection set, return the collection of fields (a mapping of response
* name to field ASTs and definitions) as well as a list of fragment names
* referenced via fragment spreads.
*
* @param CompositeType $parentType
*
* @return mixed[]|SplObjectStorage
*/
private function getFieldsAndFragmentNames(
ValidationContext $context,
$parentType,
SelectionSetNode $selectionSet
) {
if (isset($this->cachedFieldsAndFragmentNames[$selectionSet])) {
$cached = $this->cachedFieldsAndFragmentNames[$selectionSet];
} else {
$astAndDefs = [];
$fragmentNames = [];
$this->internalCollectFieldsAndFragmentNames(
$context,
$parentType,
$selectionSet,
$astAndDefs,
$fragmentNames
);
$cached = [$astAndDefs, array_keys($fragmentNames)];
$this->cachedFieldsAndFragmentNames[$selectionSet] = $cached;
}
return $cached;
}
/**
* Algorithm:
*
* Conflicts occur when two fields exist in a query which will produce the same
* response name, but represent differing values, thus creating a conflict.
* The algorithm below finds all conflicts via making a series of comparisons
* between fields. In order to compare as few fields as possible, this makes
* a series of comparisons "within" sets of fields and "between" sets of fields.
*
* Given any selection set, a collection produces both a set of fields by
* also including all inline fragments, as well as a list of fragments
* referenced by fragment spreads.
*
* A) Each selection set represented in the document first compares "within" its
* collected set of fields, finding any conflicts between every pair of
* overlapping fields.
* Note: This is the *only time* that a the fields "within" a set are compared
* to each other. After this only fields "between" sets are compared.
*
* B) Also, if any fragment is referenced in a selection set, then a
* comparison is made "between" the original set of fields and the
* referenced fragment.
*
* C) Also, if multiple fragments are referenced, then comparisons
* are made "between" each referenced fragment.
*
* D) When comparing "between" a set of fields and a referenced fragment, first
* a comparison is made between each field in the original set of fields and
* each field in the the referenced set of fields.
*
* E) Also, if any fragment is referenced in the referenced selection set,
* then a comparison is made "between" the original set of fields and the
* referenced fragment (recursively referring to step D).
*
* F) When comparing "between" two fragments, first a comparison is made between
* each field in the first referenced set of fields and each field in the the
* second referenced set of fields.
*
* G) Also, any fragments referenced by the first must be compared to the
* second, and any fragments referenced by the second must be compared to the
* first (recursively referring to step F).
*
* H) When comparing two fields, if both have selection sets, then a comparison
* is made "between" both selection sets, first comparing the set of fields in
* the first selection set with the set of fields in the second.
*
* I) Also, if any fragment is referenced in either selection set, then a
* comparison is made "between" the other set of fields and the
* referenced fragment.
*
* J) Also, if two fragments are referenced in both selection sets, then a
* comparison is made "between" the two fragments.
*/
/**
* Given a reference to a fragment, return the represented collection of fields
* as well as a list of nested fragment names referenced via fragment spreads.
*
* @param CompositeType $parentType
* @param mixed[][][] $astAndDefs
* @param bool[] $fragmentNames
*/
private function internalCollectFieldsAndFragmentNames(
ValidationContext $context,
$parentType,
SelectionSetNode $selectionSet,
array &$astAndDefs,
array &$fragmentNames
) {
foreach ($selectionSet->selections as $selection) {
switch (true) {
case $selection instanceof FieldNode:
$fieldName = $selection->name->value;
$fieldDef = null;
if ($parentType instanceof ObjectType ||
$parentType instanceof InterfaceType
) {
if ($parentType->hasField($fieldName)) {
$fieldDef = $parentType->getField($fieldName);
}
}
$responseName = $selection->alias ? $selection->alias->value : $fieldName;
if (! isset($astAndDefs[$responseName])) {
$astAndDefs[$responseName] = [];
}
$astAndDefs[$responseName][] = [$parentType, $selection, $fieldDef];
break;
case $selection instanceof FragmentSpreadNode:
$fragmentNames[$selection->name->value] = true;
break;
case $selection instanceof InlineFragmentNode:
$typeCondition = $selection->typeCondition;
$inlineFragmentType = $typeCondition
? TypeInfo::typeFromAST($context->getSchema(), $typeCondition)
: $parentType;
$this->internalCollectFieldsAndFragmentNames(
$context,
$inlineFragmentType,
$selection->selectionSet,
$astAndDefs,
$fragmentNames
);
break;
}
}
}
/**
* Collect all Conflicts "within" one collection of fields.
*
* @param mixed[][] $conflicts
* @param mixed[][] $fieldMap
*/
private function collectConflictsWithin(
ValidationContext $context,
array &$conflicts,
array $fieldMap
) {
// A field map is a keyed collection, where each key represents a response
// name and the value at that key is a list of all fields which provide that
// response name. For every response name, if there are multiple fields, they
// must be compared to find a potential conflict.
foreach ($fieldMap as $responseName => $fields) {
// This compares every field in the list to every other field in this list
// (except to itself). If the list only has one item, nothing needs to
// be compared.
$fieldsLength = count($fields);
if ($fieldsLength <= 1) {
continue;
}
for ($i = 0; $i < $fieldsLength; $i++) {
for ($j = $i + 1; $j < $fieldsLength; $j++) {
$conflict = $this->findConflict(
$context,
false, // within one collection is never mutually exclusive
$responseName,
$fields[$i],
$fields[$j]
);
if (! $conflict) {
continue;
}
$conflicts[] = $conflict;
}
}
}
}
/**
* Determines if there is a conflict between two particular fields, including
* comparing their sub-fields.
*
* @param bool $parentFieldsAreMutuallyExclusive
* @param string $responseName
* @param mixed[] $field1
* @param mixed[] $field2
*
* @return mixed[]|null
*/
private function findConflict(
ValidationContext $context,
$parentFieldsAreMutuallyExclusive,
$responseName,
array $field1,
array $field2
) {
[$parentType1, $ast1, $def1] = $field1;
[$parentType2, $ast2, $def2] = $field2;
// If it is known that two fields could not possibly apply at the same
// time, due to the parent types, then it is safe to permit them to diverge
// in aliased field or arguments used as they will not present any ambiguity
// by differing.
// It is known that two parent types could never overlap if they are
// different Object types. Interface or Union types might overlap - if not
// in the current state of the schema, then perhaps in some future version,
// thus may not safely diverge.
$areMutuallyExclusive =
$parentFieldsAreMutuallyExclusive ||
(
$parentType1 !== $parentType2 &&
$parentType1 instanceof ObjectType &&
$parentType2 instanceof ObjectType
);
// The return type for each field.
$type1 = $def1 === null ? null : $def1->getType();
$type2 = $def2 === null ? null : $def2->getType();
if (! $areMutuallyExclusive) {
// Two aliases must refer to the same field.
$name1 = $ast1->name->value;
$name2 = $ast2->name->value;
if ($name1 !== $name2) {
return [
[$responseName, sprintf('%s and %s are different fields', $name1, $name2)],
[$ast1],
[$ast2],
];
}
if (! $this->sameArguments($ast1->arguments ?? [], $ast2->arguments ?? [])) {
return [
[$responseName, 'they have differing arguments'],
[$ast1],
[$ast2],
];
}
}
if ($type1 && $type2 && $this->doTypesConflict($type1, $type2)) {
return [
[$responseName, sprintf('they return conflicting types %s and %s', $type1, $type2)],
[$ast1],
[$ast2],
];
}
// Collect and compare sub-fields. Use the same "visited fragment names" list
// for both collections so fields in a fragment reference are never
// compared to themselves.
$selectionSet1 = $ast1->selectionSet;
$selectionSet2 = $ast2->selectionSet;
if ($selectionSet1 && $selectionSet2) {
$conflicts = $this->findConflictsBetweenSubSelectionSets(
$context,
$areMutuallyExclusive,
Type::getNamedType($type1),
$selectionSet1,
Type::getNamedType($type2),
$selectionSet2
);
return $this->subfieldConflicts(
$conflicts,
$responseName,
$ast1,
$ast2
);
}
return null;
}
/**
* @param ArgumentNode[] $arguments1
* @param ArgumentNode[] $arguments2
*
* @return bool
*/
private function sameArguments($arguments1, $arguments2)
{
if (count($arguments1) !== count($arguments2)) {
return false;
}
foreach ($arguments1 as $argument1) {
$argument2 = null;
foreach ($arguments2 as $argument) {
if ($argument->name->value === $argument1->name->value) {
$argument2 = $argument;
break;
}
}
if (! $argument2) {
return false;
}
if (! $this->sameValue($argument1->value, $argument2->value)) {
return false;
}
}
return true;
}
/**
* @return bool
*/
private function sameValue(Node $value1, Node $value2)
{
return (! $value1 && ! $value2) || (Printer::doPrint($value1) === Printer::doPrint($value2));
}
/**
* Two types conflict if both types could not apply to a value simultaneously.
* Composite types are ignored as their individual field types will be compared
* later recursively. However List and Non-Null types must match.
*/
private function doTypesConflict(Type $type1, Type $type2) : bool
{
if ($type1 instanceof ListOfType) {
return $type2 instanceof ListOfType
? $this->doTypesConflict($type1->getWrappedType(), $type2->getWrappedType())
: true;
}
if ($type2 instanceof ListOfType) {
return $type1 instanceof ListOfType
? $this->doTypesConflict($type1->getWrappedType(), $type2->getWrappedType())
: true;
}
if ($type1 instanceof NonNull) {
return $type2 instanceof NonNull
? $this->doTypesConflict($type1->getWrappedType(), $type2->getWrappedType())
: true;
}
if ($type2 instanceof NonNull) {
return $type1 instanceof NonNull
? $this->doTypesConflict($type1->getWrappedType(), $type2->getWrappedType())
: true;
}
if (Type::isLeafType($type1) || Type::isLeafType($type2)) {
return $type1 !== $type2;
}
return false;
}
/**
* Find all conflicts found between two selection sets, including those found
* via spreading in fragments. Called when determining if conflicts exist
* between the sub-fields of two overlapping fields.
*
* @param bool $areMutuallyExclusive
* @param CompositeType $parentType1
* @param CompositeType $parentType2
*
* @return mixed[][]
*/
private function findConflictsBetweenSubSelectionSets(
ValidationContext $context,
$areMutuallyExclusive,
$parentType1,
SelectionSetNode $selectionSet1,
$parentType2,
SelectionSetNode $selectionSet2
) {
$conflicts = [];
[$fieldMap1, $fragmentNames1] = $this->getFieldsAndFragmentNames(
$context,
$parentType1,
$selectionSet1
);
[$fieldMap2, $fragmentNames2] = $this->getFieldsAndFragmentNames(
$context,
$parentType2,
$selectionSet2
);
// (H) First, collect all conflicts between these two collections of field.
$this->collectConflictsBetween(
$context,
$conflicts,
$areMutuallyExclusive,
$fieldMap1,
$fieldMap2
);
// (I) Then collect conflicts between the first collection of fields and
// those referenced by each fragment name associated with the second.
$fragmentNames2Length = count($fragmentNames2);
if ($fragmentNames2Length !== 0) {
$comparedFragments = [];
for ($j = 0; $j < $fragmentNames2Length; $j++) {
$this->collectConflictsBetweenFieldsAndFragment(
$context,
$conflicts,
$comparedFragments,
$areMutuallyExclusive,
$fieldMap1,
$fragmentNames2[$j]
);
}
}
// (I) Then collect conflicts between the second collection of fields and
// those referenced by each fragment name associated with the first.
$fragmentNames1Length = count($fragmentNames1);
if ($fragmentNames1Length !== 0) {
$comparedFragments = [];
for ($i = 0; $i < $fragmentNames1Length; $i++) {
$this->collectConflictsBetweenFieldsAndFragment(
$context,
$conflicts,
$comparedFragments,
$areMutuallyExclusive,
$fieldMap2,
$fragmentNames1[$i]
);
}
}
// (J) Also collect conflicts between any fragment names by the first and
// fragment names by the second. This compares each item in the first set of
// names to each item in the second set of names.
for ($i = 0; $i < $fragmentNames1Length; $i++) {
for ($j = 0; $j < $fragmentNames2Length; $j++) {
$this->collectConflictsBetweenFragments(
$context,
$conflicts,
$areMutuallyExclusive,
$fragmentNames1[$i],
$fragmentNames2[$j]
);
}
}
return $conflicts;
}
/**
* Collect all Conflicts between two collections of fields. This is similar to,
* but different from the `collectConflictsWithin` function above. This check
* assumes that `collectConflictsWithin` has already been called on each
* provided collection of fields. This is true because this validator traverses
* each individual selection set.
*
* @param mixed[][] $conflicts
* @param bool $parentFieldsAreMutuallyExclusive
* @param mixed[] $fieldMap1
* @param mixed[] $fieldMap2
*/
private function collectConflictsBetween(
ValidationContext $context,
array &$conflicts,
$parentFieldsAreMutuallyExclusive,
array $fieldMap1,
array $fieldMap2
) {
// A field map is a keyed collection, where each key represents a response
// name and the value at that key is a list of all fields which provide that
// response name. For any response name which appears in both provided field
// maps, each field from the first field map must be compared to every field
// in the second field map to find potential conflicts.
foreach ($fieldMap1 as $responseName => $fields1) {
if (! isset($fieldMap2[$responseName])) {
continue;
}
$fields2 = $fieldMap2[$responseName];
$fields1Length = count($fields1);
$fields2Length = count($fields2);
for ($i = 0; $i < $fields1Length; $i++) {
for ($j = 0; $j < $fields2Length; $j++) {
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields1[$i],
$fields2[$j]
);
if (! $conflict) {
continue;
}
$conflicts[] = $conflict;
}
}
}
}
/**
* Collect all conflicts found between a set of fields and a fragment reference
* including via spreading in any nested fragments.
*
* @param mixed[][] $conflicts
* @param bool[] $comparedFragments
* @param bool $areMutuallyExclusive
* @param mixed[][] $fieldMap
* @param string $fragmentName
*/
private function collectConflictsBetweenFieldsAndFragment(
ValidationContext $context,
array &$conflicts,
array &$comparedFragments,
$areMutuallyExclusive,
array $fieldMap,
$fragmentName
) {
if (isset($comparedFragments[$fragmentName])) {
return;
}
$comparedFragments[$fragmentName] = true;
$fragment = $context->getFragment($fragmentName);
if (! $fragment) {
return;
}
[$fieldMap2, $fragmentNames2] = $this->getReferencedFieldsAndFragmentNames(
$context,
$fragment
);
if ($fieldMap === $fieldMap2) {
return;
}
// (D) First collect any conflicts between the provided collection of fields
// and the collection of fields represented by the given fragment.
$this->collectConflictsBetween(
$context,
$conflicts,
$areMutuallyExclusive,
$fieldMap,
$fieldMap2
);
// (E) Then collect any conflicts between the provided collection of fields
// and any fragment names found in the given fragment.
$fragmentNames2Length = count($fragmentNames2);
for ($i = 0; $i < $fragmentNames2Length; $i++) {
$this->collectConflictsBetweenFieldsAndFragment(
$context,
$conflicts,
$comparedFragments,
$areMutuallyExclusive,
$fieldMap,
$fragmentNames2[$i]
);
}
}
/**
* Given a reference to a fragment, return the represented collection of fields
* as well as a list of nested fragment names referenced via fragment spreads.
*
* @return mixed[]|SplObjectStorage
*/
private function getReferencedFieldsAndFragmentNames(
ValidationContext $context,
FragmentDefinitionNode $fragment
) {
// Short-circuit building a type from the AST if possible.
if (isset($this->cachedFieldsAndFragmentNames[$fragment->selectionSet])) {
return $this->cachedFieldsAndFragmentNames[$fragment->selectionSet];
}
$fragmentType = TypeInfo::typeFromAST($context->getSchema(), $fragment->typeCondition);
return $this->getFieldsAndFragmentNames(
$context,
$fragmentType,
$fragment->selectionSet
);
}
/**
* Collect all conflicts found between two fragments, including via spreading in
* any nested fragments.
*
* @param mixed[][] $conflicts
* @param bool $areMutuallyExclusive
* @param string $fragmentName1
* @param string $fragmentName2
*/
private function collectConflictsBetweenFragments(
ValidationContext $context,
array &$conflicts,
$areMutuallyExclusive,
$fragmentName1,
$fragmentName2
) {
// No need to compare a fragment to itself.
if ($fragmentName1 === $fragmentName2) {
return;
}
// Memoize so two fragments are not compared for conflicts more than once.
if ($this->comparedFragmentPairs->has(
$fragmentName1,
$fragmentName2,
$areMutuallyExclusive
)
) {
return;
}
$this->comparedFragmentPairs->add(
$fragmentName1,
$fragmentName2,
$areMutuallyExclusive
);
$fragment1 = $context->getFragment($fragmentName1);
$fragment2 = $context->getFragment($fragmentName2);
if (! $fragment1 || ! $fragment2) {
return;
}
[$fieldMap1, $fragmentNames1] = $this->getReferencedFieldsAndFragmentNames(
$context,
$fragment1
);
[$fieldMap2, $fragmentNames2] = $this->getReferencedFieldsAndFragmentNames(
$context,
$fragment2
);
// (F) First, collect all conflicts between these two collections of fields
// (not including any nested fragments).
$this->collectConflictsBetween(
$context,
$conflicts,
$areMutuallyExclusive,
$fieldMap1,
$fieldMap2
);
// (G) Then collect conflicts between the first fragment and any nested
// fragments spread in the second fragment.
$fragmentNames2Length = count($fragmentNames2);
for ($j = 0; $j < $fragmentNames2Length; $j++) {
$this->collectConflictsBetweenFragments(
$context,
$conflicts,
$areMutuallyExclusive,
$fragmentName1,
$fragmentNames2[$j]
);
}
// (G) Then collect conflicts between the second fragment and any nested
// fragments spread in the first fragment.
$fragmentNames1Length = count($fragmentNames1);
for ($i = 0; $i < $fragmentNames1Length; $i++) {
$this->collectConflictsBetweenFragments(
$context,
$conflicts,
$areMutuallyExclusive,
$fragmentNames1[$i],
$fragmentName2
);
}
}
/**
* Given a series of Conflicts which occurred between two sub-fields, generate
* a single Conflict.
*
* @param mixed[][] $conflicts
* @param string $responseName
*
* @return mixed[]|null
*/
private function subfieldConflicts(
array $conflicts,
$responseName,
FieldNode $ast1,
FieldNode $ast2
) {
if (count($conflicts) === 0) {
return null;
}
return [
[
$responseName,
array_map(
static function ($conflict) {
return $conflict[0];
},
$conflicts
),
],
array_reduce(
$conflicts,
static function ($allFields, $conflict) : array {
return array_merge($allFields, $conflict[1]);
},
[$ast1]
),
array_reduce(
$conflicts,
static function ($allFields, $conflict) : array {
return array_merge($allFields, $conflict[2]);
},
[$ast2]
),
];
}
/**
* @param string $responseName
* @param string $reason
*/
public static function fieldsConflictMessage($responseName, $reason)
{
$reasonMessage = self::reasonMessage($reason);
return sprintf(
'Fields "%s" conflict because %s. Use different aliases on the fields to fetch both if this was intentional.',
$responseName,
$reasonMessage
);
}
public static function reasonMessage($reason)
{
if (is_array($reason)) {
$tmp = array_map(
static function ($tmp) : string {
[$responseName, $subReason] = $tmp;
$reasonMessage = self::reasonMessage($subReason);
return sprintf('subfields "%s" conflict because %s', $responseName, $reasonMessage);
},
$reason
);
return implode(' and ', $tmp);
}
return $reason;
}
}