ecodev/graphql-doctrine

从 Doctrine 实体和属性声明 GraphQL 类型


README

Build Status Code Quality Code Coverage Total Downloads Latest Stable Version License Join the chat at https://gitter.im/Ecodev/graphql-doctrine

一个库,用于从 Doctrine 实体、PHP 类型提示和属性中声明 GraphQL 类型,并与 webonyx/graphql-php 一起使用。

它从类型提示中读取大部分信息,从现有的 Doctrine 属性中完成一些事情,并允许通过特定的属性进行进一步的定制。然后,它会创建具有所有在 Doctrine 实体上找到的 getter 和 setter 字段的 ObjectTypeInputObjectType 实例。

它不会构建整个模式。用户需要使用自动类型和其他自定义类型来定义根查询。

快速开始

通过 composer 安装库

composer require ecodev/graphql-doctrine

并开始使用它

<?php

use GraphQLTests\Doctrine\Blog\Model\Post;
use GraphQLTests\Doctrine\Blog\Types\DateTimeType;
use GraphQLTests\Doctrine\Blog\Types\PostStatusType;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema;
use GraphQL\Doctrine\DefaultFieldResolver;
use GraphQL\Doctrine\Types;
use Laminas\ServiceManager\ServiceManager;

// Define custom types with a PSR-11 container
$customTypes = new ServiceManager([
    'invokables' => [
        DateTimeImmutable::class => DateTimeType::class,
        'PostStatus' => PostStatusType::class,
    ],
    'aliases' => [
        'datetime_immutable' => DateTimeImmutable::class, // Declare alias for Doctrine type to be used for filters
    ],
]);

// Configure the type registry
$types = new Types($entityManager, $customTypes);

// Configure default field resolver to be able to use getters
GraphQL::setDefaultFieldResolver(new DefaultFieldResolver());

// Build your Schema
$schema = new Schema([
    'query' => new ObjectType([
        'name' => 'query',
        'fields' => [
            'posts' => [
                'type' => Type::listOf($types->getOutput(Post::class)), // Use automated ObjectType for output
                'args' => [
                    [
                        'name' => 'filter',
                        'type' => $types->getFilter(Post::class), // Use automated filtering options
                    ],
                    [
                        'name' => 'sorting',
                        'type' => $types->getSorting(Post::class), // Use automated sorting options
                    ],
                ],
                'resolve' => function ($root, $args) use ($types): void {
                    $queryBuilder = $types->createFilteredQueryBuilder(Post::class, $args['filter'] ?? [], $args['sorting'] ?? []);

                    // execute query...
                },
            ],
        ],
    ]),
    'mutation' => new ObjectType([
        'name' => 'mutation',
        'fields' => [
            'createPost' => [
                'type' => Type::nonNull($types->getOutput(Post::class)),
                'args' => [
                    'input' => Type::nonNull($types->getInput(Post::class)), // Use automated InputObjectType for input
                ],
                'resolve' => function ($root, $args): void {
                    // create new post and flush...
                },
            ],
            'updatePost' => [
                'type' => Type::nonNull($types->getOutput(Post::class)),
                'args' => [
                    'id' => Type::nonNull(Type::id()), // Use standard API when needed
                    'input' => $types->getPartialInput(Post::class),  // Use automated InputObjectType for partial input for updates
                ],
                'resolve' => function ($root, $args): void {
                    // update existing post and flush...
                },
            ],
        ],
    ]),
]);

用法

公共 API 限制在 TypesInterface 的公共方法、Types 的构造函数和属性上。

以下是对 TypesInterface 的简要概述

  • $types->get() 获取自定义类型
  • $types->getOutput() 获取用于查询的 ObjectType
  • $types->getFilter() 获取用于查询的 InputObjectType
  • $types->getSorting() 获取用于查询的 InputObjectType
  • $types->getInput() 获取用于变更(通常是创建)的 InputObjectType
  • $types->getPartialInput() 获取用于变更(通常是更新)的 InputObjectType
  • $types->getId() 获取可能用于从数据库接收对象的 EntityIDType 而不是标量
  • $types->has() 检查是否存在类型
  • $types->createFilteredQueryBuilder() 用于查询解析器

