grifart/tables

静态类型表格网关,支持从 PostgreSQL 支持组合字段、数组等。

0.10.2 2023-08-10 07:42 UTC

README

一个简单的库,用于访问和操作数据库记录。基于 Dibi 构建,并针对 PostgreSQL 进行了硬编码。

此库在 gitlab.grifart.cz/grifart/tables 开发,并使用 github.com/grifart/tables 分发。GitLab 存储库会自动镜像到 GitHub 上的所有受保护分支和标签。开发分支只能在 GitLab 上找到。

安装

composer require grifart/tables

快速开始

  1. 注册表格 DI 扩展。 表格期望容器中已配置并注册了 Dibi 实例。

    extensions:
        tables: Grifart\Tables\DI\TablesExtension
  2. 创建数据库表。 您可以使用您喜欢的数据库迁移工具。

    CREATE TABLE "article" (
      "id" uuid NOT NULL PRIMARY KEY,
      "title" varchar NOT NULL,
      "text" text NOT NULL,
      "createdAt" timestamp without time zone NOT NULL,
      "deletedAt" timestamp without time zone DEFAULT NULL,
      "published" boolean NOT NULL
    );
  3. scaffolder 创建定义文件。 表格暴露了一个帮助器,为您创建所有必要的类定义

    <?php
    
    use Grifart\Tables\Scaffolding\TablesDefinitions;
    
    // create a DI container, the same way as you do in your application's bootstrap.php, e.g.
    $container = App\Bootstrap::boot();
    
    // grab the definitions factory from the container
    $tablesDefinitions = $container->getByType(TablesDefinitions::class);
    
    return $tablesDefinitions->for(
        'public', // table schema
        'article', // table name
        ArticleRow::class,
        ArticleChangeSet::class,
        ArticlesTable::class,
        ArticlePrimaryKey::class,
    );

    一旦您 运行 scaffolder,它将检查数据库模式并生成一组四个类

    • ArticlesTable,一个提供访问和操作 article 表数据的 API 服务的类;
    • ArticleRow,一个简单的 DTO,封装了 article 表的单行数据;
    • ArticleChangeSet,一个可变包装器,用于在 article 表中持久化数据;
    • ArticlePrimaryKeyarticle 表主键的表示。
  4. 在您的 DI 容器中注册 ArticlesTable

services:
    - ArticlesTable

使用

使用依赖注入在您的模型层中检索 ArticlesTable 服务的实例。表格类公开了以下方法

读取

您可以通过调用 getAll() 方法列出表中的所有记录。此方法可以接受排序标准和分页器(下面将详细介绍这两个方面)。

$rows = $table->getAll($orderBy, $paginator);

要从表中获取特定记录,请使用带有所需记录主键的 find()get() 方法。两者的区别在于,如果查询返回空结果,则 find() 返回 null,而 get() 会抛出异常

$row = $table->find(ArticlePrimaryKey::of($articleId));
// or
$row = $table->get(ArticlePrimaryKey::of($articleId));

要检索匹配给定条件的记录列表,您可以使用 findBy() 方法并将一系列条件传递给它(下面将详细介绍这一点)

$rows = $table->findBy($conditions, $orderBy, $paginator);

还有一个辅助方法用于检索匹配给定条件的 单个 记录。如果查询没有返回正好一个结果,则会抛出异常

$row = $table->getBy($conditions);

条件

当涉及到搜索条件时,表格期望一个 Condition(或其列表)。以下是一个简单搜索已发布文章的示例:

$rows = $table->findBy(
    Composite::and(
        $table->published()->is(equalTo(true)),
        $table->createdAt()->is(lesserThanOrEqualTo(Instant::now())),
    ),
);

上面的代码可以简化为一系列条件 - 如果传递了一个列表,则隐式假定存在 and 关系

$rows = $table->findBy([
    $table->published()->is(equalTo(true)),
    $table->createdAt()->is(lesserThanOrEqualTo(Instant::now())),
]);

此外,is() 方法默认为相等检查,因此您可以省略 equalTo() 并直接传递值

$rows = $table->findBy([
    $table->published()->is(true),
    $table->createdAt()->is(lesserThanOrEqualTo(Instant::now())),
]);

本软件包提供了一个 Composite 条件,允许您将最复杂的布尔逻辑树组合在一起,以及一系列最常见的条件,如相等性、比较和空值检查。要查看完整列表,请查阅 Conditions/functions.php 文件。

除了这些,您还可以通过实现 Condition 接口来编写自己的条件。它定义了一个唯一的方法 toSql(),该方法应返回与 Dibi 兼容的数组。

