lss / yadbal
另一个数据库抽象层
Requires
- php: >=8.0
- ext-pdo: *
- latitude/latitude: ^4
- thecodingmachine/safe: ^2
Requires (Dev)
- nesbot/carbon: ^2.53
- phpstan/phpstan: ^1
- phpstan/phpstan-phpunit: ^1
- phpstan/phpstan-strict-rules: ^1
- phpunit/phpunit: ^9.1
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
: 继承自 AbstractRepositoryRepository/Company/EmployeeRepository.php
: 继承自 AbstractChildRepositoryRepository/Company/JobRepository.php
: 继承自 AbstractChildRepositoryRepository/UserRepository.php
: 继承自 AbstractRepositoryRepository/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
中的示例。您可能对计算列有问题... 如果您知道如何修复它,请提交拉取请求?