squirrelphp/entities

简单、安全且灵活的实现,用于处理SQL实体和仓库以及多表SQL查询,同时保持轻量级和直观。

v1.1.3 2023-12-01 08:57 UTC

README

Build Status Test Coverage PHPStan Packagist Version PHP Version Software License

简单且安全的SQL实体和仓库处理实现,以及多表SQL查询,同时保持轻量级和易于理解和使用。通过为实体生成仓库(不应添加到VCS)来提供快速应用程序开发,而 squirrelphp/entities-bundle 则提供了将这些仓库自动集成到Symfony中的功能。

这个库基于 squirrelphp/queries 构建,并且以类似的方式工作:接口、方法名称以及查询构建器的看起来几乎相同,只是在实体和类型化字段属性上提高了抽象级别。

安装

composer require squirrelphp/entities

目录

创建实体

使用属性定义实体

如果您使用过像Doctrine这样的ORM,一开始会感觉类似,尽管功能不同。以下是如何使用属性定义实体的示例

namespace Application\Entity;

use Squirrel\Entities\Attribute\Entity;
use Squirrel\Entities\Attribute\Field;

#[Entity("users")]
class User
{
    #[Field("user_id", autoincrement: true)]
    private int $userId;

    #[Field("active")]
    private bool $active;

    #[Field("street_name")]
    private ?string $streetName;

    #[Field("street_number")]
    private ?string $streetNumber;

    #[Field("city")]
    private string $city;

    #[Field("balance")]
    private float $balance;

    #[Field("picture_file", blob: true)]
    private ?string $picture;

    #[Field("visits")]
    private int $visitsNumber;
}

类被定义为实体,带有表名,并且每个类属性被定义为数据库中的表字段,其中列类型从PHP属性类型(字符串、整数、浮点数、布尔值)获取。如果属性类型是可空的,则假定列类型也是可空的。您还可以定义它是否是自增列(在Postgres中称为SERIAL)以及是否是blob列(二进制大对象,在大多数数据库中称为"blob",在Postgres中称为"bytea")。

类属性是私有、受保护还是公共的并不重要(此库不使用构造函数来创建实体),您可以选择任何您想要的名称,并且可以按照您想要的任何方式设计类的其余部分。您甚至可以制作只读的类或属性 - 请参阅 只读实体对象 了解更多关于为什么要这样做的原因。

直接定义实体

目前不建议这样做,但如果您不使用 squirrelphp/entities-bundle 并且想手动配置/创建实体,您可以创建 RepositoryConfig 对象 - 使用 entities-bundle,这些对象会自动为您创建(使用反射)。上一节中 User 示例中的属性将与以下 RepositoryConfig 定义等效

$repositoryConfig = new \Squirrel\Entities\RepositoryConfig(
    '', // connectionName, none defined
    'users', // tableName
    [   // tableToObjectFields, mapping table column names to object property names
        'user_id' => 'userId',
        'active' => 'active',
        'street_name' => 'streetName',
        'street_number' => 'streetNumber',
        'city' => 'city',
        'balance' => 'balance',
        'picture_file' => 'picture',
        'visits' => 'visitsNumber',
    ],
    [   // objectToTableFields, mapping object property names to table column names
        'userId' => 'user_id',
        'active' => 'active',
        'streetName' => 'street_name',
        'streetNumber' => 'street_number',
        'city' => 'city',
        'balance' => 'balance',
        'picture' => 'picture_file',
        'visitsNumber' => 'visits',
    ],
    \Application\Entity\User::class, // object class
    [   // objectTypes, which class properties should have which database type
        'userId' => 'int',
        'active' => 'bool',
        'streetName' => 'string',
        'streetNumber' => 'string',
        'city' => 'string',
        'balance' => 'float',
        'picture' => 'blob',
        'visitsNumber' => 'int',
    ],
    [   // objectTypesNullable, which fields can be NULL
        'userId' => false,
        'active' => false,
        'streetName' => true,
        'streetNumber' => true,
        'city' => false,
        'balance' => false,
        'picture' => true,
        'visitsNumber' => false,
    ],
    'user_id' // Table field name of the autoincrement column - if there is none this is an empty string
);

