Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/class-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,22 @@ static function builtInTypes(): array
static function builtInScalars(): array
```

```php
/**
* Determines if the given type is a built-in scalar (Int, Float, String, Boolean, ID).
*
* Does not unwrap NonNull/List wrappers — checks the type instance directly.
* ScalarType is a NamedType, so {@see Type::getNamedType()} is unnecessary.
*
* @param mixed $type
*
* @phpstan-assert-if-true ScalarType $type
*
* @api
*/
static function isBuiltInScalar($type): bool
```

```php
/**
* Determines if the given type is an input type.
Expand Down
7 changes: 4 additions & 3 deletions src/Executor/ReferenceExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -912,13 +912,14 @@ protected function completeValue(
// instance than `resolveType` or $field->getType() or $arg->getType()
assert(
$returnType === $this->exeContext->schema->getType($returnType->name)
|| in_array($returnType->name, Type::BUILT_IN_SCALAR_NAMES, true),
|| Type::isBuiltInScalar($returnType),
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) {
if (Type::isBuiltInScalar($returnType)) {
$schemaType = $this->exeContext->schema->getType($returnType->name);
assert($schemaType instanceof LeafType, "Schema must provide a LeafType for built-in scalar \"{$returnType->name}\".");
$returnType = $schemaType;
}

Expand Down
2 changes: 1 addition & 1 deletion src/Executor/Values.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ public static function getVariableValues(Schema $schema, NodeList $varDefNodes,
} else {
// Otherwise, a non-null value was provided, coerce it to the expected
// type or report an error if coercion fails.
$coerced = Value::coerceInputValue($value, $varType);
$coerced = Value::coerceInputValue($value, $varType, null, $schema);

$coercionErrors = $coerced['errors'];
if ($coercionErrors !== null) {
Expand Down
18 changes: 18 additions & 0 deletions src/Type/Definition/Type.php
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,24 @@ public static function overrideStandardTypes(array $types): void
}
}

/**
* Determines if the given type is a built-in scalar (Int, Float, String, Boolean, ID).
*
* Does not unwrap NonNull/List wrappers — checks the type instance directly.
* ScalarType is a NamedType, so {@see Type::getNamedType()} is unnecessary.
*
* @param mixed $type
*
* @phpstan-assert-if-true ScalarType $type
*
* @api
*/
public static function isBuiltInScalar($type): bool
{
return $type instanceof ScalarType
&& in_array($type->name, self::BUILT_IN_SCALAR_NAMES, true);
Comment thread
spawnia marked this conversation as resolved.
}

/**
* Determines if the given type is an input type.
*
Expand Down
19 changes: 19 additions & 0 deletions src/Utils/TypeComparators.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ public static function isEqualType(Type $typeA, Type $typeB): bool
return true;
}

if (self::areSameBuiltInScalar($typeA, $typeB)) {
return true;
}

// If either type is non-null, the other must also be non-null.
if ($typeA instanceof NonNull && $typeB instanceof NonNull) {
return self::isEqualType($typeA->getWrappedType(), $typeB->getWrappedType());
Expand Down Expand Up @@ -46,6 +50,10 @@ public static function isTypeSubTypeOf(Schema $schema, Type $maybeSubType, Type
return true;
}

if (self::areSameBuiltInScalar($maybeSubType, $superType)) {
return true;
}

// If superType is non-null, maybeSubType must also be nullable.
if ($superType instanceof NonNull) {
if ($maybeSubType instanceof NonNull) {
Expand Down Expand Up @@ -84,4 +92,15 @@ public static function isTypeSubTypeOf(Schema $schema, Type $maybeSubType, Type

return false;
}

/**
* Built-in scalars may exist as different instances when a type loader
* overrides them. Compare by name to handle this case.
*/
private static function areSameBuiltInScalar(Type $typeA, Type $typeB): bool
{
return Type::isBuiltInScalar($typeA)
&& Type::isBuiltInScalar($typeB)
&& $typeA->name() === $typeB->name();
}
}
23 changes: 16 additions & 7 deletions src/Utils/Value.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use GraphQL\Type\Definition\NonNull;
use GraphQL\Type\Definition\ScalarType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema;

