lss/yadbal

另一个数据库抽象层

dev-main 2022-03-07 20:19 UTC

This package is auto-updated.

Last update: 2024-09-08 01:18:29 UTC


README

这是一个围绕PDO的简单包装器,提供了

  • 实用函数,以简化数据库查询,减少样板代码
  • 工具,用于在代码中声明数据库模式并同步服务器
  • 实用工具,使常见字段如显示顺序、创建/更新日期、json更容易处理
  • 一个轻量级分页器
  • 模拟和存根,以便测试时无需与物理数据库交互

目前仅支持MySQL,提供一些SQLite支持以帮助进行单元测试。欢迎通过拉取请求扩展到其他DBMS。

为什么还要另一个?

这是基于我在2007年编写的一个古老库,该库允许在PHP中定义数据库模式,并(通过点击按钮)生成更新表语句以同步MySQL服务器与PHP。这对于拥有单个安装位置或用户数量非常有限的网站非常有用。它允许

  • 在需要时更新模式的过程。
  • 自定义报告,以查看模式并生成关于表和字段的合理丰富描述。用户可以选择从哪个表开始,报告编写者知道哪些表可以通过外键关系与其连接。
  • 使用graphviz生成数据库图
  • 和自动化的模式文档,这些都支持报告构建器。
  • 检查外键关系,以便我们可以执行不同类型的数据完整性检查(而不依赖于DBMS维护引用完整性,这在所有情况下都不合理)
  • 检查依赖模型是否正确连接以处理父模型数据更改时的onChange和onDelete事件。(这是在预发布构建步骤中完成的)。在大多数情况下,您可以使用外键约束,但并非所有情况都可以

是的,现在有更好的方法来做这件事,使用完整的ORM和其他好处。但是,这是

  • 经过多年战斗考验的
  • 尽可能轻量级
  • 努力减少使用它所需的样板代码量
  • 有高测试覆盖率
  • phpstan --level=max
  • PHP8.0,严格类型

我仍然在某些地方和新绿色项目中使用它。

安装

composer require lss/yadbal

lss/yadbal替换了lss/schema,后者只处理模式。它通过支持MySQL 8和PHP8、添加仓库类和数据库连接、添加分页器以及为lss/yareport提供核心服务来改进它:这是一个低努力报告编写工具。

每个表一个仓库

使用TableBuilder便利方法来声明您的模式,或者只需根据需要创建新列并将它们添加到Schema中。

class CompanyRepository extends AbstractRepository
{
    use DateCreatedFieldTrait, DateUpdatedFieldTrait;
    
    public const TABLE_NAME = 'company';

    public function getSchema(): Table
    {
        return (new TableBuilder(static::TABLE_NAME, 'Company profile'))
            ->addPrimaryKeyColumn()
            ->addStringColumn('name', FieldLength::COMPANY_NAME, 'name of company')
            ->addTextColumn('description', 'longer description in markdown format')
            ->addStringColumn('email_address', FieldLength::EMAIL, 'company generic contact email')
            ->addStringColumn('phone', FieldLength::PHONE, 'company phone number')
            ->addStringColumn('web_site', FieldLength::WEBSITE, 'company public web site')
            ->addTextColumn('address', 'physical address')
            ->addDateColumn('subscription_expires', 'Display less info after this date')
            ->addIntegerColumn('tech_staff', 'number of tech employed')
            ->addBooleanColumn('is_visible', 'true if the listing is shown to the public')
            ->addDateCreatedColumn()
            ->addDateUpdatedColumn()
            ->addStandardIndex('name')
            ->addStandardIndex('email_address')
            ->build();
    }

    public function getAllVisible(): array
    {
        $select = $this->selectAll()
                       ->andWhere(field('is_visible')->gt(0))
                       ->orderBy('name');
        return $this->fetchAll($select);
    }

    /**
     * @return string[]
     */
    public function getList(): array
    {
        $select = $this->select('id', 'name')->from(static::TABLE_NAME)->orderBy('name');
        return $this->fetchPairs($select);
    }
}

为依赖表使用子仓库

class ChildRepository extends AbstractChildRepository
{
    use DateCreatedColumnTrait;
    
    public const MAX_DAYS_OLD = 28;
    
    public const TABLE_NAME = 'company_job';

    public function getSchema(): Table
    {
        return (new TableBuilder(self::TABLE_NAME, ''))
            ->addPrimaryKeyColumn()
            ->addForeignKeyColumn(CompanyRepository::TABLE_NAME, '', '', ForeignKeyColumn::ACTION_CASCADE)
            ->addStringColumn('title', FieldLength::PAGE_TITLE, 'title of job')
            ->addTextColumn('content', 'description of job in plain text')
            ->addStringColumn('web_site', FieldLength::WEBSITE, 'where to apply')
            ->addDateColumn('date_expires', 'show the job until this date')
            ->addDateCreatedColumn()
            ->addStandardIndex('date_expires')
            ->build();
    }

    public function getVisibleFor(int $companyId): array
    {
        $select = $this->getSelect();
        $select->andWhere(field('company_id')->eq($companyId));
        $this->whereIsNotExpired($select);
        return $this->fetchAll($select);
    }

