Skip to content
Merged
3 changes: 2 additions & 1 deletion protographic/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ dist
out
node_modules
.env
.eslintcache
.eslintcache
coverage
2 changes: 1 addition & 1 deletion protographic/src/abstract-selection-rewriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ export class AbstractSelectionRewriter {
// the wrong inline fragments.
const fieldsToAdd = fields
.filter((field) => !existingFields.has(field.name.value))
.filter((field) => inlineFragmentType.getFields()[field.name.value]);
.filter((field) => field.name.value === '__typename' || inlineFragmentType.getFields()[field.name.value]);

// Add the interface fields to the fragment. We always prepend them for now.
// TODO: Check if fields should be inserted in the order of appearance in the selection set.
Expand Down
2 changes: 1 addition & 1 deletion protographic/src/required-fields-visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ export class RequiredFieldsVisitor {
* @throws Error if the field definition is not found on the current type
*/
private onEnterField(ctx: VisitContext<FieldNode>): void {
if (!this.current) {
if (!this.current || ctx.node.name.value === '__typename') {
return;
}

Expand Down
108 changes: 67 additions & 41 deletions protographic/src/sdl-validation-visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ import {
NamedTypeNode,
GraphQLID,
ConstArgumentNode,
GraphQLObjectType,
GraphQLSchema,
buildASTSchema,
} from 'graphql';
import { CONNECT_FIELD_RESOLVER, CONTEXT } from './string-constants.js';
import { safeParse } from '@wundergraph/composition';
import { CONNECT_FIELD_RESOLVER, CONTEXT, FIELDS, REQUIRES_DIRECTIVE_NAME } from './string-constants.js';
import { SelectionSetValidationVisitor } from './selection-set-validation-visitor.js';

/**
* Type mapping from Kind enum values to their corresponding AST node types
Expand Down Expand Up @@ -89,6 +94,7 @@ export class SDLValidationVisitor {
private readonly validationResult: ValidationResult;
private lintingRules: LintingRule<any>[] = [];
private visitor: ASTVisitor;
private parsedSchema: GraphQLSchema;

/**
* Creates a new SDL validation visitor for the given GraphQL schema
Expand All @@ -101,6 +107,8 @@ export class SDLValidationVisitor {
warnings: [],
};

this.parsedSchema = buildASTSchema(parse(schema), { assumeValid: true, assumeValidSDL: true });

this.initializeLintingRules();
this.visitor = this.createASTVisitor();
}
Expand Down Expand Up @@ -143,7 +151,64 @@ export class SDLValidationVisitor {
validationFunction: (ctx) => this.validateInvalidResolverContext(ctx),
};

this.lintingRules = [objectTypeRule, listTypeRule, providesRule, resolverContextRule];
const compositeTypeReflectionRule: LintingRule<Kind.FIELD_DEFINITION> = {
name: 'use-of-typename',
description: 'Validates usage of __typename field which is not supported',
enabled: true,
nodeKind: Kind.FIELD_DEFINITION,
validationFunction: (ctx) => this.validateCompositeTypeReflection(ctx),
};

this.lintingRules = [objectTypeRule, listTypeRule, providesRule, resolverContextRule, compositeTypeReflectionRule];
}

private validateCompositeTypeReflection(ctx: VisitContext<FieldDefinitionNode>): void {
const directive = ctx.node.directives?.find((directive) => directive.name.value === REQUIRES_DIRECTIVE_NAME);
if (!directive) {
return;
}

const fieldSet = directive.arguments?.find((arg) => arg.name.value === FIELDS);
if (!fieldSet) {
return;
}

if (fieldSet.value.kind !== Kind.STRING) {
this.addError('Invalid @requires directive: fields argument must be a string', fieldSet.loc);
return;
}

const fieldSetValue = fieldSet.value.value;
const { error, documentNode } = safeParse('{' + fieldSetValue + '}');
if (error || !documentNode) {
this.addError('Invalid @requires directive: fields argument must be a valid GraphQL selection set', fieldSet.loc);
return;
}

const parentNode = ctx.ancestors.at(-1);
if (!parentNode || !this.isASTObjectTypeNode(parentNode)) {
this.addError('Invalid @requires directive: fields argument must be a valid GraphQL selection set', fieldSet.loc);
return;
}

// Get the object type from the field set. This is the parent type name of the current field.
Comment thread
endigma marked this conversation as resolved.
Outdated
const visitor = new SelectionSetValidationVisitor(
documentNode,
this.parsedSchema.getType(parentNode.name.value) as GraphQLObjectType,
this.parsedSchema,
false,
);
Comment thread
Noroth marked this conversation as resolved.
Outdated

visitor.visit();

const { errors, warnings } = visitor.getValidationResult();
for (const error of errors) {
this.addError(error, fieldSet.loc);
}

for (const warning of warnings) {
this.addWarning(warning, fieldSet.loc);
}
}

/**
Expand Down Expand Up @@ -572,45 +637,6 @@ export class SDLValidationVisitor {
return !Array.isArray(node) && 'kind' in node && node.kind === Kind.OBJECT_TYPE_DEFINITION;
}

/**
* Enable or disable a specific validation rule by name
* @param ruleName - The name of the rule to configure
* @param enabled - Whether the rule should be enabled
* @returns true if the rule was found and configured, false otherwise
*/
public configureRule(ruleName: string, enabled: boolean): boolean {
const rule = this.lintingRules.find((gate) => gate.name === ruleName);
if (rule) {
rule.enabled = enabled;
return true;
}
return false;
}

/**
* Get information about all available validation rules
* @returns Array of rule configurations
*/
public getAvailableRules(): Readonly<LintingRule<any>[]> {
return Object.freeze([...this.lintingRules]);
}

/**
* Check if the validation found any critical errors
* @returns true if errors were found, false otherwise
*/
public hasErrors(): boolean {
return this.validationResult.errors.length > 0;
}

/**
* Check if the validation found any warnings
* @returns true if warnings were found, false otherwise
*/
public hasWarnings(): boolean {
return this.validationResult.warnings.length > 0;
}

/**
* Add a warning to the validation results
* @param message - The warning message
Expand Down
195 changes: 195 additions & 0 deletions protographic/src/selection-set-validation-visitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import {
ASTNode,
ASTVisitor,
DocumentNode,
FieldNode,
GraphQLObjectType,
GraphQLSchema,
InlineFragmentNode,
Kind,
SelectionSetNode,
visit,
print,
} from 'graphql';
import { VisitContext } from './types.js';
import { ValidationResult } from './sdl-validation-visitor.js';
import { AbstractSelectionRewriter } from './abstract-selection-rewriter.js';

/**
* Validates selection sets within @requires directive field sets.
*
* This visitor traverses a parsed field set document and ensures that inline
* fragments on composite types (interfaces, unions) include `__typename` for
* type discrimination in protobuf. The `__typename` field can appear either
* in the parent field's selection set or within each inline fragment's
* selection set — at least one of these locations must contain it.
*
* Before validation, the selection set is normalized by the
* {@link AbstractSelectionRewriter}, which distributes parent-level fields
* (including `__typename`) into each inline fragment.
*/
export class SelectionSetValidationVisitor {
private currentFieldSelectionSet: SelectionSetNode | undefined;
private fieldSelectionSetStack: SelectionSetNode[] = [];
private readonly operationDocument: DocumentNode;

private readonly schema: GraphQLSchema;
private readonly objectType: GraphQLObjectType;
private readonly fix: boolean = false;
Comment thread
endigma marked this conversation as resolved.
Outdated

private validationResult: ValidationResult = {
errors: [],
warnings: [],
};

/**
* Creates a new SelectionSetValidationVisitor.
*
* @param operationDocument - The parsed GraphQL document representing the field set
* @param objectType - The root GraphQL object type to validate against
* @param schema - The full GraphQL schema, used for normalization of abstract type selections
* @param fix - When true, missing `__typename` fields are added automatically instead of reporting errors
*/
constructor(operationDocument: DocumentNode, objectType: GraphQLObjectType, schema: GraphQLSchema, fix: boolean) {
this.operationDocument = operationDocument;
this.objectType = objectType;
this.schema = schema;
this.fix = fix;

this.normalizeSelectionSet();
}

/**
* Executes the validation by traversing the operation document.
* After calling this method, use `getValidationResult()` to retrieve any errors or warnings.
*/
public visit(): void {
visit(this.operationDocument, this.createASTVisitor());
}

/**
* Returns the validation result containing any errors and warnings found during traversal.
*
* @returns The validation result with errors and warnings arrays
*/
public getValidationResult(): ValidationResult {
return this.validationResult;
}

/**
* Normalizes the parsed field set operation by rewriting abstract selections.
* This ensures consistent handling of interface and union type selections.
*/
private normalizeSelectionSet(): void {
const visitor = new AbstractSelectionRewriter(this.operationDocument, this.schema, this.objectType);
visitor.normalize();
}

/**
* Creates the AST visitor configuration for traversing the document.
*
* @returns An ASTVisitor object with enter/leave handlers for SelectionSet nodes
*/
private createASTVisitor(): ASTVisitor {
return {
SelectionSet: {
enter: (node, key, parent, path, ancestors) => {
return this.onEnterSelectionSet({ node, key, parent, path, ancestors });
},
leave: (node, key, parent, path, ancestors) => {
this.onLeaveSelectionSet({ node, key, parent, path, ancestors });
},
},
};
}

/**
* Handles entering a selection set node during traversal.
*
* @param ctx - The visit context containing the selection set node and its parent
*/
private onEnterSelectionSet(ctx: VisitContext<SelectionSetNode>): void {
// When we have no parent, we are at the root of the selection set.
if (!ctx.parent) {
return;
}

// We store the stack for field selection sets. We ignore the root selection set and the inline fragments in the stack
if (this.isFieldNode(ctx.parent)) {
this.currentFieldSelectionSet = ctx.node;
this.fieldSelectionSetStack.push(ctx.node);
return;
}

// We currently only check for inline fragments.
if (!this.isInlineFragment(ctx.parent)) {
return;
}

// either the selection set of the inline fragment or the parent selection set must contain __typename.
if (
!this.selectionSetContainsTypename(ctx.node) &&
!this.selectionSetContainsTypename(this.currentFieldSelectionSet)
) {
if (!this.fix) {
this.validationResult.errors.push(
`Selection set must contain __typename for inline fragment ${ctx.parent.typeCondition?.name.value}`,
);
return;
}

this.ensureTypenameInSelection(ctx.node);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private ensureTypenameInSelection(selectionSet: SelectionSetNode): void {
selectionSet.selections = [
Comment thread
endigma marked this conversation as resolved.
Outdated
{
kind: Kind.FIELD,
name: { kind: Kind.NAME, value: '__typename' },
},
...selectionSet.selections,
];
}

private selectionSetContainsTypename(selectionSet: SelectionSetNode | undefined): boolean {
if (!selectionSet) {
return false;
}

return selectionSet.selections.some(
(selection) => selection.kind === Kind.FIELD && selection.name.value === '__typename',
);
}

/**
* Handles leaving a selection set node during traversal.
* Restores the previous field selection set from the stack when leaving a field's selection set.
*
* @param ctx - The visit context containing the selection set node and its parent
*/
private onLeaveSelectionSet(ctx: VisitContext<SelectionSetNode>): void {
if (!ctx.parent) {
return;
}

if (this.isFieldNode(ctx.parent)) {
this.currentFieldSelectionSet = this.fieldSelectionSetStack.pop();
}
Comment thread
Noroth marked this conversation as resolved.
}

private isInlineFragment(node: ASTNode | readonly ASTNode[]): node is InlineFragmentNode {
if (Array.isArray(node)) {
return false;
}

return (node as ASTNode).kind === Kind.INLINE_FRAGMENT;
}

private isFieldNode(node: ASTNode | ReadonlyArray<ASTNode>): node is FieldNode {
if (Array.isArray(node)) {
return false;
}
return (node as ASTNode).kind === Kind.FIELD;
}
}
Loading
Loading