diff --git a/CHANGELOG.md b/CHANGELOG.md index 597124776..2fcd9e7ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ You can find and compare releases at the [GitHub release page](https://github.co ## Unreleased +### Added + +- Allow injecting pre-built type instances when building a schema from SDL https://github.com/webonyx/graphql-php/issues/681 + ## v15.37.2 ### Changed diff --git a/docs/class-reference.md b/docs/class-reference.md index 81cd48e81..ad31844ae 100644 --- a/docs/class-reference.md +++ b/docs/class-reference.md @@ -2832,6 +2832,7 @@ See [schema definition language docs](schema-definition-language.md) for details * * @param DocumentNode|Source|string $source * @param array $options + * @param iterable $types * * @phpstan-param TypeConfigDecorator|null $typeConfigDecorator * @phpstan-param FieldConfigDecorator|null $fieldConfigDecorator @@ -2849,7 +2850,8 @@ static function build( $source, ?callable $typeConfigDecorator = null, array $options = [], - ?callable $fieldConfigDecorator = null + ?callable $fieldConfigDecorator = null, + iterable $types = [] ): GraphQL\Type\Schema ``` @@ -2863,6 +2865,7 @@ static function build( * has no resolve methods, so execution will use default resolvers. * * @param array $options + * @param iterable $types * * @phpstan-param TypeConfigDecorator|null $typeConfigDecorator * @phpstan-param FieldConfigDecorator|null $fieldConfigDecorator @@ -2879,7 +2882,8 @@ static function buildAST( GraphQL\Language\AST\DocumentNode $ast, ?callable $typeConfigDecorator = null, array $options = [], - ?callable $fieldConfigDecorator = null + ?callable $fieldConfigDecorator = null, + iterable $types = [] ): GraphQL\Type\Schema ``` diff --git a/docs/schema-definition-language.md b/docs/schema-definition-language.md index b0caa4efc..3f852d070 100644 --- a/docs/schema-definition-language.md +++ b/docs/schema-definition-language.md @@ -55,6 +55,34 @@ $schema = BuildSchema::build($contents, $typeConfigDecorator); You can learn more about using `$typeConfigDecorator` in [examples/05-type-config-decorator](https://github.com/webonyx/graphql-php/blob/master/examples/05-type-config-decorator). +## Custom scalar and enum types + +When building a schema from SDL, scalar types are stubs — they serialize and parse values as-is. +To attach real behavior (validation, coercion, PHP-backed enum values), pass pre-built type instances via the `types` parameter: + +```php +use GraphQL\Type\Definition\CustomScalarType; +use GraphQL\Utils\BuildSchema; + +$dateType = new CustomScalarType([ + 'name' => 'Date', + 'serialize' => static fn ($value) => $value->format('Y-m-d'), + 'parseValue' => static fn ($value) => new DateTimeImmutable($value), +]); + +$schema = BuildSchema::build( + file_get_contents('schema.graphql'), + null, + [], + null, + [$dateType], +); +``` + +Types whose names match SDL definitions replace them entirely, including their extensions. +This works for any kind of type, so SDL can reference code-first types by declaring a stub such as `type User`. +Types whose names are absent from the SDL are registered as extras and remain reachable via `$schema->getType()`. + ## Performance considerations Method **BuildSchema::build()** produces a [lazy schema](schema-definition.md#lazy-loading-of-types) automatically, diff --git a/src/Utils/ASTDefinitionBuilder.php b/src/Utils/ASTDefinitionBuilder.php index 30fd51a81..e7609a3ae 100644 --- a/src/Utils/ASTDefinitionBuilder.php +++ b/src/Utils/ASTDefinitionBuilder.php @@ -87,9 +87,13 @@ class ASTDefinitionBuilder /** @var array> */ private array $typeExtensionsMap; + /** @var array */ + private array $typeOverrides; + /** * @param array $typeDefinitionsMap * @param array> $typeExtensionsMap + * @param array $typeOverrides * * @phpstan-param ResolveType $resolveType * @phpstan-param TypeConfigDecorator|null $typeConfigDecorator @@ -101,13 +105,15 @@ public function __construct( array $typeExtensionsMap, callable $resolveType, ?callable $typeConfigDecorator = null, - ?callable $fieldConfigDecorator = null + ?callable $fieldConfigDecorator = null, + array $typeOverrides = [] ) { $this->typeDefinitionsMap = $typeDefinitionsMap; $this->typeExtensionsMap = $typeExtensionsMap; $this->resolveType = $resolveType; $this->typeConfigDecorator = $typeConfigDecorator; $this->fieldConfigDecorator = $fieldConfigDecorator; + $this->typeOverrides = $typeOverrides; $this->cache = Type::builtInTypes(); } @@ -264,6 +270,10 @@ private function internalBuildType(string $typeName, ?Node $typeNode = null): Ty return $this->cache[$typeName]; } + if (isset($this->typeOverrides[$typeName])) { + return $this->cache[$typeName] = $this->typeOverrides[$typeName]; + } + if (isset($this->typeDefinitionsMap[$typeName])) { $type = $this->makeSchemaDef($this->typeDefinitionsMap[$typeName]); diff --git a/src/Utils/BuildSchema.php b/src/Utils/BuildSchema.php index 863946f92..e4ded7744 100644 --- a/src/Utils/BuildSchema.php +++ b/src/Utils/BuildSchema.php @@ -14,6 +14,7 @@ use GraphQL\Language\Parser; use GraphQL\Language\Source; use GraphQL\Type\Definition\Directive; +use GraphQL\Type\Definition\NamedType; use GraphQL\Type\Definition\Type; use GraphQL\Type\Schema; use GraphQL\Type\SchemaConfig; @@ -68,8 +69,12 @@ class BuildSchema */ private array $options; + /** @var iterable */ + private iterable $types; + /** * @param array $options + * @param iterable $types * * @phpstan-param TypeConfigDecorator|null $typeConfigDecorator * @phpstan-param BuildSchemaOptions $options @@ -78,12 +83,14 @@ public function __construct( DocumentNode $ast, ?callable $typeConfigDecorator = null, array $options = [], - ?callable $fieldConfigDecorator = null + ?callable $fieldConfigDecorator = null, + iterable $types = [] ) { $this->ast = $ast; $this->typeConfigDecorator = $typeConfigDecorator; $this->options = $options; $this->fieldConfigDecorator = $fieldConfigDecorator; + $this->types = $types; } /** @@ -92,6 +99,7 @@ public function __construct( * * @param DocumentNode|Source|string $source * @param array $options + * @param iterable $types * * @phpstan-param TypeConfigDecorator|null $typeConfigDecorator * @phpstan-param FieldConfigDecorator|null $fieldConfigDecorator @@ -109,13 +117,14 @@ public static function build( $source, ?callable $typeConfigDecorator = null, array $options = [], - ?callable $fieldConfigDecorator = null + ?callable $fieldConfigDecorator = null, + iterable $types = [] ): Schema { $doc = $source instanceof DocumentNode ? $source : Parser::parse($source); - return self::buildAST($doc, $typeConfigDecorator, $options, $fieldConfigDecorator); + return self::buildAST($doc, $typeConfigDecorator, $options, $fieldConfigDecorator, $types); } /** @@ -127,6 +136,7 @@ public static function build( * has no resolve methods, so execution will use default resolvers. * * @param array $options + * @param iterable $types * * @phpstan-param TypeConfigDecorator|null $typeConfigDecorator * @phpstan-param FieldConfigDecorator|null $fieldConfigDecorator @@ -143,9 +153,10 @@ public static function buildAST( DocumentNode $ast, ?callable $typeConfigDecorator = null, array $options = [], - ?callable $fieldConfigDecorator = null + ?callable $fieldConfigDecorator = null, + iterable $types = [] ): Schema { - return (new self($ast, $typeConfigDecorator, $options, $fieldConfigDecorator))->buildSchema(); + return (new self($ast, $typeConfigDecorator, $options, $fieldConfigDecorator, $types))->buildSchema(); } /** @@ -201,6 +212,27 @@ public function buildSchema(): Schema 'subscription' => 'Subscription', ]; + /** @var array $typeOverrides */ + $typeOverrides = []; + /** @var array $extraTypesMap */ + $extraTypesMap = []; + foreach ($this->types as $type) { + // @phpstan-ignore function.alreadyNarrowedType, instanceof.alwaysTrue (unnecessary according to types, but can happen during runtime) + assert($type instanceof NamedType, 'Types passed to BuildSchema must be named types, got: ' . Utils::printSafe($type) . '.'); + $typeName = $type->name; + $knownType = $typeOverrides[$typeName] ?? $extraTypesMap[$typeName] ?? $type; + assert( + $knownType === $type, + "Schema must contain unique named types but contains multiple types named \"{$typeName}\" (see https://webonyx.github.io/graphql-php/type-definitions/#type-registry).", + ); + + if (isset($typeDefinitionsMap[$typeName])) { + $typeOverrides[$typeName] = $type; + } else { + $extraTypesMap[$typeName] = $type; + } + } + $definitionBuilder = new ASTDefinitionBuilder( $typeDefinitionsMap, $typeExtensionsMap, @@ -208,7 +240,8 @@ static function (string $typeName): Type { throw self::unknownType($typeName); }, $this->typeConfigDecorator, - $this->fieldConfigDecorator + $this->fieldConfigDecorator, + $typeOverrides ); $directives = array_map( @@ -256,12 +289,16 @@ static function (string $typeName): Type { ->setSubscription(isset($operationTypes['subscription']) ? $definitionBuilder->maybeBuildType($operationTypes['subscription']) : null) - ->setTypeLoader(static fn (string $name): ?Type => $definitionBuilder->maybeBuildType($name)) + ->setTypeLoader(static fn (string $name): ?Type => $definitionBuilder->maybeBuildType($name) + ?? ($extraTypesMap[$name] ?? null)) ->setDirectives($directives) ->setAstNode($schemaDef) - ->setTypes(fn (): array => array_map( - static fn (TypeDefinitionNode $def): Type => $definitionBuilder->buildType($def->getName()->value), - $typeDefinitionsMap, + ->setTypes(fn (): array => array_merge( + array_map( + static fn (TypeDefinitionNode $def): Type => $definitionBuilder->buildType($def->getName()->value), + $typeDefinitionsMap, + ), + array_values($extraTypesMap), )) ); } diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php index 8cf75f0d4..b79133da3 100644 --- a/tests/Utils/BuildSchemaTest.php +++ b/tests/Utils/BuildSchemaTest.php @@ -24,6 +24,7 @@ use GraphQL\Language\Parser; use GraphQL\Language\Printer; use GraphQL\Tests\TestCaseBase; +use GraphQL\Type\Definition\CustomScalarType; use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\EnumType; use GraphQL\Type\Definition\EnumValueDefinition; @@ -1496,6 +1497,129 @@ interface Hello { self::assertSame('My description of Hello', $hello->description); } + public function testBuildSchemaWithTypeOverrides(): void + { + $sdl = ' + schema { + query: Query + } + + type Query { + value: MyScalar + status: Status + } + + scalar MyScalar + + enum Status { + ACTIVE + INACTIVE + } + '; + + $myScalar = new CustomScalarType([ + 'name' => 'MyScalar', + 'serialize' => static fn ($value) => 'serialized:' . $value, + 'parseValue' => static fn ($value) => 'parsed:' . $value, + ]); + + $myEnum = new EnumType([ + 'name' => 'Status', + 'values' => [ + 'ACTIVE' => [ + 'value' => 1, + ], + 'INACTIVE' => [ + 'value' => 0, + ], + ], + ]); + + $extraType = new ObjectType([ + 'name' => 'ExtraType', + 'fields' => [ + 'id' => \GraphQL\Type\Definition\Type::string(), + ], + ]); + + $schema = BuildSchema::build($sdl, null, [], null, [$myScalar, $myEnum, $extraType]); + + $scalar = $schema->getType('MyScalar'); + self::assertSame($myScalar, $scalar); + + $enum = $schema->getType('Status'); + self::assertSame($myEnum, $enum); + + $extra = $schema->getType('ExtraType'); + self::assertSame($extraType, $extra); + + // Verify the custom scalar serialize/parseValue are actually used + $result = GraphQL::executeQuery( + $schema, + '{ value }', + ['value' => 'hello'] + ); + self::assertSame(['value' => 'serialized:hello'], $result->data); + } + + public function testBuildSchemaWithObjectTypeOverride(): void + { + $sdl = ' + type Query { + user: User + } + + type User + '; + + $user = new ObjectType([ + 'name' => 'User', + 'fields' => [ + 'name' => [ + 'type' => Type::string(), + 'resolve' => static fn (): string => 'resolved in PHP', + ], + ], + ]); + + $schema = BuildSchema::build($sdl, null, [], null, [$user]); + $schema->assertValid(); + + $result = GraphQL::executeQuery( + $schema, + '{ user { name } }', + ['user' => []] + ); + self::assertSame(['user' => ['name' => 'resolved in PHP']], $result->data); + } + + public function testBuildSchemaAllowsSameTypeInstanceTwice(): void + { + $date = new CustomScalarType(['name' => 'Date']); + + $schema = BuildSchema::build('type Query { date: Date } scalar Date', null, [], null, [$date, $date]); + + self::assertSame($date, $schema->getType('Date')); + } + + public function testBuildSchemaAssertsNamedTypesAtDevelopmentTime(): void + { + $this->expectException(\AssertionError::class); + // @phpstan-ignore-next-line intentionally wrong + BuildSchema::build('type Query { date: Date } scalar Date', null, [], null, [ + Type::nonNull(new CustomScalarType(['name' => 'Date'])), + ]); + } + + public function testBuildSchemaAssertsUniqueTypeNamesAtDevelopmentTime(): void + { + $this->expectException(\AssertionError::class); + BuildSchema::build('type Query { id: ID }', null, [], null, [ + new CustomScalarType(['name' => 'Date']), + new CustomScalarType(['name' => 'Date']), + ]); + } + public function testCreatesTypesLazily(): void { $sdl = '