diff --git a/src/Utils/SchemaPrinter.php b/src/Utils/SchemaPrinter.php index 28c92edba..e1e3bd85c 100644 --- a/src/Utils/SchemaPrinter.php +++ b/src/Utils/SchemaPrinter.php @@ -5,7 +5,18 @@ namespace GraphQL\Utils; use GraphQL\Error\Error; +use GraphQL\Language\AST\ArgumentNode; +use GraphQL\Language\AST\BooleanValueNode; +use GraphQL\Language\AST\DirectiveNode; +use GraphQL\Language\AST\EnumValueNode; +use GraphQL\Language\AST\FloatValueNode; +use GraphQL\Language\AST\IntValueNode; +use GraphQL\Language\AST\ListValueNode; +use GraphQL\Language\AST\NullValueNode; +use GraphQL\Language\AST\ObjectValueNode; +use GraphQL\Language\AST\StringValueNode; use GraphQL\Language\BlockString; +use GraphQL\Language\Parser; use GraphQL\Language\Printer; use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\EnumType; @@ -30,23 +41,32 @@ use function count; use function explode; use function implode; +use function is_callable; +use function is_string; +use function json_encode; use function ksort; +use function ltrim; use function mb_strlen; +use function mb_strpos; use function sprintf; use function str_replace; -use function strlen; +use function trim; /** * Prints the contents of a Schema in schema definition language. * - * @phpstan-type Options array{commentDescriptions?: bool} + * @phpstan-type Options array{commentDescriptions?: bool, printDirectives?: callable(DirectiveNode): bool} * * - commentDescriptions: * Provide true to use preceding comments as the description. * This option is provided to ease adoption and will be removed in v16. + * - printDirectives + * Callable used to determine should be directive printed or not. */ class SchemaPrinter { + protected const LINE_LENGTH = 70; + /** * @param array $options * @phpstan-param Options $options @@ -226,7 +246,7 @@ protected static function printDescription(array $options, $def, string $indenta return static::printDescriptionWithComments($description, $indentation, $firstInBlock); } - $preferMultipleLines = mb_strlen($description) > 70; + $preferMultipleLines = static::isLineTooLong($description); $blockString = BlockString::print($description, '', $preferMultipleLines); $prefix = $indentation !== '' && ! $firstInBlock ? "\n" . $indentation @@ -256,37 +276,37 @@ protected static function printDescriptionWithComments(string $description, stri */ protected static function printArgs(array $options, array $args, string $indentation = ''): string { + // Empty? if (count($args) === 0) { return ''; } - // If every arg does not have a description, print them on one line. - if ( - Utils::every( - $args, - static function ($arg): bool { - return strlen($arg->description ?? '') === 0; - } - ) - ) { - return '(' . implode(', ', array_map('static::printInputValue', $args)) . ')'; - } - - return sprintf( - "(\n%s\n%s)", - implode( - "\n", - array_map( - static function (FieldArgument $arg, int $i) use ($indentation, $options): string { - return static::printDescription($options, $arg, ' ' . $indentation, $i === 0) . ' ' . $indentation . - static::printInputValue($arg); - }, - $args, - array_keys($args) - ) - ), - $indentation - ); + // Print arguments + $length = 0; + $arguments = []; + $description = false; + + foreach ($args as $i => $arg) { + $value = static::printArg($arg, $options, ' ' . $indentation, $i === 0); + $length += mb_strlen($value); + $description = $description || mb_strlen($arg->description ?? '') > 0; + $arguments[] = $value; + } + + // Return + return static::printChildrenBlock($arguments, '(', ')', $description || static::isLineTooLong($length), $indentation); + } + + /** + * @param array $options + * @phpstan-param Options $options + */ + protected static function printArg(FieldArgument $type, array $options, string $indentation = '', bool $firstInBlock = true): string + { + return static::printDescription($options, $type, $indentation, $firstInBlock) . + $indentation . + static::printInputValue($type) . + static::printTypeDirectives($type, $options, $indentation); } /** @@ -296,7 +316,10 @@ protected static function printInputValue($arg): string { $argDecl = $arg->name . ': ' . (string) $arg->getType(); if ($arg->defaultValueExists()) { - $argDecl .= ' = ' . Printer::doPrint(AST::astFromValue($arg->defaultValue, $arg->getType())); + // TODO Pass `options`. + $value = AST::astFromValue($arg->defaultValue, $arg->getType()); + $indentation = $arg instanceof InputObjectField ? ' ' : ' '; + $argDecl .= ' = ' . static::printValue($value, [], $indentation); } return $argDecl; @@ -308,7 +331,9 @@ protected static function printInputValue($arg): string */ protected static function printScalar(ScalarType $type, array $options): string { - return sprintf('%sscalar %s', static::printDescription($options, $type), $type->name); + return static::printDescription($options, $type) . + sprintf('scalar %s', $type->name) . + static::printTypeDirectives($type, $options); } /** @@ -320,6 +345,7 @@ protected static function printObject(ObjectType $type, array $options): string return static::printDescription($options, $type) . sprintf('type %s', $type->name) . self::printImplementedInterfaces($type) . + static::printTypeDirectives($type, $options) . static::printFields($options, $type); } @@ -333,13 +359,7 @@ protected static function printFields(array $options, $type): string $fields = array_values($type->getFields()); $fields = array_map( static function (FieldDefinition $f, int $i) use ($options): string { - return static::printDescription($options, $f, ' ', $i === 0) . - ' ' . - $f->name . - static::printArgs($options, $f->args, ' ') . - ': ' . - (string) $f->getType() . - static::printDeprecated($f); + return static::printField($f, $options, ' ', $i === 0); }, $fields, array_keys($fields) @@ -348,17 +368,32 @@ static function (FieldDefinition $f, int $i) use ($options): string { return self::printBlock($fields); } + /** + * @param array $options + * @phpstan-param Options $options + */ + protected static function printField(FieldDefinition $type, array $options, string $indentation = '', bool $firstInBlock = true): string + { + return static::printDescription($options, $type, $indentation, $firstInBlock) . + ' ' . + $type->name . + static::printArgs($options, $type->args, ' ') . + ': ' . + (string) $type->getType() . + static::printTypeDirectives($type, $options, $indentation); + } + /** * @param FieldDefinition|EnumValueDefinition $fieldOrEnumVal */ protected static function printDeprecated($fieldOrEnumVal): string { - $reason = $fieldOrEnumVal->deprecationReason; + $reason = static::getDeprecatedReason($fieldOrEnumVal); if ($reason === null) { return ''; } - if ($reason === '' || $reason === Directive::DEFAULT_DEPRECATION_REASON) { + if ($reason === '') { return ' @deprecated'; } @@ -390,6 +425,7 @@ protected static function printInterface(InterfaceType $type, array $options): s return static::printDescription($options, $type) . sprintf('interface %s', $type->name) . self::printImplementedInterfaces($type) . + static::printTypeDirectives($type, $options) . static::printFields($options, $type); } @@ -399,12 +435,21 @@ protected static function printInterface(InterfaceType $type, array $options): s */ protected static function printUnion(UnionType $type, array $options): string { - $types = $type->getTypes(); - $types = count($types) > 0 + $types = $type->getTypes(); + $types = count($types) > 0 ? ' = ' . implode(' | ', $types) : ''; + $directives = static::printTypeDirectives($type, $options, ''); + + if (static::isLineTooLong($directives)) { + $types = ltrim($types); + $directives .= "\n"; + } - return static::printDescription($options, $type) . 'union ' . $type->name . $types; + return static::printDescription($options, $type) . + 'union ' . $type->name . + $directives . + $types; } /** @@ -416,10 +461,7 @@ protected static function printEnum(EnumType $type, array $options): string $values = $type->getValues(); $values = array_map( static function (EnumValueDefinition $value, int $i) use ($options): string { - return static::printDescription($options, $value, ' ', $i === 0) . - ' ' . - $value->name . - static::printDeprecated($value); + return static::printEnumValue($value, $options, ' ', $i === 0); }, $values, array_keys($values) @@ -427,9 +469,22 @@ static function (EnumValueDefinition $value, int $i) use ($options): string { return static::printDescription($options, $type) . sprintf('enum %s', $type->name) . + static::printTypeDirectives($type, $options) . static::printBlock($values); } + /** + * @param array $options + * @phpstan-param Options $options + */ + protected static function printEnumValue(EnumValueDefinition $type, array $options, string $indentation = '', bool $firstInBlock = false): string + { + return static::printDescription($options, $type, $indentation, $firstInBlock) . + ' ' . + $type->name . + static::printTypeDirectives($type, $options, $indentation); + } + /** * @param array $options * @phpstan-param Options $options @@ -438,8 +493,8 @@ protected static function printInputObject(InputObjectType $type, array $options { $fields = array_values($type->getFields()); $fields = array_map( - static function ($f, $i) use ($options): string { - return static::printDescription($options, $f, ' ', ! $i) . ' ' . static::printInputValue($f); + static function (InputObjectField $f, $i) use ($options): string { + return static::printInputObjectField($f, $options, ' ', ! $i); }, $fields, array_keys($fields) @@ -447,16 +502,209 @@ static function ($f, $i) use ($options): string { return static::printDescription($options, $type) . sprintf('input %s', $type->name) . + static::printTypeDirectives($type, $options) . static::printBlock($fields); } + /** + * @param array $options + * @phpstan-param Options $options + */ + protected static function printInputObjectField(InputObjectField $type, array $options, string $indentation = '', bool $firstInBlock = true): string + { + return static::printDescription($options, $type, $indentation, $firstInBlock) . + ' ' . + static::printInputValue($type) . + static::printTypeDirectives($type, $options, $indentation); + } + /** * @param array $items */ protected static function printBlock(array $items): string { - return count($items) > 0 - ? " {\n" . implode("\n", $items) . "\n}" - : ''; + // TODO Deprecated? + return static::printChildrenBlock($items, ' {', '}', true); + } + + /** + * @param Type|EnumValueDefinition|EnumType|InterfaceType|FieldDefinition|UnionType|InputObjectType|InputObjectField|FieldArgument $type + * @param array $options + * @phpstan-param Options $options + */ + protected static function printTypeDirectives($type, array $options, string $indentation = ''): string + { + // Enabled? + $filter = $options['printDirectives'] ?? null; + $deprecatable = $type instanceof EnumValueDefinition || $type instanceof FieldDefinition; + + if (! is_callable($filter)) { + if ($deprecatable) { + return static::printDeprecated($type); + } + + return ''; + } + + // Collect directives + $node = $type->astNode; + $nodeDirectives = []; + + if ($node !== null) { + $nodeDirectives = $node->directives; + } elseif ($deprecatable && $type->deprecationReason !== null) { + // TODO Is there a better way to create directive node? + $name = Directive::DEPRECATED_NAME; + $reason = json_encode(static::getDeprecatedReason($type)); + $nodeDirectives[] = Parser::directive("@{$name}(reason: {$reason})"); + } + + if (count($nodeDirectives) === 0) { + return ''; + } + + // Print + $length = 0; + $directives = []; + + foreach ($nodeDirectives as $nodeDirective) { + if (! $filter($nodeDirective)) { + continue; + } + + $directive = static::printTypeDirective($nodeDirective, $options, $indentation); + $length += mb_strlen($directive); + $directives[] = $directive; + } + + // Multiline? + $serialized = ''; + + if (count($directives) > 0) { + $delimiter = static::isLineTooLong($length) + ? "\n{$indentation}" + : ' '; + $serialized = $delimiter . implode($delimiter, $directives); + } + + // Return + return $serialized; + } + + /** + * @param array $options + * @phpstan-param Options $options + */ + protected static function printTypeDirective(DirectiveNode $directive, array $options, string $indentation): string + { + $length = 0; + $arguments = []; + + foreach ($directive->arguments as $argument) { + $value = static::printArgument($argument, $options, ' ' . $indentation); + $length += mb_strlen($value); + $arguments[] = $value; + } + + return "@{$directive->name->value}" . + static::printChildrenBlock($arguments, '(', ')', static::isLineTooLong($length), $indentation); + } + + /** + * @param array $options + * @phpstan-param Options $options + */ + protected static function printArgument(ArgumentNode $argument, array $options, string $indentation): string + { + return "{$indentation}{$argument->name->value}: " . + self::printValue($argument->value, $options, $indentation); + } + + /** + * @param ObjectValueNode|ListValueNode|BooleanValueNode|IntValueNode|FloatValueNode|EnumValueNode|StringValueNode|NullValueNode|null $value + * @param array $options + * @phpstan-param Options $options + */ + protected static function printValue($value, array $options, string $indentation): string + { + $result = ''; + + if ($value instanceof ListValueNode) { + $length = 0; + $values = []; + + foreach ($value->values as $item) { + $string = ' ' . $indentation . Printer::doPrint($item); + $length += mb_strlen($string); + $values[] = $string; + } + + $result = static::printChildrenBlock($values, '[', ']', static::isLineTooLong($length), $indentation); + } else { + $result = Printer::doPrint($value); + } + + return $result; + } + + /** + * @param array $lines + */ + protected static function printChildrenBlock(array $lines, string $begin, string $end, bool $multiline, string $indentation = ''): string + { + $block = ''; + + if (count($lines) > 0) { + if ($multiline) { + $wrapped = false; + + for ($i = 0, $c = count($lines); $i < $c; $i++) { + // If line contains LF we wrap it by empty lines + $line = trim($lines[$i], "\n"); + $wrap = mb_strpos($line, "\n") !== false; + + if ($i === 0) { + $line = "\n{$line}"; + } + + if (($wrap && $i > 0) || $wrapped) { + $line = "\n{$line}"; + } + + $block .= "{$line}\n"; + $wrapped = $wrap; + } + + $block .= $indentation; + } else { + $block = implode(', ', array_map('trim', $lines)); + } + + $block = $begin . $block . $end; + } + + return $block; + } + + /** + * @param string|int $string + */ + protected static function isLineTooLong($string): bool + { + return (is_string($string) ? mb_strlen($string) : $string) > self::LINE_LENGTH; + } + + /** + * @param FieldDefinition|EnumValueDefinition $fieldOrEnumVal + */ + protected static function getDeprecatedReason($fieldOrEnumVal): ?string + { + $reason = $fieldOrEnumVal->deprecationReason; + + if ($reason === '' || $reason === Directive::DEFAULT_DEPRECATION_REASON) { + $reason = ''; + } + + return $reason; } } diff --git a/tests/Utils/BuildSchemaTest.php b/tests/Utils/BuildSchemaTest.php index 6823d1c72..626e74923 100644 --- a/tests/Utils/BuildSchemaTest.php +++ b/tests/Utils/BuildSchemaTest.php @@ -155,6 +155,7 @@ enum Color { """Not a creative color""" GREEN + BLUE } @@ -187,6 +188,7 @@ enum Color { # Not a creative color GREEN + BLUE } @@ -492,7 +494,7 @@ public function testCanBuildRecursiveUnion(): void { $schema = BuildSchema::build(' union Hello = Hello - + type Query { hello: Hello } @@ -758,7 +760,7 @@ interfaceField: String type TestType implements TestInterface { interfaceField: String } - + scalar TestScalar directive @test(arg: TestScalar) on FIELD diff --git a/tests/Utils/SchemaPrinterTest.php b/tests/Utils/SchemaPrinterTest.php index ac29eed9a..a9603073e 100644 --- a/tests/Utils/SchemaPrinterTest.php +++ b/tests/Utils/SchemaPrinterTest.php @@ -19,6 +19,8 @@ use GraphQL\Utils\SchemaPrinter; use PHPUnit\Framework\TestCase; +use function str_pad; + class SchemaPrinterTest extends TestCase { private static function assertPrintedSchemaEquals(string $expected, Schema $schema): void @@ -1273,4 +1275,357 @@ enum __TypeKind { GRAPHQL; self::assertEquals($expected, $output); } + + public function testPrintDirectivesAst(): void + { + $text = str_pad('a', 80, 'a'); + $schema = /** @lang GraphQL */ << static function (): bool { + return true; + }, + ]); + + self::assertEquals($expected, $actual); + } + + public function testPrintDirectivesDeprecated(): void + { + $text = str_pad('a', 80, 'a'); + $enum = new EnumType([ + 'name' => 'Aaa', + 'values' => [ + 'A' => [ + 'value' => 'AAA', + 'description' => 'AAAAAAAAAAAAA', + 'deprecationReason' => 'deprecated for tests', + ], + 'B' => [ + 'value' => 'AAA', + 'deprecationReason' => $text, + ], + ], + ]); + $schema = new Schema(['types' => [$enum]]); + $actual = SchemaPrinter::doPrint($schema, [ + 'printDirectives' => static function (): bool { + return true; + }, + ]); + + self::assertEquals( + <<<'GRAPHQL' + enum Aaa { + """AAAAAAAAAAAAA""" + A @deprecated(reason: "deprecated for tests") + + B + @deprecated( + reason: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ) + } + + GRAPHQL, + $actual + ); + } }