/**
* @phpstan-type CoercedValue array{errors: null, value: mixed}
Expand All @@ -37,7 +38,7 @@ class Value
*
* @phpstan-return CoercedValue|CoercedErrors
*/
public static function coerceInputValue($value, InputType $type, ?array $path = null): array
public static function coerceInputValue($value, InputType $type, ?array $path = null, ?Schema $schema = null): array
{
if ($type instanceof NonNull) {
if ($value === null) {
Expand All @@ -47,18 +48,24 @@ public static function coerceInputValue($value, InputType $type, ?array $path =
}

// @phpstan-ignore-next-line wrapped type is known to be input type after schema validation
return self::coerceInputValue($value, $type->getWrappedType(), $path);
return self::coerceInputValue($value, $type->getWrappedType(), $path, $schema);
}

if ($value === null) {
// Explicitly return the value null.
return self::ofValue(null);
}

// Account for type loader returning a different scalar instance than
// the built-in singleton used in field definitions. Resolve the actual
// type from the schema to ensure the correct parseValue() is called.
if ($schema !== null && Type::isBuiltInScalar($type)) {
$schemaType = $schema->getType($type->name);
assert($schemaType instanceof ScalarType, "Schema must provide a ScalarType for built-in scalar \"{$type->name}\".");
$type = $schemaType;
}

if ($type instanceof ScalarType || $type instanceof EnumType) {
// Scalars and Enums determine if a input value is valid via parseValue(), which can
// throw to indicate failure. If it throws, maintain a reference to
// the original error.
try {
return self::ofValue($type->parseValue($value));
} catch (\Throwable $error) {
Expand Down Expand Up @@ -88,7 +95,8 @@ public static function coerceInputValue($value, InputType $type, ?array $path =
$coercedItem = self::coerceInputValue(
$itemValue,
$itemType,
[...$path ?? [], $index]
[...$path ?? [], $index],
$schema,
);

if (isset($coercedItem['errors'])) {
Expand All @@ -104,7 +112,7 @@ public static function coerceInputValue($value, InputType $type, ?array $path =
}

// Lists accept a non-list value as a list of one.
$coercedItem = self::coerceInputValue($value, $itemType);
$coercedItem = self::coerceInputValue($value, $itemType, null, $schema);

return isset($coercedItem['errors'])
? $coercedItem
Expand Down Expand Up @@ -133,6 +141,7 @@ public static function coerceInputValue($value, InputType $type, ?array $path =
$fieldValue,
$field->getType(),
[...$path ?? [], $fieldName],
$schema,
);

if (isset($coercedField['errors'])) {
Expand Down
35 changes: 35 additions & 0 deletions tests/Type/Definition/TypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

namespace GraphQL\Tests\Type\Definition;

use GraphQL\Type\Definition\CustomScalarType;
use GraphQL\Type\Definition\EnumType;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use PHPUnit\Framework\TestCase;

Expand All @@ -13,4 +16,36 @@ public function testWrappingNonNullableTypeWithNonNull(): void

self::assertSame($nonNullableString, Type::nonNull($nonNullableString));
}

public function testIsBuiltInScalarReturnsTrueForBuiltInScalars(): void
{
self::assertTrue(Type::isBuiltInScalar(Type::int())); // @phpstan-ignore staticMethod.alreadyNarrowedType
self::assertTrue(Type::isBuiltInScalar(Type::float())); // @phpstan-ignore staticMethod.alreadyNarrowedType
self::assertTrue(Type::isBuiltInScalar(Type::string())); // @phpstan-ignore staticMethod.alreadyNarrowedType
self::assertTrue(Type::isBuiltInScalar(Type::boolean())); // @phpstan-ignore staticMethod.alreadyNarrowedType
self::assertTrue(Type::isBuiltInScalar(Type::id())); // @phpstan-ignore staticMethod.alreadyNarrowedType
}

public function testIsBuiltInScalarReturnsTrueForCustomScalarWithBuiltInName(): void
{
self::assertTrue(Type::isBuiltInScalar(new CustomScalarType(['name' => Type::STRING]))); // @phpstan-ignore staticMethod.alreadyNarrowedType
self::assertTrue(Type::isBuiltInScalar(new CustomScalarType(['name' => Type::ID]))); // @phpstan-ignore staticMethod.alreadyNarrowedType
}

public function testIsBuiltInScalarReturnsFalseForNonScalarTypes(): void
{
self::assertFalse(Type::isBuiltInScalar(new ObjectType(['name' => 'Obj', 'fields' => []]))); // @phpstan-ignore staticMethod.impossibleType
self::assertFalse(Type::isBuiltInScalar(new EnumType(['name' => 'E', 'values' => ['A']]))); // @phpstan-ignore staticMethod.impossibleType
}

public function testIsBuiltInScalarReturnsFalseForWrappedTypes(): void
{
self::assertFalse(Type::isBuiltInScalar(Type::nonNull(Type::string()))); // @phpstan-ignore staticMethod.impossibleType
self::assertFalse(Type::isBuiltInScalar(Type::listOf(Type::string()))); // @phpstan-ignore staticMethod.impossibleType
}

public function testIsBuiltInScalarReturnsFalseForCustomScalarWithNonBuiltInName(): void
{
self::assertFalse(Type::isBuiltInScalar(new CustomScalarType(['name' => 'MyScalar']))); // @phpstan-ignore staticMethod.alreadyNarrowedType
}
}
137 changes: 137 additions & 0 deletions tests/Type/ScalarOverridesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

use GraphQL\Error\InvariantViolation;
use GraphQL\GraphQL;
use GraphQL\Language\AST\StringValueNode;
use GraphQL\Type\Definition\CustomScalarType;
use GraphQL\Type\Definition\InputObjectType;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\ScalarType;
use GraphQL\Type\Definition\Type;
Expand Down Expand Up @@ -204,12 +206,147 @@ public function testNonOverriddenScalarsAreUnaffected(): void
self::assertSame('abc-123', $data['identifier']);
}

public function testTypeLoaderOverrideWithVariableOfOverriddenBuiltInScalarType(): void
{
$customID = self::createCustomID(static fn ($value): string => (string) $value);

$queryType = new ObjectType([
'name' => 'Query',
'fields' => [
'node' => [
'type' => Type::string(),
'args' => [
'id' => Type::nonNull(Type::id()),
],
'resolve' => static fn ($root, array $args): string => 'node-' . $args['id'],
],
],
]);

$types = ['Query' => $queryType, 'ID' => $customID];

$schema = new Schema([
'query' => $queryType,
'typeLoader' => static fn (string $name): ?Type => $types[$name] ?? null,
]);

$schema->assertValid();

$result = GraphQL::executeQuery($schema, 'query ($id: ID!) { node(id: $id) }', null, null, ['id' => 'abc-123']);

self::assertEmpty($result->errors, isset($result->errors[0]) ? $result->errors[0]->getMessage() : '');
self::assertSame(['data' => ['node' => 'node-abc-123']], $result->toArray());
}

public function testTypeLoaderOverrideWithNullableVariableOfOverriddenBuiltInScalarType(): void
{
$customString = self::createUppercaseString();

$queryType = new ObjectType([
'name' => 'Query',
'fields' => [
'echo' => [
'type' => Type::string(),
'args' => [
'text' => Type::string(),
],
'resolve' => static fn ($root, array $args): ?string => $args['text'] ?? null,
],
],
]);

$types = ['Query' => $queryType, 'String' => $customString];

$schema = new Schema([
'query' => $queryType,
'typeLoader' => static fn (string $name): ?Type => $types[$name] ?? null,
]);

$schema->assertValid();

$result = GraphQL::executeQuery($schema, 'query ($text: String) { echo(text: $text) }', null, null, ['text' => 'hello']);

self::assertEmpty($result->errors, isset($result->errors[0]) ? $result->errors[0]->getMessage() : '');
self::assertSame(['data' => ['echo' => 'HELLO']], $result->toArray());
}

public function testTypeLoaderOverrideWithInputObjectFieldOfOverriddenBuiltInScalarType(): void
{
$customID = self::createCustomID(static fn ($value): string => 'custom-' . $value);

$inputType = new InputObjectType([
'name' => 'NodeInput',
'fields' => [
'id' => Type::nonNull(Type::id()),
'label' => Type::string(),
],
]);

$queryType = new ObjectType([
'name' => 'Query',
'fields' => [
'node' => [
'type' => Type::string(),
'args' => [
'input' => Type::nonNull($inputType),
],
'resolve' => static fn ($root, array $args): string => $args['input']['id'] . ':' . ($args['input']['label'] ?? ''),
],
],
]);

$types = ['Query' => $queryType, 'ID' => $customID, 'NodeInput' => $inputType];

$schema = new Schema([
'query' => $queryType,
'typeLoader' => static fn (string $name): ?Type => $types[$name] ?? null,
]);

$schema->assertValid();

$result = GraphQL::executeQuery(
$schema,
'query ($input: NodeInput!) { node(input: $input) }',
null,
null,
['input' => ['id' => 'abc-123', 'label' => 'test']],
);

self::assertEmpty($result->errors, isset($result->errors[0]) ? $result->errors[0]->getMessage() : '');
self::assertSame(['data' => ['node' => 'custom-abc-123:test']], $result->toArray());
}

/** @throws InvariantViolation */
private static function createCustomID(\Closure $parseValue): CustomScalarType
{
return new CustomScalarType([
'name' => Type::ID,
'serialize' => static fn ($value): string => (string) $value,
'parseValue' => $parseValue,
'parseLiteral' => static function ($node): string {
if (! $node instanceof StringValueNode) {
throw new \Exception('Expected a string literal for ID.');
}

return $node->value;
},
]);
}

/** @throws InvariantViolation */
private static function createUppercaseString(): CustomScalarType
{
return new CustomScalarType([
'name' => Type::STRING,
'serialize' => static fn ($value): string => strtoupper((string) $value),
'parseValue' => static fn ($value): string => (string) $value,
'parseLiteral' => static function ($node): string {
if (! $node instanceof StringValueNode) {
throw new \Exception('Expected a string literal for String.');
}

return $node->value;
},
]);
}

Expand Down
Loading
Loading