diff --git a/protographic/.gitignore b/protographic/.gitignore index a83aa9c340..6f8fed4b9a 100644 --- a/protographic/.gitignore +++ b/protographic/.gitignore @@ -2,4 +2,5 @@ dist out node_modules .env -.eslintcache \ No newline at end of file +.eslintcache +coverage \ No newline at end of file diff --git a/protographic/SDL_PROTO_RULES.md b/protographic/SDL_PROTO_RULES.md index c1b726b77b..f93a678f68 100644 --- a/protographic/SDL_PROTO_RULES.md +++ b/protographic/SDL_PROTO_RULES.md @@ -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 @@ -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. diff --git a/protographic/src/abstract-selection-rewriter.ts b/protographic/src/abstract-selection-rewriter.ts index 675cdbb55d..b8d8577350 100644 --- a/protographic/src/abstract-selection-rewriter.ts +++ b/protographic/src/abstract-selection-rewriter.ts @@ -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. diff --git a/protographic/src/required-fields-visitor.ts b/protographic/src/required-fields-visitor.ts index 893842428f..87901f124a 100644 --- a/protographic/src/required-fields-visitor.ts +++ b/protographic/src/required-fields-visitor.ts @@ -365,7 +365,7 @@ export class RequiredFieldsVisitor { * @throws Error if the field definition is not found on the current type */ private onEnterField(ctx: VisitContext): void { - if (!this.current) { + if (!this.current || ctx.node.name.value === '__typename') { return; } diff --git a/protographic/src/sdl-validation-visitor.ts b/protographic/src/sdl-validation-visitor.ts index 39b2027181..2d417a0bdb 100644 --- a/protographic/src/sdl-validation-visitor.ts +++ b/protographic/src/sdl-validation-visitor.ts @@ -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 @@ -48,8 +53,6 @@ interface LintingRule[] = []; private visitor: ASTVisitor; + private parsedSchema: GraphQLSchema; /** * Creates a new SDL validation visitor for the given GraphQL schema @@ -101,6 +105,8 @@ export class SDLValidationVisitor { warnings: [], }; + this.parsedSchema = buildASTSchema(parse(schema), { assumeValid: true, assumeValidSDL: true }); + this.initializeLintingRules(); this.visitor = this.createASTVisitor(); } @@ -114,7 +120,6 @@ export class SDLValidationVisitor { const objectTypeRule: LintingRule = { 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), }; @@ -122,7 +127,6 @@ export class SDLValidationVisitor { const listTypeRule: LintingRule = { 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), }; @@ -130,7 +134,6 @@ export class SDLValidationVisitor { const providesRule: LintingRule = { 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), }; @@ -138,12 +141,67 @@ export class SDLValidationVisitor { const resolverContextRule: LintingRule = { 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 = { + 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): 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); + } } /** @@ -213,7 +271,7 @@ export class SDLValidationVisitor { * @private */ private executeValidationRules(ctx: VisitContext): 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 @@ -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[]> { - 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 diff --git a/protographic/src/selection-set-validation-visitor.ts b/protographic/src/selection-set-validation-visitor.ts new file mode 100644 index 0000000000..8633f15bb3 --- /dev/null +++ b/protographic/src/selection-set-validation-visitor.ts @@ -0,0 +1,189 @@ +import { + ASTNode, + ASTVisitor, + DocumentNode, + FieldNode, + GraphQLObjectType, + GraphQLSchema, + InlineFragmentNode, + Kind, + SelectionSetNode, + visit, +} 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 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 + */ + constructor(operationDocument: DocumentNode, objectType: GraphQLObjectType, schema: GraphQLSchema) { + this.operationDocument = operationDocument; + this.objectType = objectType; + this.schema = schema; + + 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): void { + // When we have no parent, we are at the root of the selection set. + if (!ctx.parent) { + return; + } + + // We store the ancestor field selection sets on the stack so we can restore them when leaving a nested field. + if (this.isFieldNode(ctx.parent)) { + if (this.currentFieldSelectionSet) { + this.fieldSelectionSetStack.push(this.currentFieldSelectionSet); + } + this.currentFieldSelectionSet = 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) + ) { + const fieldPath = this.getFieldPath(ctx.ancestors); + const pathSuffix = fieldPath ? ` in "${fieldPath}"` : ''; + this.validationResult.errors.push( + `Selection set must contain __typename for inline fragment ${ctx.parent.typeCondition?.name.value}${pathSuffix}`, + ); + } + } + + 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): void { + if (!ctx.parent) { + return; + } + + if (this.isFieldNode(ctx.parent)) { + this.currentFieldSelectionSet = this.fieldSelectionSetStack.pop(); + } + } + + private getFieldPath(ancestors: ReadonlyArray> | undefined): string { + if (!ancestors) { + return ''; + } + return ancestors + .filter((a) => this.isFieldNode(a)) + .map((f) => f.name.value) + .join('.'); + } + + 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): node is FieldNode { + if (Array.isArray(node)) { + return false; + } + return (node as ASTNode).kind === Kind.FIELD; + } +} diff --git a/protographic/tests/abstract-selection-rewriter/02-advanced.test.ts b/protographic/tests/abstract-selection-rewriter/02-advanced.test.ts index b04b3a4d43..43ad4ad8be 100644 --- a/protographic/tests/abstract-selection-rewriter/02-advanced.test.ts +++ b/protographic/tests/abstract-selection-rewriter/02-advanced.test.ts @@ -1622,4 +1622,43 @@ describe('AbstractSelectionRewriter - Advanced Cases', () => { `); }); }); + + describe('__typename in selection sets', () => { + it('should distribute __typename into inline fragments on interface fields', () => { + const input = ` + departments { + members { + __typename + ... on Manager { level } + ... on Engineer { specialty } + } + } + `; + + const result = normalizeFieldSet(input, 'Query'); + + expect(result).toMatchInlineSnapshot(` + "{ + departments { + members { + ... on Contractor { + __typename + } + ... on Intern { + __typename + } + ... on Manager { + __typename + level + } + ... on Engineer { + __typename + specialty + } + } + } + }" + `); + }); + }); }); diff --git a/protographic/tests/sdl-to-proto/04-federation.test.ts b/protographic/tests/sdl-to-proto/04-federation.test.ts index 70b612f6cc..96a95bbe4d 100644 --- a/protographic/tests/sdl-to-proto/04-federation.test.ts +++ b/protographic/tests/sdl-to-proto/04-federation.test.ts @@ -1418,4 +1418,43 @@ describe('SDL to Proto - Federation and Special Types', () => { }" `); }); + test('should generate rpc method for required field with inline fragments and __typename', () => { + const sdl = ` + 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! + } + `; + + const { proto: protoText } = compileGraphQLToProto(sdl); + + // Validate Proto definition + expectValidProto(protoText); + + // Should generate a Require RPC with oneof for the interface type + // __typename should be skipped in the proto generation + expect(protoText).toContain('rpc RequireStorageItemInfoById'); + expect(protoText).toContain('RequireStorageItemInfoByIdFields'); + expect(protoText).toContain('oneof instance'); + expect(protoText).toContain('PalletItem'); + expect(protoText).toContain('ContainerItem'); + // __typename should NOT appear in proto + expect(protoText).not.toContain('__typename'); + expect(protoText).not.toContain('typename'); + }); }); diff --git a/protographic/tests/sdl-validation/02-required-validation.test.ts b/protographic/tests/sdl-validation/02-required-validation.test.ts new file mode 100644 index 0000000000..4d14ba0893 --- /dev/null +++ b/protographic/tests/sdl-validation/02-required-validation.test.ts @@ -0,0 +1,363 @@ +import { describe, expect, test } from 'vitest'; +import { SDLValidationVisitor } from '../../src/sdl-validation-visitor.js'; + +function buildSdl(requiresFields: string): string { + return ` + type Query { + user(id: ID!): User! + } + + type User @key(fields: "id") { + id: ID! + pet: Animal! @external + details: Details! @requires(fields: "${requiresFields}") + } + + interface Animal { + name: String! + } + + type Cat implements Animal { + name: String! + catBreed: String! + } + + type Dog implements Animal { + name: String! + dogBreed: String! + } + + type Details { + firstName: String! + lastName: String! + } + `; +} + +function buildUnionSdl(requiresFields: string): string { + return ` + type Query { + user(id: ID!): User! + } + + type User @key(fields: "id") { + id: ID! + result: SearchResult! @external + details: Details! @requires(fields: "${requiresFields}") + } + + union SearchResult = Product | Article + + type Product { + sku: String! + price: Float! + } + + type Article { + title: String! + body: String! + } + + type Details { + firstName: String! + lastName: String! + } + `; +} + +describe('Validation of @requires directive', () => { + test('should validate a schema with a required field', () => { + const sdl = ` + type Query { + user(id: ID!): User! + } + + type User @key(fields: "id") { + id: ID! + name: String! @external + age: Int! @requires(fields: "name") + } + `; + + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + test('should return an error if the selection set does not contain __typename for an inline fragment', () => { + const sdl = ` + type Query { + user(id: ID!): User! + } + + type User @key(fields: "id") { + id: ID! + pet: Animal! @external + details: Details! @requires(fields: "pet { ... on Cat { name catBreed } ... on Dog { name dogBreed } }") + } + + interface Animal { + name: String! + } + + type Cat implements Animal { + name: String! + catBreed: String! + } + + type Dog implements Animal { + name: String! + dogBreed: String! + } + + type Details { + firstName: String! + lastName: String! + } + `; + + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(2); + expect(result.warnings).toHaveLength(0); + }); + + describe('__typename validation for inline fragments', () => { + test('__typename in parent field, missing in all fragments — no errors', () => { + const sdl = buildSdl('pet { __typename ... on Cat { name } ... on Dog { name } }'); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(0); + }); + + test('__typename in each fragment, missing in parent — no errors', () => { + const sdl = buildSdl('pet { ... on Cat { __typename name } ... on Dog { __typename name } }'); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(0); + }); + + test('__typename in both parent and fragments — no errors', () => { + const sdl = buildSdl('pet { __typename ... on Cat { __typename name } ... on Dog { __typename name } }'); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(0); + }); + + test('__typename missing everywhere — 2 errors', () => { + const sdl = buildSdl('pet { ... on Cat { name } ... on Dog { name } }'); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(2); + }); + + test('__typename in parent, one fragment also has it — no errors', () => { + const sdl = buildSdl('pet { __typename ... on Cat { __typename name } ... on Dog { name } }'); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(0); + }); + + test('__typename only in one fragment, no parent — 1 error for Dog', () => { + const sdl = buildSdl('pet { ... on Cat { __typename name } ... on Dog { name } }'); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('Dog'); + expect(result.errors[0]).toContain('in "pet"'); + }); + + test('single fragment missing __typename, no parent — 1 error', () => { + const sdl = buildSdl('pet { ... on Cat { name } }'); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(1); + }); + }); + + describe('nested inline fragment __typename validation', () => { + const nestedSdl = ` + type Query { + user(id: ID!): User! + } + + type User @key(fields: "id") { + id: ID! + pet: Animal! @external + details: Details! @requires(fields: "PLACEHOLDER") + } + + interface Animal { + name: String! + friend: Animal! + } + + type Cat implements Animal { + name: String! + friend: Animal! + catBreed: String! + } + + type Dog implements Animal { + name: String! + friend: Animal! + dogBreed: String! + } + + type Details { + firstName: String! + lastName: String! + } + `; + + function buildNestedSdl(requiresFields: string): string { + return nestedSdl.replace('PLACEHOLDER', requiresFields); + } + + test('nested fragments with __typename at both levels — no errors', () => { + const sdl = buildNestedSdl( + 'pet { __typename ... on Cat { name friend { __typename ... on Dog { name } ... on Cat { name } } } ... on Dog { name } }', + ); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(0); + }); + + test('nested fragments missing __typename at inner level — errors for inner fragments', () => { + const sdl = buildNestedSdl( + 'pet { __typename ... on Cat { name friend { ... on Dog { name } ... on Cat { name } } } ... on Dog { name } }', + ); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(2); + expect(result.errors[0]).toContain('in "pet.friend"'); + expect(result.errors[1]).toContain('in "pet.friend"'); + }); + + test('nested fragments with __typename in inner parent field — no errors', () => { + const sdl = buildNestedSdl( + 'pet { __typename ... on Cat { name friend { __typename ... on Dog { name } } } ... on Dog { name } }', + ); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(0); + }); + + test('sibling inline fragment after nested field uses parent __typename, not inner field', () => { + // __typename only on pet (outer), friend has no __typename. + // After leaving friend, currentFieldSelectionSet must restore to pet so Dog (sibling) passes. + const sdl = buildNestedSdl( + 'pet { __typename ... on Cat { name friend { __typename ... on Dog { name } } } ... on Dog { name } }', + ); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(0); + }); + + test('sibling inline fragment after nested field fails when neither level has __typename', () => { + // No __typename anywhere — inner fragments (2) and both outer fragments (2) should all fail + const sdl = buildNestedSdl( + 'pet { ... on Cat { name friend { ... on Dog { name } ... on Cat { name } } } ... on Dog { name } }', + ); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(4); + const petErrors = result.errors.filter((e) => e.includes('in "pet"') && !e.includes('in "pet.friend"')); + const petFriendErrors = result.errors.filter((e) => e.includes('in "pet.friend"')); + expect(petErrors).toHaveLength(2); + expect(petFriendErrors).toHaveLength(2); + }); + + test('triple nesting — path should be "pet.friend.friend"', () => { + const sdl = buildNestedSdl( + 'pet { __typename ... on Cat { name friend { __typename ... on Dog { name friend { ... on Cat { name } } } } } ... on Dog { name } }', + ); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('in "pet.friend.friend"'); + }); + + test('fragments at different nesting levels produce distinct paths', () => { + // __typename missing at both levels + const sdl = buildNestedSdl('pet { ... on Cat { name friend { ... on Dog { name } } } ... on Dog { name } }'); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + const petErrors = result.errors.filter((e) => e.includes('in "pet"') && !e.includes('in "pet.friend"')); + const petFriendErrors = result.errors.filter((e) => e.includes('in "pet.friend"')); + expect(petErrors).toHaveLength(2); + expect(petFriendErrors).toHaveLength(1); + }); + }); + + describe('union type __typename validation', () => { + test('__typename in parent field, missing in all fragments — no errors', () => { + const sdl = buildUnionSdl('result { __typename ... on Product { sku } ... on Article { title } }'); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(0); + }); + + test('__typename in each fragment, missing in parent — no errors', () => { + const sdl = buildUnionSdl('result { ... on Product { __typename sku } ... on Article { __typename title } }'); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(0); + }); + + test('__typename missing everywhere — 2 errors', () => { + const sdl = buildUnionSdl('result { ... on Product { sku } ... on Article { title } }'); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(2); + }); + + test('__typename only in one fragment — 1 error for Article', () => { + const sdl = buildUnionSdl('result { ... on Product { __typename sku } ... on Article { title } }'); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('Article'); + expect(result.errors[0]).toContain('in "result"'); + }); + + test('__typename in both parent and fragments — no errors', () => { + const sdl = buildUnionSdl( + 'result { __typename ... on Product { __typename sku } ... on Article { __typename title } }', + ); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(0); + }); + + test('single union member fragment missing __typename — 1 error', () => { + const sdl = buildUnionSdl('result { ... on Product { sku } }'); + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('Product'); + }); + }); +}); diff --git a/protographic/tests/sdl-validation/03-sdl-validation-visitor.test.ts b/protographic/tests/sdl-validation/03-sdl-validation-visitor.test.ts new file mode 100644 index 0000000000..00c6f82d64 --- /dev/null +++ b/protographic/tests/sdl-validation/03-sdl-validation-visitor.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from 'vitest'; +import { SDLValidationVisitor } from '../../src/sdl-validation-visitor.js'; + +describe('SDLValidationVisitor public API', () => { + const cleanSdl = ` + type Query { + hello: String! + } + `; + + describe('error message formatting', () => { + test('errors include [Error] prefix and location info', () => { + const sdl = ` + type Query { + user: User! + } + type User @key(fields: "id nested { name }") { + id: ID! + nested: Nested! + } + type Nested { + name: String! + } + `; + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0]).toMatch(/^\[Error]/); + expect(result.errors[0]).toMatch(/at line \d+, column \d+/); + }); + + test('warnings include [Warning] prefix and location info', () => { + const sdl = ` + type Query { + items: [String]! + } + `; + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings[0]).toMatch(/^\[Warning]/); + expect(result.warnings[0]).toMatch(/at line \d+, column \d+/); + }); + }); + + describe('validateCompositeTypeReflection edge cases', () => { + test('@requires with unparseable field set produces error', () => { + const sdl = ` + type Query { + user(id: ID!): User! + } + type User @key(fields: "id") { + id: ID! + name: String! @external + details: String! @requires(fields: "{ invalid [") + } + `; + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.some((e) => e.includes('Invalid @requires directive'))).toBe(true); + }); + + test('multiple @requires on different fields in the same type validates each independently', () => { + const sdl = ` + type Query { + user(id: ID!): User! + } + type User @key(fields: "id") { + id: ID! + pet: Animal! @external + companion: Animal! @external + petName: String! @requires(fields: "pet { __typename ... on Cat { name } }") + companionName: String! @requires(fields: "companion { __typename ... on Dog { breed } }") + } + interface Animal { + name: String! + } + type Cat implements Animal { + name: String! + } + type Dog implements Animal { + name: String! + breed: String! + } + `; + const visitor = new SDLValidationVisitor(sdl); + const result = visitor.visit(); + + // Both @requires are valid — __typename is present + expect(result.errors).toHaveLength(0); + }); + }); + + describe('schema parse error handling', () => { + test('completely invalid GraphQL throws during construction', () => { + expect(() => new SDLValidationVisitor('this is not graphql at all!!!')).toThrow(); + }); + + test('visit() wraps parse errors as TypeError', () => { + // Construct with valid SDL, then corrupt to trigger visit() error path + const visitor = new SDLValidationVisitor(cleanSdl); + // Override the schema to force a parse failure in visit() + (visitor as any).schema = '!!!invalid!!!'; + expect(() => visitor.visit()).toThrow(TypeError); + }); + }); +}); diff --git a/protographic/vite.config.ts b/protographic/vite.config.ts index a562146505..3dea5f77c7 100644 --- a/protographic/vite.config.ts +++ b/protographic/vite.config.ts @@ -4,5 +4,10 @@ export default defineConfig({ test: { // Ensure always the CJS version is used otherwise we might conflict with multiple versions of graphql alias: [{ find: /^graphql$/, replacement: 'graphql/index.js' }], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**/*.ts'], + }, }, });