以下是如何实现一个 LIKE 条件的示例。它映射到具有两个操作数、子表达式(下面将详细介绍)和与数据库文本映射的模式的数据库操作。

use Grifart\Tables\Expression;
use Grifart\Tables\Types\TextType;
use function Grifart\Tables\Types\mapToDatabase;

final class IsLike implements Condition
{
	/**
	 * @param Expression<string> $expression
	 */
	public function __construct(
		private Expression $expression,
		private string $pattern,
	) {}

	public function toSql(): \Dibi\Expression
	{
		return new \Dibi\Expression(
			'? LIKE ?',
			$this->expression->toSql(),
			mapToDatabase($this->pattern, TextType::varchar()),
		);
	}
}

然后您可以像这样使用条件

$rows = $table->findBy([
    new IsLike($table->title(), 'Top 10%'),
]);

或者创建一个工厂函数

function like(string $pattern) {
    return static fn(Expression $expression) => new IsLike($expression, $pattern);
}

然后像这样使用它

$rows = $table->findBy([
    $table->title()->is(like('Top 10%')),
]);

表达式

表达式是数据库表达式的抽象。所有表列都是表达式,如您所见,生成的 ArticlesTable 通过一个恰如其分的命名方法公开了每个表达式。

您还可以创建自定义表达式,它们映射到各种数据库函数和操作。您只需要实现 Expression 接口,该接口要求您指定表达式的 SQL 表示形式,以及其类型(用于在条件中格式化值)。

use Grifart\Tables\Expression;
use Grifart\Tables\Types\IntType;
use Grifart\Tables\Type;

/**
 * @implements Expression<int>
 */
final class Year implements Expression
{
    /**
     * @param Expression<\Brick\DateTime\Instant>|Expression<\Brick\DateTime\LocalDate> $sub
    */
    public function __construct(
        private Expression $sub,
    ) {}

    public function toSql(): \Dibi\Expression
    {
        return new \Dibi\Expression(
            "EXTRACT ('year' FROM ?)",
            $this->sub->toSql(),
        );
    }

    public function getType(): Type
    {
        return IntType::integer();
    }
}

或者,您可以扩展 ExpressionWithShorthands 基类

/**
 * @extends ExpressionWithShorthands<int>
 */
final class Year extends ExpressionWithShorthands
{
    // ...
}

这样,方便的 is() 省略号将在表达式实例上可用

$rows = $table->findBy(
    (new Year($table->createdAt()))->is(equalTo(2021)),
);

您还可以使用 expr() 函数创建此类表达式

$year = fn(Expression $expr) => expr(IntType::integer(), "EXTRACT ('year' FROM ?)", $expr->toSql());
$rows = $table->findBy(
    $year($table->createdAt())->is(equalTo(2021)),
);

排序

要指定所需记录的顺序,您需要提供一个排序标准列表。这使用与过滤相同的表达式机制。您可以使用 Expression 的简写方法 ascending()descending()

$rows = $table->getAll(orderBy: [
    $table->createdAt()->descending(),
    $table->title(), // ->ascending() is the default
]);

分页

getAllfindBy 方法也接受一个 Nette\Utils\Paginator 实例。如果您提供它,表将不仅设置正确的限制和偏移量,还会查询数据库中的项目总数,并使用该值更新分页器。

$paginator = new \Nette\Utils\Paginator();
$paginator->setItemsPerPage(20);
$paginator->setPage(2);

$rows = $table->getAll($orderBy, $paginator);

插入

要将新记录插入到数据库表中,请使用 $table->new() 方法。您必须向方法提供所有必需的值(对于没有默认值的列)

$changeSet = $table->new(
    id: \Ramsey\Uuid\Uuid::uuid4(),
    title: 'Title of the post',
    text: 'Postt text',
    createdAt: \Brick\DateTime\Instant::now(),
    published: true,
);

该方法返回一个可以进一步修改并最终插入的更改集

$changeSet->modifyText('Post text');
$table->insert($changeSet);

更新

要更新表中的记录,您需要获取特定记录的更改集实例。您可以为任何给定的主键或行获取一个实例

$changeSet = $table->edit(ArticlePrimaryKey::from($articleId));
// or
$changeSet = $table->edit($articleRow);

您可以在方法调用内使用命名参数来提供要更新的值

$changeSet = $table->edit(
    $articleRow,
    deletedAt: \Brick\DateTime\Instant::now(),
);

与之前一样,您还可以之后添加修改到更改集,并最终保存它

