@ardatan/relay-compiler
Version:
A compiler tool for building GraphQL-driven applications.
594 lines (550 loc) • 17.4 kB
Flow
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/
// flowlint ambiguous-object-type:error
;
const IRTransformer = require('../core/IRTransformer');
const getLiteralArgumentValues = require('../core/getLiteralArgumentValues');
const getNormalizationOperationName = require('../core/getNormalizationOperationName');
const {createCompilerError, createUserError} = require('../core/CompilerError');
const {getModuleComponentKey, getModuleOperationKey} = require('relay-runtime');
import type CompilerContext from '../core/CompilerContext';
import type {
InlineFragment,
FragmentSpread,
LinkedField,
Location,
ScalarField,
} from '../core/IR';
import type {TypeID} from '../core/Schema';
const SUPPORTED_ARGUMENT_NAME = 'supported';
const JS_FIELD_TYPE = 'JSDependency';
const JS_FIELD_MODULE_ARG = 'module';
const JS_FIELD_ID_ARG = 'id';
const JS_FIELD_NAME = 'js';
const SCHEMA_EXTENSION = `
directive @match(key: String) on FIELD
directive @module(
name: String!
) on FRAGMENT_SPREAD
`;
type State = {|
+documentName: string,
+parentType: TypeID,
+path: Array<LinkedField>,
+moduleKey: ?string,
+matchesForPath: Map<
string,
{|
+key: string,
+location: Location,
+types: Map<
string,
{|+location: Location, +fragment: string, +module: string|},
>,
|},
>,
|};
/**
* This transform rewrites LinkedField nodes with @match and rewrites them
* into `LinkedField` nodes with a `supported` argument.
*/
function matchTransform(context: CompilerContext): CompilerContext {
return IRTransformer.transform(
context,
{
// TODO: type IRTransformer to allow changing result type
FragmentSpread: (visitFragmentSpread: $FlowFixMe),
LinkedField: visitLinkedField,
InlineFragment: visitInlineFragment,
ScalarField: visitScalarField,
},
node => ({
documentName: node.name,
matchesForPath: new Map(),
moduleKey: null,
parentType: node.type,
path: [],
}),
);
}
function visitInlineFragment(
node: InlineFragment,
state: State,
): InlineFragment {
// $FlowFixMe[incompatible-use]
return this.traverse(node, {
...state,
parentType: node.typeCondition,
});
}
function visitScalarField(field: ScalarField): ScalarField {
// $FlowFixMe[incompatible-use]
const context: CompilerContext = this.getContext();
const schema = context.getSchema();
if (field.name === JS_FIELD_NAME) {
const jsModuleType = schema.getTypeFromString(JS_FIELD_TYPE);
if (jsModuleType == null || !schema.isServerType(jsModuleType)) {
throw new createUserError(
`'${JS_FIELD_NAME}' should be defined on the server schema.`,
[field.loc],
);
}
if (
schema.isScalar(jsModuleType) &&
schema.areEqualTypes(schema.getRawType(field.type), jsModuleType)
) {
throw new createUserError(
`Direct use of the '${JS_FIELD_NAME}' field is not allowed, use ` +
'@match/@module instead.',
[field.loc],
);
}
}
return field;
}
function visitLinkedField(node: LinkedField, state: State): LinkedField {
// $FlowFixMe[incompatible-use]
const context: CompilerContext = this.getContext();
const schema = context.getSchema();
const matchDirective = node.directives.find(
directive => directive.name === 'match',
);
let moduleKey = null;
if (matchDirective != null) {
({key: moduleKey} = getLiteralArgumentValues(matchDirective.args));
if (
moduleKey != null &&
(typeof moduleKey !== 'string' ||
!moduleKey.startsWith(state.documentName))
) {
throw createUserError(
"Expected the 'key' argument of @match to be a literal string starting " +
`with the document name, e.g. '${state.documentName}_<localName>'.`,
[
(
matchDirective.args.find(arg => arg.name === 'key') ??
matchDirective
).loc,
],
);
}
}
state.path.push(node);
// $FlowFixMe[incompatible-use]
const transformedNode: LinkedField = this.traverse(node, {
...state,
moduleKey,
parentType: node.type,
});
state.path.pop();
if (matchDirective == null) {
return transformedNode;
}
const {parentType} = state;
const rawType = schema.getRawType(parentType);
if (!(schema.isInterface(rawType) || schema.isObject(rawType))) {
throw createUserError(
`@match used on incompatible field '${transformedNode.name}'.` +
'@match may only be used with fields whose parent type is an ' +
`interface or object, got invalid type '${schema.getTypeString(
parentType,
)}'.`,
[node.loc],
);
}
const currentField = schema.getFieldConfig(
schema.expectField(
schema.assertCompositeType(rawType),
transformedNode.name,
),
);
const supportedArgumentDefinition = currentField.args.find(
({name}) => name === SUPPORTED_ARGUMENT_NAME,
);
if (supportedArgumentDefinition == null) {
if (moduleKey == null) {
throw createUserError(
'@match on a field without the `supported` argument is a no-op, please remove the `@match`.',
[node.loc],
);
}
return transformedNode;
}
const supportedArgType = schema.getNullableType(
supportedArgumentDefinition.type,
);
const supportedArgOfType =
supportedArgType != null && schema.isList(supportedArgType)
? schema.getListItemType(supportedArgType)
: null;
if (
supportedArgType == null ||
supportedArgOfType == null ||
!schema.isString(schema.getNullableType(supportedArgOfType))
) {
throw createUserError(
`@match used on incompatible field '${transformedNode.name}'. ` +
'@match may only be used with fields that accept a ' +
"'supported: [String!]!' argument.",
[node.loc],
);
}
const rawFieldType = schema.getRawType(transformedNode.type);
if (!schema.isAbstractType(rawFieldType)) {
throw createUserError(
`@match used on incompatible field '${transformedNode.name}'.` +
'@match may only be used with fields that return a union or interface.',
[node.loc],
);
}
const seenTypes: Map<TypeID, InlineFragment> = new Map();
const selections = [];
transformedNode.selections.forEach(matchSelection => {
if (
matchSelection.kind === 'ScalarField' &&
matchSelection.name === '__typename'
) {
selections.push(matchSelection);
return;
}
const moduleImport =
matchSelection.kind === 'InlineFragment'
? matchSelection.selections[0]
: null;
if (
matchSelection.kind !== 'InlineFragment' ||
moduleImport == null ||
moduleImport.kind !== 'ModuleImport'
) {
throw createUserError(
'Invalid @match selection: all selections should be ' +
'fragment spreads with @module.',
[matchSelection.loc],
);
}
const matchedType = matchSelection.typeCondition;
seenTypes.set(matchedType, matchSelection);
selections.push(matchSelection);
});
if (seenTypes.size === 0) {
throw createUserError(
'Invalid @match selection: expected at least one @module selection. ' +
"Remove @match or add a '...Fragment @module()' selection.",
[matchDirective.loc],
);
}
const supportedArg = transformedNode.args.find(
arg => arg.name === SUPPORTED_ARGUMENT_NAME,
);
if (supportedArg != null) {
throw createUserError(
`Invalid @match selection: the '${SUPPORTED_ARGUMENT_NAME}' argument ` +
'is automatically added and cannot be supplied explicitly.',
[supportedArg.loc],
);
}
return {
kind: 'LinkedField',
alias: transformedNode.alias,
args: [
...transformedNode.args,
{
kind: 'Argument',
name: SUPPORTED_ARGUMENT_NAME,
type: supportedArgumentDefinition.type,
value: {
kind: 'Literal',
loc: node.loc,
value: Array.from(seenTypes.keys()).map(type =>
schema.getTypeString(type),
),
},
loc: node.loc,
},
],
connection: false,
directives: [],
handles: null,
loc: node.loc,
metadata: null,
name: transformedNode.name,
type: transformedNode.type,
selections,
};
}
// Transform @module
function visitFragmentSpread(
spread: FragmentSpread,
{documentName, path, matchesForPath, moduleKey: moduleKeyFromParent}: State,
): FragmentSpread | InlineFragment {
const transformedNode: FragmentSpread = this.traverse(spread);
const moduleDirective = transformedNode.directives.find(
directive => directive.name === 'module',
);
if (moduleDirective == null) {
return transformedNode;
}
if (spread.args.length !== 0) {
throw createUserError(
'@module does not support @arguments.',
[spread.args[0]?.loc].filter(Boolean),
);
}
const context: CompilerContext = this.getContext();
const schema = context.getSchema();
const jsModuleType = schema.asScalarFieldType(
schema.getTypeFromString(JS_FIELD_TYPE),
);
if (jsModuleType == null || !schema.isServerType(jsModuleType)) {
throw new createUserError(
`'${JS_FIELD_NAME}' should be defined on the server schema.`,
[spread.loc],
);
}
if (!schema.isScalar(jsModuleType)) {
throw createUserError(
'Using @module requires the schema to define a scalar ' +
`'${JS_FIELD_TYPE}' type.`,
);
}
const fragment = context.getFragment(spread.name, spread.loc);
if (!schema.isObject(fragment.type)) {
throw createUserError(
`@module used on invalid fragment spread '...${spread.name}'. @module ` +
'may only be used with fragments on a concrete (object) type, ' +
`but the fragment has abstract type '${schema.getTypeString(
fragment.type,
)}'.`,
[spread.loc, fragment.loc],
);
}
const field = schema.getFieldByName(fragment.type, JS_FIELD_NAME);
if (!field) {
throw createUserError(
`@module used on invalid fragment spread '...${spread.name}'. @module ` +
`requires the fragment type '${schema.getTypeString(
fragment.type,
)}' to have a ` +
`'${JS_FIELD_NAME}(${JS_FIELD_MODULE_ARG}: String! ` +
`[${JS_FIELD_ID_ARG}: String]): ${JS_FIELD_TYPE}' field (your ` +
"schema may choose to omit the 'id' argument but if present it " +
"must accept a 'String').",
[moduleDirective.loc],
);
}
const jsField = schema.getFieldConfig(field);
const jsFieldModuleArg = jsField
? jsField.args.find(arg => arg.name === JS_FIELD_MODULE_ARG)
: null;
const jsFieldIdArg = jsField
? jsField.args.find(arg => arg.name === JS_FIELD_ID_ARG)
: null;
if (
jsFieldModuleArg == null ||
!schema.isString(schema.getNullableType(jsFieldModuleArg.type)) ||
(jsFieldIdArg != null && !schema.isString(jsFieldIdArg.type)) ||
jsField.type !== jsModuleType
) {
throw createUserError(
`@module used on invalid fragment spread '...${spread.name}'. @module ` +
`requires the fragment type '${schema.getTypeString(
fragment.type,
)}' to have a ` +
`'${JS_FIELD_NAME}(${JS_FIELD_MODULE_ARG}: String! ` +
`[${JS_FIELD_ID_ARG}: String]): ${JS_FIELD_TYPE}' field (your ` +
"schema may choose to omit the 'id' argument but if present it " +
"must accept a 'String').",
[moduleDirective.loc],
);
}
if (spread.directives.length !== 1) {
throw createUserError(
`@module used on invalid fragment spread '...${spread.name}'. @module ` +
'may not have additional directives.',
[spread.loc],
);
}
const {name: moduleName} = getLiteralArgumentValues(moduleDirective.args);
if (typeof moduleName !== 'string') {
throw createUserError(
"Expected the 'name' argument of @module to be a literal string",
[(moduleDirective.args.find(arg => arg.name === 'name') ?? spread).loc],
);
}
const parentField = path[path.length - 1];
const moduleKey = moduleKeyFromParent ?? documentName;
const aliasPath = path.map(x => x.alias).join('.');
const moduleId =
aliasPath === '' ? documentName : `${documentName}.${aliasPath}`;
const typeName = schema.getTypeString(fragment.type);
let matches = matchesForPath.get(aliasPath);
if (matches == null) {
if (matchesForPath.size !== 0) {
const existingMatchWithKey = Array.from(matchesForPath.values()).find(
entry => entry.key === moduleKey,
);
if (existingMatchWithKey != null) {
if (parentField == null) {
throw createCompilerError(
'Cannot have @module selections at multiple paths unless the selections are within fields.',
[spread.loc],
);
}
throw createUserError(
'Invalid @module selection: documents with multiple fields ' +
"containing 3D selections must specify a unique 'key' value " +
`for each field: use '${parentField.alias} @match(key: "${documentName}_<localName>")'.`,
[parentField.loc],
);
}
}
matches = {
key: moduleKey,
location: parentField?.loc ?? spread.loc,
types: new Map(),
};
matchesForPath.set(aliasPath, matches);
}
if (moduleKey !== matches.key) {
// The user can't override the key locally (per @module),
// so this is just an internal sanity check
throw createCompilerError(
'Invalid @module selection: expected all selections at path ' +
`'${aliasPath} to have the same 'key', got '${moduleKey}' and '${matches.key}'.`,
[parentField?.loc ?? spread.loc],
);
}
const previousMatchForType = matches.types.get(typeName);
if (
previousMatchForType != null &&
(previousMatchForType.fragment !== spread.name ||
previousMatchForType.module !== moduleName)
) {
throw createUserError(
'Invalid @module selection: concrete type ' +
`'${typeName}' was matched multiple times at path ` +
`'${aliasPath}' but with a different fragment or module name.`,
[spread.loc, previousMatchForType.location],
);
}
matches.types.set(typeName, {
location: spread.loc,
fragment: spread.name,
module: moduleName,
});
const normalizationName =
getNormalizationOperationName(spread.name) + '.graphql';
const componentKey = getModuleComponentKey(moduleKey);
const componentField: ScalarField = {
alias: componentKey,
args: [
{
kind: 'Argument',
name: JS_FIELD_MODULE_ARG,
type: jsFieldModuleArg.type,
value: {
kind: 'Literal',
loc: moduleDirective.args[0]?.loc ?? moduleDirective.loc,
value: moduleName,
},
loc: moduleDirective.loc,
},
jsFieldIdArg != null
? {
kind: 'Argument',
name: JS_FIELD_ID_ARG,
type: jsFieldIdArg.type,
value: {
kind: 'Literal',
loc: moduleDirective.args[0]?.loc ?? moduleDirective.loc,
value: moduleId,
},
loc: moduleDirective.loc,
}
: null,
].filter(Boolean),
directives: [],
handles: null,
kind: 'ScalarField',
loc: moduleDirective.loc,
metadata: {skipNormalizationNode: true},
name: JS_FIELD_NAME,
type: jsModuleType,
};
const operationKey = getModuleOperationKey(moduleKey);
const operationField: ScalarField = {
alias: operationKey,
args: [
{
kind: 'Argument',
name: JS_FIELD_MODULE_ARG,
type: jsFieldModuleArg.type,
value: {
kind: 'Literal',
loc: moduleDirective.loc,
value: normalizationName,
},
loc: moduleDirective.loc,
},
jsFieldIdArg != null
? {
kind: 'Argument',
name: JS_FIELD_ID_ARG,
type: jsFieldIdArg.type,
value: {
kind: 'Literal',
loc: moduleDirective.args[0]?.loc ?? moduleDirective.loc,
value: moduleId,
},
loc: moduleDirective.loc,
}
: null,
].filter(Boolean),
directives: [],
handles: null,
kind: 'ScalarField',
loc: moduleDirective.loc,
metadata: {skipNormalizationNode: true},
name: JS_FIELD_NAME,
type: jsModuleType,
};
return {
kind: 'InlineFragment',
directives: [],
loc: moduleDirective.loc,
metadata: null,
selections: [
{
kind: 'ModuleImport',
loc: moduleDirective.loc,
key: moduleKey,
id: moduleId,
module: moduleName,
sourceDocument: documentName,
name: spread.name,
selections: [
{
...spread,
directives: spread.directives.filter(
directive => directive !== moduleDirective,
),
},
operationField,
componentField,
],
},
],
typeCondition: fragment.type,
};
}
module.exports = {
SCHEMA_EXTENSION,
transform: matchTransform,
};