From 44c92662e1ffe66375ef662d9ff282ef449c2590 Mon Sep 17 00:00:00 2001 From: Vishwak Thatikonda Date: Wed, 3 Jun 2026 22:30:06 -0700 Subject: [PATCH 1/2] feat: add validateFullSchema and assertValidFullSchema validateSchema validates a source schema and rejects any name beginning with "__", which is reserved by GraphQL introspection. Tools that work with a full schema (for example one reconstructed from introspection) may legitimately encounter reserved names, including ones added by a newer version of GraphQL than this library implements. Add validateFullSchema and assertValidFullSchema, which run the same checks as validateSchema except that reserved names are permitted. The two modes use separate validation caches so their results never collide. Closes #4415 --- src/index.ts | 2 + src/type/__tests__/validation-test.ts | 127 +++++++++++++++++++++++++- src/type/index.ts | 7 +- src/type/schema.ts | 8 ++ src/type/validate.ts | 113 +++++++++++++++++++++-- 5 files changed, 247 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index 219478ad42..7e2211cfd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -172,6 +172,8 @@ export { // Validate GraphQL schema. validateSchema, assertValidSchema, + validateFullSchema, + assertValidFullSchema, // Upholds the spec rules about naming. assertName, assertEnumValueName, diff --git a/src/type/__tests__/validation-test.ts b/src/type/__tests__/validation-test.ts index 8973013fa1..6fe1de0d41 100644 --- a/src/type/__tests__/validation-test.ts +++ b/src/type/__tests__/validation-test.ts @@ -40,7 +40,12 @@ import { import { assertDirective, GraphQLDirective } from '../directives.ts'; import { GraphQLInt, GraphQLString } from '../scalars.ts'; import { GraphQLSchema } from '../schema.ts'; -import { assertValidSchema, validateSchema } from '../validate.ts'; +import { + assertValidFullSchema, + assertValidSchema, + validateFullSchema, + validateSchema, +} from '../validate.ts'; const SomeSchema = buildSchema(` scalar SomeScalar @@ -3401,3 +3406,123 @@ describe('assertValidSchema', () => { Type SomeType must define one or more fields.`); }); }); + +describe('Type System: A full schema may contain reserved names', () => { + it('accepts reserved names from a newer version of GraphQL', () => { + // Valid SDL returned from a hypothetical newer version of GraphQL. + const schema = buildSchema(` + type Query { + hello: String + } + + enum __ErrorBehavior { + NULL + PROPAGATE + HALT + } + + directive @behavior(onError: __ErrorBehavior) on SCHEMA + `); + + // validateSchema rejects the reserved name. + expectJSON(validateSchema(schema)).toDeepEqual([ + { + message: + 'Name "__ErrorBehavior" must not begin with "__", which is reserved by GraphQL introspection.', + locations: [{ line: 6, column: 7 }], + }, + ]); + + // validateFullSchema permits it. + expectJSON(validateFullSchema(schema)).toDeepEqual([]); + }); + + it('accepts reserved names on types, fields, args, enum values and input fields', () => { + const schema = schemaWithFieldType( + new GraphQLObjectType({ + name: '__SomeObject', + fields: { + __badField: { + type: GraphQLString, + args: { + __badArg: { type: GraphQLString }, + }, + }, + }, + }), + ); + + expect(validateSchema(schema)).to.not.deep.equal([]); + expectJSON(validateFullSchema(schema)).toDeepEqual([]); + }); + + it('still reports non-name errors when validating a full schema', () => { + const schema = buildSchema(` + type SomeType + + enum __ErrorBehavior { + NULL + PROPAGATE + HALT + } + `); + + expectJSON(validateFullSchema(schema)).toDeepEqual([ + { + message: 'Query root type must be provided.', + }, + { + message: 'Type SomeType must define one or more fields.', + locations: [{ line: 2, column: 7 }], + }, + ]); + }); + + it('caches full schema validation results independently', () => { + const schema = buildSchema(` + type Query { + hello: String + } + + enum __ErrorBehavior { + NULL + } + `); + + // Populate the regular cache first, then the full schema cache. + const regularErrors = validateSchema(schema); + const fullErrors = validateFullSchema(schema); + + expect(regularErrors).to.have.lengthOf(1); + expect(fullErrors).to.deep.equal([]); + + // Repeated calls return the cached results for each mode. + expect(validateSchema(schema)).to.equal(regularErrors); + expect(validateFullSchema(schema)).to.equal(fullErrors); + }); +}); + +describe('assertValidFullSchema', () => { + it('does not throw on a full schema containing reserved names', () => { + const schema = buildSchema(` + type Query { + hello: String + } + + enum __ErrorBehavior { + NULL + PROPAGATE + HALT + } + `); + expect(() => assertValidFullSchema(schema)).to.not.throw(); + }); + + it('combines multiple errors', () => { + const schema = buildSchema('type SomeType'); + expect(() => assertValidFullSchema(schema)).to.throw(dedent` + Query root type must be provided. + + Type SomeType must define one or more fields.`); + }); +}); diff --git a/src/type/index.ts b/src/type/index.ts index 0f7f6206a9..b13746083b 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -207,7 +207,12 @@ export { } from './introspection.ts'; // Validate GraphQL schema. -export { validateSchema, assertValidSchema } from './validate.ts'; +export { + validateSchema, + assertValidSchema, + validateFullSchema, + assertValidFullSchema, +} from './validate.ts'; // Upholds the spec rules about naming. export { assertName, assertEnumValueName } from './assertName.ts'; diff --git a/src/type/schema.ts b/src/type/schema.ts index 93bff3c792..84728d2536 100644 --- a/src/type/schema.ts +++ b/src/type/schema.ts @@ -208,6 +208,11 @@ export class GraphQLSchema { * @private */ __validationErrors: Maybe>; + /** + * Cached full schema validation errors, if validation has already run. + * @private + */ + __fullSchemaValidationErrors: Maybe>; private _queryType: Maybe; private _mutationType: Maybe; @@ -323,6 +328,9 @@ export class GraphQLSchema { this.assumeValid = config.assumeValid ?? false; // Used as a cache for validateSchema(). this.__validationErrors = config.assumeValid === true ? [] : undefined; + // Used as a cache for validateFullSchema(). + this.__fullSchemaValidationErrors = + config.assumeValid === true ? [] : undefined; this.description = config.description; this.extensions = toObjMapWithSymbols(config.extensions); diff --git a/src/type/validate.ts b/src/type/validate.ts index 4579901e2e..cfe7b63947 100644 --- a/src/type/validate.ts +++ b/src/type/validate.ts @@ -103,18 +103,81 @@ export function validateSchema( } // Validate the schema, producing a list of errors. - const context = new SchemaValidationContext(schema); - validateRootTypes(context); - validateDirectives(context); - validateTypes(context); + const errors = validateSchemaImpl(schema, false); // Persist the results of validation before returning to ensure validation // does not run multiple times for this schema. - const errors = context.getErrors(); schema.__validationErrors = errors; return errors; } +/** + * Implements the "Type Validation" sub-sections of the specification's + * "Type System" section, with the exception that names reserved by GraphQL + * introspection (those beginning with `__`) are permitted. + * + * Unlike `validateSchema`, this validates a "full schema": one that includes + * the built-in definitions and service capabilities added by an implementation, + * such as a schema reconstructed from an introspection result. Such a schema may + * legitimately contain reserved names, including ones added by a newer version + * of GraphQL than this library implements, so those names are not rejected. + * + * Validation runs synchronously, returning an array of encountered errors, or + * an empty array if no errors were encountered and the Schema is valid. + * @param schema - GraphQL full schema to use. + * @returns Schema validation errors, or an empty array when the schema is valid. + * @example + * ```ts + * import { validateFullSchema } from 'graphql/type'; + * import { buildSchema } from 'graphql/utilities'; + * + * const schema = buildSchema(` + * type Query { + * name: String + * } + * + * enum __ErrorBehavior { + * NULL + * PROPAGATE + * HALT + * } + * `); + * const errors = validateFullSchema(schema); + * + * errors; // => [] + * ``` + */ +export function validateFullSchema( + schema: GraphQLSchema, +): ReadonlyArray { + // First check to ensure the provided value is in fact a GraphQLSchema. + assertSchema(schema); + + // If this Schema has already been validated, return the previous results. + if (schema.__fullSchemaValidationErrors) { + return schema.__fullSchemaValidationErrors; + } + + // Validate the schema, producing a list of errors. + const errors = validateSchemaImpl(schema, true); + + // Persist the results of validation before returning to ensure validation + // does not run multiple times for this schema. + schema.__fullSchemaValidationErrors = errors; + return errors; +} + +function validateSchemaImpl( + schema: GraphQLSchema, + allowReservedNames: boolean, +): ReadonlyArray { + const context = new SchemaValidationContext(schema, allowReservedNames); + validateRootTypes(context); + validateDirectives(context); + validateTypes(context); + return context.getErrors(); +} + /** * Utility function which asserts a schema is valid by throwing an error if * it is invalid. @@ -140,13 +203,46 @@ export function assertValidSchema(schema: GraphQLSchema): void { } } +/** + * Utility function which asserts a full schema is valid by throwing an error if + * it is invalid. + * @param schema - GraphQL full schema to use. + * @example + * ```ts + * import { assertValidFullSchema } from 'graphql/type'; + * import { buildSchema } from 'graphql/utilities'; + * + * const schema = buildSchema(` + * type Query { + * name: String + * } + * + * enum __ErrorBehavior { + * NULL + * PROPAGATE + * HALT + * } + * `); + * + * assertValidFullSchema(schema); // does not throw + * ``` + */ +export function assertValidFullSchema(schema: GraphQLSchema): void { + const errors = validateFullSchema(schema); + if (errors.length !== 0) { + throw new Error(errors.map((error) => error.message).join('\n\n')); + } +} + class SchemaValidationContext { readonly _errors: Array; readonly schema: GraphQLSchema; + readonly allowReservedNames: boolean; - constructor(schema: GraphQLSchema) { + constructor(schema: GraphQLSchema, allowReservedNames: boolean) { this._errors = []; this.schema = schema; + this.allowReservedNames = allowReservedNames; } reportError( @@ -395,8 +491,9 @@ function validateName( context: SchemaValidationContext, node: { readonly name: string; readonly astNode: Maybe }, ): void { - // Ensure names are valid, however introspection types opt out. - if (node.name.startsWith('__')) { + // Ensure names are valid, however introspection types opt out, as do all + // reserved names when validating a full schema. + if (!context.allowReservedNames && node.name.startsWith('__')) { context.reportError( `Name "${node.name}" must not begin with "__", which is reserved by GraphQL introspection.`, node.astNode, From 3a1ea648d574c3264afd60d9dbe28a292399e6de Mon Sep 17 00:00:00 2001 From: Vishwak Thatikonda Date: Thu, 4 Jun 2026 06:14:32 -0700 Subject: [PATCH 2/2] refactor: mark full schema validation as experimental Per review feedback, rename validateFullSchema and assertValidFullSchema to experimentalValidateFullSchema and experimentalAssertValidFullSchema, matching the existing experimental* naming convention used for experimentalExecuteIncrementally. This signals the API may change while the behavior and test coverage for full schemas converge. --- src/index.ts | 4 ++-- src/type/__tests__/validation-test.ts | 22 +++++++++++----------- src/type/index.ts | 4 ++-- src/type/validate.ts | 22 +++++++++++++++------- 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7e2211cfd5..0aa7ee3b64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -172,8 +172,8 @@ export { // Validate GraphQL schema. validateSchema, assertValidSchema, - validateFullSchema, - assertValidFullSchema, + experimentalValidateFullSchema, + experimentalAssertValidFullSchema, // Upholds the spec rules about naming. assertName, assertEnumValueName, diff --git a/src/type/__tests__/validation-test.ts b/src/type/__tests__/validation-test.ts index 6fe1de0d41..8b4db34f45 100644 --- a/src/type/__tests__/validation-test.ts +++ b/src/type/__tests__/validation-test.ts @@ -41,9 +41,9 @@ import { assertDirective, GraphQLDirective } from '../directives.ts'; import { GraphQLInt, GraphQLString } from '../scalars.ts'; import { GraphQLSchema } from '../schema.ts'; import { - assertValidFullSchema, assertValidSchema, - validateFullSchema, + experimentalAssertValidFullSchema, + experimentalValidateFullSchema, validateSchema, } from '../validate.ts'; @@ -3433,8 +3433,8 @@ describe('Type System: A full schema may contain reserved names', () => { }, ]); - // validateFullSchema permits it. - expectJSON(validateFullSchema(schema)).toDeepEqual([]); + // experimentalValidateFullSchema permits it. + expectJSON(experimentalValidateFullSchema(schema)).toDeepEqual([]); }); it('accepts reserved names on types, fields, args, enum values and input fields', () => { @@ -3453,7 +3453,7 @@ describe('Type System: A full schema may contain reserved names', () => { ); expect(validateSchema(schema)).to.not.deep.equal([]); - expectJSON(validateFullSchema(schema)).toDeepEqual([]); + expectJSON(experimentalValidateFullSchema(schema)).toDeepEqual([]); }); it('still reports non-name errors when validating a full schema', () => { @@ -3467,7 +3467,7 @@ describe('Type System: A full schema may contain reserved names', () => { } `); - expectJSON(validateFullSchema(schema)).toDeepEqual([ + expectJSON(experimentalValidateFullSchema(schema)).toDeepEqual([ { message: 'Query root type must be provided.', }, @@ -3491,18 +3491,18 @@ describe('Type System: A full schema may contain reserved names', () => { // Populate the regular cache first, then the full schema cache. const regularErrors = validateSchema(schema); - const fullErrors = validateFullSchema(schema); + const fullErrors = experimentalValidateFullSchema(schema); expect(regularErrors).to.have.lengthOf(1); expect(fullErrors).to.deep.equal([]); // Repeated calls return the cached results for each mode. expect(validateSchema(schema)).to.equal(regularErrors); - expect(validateFullSchema(schema)).to.equal(fullErrors); + expect(experimentalValidateFullSchema(schema)).to.equal(fullErrors); }); }); -describe('assertValidFullSchema', () => { +describe('experimentalAssertValidFullSchema', () => { it('does not throw on a full schema containing reserved names', () => { const schema = buildSchema(` type Query { @@ -3515,12 +3515,12 @@ describe('assertValidFullSchema', () => { HALT } `); - expect(() => assertValidFullSchema(schema)).to.not.throw(); + expect(() => experimentalAssertValidFullSchema(schema)).to.not.throw(); }); it('combines multiple errors', () => { const schema = buildSchema('type SomeType'); - expect(() => assertValidFullSchema(schema)).to.throw(dedent` + expect(() => experimentalAssertValidFullSchema(schema)).to.throw(dedent` Query root type must be provided. Type SomeType must define one or more fields.`); diff --git a/src/type/index.ts b/src/type/index.ts index b13746083b..ec0adef6ed 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -210,8 +210,8 @@ export { export { validateSchema, assertValidSchema, - validateFullSchema, - assertValidFullSchema, + experimentalValidateFullSchema, + experimentalAssertValidFullSchema, } from './validate.ts'; // Upholds the spec rules about naming. diff --git a/src/type/validate.ts b/src/type/validate.ts index cfe7b63947..3fe6546736 100644 --- a/src/type/validate.ts +++ b/src/type/validate.ts @@ -122,13 +122,17 @@ export function validateSchema( * legitimately contain reserved names, including ones added by a newer version * of GraphQL than this library implements, so those names are not rejected. * + * Note: This API is experimental and may change or be removed in a future + * version while the behavior and test coverage for full schemas converge. + * * Validation runs synchronously, returning an array of encountered errors, or * an empty array if no errors were encountered and the Schema is valid. * @param schema - GraphQL full schema to use. * @returns Schema validation errors, or an empty array when the schema is valid. + * @experimental * @example * ```ts - * import { validateFullSchema } from 'graphql/type'; + * import { experimentalValidateFullSchema } from 'graphql/type'; * import { buildSchema } from 'graphql/utilities'; * * const schema = buildSchema(` @@ -142,12 +146,12 @@ export function validateSchema( * HALT * } * `); - * const errors = validateFullSchema(schema); + * const errors = experimentalValidateFullSchema(schema); * * errors; // => [] * ``` */ -export function validateFullSchema( +export function experimentalValidateFullSchema( schema: GraphQLSchema, ): ReadonlyArray { // First check to ensure the provided value is in fact a GraphQLSchema. @@ -206,10 +210,14 @@ export function assertValidSchema(schema: GraphQLSchema): void { /** * Utility function which asserts a full schema is valid by throwing an error if * it is invalid. + * + * Note: This API is experimental and may change or be removed in a future + * version while the behavior and test coverage for full schemas converge. * @param schema - GraphQL full schema to use. + * @experimental * @example * ```ts - * import { assertValidFullSchema } from 'graphql/type'; + * import { experimentalAssertValidFullSchema } from 'graphql/type'; * import { buildSchema } from 'graphql/utilities'; * * const schema = buildSchema(` @@ -224,11 +232,11 @@ export function assertValidSchema(schema: GraphQLSchema): void { * } * `); * - * assertValidFullSchema(schema); // does not throw + * experimentalAssertValidFullSchema(schema); // does not throw * ``` */ -export function assertValidFullSchema(schema: GraphQLSchema): void { - const errors = validateFullSchema(schema); +export function experimentalAssertValidFullSchema(schema: GraphQLSchema): void { + const errors = experimentalValidateFullSchema(schema); if (errors.length !== 0) { throw new Error(errors.map((error) => error.message).join('\n\n')); }