创建仓库

基本仓库

您需要为每个实体定义仓库才能使用它们,并从实体类创建 RepositoryConfig 类(squirrelphp/entities-bundle 会为您做这件事,因此您不需要太关注这些步骤)。

仓库只需要一个DBInterface服务(来自squirrelphp/queries)和RepositoryConfig对象。存在只读仓库和可写仓库,这样您可以更容易地限制数据的更改位置和方式。以下是基于这些的仓库类:

  • Squirrel\Entities\RepositoryReadOnly
  • Squirrel\Entities\RepositoryWriteable

它们几乎提供与squirrelphp/queries中的DBInterface相同的功能,但它们还采取了额外步骤来避免错误。

  • 您在查询中使用对象属性名,而不是表中的列名,库将它们转换为查询。
  • 所有提供的值都转换为适当的类型(字符串、整数、布尔值、浮点数、二进制大对象)。
  • 如果使用了任何未知列名,则会抛出异常。
  • 每次您进行SELECT查询以检索条目时,您都会得到实体对象而不是数组,并且所有值在放入实体对象之前都会正确转换。

这使得编写无效查询变得困难,因为这些查询在执行之前没有被识别为无效的,并且消除了进行任何繁琐的类型转换的需要。

构建器仓库

尽管您可以直接使用基本仓库,但构建器仓库更容易使用,可以使您的查询更易读——这些与squirrelphp/queries中的查询构建器非常相似。此库假设您使用构建器仓库。

生成构建器仓库

为了有适当的类型提示(便于编码和静态分析)并为所有实体创建单独的类以用于依赖注入,需要为所有实体生成构建器仓库。

您可以使用此库中的squirrel_repositories_generate命令自动生成仓库和.gitignore文件——像这样运行:

vendor/bin/squirrel_repositories_generate --source-dir=src

您可以定义多个源目录。

vendor/bin/squirrel_repositories_generate --source-dir=src/Entity --source-dir=src/Domain/Entity

每当找到具有库属性的实体时,在实体类相同的目录中创建以下文件:

  • RepositoryReadOnly构建器类,通过在实体类名称中添加RepositoryReadOnly
  • RepositoryWriteable构建器类,通过在实体类名称中添加RepositoryWriteable
  • 一个.gitignore文件,它忽略了.gitignore文件本身和所有生成的构建器类。

这意味着您永远不需要编辑这些生成的仓库,也不应该将它们提交到git。它们是此库的责任,而不是您的应用程序的责任。

我们的实体示例User将在与实体相同的目录中生成以下类:

  • Application\Entity\UserRepositoryReadOnly,文件名为UserRepositoryReadOnly.php。
  • Application\Entity\UserRepositoryWriteable,文件名为UserRepositoryWriteable.php。
  • .gitignore文件,列出了.gitignore、UserRepositoryReadOnly.php和UserRepositoryWriteable.php。

使用仓库

与“常规”ORM相比,实体类仅在从数据库获取结果时使用,而不是将任何更改写入数据库,这就是为什么实体类的使用或设计方式无关紧要,除了必要的属性。

所有示例都使用生成的构建器仓库,而不是基本仓库。您的IDE将提供适当的类型提示和建议可以使用的功能,或者您也可以查看生成的构建器仓库。

将数据库条目作为对象检索

$users = $userRepositoryReadOnly // \Application\Entity\UserRepositoryReadOnly instance
    ->select()
    ->where([
        'active' => true,
        'userId' => [5, 77, 186],
    ])
    ->orderBy([
        'balance' => 'DESC',
    ])
    ->getAllEntries();

foreach ($users as $user) {
    // Each $user entry is an instance of Application\Entity\User
}

如果您只需要表中的某些字段,您可以明确定义它们。

