hiraeth / turso
Turso 数据库抽象层,用于 Hiraeth (及其他)
Requires
- php: >=8.2
- ext-pdo_sqlite: *
- guzzlehttp/guzzle: ^7.8
- hiraeth/app: ^3.0
Requires (Dev)
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^8.5
This package is auto-updated.
Last update: 2024-09-21 14:41:10 UTC
README
此包为 Hiraeth Nano 框架提供 Turso 数据库抽象和轻量级 ORM 层,尽管该包对于其他人来说也普遍有用,作为一个通用的 Turso 库。
主要目标是创建一个相当薄的一层,具有一些基本且实用的特性。此项目并不旨在重现 Doctrine 或其他同等强大的 ORM 的所有功能。如果您想为 Turso 使用 Doctrine,我建议在 Doctrine 内部进行支持,因为它可能足够抽象,您可能可以做到,而且可能并不那么困难。
注意:此软件仍在 beta 测试阶段,更接近 alpha,事物正在迅速变化,一些功能可能完全不完整。现在请自行承担更多高级功能的风险。此外,如果您想知道版本 1 和 2 发生了什么,Hiraeth 包与框架一起版本化,Hiraeth 1.x - 2.x 没有此包,且不会回滚,因此只有 3.0-beta。如果您只是将其用作库,那么这应该无关紧要。
安装
composer require hiraeth/turso
测试
如果您想测试并玩转这个,请按照以下说明操作
- 克隆此仓库:
git clone https://github.com/hiraeth-php/turso.git
- 更改目录:
cd turso
- 执行:
composer install
- 在 Linux (仅限?) 上:
chmod 666:666 test/data/sqld
- 在 docker 中运行:
docker compose up -d
- 执行:
php test/index.php
注意:上述第 3 步似乎是解决 Linux 上 docker 一些权限问题的必要步骤。基本上,内部 LibSQL 服务器 docker 映像创建了和
sqld
用户/组(id 为 666)。它写入的文件夹需要具有此 uid 和 gid,以便数据库能够正确初始化并写入。
修改测试
如果您想创建自己的表并添加相应的实体和存储库,请注意该包是类映射自动加载的。只要您从克隆的 repo 运行测试(以库作为根包),它将从 test/src
自动加载所有内容,因此您只需将类添加到那里即可。然而,您需要运行 composer dump
以使它们被识别。
基本用法
在 Hiraeth 中,您可以通过将以下内容添加到您的 .env
中来配置默认数据库连接
[TURSO] URL = https://<dbname>-<organization>.turso.io TOKEN = Bearer <token>
注意:尽管有一个数据库管理器,但它将不再得到支持。话虽如此,创建新的数据库相当简单,在 Hiraeth 中配置多个数据库只需编写一点包装器就非常直接。
这就是您需要做的。从那里,您可以通过 Hiraeth\Turso\Database
类在任何您通常获取自动注入的地方进行自动注入(动作、中间件等)。您的存储库也可以使用默认数据库进行自动注入。
对于其他框架/作为库
对于非集成使用或 Hiraeth 之外,或要实例化多个数据库,构造一个新实例如下所示
$database = new Hiraeth\Turso\Database( new GuzzleHttp\Client(), 'https://<dbname>-<organization>.turso.io', 'Bearer <token>' );
注意:您可以通过替换 URL 并通常使用
Basic
身份验证令牌代替Bearer
来针对本地 LibSQL SQLD 服务器(如测试中所示)运行。
执行查询
您可以运行两种主要类型的查询
- 静态查询
- 参数化查询
两种样式不应混合。已提醒。
静态查询
静态查询是不带参数的完整SQL查询,所有值都假定为已转义,它就相当于直接在数据库shell中输入查询一样。一个简单的例子可能是从数据库中获取所有用户。
$result = $database->execute( "SELECT * FROM users" );
或者,获取具有特定电子邮件域的所有用户
$result = $database->execute( "SELECT * FROM users WHERE email LIKE '%@hiraeth.dev'" );
当没有将额外的参数传递给Database::execute()
函数时,查询被确定为静态。
参数化查询
相比之下,参数化查询允许您在占位符处插入变量。将上述查询重写为参数化查询的例子如下
$result = $database->execute( "SELECT * FROM @table WHERE email LIKE {domain}", [ 'domain' => '%@hiraeth.dev' ], [ 'table' => 'users' ] );
参数化查询可以放置两种类型的参数
- 可转义变量
- 原始值
如上所示,可转义值以{variable}
(由{}括号包围的名称)的样式出现,而原始值以@reference
(以@符号开头的名称)的样式出现。原始值不会转义,因此您需要根据它们放置的位置来验证它们是否有效。
获取结果
所有上述示例中的$result
将保存Hiraeth\Turso\Result
的实例。您可以通过多种方式轻松访问其中的记录。但是,当使用execute()
方法时,无法保证查询是否成功,因此您首先需要检查错误。
if ($result->isError()) { // // handle the error // }
您还可以简单地抛出带有自定义消息的\RuntimeException
$result->throw('Failed executing the query');
假设没有错误,让我们来看看如何使用结果。
迭代
您可以轻松地按如下方式遍历记录
foreach ($result as $record) { echo sprintf( 'User with e-mail %s has an ID of %s', $record->email, $record->id ); }
单个记录
如果您需要获取单个记录,例如如果使用了LIMIT 1
,您可以使用
$record = $result->getRecord(0);
但是请注意,如果查询实际上没有产生任何记录或请求的记录超过返回的记录数,则getRecord()
方法将返回NULL
。
所有记录
在某些情况下,您可能希望使用PHP内置的数组函数(如映射、遍历、排序等)通过其他应用程序逻辑来处理记录。虽然这可能不适合大量记录,但您可以使用以下方式获取所有记录
$records = $result->getRecords();
记录计数
如果您只需计算记录数,可以使用PHP的count()
标准函数
$count = count($result);
记录作为实体
记录采用对象实体的形式,简单的DTO(数据传输对象),默认情况下将一对一地映射到您的列名。所有记录(无论它们是从哪个表中检索的)都将具有Hiraeth\Turso\Entity
类。虽然这可能适用于简单的应用程序,但如果您想添加额外的业务逻辑来创建更复杂的模型、保护并使用getter和setter包装实体属性等,则您将想要创建一个类型化实体。
类型化实体
类型化实体是简单地扩展了Hiraeth\Turso\Entity
类的自定义类。它们比默认的无类型实体具有许多高级功能。以下是一个类型化实体的简单示例,继续使用我们的用户示例
use Hiraeth\Turso\Types; class User extends Hiraeth\Turso\Entity { const table = 'users'; const identity = [ 'id' ]; const types = [ 'dateOfBirth' => Types\Date::class ]; protected $id; public $firstName; public $lastName; public $email; public $dateOfBirth; public function fullName() { return trim(sprintf( '%s %s', $this->firstName, $this->lastName )); } }
在上面的示例中,我们选择保护id
使其不能被修改。您也可以轻松地保护其他属性并按需使用setter和getter。您可能还注意到一些属性是camelCase。这是因为Hiraeth\Turso
将“自动”通过将属性名称转换为小写并删除所有非字母数字字符,然后与返回的列进行比较来“确定”哪些属性映射到哪些返回的列。
在上面的示例中,数据库中的列可能是first_name
和last_name
。
转换结果
为了将结果记录作为类型化实体获取,您需要将结果转换,这是通过使用of()
方法完成的,该方法将在转换后返回结果。
$records = $result->of(User::class);
由于该方法返回结果(只是设置了一些内部属性),您同样可以在迭代中使用它。
foreach ($result->of(User::class) as $user) { echo sprintf( '%s has an e-mail of %s' . PHP_EOL, $user->fullName(), $user->email ); }
自定义数据类型
LibSQL(类似于SQLite)没有丰富的数据类型,如“日期”、“时间”等。如果我们考虑SQL作为一门语言的工作方式,日期是以字符串的形式发送和接收的,通常格式为YYYY-MM-DD
。因此,为了获取和设置自定义数据类型,您需要定义实体的字段类型。这是通过实体的types
常量来完成的。回想一下上面的内容
const types = [ 'dateOfBirth' => Types\Date::class ];
自定义类型是一个简单的类。以下是一个Hireath\Turso\Types\Date
类型的示例
namespace Hiraeth\Turso\Types; use DateTime; /** * Handles dates */ class Date { /** * Convert a value from the database to the entity */ static public function from(string|null $date): DateTime|null { return $date ? new DateTime($date) : NULL; } /** * Convert a value from an entity to the database */ static public function to(DateTime|null $date): string|null { return $date ? $date->format('Y-m-d') : NULL; } }
可以看到,只需要定义两个方法,即from
和to
。注释应该已经很清楚地说明了每个方法的作用。类型基本上总是支持NULL
,以防列是可空的。
这意味着当您从用户存储库中提取实体或将它转换为User
时,类型将相应地转换。也就是说,对于这个用户,实体的dateOfBirth
属性将是NULL
或实际的DateTime
对象。当您使用存储库插入或更新实体时,to
方法将它们转换回所需的数据库值。
注意:在类型中不需要转义值。转换发生在其他转义之前。
关联
类型化实体还有一个好处。即,它们可以轻松地获取其他类型的关联实体。让我们假设除了我们的User
实体之外,我们还有一个Occupation
实体,每个用户有一个职业,每个职业有多个用户。此外,让我们假设我们有一个允许用户互加为朋友的friends
表,从而创建了一个多对多关系。在这里,我们可以更详细地查看这些关系中的每一个。
星型到一对一
public function occupation() { return $this(Occupation::class)->hasOne( [ 'occupation' => 'id' ], FALSE ); }
在上面的例子中,当我们调用User::occupation()
时,我们将获得与User
上的occupation
相对应的Occupation
。让我们一步步来分析。首先,创建一个新的与Occupation
类型的关联。
$this(Occupation::class)
然后,我们使用这个关联通过hasOne()
检索一条记录。第一个属性包含从User
到Occupation
的映射
[ 'occupation' => 'id' ]
注意:当定义返回的关联时,我们总是使用实际的列名,而不是实体的对应属性名。
hasOne()
方法的第二个参数告诉关联不要刷新。这意味着一旦获取了相关记录,我们不会再次查询。如果这设置为TRUE
,每次调用occupation()
都会执行查询以查找相关的职业。虽然这对于快速变化的数据是有益的,但也会影响性能。我们建议在occupation()
方法本身中添加一个参数以传递,以防您需要刷新
public function occupation(bool $refresh = FALSE): ?Occupation { return $this(Occupation::class)->hasOne( [ 'occupation' => 'id' ], $refresh ); }
在*到一对一关联的情况下,返回值总是相应的实体类型或如果该人没有相关记录的NULL
。
现在我们有了基本的概念,让我们继续看其他,它们看起来会非常相似。
一对一
在我们User
的例子中,我们可以想象在Occupation
类上有以下内容
public function users(bool $refresh = FALSE): Result { return $this(User::class)->hasMany( [ 'id' => 'occupation' ], $refresh ); }
在这里,默认的$refresh
为false非常重要。虽然您可以将结果存储在变量中,但如果您在调用方法时执行了count()
,然后迭代,如果$refresh
为TRUE
,实际上将执行两次查询
if (count($occupation->users())) { foreach ($occupation->users() as $user) { // ... } }
这在大关系的情况下可能会非常昂贵。最后,最后一个也是最为复杂的例子。
多对多
与前例相比,这个例子的主要区别在于我们需要指定一个额外的“通过”表,以及在我们映射中的两个条目。记住,这是一个关于User
类的假设,为了获取一个用户的好友,因为许多用户可能有多个好友,我们需要一个连接表。
public function friends($bool $refresh = FALSE): Result { return $this(User::class, 'friends')->hasMany( [ 'id' => 'user', 'friend' => 'id' ], $refresh ); }
在调用$this()
时要注意第二个参数。第一个参数保持为我们最终的目标表,第二个定义了“通过”表。相应地,我们的映射现在有两个条目。第一个是如何从正在操作的当前用户到好友,第二个是从好友到他们的用户。关联映射始终从当前记录移动到目标记录,在这种情况下,中间的'user', 'friend'
反映了我们连接表的结构。
CREATE TABLE friends (
user INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
friend INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
PRIMARY KEY (user, friend)
);
路线图
以下功能尚未完成,但它们与关联相关联。
设置关联
已经建立了基础,以便设置关联记录,这将自动更新相关ID。这种风格的建议是修改关联函数以调用相应的changeOne
和changeMany
方法,一个快速示例重新使用User::occupation()
方法可能如下所示
public function occupation(bool|Occupation $refresh = FALSE): ?Occupation { if ($refresh instanceof Occupation) { $this(Occupation::class)->changeOne($refresh, [ 'id' => 'occupation' ]); } return $this(Occupation::class)->hasOne( [ 'occupation' => 'id' ], $refresh ); }
然后,您将传递新关联的职业,如下所示
$user->occupation($occupation);
这将
- 将
occupation
属性更新为与Occupation
的ID相匹配的User
。 - 将
users
表更新为将职业列设置为职业的ID。 - 更新缓存并返回新职业。
守卫子句
定义守卫子句的方式,这样您就可以获取相关记录,但仅基于满足额外条件的一小部分。这是一个长期愿景,对此没有想象中的语法。欢迎提出建议。
存储库
存储库是访问您的表的更高级网关。它们仅处理类型实体,并提供了轻松创建、插入、更新和删除实体的能力。它们还允许更短的关联语法。
您可以通过扩展Hiraeth\Turso\Repository
来创建存储库。存储库看起来像这样
class Users extends Hiraeth\Turso\Repository { const entity = User::class; const order = [ 'firstName' => 'asc', 'lastName' => 'asc' ]; }
注意:定义在从数据库选择实体时的默认排序顺序以及指定实体主键或唯一ID构成字段的
order
常量和identity
常量都使用字段名表示,而不是列。
获取存储库
在Hiraeth中,存储库将与默认配置的数据库自动注入,因此您可以将其直接添加到任何依赖注入的位置(操作、中间件等)。或者,当将其用作库时,您可以从Database
实例请求存储库。
$users = $database->getRepository(Users::class);
如果您直接实例化,则必须将数据库实例传递给它
$users = new Users($database);
执行常见操作
存储库支持以下常见操作
- 创建新实体实例。
- 将实例插入数据库。
- 更新数据库中的实例。
- 删除数据库中的实例。
- 在数据库中查找实体。
我们将非常简要地介绍这些,因为它们都很直接,不需要太多解释。让我们看看整个生命周期
创建
$user = $users->create([ 'firstName' => 'Hiraeth', 'lastName' => 'User', 'email' => 'info@hiraeth.dev' ]);
注意:创建不会将实体插入数据库,它仅创建实例并填充数据。
您也可以单独设置属性
$user = $users->create(); $user->firstName = 'Hiraeth'; $user->lastName = 'User'; $user->email = 'info@hiraeth.dev';
插入
一旦创建了实例,您可以轻松地将其插入
$result = $users->insert($user);
与Database::execute()
不同,所有存储库操作在Turso错误响应时都会立即抛出异常。这意味着您可能想要将操作包装在try/catch中。无论如何,在您想要进一步检查结果的情况下,成功时将返回一个Hiraeth\Turso\Result
。这在我们将要看到的更新中很有用。
更新
当实体从存储库加载时,它们的初始值存储在实体的 $_values
属性上(通常由未定义类型的实体用于存储实际属性值)。因此,使用类型化实体进行存储库仅访问的好处是,可以进行差异比较,从而只发送带有更新的列。例如,如果我们更改用户的 firstName
,它只会在 SQL 中 SET
。
$user->firstName = 'Laravel';
虽然我们可能现在就直接发送到 delete()
,但让我们先展示一些礼貌。我们仍然会执行更新。
$result = $users->update($user);
调用时
- 将执行当前属性值和实体由存储库实例化时获得的原始值之间的“差异”。
- 用户的相应
first_name
列(唯一更改的列)将通过标准UPDATE
查询进行更新。 - 更改将推送到
$_values
属性,以便更新的信息现在被认为是反映数据库中的信息。
成功吗?
遗憾的是,由于 UPDATE
本身的性质,Turso 没有返回错误 并不 暗示该实体实际上已更新。事实上,在我们处理它们的同时,它们可能已被完全从数据库中删除。由于 UPDATE
可以对其 WHERE
子句的多个(因此是 0)匹配记录执行更新,我们实际上可能需要通过查看受影响的行来再次确认。
if (!$result->getAffectedRows()) { // // Handle the user having gone missing // }
如果您根本不在乎用户可能已丢失,显然您不需要在这里做任何事情。
删除
好吧,我想我们无论如何都到了这里。现在,我们的 Hiraeth 用户已更新为 Laravel 用户,我们可以删除它们。
$users->delete($user);
现在您可能已经看到了这些方法是如何工作的模式。与 UPDATE
类似,您可以检查它是否影响了一行,但为什么要费这个功夫呢?
查找
查找实体相对简单。与其他所有存储库操作一样,大多数查找和选择都会返回一个结果(不是数组),有一个例外。
$user = $users->find(1);
简单的 find()
方法将基于主键 ID 或一组标准返回单个结果(如果找不到,则返回 NULL
)。严格来说,标识符不需要是主键或甚至是一个列。它可以是一组产生单个结果的标准。
$user = $users->find(['email' => 'info@hiraeth.dev'])
仅当有单个列 Repository::identity
时才支持标量,这确实可能是大多数情况。
注意:在尝试将
find()
作为一般简写使用之前,如果标准产生多个结果,它将抛出\InvalidArgumentException
。因此,您应该只使用与主键或唯一约束(单列或多列)相对应的标准。
查找所有
如果您只想查找所有记录,这很简单。
$all_users = $users->findAll();
您可以传递一个可选的排序数组作为参数,或者如果为空,则将使用存储库上定义的默认 order
。
$all_users = $users->findAll(['email' => 'asc']);
按...查找
介于 find()
和 findAll()
之间的是 findBy()
方法。它接受一组与 find()
类似的标准,但如果结果中返回多个记录,则不会抛出异常。它还接受可选的排序数组作为其第二个参数。最后,它接受第三个和第四个参数的 $limit
和 $page
(不是 偏移量)。一个完整的示例可能看起来像这样
$taylors = $users->findBy( [ 'firstName' = 'Taylor' ], [ 'lastName' => 'desc' ], 20, 1 );
更简单地说,查找所有名字为 Taylor 的用户,按姓氏降序排序,并只提供前 20 个结果。
选择
唉,并非所有用户搜索都是通过像 x = y 这样的简单等式来约束的。所以最后一个也是功能最强大(也是最复杂)的特性当然是完整的 SELECT
查询。
use Hiraeth\Turso\SelectQuery; use Hiraeth\Turso\Expression; $entities = $users->select( function(SelectQuery $query, Expression $is) { $query ->where( $is->like('email', '%@hiraeth.dev'), $is->gte('age', 30) ) ->order( $query->sort('age', 'desc') ) ->limit(20) ->offset(0) } );
希望这很容易理解,因为我并不想解释它。显然受到了 Doctrine 查询构建器的启发,但重要的是要注意它 不是 一个构建器。它更像是一个模板扩展器。SelectQuery
只是一个模板化并带有方法的查询,它将我们带回到 Database::execute
的例子中。以下是这个模板,如果你有兴趣的话
SELECT @names FROM @table @where @order @limit @offset
下面是调用 order()
方法后的结果
/** * Set the "ORDER BY" portion of the statement */ public function order(Query ...$sorts): static { if (empty($sorts)) { $clause = $this(''); } else { $clause = $this('ORDER BY @sorts')->bind(', ', FALSE)->raw('sorts', $sorts); } $this->raw('order', $clause); return $this; }
考虑到这一点,调用上述任何一种方法都会 替换(而不是像构建器那样添加)现有的方法。因此,要创建复杂的 where()
条件,你可以使用 all
(与)和 any
(或)表达式
where( $is->like('email', '%@hiraeth.dev'), $is->any( $is->gte('age', 30), $is->lte('age', 50) ) );
这将导致以下结果
WHERE email LIKE '%@hiraeth.dev' AND (age >= 30 OR age <= 50)
你可以嵌套任意数量的 any()
和 all()
来分组条件。名称的选择是这样的,因为 any()
表示组中的任意一个表达式必须为 TRUE
(因此具有 '或' 的等价性),而 all()
表示组中的所有表达式都必须为 TRUE(因此具有 '与' 的等价性)。
有关所有支持的操作符列表,请参阅源代码中的 src/Expression.php
文件。