信息优先级

为了最大限度地减少代码重复,信息来自几个地方,其中可用。并且这些都可以被覆盖。从最不重要的是到最重要的事物的优先级顺序是

  1. 类型提示
  2. 文档块
  3. 属性

这意味着始终可以使用属性覆盖一切。但现有的类型提示和文档块应该涵盖了大多数情况。

排除敏感内容

默认情况下,类型包含所有获取器和设置器,并且所有属性都包含在过滤器中。但对于每个方法和属性都可以指定其他内容。

要排除从 API 中公开的敏感字段,请使用 #[API\Exclude]

use GraphQL\Doctrine\Attribute as API;

/**
 * Returns the hashed password
 *
 * @return string
 */
#[API\Exclude]
public function getPassword(): string
{
    return $this->password;
}

要排除属性作为过滤器公开

use GraphQL\Doctrine\Attribute as API;

#[ORM\Column(name: 'password', type: 'string', length: 255)]
#[API\Exclude]
private string $password = '';

覆盖输出类型

即使获取器返回 PHP 标量类型,如 string,也可能更喜欢使用自定义 GraphQL 类型来覆盖类型。这对于枚举或其他验证目的(如电子邮件地址)通常很有用。这可以通过指定通过 #[API\Field] 属性的 GraphQL 类型 FQCN 来完成

use GraphQL\Doctrine\Attribute as API;
use GraphQLTests\Doctrine\Blog\Types\PostStatusType;

/**
 * Get status
 *
 * @return string
 */
#[API\Field(type: PostStatusType::class)]
public function getStatus(): string
{
    return $this->status;
}

类型语法

在大多数情况下,类型必须使用 ::class 符号来指定 PHP 类,该类可以是实现 GraphQL 类型的类或实体本身(参见 限制)。只有当必须将其定义为可空且/或为数组时才使用字符串字面量。永远不要使用实体的短名(仅适用于用户定义的自定义类型)。

支持的语法(PHP 风格或 GraphQL 风格)包括

  • MyType::class
  • '?Application\MyType'
  • 'null|Application\MyType'
  • 'Application\MyType|null'
  • 'Application\MyType[]'
  • '?Application\MyType[]'
  • 'null|Application\MyType[]'
  • 'Application\MyType[]|null'
  • 'Collection'

此属性可用于覆盖其他内容,例如 namedescriptionargs

覆盖参数

类似于 #[API\Field]#[API\Argument] 允许在 PHP 类型提示不足的情况下覆盖参数的类型。

use GraphQL\Doctrine\Attribute as API;

/**
 * Returns all posts of the specified status
 *
 * @param string $status the status of posts as defined in \GraphQLTests\Doctrine\Blog\Model\Post
 *
 * @return Collection
 */
public function getPosts(
     #[API\Argument(type: '?GraphQLTests\Doctrine\Blog\Types\PostStatusType')]
    ?string $status = Post::STATUS_PUBLIC
): Collection
{
    // ...
}

它还可以覆盖其他内容,例如 namedescriptiondefaultValue

覆盖输入类型

#[API\Input]#[API\Field] 的对立面,可用于覆盖输入类型(设置器)的内容,通常用于验证目的。这看起来是这样的

use GraphQL\Doctrine\Attribute as API;
use GraphQLTests\Doctrine\Blog\Types\PostStatusType;

/**
 * Set status
 *
 * @param string $status
 */
#[API\Input(type: PostStatusType::class)]
public function setStatus(string $status = self::STATUS_PUBLIC): void
{
    $this->status = $status;
}

此属性也支持 descriptiondefaultValue

覆盖过滤器类型