$users = $userRepositoryReadOnly // \Application\Entity\UserRepositoryReadOnly instance
    ->select()
    ->fields([
        'userId',
        'active',
        'city',
    ])
    ->where([
        'active' => true,
        'userId' => [5, 77, 186],
    ])
    ->orderBy([
        'balance' => 'DESC',
    ])
    ->getAllEntries();

foreach ($users as $user) {
    // Only 'userId', 'active' and 'city' have been populated in the entity instances
}

或者,如果您只需要用户ID列表,您可以仅通过getFlattenedFields获取这些。

$userIds = $userRepositoryReadOnly // \Application\Entity\UserRepositoryReadOnly instance
    ->select()
    ->fields([
        'userId',
    ])
    ->where([
        'active' => true,
        'userId' => [5, 77, 186],
    ])
    ->orderBy([
        'balance' => 'DESC',
    ])
    ->getFlattenedFields();

foreach ($userIds as $userId) {
    // Each $userId is an integer with the user ID
}

您可以通过使用 getFlattenedIntegerFieldsgetFlattenedFloatFieldsgetFlattenedStringFieldsgetFlattenedBooleanFields 来对扁平化字段施加类型。这样做可以增加类型安全性,并使静态分析器/IDE更容易理解您的代码。然后,此库将尝试将所有值转换为请求的类型,并在存在任何歧义时抛出 DBInvalidOptionException

如果您想逐条获取条目,可以使用选择构建器作为迭代器。

$userBuilder = $userRepositoryReadOnly // \Application\Entity\UserRepositoryReadOnly instance
    ->select()
    ->where([
        'active' => true,
        'userId' => [5, 77, 186],
    ])
    ->orderBy([
        'balance' => 'DESC',
    ]);

foreach ($userBuilder as $user) {
    // The query is executed when the foreach loop starts,
    // and one entry after another is retrieved until no more results exist
}

或者,如果您只需要一个条目,可以使用 `getOneEntry`。

$user = $userRepositoryReadOnly // \Application\Entity\UserRepositoryReadOnly instance
    ->select()
    ->where([
        'userId' => 13,
    ])
    ->getOneEntry();

// $user is now either null, if the entry was not found,
// or an instance of Application\Entity\User

如果在事务中执行 SELECT 查询,您可能希望阻止检索到的条目,以免在事务完成之前被另一个查询更改 - 您可以使用 blocking() 来实现这一点。

$user = $userRepositoryReadOnly // \Application\Entity\UserRepositoryReadOnly instance
    ->select()
    ->where([
        'userId' => 13,
    ])
    ->blocking()
    ->getOneEntry();

上述查询的 SELECT 查询以 ... FOR UPDATE 结尾。

计数条目数量

通常,您只想知道有多少条目,这就是 count 的用途。

$activeUsersNumber = $userRepositoryReadOnly // \Application\Entity\UserRepositoryReadOnly instance
    ->count()
    ->where([
        'active' => true,
    ])
    ->getNumber();

// $activeUsersNumber is an integer
if ($activeUsersNumber === 0) {
    throw new \Exception('No users found!');
}

您可以使用 blocking() 阻止对计数的条目进行更改,并将计数查询放在事务中,尽管这可能会导致锁定表中的许多条目并导致死锁 - 请谨慎使用。

添加新条目(INSERT)

$newUserId = $userRepositoryWriteable // \Application\Entity\UserRepositoryWriteable instance
    ->insert()
    ->set([
        'active' => true,
        'city' => 'London',
        'balance' => 500,
    ])
    ->writeAndReturnNewId();

writeAndReturnNewId 仅在您指定了自增列的情况下才有效,否则它将抛出 DBInvalidOptionException - 如果没有自增列(或您不需要它的值),请使用 write

更新现有条目(UPDATE)