    public function getAllFor(int $companyId): array
    {
        $select = $this->getSelect();
        $select->andWhere(field('company_id')->eq($companyId));
        return $this->fetchAll($select);
    }
    
    protected function beforeSave(array $data): array
    {
        if (empty($data['date_expires'])) {
            $data['date_expires'] = Carbon::now()->addDays(self::MAX_DAYS_OLD)->toDateString();
        }
        // date_created is automatically calculated
        return parent::beforeSave($data);
    }

    private function whereIsNotExpired(SelectQuery $select): SelectQuery
    {
        $select->andWhere(field(static::TABLE_NAME . '.date_expires')->gte($this->now()));
        return $select;
    }
}

子仓库有一些防止不安全直接对象引用的工具,因此只有知道父ID时才能访问子记录。例如

  • findChildOrNull($id, $parentId)
  • findChildOrException($id, $parentId)
  • 在新行中,save()如果父ID未设置则抛出异常
  • 在保存现有行时,save()只有在ID与父ID匹配时才会更新
    $job = $jobRepository->findChildOrException($jobId, $companyId);

在每个数据库表中声明一个类。将它们都放在一个与表结构相对应的目录结构中的同一个目录里。例如,如果您的数据库结构是

  • company
  • company_employee
  • company_job
  • user
  • user_mentor

那么您的文件系统可能看起来像这样

  • Repository/CompanyRepository.php : 继承自 AbstractRepository
  • Repository/Company/EmployeeRepository.php : 继承自 AbstractChildRepository
  • Repository/Company/JobRepository.php : 继承自 AbstractChildRepository
  • Repository/UserRepository.php : 继承自 AbstractRepository
  • Repository/User/MentorRepository.php : 继承自 AbstractChildRepository

添加实用函数以将您的代码与数据库查询隔离:理想情况下,您的代码中不应有任何 SelectQuery 实例。它们在那里是为了在需要时使用,但尽量将所有查询构建代码移动到 Repository 类的方法上。调用这些方法来获取所需的数据。这种封装将在您的业务逻辑和数据库逻辑之间创建一个很好的层。它还使在编写测试时模拟 Repositories 变得更容易。您可以模拟 $companyRepository->getVisibleCompanies() 并返回所需的数组,而不是在测试中重新构建 SQL 查询。

模式同步

上面的代码片段包含了为每个表声明模式示例。

创建一个类,通过 getSchema() 向所有 Repositories 请求它们的模式

/**
 * Get the database schema: a set of tables that are in the database.
 * Note this is the ideal / declared schema from the code.
 * After changing the software, it may need to be upgraded using SchemaUpgrade
 */
class SchemaFromDeclarations
{
    public const REPOSITORY_PATH = '/path/to/repositories';
    
    private SchemaInterface $schema;
    
    public function __construct(private ContainerInterface $container) 
    {
    }    

    public function build(): SchemaInterface
    {
        if (!empty($this->schema)) {
            return $this->schema;
        }
        $this->schema = new Schema();
        $finder = new Symfony\Finder();
        $finder->in(self::REPOSITORY_PATH)->name('*Repository.php')->sortByName()->notName('Abstract*.php');
        foreach ($finder as $fileInfo) {
            $className = $this->fileNameToClassName($fileInfo->getRealPath() ?: '');
            $repository = $this->container->get($className);
            assert($repository instanceof AbstractRepository);
            $this->schema->addTable($repository->getSchema());
        }
        return $this->schema;
    }
    
    private function fileNameToClassName(string $fileName): string
    {
        // ...
    }
}

然后是一个可以进行升级的东西

class DatabaseUpgradeCommand 
{
    public function __construct(
        private SchemaFromDeclarations $wanted,
        private SchemaFromMySQL $actual,
        private DatabaseConnectionInterface $database
    ) {
    }

    public function doUpgrade(): void
    {
        $wanted  = $this->wanted->build();
        $upgrade = (new SchemaUpgradeCalculator())->getUpgradeSQL($wanted, $this->actual->build());
        $this->database->transaction(
            function () use ($upgrade): void {
                foreach ($upgrade as $query) {
                    $this->database->execute($query);
                }
            }
        );
    }
}

我通常创建一个 symfony 控制台命令来输出升级语句,除非传递了 --apply 选项。

已知限制

  • 不处理表末尾的元数据,例如类型、编码等
  • 更适合在表中首先有一个整数主键索引字段
  • 同步/比较器可以处理一个或多个列的添加/删除/重命名,但如果一次进行大量大更改则很容易混淆。每个字段上有唯一的注释有助于它重新同步。您可以更改字段的一个(列名、数据类型、注释)来更改该字段。更改两个,它将认为它是一个新字段,删除旧的一个并添加一个新的

测试实用工具

使用 FakeDatabaseConnection 来检查预期的 SQL 查询和参数是否传递到最低的数据库层,而实际上并不接触真实的数据库。它们对单元测试很好,但仍然可能允许出现错误,因为它不会检查您声明的模式是否与查询匹配。因此,您仍然需要进行端到端测试。

使用 MemoryDatabaseConnection 进行端到端测试。首先使用 $repository->getSchema()->toSQLite() 建立表,然后使用假数据填充以允许真正的端到端测试。请参阅 DisplayOrderTest 中的示例。您可能对计算列有问题... 如果您知道如何修复它,请提交拉取请求?