$changeSet->modifyDeletedAt(\Brick\DateTime\Instant::now());
$table->update($changeSet);

删除

要删除记录,您只需要它的主键或行

$table->delete(ArticlePrimaryKey::from($articleId));
// or
$table->delete($articleRow);

类型映射

基本类型

如您所注意到的,Tables 为大多数 PostgreSQL 的基本类型提供了默认映射

  • 文本类型(charactercharacter varyingtext)都映射到 string
  • 整数类型(smallintintbigint)都映射到 int
  • 浮点类型(realdouble precision)都映射到 float
  • 布尔类型映射到 bool
  • 二进制类型(bytea)映射到二进制 string
  • JSON 类型(jsonjsonb)映射到 PHP 的 json_decode() 值。

其他基本类型仅在安装了某些包的情况下进行映射

  • 数值类型(numericdecimal)映射到来自 brick/mathBigDecimal
  • 日期时间类型(datetimetimestamp)分别映射到 LocalDateLocalTimeInstant,来自 brick/date-time
  • Uuid 类型映射到来自 ramsey/uuidUuid

高级类型

除了默认映射 PostgreSQL 的基本类型外,表(Tables)还允许你充分利用数据库的复杂类型系统。你可以描述并提供对 PostgreSQL 类型组合的映射。

类型解析器

在 Tables 的类型系统中,核心是 TypeResolver。它根据数据库类型或其作用域名称决定每个列使用哪种类型。

你可以在配置文件中注册自己的类型

tables:
    types:
        - App\Tables\MyType
        - App\Tables\MyType::decimal(10, 5) # named constructor with parameters
        schema.table.column: App\Tables\MyType

你可以通过使用项目的完整限定符在键中显式映射类型到特定列(如上第二项所示)。如果你省略项目的键(如上第一项所示),则类型将根据其 getDatabaseType() 注册,并将用于所有没有显式映射的该类型的列。

或者,你可以在依赖注入(DI)容器中注册 TypeResolverConfigurator 接口的实现。Tables 将自动拾取它们并将 TypeResolver 传递给配置器的 configure() 方法。

自定义类型

所有类型都实现了 Type 接口及其四个方法

  • getPhpType(): PhpType 返回表示的 PHP 值的脚手架兼容类型;
  • getDatabaseType(): DatabaseType 返回数据库类型名称 - 这是在使用 TypeResolver::addResolutionByTypeName($type) 方法注册类型时使用的;
  • toDatabase(mixed $value): Dibi\Expression 将给定类型的 PHP 值映射到其数据库表示形式;
  • fromDatabase(mixed $value): mixed 将数据库表示形式映射到相应的 PHP 值。

这是一个自定义货币类型的示例,它将某些 Currency 实例映射到数据库中的 char(3) 货币代码上

/**
 * @implements Type<Currency>
 */
final class CurrencyType implements Type
{
	public function getPhpType(): PhpType
	{
		return resolve(Currency::class);
	}

	public function getDatabaseType(): DatabaseType
	{
		return BuiltInType::char();
	}

	public function toDatabase(mixed $value): Expression
	{
		return $value->getCode();
	}

	public function fromDatabase(mixed $value): mixed
	{
		return Currency::of($value);
	}
}

还有一些用于创建最常见的高级类型的辅助器

数组类型

您可以通过 ArrayType 将值映射到数组。这将使用声明的子类型格式化项目,并将它们序列化到 PostgreSQL 数组中。例如,日期数组

$dateArrayType = ArrayType::of(new DateType());
枚举类型

您可以使用 EnumType 将原生 PHP 枚举映射到 PostgreSQL 的枚举。这要求提供的枚举是 \BackedEnum,并将其序列化为其支持值

enum Status: string {
    case DRAFT = 'draft';
    case PUBLISHED = 'published';
}

$statusType = EnumType::of(Status::class);
复合类型

还有一个用于描述复合类型的基类

$moneyType = new class extends CompositeType {
    public function __construct()
    {
        parent::__construct(
            new Database\NamedType(new Database\Identifier('public', 'money')),
            DecimalType::decimal(),
            new CurrencyType(), // custom type from above
        );
    }

    public function getPhpType(): PhpType
    {
        return resolve(Money::class);
    }

    public function toDatabase(mixed $value): Dibi\Expression
    {
        return $this->tupleToDatabase([
            $value->getAmount(),
            $value->getCurrency(),
        ]);
    }

    public function fromDatabase(mixed $value): Money
    {
        [$amount, $currency] = $this->tupleFromDatabase($value);
        return Money::of($amount, $currency);
    }
};