$foundRows = $userRepositoryWriteable // \Application\Entity\UserRepositoryWriteable instance
    ->update()
    ->set([
        'active' => false,
        'city' => 'Paris',
    ])
    ->where([
        'userId' => 5,
    ])
    ->writeAndReturnAffectedNumber();

受影响行数只是与数据库中 WHERE 子句匹配的行。如果您不感兴趣,可以使用 write

您可以在没有 WHERE 子句的情况下执行 UPDATE,更新表中的所有条目,但您需要明确告诉构建器,因为我们希望避免意外的“UPDATE all”查询。

$userRepositoryWriteable // \Application\Entity\UserRepositoryWriteable instance
    ->update()
    ->set([
        'active' => false,
    ])
    ->confirmNoWhereRestrictions()
    ->write();

如果条目不存在则插入,否则更新(UPSERT - insertOrUpdate)

如果条目尚不存在,则插入它,否则更新现有条目。这种功能存在于它可以在数据库中作为单个原子查询执行,这使得它比在事务中执行自己的单独查询更快、更有效。它通常被称为 UPSERT(更新或插入),MySQL 使用语法 INSERT ... ON DUPLICATE KEY UPDATE ...,而 Postgres/SQLite 使用 INSERT ... ON CONFLICT (index_columns) DO UPDATE SET ...

$userRepositoryWriteable // \Application\Entity\UserRepositoryWriteable instance
    ->insertOrUpdate()
    ->set([
        'userId' => 5,
        'active' => true,
        'city' => 'Paris',
        'balance' => 500,
    ])
    ->index('userId')
    ->write();

您需要提供由 index 方法形成的唯一索引的列。如果行尚不存在,则插入它,如果它已存在,则更新 set 中的值。但是,您可以更改 UPDATE 部分。

$userRepositoryWriteable // \Application\Entity\UserRepositoryWriteable instance
    ->insertOrUpdate()
    ->set([
        'userId' => 5,
        'active' => true,
        'city' => 'Paris',
        'balance' => 500,
    ])
    ->index('userId')
    ->setOnUpdate([
        'balance' => 500,
    ])
    ->write();

这将插入具有所有提供值的行,但如果它已存在,则只有 balance 会更改,而不会更改 cityactive。自定义 UPDATE 部分的常见用例是更改现有的数字。

$userRepositoryWriteable // \Application\Entity\UserRepositoryWriteable instance
    ->insertOrUpdate()
    ->set([
        'userId' => 5,
        'active' => true,
        'visitsNumber' => 1,
    ])
    ->index('userId')
    ->setOnUpdate([
        ':visitsNumber: = :visitsNumber: + 1',
    ])
    ->write();

这将创建一个具有一次访问的用户条目,或者如果它已存在,则将 visitsNumber 加一。

删除现有条目

$deletedNumber = $userRepositoryWriteable // \Application\Entity\UserRepositoryWriteable instance
    ->delete()
    ->where([
        'active' => true,
    ])
    ->writeAndReturnAffectedNumber();

这将删除所有活动用户,并返回被删除的行数作为整数。如果您对删除条目的数量不感兴趣,则可以调用 write 方法。

您可以删除表中的所有条目,但您必须明确指出,以避免意外忘记 WHERE 限制并删除所有数据(类似于更新方法,您也需要确认没有 WHERE 限制)。

$userRepositoryWriteable // \Application\Entity\UserRepositoryWriteable instance
    ->delete()
    ->confirmNoWhereRestrictions()
    ->write();

多仓库查询

有时您可能想要执行涉及多个实体(或相同实体多次)的查询,这就是 MultiRepository 类的作用所在。与常规仓库一样,有基类仓库和构建器仓库,但与常规仓库不同,它们没有自己的配置 - 它们从涉及的仓库中获取所有必要的数据。

所有示例都是针对构建器仓库的,因为它们更容易解释和使用。我们再次使用用户实体,并添加一个名为 Visit 的额外实体,其定义如下

namespace Application\Entity;

use Squirrel\Entities\Attribute\Entity;
use Squirrel\Entities\Attribute\Field;

