diff --git a/.ai/AGENTS.md b/.ai/AGENTS.md index d801459e2..89151a197 100644 --- a/.ai/AGENTS.md +++ b/.ai/AGENTS.md @@ -20,6 +20,12 @@ make bench # Run PHPBench benchmarks make docs # Generate class reference docs ``` +## Public API + +Elements marked with `@api` in PHPDoc are part of the stable public API. + +Constants listed in the [class-reference docs](https://webonyx.github.io/graphql-php/class-reference/) (generated via `generate-class-reference.php` with `'constants' => true`) are also stable public API, even without an `@api` tag. + ## Code and Testing Expectations - Preserve backward compatibility for public APIs unless explicitly requested. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2509467e7..b5746dc9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +## v15.31.0 + +### Added + +- Support per-schema scalar overrides via `types` config or `typeLoader`, without global side effects https://github.com/webonyx/graphql-php/pull/1869 +- Add `Type::builtInScalars()` and `Type::BUILT_IN_SCALAR_NAMES` aligning with GraphQL spec terminology https://github.com/webonyx/graphql-php/pull/1869 +- Add `Directive::builtInDirectives()` and `Directive::isBuiltInDirective()` aligning with GraphQL spec terminology https://github.com/webonyx/graphql-php/pull/1869 + +### Deprecated + +- Deprecate `Type::overrideStandardTypes()` in favor of per-schema scalar overrides https://github.com/webonyx/graphql-php/pull/1869 +- Deprecate `Type::getStandardTypes()` in favor of `Type::builtInScalars()` https://github.com/webonyx/graphql-php/pull/1869 +- Deprecate `Type::STANDARD_TYPE_NAMES` in favor of `Type::BUILT_IN_SCALAR_NAMES` https://github.com/webonyx/graphql-php/pull/1869 +- Deprecate `Directive::getInternalDirectives()` in favor of `Directive::builtInDirectives()` https://github.com/webonyx/graphql-php/pull/1869 +- Deprecate `Directive::isSpecifiedDirective()` in favor of `Directive::isBuiltInDirective()` https://github.com/webonyx/graphql-php/pull/1869 + ## v15.30.2 ### Fixed diff --git a/README.md b/README.md index f6295a729..a84a60504 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,9 @@ with a specific README file per example. This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). Elements that belong to the public API of this package are marked with the `@api` PHPDoc tag. -Those elements are thus guaranteed to be stable within major versions. All other elements are -not part of this backwards compatibility guarantee and may change between minor or patch versions. +Constants included in the [class-reference docs](https://webonyx.github.io/graphql-php/class-reference) are also part of the public API. +Those elements are thus guaranteed to be stable within major versions. +All other elements are not part of this backwards compatibility guarantee and may change between minor or patch versions. The most recent version is actively developed on [`master`](https://github.com/webonyx/graphql-php/tree/master). Older versions are generally no longer supported, although exceptions may be made for [sponsors](#sponsors). diff --git a/benchmarks/BuildSchemaBench.php b/benchmarks/BuildSchemaBench.php index 46069373a..856cca298 100644 --- a/benchmarks/BuildSchemaBench.php +++ b/benchmarks/BuildSchemaBench.php @@ -11,9 +11,11 @@ * * @Warmup(2) * + * @Sleep(500000) + * * @Revs(10) * - * @Iterations(2) + * @Iterations(5) */ class BuildSchemaBench { diff --git a/benchmarks/HugeSchemaBench.php b/benchmarks/HugeSchemaBench.php index 1f58835a5..dfb3610cb 100644 --- a/benchmarks/HugeSchemaBench.php +++ b/benchmarks/HugeSchemaBench.php @@ -16,9 +16,11 @@ * * @Warmup(2) * + * @Sleep(500000) + * * @Revs(10) * - * @Iterations(3) + * @Iterations(5) */ class HugeSchemaBench { diff --git a/benchmarks/ScalarOverrideBench.php b/benchmarks/ScalarOverrideBench.php new file mode 100644 index 000000000..e6e6c0b03 --- /dev/null +++ b/benchmarks/ScalarOverrideBench.php @@ -0,0 +1,109 @@ + Type::STRING, + 'serialize' => static fn ($value): string => strtoupper((string) $value), + ]); + + $queryTypeBaseline = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'greeting' => [ + 'type' => Type::string(), + 'resolve' => static fn (): string => 'hello world', + ], + ], + ]); + $this->schemaBaseline = new Schema([ + 'query' => $queryTypeBaseline, + ]); + + $queryTypeLoader = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'greeting' => [ + 'type' => Type::string(), + 'resolve' => static fn (): string => 'hello world', + ], + ], + ]); + $typesForLoader = ['Query' => $queryTypeLoader, 'String' => $uppercaseString]; + $this->schemaTypeLoader = new Schema([ + 'query' => $queryTypeLoader, + 'typeLoader' => static fn (string $name): ?Type => $typesForLoader[$name] ?? null, + ]); + + $queryTypeTypes = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'greeting' => [ + 'type' => Type::string(), + 'resolve' => static fn (): string => 'hello world', + ], + ], + ]); + $this->schemaTypes = new Schema([ + 'query' => $queryTypeTypes, + 'types' => [$uppercaseString], + ]); + } + + public function benchGetTypeWithoutOverride(): void + { + $this->schemaBaseline->getType('String'); + } + + public function benchGetTypeWithTypeLoaderOverride(): void + { + $this->schemaTypeLoader->getType('String'); + } + + public function benchGetTypeWithTypesOverride(): void + { + $this->schemaTypes->getType('String'); + } + + public function benchExecuteWithoutOverride(): void + { + GraphQL::executeQuery($this->schemaBaseline, '{ greeting }'); + } + + public function benchExecuteWithTypeLoaderOverride(): void + { + GraphQL::executeQuery($this->schemaTypeLoader, '{ greeting }'); + } + + public function benchExecuteWithTypesOverride(): void + { + GraphQL::executeQuery($this->schemaTypes, '{ greeting }'); + } +} diff --git a/docs/class-reference.md b/docs/class-reference.md index 0102d10df..f6e627c43 100644 --- a/docs/class-reference.md +++ b/docs/class-reference.md @@ -108,6 +108,8 @@ static function promiseToExecute( /** * Returns directives defined in GraphQL spec. * + * @deprecated use {@see Directive::builtInDirectives()} + * * @throws InvariantViolation * * @return array @@ -119,7 +121,9 @@ static function getStandardDirectives(): array ```php /** - * Returns types defined in GraphQL spec. + * Returns built-in scalar types defined in GraphQL spec. + * + * @deprecated use {@see Type::builtInScalars()} * * @throws InvariantViolation * @@ -136,6 +140,8 @@ static function getStandardTypes(): array * * Standard types not listed here remain untouched. * + * @deprecated prefer per-schema scalar overrides via {@see \GraphQL\Type\SchemaConfig::$types} or {@see \GraphQL\Type\SchemaConfig::$typeLoader} + * * @param array $types * * @api @@ -180,13 +186,52 @@ static function setDefaultArgsMapper(callable $fn): void ## GraphQL\Type\Definition\Type -Registry of standard GraphQL types and base class for all other types. +Registry of built-in GraphQL types and base class for all other types. + +### GraphQL\Type\Definition\Type Constants + +```php +const INT = 'Int'; +const FLOAT = 'Float'; +const STRING = 'String'; +const BOOLEAN = 'Boolean'; +const ID = 'ID'; +const BUILT_IN_SCALAR_NAMES = [ + 'Int', + 'Float', + 'String', + 'Boolean', + 'ID', +]; +const STANDARD_TYPE_NAMES = [ + 'Int', + 'Float', + 'String', + 'Boolean', + 'ID', +]; +const BUILT_IN_TYPE_NAMES = [ + 'Int', + 'Float', + 'String', + 'Boolean', + 'ID', + '__Schema', + '__Type', + '__Directive', + '__Field', + '__InputValue', + '__EnumValue', + '__TypeKind', + '__DirectiveLocation', +]; +``` ### GraphQL\Type\Definition\Type Methods ```php /** - * Returns the registered or default standard Int type. + * Returns the built-in Int scalar type. * * @api */ @@ -195,7 +240,7 @@ static function int(): GraphQL\Type\Definition\ScalarType ```php /** - * Returns the registered or default standard Float type. + * Returns the built-in Float scalar type. * * @api */ @@ -204,7 +249,7 @@ static function float(): GraphQL\Type\Definition\ScalarType ```php /** - * Returns the registered or default standard String type. + * Returns the built-in String scalar type. * * @api */ @@ -213,7 +258,7 @@ static function string(): GraphQL\Type\Definition\ScalarType ```php /** - * Returns the registered or default standard Boolean type. + * Returns the built-in Boolean scalar type. * * @api */ @@ -222,7 +267,7 @@ static function boolean(): GraphQL\Type\Definition\ScalarType ```php /** - * Returns the registered or default standard ID type. + * Returns the built-in ID scalar type. * * @api */ @@ -255,6 +300,28 @@ static function listOf($type): GraphQL\Type\Definition\ListOfType static function nonNull($type): GraphQL\Type\Definition\NonNull ``` +```php +/** + * Returns all built-in types: built-in scalars and introspection types. + * + * @api + * + * @return array + */ +static function builtInTypes(): array +``` + +```php +/** + * Returns all built-in scalar types. + * + * @api + * + * @return array + */ +static function builtInScalars(): array +``` + ```php /** * Determines if the given type is an input type. diff --git a/examples/06-per-schema-scalar-override/example.php b/examples/06-per-schema-scalar-override/example.php new file mode 100644 index 000000000..9aa5bd87b --- /dev/null +++ b/examples/06-per-schema-scalar-override/example.php @@ -0,0 +1,70 @@ + Type::STRING, + 'serialize' => static fn ($value): string => trim((string) $value), + 'parseValue' => static fn ($value): string => trim((string) $value), + 'parseLiteral' => static fn ($valueNode): string => trim($valueNode->value), +]); + +$queryType = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'greeting' => [ + 'type' => Type::string(), + 'args' => [ + 'name' => ['type' => Type::string()], + ], + 'resolve' => static fn ($root, array $args): string => " Hello, {$args['name']}! ", + ], + ], +]); + +// Override via types config +$schemaViaTypes = new Schema([ + 'query' => $queryType, + 'types' => [$trimmedString], +]); + +$result = GraphQL::executeQuery($schemaViaTypes, '{ greeting(name: "World") }'); +echo "Override via types config:\n"; +echo json_encode($result->toArray(), JSON_THROW_ON_ERROR) . "\n\n"; + +// Override via typeLoader +$queryTypeForLoader = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'greeting' => [ + 'type' => Type::string(), + 'args' => [ + 'name' => ['type' => Type::string()], + ], + 'resolve' => static fn ($root, array $args): string => " Hello, {$args['name']}! ", + ], + ], +]); + +$types = ['Query' => $queryTypeForLoader, 'String' => $trimmedString]; +$schemaViaTypeLoader = new Schema([ + 'query' => $queryTypeForLoader, + 'typeLoader' => static fn (string $name): ?Type => $types[$name] ?? null, +]); + +$result = GraphQL::executeQuery($schemaViaTypeLoader, '{ greeting(name: "World") }'); +echo "Override via typeLoader:\n"; +echo json_encode($result->toArray(), JSON_THROW_ON_ERROR) . "\n"; + +// Expected output: +// Override via types config: +// {"data":{"greeting":"Hello, World!"}} +// +// Override via typeLoader: +// {"data":{"greeting":"Hello, World!"}} diff --git a/generate-class-reference.php b/generate-class-reference.php index 70f25e11e..841ee88b4 100644 --- a/generate-class-reference.php +++ b/generate-class-reference.php @@ -10,7 +10,7 @@ const ENTRIES = [ GraphQL\GraphQL::class => [], - GraphQL\Type\Definition\Type::class => [], + GraphQL\Type\Definition\Type::class => ['constants' => true], GraphQL\Type\Definition\ResolveInfo::class => [], GraphQL\Language\DirectiveLocation::class => ['constants' => true], GraphQL\Type\SchemaConfig::class => [], diff --git a/phpbench.json b/phpbench.json index b47419350..de5b5c6f5 100644 --- a/phpbench.json +++ b/phpbench.json @@ -2,7 +2,7 @@ "runner.bootstrap": "vendor/autoload.php", "runner.path": "benchmarks", "runner.file_pattern": "*Bench.php", - "runner.retry_threshold": 5, + "runner.retry_threshold": 2, "runner.time_unit": "milliseconds", "runner.progress": "plain", "report.generators": { diff --git a/src/Executor/ReferenceExecutor.php b/src/Executor/ReferenceExecutor.php index 53c52dd8a..ecd3514f5 100644 --- a/src/Executor/ReferenceExecutor.php +++ b/src/Executor/ReferenceExecutor.php @@ -911,11 +911,17 @@ protected function completeValue( // Account for invalid schema definition when typeLoader returns different // instance than `resolveType` or $field->getType() or $arg->getType() assert( - $returnType === $this->exeContext->schema->getType($returnType->name), + $returnType === $this->exeContext->schema->getType($returnType->name) + || in_array($returnType->name, Type::BUILT_IN_SCALAR_NAMES, true), SchemaValidationContext::duplicateType($this->exeContext->schema, "{$info->parentType}.{$info->fieldName}", $returnType->name) ); if ($returnType instanceof LeafType) { + $schemaType = $this->exeContext->schema->getType($returnType->name); + if ($schemaType instanceof LeafType) { + $returnType = $schemaType; + } + return $this->completeLeafValue($returnType, $result); } diff --git a/src/GraphQL.php b/src/GraphQL.php index 919b09dec..b96d489ff 100644 --- a/src/GraphQL.php +++ b/src/GraphQL.php @@ -180,6 +180,8 @@ public static function promiseToExecute( /** * Returns directives defined in GraphQL spec. * + * @deprecated use {@see Directive::builtInDirectives()} + * * @throws InvariantViolation * * @return array @@ -188,11 +190,13 @@ public static function promiseToExecute( */ public static function getStandardDirectives(): array { - return Directive::getInternalDirectives(); + return Directive::builtInDirectives(); } /** - * Returns types defined in GraphQL spec. + * Returns built-in scalar types defined in GraphQL spec. + * + * @deprecated use {@see Type::builtInScalars()} * * @throws InvariantViolation * @@ -202,7 +206,7 @@ public static function getStandardDirectives(): array */ public static function getStandardTypes(): array { - return Type::getStandardTypes(); + return Type::builtInScalars(); } /** @@ -210,6 +214,8 @@ public static function getStandardTypes(): array * * Standard types not listed here remain untouched. * + * @deprecated prefer per-schema scalar overrides via {@see \GraphQL\Type\SchemaConfig::$types} or {@see \GraphQL\Type\SchemaConfig::$typeLoader} + * * @param array $types * * @api diff --git a/src/Type/Definition/Directive.php b/src/Type/Definition/Directive.php index e613b85d3..80d1ce956 100644 --- a/src/Type/Definition/Directive.php +++ b/src/Type/Definition/Directive.php @@ -76,7 +76,7 @@ public function __construct(array $config) } /** @return array */ - public static function getInternalDirectives(): array + public static function builtInDirectives(): array { return [ self::INCLUDE_NAME => self::includeDirective(), @@ -86,6 +86,16 @@ public static function getInternalDirectives(): array ]; } + /** + * @deprecated use {@see Directive::builtInDirectives()} + * + * @return array + */ + public static function getInternalDirectives(): array + { + return self::builtInDirectives(); + } + public static function includeDirective(): Directive { return self::$internalDirectives[self::INCLUDE_NAME] ??= new self([ @@ -157,9 +167,15 @@ public static function oneOfDirective(): Directive ]); } + public static function isBuiltInDirective(self $directive): bool + { + return array_key_exists($directive->name, self::builtInDirectives()); + } + + /** @deprecated use {@see Directive::isBuiltInDirective()} */ public static function isSpecifiedDirective(Directive $directive): bool { - return array_key_exists($directive->name, self::getInternalDirectives()); + return self::isBuiltInDirective($directive); } public static function resetCachedInstances(): void diff --git a/src/Type/Definition/Type.php b/src/Type/Definition/Type.php index 8dee72b8d..9fff72751 100644 --- a/src/Type/Definition/Type.php +++ b/src/Type/Definition/Type.php @@ -4,10 +4,11 @@ use GraphQL\Error\InvariantViolation; use GraphQL\Type\Introspection; +use GraphQL\Type\SchemaConfig; use GraphQL\Utils\Utils; /** - * Registry of standard GraphQL types and base class for all other types. + * Registry of built-in GraphQL types and base class for all other types. */ abstract class Type implements \JsonSerializable { @@ -17,7 +18,8 @@ abstract class Type implements \JsonSerializable public const BOOLEAN = 'Boolean'; public const ID = 'ID'; - public const STANDARD_TYPE_NAMES = [ + /** @var list */ + public const BUILT_IN_SCALAR_NAMES = [ self::INT, self::FLOAT, self::STRING, @@ -25,65 +27,79 @@ abstract class Type implements \JsonSerializable self::ID, ]; + /** + * @deprecated use {@see Type::BUILT_IN_SCALAR_NAMES} + * + * @var list + */ + public const STANDARD_TYPE_NAMES = self::BUILT_IN_SCALAR_NAMES; + + /** + * Names of all built-in types: built-in scalars and introspection types. + * + * @see Type::BUILT_IN_SCALAR_NAMES for just the built-in scalar names. + * + * @var list + */ public const BUILT_IN_TYPE_NAMES = [ - ...self::STANDARD_TYPE_NAMES, + ...self::BUILT_IN_SCALAR_NAMES, ...Introspection::TYPE_NAMES, ]; /** @var array|null */ - protected static ?array $standardTypes; + protected static ?array $builtInScalars; /** @var array|null */ protected static ?array $builtInTypes; /** - * Returns the registered or default standard Int type. + * Returns the built-in Int scalar type. * * @api */ public static function int(): ScalarType { - return static::$standardTypes[self::INT] ??= new IntType(); // @phpstan-ignore missingType.checkedException (static configuration is known to be correct) + return static::$builtInScalars[self::INT] ??= new IntType(); // @phpstan-ignore missingType.checkedException (static configuration is known to be correct) } /** - * Returns the registered or default standard Float type. + * Returns the built-in Float scalar type. * * @api */ public static function float(): ScalarType { - return static::$standardTypes[self::FLOAT] ??= new FloatType(); // @phpstan-ignore missingType.checkedException (static configuration is known to be correct) + return static::$builtInScalars[self::FLOAT] ??= new FloatType(); // @phpstan-ignore missingType.checkedException (static configuration is known to be correct) } /** - * Returns the registered or default standard String type. + * Returns the built-in String scalar type. * * @api */ public static function string(): ScalarType { - return static::$standardTypes[self::STRING] ??= new StringType(); // @phpstan-ignore missingType.checkedException (static configuration is known to be correct) + return static::$builtInScalars[self::STRING] ??= new StringType(); // @phpstan-ignore missingType.checkedException (static configuration is known to be correct) } /** - * Returns the registered or default standard Boolean type. + * Returns the built-in Boolean scalar type. * * @api */ public static function boolean(): ScalarType { - return static::$standardTypes[self::BOOLEAN] ??= new BooleanType(); // @phpstan-ignore missingType.checkedException (static configuration is known to be correct) + return static::$builtInScalars[self::BOOLEAN] ??= new BooleanType(); // @phpstan-ignore missingType.checkedException (static configuration is known to be correct) } /** - * Returns the registered or default standard ID type. + * Returns the built-in ID scalar type. * * @api */ public static function id(): ScalarType { - return static::$standardTypes[self::ID] ??= new IDType(); // @phpstan-ignore missingType.checkedException (static configuration is known to be correct) + return static::$builtInScalars[self::ID] ??= new IDType(); // @phpstan-ignore missingType.checkedException (static configuration is known to be correct) } /** @@ -119,7 +135,9 @@ public static function nonNull($type): NonNull } /** - * Returns all builtin in types including base scalar and introspection types. + * Returns all built-in types: built-in scalars and introspection types. + * + * @api * * @return array */ @@ -127,16 +145,18 @@ public static function builtInTypes(): array { return self::$builtInTypes ??= array_merge( Introspection::getTypes(), - self::getStandardTypes() + self::builtInScalars() ); } /** - * Returns all builtin scalar types. + * Returns all built-in scalar types. + * + * @api * * @return array */ - public static function getStandardTypes(): array + public static function builtInScalars(): array { return [ self::INT => static::int(), @@ -148,7 +168,21 @@ public static function getStandardTypes(): array } /** - * Allows partially or completely overriding the standard types. + * Returns all built-in scalar types. + * + * @deprecated use {@see Type::builtInScalars()} + * + * @return array + */ + public static function getStandardTypes(): array + { + return self::builtInScalars(); + } + + /** + * Allows partially or completely overriding the standard types globally. + * + * @deprecated prefer per-schema scalar overrides via {@see SchemaConfig::$types} or {@see SchemaConfig::$typeLoader} * * @param array $types * @@ -156,7 +190,7 @@ public static function getStandardTypes(): array */ public static function overrideStandardTypes(array $types): void { - // Reset caches that might contain instances of standard types + // Reset caches that might contain instances of built-in scalars static::$builtInTypes = null; Introspection::resetCachedInstances(); Directive::resetCachedInstances(); @@ -169,13 +203,13 @@ public static function overrideStandardTypes(array $types): void throw new InvariantViolation("Expecting instance of {$typeClass}, got {$notType}"); } - if (! in_array($type->name, self::STANDARD_TYPE_NAMES, true)) { - $standardTypeNames = implode(', ', self::STANDARD_TYPE_NAMES); + if (! in_array($type->name, self::BUILT_IN_SCALAR_NAMES, true)) { + $standardTypeNames = implode(', ', self::BUILT_IN_SCALAR_NAMES); $notStandardTypeName = Utils::printSafe($type->name); throw new InvariantViolation("Expecting one of the following names for a standard type: {$standardTypeNames}; got {$notStandardTypeName}"); } - static::$standardTypes[$type->name] = $type; + static::$builtInScalars[$type->name] = $type; } } diff --git a/src/Type/Schema.php b/src/Type/Schema.php index 03fc0034a..8708552b4 100644 --- a/src/Type/Schema.php +++ b/src/Type/Schema.php @@ -14,6 +14,7 @@ use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\NamedType; use GraphQL\Type\Definition\ObjectType; +use GraphQL\Type\Definition\ScalarType; use GraphQL\Type\Definition\Type; use GraphQL\Type\Definition\UnionType; use GraphQL\Utils\InterfaceImplementations; @@ -126,6 +127,12 @@ public function getTypeMap(): array // Reset order of user provided types, since calls to getType() may have loaded them $this->resolvedTypes = []; + // Separate built-in scalar overrides to avoid identity conflicts + // with Type::string() etc. references in field definitions during extractTypes. + /** @var array $scalarOverrides */ + $scalarOverrides = []; + $builtInScalars = Type::builtInScalars(); + foreach ($types as $typeOrLazyType) { /** @var Type|callable(): Type $typeOrLazyType */ $type = self::resolveType($typeOrLazyType); @@ -133,6 +140,15 @@ public function getTypeMap(): array /** @var string $typeName Necessary assertion for PHPStan + PHP 8.2 */ $typeName = $type->name; + + if ($type instanceof ScalarType + && isset($builtInScalars[$typeName]) + && $type !== $builtInScalars[$typeName] + ) { + $scalarOverrides[$typeName] = $type; + continue; + } + assert( ! isset($this->resolvedTypes[$typeName]) || $type === $this->resolvedTypes[$typeName], "Schema must contain unique named types but contains multiple types named \"{$type}\" (see https://webonyx.github.io/graphql-php/type-definitions/#type-registry).", @@ -166,6 +182,28 @@ public function getTypeMap(): array } TypeInfo::extractTypes(Introspection::_schema(), $allReferencedTypes); + // Apply scalar overrides after all extractions, replacing the + // global singletons with user-provided instances. + foreach ($scalarOverrides as $name => $override) { + $allReferencedTypes[$name] = $override; + } + + if (isset($this->config->typeLoader)) { + foreach (Type::BUILT_IN_SCALAR_NAMES as $scalarName) { + if (isset($scalarOverrides[$scalarName])) { + continue; + } + + $type = ($this->config->typeLoader)($scalarName); + if ($type instanceof ScalarType + && $type->name === $scalarName + && $type !== $builtInScalars[$scalarName] + ) { + $allReferencedTypes[$scalarName] = $type; + } + } + } + $this->resolvedTypes = $allReferencedTypes; $this->fullyLoaded = true; } @@ -298,17 +336,17 @@ public function getType(string $name): ?Type return $introspectionTypes[$name]; } - $standardTypes = Type::getStandardTypes(); - if (isset($standardTypes[$name])) { - return $standardTypes[$name]; + $type = $this->loadType($name); + if ($type !== null) { + return $this->resolvedTypes[$name] = self::resolveType($type); } - $type = $this->loadType($name); - if ($type === null) { - return null; + $builtInScalars = Type::builtInScalars(); + if (isset($builtInScalars[$name])) { + return $this->resolvedTypes[$name] = $builtInScalars[$name]; } - return $this->resolvedTypes[$name] = self::resolveType($type); + return null; } /** @throws InvariantViolation */ @@ -511,7 +549,7 @@ public function assertValid(): void throw new InvariantViolation(implode("\n\n", $this->validationErrors)); } - $internalTypes = Type::getStandardTypes() + Introspection::getTypes(); + $internalTypes = Type::builtInScalars() + Introspection::getTypes(); foreach ($this->getTypeMap() as $name => $type) { if (isset($internalTypes[$name])) { continue; diff --git a/src/Utils/BuildClientSchema.php b/src/Utils/BuildClientSchema.php index 7cd55e78b..40845936a 100644 --- a/src/Utils/BuildClientSchema.php +++ b/src/Utils/BuildClientSchema.php @@ -111,7 +111,7 @@ public function buildSchema(): Schema $schemaIntrospection = $this->introspection['__schema']; $builtInTypes = array_merge( - Type::getStandardTypes(), + Type::builtInScalars(), Introspection::getTypes() ); diff --git a/tests/Executor/ExecutorLazySchemaTest.php b/tests/Executor/ExecutorLazySchemaTest.php index 0ffedc6bb..97f87bcb5 100644 --- a/tests/Executor/ExecutorLazySchemaTest.php +++ b/tests/Executor/ExecutorLazySchemaTest.php @@ -213,6 +213,7 @@ public function testSimpleQuery(): void 'Query.fields', 'SomeObject', 'SomeObject.fields', + 'String', ]; self::assertSame($expected, $result->toArray(DebugFlag::INCLUDE_DEBUG_MESSAGE)); self::assertSame($expectedExecutorCalls, $this->calls); @@ -379,6 +380,7 @@ public function testDeepQuery(): void 'Query' => true, 'SomeObject' => true, 'OtherObject' => true, + 'String' => true, ], $this->loadedTypes ); @@ -387,6 +389,7 @@ public function testDeepQuery(): void 'Query.fields', 'SomeObject', 'SomeObject.fields', + 'String', ], $this->calls ); diff --git a/tests/Type/ScalarOverridesTest.php b/tests/Type/ScalarOverridesTest.php new file mode 100644 index 000000000..d93f48000 --- /dev/null +++ b/tests/Type/ScalarOverridesTest.php @@ -0,0 +1,229 @@ + */ + private static array $originalStandardTypes; + + public static function setUpBeforeClass(): void + { + self::$originalStandardTypes = Type::builtInScalars(); + } + + public function tearDown(): void + { + parent::tearDown(); + Type::overrideStandardTypes(self::$originalStandardTypes); + } + + public function testTypeLoaderOverrideWorksEndToEnd(): void + { + $uppercaseString = self::createUppercaseString(); + $queryType = self::createQueryType(); + $types = ['Query' => $queryType, 'String' => $uppercaseString]; + + $schema = new Schema([ + 'query' => $queryType, + 'typeLoader' => static fn (string $name): ?Type => $types[$name] ?? null, + ]); + + $schema->assertValid(); + + $result = GraphQL::executeQuery($schema, '{ greeting }'); + + self::assertSame(['data' => ['greeting' => 'HELLO WORLD']], $result->toArray()); + } + + public function testTypeLoaderOverrideWorksInProductionMode(): void + { + $assertActive = (int) ini_get('assert.active'); + @ini_set('assert.active', '0'); + + try { + $uppercaseString = self::createUppercaseString(); + $queryType = self::createQueryType(); + $types = ['Query' => $queryType, 'String' => $uppercaseString]; + + $schema = new Schema([ + 'query' => $queryType, + 'typeLoader' => static fn (string $name): ?Type => $types[$name] ?? null, + ]); + + $result = GraphQL::executeQuery($schema, '{ greeting }'); + + self::assertSame(['data' => ['greeting' => 'HELLO WORLD']], $result->toArray()); + } finally { + @ini_set('assert.active', (string) $assertActive); + } + } + + public function testTypesConfigOverrideWorksEndToEnd(): void + { + $uppercaseString = self::createUppercaseString(); + + $schema = new Schema([ + 'query' => self::createQueryType(), + 'types' => [$uppercaseString], + ]); + + $schema->assertValid(); + + $result = GraphQL::executeQuery($schema, '{ greeting }'); + + self::assertSame(['data' => ['greeting' => 'HELLO WORLD']], $result->toArray()); + } + + public function testTypesConfigOverrideWorksWithAssumeValid(): void + { + $uppercaseString = self::createUppercaseString(); + + $config = SchemaConfig::create([ + 'query' => self::createQueryType(), + 'types' => [$uppercaseString], + ]); + $config->setAssumeValid(true); + + $schema = new Schema($config); + + $result = GraphQL::executeQuery($schema, '{ greeting }'); + + self::assertSame(['data' => ['greeting' => 'HELLO WORLD']], $result->toArray()); + } + + public function testIntrospectionUsesOverriddenScalar(): void + { + $uppercaseString = self::createUppercaseString(); + $queryType = self::createQueryType(); + $types = ['Query' => $queryType, 'String' => $uppercaseString]; + + $schema = new Schema([ + 'query' => $queryType, + 'typeLoader' => static fn (string $name): ?Type => $types[$name] ?? null, + ]); + + $result = GraphQL::executeQuery($schema, '{ __type(name: "Query") { fields { name } } }'); + + $data = $result->toArray()['data'] ?? []; + $fields = $data['__type']['fields']; + $fieldNames = array_column($fields, 'name'); + + self::assertContains('GREETING', $fieldNames); + } + + public function testTwoSchemasWithDifferentOverridesAreIndependent(): void + { + $uppercaseString = new CustomScalarType([ + 'name' => Type::STRING, + 'serialize' => static fn ($value): string => strtoupper((string) $value), + ]); + $reversedString = new CustomScalarType([ + 'name' => Type::STRING, + 'serialize' => static fn ($value): string => strrev((string) $value), + ]); + + $queryTypeA = self::createQueryType(); + $typesA = ['Query' => $queryTypeA, 'String' => $uppercaseString]; + $schemaA = new Schema([ + 'query' => $queryTypeA, + 'typeLoader' => static fn (string $name): ?Type => $typesA[$name] ?? null, + ]); + + $queryTypeB = self::createQueryType(); + $typesB = ['Query' => $queryTypeB, 'String' => $reversedString]; + $schemaB = new Schema([ + 'query' => $queryTypeB, + 'typeLoader' => static fn (string $name): ?Type => $typesB[$name] ?? null, + ]); + + $resultA = GraphQL::executeQuery($schemaA, '{ greeting }'); + $resultB = GraphQL::executeQuery($schemaB, '{ greeting }'); + + self::assertSame(['data' => ['greeting' => 'HELLO WORLD']], $resultA->toArray()); + self::assertSame(['data' => ['greeting' => 'dlrow olleh']], $resultB->toArray()); + } + + public function testNonOverriddenScalarsAreUnaffected(): void + { + $uppercaseString = self::createUppercaseString(); + $queryType = new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'greeting' => [ + 'type' => Type::string(), + 'resolve' => static fn (): string => 'hello world', + ], + 'count' => [ + 'type' => Type::int(), + 'resolve' => static fn (): int => 42, + ], + 'ratio' => [ + 'type' => Type::float(), + 'resolve' => static fn (): float => 3.14, + ], + 'active' => [ + 'type' => Type::boolean(), + 'resolve' => static fn (): bool => true, + ], + 'identifier' => [ + 'type' => Type::id(), + 'resolve' => static fn (): string => 'abc-123', + ], + ], + ]); + + $types = ['Query' => $queryType, 'String' => $uppercaseString]; + + $schema = new Schema([ + 'query' => $queryType, + 'typeLoader' => static fn (string $name): ?Type => $types[$name] ?? null, + ]); + + $result = GraphQL::executeQuery($schema, '{ greeting count ratio active identifier }'); + $data = $result->toArray()['data'] ?? []; + + self::assertSame('HELLO WORLD', $data['greeting']); + self::assertSame(42, $data['count']); + self::assertSame(3.14, $data['ratio']); + self::assertTrue($data['active']); + self::assertSame('abc-123', $data['identifier']); + } + + /** @throws InvariantViolation */ + private static function createUppercaseString(): CustomScalarType + { + return new CustomScalarType([ + 'name' => Type::STRING, + 'serialize' => static fn ($value): string => strtoupper((string) $value), + ]); + } + + /** @throws InvariantViolation */ + private static function createQueryType(): ObjectType + { + return new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'greeting' => [ + 'type' => Type::string(), + 'resolve' => static fn (): string => 'hello world', + ], + ], + ]); + } +} diff --git a/tests/Type/StandardTypesTest.php b/tests/Type/StandardTypesTest.php index 6a569f45e..afec2d818 100644 --- a/tests/Type/StandardTypesTest.php +++ b/tests/Type/StandardTypesTest.php @@ -3,12 +3,14 @@ namespace GraphQL\Tests\Type; use GraphQL\Error\InvariantViolation; +use GraphQL\GraphQL; use GraphQL\Type\Definition\CustomScalarType; use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\ScalarType; use GraphQL\Type\Definition\Type; use GraphQL\Type\Introspection; +use GraphQL\Type\Schema; use PHPUnit\Framework\TestCase; final class StandardTypesTest extends TestCase @@ -147,6 +149,71 @@ public function testCachesShouldResetWhenOverridingStandardTypes(): void self::assertSame($newString, $newDeprecatedDirective->args[0]->getType()); } + /** @see ScalarOverridesTest for the per-schema alternative */ + public function testGlobalOverrideAffectsSchemaExecution(): void + { + $uppercaseString = self::createUppercaseString(); + Type::overrideStandardTypes([$uppercaseString]); + + $schema = new Schema([ + 'query' => self::createQueryType(), + ]); + + $result = GraphQL::executeQuery($schema, '{ greeting }'); + + self::assertSame(['data' => ['greeting' => 'HELLO WORLD']], $result->toArray()); + } + + /** + * Documents the exact problem from https://github.com/webonyx/graphql-php/issues/1424. + * + * @see ScalarOverridesTest for the per-schema alternative + */ + public function testStaticOverrideAffectsAllSchemas(): void + { + $schemaA = new Schema([ + 'query' => self::createQueryType(), + ]); + + $uppercaseString = self::createUppercaseString(); + Type::overrideStandardTypes([$uppercaseString]); + + new Schema([ + 'query' => self::createQueryType(), + ]); + + // Schema A was built before the override. Its query type fields reference + // the old String singleton, but introspection types (rebuilt by + // overrideStandardTypes) now hold the new String. When getTypeMap() + // encounters both instances during extractTypes, it throws. + $this->expectException(InvariantViolation::class); + $this->expectExceptionMessage('contains multiple types named "String"'); + $schemaA->getTypeMap(); + } + + /** @throws InvariantViolation */ + private static function createUppercaseString(): CustomScalarType + { + return new CustomScalarType([ + 'name' => Type::STRING, + 'serialize' => static fn ($value): string => strtoupper((string) $value), + ]); + } + + /** @throws InvariantViolation */ + private static function createQueryType(): ObjectType + { + return new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'greeting' => [ + 'type' => Type::string(), + 'resolve' => static fn (): string => 'hello world', + ], + ], + ]); + } + /** @throws InvariantViolation */ private static function createCustomScalarType(string $name): CustomScalarType {