Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
111 changes: 109 additions & 2 deletions protographic/SDL_PROTO_RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,6 @@ Rules should follow [Proto Best Practices](https://protobuf.dev/best-practices/d
#### Federation Features

- ✗ Federation entity lookups with nested keys
- ✗ Abstract types (interfaces/unions) in @requires field selections
- ✗ Inline fragments in @requires field selections

#### GraphQL Features

Expand Down Expand Up @@ -289,6 +287,115 @@ message RequireProductNameByIdFields {
- Only the selected fields from nested types are included (e.g., `description` and `reviewSummary` from `ProductDetails`, not `id` or `title`)
- Field order in the proto matches the normalized selection order

#### Composite Types in Required Fields (Interfaces & Unions)

When `@requires` references a field whose type is an interface or union, the required field selection must include inline fragments to specify which fields to extract from each concrete type.

##### The `__typename` Requirement

When using inline fragments on an interface or union field in `@requires`, `__typename` must be present in the selection set. This is required for the engine to determine which concrete type to deserialize at runtime. Validation produces errors with field paths (e.g., `in "pet.friend"`) for nested selections missing `__typename`.

Example:

```graphql
@requires(fields: "primaryItem { __typename ... on PalletItem { name } ... on ContainerItem { name } }")
```

##### Selection Normalization

Before proto generation, selections are normalized by distributing parent-level fields into each inline fragment. For example:

```graphql
media { id ... on Book { author } ... on Movie { director } }
```

Normalizes to:

```graphql
media { ... on Book { id author } ... on Movie { id director } }
```

This ensures each fragment is self-contained. Nested inline fragments on sub-interfaces are also flattened to concrete types. For example, given `Employee` (interface) with implementors `Manager`, `Engineer`, `Contractor`, and `Intern` — where `Engineer` and `Contractor` also implement `Managed`:

```graphql
# Before normalization:
members {
id
... on Managed { supervisor }
}

# After normalization — expanded to all concrete types,
# with `supervisor` only on types that implement Managed:
members {
... on Manager { id }
... on Engineer { id supervisor }
... on Contractor { id supervisor }
... on Intern { id }
}
```

##### Proto Mapping — Interface/Union Becomes `oneof`

The interface or union type maps to a message with `oneof instance` containing each concrete type. `__typename` is consumed during validation only — it does **not** appear in the generated proto.

```graphql
type Storage @key(fields: "id") {
id: ID!
primaryItem: StorageItem! @external
itemInfo: String!
@requires(
fields: "primaryItem { __typename ... on PalletItem { name palletCount } ... on ContainerItem { name containerSize } }"
)
}

interface StorageItem {
name: String!
}

type PalletItem implements StorageItem {
name: String!
palletCount: Int!
}

type ContainerItem implements StorageItem {
name: String!
containerSize: String!
}
```

Maps to (Fields message only):

```protobuf
message RequireStorageItemInfoByIdFields {
message PalletItem {
string name = 1;
int32 pallet_count = 2;
}

message ContainerItem {
string name = 1;
string container_size = 2;
}

message StorageItem {
oneof instance {
ContainerItem container_item = 1;
PalletItem pallet_item = 2;
}
}

StorageItem primary_item = 1;
}
```

**Key Points**:

- `__typename` is absent from the proto — it is consumed during validation only
- The interface type becomes a message with `oneof instance`
- Each implementing type gets its own message with only the fields selected in its fragment
- All type messages (`StorageItem`, `PalletItem`, `ContainerItem`) are **nested inside** the `Fields` message. This scoping ensures they don't collide with the root-level messages of the same name, which contain all fields of the type — while the nested versions contain only the subset selected in `@requires`
- The same pattern applies to union types (union member types instead of implementing types)

## Field Resolvers

Field resolvers allow you to define custom resolution logic for specific fields within a GraphQL type. Using the `@connect__fieldResolver` directive, you can specify which fields should be resolved through dedicated RPC methods, enabling lazy loading, computed fields, or integration with external data sources.
Expand Down
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
115 changes: 67 additions & 48 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 @@ -48,8 +53,6 @@ interface LintingRule<K extends keyof KindToNodeTypeMap = keyof KindToNodeTypeMa
name: string;
/** Human-readable description of what this rule validates */
description?: string;
/** Whether this validation rule is currently active */
enabled: boolean;
/** The AST node kind this rule applies to */
nodeKind: K;
/** The validation function to execute for matching nodes */
Expand Down Expand Up @@ -89,6 +92,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 +105,8 @@ export class SDLValidationVisitor {
warnings: [],
};

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

this.initializeLintingRules();
this.visitor = this.createASTVisitor();
}
Expand All @@ -114,36 +120,88 @@ export class SDLValidationVisitor {
const objectTypeRule: LintingRule<Kind.OBJECT_TYPE_DEFINITION> = {
name: 'nested-key-directives',
description: 'Validates that @key directives do not contain nested field selections',
enabled: true,
nodeKind: Kind.OBJECT_TYPE_DEFINITION,
validationFunction: (ctx) => this.validateObjectTypeKeyDirectives(ctx),
};

const listTypeRule: LintingRule<Kind.LIST_TYPE> = {
name: 'nullable-items-in-list-types',
description: 'Validates that list types do not contain nullable items',
enabled: true,
nodeKind: Kind.LIST_TYPE,
validationFunction: (ctx) => this.validateListTypeNullability(ctx),
};

const providesRule: LintingRule<Kind.FIELD_DEFINITION> = {
name: 'use-of-provides',
description: 'Validates usage of @provides directive which is not yet supported',
enabled: true,
nodeKind: Kind.FIELD_DEFINITION,
validationFunction: (ctx) => this.validateProvidesDirective(ctx),
};

const resolverContextRule: LintingRule<Kind.FIELD_DEFINITION> = {
name: 'use-of-invalid-resolver-context',
description: 'Validates whether a resolver context can be extracted from a type',
enabled: true,
nodeKind: Kind.FIELD_DEFINITION,
validationFunction: (ctx) => this.validateInvalidResolverContext(ctx),
};

this.lintingRules = [objectTypeRule, listTypeRule, providesRule, resolverContextRule];
const compositeTypeReflectionRule: LintingRule<Kind.FIELD_DEFINITION> = {
name: 'use-of-typename',
description: 'Validates that __typename is present for composite types in @requires selections',
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;
}

const objectType = this.parsedSchema.getType(parentNode.name.value) as GraphQLObjectType;
if (!objectType) {
this.addError('Invalid @requires directive: parent type not found', fieldSet.loc);
return;
}

const visitor = new SelectionSetValidationVisitor(documentNode, objectType, this.parsedSchema);

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 @@ -213,7 +271,7 @@ export class SDLValidationVisitor {
* @private
*/
private executeValidationRules(ctx: VisitContext<ASTNode>): void {
const applicableRules = this.lintingRules.filter((rule) => rule.nodeKind === ctx.node.kind && rule.enabled);
const applicableRules = this.lintingRules.filter((rule) => rule.nodeKind === ctx.node.kind);

for (const rule of applicableRules) {
// Type assertion is safe here because we've filtered by nodeKind
Expand Down Expand Up @@ -572,45 +630,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
Loading
Loading