#[Entity("users_visits")]
class Visit
{
    #[Field("visit_id", autoincrement: true)]
    private int $visitId = 0;

    #[Field("user_id")]
    private int $userId = 0;

    #[Field("created_timestamp")]
    private int $timestamp = 0;
}

选择查询

$multiBuilder = new \Squirrel\Entities\MultiRepositoryBuilderReadOnly();

$entries = $multiBuilder
    ->select()
    ->fields([
        'user.userId',
        'user.active',
        'visit.timestamp',
    ])
    ->inRepositories([
        'user' => $userRepositoryReadOnly // \Application\Entity\UserRepositoryReadOnly instance
        'visit' => $visitRepositoryReadOnly // \Application\Entity\VisitRepositoryReadOnly instance
    ])
    ->where([
        ':user.userId: = :visit.userId:',
        'user.userId' => 5,
    ])
    ->orderBy([
        'visit.timestamp' => 'DESC',
    ])
    ->limitTo(10)
    ->getAllEntries();

foreach ($entries as $entry) {
    // Each $entry has the following data in it:
    // - $entry['user.userId'] as an integer
    // - $entry['user.active'] as a boolean
    // - $entry['visit.timestamp'] as an integer
}

您可以重命名返回的字段

$entries = $multiBuilder
    ->select()
    ->fields([
        'userId' => 'user.userId',
        'isActive' => 'user.active',
        'visitTimestamp' => 'visit.timestamp',
    ])
    ->inRepositories([
        'user' => $userRepositoryReadOnly // \Application\Entity\UserRepositoryReadOnly instance
        'visit' => $visitRepositoryReadOnly // \Application\Entity\VisitRepositoryReadOnly instance
    ])
    ->where([
        ':user.userId: = :visit.userId:',
        'user.userId' => 5,
    ])
    ->getAllEntries();

foreach ($entries as $entry) {
    // Each $entry has the following data in it:
    // - $entry['userId'] as an integer
    // - $entry['isActive'] as a boolean
    // - $entry['visitTimestamp'] as an integer
}

您可以定义自己的方式连接实体表,分组条目并使选择阻塞。此示例使用了所有这些可能性

$multiBuilder = new \Squirrel\Entities\MultiRepositoryBuilderReadOnly();

$entries = $multiBuilder
    ->select()
    ->fields([
        'visit.userId',
        'visit.timestamp',
        'userIdWhenActive' => 'user.userId',
    ])
    ->inRepositories([
        'user' => $userRepositoryReadOnly // \Application\Entity\UserRepositoryReadOnly instance
        'visit' => $visitRepositoryReadOnly // \Application\Entity\VisitRepositoryReadOnly instance
    ])
    ->joinTables([
        ':visit: LEFT JOIN :user: ON (:user.userId: = :visit.userId: AND :user.active: = ?)' => true,
    ])
    ->where([
        ':visit.timestamp: > ?' => time() - 86400, // Visit timestamp within the last 24 hours
    ])
    ->groupBy([
        'visit.userId',
    ])
    ->orderBy([
        'visit.timestamp' => 'DESC',
    ])
    ->limitTo(5)
    ->startAt(10)
    ->blocking()
    ->getAllEntries();

foreach ($entries as $entry) {
    // Each $entry has the following data in it:
    // - $entry['visit.userId'] as an integer
    // - $entry['visit.timestamp'] as an integer
    // - $entry['userIdWhenActive'] as an integer if the LEFT JOIN was successful, otherwise NULL
}

与单一仓库的选择构建器类似,您可以通过 getAllEntriesgetOneEntrygetFlattenedFields(或其任何变体,如 getFlattenedIntegerFieldsgetFlattenedFloatFieldsgetFlattenedStringFieldsgetFlattenedBooleanFields)或通过遍历构建器来检索结果