#[API\FilterGroupCondition] 是从属性生成的过滤器的等效项。因此,用法如下

use GraphQL\Doctrine\Attribute as API;

#[API\FilterGroupCondition(type: '?GraphQLTests\Doctrine\Blog\Types\PostStatusType')]
#[ORM\Column(type: 'string', options: ['default' => self::STATUS_PRIVATE])]
private string $status = self::STATUS_PRIVATE;

需要注意的是,指定类型的值将直接用于 DQL。这意味着如果该值不是 PHP 标量,则它必须可以通过 __toString() 转换为字符串,或者您必须在将筛选器值传递给 Types::createFilteredQueryBuilder() 之前自行进行转换。

自定义类型

默认情况下,所有 PHP 标量类型和 Doctrine 集合都会自动检测并映射到 GraphQL 类型。但是,如果某些 getter 返回自定义类型,例如 DateTimeImmutable 或自定义类,则必须在之前进行配置。

配置是通过根据您的需求配置的 PSR-11 容器 实现完成的。在以下示例中,我们使用 laminas/laminas-servicemanager,因为它提供了有用的概念,例如:可调用对象、别名、工厂和抽象工厂。但也可以使用任何其他 PSR-11 容器实现。

键应该是您在模型中用来引用类型的任何内容。通常,这将是对 PHP 类 "原生" 类型(如 DateTimeImmutable)的 FQCN、实现 GraphQL 类型的 PHP 类的 FQCN,或直接是 GraphQL 类型名

$customTypes = new ServiceManager([
    'invokables' => [
        DateTimeImmutable::class => DateTimeType::class,
        'PostStatus' => PostStatusType::class,
    ],
]);

$types = new Types($entityManager, $customTypes);

// Build schema...

这样,就无需为返回配置类型之一的每个 getter 进行注释。它将自动映射。

实体作为输入参数

如果 getter 使用实体作为参数,则会自动创建一个特殊的 InputType 以接受 ID。然后,实体将自动从数据库中获取并转发给 getter。因此,这可以无缝工作

public function isAllowedEditing(User $user): bool
{
    return $this->getUser() === $user;
}

您还可以通过使用 Types::getId() 来获取实体的输入类型,如下所示

[
    // ...
    'args' => [
        'id' => $types->getId(Post::class),
    ],
    'resolve' => function ($root, array $args) {
        $post = $args['id']->getEntity();

        // ...
    },
]

部分输入

除了正常输入类型之外,还可以通过 getPartialInput() 获取部分输入类型。这对于更新现有实体的突变特别有用,当我们不想重新提交所有字段时。通过使用部分输入,API 客户端可以仅提交需要更新的字段,而无需提交更多内容。

这可能会减少网络流量,因为客户端不需要获取所有字段,只是为了在他想修改一个字段时再次提交。

它还允许轻松设计批量编辑突变,客户端只需提交少量需要更新的字段即可一次性更新多个实体。这可以看起来像

<?php

$mutations = [
    'updatePosts' => [
        'type' => Type::nonNull(Type::listOf(Type::nonNull($types->get(Post::class)))),
        'args' => [
            'ids' => Type::nonNull(Type::listOf(Type::nonNull(Type::id()))),
            'input' => $types->getPartialInput(Post::class),  // Use automated InputObjectType for partial input for updates
        ],
        'resolve' => function ($root, $args) {
            // update existing posts and flush...
        }
    ],
];

默认值

默认值是从getters的参数自动检测到的,如上面getPosts()示例所示。

对于setter,将在映射的属性中查找默认值,如果存在匹配setter名称的属性。但如果setter本身有一个具有默认值的参数,它将具有优先级。

因此,以下将创建一个具有可选字段name(默认值为john)、可选字段foo(默认值为defaultFoo)和必填字段bar(没有默认值)的输入类型。

#[ORM\Column(type: 'string']
private $name = 'jane';

public function setName(string $name = 'john'): void
{
    $this->name = $name;
}

