import { inspect } from '../../jsutils/inspect.js';
import type { Maybe } from '../../jsutils/Maybe.js';
import type { ObjMap } from '../../jsutils/ObjMap.js';
import { GraphQLError } from '../../error/GraphQLError.js';
import type {
DirectiveNode,
FieldNode,
FragmentDefinitionNode,
ObjectValueNode,
SelectionSetNode,
} from '../../language/ast.js';
import { Kind } from '../../language/kinds.js';
import { print } from '../../language/printer.js';
import type { ASTVisitor } from '../../language/visitor.js';
import type {
GraphQLField,
GraphQLNamedType,
GraphQLOutputType,
} from '../../type/definition.js';
import {
getNamedType,
isInterfaceType,
isLeafType,
isListType,
isNonNullType,
isObjectType,
} from '../../type/definition.js';
import { sortValueNode } from '../../utilities/sortValueNode.js';
import { typeFromAST } from '../../utilities/typeFromAST.js';
import type { ValidationContext } from '../ValidationContext.js';
function reasonMessage(reason: ConflictReasonMessage): string {
if (Array.isArray(reason)) {
return reason
.map(
([responseName, subReason]) =>
`subfields "${responseName}" conflict because ` +
reasonMessage(subReason),
)
.join(' and ');
}
return reason;
}
export function OverlappingFieldsCanBeMergedRule(
context: ValidationContext,
): ASTVisitor {
const comparedFragmentPairs = new PairSet();
const cachedFieldsAndFragmentNames = new Map();
return {
SelectionSet(selectionSet) {
const conflicts = findConflictsWithinSelectionSet(
context,
cachedFieldsAndFragmentNames,
comparedFragmentPairs,
context.getParentType(),
selectionSet,
);
for (const [[responseName, reason], fields1, fields2] of conflicts) {
const reasonMsg = reasonMessage(reason);
context.reportError(
new GraphQLError(
`Fields "${responseName}" conflict because ${reasonMsg}. Use different aliases on the fields to fetch both if this was intentional.`,
{ nodes: fields1.concat(fields2) },
),
);
}
},
};
}
type Conflict = [ConflictReason, Array<FieldNode>, Array<FieldNode>];
type ConflictReason = [string, ConflictReasonMessage];
type ConflictReasonMessage = string | Array<ConflictReason>;
type NodeAndDef = [
Maybe<GraphQLNamedType>,
FieldNode,
Maybe<GraphQLField<unknown, unknown>>,
];
type NodeAndDefCollection = ObjMap<Array<NodeAndDef>>;
type FragmentNames = ReadonlyArray<string>;
type FieldsAndFragmentNames = readonly [NodeAndDefCollection, FragmentNames];
function findConflictsWithinSelectionSet(
context: ValidationContext,
cachedFieldsAndFragmentNames: Map<SelectionSetNode, FieldsAndFragmentNames>,
comparedFragmentPairs: PairSet,
parentType: Maybe<GraphQLNamedType>,
selectionSet: SelectionSetNode,
): Array<Conflict> {
const conflicts: Array<Conflict> = [];
const [fieldMap, fragmentNames] = getFieldsAndFragmentNames(
context,
cachedFieldsAndFragmentNames,
parentType,
selectionSet,
);
collectConflictsWithin(
context,
conflicts,
cachedFieldsAndFragmentNames,
comparedFragmentPairs,
fieldMap,
);
if (fragmentNames.length !== 0) {
for (let i = 0; i < fragmentNames.length; i++) {
collectConflictsBetweenFieldsAndFragment(
context,
conflicts,
cachedFieldsAndFragmentNames,
comparedFragmentPairs,
false,
fieldMap,
fragmentNames[i],
);
for (let j = i + 1; j < fragmentNames.length; j++) {
collectConflictsBetweenFragments(
context,
conflicts,
cachedFieldsAndFragmentNames,
comparedFragmentPairs,
false,
fragmentNames[i],
fragmentNames[j],
);
}
}
}
return conflicts;
}
function collectConflictsBetweenFieldsAndFragment(
context: ValidationContext,
conflicts: Array<Conflict>,
cachedFieldsAndFragmentNames: Map<SelectionSetNode, FieldsAndFragmentNames>,
comparedFragmentPairs: PairSet,
areMutuallyExclusive: boolean,
fieldMap: NodeAndDefCollection,
fragmentName: string,
): void {
const fragment = context.getFragment(fragmentName);
if (!fragment) {
return;
}
const [fieldMap2, referencedFragmentNames] =
getReferencedFieldsAndFragmentNames(
context,
cachedFieldsAndFragmentNames,
fragment,
);
if (fieldMap === fieldMap2) {
return;
}
collectConflictsBetween(
context,
conflicts,
cachedFieldsAndFragmentNames,
comparedFragmentPairs,
areMutuallyExclusive,
fieldMap,
fieldMap2,
);
for (const referencedFragmentName of referencedFragmentNames) {
if (
comparedFragmentPairs.has(
referencedFragmentName,
fragmentName,
areMutuallyExclusive,
)
) {
continue;
}
comparedFragmentPairs.add(
referencedFragmentName,
fragmentName,
areMutuallyExclusive,
);
collectConflictsBetweenFieldsAndFragment(
context,
conflicts,
cachedFieldsAndFragmentNames,
comparedFragmentPairs,
areMutuallyExclusive,
fieldMap,
referencedFragmentName,
);
}
}
function collectConflictsBetweenFragments(
context: ValidationContext,
conflicts: Array<Conflict>,
cachedFieldsAndFragmentNames: Map<SelectionSetNode, FieldsAndFragmentNames>,
comparedFragmentPairs: PairSet,
areMutuallyExclusive: boolean,
fragmentName1: string,
fragmentName2: string,
): void {
if (fragmentName1 === fragmentName2) {
return;
}
if (
comparedFragmentPairs.has(
fragmentName1,
fragmentName2,
areMutuallyExclusive,
)
) {
return;
}
comparedFragmentPairs.add(fragmentName1, fragmentName2, areMutuallyExclusive);
const fragment1 = context.getFragment(fragmentName1);
const fragment2 = context.getFragment(fragmentName2);
if (!fragment1 || !fragment2) {
return;
}
const [fieldMap1, referencedFragmentNames1] =
getReferencedFieldsAndFragmentNames(
context,
cachedFieldsAndFragmentNames,
fragment1,
);
const [fieldMap2, referencedFragmentNames2] =
getReferencedFieldsAndFragmentNames(
context,
cachedFieldsAndFragmentNames,
fragment2,
);
collectConflictsBetween(
context,
conflicts,
cachedFieldsAndFragmentNames,
comparedFragmentPairs,
areMutuallyExclusive,
fieldMap1,
fieldMap2,
);
for (const referencedFragmentName2 of referencedFragmentNames2) {
collectConflictsBetweenFragments(
context,
conflicts,
cachedFieldsAndFragmentNames,
comparedFragmentPairs,
areMutuallyExclusive,
fragmentName1,
referencedFragmentName2,
);
}
for (const referencedFragmentName1 of referencedFragmentNames1) {
collectConflictsBetweenFragments(
context,
conflicts,
cachedFieldsAndFragmentNames,
comparedFragmentPairs,
areMutuallyExclusive,
referencedFragmentName1,
fragmentName2,
);
}
}
function findConflictsBetweenSubSelectionSets(
context: ValidationContext,
cachedFieldsAndFragmentNames: Map<SelectionSetNode, FieldsAndFragmentNames>,
comparedFragmentPairs: PairSet,
areMutuallyExclusive: boolean,
parentType1: Maybe<GraphQLNamedType>,
selectionSet1: SelectionSetNode,
parentType2: Maybe<GraphQLNamedType>,
selectionSet2: SelectionSetNode,
): Array<Conflict> {
const conflicts: Array<Conflict> = [];
const [fieldMap1, fragmentNames1] = getFieldsAndFragmentNames(
context,
cachedFieldsAndFragmentNames,
parentType1,
selectionSet1,
);
const [fieldMap2, fragmentNames2] = getFieldsAndFragmentNames(
context,
cachedFieldsAndFragmentNames,
parentType2,
selectionSet2,
);
collectConflictsBetween(
context,
conflicts,
cachedFieldsAndFragmentNames,
comparedFragmentPairs,
areMutuallyExclusive,
fieldMap1,
fieldMap2,
);
for (const fragmentName2 of fragmentNames2) {
collectConflictsBetweenFieldsAndFragment(
context,
conflicts,
cachedFieldsAndFragmentNames,
comparedFragmentPairs,
areMutuallyExclusive,
fieldMap1,
fragmentName2,
);
}
for (const fragmentName1 of fragmentNames1) {
collectConflictsBetweenFieldsAndFragment(
context,
conflicts,
cachedFieldsAndFragmentNames,
comparedFragmentPairs,
areMutuallyExclusive,
fieldMap2,
fragmentName1,
);
}
for (const fragmentName1 of fragmentNames1) {
for (const fragmentName2 of fragmentNames2) {
collectConflictsBetweenFragments(
context,
conflicts,
cachedFieldsAndFragmentNames,
comparedFragmentPairs,
areMutuallyExclusive,
fragmentName1,
fragmentName2,
);
}
}
return conflicts;
}
function collectConflictsWithin(
context: ValidationContext,
conflicts: Array<Conflict>,
cachedFieldsAndFragmentNames: Map<SelectionSetNode, FieldsAndFragmentNames>,
comparedFragmentPairs: PairSet,
fieldMap: NodeAndDefCollection,
): void {
for (const [responseName, fields] of Object.entries(fieldMap)) {
if (fields.length > 1) {
for (let i = 0; i < fields.length; i++) {
for (let j = i + 1; j < fields.length; j++) {
const conflict = findConflict(
context,
cachedFieldsAndFragmentNames,
comparedFragmentPairs,
false,
responseName,
fields[i],
fields[j],
);
if (conflict) {
conflicts.push(conflict);
}
}
}
}
}
}
function collectConflictsBetween(
context: ValidationContext,
conflicts: Array<Conflict>,
cachedFieldsAndFragmentNames: Map<SelectionSetNode, FieldsAndFragmentNames>,
comparedFragmentPairs: PairSet,
parentFieldsAreMutuallyExclusive: boolean,
fieldMap1: NodeAndDefCollection,
fieldMap2: NodeAndDefCollection,
): void {
for (const [responseName, fields1] of Object.entries(fieldMap1)) {
const fields2 = fieldMap2[responseName];
if (fields2) {
for (const field1 of fields1) {
for (const field2 of fields2) {
const conflict = findConflict(
context,
cachedFieldsAndFragmentNames,
comparedFragmentPairs,
parentFieldsAreMutuallyExclusive,
responseName,
field1,
field2,
);
if (conflict) {
conflicts.push(conflict);
}
}
}
}
}
}
function findConflict(
context: ValidationContext,
cachedFieldsAndFragmentNames: Map<SelectionSetNode, FieldsAndFragmentNames>,
comparedFragmentPairs: PairSet,
parentFieldsAreMutuallyExclusive: boolean,
responseName: string,
field1: NodeAndDef,
field2: NodeAndDef,
): Maybe<Conflict> {
const [parentType1, node1, def1] = field1;
const [parentType2, node2, def2] = field2;
const areMutuallyExclusive =
parentFieldsAreMutuallyExclusive ||
(parentType1 !== parentType2 &&
isObjectType(parentType1) &&
isObjectType(parentType2));
if (!areMutuallyExclusive) {
const name1 = node1.name.value;
const name2 = node2.name.value;
if (name1 !== name2) {
return [
[responseName, `"${name1}" and "${name2}" are different fields`],
[node1],
[node2],
];
}
if (stringifyArguments(node1) !== stringifyArguments(node2)) {
return [
[responseName, 'they have differing arguments'],
[node1],
[node2],
];
}
}
const directives1 = node1.directives ?? [];
const directives2 = node2.directives ?? [];
if (!sameStreams(directives1, directives2)) {
return [
[responseName, 'they have differing stream directives'],
[node1],
[node2],
];
}
const type1 = def1?.type;
const type2 = def2?.type;
if (type1 && type2 && doTypesConflict(type1, type2)) {
return [
[
responseName,
`they return conflicting types "${inspect(type1)}" and "${inspect(
type2,
)}"`,
],
[node1],
[node2],
];
}
const selectionSet1 = node1.selectionSet;
const selectionSet2 = node2.selectionSet;
if (selectionSet1 && selectionSet2) {
const conflicts = findConflictsBetweenSubSelectionSets(
context,
cachedFieldsAndFragmentNames,
comparedFragmentPairs,
areMutuallyExclusive,
getNamedType(type1),
selectionSet1,
getNamedType(type2),
selectionSet2,
);
return subfieldConflicts(conflicts, responseName, node1, node2);
}
}
function stringifyArguments(fieldNode: FieldNode | DirectiveNode): string {
const args = fieldNode.arguments ?? [];
const inputObjectWithArgs: ObjectValueNode = {
kind: Kind.OBJECT,
fields: args.map((argNode) => ({
kind: Kind.OBJECT_FIELD,
name: argNode.name,
value: argNode.value,
})),
};
return print(sortValueNode(inputObjectWithArgs));
}
function getStreamDirective(
directives: ReadonlyArray<DirectiveNode>,
): DirectiveNode | undefined {
return directives.find((directive) => directive.name.value === 'stream');
}
function sameStreams(
directives1: ReadonlyArray<DirectiveNode>,
directives2: ReadonlyArray<DirectiveNode>,
): boolean {
const stream1 = getStreamDirective(directives1);
const stream2 = getStreamDirective(directives2);
if (!stream1 && !stream2) {
return true;
} else if (stream1 && stream2) {
return stringifyArguments(stream1) === stringifyArguments(stream2);
}
return false;
}
function doTypesConflict(
type1: GraphQLOutputType,
type2: GraphQLOutputType,
): boolean {
if (isListType(type1)) {
return isListType(type2)
? doTypesConflict(type1.ofType, type2.ofType)
: true;
}
if (isListType(type2)) {
return true;
}
if (isNonNullType(type1)) {
return isNonNullType(type2)
? doTypesConflict(type1.ofType, type2.ofType)
: true;
}
if (isNonNullType(type2)) {
return true;
}
if (isLeafType(type1) || isLeafType(type2)) {
return type1 !== type2;
}
return false;
}
function getFieldsAndFragmentNames(
context: ValidationContext,
cachedFieldsAndFragmentNames: Map<SelectionSetNode, FieldsAndFragmentNames>,
parentType: Maybe<GraphQLNamedType>,
selectionSet: SelectionSetNode,
): FieldsAndFragmentNames {
const cached = cachedFieldsAndFragmentNames.get(selectionSet);
if (cached) {
return cached;
}
const nodeAndDefs: NodeAndDefCollection = Object.create(null);
const fragmentNames = new Set<string>();
_collectFieldsAndFragmentNames(
context,
parentType,
selectionSet,
nodeAndDefs,
fragmentNames,
);
const result = [nodeAndDefs, [...fragmentNames]] as const;
cachedFieldsAndFragmentNames.set(selectionSet, result);
return result;
}
function getReferencedFieldsAndFragmentNames(
context: ValidationContext,
cachedFieldsAndFragmentNames: Map<SelectionSetNode, FieldsAndFragmentNames>,
fragment: FragmentDefinitionNode,
) {
const cached = cachedFieldsAndFragmentNames.get(fragment.selectionSet);
if (cached) {
return cached;
}
const fragmentType = typeFromAST(context.getSchema(), fragment.typeCondition);
return getFieldsAndFragmentNames(
context,
cachedFieldsAndFragmentNames,
fragmentType,
fragment.selectionSet,
);
}
function _collectFieldsAndFragmentNames(
context: ValidationContext,
parentType: Maybe<GraphQLNamedType>,
selectionSet: SelectionSetNode,
nodeAndDefs: NodeAndDefCollection,
fragmentNames: Set<string>,
): void {
for (const selection of selectionSet.selections) {
switch (selection.kind) {
case Kind.FIELD: {
const fieldName = selection.name.value;
let fieldDef;
if (isObjectType(parentType) || isInterfaceType(parentType)) {
fieldDef = parentType.getFields()[fieldName];
}
const responseName = selection.alias
? selection.alias.value
: fieldName;
if (!nodeAndDefs[responseName]) {
nodeAndDefs[responseName] = [];
}
nodeAndDefs[responseName].push([parentType, selection, fieldDef]);
break;
}
case Kind.FRAGMENT_SPREAD:
fragmentNames.add(selection.name.value);
break;
case Kind.INLINE_FRAGMENT: {
const typeCondition = selection.typeCondition;
const inlineFragmentType = typeCondition
? typeFromAST(context.getSchema(), typeCondition)
: parentType;
_collectFieldsAndFragmentNames(
context,
inlineFragmentType,
selection.selectionSet,
nodeAndDefs,
fragmentNames,
);
break;
}
}
}
}
function subfieldConflicts(
conflicts: ReadonlyArray<Conflict>,
responseName: string,
node1: FieldNode,
node2: FieldNode,
): Maybe<Conflict> {
if (conflicts.length > 0) {
return [
[responseName, conflicts.map(([reason]) => reason)],
[node1, ...conflicts.map(([, fields1]) => fields1).flat()],
[node2, ...conflicts.map(([, , fields2]) => fields2).flat()],
];
}
}
class PairSet {
_data: Map<string, Map<string, boolean>>;
constructor() {
this._data = new Map();
}
has(a: string, b: string, areMutuallyExclusive: boolean): boolean {
const [key1, key2] = a < b ? [a, b] : [b, a];
const result = this._data.get(key1)?.get(key2);
if (result === undefined) {
return false;
}
return areMutuallyExclusive ? true : areMutuallyExclusive === result;
}
add(a: string, b: string, areMutuallyExclusive: boolean): void {
const [key1, key2] = a < b ? [a, b] : [b, a];
const map = this._data.get(key1);
if (map === undefined) {
this._data.set(key1, new Map([[key2, areMutuallyExclusive]]));
} else {
map.set(key2, areMutuallyExclusive);
}
}
}