$selectBuilder = $multiBuilder
    ->select()
    ->fields([
        'userId' => 'user.userId',
        'isActive' => 'user.active',
        'visitTimestamp' => 'visit.timestamp',
    ])
    ->inRepositories([
        'user' => $userRepositoryReadOnly // \Application\Entity\UserRepositoryReadOnly instance
        'visit' => $visitRepositoryReadOnly // \Application\Entity\VisitRepositoryReadOnly instance
    ])
    ->where([
        ':user.userId: = :visit.userId:',
        'user.userId' => 5,
    ]);

foreach ($selectBuilder as $entry) {
    // Each $entry has the following data in it:
    // - $entry['userId'] as an integer
    // - $entry['isActive'] as a boolean
    // - $entry['visitTimestamp'] as an integer
}

计数查询

$entriesNumber = $multiBuilder
    ->count()
    ->inRepositories([
        'user' => $userRepositoryReadOnly // \Application\Entity\UserRepositoryReadOnly instance
        'visit' => $visitRepositoryReadOnly // \Application\Entity\VisitRepositoryReadOnly instance
    ])
    ->where([
        ':user.userId: = :visit.userId:',
        'user.userId' => 5,
    ])
    ->getNumber();

// $entriesNumber now contains the number of visits of userId = 5

自由格式选择查询

有时您可能想要创建更复杂的选择查询,例如使用子查询或其他非直接由多仓库选择构建器支持的功能。自由格式查询给您提供了这种自由度,尽管建议您谨慎使用,因为它们不能像常规查询那样进行严格的检查,并且它们更有可能仅适用于特定的数据库系统(因为供应商之间通常存在语法/行为差异)。使用特定供应商的功能可能是自由格式查询的良好用例,前提是您要记住您正在编写不可移植的SQL。

$entries = $multiBuilder
    ->selectFreeform()
    ->fields([
        'userId' => 'user.userId',
        'isActive' => 'user.active',
    ])
    ->inRepositories([
        'user' => $userRepositoryReadOnly // \Application\Entity\UserRepositoryReadOnly instance
        'visit' => $visitRepositoryReadOnly // \Application\Entity\VisitRepositoryReadOnly instance
    ])
    ->queryAfterFROM(':user: WHERE :user.userId: = ? AND NOT EXISTS ( SELECT * FROM :visit: WHERE :user.userId: = :visit.userId: )')
    ->withParameters([5])
    ->confirmFreeformQueriesAreNotRecommended('OK')
    ->getAllEntries();

foreach ($entries as $entry) {
    // Each $entry has the following data in it:
    // - $entry['userId'] as an integer
    // - $entry['isActive'] as a boolean
}

获取和转换字段的方式与完全结构化的选择查询相同,但 SELECT ... FROM 之后的内容可以自由定义 - 如何连接表,检查什么,等等。您需要在查询构建器中调用 confirmFreeformQueriesAreNotRecommended 并输入 'OK' 来清楚地表明您已做出有意识的决定使用自由格式查询。

自由格式更新查询

自由格式更新查询也不推荐,但有时您可能没有其他执行查询的方法,而完全的自由可以启用比多次其他查询/多次更新更高效的查询。它的一般工作方式是通过定义 querywithParameters

$multiBuilder
    ->updateFreeform()
    ->inRepositories([
        'user' => $userRepositoryReadOnly // \Application\Entity\UserRepositoryReadOnly instance
        'visit' => $visitRepositoryReadOnly // \Application\Entity\VisitRepositoryReadOnly instance
    ])
    ->query('UPDATE :user:, :visit: SET :visit.timestamp: = ? WHERE :user.userId: = :visit.userId: AND :user.userId: = ?')
    ->withParameters([time(), 5])
    ->confirmFreeformQueriesAreNotRecommended('OK')
    ->write();

上述查询也显示了多表更新查询的主要缺点 - 它们几乎不能移植到其他数据库系统(因为它们不是SQL标准的一部分),因为上述查询对MySQL有效,但会对Postgres或SQLite失败,因为它们具有不同的语法/不同的限制。在许多情况下,如果您真正从这样的自定义查询中获益,这可能是可以接受的。