public function setFoo(string $foo = 'defaultFoo'): void
{
    // do something
}

public function setBar(string $bar): void
{
    // do something
}

过滤和排序

可以公开对实体字段及其类型的通用过滤,以便用户轻松创建和应用通用过滤器。这公开了类似于SQL的基本语法,应该涵盖大多数简单情况。

过滤器是按顺序排列的组列表。每个组包含字段上的连接和条件的不有序集合。对于简单情况,一个包含少量条件的单个组可能就足够了。但是,有序的组列表允许使用一组条件之间的OR逻辑进行更高级的过滤。

Post类的例子中,它会生成用于过滤的该GraphQL模式,用于排序的将是该更简单的模式

有关具体示例和变量语法,请参阅测试用例

出于安全和复杂性的原因,它不是为了解决高级用例。对于这些情况,可以编写自定义过滤和排序。

自定义过滤

自定义过滤器必须扩展AbstractOperator。这将允许为API定义自定义参数,然后定义一个方法来构建与参数相对应的DQL条件。

这还允许通过在必要时谨慎添加连接来过滤连接关系。

然后,自定义过滤器可以这样使用

use Doctrine\ORM\Mapping as ORM;
use GraphQL\Doctrine\Attribute as API;
use GraphQLTests\Doctrine\Blog\Filtering\SearchOperatorType;

/**
 * A blog post with title and body
 */
#[ORM\Entity]
#[API\Filter(field: 'custom', operator: SearchOperatorType::class, type: 'string')]
final class Post extends AbstractModel

自定义排序

自定义排序选项必须实现SortingInterface。构造函数没有参数,并且__invoke()必须定义如何应用排序。

与自定义过滤器类似,如果需要,可能可以谨慎添加连接。

然后,自定义排序可以这样使用

use Doctrine\ORM\Mapping as ORM;
use GraphQL\Doctrine\Attribute as API;
use GraphQLTests\Doctrine\Blog\Sorting\UserName;

/**
 * A blog post with title and body
 */
#[ORM\Entity]
#[API\Sorting([UserName::class])]
final class Post extends AbstractModel

限制

命名空间

不支持use语句。因此,属性或doc块中的类型必须是FQCN,或者用户定义的自定义类型的名称(但永远不是实体的简称)。

复合标识符

具有复合标识符的实体不支持自动创建输入类型。可能的解决方案是将输入参数更改为不是实体,编写自定义输入类型并通过属性使用它们,或者调整数据库模式。

过滤中的逻辑运算符

逻辑运算符仅支持两个级别,第二级不能混合逻辑运算符。在SQL中,这意味着只有一个括号级别。因此,可以生成看起来像这样的SQL

-- mixed top level
WHERE cond1 AND cond2 OR cond3 AND ...

-- mixed top level and non-mixed sublevels
WHERE cond1 OR (cond2 OR cond3 OR ...) AND (cond4 AND cond5 AND ...) OR ...

但是,不能生成看起来像这样的SQL

-- mixed sublevels does NOT work
WHERE cond1 AND (cond2 OR cond3 AND cond4) AND ...

-- more than two levels will NOT work
WHERE cond1 OR (cond2 AND (cond3 OR cond4)) OR ...

这些情况可能过于复杂,无法在客户端处理。我们建议在服务器端实现它们作为自定义过滤器,以隐藏客户端的复杂性,并从Doctrine的QueryBuilder的完整灵活性中受益。

连接排序

默认情况下,无法通过连接关系的字段进行排序。这应通过自定义排序来实现,以确保连接执行正确。

前期工作

Doctrine GraphQL Mapper 为编写此包提供了灵感。尽管目标相似,但工作方式不同。在 Doctrine GraphQL Mapper 中,属性分散在属性、方法和(用于过滤的)类中,但我们只关注方法。设置似乎稍微复杂一些,但可能更灵活。我们基于约定和 PHP 类型提示的广泛使用,以提供更易于使用的默认体验。