From 0effdb4f1497ee6c618ef3d7bdeab4b6122c0e68 Mon Sep 17 00:00:00 2001 From: Alex Bradley Date: Mon, 3 Nov 2025 08:58:53 -0500 Subject: [PATCH 1/7] detect extra fields in fixtures --- src/methods/validate-fixture-input.ts | 51 ++++++++++- test/fixtures/test-schema.graphql | 1 + test/methods/validate-fixture-input.test.ts | 97 ++++++++++++++++++--- test/methods/validate-test-assets.test.ts | 13 ++- 4 files changed, 138 insertions(+), 24 deletions(-) diff --git a/src/methods/validate-fixture-input.ts b/src/methods/validate-fixture-input.ts index 79e74a5..970805a 100644 --- a/src/methods/validate-fixture-input.ts +++ b/src/methods/validate-fixture-input.ts @@ -54,6 +54,7 @@ export function validateFixtureInput( new Set([schema.getQueryType()!.name]), ]; const typenameResponseKeyStack: (string | undefined)[] = []; + const expectedFieldsStack: Set[] = [new Set()]; const errors: string[] = []; @@ -89,6 +90,7 @@ export function validateFixtureInput( const nestedValues = []; const responseKey = node.alias?.value || node.name.value; + expectedFieldsStack[expectedFieldsStack.length - 1].add(responseKey); const fieldDefinition = typeInfo.getFieldDef(); if (fieldDefinition === undefined || fieldDefinition === null) { @@ -210,11 +212,23 @@ export function validateFixtureInput( possibleTypesStack.push(new Set(possibleTypes)); typeStack.push(getNamedType(fieldType)); + // If this field has nested selections, prepare to track expected child fields + if (node.selectionSet) { + expectedFieldsStack.push(new Set()); + } + valueStack.push(nestedValues); return undefined; }, - leave() { - valueStack.pop(); + leave(node) { + const nestedValues = valueStack.pop()!; + + // If this field had nested selections, check for extra fields + if (node.selectionSet) { + const expectedFields = expectedFieldsStack.pop()!; + errors.push(...checkForExtraFields(nestedValues, expectedFields)); + } + typeStack.pop(); possibleTypesStack.pop(); }, @@ -274,6 +288,11 @@ export function validateFixtureInput( }, }), ); + + // The query's root SelectionSet has no parent Field node, so there's no Field.leave event to check it. + // We manually perform the same check here that would happen in Field.leave for nested objects. + errors.push(...checkForExtraFields(valueStack[0], expectedFieldsStack[0])); + return { errors }; } @@ -401,3 +420,31 @@ function isValueExpectedForType( return possibleTypes.has(valueTypename); } + +/** + * Checks fixture objects for fields that are not present in the GraphQL query. + * + * @param fixtureObjects - Array of fixture objects to validate + * @param expectedFields - Set of field names that are expected based on the query + * @returns Array of error messages for any extra fields found (empty if valid) + * + * @remarks + * Only validates object types - skips null values and arrays. + */ +function checkForExtraFields( + fixtureObjects: any[], + expectedFields: Set +): string[] { + const errors: string[] = []; + for (const fixtureObject of fixtureObjects) { + if (typeof fixtureObject === "object" && fixtureObject !== null && !Array.isArray(fixtureObject)) { + const fixtureFields = Object.keys(fixtureObject); + for (const fixtureField of fixtureFields) { + if (!expectedFields.has(fixtureField)) { + errors.push(`Extra field "${fixtureField}" found in fixture data not in query`); + } + } + } + } + return errors; +} diff --git a/test/fixtures/test-schema.graphql b/test/fixtures/test-schema.graphql index 7f1c6e3..4c576f6 100644 --- a/test/fixtures/test-schema.graphql +++ b/test/fixtures/test-schema.graphql @@ -5,6 +5,7 @@ schema { type Query { data: DataContainer + version: String } type DataContainer { diff --git a/test/methods/validate-fixture-input.test.ts b/test/methods/validate-fixture-input.test.ts index 8d09e5c..e5920f9 100644 --- a/test/methods/validate-fixture-input.test.ts +++ b/test/methods/validate-fixture-input.test.ts @@ -77,16 +77,12 @@ describe("validateFixtureInput", () => { firstItems: [ { id: "gid://test/Item/1", - count: 5, - details: { - name: "First Item", - }, - }, + count: 5 + } ], secondItems: [ { id: "gid://test/Item/1", - count: 5, details: { name: "First Item", }, @@ -1233,11 +1229,7 @@ describe("validateFixtureInput", () => { ); }); - // This test is skipped because the validator doesn't yet detect extra fields - // in fixture data that aren't present in the query. Currently, it only validates - // that all required fields from the query are present in the fixture, but doesn't - // flag additional fields that shouldn't be there. - it.skip("detects extra fields not in query", () => { + it("detects extra fields not in query", () => { const queryAST = parse(` query Query { data { @@ -1261,10 +1253,87 @@ describe("validateFixtureInput", () => { const result = validateFixtureInput(queryAST, schema, fixtureInput); - // When implemented, should detect that 'count' is not in the query + // Should detect that 'count' is not in the query + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toBe('Extra field "count" found in fixture data not in query'); + }); + + it("detects extra fields with multiple aliases for the same field", () => { + const queryAST = parse(` + query Query { + data { + firstItems: items { + id + count + } + secondItems: items { + id + details { + name + } + } + } + } + `); + + const fixtureInput = { + data: { + firstItems: [ + { + id: "gid://test/Item/1", + count: 5, + details: { + name: "First Item" + } + } + ], + secondItems: [ + { + id: "gid://test/Item/1", + count: 5, + details: { + name: "First Item" + } + } + ] + } + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // Each alias is validated independently, so extra fields in each should be detected + expect(result.errors).toHaveLength(2); + expect(result.errors[0]).toBe('Extra field "details" found in fixture data not in query'); + expect(result.errors[1]).toBe('Extra field "count" found in fixture data not in query'); + }); + + it("detects extra fields at root level", () => { + const queryAST = parse(` + query Query { + data { + items { + id + } + } + } + `); + + const fixtureInput = { + data: { + items: [ + { + id: "gid://test/Item/1" + } + ] + }, + version: "1.0.0" // Real field from schema, but not selected in query + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // Should detect the version field since it wasn't selected in the query expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toContain("count"); - expect(result.errors[0]).toContain("not in query"); + expect(result.errors[0]).toBe('Extra field "version" found in fixture data not in query'); }); it("detects type mismatches (object vs scalar)", () => { diff --git a/test/methods/validate-test-assets.test.ts b/test/methods/validate-test-assets.test.ts index 3c9f4dd..9802c5f 100644 --- a/test/methods/validate-test-assets.test.ts +++ b/test/methods/validate-test-assets.test.ts @@ -157,14 +157,11 @@ describe("validateTestAssets", () => { expect(result.inputQuery.errors).toHaveLength(0); - // Input fixture should be invalid due to missing fields - expect(result.inputFixture.errors.length).toBe(2); - expect(result.inputFixture.errors[0]).toBe( - "Missing expected fixture data for details", - ); - expect(result.inputFixture.errors[1]).toBe( - "Missing expected fixture data for metadata", - ); + // Input fixture should be invalid due to missing fields and extra field + expect(result.inputFixture.errors.length).toBe(3); + expect(result.inputFixture.errors[0]).toBe('Missing expected fixture data for details'); + expect(result.inputFixture.errors[1]).toBe('Extra field "invalidField" found in fixture data not in query'); + expect(result.inputFixture.errors[2]).toBe('Missing expected fixture data for metadata'); expect(result.outputFixture.errors).toHaveLength(0); }); From d0786854790904020e446ddfc76a1f515e735d3f Mon Sep 17 00:00:00 2001 From: Alex Bradley Date: Tue, 4 Nov 2025 10:33:45 -0500 Subject: [PATCH 2/7] refactor expectedFields handling --- src/methods/validate-fixture-input.ts | 137 +++++-- test/methods/validate-fixture-input.test.ts | 413 ++++++++++++++++++++ 2 files changed, 516 insertions(+), 34 deletions(-) diff --git a/src/methods/validate-fixture-input.ts b/src/methods/validate-fixture-input.ts index 970805a..0c07a3e 100644 --- a/src/methods/validate-fixture-input.ts +++ b/src/methods/validate-fixture-input.ts @@ -27,6 +27,16 @@ export interface ValidateFixtureInputResult { errors: string[]; } +/** + * Tracks expected fields at a selection set level, distinguishing between: + * - common fields (selected outside inline fragments) + * - type-specific fields (selected inside inline fragments) + */ +interface ExpectedFields { + common: Set; // Fields that should be present on all objects + byType: Map, possibleTypes: Set }>; // Fields specific to inline fragment types +} + /** * Validates that fixture input data matches the structure and types defined in a GraphQL query. * @@ -54,7 +64,8 @@ export function validateFixtureInput( new Set([schema.getQueryType()!.name]), ]; const typenameResponseKeyStack: (string | undefined)[] = []; - const expectedFieldsStack: Set[] = [new Set()]; + const expectedFieldsStack: ExpectedFields[] = [{ common: new Set(), byType: new Map() }]; + const typeConditionStack: (GraphQLNamedType | null)[] = [null]; const errors: string[] = []; @@ -63,25 +74,26 @@ export function validateFixtureInput( visitWithTypeInfo(typeInfo, { InlineFragment: { enter(node) { - let possibleTypes = new Set( - possibleTypesStack[possibleTypesStack.length - 1], - ); + let possibleTypes = new Set(possibleTypesStack[possibleTypesStack.length - 1]); + let namedType: GraphQLNamedType | undefined; + if (node.typeCondition !== null && node.typeCondition !== undefined) { - const namedType = schema.getType(node.typeCondition.name.value); - if (namedType && isAbstractType(namedType)) { - possibleTypes = possibleTypes.intersection( - new Set( - schema.getPossibleTypes(namedType).map((type) => type.name), - ), - ); - } else if (namedType && isObjectType(namedType)) { - possibleTypes = new Set([namedType.name]); + namedType = schema.getType(node.typeCondition.name.value); + + if (namedType) { + if (isAbstractType(namedType)) { + possibleTypes = possibleTypes.intersection(new Set(schema.getPossibleTypes(namedType).map(type => type.name))); + } else if (isObjectType(namedType)) { + possibleTypes = new Set([namedType.name]); + } } } possibleTypesStack.push(possibleTypes); + typeConditionStack.push(namedType ?? null); }, leave() { possibleTypesStack.pop(); + typeConditionStack.pop(); }, }, Field: { @@ -90,7 +102,25 @@ export function validateFixtureInput( const nestedValues = []; const responseKey = node.alias?.value || node.name.value; - expectedFieldsStack[expectedFieldsStack.length - 1].add(responseKey); + + // Track this field in the appropriate set based on whether we're in an inline fragment + const currentExpectedFields = expectedFieldsStack[expectedFieldsStack.length - 1]; + const currentFragmentType = typeConditionStack[typeConditionStack.length - 1]; + + if (currentFragmentType) { + // Inside an inline fragment - add to type-specific set + if (!currentExpectedFields.byType.has(currentFragmentType)) { + const fragmentPossibleTypes = possibleTypesStack[possibleTypesStack.length - 1]; + currentExpectedFields.byType.set(currentFragmentType, { + fields: new Set(), + possibleTypes: fragmentPossibleTypes + }); + } + currentExpectedFields.byType.get(currentFragmentType)!.fields.add(responseKey); + } else { + // Outside inline fragments - add to common fields + currentExpectedFields.common.add(responseKey); + } const fieldDefinition = typeInfo.getFieldDef(); if (fieldDefinition === undefined || fieldDefinition === null) { @@ -212,29 +242,23 @@ export function validateFixtureInput( possibleTypesStack.push(new Set(possibleTypes)); typeStack.push(getNamedType(fieldType)); - // If this field has nested selections, prepare to track expected child fields - if (node.selectionSet) { - expectedFieldsStack.push(new Set()); - } - valueStack.push(nestedValues); return undefined; }, - leave(node) { - const nestedValues = valueStack.pop()!; - - // If this field had nested selections, check for extra fields - if (node.selectionSet) { - const expectedFields = expectedFieldsStack.pop()!; - errors.push(...checkForExtraFields(nestedValues, expectedFields)); - } - + leave() { + valueStack.pop(); typeStack.pop(); possibleTypesStack.pop(); }, }, SelectionSet: { enter(node, _key, parent) { + // If this SelectionSet belongs to a Field, prepare to track expected fields + if (parent && 'kind' in parent && parent.kind === Kind.FIELD) { + expectedFieldsStack.push({ common: new Set(), byType: new Map() }); + typeConditionStack.push(null); + } + // Look ahead to find __typename field and track its response key const typenameField = node.selections.find( (selection) => @@ -282,7 +306,17 @@ export function validateFixtureInput( } return undefined; }, - leave() { + leave(_node, _key, parent) { + // If this SelectionSet belongs to a Field, validate for extra fields + if (parent && 'kind' in parent && parent.kind === Kind.FIELD) { + const expectedFields = expectedFieldsStack.pop()!; + const nestedValues = valueStack[valueStack.length - 1]; + const typenameResponseKey = typenameResponseKeyStack[typenameResponseKeyStack.length - 1]; + errors.push(...checkForExtraFields(nestedValues, expectedFields, typenameResponseKey)); + + typeConditionStack.pop(); + } + typenameResponseKeyStack.pop(); }, }, @@ -291,7 +325,8 @@ export function validateFixtureInput( // The query's root SelectionSet has no parent Field node, so there's no Field.leave event to check it. // We manually perform the same check here that would happen in Field.leave for nested objects. - errors.push(...checkForExtraFields(valueStack[0], expectedFieldsStack[0])); + const rootTypenameResponseKey = typenameResponseKeyStack[typenameResponseKeyStack.length - 1]; + errors.push(...checkForExtraFields(valueStack[0], expectedFieldsStack[0], rootTypenameResponseKey)); return { errors }; } @@ -423,28 +458,62 @@ function isValueExpectedForType( /** * Checks fixture objects for fields that are not present in the GraphQL query. + * Supports type discrimination for abstract types (unions/interfaces) using __typename. * * @param fixtureObjects - Array of fixture objects to validate - * @param expectedFields - Set of field names that are expected based on the query + * @param expectedFields - Expected fields structure with common fields and type-specific fields + * @param typenameResponseKey - The response key for the __typename field (supports aliases like `type: __typename`) * @returns Array of error messages for any extra fields found (empty if valid) * * @remarks - * Only validates object types - skips null values and arrays. + * - Only validates object types - skips null values and arrays + * - Uses __typename to determine which inline fragment fields apply to each object + * - Common fields (outside inline fragments) are expected on all objects + * - Type-specific fields (inside inline fragments) are only expected on matching types + * - No schema lookups needed - possible types were pre-computed during traversal */ function checkForExtraFields( fixtureObjects: any[], - expectedFields: Set + expectedFields: ExpectedFields, + typenameResponseKey: string | undefined ): string[] { const errors: string[] = []; + for (const fixtureObject of fixtureObjects) { if (typeof fixtureObject === "object" && fixtureObject !== null && !Array.isArray(fixtureObject)) { const fixtureFields = Object.keys(fixtureObject); + + // Build the set of expected fields for this specific object + const expectedForThisObject = new Set(expectedFields.common); + + const objectTypename = typenameResponseKey ? fixtureObject[typenameResponseKey] : fixtureObject.__typename; + + if (objectTypename) { + // Object has __typename - check which fragment types match + for (const { fields, possibleTypes } of expectedFields.byType.values()) { + if (possibleTypes.has(objectTypename)) { + fields.forEach(field => expectedForThisObject.add(field)); + } + } + } else if (expectedFields.byType.size > 0) { + // No __typename - allow union of all fragment fields + // Without __typename we can't discriminate which fragment applies + // Note: We use > 0 (not === 1) to handle nested fragments (e.g., ... on HasId { ... on HasName { ... }}) + // where byType.size can be > 1. For 2+ sibling fragments (e.g., ... on Item / ... on Metadata) + // without __typename, validation BREAKs early (line 277) to enforce __typename requirement. + expectedFields.byType.forEach(({ fields }) => { + fields.forEach(field => expectedForThisObject.add(field)); + }); + } + + // Check each field in the fixture object for (const fixtureField of fixtureFields) { - if (!expectedFields.has(fixtureField)) { + if (!expectedForThisObject.has(fixtureField)) { errors.push(`Extra field "${fixtureField}" found in fixture data not in query`); } } } } + return errors; } diff --git a/test/methods/validate-fixture-input.test.ts b/test/methods/validate-fixture-input.test.ts index e5920f9..0213f49 100644 --- a/test/methods/validate-fixture-input.test.ts +++ b/test/methods/validate-fixture-input.test.ts @@ -1336,6 +1336,419 @@ describe("validateFixtureInput", () => { expect(result.errors[0]).toBe('Extra field "version" found in fixture data not in query'); }); + it("detects extra fields with complex nesting, typename aliases, and type discrimination", () => { + const queryAST = parse(` + query { + queryType: __typename + data { + searchResults { + resultType: __typename + ... on Item { + id + details { + __typename + name + } + } + ... on Metadata { + email + } + } + interfaceImplementers { + implType: __typename + ... on HasId { + id + } + ... on HasName { + name + } + } + implementersNoType: interfaceImplementers { + ... on HasId { + id + } + } + nested { + nestedType: __typename + ... on NestedOuterA { + id + inner { + innerType: __typename + ... on NestedInnerA { + name + } + ... on NestedInnerB { + value + } + } + } + ... on NestedOuterB { + email + } + } + } + } + `); + + const fixtureInput = { + queryType: "Query", + extraRootField: "should not be here", // Extra at root + data: { + searchResults: [ + { + resultType: "Item", + id: "1", + count: 999, // Extra - count not queried for Item + details: { + __typename: "ItemDetails", + name: "Details", + extraDetailField: "wrong" // Extra at nested level + } + }, + { + resultType: "Metadata", + email: "test@example.com", + phone: "555-1234" // Extra - phone not queried + } + ], + interfaceImplementers: [ + { + implType: "InterfaceImplementer1", + id: "impl1", + name: "First", + description: "extra" // Extra - description not queried + }, + { + implType: "InterfaceImplementer2", + id: "impl2", + name: "Second", + extraField: "also wrong" // Generic extra field + } + ], + implementersNoType: [ + { id: "impl3" }, // Valid - has id from HasId fragment + {} // Empty object - valid without __typename (single fragment, union of all fields) + ], + nested: [ + { + nestedType: "NestedOuterA", + id: "outer1", + email: "cross-contamination", // Extra - email is from NestedOuterB + inner: [ + { + innerType: "NestedInnerA", + name: "Inner", + value: "cross-contamination" // Extra - value is from NestedInnerB + } + ] + }, + { + nestedType: "NestedOuterB", + email: "outer@example.com", + id: "cross-contamination" // Extra - id is from NestedOuterA + } + ] + } + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // Should detect extra fields at all levels with cross-contamination and typename aliases + // Empty object in implementersNoType is valid (single fragment without __typename - union mode) + // Errors appear in post-order traversal (deepest to shallowest): + expect(result.errors).toHaveLength(9); + expect(result.errors[0]).toBe('Extra field "extraDetailField" found in fixture data not in query'); // details (deepest) + expect(result.errors[1]).toBe('Extra field "count" found in fixture data not in query'); // Item + expect(result.errors[2]).toBe('Extra field "phone" found in fixture data not in query'); // Metadata + expect(result.errors[3]).toBe('Extra field "description" found in fixture data not in query'); // InterfaceImplementer1 + expect(result.errors[4]).toBe('Extra field "extraField" found in fixture data not in query'); // InterfaceImplementer2 + expect(result.errors[5]).toBe('Extra field "value" found in fixture data not in query'); // NestedInnerA cross-contamination + expect(result.errors[6]).toBe('Extra field "email" found in fixture data not in query'); // NestedOuterA cross-contamination + expect(result.errors[7]).toBe('Extra field "id" found in fixture data not in query'); // NestedOuterB cross-contamination + expect(result.errors[8]).toBe('Extra field "extraRootField" found in fixture data not in query'); // root (last) + }); + + it("detects extra fields in union types with inline fragments", () => { + const queryAST = parse(` + query { + data { + searchResults { + __typename + ... on Item { + id + } + ... on Metadata { + email + } + } + } + } + `); + + const fixtureInput = { + data: { + searchResults: [ + { + __typename: "Item", + id: "gid://test/Item/1", + count: 5 // Extra field - queried id but not count + }, + { + __typename: "Metadata", + email: "test@example.com", + phone: "555-0001" // Extra field - queried email but not phone + } + ] + } + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // Should detect extra fields in both union members + expect(result.errors).toHaveLength(2); + expect(result.errors[0]).toBe('Extra field "count" found in fixture data not in query'); + expect(result.errors[1]).toBe('Extra field "phone" found in fixture data not in query'); + }); + + it("detects fields from wrong fragment type in unions (cross-contamination)", () => { + const queryAST = parse(` + query { + data { + searchResults { + __typename + ... on Item { + id + count + } + ... on Metadata { + email + phone + } + } + } + } + `); + + const fixtureInput = { + data: { + searchResults: [ + { + __typename: "Item", + id: "gid://test/Item/1", + count: 5, + email: "item@example.com", // email is only in Metadata fragment + phone: "555-1234" // phone is only in Metadata fragment + }, + { + __typename: "Metadata", + email: "metadata@example.com", + phone: "555-5678", + id: "wrong-id", // id is only in Item fragment + count: 10 // count is only in Item fragment + } + ] + } + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // Should detect fields from wrong fragment types + // Item should NOT have email/phone (those are Metadata fields) + // Metadata should NOT have id/count (those are Item fields) + expect(result.errors).toHaveLength(4); + expect(result.errors[0]).toBe('Extra field "email" found in fixture data not in query'); + expect(result.errors[1]).toBe('Extra field "phone" found in fixture data not in query'); + expect(result.errors[2]).toBe('Extra field "id" found in fixture data not in query'); + expect(result.errors[3]).toBe('Extra field "count" found in fixture data not in query'); + }); + + it("detects extra fields in interface fragments with type discrimination", () => { + const queryAST = parse(` + query { + data { + interfaceImplementers { + __typename + ... on HasId { + id + } + ... on HasName { + name + } + ... on HasDescription { + description + } + } + } + } + `); + + const fixtureInput = { + data: { + interfaceImplementers: [ + { + __typename: "InterfaceImplementer1", // Implements HasId, HasName, HasDescription + id: "1", + name: "First", + description: "Desc", + extraField1: "should not be here" // Extra field + }, + { + __typename: "InterfaceImplementer2", // Implements HasId, HasName only + id: "2", + name: "Second", + description: "Wrong!", // Does NOT implement HasDescription + extraField2: "also wrong" + }, + { + __typename: "InterfaceImplementer3", // Implements HasId only + id: "3", + name: "Wrong!", // Does NOT implement HasName + description: "Wrong!", // Does NOT implement HasDescription + extraField3: "also wrong" + } + ] + } + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // Should detect: + // - extraField1 on InterfaceImplementer1 (implements all interfaces, but field not in query) + // - description and extraField2 on InterfaceImplementer2 (doesn't implement HasDescription) + // - name, description, and extraField3 on InterfaceImplementer3 (doesn't implement HasName or HasDescription) + expect(result.errors).toHaveLength(6); + expect(result.errors[0]).toBe('Extra field "extraField1" found in fixture data not in query'); + expect(result.errors[1]).toBe('Extra field "description" found in fixture data not in query'); + expect(result.errors[2]).toBe('Extra field "extraField2" found in fixture data not in query'); + expect(result.errors[3]).toBe('Extra field "name" found in fixture data not in query'); + expect(result.errors[4]).toBe('Extra field "description" found in fixture data not in query'); + expect(result.errors[5]).toBe('Extra field "extraField3" found in fixture data not in query'); + }); + + it("detects extra fields in truly nested inline fragments (fragment within fragment)", () => { + const queryAST = parse(` + query { + data { + nested { + __typename + ... on NestedOuterA { + id + inner { + __typename + ... on NestedInnerA { + name + } + ... on NestedInnerB { + value + } + } + } + ... on NestedOuterB { + email + } + } + } + } + `); + + const fixtureInput = { + data: { + nested: [ + { + __typename: "NestedOuterA", + id: "1", + email: "wrongField", // email is from NestedOuterB, not NestedOuterA + inner: [ + { + __typename: "NestedInnerA", + name: "Inner name", + value: "wrongField" // value is from NestedInnerB, not NestedInnerA + } + ] + }, + { + __typename: "NestedOuterB", + email: "outer@example.com", + id: "wrongField" // id is from NestedOuterA, not NestedOuterB + } + ] + } + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // Should detect: + // - value on NestedInnerA (nested level validated first) + // - email on NestedOuterA (outer level) + // - id on NestedOuterB (outer level) + expect(result.errors).toHaveLength(3); + expect(result.errors[0]).toBe('Extra field "value" found in fixture data not in query'); + expect(result.errors[1]).toBe('Extra field "email" found in fixture data not in query'); + expect(result.errors[2]).toBe('Extra field "id" found in fixture data not in query'); + }); + + it("detects extra fields in nested inline fragments on concrete union types", () => { + const queryAST = parse(` + query { + data { + interfaceImplementers { + __typename + ... on InterfaceImplementer1 { + id + name + } + ... on InterfaceImplementer2 { + id + } + ... on InterfaceImplementer3 { + id + } + } + } + } + `); + + const fixtureInput = { + data: { + interfaceImplementers: [ + { + __typename: "InterfaceImplementer1", + id: "1", + name: "First", + description: "extra field", // Not queried for InterfaceImplementer1 + extraField1: "should not be here" + }, + { + __typename: "InterfaceImplementer2", + id: "2", + name: "Wrong!", // InterfaceImplementer2 fragment only queries id + extraField2: "also wrong" + }, + { + __typename: "InterfaceImplementer3", + id: "3", + name: "Wrong!", // InterfaceImplementer3 fragment only queries id + description: "also wrong" + } + ] + } + }; + + const result = validateFixtureInput(queryAST, schema, fixtureInput); + + // Should detect: + // - description and extraField1 on InterfaceImplementer1 (only id, name queried) + // - name and extraField2 on InterfaceImplementer2 (only id queried) + // - name and description on InterfaceImplementer3 (only id queried) + expect(result.errors).toHaveLength(6); + expect(result.errors[0]).toBe('Extra field "description" found in fixture data not in query'); + expect(result.errors[1]).toBe('Extra field "extraField1" found in fixture data not in query'); + expect(result.errors[2]).toBe('Extra field "name" found in fixture data not in query'); + expect(result.errors[3]).toBe('Extra field "extraField2" found in fixture data not in query'); + expect(result.errors[4]).toBe('Extra field "name" found in fixture data not in query'); + expect(result.errors[5]).toBe('Extra field "description" found in fixture data not in query'); + }); + it("detects type mismatches (object vs scalar)", () => { const queryAST = parse(` query Query { From 6e431d57e1d11820747d9886ec99e70c031d1bf1 Mon Sep 17 00:00:00 2001 From: Alex Bradley Date: Tue, 4 Nov 2025 12:30:43 -0500 Subject: [PATCH 3/7] fix fixture with extra data --- .../tests/fixtures/delivery-valid-fixture.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/test-app/extensions/discount-function-rs/tests/fixtures/delivery-valid-fixture.json b/test-app/extensions/discount-function-rs/tests/fixtures/delivery-valid-fixture.json index 2b2b0a9..5ee44a7 100644 --- a/test-app/extensions/discount-function-rs/tests/fixtures/delivery-valid-fixture.json +++ b/test-app/extensions/discount-function-rs/tests/fixtures/delivery-valid-fixture.json @@ -11,12 +11,7 @@ { "id": "gid://shopify/CartDeliveryGroup/1" } - ], - "cost": { - "subtotalAmount": { - "amount": "100.0" - } - } + ] } }, "output": { From a3385d2dcf375d89eb0ab939f96fc2616b8e4e99 Mon Sep 17 00:00:00 2001 From: Alex Bradley Date: Tue, 4 Nov 2025 12:31:07 -0500 Subject: [PATCH 4/7] lint --- src/methods/validate-fixture-input.ts | 87 ++++-- test/methods/validate-fixture-input.test.ts | 284 ++++++++++++-------- test/methods/validate-test-assets.test.ts | 12 +- 3 files changed, 249 insertions(+), 134 deletions(-) diff --git a/src/methods/validate-fixture-input.ts b/src/methods/validate-fixture-input.ts index 0c07a3e..b0ad89e 100644 --- a/src/methods/validate-fixture-input.ts +++ b/src/methods/validate-fixture-input.ts @@ -33,8 +33,11 @@ export interface ValidateFixtureInputResult { * - type-specific fields (selected inside inline fragments) */ interface ExpectedFields { - common: Set; // Fields that should be present on all objects - byType: Map, possibleTypes: Set }>; // Fields specific to inline fragment types + common: Set; // Fields that should be present on all objects + byType: Map< + GraphQLNamedType, + { fields: Set; possibleTypes: Set } + >; // Fields specific to inline fragment types } /** @@ -64,7 +67,9 @@ export function validateFixtureInput( new Set([schema.getQueryType()!.name]), ]; const typenameResponseKeyStack: (string | undefined)[] = []; - const expectedFieldsStack: ExpectedFields[] = [{ common: new Set(), byType: new Map() }]; + const expectedFieldsStack: ExpectedFields[] = [ + { common: new Set(), byType: new Map() }, + ]; const typeConditionStack: (GraphQLNamedType | null)[] = [null]; const errors: string[] = []; @@ -74,7 +79,9 @@ export function validateFixtureInput( visitWithTypeInfo(typeInfo, { InlineFragment: { enter(node) { - let possibleTypes = new Set(possibleTypesStack[possibleTypesStack.length - 1]); + let possibleTypes = new Set( + possibleTypesStack[possibleTypesStack.length - 1], + ); let namedType: GraphQLNamedType | undefined; if (node.typeCondition !== null && node.typeCondition !== undefined) { @@ -82,7 +89,11 @@ export function validateFixtureInput( if (namedType) { if (isAbstractType(namedType)) { - possibleTypes = possibleTypes.intersection(new Set(schema.getPossibleTypes(namedType).map(type => type.name))); + possibleTypes = possibleTypes.intersection( + new Set( + schema.getPossibleTypes(namedType).map((type) => type.name), + ), + ); } else if (isObjectType(namedType)) { possibleTypes = new Set([namedType.name]); } @@ -104,19 +115,24 @@ export function validateFixtureInput( const responseKey = node.alias?.value || node.name.value; // Track this field in the appropriate set based on whether we're in an inline fragment - const currentExpectedFields = expectedFieldsStack[expectedFieldsStack.length - 1]; - const currentFragmentType = typeConditionStack[typeConditionStack.length - 1]; + const currentExpectedFields = + expectedFieldsStack[expectedFieldsStack.length - 1]; + const currentFragmentType = + typeConditionStack[typeConditionStack.length - 1]; if (currentFragmentType) { // Inside an inline fragment - add to type-specific set if (!currentExpectedFields.byType.has(currentFragmentType)) { - const fragmentPossibleTypes = possibleTypesStack[possibleTypesStack.length - 1]; + const fragmentPossibleTypes = + possibleTypesStack[possibleTypesStack.length - 1]; currentExpectedFields.byType.set(currentFragmentType, { fields: new Set(), - possibleTypes: fragmentPossibleTypes + possibleTypes: fragmentPossibleTypes, }); } - currentExpectedFields.byType.get(currentFragmentType)!.fields.add(responseKey); + currentExpectedFields.byType + .get(currentFragmentType)! + .fields.add(responseKey); } else { // Outside inline fragments - add to common fields currentExpectedFields.common.add(responseKey); @@ -254,7 +270,7 @@ export function validateFixtureInput( SelectionSet: { enter(node, _key, parent) { // If this SelectionSet belongs to a Field, prepare to track expected fields - if (parent && 'kind' in parent && parent.kind === Kind.FIELD) { + if (parent && "kind" in parent && parent.kind === Kind.FIELD) { expectedFieldsStack.push({ common: new Set(), byType: new Map() }); typeConditionStack.push(null); } @@ -308,11 +324,18 @@ export function validateFixtureInput( }, leave(_node, _key, parent) { // If this SelectionSet belongs to a Field, validate for extra fields - if (parent && 'kind' in parent && parent.kind === Kind.FIELD) { + if (parent && "kind" in parent && parent.kind === Kind.FIELD) { const expectedFields = expectedFieldsStack.pop()!; const nestedValues = valueStack[valueStack.length - 1]; - const typenameResponseKey = typenameResponseKeyStack[typenameResponseKeyStack.length - 1]; - errors.push(...checkForExtraFields(nestedValues, expectedFields, typenameResponseKey)); + const typenameResponseKey = + typenameResponseKeyStack[typenameResponseKeyStack.length - 1]; + errors.push( + ...checkForExtraFields( + nestedValues, + expectedFields, + typenameResponseKey, + ), + ); typeConditionStack.pop(); } @@ -325,8 +348,15 @@ export function validateFixtureInput( // The query's root SelectionSet has no parent Field node, so there's no Field.leave event to check it. // We manually perform the same check here that would happen in Field.leave for nested objects. - const rootTypenameResponseKey = typenameResponseKeyStack[typenameResponseKeyStack.length - 1]; - errors.push(...checkForExtraFields(valueStack[0], expectedFieldsStack[0], rootTypenameResponseKey)); + const rootTypenameResponseKey = + typenameResponseKeyStack[typenameResponseKeyStack.length - 1]; + errors.push( + ...checkForExtraFields( + valueStack[0], + expectedFieldsStack[0], + rootTypenameResponseKey, + ), + ); return { errors }; } @@ -475,24 +505,33 @@ function isValueExpectedForType( function checkForExtraFields( fixtureObjects: any[], expectedFields: ExpectedFields, - typenameResponseKey: string | undefined + typenameResponseKey: string | undefined, ): string[] { const errors: string[] = []; for (const fixtureObject of fixtureObjects) { - if (typeof fixtureObject === "object" && fixtureObject !== null && !Array.isArray(fixtureObject)) { + if ( + typeof fixtureObject === "object" && + fixtureObject !== null && + !Array.isArray(fixtureObject) + ) { const fixtureFields = Object.keys(fixtureObject); // Build the set of expected fields for this specific object const expectedForThisObject = new Set(expectedFields.common); - const objectTypename = typenameResponseKey ? fixtureObject[typenameResponseKey] : fixtureObject.__typename; + const objectTypename = typenameResponseKey + ? fixtureObject[typenameResponseKey] + : fixtureObject.__typename; if (objectTypename) { // Object has __typename - check which fragment types match - for (const { fields, possibleTypes } of expectedFields.byType.values()) { + for (const { + fields, + possibleTypes, + } of expectedFields.byType.values()) { if (possibleTypes.has(objectTypename)) { - fields.forEach(field => expectedForThisObject.add(field)); + fields.forEach((field) => expectedForThisObject.add(field)); } } } else if (expectedFields.byType.size > 0) { @@ -502,14 +541,16 @@ function checkForExtraFields( // where byType.size can be > 1. For 2+ sibling fragments (e.g., ... on Item / ... on Metadata) // without __typename, validation BREAKs early (line 277) to enforce __typename requirement. expectedFields.byType.forEach(({ fields }) => { - fields.forEach(field => expectedForThisObject.add(field)); + fields.forEach((field) => expectedForThisObject.add(field)); }); } // Check each field in the fixture object for (const fixtureField of fixtureFields) { if (!expectedForThisObject.has(fixtureField)) { - errors.push(`Extra field "${fixtureField}" found in fixture data not in query`); + errors.push( + `Extra field "${fixtureField}" found in fixture data not in query`, + ); } } } diff --git a/test/methods/validate-fixture-input.test.ts b/test/methods/validate-fixture-input.test.ts index 0213f49..d2313c3 100644 --- a/test/methods/validate-fixture-input.test.ts +++ b/test/methods/validate-fixture-input.test.ts @@ -77,8 +77,8 @@ describe("validateFixtureInput", () => { firstItems: [ { id: "gid://test/Item/1", - count: 5 - } + count: 5, + }, ], secondItems: [ { @@ -1255,7 +1255,9 @@ describe("validateFixtureInput", () => { // Should detect that 'count' is not in the query expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toBe('Extra field "count" found in fixture data not in query'); + expect(result.errors[0]).toBe( + 'Extra field "count" found in fixture data not in query', + ); }); it("detects extra fields with multiple aliases for the same field", () => { @@ -1283,28 +1285,32 @@ describe("validateFixtureInput", () => { id: "gid://test/Item/1", count: 5, details: { - name: "First Item" - } - } + name: "First Item", + }, + }, ], secondItems: [ { id: "gid://test/Item/1", count: 5, details: { - name: "First Item" - } - } - ] - } + name: "First Item", + }, + }, + ], + }, }; const result = validateFixtureInput(queryAST, schema, fixtureInput); // Each alias is validated independently, so extra fields in each should be detected expect(result.errors).toHaveLength(2); - expect(result.errors[0]).toBe('Extra field "details" found in fixture data not in query'); - expect(result.errors[1]).toBe('Extra field "count" found in fixture data not in query'); + expect(result.errors[0]).toBe( + 'Extra field "details" found in fixture data not in query', + ); + expect(result.errors[1]).toBe( + 'Extra field "count" found in fixture data not in query', + ); }); it("detects extra fields at root level", () => { @@ -1322,18 +1328,20 @@ describe("validateFixtureInput", () => { data: { items: [ { - id: "gid://test/Item/1" - } - ] + id: "gid://test/Item/1", + }, + ], }, - version: "1.0.0" // Real field from schema, but not selected in query + version: "1.0.0", // Real field from schema, but not selected in query }; const result = validateFixtureInput(queryAST, schema, fixtureInput); // Should detect the version field since it wasn't selected in the query expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toBe('Extra field "version" found in fixture data not in query'); + expect(result.errors[0]).toBe( + 'Extra field "version" found in fixture data not in query', + ); }); it("detects extra fields with complex nesting, typename aliases, and type discrimination", () => { @@ -1392,63 +1400,63 @@ describe("validateFixtureInput", () => { const fixtureInput = { queryType: "Query", - extraRootField: "should not be here", // Extra at root + extraRootField: "should not be here", // Extra at root data: { searchResults: [ { resultType: "Item", id: "1", - count: 999, // Extra - count not queried for Item + count: 999, // Extra - count not queried for Item details: { __typename: "ItemDetails", name: "Details", - extraDetailField: "wrong" // Extra at nested level - } + extraDetailField: "wrong", // Extra at nested level + }, }, { resultType: "Metadata", email: "test@example.com", - phone: "555-1234" // Extra - phone not queried - } + phone: "555-1234", // Extra - phone not queried + }, ], interfaceImplementers: [ { implType: "InterfaceImplementer1", id: "impl1", name: "First", - description: "extra" // Extra - description not queried + description: "extra", // Extra - description not queried }, { implType: "InterfaceImplementer2", id: "impl2", name: "Second", - extraField: "also wrong" // Generic extra field - } + extraField: "also wrong", // Generic extra field + }, ], implementersNoType: [ - { id: "impl3" }, // Valid - has id from HasId fragment - {} // Empty object - valid without __typename (single fragment, union of all fields) + { id: "impl3" }, // Valid - has id from HasId fragment + {}, // Empty object - valid without __typename (single fragment, union of all fields) ], nested: [ { nestedType: "NestedOuterA", id: "outer1", - email: "cross-contamination", // Extra - email is from NestedOuterB + email: "cross-contamination", // Extra - email is from NestedOuterB inner: [ { innerType: "NestedInnerA", name: "Inner", - value: "cross-contamination" // Extra - value is from NestedInnerB - } - ] + value: "cross-contamination", // Extra - value is from NestedInnerB + }, + ], }, { nestedType: "NestedOuterB", email: "outer@example.com", - id: "cross-contamination" // Extra - id is from NestedOuterA - } - ] - } + id: "cross-contamination", // Extra - id is from NestedOuterA + }, + ], + }, }; const result = validateFixtureInput(queryAST, schema, fixtureInput); @@ -1457,15 +1465,33 @@ describe("validateFixtureInput", () => { // Empty object in implementersNoType is valid (single fragment without __typename - union mode) // Errors appear in post-order traversal (deepest to shallowest): expect(result.errors).toHaveLength(9); - expect(result.errors[0]).toBe('Extra field "extraDetailField" found in fixture data not in query'); // details (deepest) - expect(result.errors[1]).toBe('Extra field "count" found in fixture data not in query'); // Item - expect(result.errors[2]).toBe('Extra field "phone" found in fixture data not in query'); // Metadata - expect(result.errors[3]).toBe('Extra field "description" found in fixture data not in query'); // InterfaceImplementer1 - expect(result.errors[4]).toBe('Extra field "extraField" found in fixture data not in query'); // InterfaceImplementer2 - expect(result.errors[5]).toBe('Extra field "value" found in fixture data not in query'); // NestedInnerA cross-contamination - expect(result.errors[6]).toBe('Extra field "email" found in fixture data not in query'); // NestedOuterA cross-contamination - expect(result.errors[7]).toBe('Extra field "id" found in fixture data not in query'); // NestedOuterB cross-contamination - expect(result.errors[8]).toBe('Extra field "extraRootField" found in fixture data not in query'); // root (last) + expect(result.errors[0]).toBe( + 'Extra field "extraDetailField" found in fixture data not in query', + ); // details (deepest) + expect(result.errors[1]).toBe( + 'Extra field "count" found in fixture data not in query', + ); // Item + expect(result.errors[2]).toBe( + 'Extra field "phone" found in fixture data not in query', + ); // Metadata + expect(result.errors[3]).toBe( + 'Extra field "description" found in fixture data not in query', + ); // InterfaceImplementer1 + expect(result.errors[4]).toBe( + 'Extra field "extraField" found in fixture data not in query', + ); // InterfaceImplementer2 + expect(result.errors[5]).toBe( + 'Extra field "value" found in fixture data not in query', + ); // NestedInnerA cross-contamination + expect(result.errors[6]).toBe( + 'Extra field "email" found in fixture data not in query', + ); // NestedOuterA cross-contamination + expect(result.errors[7]).toBe( + 'Extra field "id" found in fixture data not in query', + ); // NestedOuterB cross-contamination + expect(result.errors[8]).toBe( + 'Extra field "extraRootField" found in fixture data not in query', + ); // root (last) }); it("detects extra fields in union types with inline fragments", () => { @@ -1491,23 +1517,27 @@ describe("validateFixtureInput", () => { { __typename: "Item", id: "gid://test/Item/1", - count: 5 // Extra field - queried id but not count + count: 5, // Extra field - queried id but not count }, { __typename: "Metadata", email: "test@example.com", - phone: "555-0001" // Extra field - queried email but not phone - } - ] - } + phone: "555-0001", // Extra field - queried email but not phone + }, + ], + }, }; const result = validateFixtureInput(queryAST, schema, fixtureInput); // Should detect extra fields in both union members expect(result.errors).toHaveLength(2); - expect(result.errors[0]).toBe('Extra field "count" found in fixture data not in query'); - expect(result.errors[1]).toBe('Extra field "phone" found in fixture data not in query'); + expect(result.errors[0]).toBe( + 'Extra field "count" found in fixture data not in query', + ); + expect(result.errors[1]).toBe( + 'Extra field "phone" found in fixture data not in query', + ); }); it("detects fields from wrong fragment type in unions (cross-contamination)", () => { @@ -1536,18 +1566,18 @@ describe("validateFixtureInput", () => { __typename: "Item", id: "gid://test/Item/1", count: 5, - email: "item@example.com", // email is only in Metadata fragment - phone: "555-1234" // phone is only in Metadata fragment + email: "item@example.com", // email is only in Metadata fragment + phone: "555-1234", // phone is only in Metadata fragment }, { __typename: "Metadata", email: "metadata@example.com", phone: "555-5678", - id: "wrong-id", // id is only in Item fragment - count: 10 // count is only in Item fragment - } - ] - } + id: "wrong-id", // id is only in Item fragment + count: 10, // count is only in Item fragment + }, + ], + }, }; const result = validateFixtureInput(queryAST, schema, fixtureInput); @@ -1556,10 +1586,18 @@ describe("validateFixtureInput", () => { // Item should NOT have email/phone (those are Metadata fields) // Metadata should NOT have id/count (those are Item fields) expect(result.errors).toHaveLength(4); - expect(result.errors[0]).toBe('Extra field "email" found in fixture data not in query'); - expect(result.errors[1]).toBe('Extra field "phone" found in fixture data not in query'); - expect(result.errors[2]).toBe('Extra field "id" found in fixture data not in query'); - expect(result.errors[3]).toBe('Extra field "count" found in fixture data not in query'); + expect(result.errors[0]).toBe( + 'Extra field "email" found in fixture data not in query', + ); + expect(result.errors[1]).toBe( + 'Extra field "phone" found in fixture data not in query', + ); + expect(result.errors[2]).toBe( + 'Extra field "id" found in fixture data not in query', + ); + expect(result.errors[3]).toBe( + 'Extra field "count" found in fixture data not in query', + ); }); it("detects extra fields in interface fragments with type discrimination", () => { @@ -1586,28 +1624,28 @@ describe("validateFixtureInput", () => { data: { interfaceImplementers: [ { - __typename: "InterfaceImplementer1", // Implements HasId, HasName, HasDescription + __typename: "InterfaceImplementer1", // Implements HasId, HasName, HasDescription id: "1", name: "First", description: "Desc", - extraField1: "should not be here" // Extra field + extraField1: "should not be here", // Extra field }, { - __typename: "InterfaceImplementer2", // Implements HasId, HasName only + __typename: "InterfaceImplementer2", // Implements HasId, HasName only id: "2", name: "Second", - description: "Wrong!", // Does NOT implement HasDescription - extraField2: "also wrong" + description: "Wrong!", // Does NOT implement HasDescription + extraField2: "also wrong", }, { - __typename: "InterfaceImplementer3", // Implements HasId only + __typename: "InterfaceImplementer3", // Implements HasId only id: "3", - name: "Wrong!", // Does NOT implement HasName - description: "Wrong!", // Does NOT implement HasDescription - extraField3: "also wrong" - } - ] - } + name: "Wrong!", // Does NOT implement HasName + description: "Wrong!", // Does NOT implement HasDescription + extraField3: "also wrong", + }, + ], + }, }; const result = validateFixtureInput(queryAST, schema, fixtureInput); @@ -1617,12 +1655,24 @@ describe("validateFixtureInput", () => { // - description and extraField2 on InterfaceImplementer2 (doesn't implement HasDescription) // - name, description, and extraField3 on InterfaceImplementer3 (doesn't implement HasName or HasDescription) expect(result.errors).toHaveLength(6); - expect(result.errors[0]).toBe('Extra field "extraField1" found in fixture data not in query'); - expect(result.errors[1]).toBe('Extra field "description" found in fixture data not in query'); - expect(result.errors[2]).toBe('Extra field "extraField2" found in fixture data not in query'); - expect(result.errors[3]).toBe('Extra field "name" found in fixture data not in query'); - expect(result.errors[4]).toBe('Extra field "description" found in fixture data not in query'); - expect(result.errors[5]).toBe('Extra field "extraField3" found in fixture data not in query'); + expect(result.errors[0]).toBe( + 'Extra field "extraField1" found in fixture data not in query', + ); + expect(result.errors[1]).toBe( + 'Extra field "description" found in fixture data not in query', + ); + expect(result.errors[2]).toBe( + 'Extra field "extraField2" found in fixture data not in query', + ); + expect(result.errors[3]).toBe( + 'Extra field "name" found in fixture data not in query', + ); + expect(result.errors[4]).toBe( + 'Extra field "description" found in fixture data not in query', + ); + expect(result.errors[5]).toBe( + 'Extra field "extraField3" found in fixture data not in query', + ); }); it("detects extra fields in truly nested inline fragments (fragment within fragment)", () => { @@ -1657,22 +1707,22 @@ describe("validateFixtureInput", () => { { __typename: "NestedOuterA", id: "1", - email: "wrongField", // email is from NestedOuterB, not NestedOuterA + email: "wrongField", // email is from NestedOuterB, not NestedOuterA inner: [ { __typename: "NestedInnerA", name: "Inner name", - value: "wrongField" // value is from NestedInnerB, not NestedInnerA - } - ] + value: "wrongField", // value is from NestedInnerB, not NestedInnerA + }, + ], }, { __typename: "NestedOuterB", email: "outer@example.com", - id: "wrongField" // id is from NestedOuterA, not NestedOuterB - } - ] - } + id: "wrongField", // id is from NestedOuterA, not NestedOuterB + }, + ], + }, }; const result = validateFixtureInput(queryAST, schema, fixtureInput); @@ -1682,9 +1732,15 @@ describe("validateFixtureInput", () => { // - email on NestedOuterA (outer level) // - id on NestedOuterB (outer level) expect(result.errors).toHaveLength(3); - expect(result.errors[0]).toBe('Extra field "value" found in fixture data not in query'); - expect(result.errors[1]).toBe('Extra field "email" found in fixture data not in query'); - expect(result.errors[2]).toBe('Extra field "id" found in fixture data not in query'); + expect(result.errors[0]).toBe( + 'Extra field "value" found in fixture data not in query', + ); + expect(result.errors[1]).toBe( + 'Extra field "email" found in fixture data not in query', + ); + expect(result.errors[2]).toBe( + 'Extra field "id" found in fixture data not in query', + ); }); it("detects extra fields in nested inline fragments on concrete union types", () => { @@ -1715,23 +1771,23 @@ describe("validateFixtureInput", () => { __typename: "InterfaceImplementer1", id: "1", name: "First", - description: "extra field", // Not queried for InterfaceImplementer1 - extraField1: "should not be here" + description: "extra field", // Not queried for InterfaceImplementer1 + extraField1: "should not be here", }, { __typename: "InterfaceImplementer2", id: "2", - name: "Wrong!", // InterfaceImplementer2 fragment only queries id - extraField2: "also wrong" + name: "Wrong!", // InterfaceImplementer2 fragment only queries id + extraField2: "also wrong", }, { __typename: "InterfaceImplementer3", id: "3", - name: "Wrong!", // InterfaceImplementer3 fragment only queries id - description: "also wrong" - } - ] - } + name: "Wrong!", // InterfaceImplementer3 fragment only queries id + description: "also wrong", + }, + ], + }, }; const result = validateFixtureInput(queryAST, schema, fixtureInput); @@ -1741,12 +1797,24 @@ describe("validateFixtureInput", () => { // - name and extraField2 on InterfaceImplementer2 (only id queried) // - name and description on InterfaceImplementer3 (only id queried) expect(result.errors).toHaveLength(6); - expect(result.errors[0]).toBe('Extra field "description" found in fixture data not in query'); - expect(result.errors[1]).toBe('Extra field "extraField1" found in fixture data not in query'); - expect(result.errors[2]).toBe('Extra field "name" found in fixture data not in query'); - expect(result.errors[3]).toBe('Extra field "extraField2" found in fixture data not in query'); - expect(result.errors[4]).toBe('Extra field "name" found in fixture data not in query'); - expect(result.errors[5]).toBe('Extra field "description" found in fixture data not in query'); + expect(result.errors[0]).toBe( + 'Extra field "description" found in fixture data not in query', + ); + expect(result.errors[1]).toBe( + 'Extra field "extraField1" found in fixture data not in query', + ); + expect(result.errors[2]).toBe( + 'Extra field "name" found in fixture data not in query', + ); + expect(result.errors[3]).toBe( + 'Extra field "extraField2" found in fixture data not in query', + ); + expect(result.errors[4]).toBe( + 'Extra field "name" found in fixture data not in query', + ); + expect(result.errors[5]).toBe( + 'Extra field "description" found in fixture data not in query', + ); }); it("detects type mismatches (object vs scalar)", () => { diff --git a/test/methods/validate-test-assets.test.ts b/test/methods/validate-test-assets.test.ts index 9802c5f..b67e3ba 100644 --- a/test/methods/validate-test-assets.test.ts +++ b/test/methods/validate-test-assets.test.ts @@ -159,9 +159,15 @@ describe("validateTestAssets", () => { // Input fixture should be invalid due to missing fields and extra field expect(result.inputFixture.errors.length).toBe(3); - expect(result.inputFixture.errors[0]).toBe('Missing expected fixture data for details'); - expect(result.inputFixture.errors[1]).toBe('Extra field "invalidField" found in fixture data not in query'); - expect(result.inputFixture.errors[2]).toBe('Missing expected fixture data for metadata'); + expect(result.inputFixture.errors[0]).toBe( + "Missing expected fixture data for details", + ); + expect(result.inputFixture.errors[1]).toBe( + 'Extra field "invalidField" found in fixture data not in query', + ); + expect(result.inputFixture.errors[2]).toBe( + "Missing expected fixture data for metadata", + ); expect(result.outputFixture.errors).toHaveLength(0); }); From a368c19779a4e3352014b0fa84559a79b885a27d Mon Sep 17 00:00:00 2001 From: Alex Bradley Date: Thu, 6 Nov 2025 12:23:15 -0500 Subject: [PATCH 5/7] use simpler expectedFields/typeConditionStack --- src/methods/validate-fixture-input.ts | 49 ++++++++++++--------------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/src/methods/validate-fixture-input.ts b/src/methods/validate-fixture-input.ts index b0ad89e..647b0aa 100644 --- a/src/methods/validate-fixture-input.ts +++ b/src/methods/validate-fixture-input.ts @@ -34,10 +34,7 @@ export interface ValidateFixtureInputResult { */ interface ExpectedFields { common: Set; // Fields that should be present on all objects - byType: Map< - GraphQLNamedType, - { fields: Set; possibleTypes: Set } - >; // Fields specific to inline fragment types + byType: Map>; // Map from concrete type name to its expected fields } /** @@ -70,7 +67,7 @@ export function validateFixtureInput( const expectedFieldsStack: ExpectedFields[] = [ { common: new Set(), byType: new Map() }, ]; - const typeConditionStack: (GraphQLNamedType | null)[] = [null]; + const typeConditionStack: (Set | null)[] = [null]; const errors: string[] = []; @@ -82,10 +79,10 @@ export function validateFixtureInput( let possibleTypes = new Set( possibleTypesStack[possibleTypesStack.length - 1], ); - let namedType: GraphQLNamedType | undefined; + let concreteTypes: Set | null = null; if (node.typeCondition !== null && node.typeCondition !== undefined) { - namedType = schema.getType(node.typeCondition.name.value); + const namedType = schema.getType(node.typeCondition.name.value); if (namedType) { if (isAbstractType(namedType)) { @@ -97,10 +94,11 @@ export function validateFixtureInput( } else if (isObjectType(namedType)) { possibleTypes = new Set([namedType.name]); } + concreteTypes = possibleTypes; } } possibleTypesStack.push(possibleTypes); - typeConditionStack.push(namedType ?? null); + typeConditionStack.push(concreteTypes); }, leave() { possibleTypesStack.pop(); @@ -121,18 +119,15 @@ export function validateFixtureInput( typeConditionStack[typeConditionStack.length - 1]; if (currentFragmentType) { - // Inside an inline fragment - add to type-specific set - if (!currentExpectedFields.byType.has(currentFragmentType)) { - const fragmentPossibleTypes = - possibleTypesStack[possibleTypesStack.length - 1]; - currentExpectedFields.byType.set(currentFragmentType, { - fields: new Set(), - possibleTypes: fragmentPossibleTypes, - }); + // Inside an inline fragment - expand to all concrete types + for (const concreteTypeName of currentFragmentType) { + if (!currentExpectedFields.byType.has(concreteTypeName)) { + currentExpectedFields.byType.set(concreteTypeName, new Set()); + } + currentExpectedFields.byType + .get(concreteTypeName)! + .add(responseKey); } - currentExpectedFields.byType - .get(currentFragmentType)! - .fields.add(responseKey); } else { // Outside inline fragments - add to common fields currentExpectedFields.common.add(responseKey); @@ -525,14 +520,12 @@ function checkForExtraFields( : fixtureObject.__typename; if (objectTypename) { - // Object has __typename - check which fragment types match - for (const { - fields, - possibleTypes, - } of expectedFields.byType.values()) { - if (possibleTypes.has(objectTypename)) { - fields.forEach((field) => expectedForThisObject.add(field)); - } + // Object has __typename - direct lookup by concrete type name + const typeSpecificFields = expectedFields.byType.get(objectTypename); + if (typeSpecificFields) { + typeSpecificFields.forEach((field) => + expectedForThisObject.add(field), + ); } } else if (expectedFields.byType.size > 0) { // No __typename - allow union of all fragment fields @@ -540,7 +533,7 @@ function checkForExtraFields( // Note: We use > 0 (not === 1) to handle nested fragments (e.g., ... on HasId { ... on HasName { ... }}) // where byType.size can be > 1. For 2+ sibling fragments (e.g., ... on Item / ... on Metadata) // without __typename, validation BREAKs early (line 277) to enforce __typename requirement. - expectedFields.byType.forEach(({ fields }) => { + expectedFields.byType.forEach((fields) => { fields.forEach((field) => expectedForThisObject.add(field)); }); } From 607595cdd330621f025358c87af44b47d17905ca Mon Sep 17 00:00:00 2001 From: Alex Bradley Date: Fri, 7 Nov 2025 10:41:39 -0500 Subject: [PATCH 6/7] simplify ExpectedFields --- src/methods/validate-fixture-input.ts | 54 +++++++-------------------- 1 file changed, 14 insertions(+), 40 deletions(-) diff --git a/src/methods/validate-fixture-input.ts b/src/methods/validate-fixture-input.ts index 647b0aa..d6ab2da 100644 --- a/src/methods/validate-fixture-input.ts +++ b/src/methods/validate-fixture-input.ts @@ -27,16 +27,6 @@ export interface ValidateFixtureInputResult { errors: string[]; } -/** - * Tracks expected fields at a selection set level, distinguishing between: - * - common fields (selected outside inline fragments) - * - type-specific fields (selected inside inline fragments) - */ -interface ExpectedFields { - common: Set; // Fields that should be present on all objects - byType: Map>; // Map from concrete type name to its expected fields -} - /** * Validates that fixture input data matches the structure and types defined in a GraphQL query. * @@ -64,10 +54,7 @@ export function validateFixtureInput( new Set([schema.getQueryType()!.name]), ]; const typenameResponseKeyStack: (string | undefined)[] = []; - const expectedFieldsStack: ExpectedFields[] = [ - { common: new Set(), byType: new Map() }, - ]; - const typeConditionStack: (Set | null)[] = [null]; + const expectedFieldsStack: Map>[] = [new Map()]; const errors: string[] = []; @@ -98,11 +85,9 @@ export function validateFixtureInput( } } possibleTypesStack.push(possibleTypes); - typeConditionStack.push(concreteTypes); }, leave() { possibleTypesStack.pop(); - typeConditionStack.pop(); }, }, Field: { @@ -115,22 +100,14 @@ export function validateFixtureInput( // Track this field in the appropriate set based on whether we're in an inline fragment const currentExpectedFields = expectedFieldsStack[expectedFieldsStack.length - 1]; - const currentFragmentType = - typeConditionStack[typeConditionStack.length - 1]; - - if (currentFragmentType) { - // Inside an inline fragment - expand to all concrete types - for (const concreteTypeName of currentFragmentType) { - if (!currentExpectedFields.byType.has(concreteTypeName)) { - currentExpectedFields.byType.set(concreteTypeName, new Set()); - } - currentExpectedFields.byType - .get(concreteTypeName)! - .add(responseKey); + const currentPossibleTypes = + possibleTypesStack[possibleTypesStack.length - 1]; + + for (const concreteTypeName of currentPossibleTypes) { + if (!currentExpectedFields.has(concreteTypeName)) { + currentExpectedFields.set(concreteTypeName, new Set()); } - } else { - // Outside inline fragments - add to common fields - currentExpectedFields.common.add(responseKey); + currentExpectedFields.get(concreteTypeName)!.add(responseKey); } const fieldDefinition = typeInfo.getFieldDef(); @@ -266,8 +243,7 @@ export function validateFixtureInput( enter(node, _key, parent) { // If this SelectionSet belongs to a Field, prepare to track expected fields if (parent && "kind" in parent && parent.kind === Kind.FIELD) { - expectedFieldsStack.push({ common: new Set(), byType: new Map() }); - typeConditionStack.push(null); + expectedFieldsStack.push(new Map()); } // Look ahead to find __typename field and track its response key @@ -331,8 +307,6 @@ export function validateFixtureInput( typenameResponseKey, ), ); - - typeConditionStack.pop(); } typenameResponseKeyStack.pop(); @@ -499,7 +473,7 @@ function isValueExpectedForType( */ function checkForExtraFields( fixtureObjects: any[], - expectedFields: ExpectedFields, + expectedFields: Map>, typenameResponseKey: string | undefined, ): string[] { const errors: string[] = []; @@ -513,7 +487,7 @@ function checkForExtraFields( const fixtureFields = Object.keys(fixtureObject); // Build the set of expected fields for this specific object - const expectedForThisObject = new Set(expectedFields.common); + const expectedForThisObject = new Set(); const objectTypename = typenameResponseKey ? fixtureObject[typenameResponseKey] @@ -521,19 +495,19 @@ function checkForExtraFields( if (objectTypename) { // Object has __typename - direct lookup by concrete type name - const typeSpecificFields = expectedFields.byType.get(objectTypename); + const typeSpecificFields = expectedFields.get(objectTypename); if (typeSpecificFields) { typeSpecificFields.forEach((field) => expectedForThisObject.add(field), ); } - } else if (expectedFields.byType.size > 0) { + } else if (expectedFields.size > 0) { // No __typename - allow union of all fragment fields // Without __typename we can't discriminate which fragment applies // Note: We use > 0 (not === 1) to handle nested fragments (e.g., ... on HasId { ... on HasName { ... }}) // where byType.size can be > 1. For 2+ sibling fragments (e.g., ... on Item / ... on Metadata) // without __typename, validation BREAKs early (line 277) to enforce __typename requirement. - expectedFields.byType.forEach((fields) => { + expectedFields.forEach((fields) => { fields.forEach((field) => expectedForThisObject.add(field)); }); } From edc262bff5febc949e00b785eefa4b11553aa559 Mon Sep 17 00:00:00 2001 From: Alex Bradley Date: Fri, 7 Nov 2025 14:07:40 -0500 Subject: [PATCH 7/7] move root extra fields check to selectionSet leave --- src/methods/validate-fixture-input.ts | 31 ++++++++------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/src/methods/validate-fixture-input.ts b/src/methods/validate-fixture-input.ts index d6ab2da..3249c58 100644 --- a/src/methods/validate-fixture-input.ts +++ b/src/methods/validate-fixture-input.ts @@ -47,9 +47,7 @@ export function validateFixtureInput( const inlineFragmentSpreadsAst = inlineNamedFragmentSpreads(queryAST); const typeInfo = new TypeInfo(schema); const valueStack: any[][] = [[value]]; - // based on field depth const typeStack: (GraphQLNamedType | undefined)[] = []; - // based on selection set depth const possibleTypesStack: Set[] = [ new Set([schema.getQueryType()!.name]), ]; @@ -66,7 +64,6 @@ export function validateFixtureInput( let possibleTypes = new Set( possibleTypesStack[possibleTypesStack.length - 1], ); - let concreteTypes: Set | null = null; if (node.typeCondition !== null && node.typeCondition !== undefined) { const namedType = schema.getType(node.typeCondition.name.value); @@ -81,7 +78,6 @@ export function validateFixtureInput( } else if (isObjectType(namedType)) { possibleTypes = new Set([namedType.name]); } - concreteTypes = possibleTypes; } } possibleTypesStack.push(possibleTypes); @@ -294,8 +290,13 @@ export function validateFixtureInput( return undefined; }, leave(_node, _key, parent) { - // If this SelectionSet belongs to a Field, validate for extra fields - if (parent && "kind" in parent && parent.kind === Kind.FIELD) { + // If this SelectionSet belongs to a Field or is the root (OperationDefinition), validate for extra fields + if ( + parent && + "kind" in parent && + (parent.kind === Kind.FIELD || + parent.kind === Kind.OPERATION_DEFINITION) + ) { const expectedFields = expectedFieldsStack.pop()!; const nestedValues = valueStack[valueStack.length - 1]; const typenameResponseKey = @@ -315,18 +316,6 @@ export function validateFixtureInput( }), ); - // The query's root SelectionSet has no parent Field node, so there's no Field.leave event to check it. - // We manually perform the same check here that would happen in Field.leave for nested objects. - const rootTypenameResponseKey = - typenameResponseKeyStack[typenameResponseKeyStack.length - 1]; - errors.push( - ...checkForExtraFields( - valueStack[0], - expectedFieldsStack[0], - rootTypenameResponseKey, - ), - ); - return { errors }; } @@ -487,7 +476,7 @@ function checkForExtraFields( const fixtureFields = Object.keys(fixtureObject); // Build the set of expected fields for this specific object - const expectedForThisObject = new Set(); + let expectedForThisObject = new Set(); const objectTypename = typenameResponseKey ? fixtureObject[typenameResponseKey] @@ -497,9 +486,7 @@ function checkForExtraFields( // Object has __typename - direct lookup by concrete type name const typeSpecificFields = expectedFields.get(objectTypename); if (typeSpecificFields) { - typeSpecificFields.forEach((field) => - expectedForThisObject.add(field), - ); + expectedForThisObject = typeSpecificFields; } } else if (expectedFields.size > 0) { // No __typename - allow union of all fragment fields