您可以使用 writeAndReturnAffectedNumber(而不是使用 write)来找出更新中找到的条目数量。您需要在查询构建器中调用 confirmFreeformQueriesAreNotRecommended 并输入 'OK' 来清楚地表明您已做出有意识的决定使用自由格式查询。

事务

通常,在事务中执行多个查询非常重要,尤其是在更改某些内容时,以确保所有更改都是原子性的。通过使用仓库,使用 Transaction 类来实现这一点很容易

use Squirrel\Entities\Transaction;

// Transaction class checks that all involved repositories use
// the same database connection so a transaction is actually possible
$transactionHandler = Transaction::withRepositories([
    $userRepositoryWriteable, // \Application\Entity\UserRepositoryWriteable instance
    $visitRepositoryReadOnly, // \Application\Entity\VisitRepositoryReadOnly instance
]);

// Passing additional arguments via `use` is recommended - you could also pass them as function arguments
$transactionHandler->run(function () use ($userId, $userRepositoryWriteable, $visitRepositoryReadOnly) {
    $visitsNumber = $visitRepositoryReadOnly
        ->count()
        ->where([
            'userId' => $userId,
        ])
        ->blocking()
        ->getNumber();

    $userRepositoryWriteable
        ->update()
        ->set([
            'visitsNumber' => $visitsNumber,
        ])
        ->where([
            'userId' => $userId,
        ])
        ->write();
});

withRepositories 函数的静态优势在于,没有它,它不会抛出 DBInvalidOptionException(无效的仓库、不同的连接等)。内部,Transaction 类使用类反射来检查数据,并期望是 RepositoryBuilderReadOnly 实例或 RepositoryReadOnly 实例(或使用 Writeable 而不是 ReadyOnly 版本)。

您可以通过传递实现 DBInterface(来自 squirrelphp/queries)的对象来轻松创建事务对象。以这种方式使用该类时,您需要自己确保所有涉及的仓库/查询使用相同的连接。

更复杂的列类型

目前只支持 stringintboolfloatblob 作为列类型,但数据库支持许多专用列类型——如日期、时间、地理位置、IP 地址、枚举值、JSON,以及可能还有很多(取决于 SQL 平台和版本)。

您应该不会在支持这些特殊类型时遇到问题,但由于这个库保持简单,它只支持基本的 PHP 类型,您将负责根据应用程序的需求使用或转换它们到其他类型。

以下是修改后的现有示例,展示如何处理非平凡列类型

namespace Application\Entity;

use Squirrel\Entities\Attribute\Entity;
use Squirrel\Entities\Attribute\Field;

#[Entity("users")]
class User
{
    #[Field("user_id", autoincrement: true)]
    private int $userId = 0;

    #[Field("active")]
    private bool $active = false;

    /** @var string JSON type in the database */
    #[Field("note_data")]
    private string $notes = '';

    /** @var string datetime type in the database */
    #[Field("created")]
    private string $createDate = '';

    public function getUserId(): int
    {
        return $this->userId;
    }

    public function isActive(): bool
    {
        return $this->active;
    }

    public function getNotes(): array
    {
        return \json_decode($this->notes, true);
    }

    public function getCreateDate(): \DateTimeImmutable
    {
        return new \DateTimeImmutable($this->createDate, new \DateTimeZone('Europe/London'));
    }
}

实体类在数据库和应用程序之间进行转换,以便应用程序可以用它理解的格式进行操作。如果 squirrelphp 处理这些转换,可能会出错——即使是看似简单的日期,也需要一个相对的时间区域。

您应该使用值对象来选择如何将数据库值转换为应用程序值,使其有意义——我们使用了 DateTimeImmutable 作为值对象,但可以是自定义的

namespace Application\Value;

class GeoPoint
{
    public function __construct(
        private float $lat = 0,
        private float $lng = 0,
    ) {
    }

    public function getLatitude(): float
    {
        return $this->lat;
    }

    public function getLongitude(): float
    {
        return $this->lng;
    }
}
namespace Application\Entity;

use Application\Value\GeoPoint;
use Squirrel\Entities\Attribute\Entity;
use Squirrel\Entities\Attribute\Field;

#[Entity("users_locations")]
class UserLocation
{
    #[Field("user_id")]
    private int $userId = 0;

    /** @var string "point" type in Postgres */
    #[Field("location")]
    private string $locationPoint = '';

    public function getUserId(): int
    {
        return $this->userId;
    }

    /**
     * Convert the point syntax from the database into a value object
     */
    public function getLocation(): GeoPoint
    {
        $point = \explode(',', \trim($this->locationPoint, '()'));

        return new GeoPoint($point[0], $point[1]);
    }
}

这里以 Postgres 的 point 数据类型为例,然后将其转换为 GeoPoint 值对象,以便应用程序可以轻松地传递和使用它。自定义数据类型通常以字符串形式由应用程序接收,然后可以按您希望的方式进行处理。

请注意,使用特定于数据库的列类型将使更改数据库系统/使您的实体和 SQL 代码成为供应商特定的变得更困难。尽管如此,使用这些列类型可能仍然值得,但您应该意识到这一点。

只读实体对象

因为这个库将读取和写入分开,并且只使用对象进行读取,所以实体对象不需要可变的——它们可以是不可变的和只读的。如果您以前使用过其他 ORM,这可能一开始看起来有些不直观(因为大多数 ORM 都是围绕可变实体对象构建的),但它确实提供了新的可能性

  • 不可变实体对象可以传递给模板或任何服务,而不用担心它们被更改或对应用程序的其它部分产生副作用
  • 缓存很容易:如果需要,使用您喜欢的任何技术来缓存实体对象,因为它们只是数据/普通对象
  • 实体类更多地关注实体逻辑(如何使用它),提供附加功能,并省略所有数据库/基础设施功能
  • 您可以为实体对象创建接口,以分离基础设施和实体逻辑,定义实体上的方法,这些方法不依赖于数据库中的数据(您还可以创建具有多个只读实体的聚合/根实体,抽象的可能性很多)
  • 库不需要跟踪您的实体,延迟加载它们的部分,或执行您不真正理解的复杂操作:所发生的一切都是直接的,并且受您控制
  • 您仍然可以使用对象来访问数据而不是使用非结构化数据,因此您可以清楚地定义您正在使用的类型,或实现返回值对象的方法,并且代码可以通过静态分析器进行检查

将查询与对象明确分开也有优势

  • 很容易发现更改的位置,只需查找使用 EntityRepositoryWriteable 类的地方,并且只在实际上写入存储库的地方使用可写的类,在其它地方使用 EntityRepositoryReadOnly
  • 查询并未完全从您这里抽象出来,您可以根据需要制作简单或复杂的查询,而不是希望库为您创建高效的查询并忘记数据是如何存储和处理的
  • 您可以使用简单的语法一次更改多行,或进行多存储库查询,而不需要学习新的查询语言
  • 使用命令总线或在应用程序中实现CQRS(命令查询责任分离)是一个容易遵循的模式,因为您始终可以定义是写入还是从存储库读取

关于如何使用此库的建议

  • 如果您使用Symfony,请务必安装并使用squirrelphp/entities-bundle,它基于这个库并为您自动配置几乎所有内容
  • 通过定义所有属性为私有,仅使用getter,或使用PHP 8.1+的public readonly,使实体对象只读/不可变
  • 仅使用生成的构建器存储库来访问和更改您的实体,并使用Transaction::withRepositories进行事务
  • 仅在是经过深思熟虑的决定且没有更好的替代方案时使用多存储库查询
  • 在您的应用程序中分离读取和写入操作,以清楚地显示数据是在哪里更改的以及在哪里只检索