数据库框架和查询构建器

0.7.8 2022-11-28 14:35 UTC

README

数据库框架和查询构建器。

PHP Version Build Status Code Coverage Scrutinizer Code Quality License GPLv3

此库实现了(又一)数据库抽象和(又一)查询构建器。

它支持

  • 支持INSERTUPDATEDELETESELECT查询的查询构建器,具有链式API。
  • 模式抽象,支持在PHP数据类型/对象和关系数据之间进行双向转换。
  • 在PDO之上进行抽象,增加对PDO风格的:name占位符的array值支持。
  • 查询结果流式迭代,允许您以任意大小的批次处理结果。
  • 对单个记录或记录批次上的函数进行即时映射。
  • 不是对象/关系映射器。

此项目的一个重要非目标是能够在不同的数据库技术之间切换 - 虽然我们支持MySQL和PostgreSQL,而且很多实现都是共享的,但我们并没有试图隐藏或抽象每种技术之间的差异。相反,我们试图明确指出,在能力和每种数据库管理系统最佳模式方面都存在差异。

我们更倾向于简单而不是易用性 - 此库最小化PDO的抽象,并尽可能地接近SQL和关系模型,而不是试图隐藏它们。

项目状态

根据SimVersion0.x版本系列是功能不完整的,不稳定的,并且只有在功能完整的情况下才会过渡到1.x

该项目在我们的组织中已被广泛用于许多内部项目 - 它是“稳定的”,但仍然可能发生变化,并且在未来一段时间内仍将如此。

公共API在许多版本中都是相对稳定的 - 到目前为止,大多数破坏性变更都是对API内部protected部分的变更;通常,一个主要的0.x+1版本对客户端代码的影响非常小。

贡献

当前目标是php 8.1或更高版本。

代码遵循PSR-2PSR-4

您需要一个工作的Docker安装来运行测试。

使用VS Code

该项目包括一个DevContainer和一个用于VS Code中测试和调试的launch.json - 只需在VS Code中打开项目,按F5运行测试,您就可以开始了。✨

使用Docker

在PHP 8.3中启动Docker并运行测试套件

./test.sh 8.3

一旦Docker环境运行,您就可以在现有的Docker环境中更快地运行测试

./test.sh

一旦运行,如果您愿意,也可以直接进入PHP容器并从那里运行测试,例如

docker compose exec php bash
composer update
composer test
exit

概述

此库中使用的概念大致可以分为两个主要领域:框架模型

框架mindplay\sql\framework命名空间)由数据库连接、语句和预处理语句抽象组成,以及PDO的实现。

此外,该框架包括一个可迭代的Result模型,它支持Mapper抽象和实现,这些实现为单个记录上的自定义操作以及批量处理大型结果集提供支持。

模型mindplay\sql\model命名空间)包括Driver抽象,用于INSERTSELECTUPDATEDELETE和自定义SQL查询的查询构建器,Schema模型和Type抽象,其中包含Type转换的Mapper实现。

使用方法

每个项目都需要一个Schema类,并为该Schema中的每个表创建一个Table类。

此引导过程可能看起来有点冗长,但有了IDE支持,您将很快就能写出这些简单的类 - 这些类反过来将为静态分析工具和IDE提供支持,例如,自动完成表/列名,使您的数据库工作简单且安全。

这是值得的。

创建表模型

通过扩展Table类来定义您的表模型。

您的表类充当Column对象的工厂。

为每个列添加一个方法,每个方法返回一个Column对象 - Table类提供几个不同的受保护工厂方法来帮助创建Column实例。

方法名称应与列名称匹配,因此您可以使用__FUNCTION__来避免重复。

Table类实现了__get(),因此当引用列时可以省略括号。您应该为每个列添加一个@property-read注解以进行最佳静态分析。

表模型模式如下

/**
 * @property-read Column $id
 * @property-read Column $first_name
 * @property-read Column $last_name
 */
class UserTable extends Table
{
    public function id($alias)
    {
        return $this->autoColumn(__FUNCTION__, IntType::class, $alias);
    }
    
    public function first_name($alias)
    {
        return $this->requiredColumn(__FUNCTION__, StringType::class, $alias);
    }

    public function last_name($alias)
    {
        return $this->requiredColumn(__FUNCTION__, StringType::class, $alias);
    }
}

以下受保护的工厂方法可用于帮助创建Column实例

  • requiredColumn()用于非必选列:如果未明确指定这些列的值,INSERT查询构建器将抛出异常。

  • optionalColumn()用于具有默认值和/或允许NULL的列。

  • autoColumn()用于数据库本身将填充的列,例如自增列或其他由数据库本身初始化的列。

有关参数,请参阅Table API。

请注意,“必需”和“可选”不一定与您的模式中的IS NULL一一对应。例如,列可以是“必需的”,但仍然允许SQL NULL值 - 在这种情况下,“必需”意味着您必须显式提供null值,例如到INSERT查询构建器,这可能在某些用例中更安全且更明确。

列类型

每个Column都通过其类名引用一个Type。(例如DateType::class等。)

Type实现负责在SQL值和PHP值之间进行转换,双向转换。

Type实现内部自动绑定到DI容器中 - 您不需要显式注册自定义Type实现。

内置类型适用于标量PHP类型(stringintfloatboolnull)以及一些其他SQL类型。

有关可用类型和文档,请查看mindplay\sql\model\types命名空间。

创建Schema模型

通过扩展Schema类来定义您的Schema模型。

您的Schema类充当Table对象的工厂。

为每种Table类型添加一个方法,每个方法返回一个Table对象 - Schema类提供了一个受保护的工厂方法createTable()来帮助创建Table实例。

方法名称应与表名称匹配,因此您可以使用__FUNCTION__来避免重复。

《Schema》类实现了__get(),因此在引用表时可以省略括号。为了最佳静态分析,您应该为每个表添加一个@property-read注解。

模式模型模式看起来像这样

/**
 * @property-read UserTable    $user
 * @property-read AddressTable $address
 */
class UserSchema extends Schema
{
    /**
     * @param string $alias
     * 
     * @return UserTable
     */
    public function user($alias)
    {
        return $this->createTable(UserTable::class, __FUNCTION__, $alias);
    }

    /**
     * @param string $alias
     * 
     * @return AddressTable
     */
    public function address($alias)
    {
        return $this->createTable(AddressTable::class, __FUNCTION__, $alias);
    }
}

启动项目

如果您使用依赖注入容器,则应一次性执行此启动操作,并将这些对象注册为容器中的服务。

首先,选择一个Database实现 - 例如

$db = new MySQLDatabase();

接下来,创建(并在您的DI容器中注册)您的Schema模型

/** @var UserSchema $schema */
$schema = $db->getSchema(UserSchema::class);

最后,创建(并在其中注册)匹配的Connection实现 - 例如

$connection = $db->createConnection(new PDO("mysql:dbname=foo;host=127.0.0.1", "root", "root"));

不要纠结于您需要三个不同的依赖项的事实 - 它可能看起来很复杂或冗长,但实际上非常简单;这三个组件各自具有非常明确的目的和范围

  • Database模型充当Schema-类型的工厂,以及各种查询类型(插入、选择等)

  • 您的Schema-类型充当您领域特定Table-类型的工厂类。

  • Connection对象在PDO上提供了一层薄包装,它没有自己的接口。

请注意,Database模型和Schema-类型与Connection对象没有依赖关系 - 数据库模型和查询构建器完全在抽象层面上操作,不依赖于任何物理数据库连接,这对于您独立于任何数据库连接编写(和单元测试)复杂的查询构建器来说是非常好的。

构建查询

创建查询从Database模型和您的Schema-类型开始。

以下是一个使用SelectQuery构建器构建选择查询的基本示例,该构建器是通过select()工厂方法创建的

$user = $schema->user;

$query = $db->select($user)
    ->where("{$user->first_name} LIKE :name")
    ->bind("name", "%rasmus%")
    ->order("{$user->last_name} DESC, {$user->first_name} DESC")
    ->page(1, 20); // page 1, 20 records per page, e.g.: OFFSET 0 LIMIT 20

请注意__toString()魔术方法的使用,它由Table-类型和Column对象支持:这些属性/方法返回引号名称 - 例如,如果您使用的是PostgresConnection,则{$user->last_name}扩展为"user"."last_name"

以下查询构建器提供了工厂方法

  • select(Table $from)创建用于SELECT查询的SelectQuery构建器
  • insert(Table $into)创建用于INSERT查询的InsertQuery构建器
  • update(Table $table)创建用于UPDATE查询的UpdateQuery构建器
  • delete(Table $table)创建用于DELETE查询的DeleteQuery构建器
  • sql(string $sql)创建用于自定义SQL查询的SQLQuery构建器

所有查询构建器都支持通过bind()apply()进行参数绑定。

SELECTUPDATEDELETE查询的构建器支持通过where()方法进行条件。

此外,一些查询构建器支持针对这些查询类型的一些特定功能。

绑定参数

所有类型的查询构建器都扩展了Query构建器,该构建器实现了参数绑定 - 这是所有查询类型共有的一个功能,包括“原始”SQLQuery类型。

为了避免SQL注入,所有值都应该绑定到占位符 - 我们无法阻止您将文本值直接插入查询中,但请不要这样做:您应该始终使用参数绑定。

您可以使用bind()方法将单个占位符(例如:name)绑定到值

$query->bind("name", $value);

对于原生标量类型(stringintfloatboolnull以及这些数组的数组)类型会自动从值类型推断出来。

对于其他类型,您必须手动指定要使用哪种类型 - 例如,内置的DateType可以将整数时间戳值扩展为DATE SQL表达式

$query->bind("created", $timestamp, DateType::class);

为了方便起见,您还可以将一个名称/值对的映射apply()到多个占位符上

$query->apply([
    "first_name" => $first_name,
    "last_name" => $last_name,
]);

请注意,apply()仅适用于标量类型(以及这些类型的数组) - 明确绑定到特定类型需要多次调用bind()

SELECT 查询

SELECT 查询构建器支持迄今为止最广泛的 API 方法。

  • 列和 SQL 表达式的投影。
  • 通过 where() 方法设置条件。
  • 通过 order() 方法排序。
  • 通过 innerJoin()leftJoin()outerJoin() 方法进行连接。
  • 通过 limit()page() 方法限制。

以下各节将介绍所有这些内容。

投影

要创建 SELECT 查询构建器,您必须指定投影的根 - 例如

$user = $schema->user;

$query = $db->select($user);

如果您没有手动指定应选择哪些列,则默认情况下,这将构建一个简单的查询,如

SELECT * FROM user

您可以显式指定希望选择的列

$user = $schema->user;

$query = $db
    ->select($user)
    ->columns([
        $user->first_name,
        $user->last_name,
    ]);

请注意,所选列的原始 SQL 值将自动转换为 PHP 类型,使用的是您表/列模型中定义的类型信息。

这与 value() 方法形成对比,后者允许您添加任何自定义 SQL 表达式进行选择

$user = $schema->user;

$query = $db
    ->select($user)
    ->columns([$user->id])
    ->value("CONCAT({$user->first_name}, ' ', {$user->last_name})", "full_name");

请注意,由于我们正在构建一个 SQL 表达式并将其作为字符串传递,列中的类型信息不能自动使用 - 在此示例中,原始 SQL 值是一个字符串,而且恰好是我们想要的类型,因此我们不需要指定类型。

在其他情况下,您可能需要显式指定类型 - 例如,在此示例中,我们计算一个 age 值,使用 IntType::class 指定转换值

$user = $schema->user;

$query = $db
    ->select($user)
    ->table($user)
    ->value("DATEDIFF(hour, {$user->dob}, NOW()) / 8766", "age", IntType::class);

请注意此示例中使用的 table($user) - 我们正在选择整个表(所有列)以及自定义的 age 表达式。

拥有

基于上述示例,我们可以添加一个 SQL HAVING 子句来选择合法饮酒年龄的用户

$query->having("age >= 21");

having() 的重复调用将追加到 HAVING 表达式的列表中。

(注意,此特定示例可以通过复制 DATEDIFF 表达式并将 >= 21 条件添加到 where() 子句来优化。)

分组

我们可以通过添加 SQL GROUP BY 子句来构建一个聚合查询 - 例如,在此示例中,我们创建了一个按国家名称分组的用户数量投影

$user = $schema->user;

$query = $db
    ->select($user)
    ->columns([$user->country])
    ->groupBy($user->country)
    ->value("COUNT({$user})", "num_users");

请注意,对 groupBy() 的重复调用将追加到 GROUP BY 项的列表中。

条件(《WHERE》)

请注意,where() 方法由 SELECT、UPDATE 和 DELETE 查询构建器支持。

当您使用 where() 添加多个条件时,这些条件将使用 AND 运算符组合 - 因此,您的查询必须匹配所应用的所有条件。

⚠ 在 where() 条件中使用的 SQL 文本表达式必须始终使用 :name 占位符 - 抵制注入文本值的诱惑,即使这看起来完全安全:重构等可能会使安全注入变得不安全,永远不要冒这个风险。

where() 方法接受单个 SQL 条件或条件数组 - 例如

$user = $schema->user;

$query = $db
    ->select($user)
    ->where([
        "{$user->first_name} LIKE :first_name",
        "{$user->last_name} LIKE :last_name",
    ])
    ->apply([
        "first_name" => "ras%",
        "last_name" => "sch%",
    ]);

这将生成一个类似的 SQL 查询

SELECT * FROM user WHERE (first_name LIKE "ras%") AND (last_name LIKE "sch%")

提供了两个简单的辅助函数来帮助您使用任何组合的 ANDOR 运算符构建任意嵌套的条件

  • expr::all() 将条件组合以匹配所有给定条件。(通过使用 AND 组合它们。)
  • expr::any() 将条件组合以匹配给定条件中的任何一个。(通过使用 OR 组合它们。)

例如

  • expr::all(["a = :a", "b = :b"]) 组合为 "(a = :a) AND (b = :b)"
  • expr::any(["a = :a", "b = :b"]) 组合为 "(a = :a) OR (b = :b)"

因此,基于上面的第一个示例,如果您想通过first_name last_name进行搜索,您可以使用expr::any()来组合条件,然后再将它们添加到查询中——也就是说

$user = $schema->user;

$query = $db
    ->select($user)
    ->where(
        expr::any([
            "{$user->first_name} LIKE :first_name",
            "{$user->last_name} LIKE :last_name",
        ])
    )
    ->apply([
        "first_name" => "ras%",
        "last_name" => "sch%",
    ]);

这将生成一个类似的 SQL 查询

SELECT * FROM user WHERE (first_name LIKE "ras%") OR (last_name LIKE "sch%")

⚠ 注意,这两个函数在您传入一个空数组时都会抛出InvalidArgumentException异常。这完全是设计的,因为我们不能将零个条件组合成一个有意义的条件——如果您的领域中的某些条件列表是零或多个,您需要主动决定是否生成没有附加条件的条件、一个IS NULL条件,或者完全不同的其他条件。

连接(Joins)

SELECT、UPDATE和DELETE查询构建器支持各种JOIN方法,包括innerJoin()leftJoin()rightJoin()

所有JOIN方法接受相同的参数,例如leftJoin(Table $table, string $expr)等等。

$table参数指定要连接的表,而$expr参数指定ON子句。

让我们通过customerorder表来考察一个典型的使用场景——假设我们想获取客户记录列表以及每个客户已下订单的数量

$customer = $schema->customer;
$order = $schema->order;

$query = $db
    ->select($customer)
    ->table($customer)
    ->leftJoin($order, "{$order->customer_id} = {$customer->id}")
    ->value("COUNT({$order})", "num_orders")
    ->groupBy($customer->id);

这将生成一个类似的 SQL 查询

SELECT
  customer.*,
  COUNT(order) AS num_orders
FROM
  customer
LEFT JOIN
  order ON order.customer_id = customer.id
GROUP BY
  customer.id

注意使用了groupBy()value(),这两个方法仅适用于SELECT查询构建器。

注意,可以通过命名关系变量来实现自连接,例如,在典型的employee表使用场景中,其中supervisor_id引用了另一个employee,我们可以创建第二个别名,例如employee AS supervisor来获取包括直接主管名称的员工列表

$employee = $schema->employee;
$supervisor = $schema->employee("supervisor"); // e.g. "employee AS supervisor"

$query = $db
    ->select($employee)
    ->table($employee)
    ->leftJoin($supervisor, "{$supervisor->id} = {$emplyoee->supervisor_id}")
    ->columns($supervisor->name);

INSERT查询

这可能是所有查询构建器中最简单的一个。

要创建一个INSERT查询构建器,您必须指定目标表,然后调用add()方法添加一个或多个记录——例如

$user = $schema->user;

$query = $db
    ->insert($user)
    ->add([
        "first_name" => "Rasmus",
        "last_name" => "Schultz",
        "dob" => 951030427,
    ]);

注意,数组键必须与目标表中的列名匹配,以便应用列的类型转换。

如果您认为这种方法太脆弱,可以选择从模式模型中获取列名

$user = $schema->user;

$query = $db
    ->insert($user)
    ->add([
        $user->first_name->getName() => "Rasmus",
        $user->last_name->getName() => "Schultz",
        $user->dob->getName() => 951030427,
    ]);

这更安全(从静态分析的角度看),但稍微有点冗长。

请注意,如果添加多个记录,执行时这些记录将使用一个单独的INSERT语句插入。

UPDATE查询

要创建一个UPDATE查询构建器,您必须指定要更新的表和条件,然后指定要应用的价值——例如,在这里我们更新user表,其中user.id = 123,设置first_name列的值

$user = $schema->user;

$query = $db
    ->update($user)
    ->where("{$user->id} = :id")
    ->bind("id", 123)
    ->setValue($user->first_name, "Rasmus");

为了方便起见,您也可以使用assign()与键/值数组一起使用

$query->assign([
    "first_name" => "Rasmus"
]);

在两种情况下,都会根据列类型自动应用类型转换。

您还可以使用setExpr(),这允许您指定一个自定义SQL表达式来计算一个值——例如,在这里我们使用SQL函数NOW()来更新last_logged_in列,以获取DB服务器的当前日期和时间

$user = $schema->user;

$query = $db
    ->update($user)
    ->where("{$user->id} = :id")
    ->bind("id", 123)
    ->setExpr($user->last_logged_in, "NOW()");

此外,PostgreSQL支持returning(),MySQL支持limit()order()

请注意,使用UPDATE查询构建器可以构建嵌套查询

DELETE查询

要创建一个DELETE查询构建器,您必须指定要删除的表和条件——例如,在这里我们从user表删除,其中user.id = 123

$user = $schema->user;

$query = $db
    ->delete($user)
    ->where("{$user->id} = :id")
    ->bind("id", 123);

此外,PostgreSQL支持returning(),MySQL支持limit()order()

请注意,使用DELETE查询构建器可以构建嵌套查询

自定义SQL查询

SQLQuery类型允许您利用框架的所有功能来利用“手写的”SQL查询——例如,参数绑定(支持数组)、列引用、类型、映射器、结果迭代等。

不要将自定义SQL查询视为“最后的手段” - 对于本质上是动态的查询,请使用查询构建器,但不要因为“看起来”或“感觉”不正确而回避原始SQL:使用纯SQL语法编写的静态查询通常既简单又容易理解。

例如,要创建一个简单的SQL查询,计算过去一个月创建的新用户

$user = $schema->user;

$query = $db
    ->sql("SELECT COUNT({$user}) as num_users FROM {$user} WHERE {$user->created} > :first_date")
    ->bind("first_date",  time() + 30*24*60*60, TimestampType::class);

与PDO的原始SQL相比,这种方法有以下几个优点

  1. 使用表/列模型可以确保引用的列存在于您的模式中,正确地有资格并引用,在IDE中进行静态分析(以及安全重命名),等等。

  2. 您可以使用类型转换将值绑定到占位符上,这样就可以使用与应用程序模型相同的类型编写代码。(在这个例子中是一个整数时间戳。)

  3. 完全支持各种便利功能,如结果迭代、分批和映射。

对于静态的一次性查询,这种方法绝对值得考虑。

嵌套查询

SELECT查询构建器支持__toString()魔术方法,允许您构建完整的SQL查询并将其插入到另一个查询构建器实例中。

这使您能够构建嵌套SELECT查询 - 例如,您可以使用value()内联子查询并返回结果,或者您可以使用expr()内联子查询及其结果的条件。

让我们通过customerorder表来分析一个典型用例,并假设我们想要一个包含客户ID和姓名的列表,以及他们已下订单的数量。

此外,假设我们只想计算总销售额至少为100美元的order行。

首先,我们需要构建订单数量的子查询

$customer = $schema->customer;
$order = $schema->order;

$num_orders = $db
    ->select($order)
    ->value("COUNT({$order})")
    ->where([
        "{$order->total} > :min_total",
        "{$order->customer_id} = {$customer->id}",
    ]);

关于这个子查询有两个需要注意的重要事项

  1. 我们故意留下了未绑定的:min_total占位符 - 这个占位符将在父查询中绑定,即我们将实际执行的查询。我们只是利用第一个查询构建器来构建SQL语句。

  2. 由于第二个条件引用了{$customer->id},这将由父查询建立,因此这个查询最初是无法执行的。

接下来,我们构建父查询,使用value()插入并返回子查询的值

$query = $db
    ->select($customer)
    ->columns([
        $customer->id,
        $customer->first_name,
        $customer->last_name,
    ])
    ->value($num_orders, "num_orders")
    ->bind("min_total", 100);

同样,请注意,:min_total占位符绑定到父查询,而不是子查询。

这将生成一个类似的 SQL 查询

SELECT
  customer.id,
  customer.first_name,
  customer.last_name,
  (
    SELECT COUNT(order) FROM order
    WHERE (order.total > 100)
    AND (order.customer_id = customer.id)
  ) AS num_orders
FROM
  customer

请注意,在简单的情况下,如上所述,使用多个查询构建器可能过于冗长:您可能需要查询构建器来处理本质上是动态的查询,但对于简单的静态子查询,您也可以考虑简单地插入子查询作为文字SQL - 如此

$query = $db
    ->select($customer)
    ->columns([
        $customer->id,
        $customer->first_name,
        $customer->last_name,
    ])
    ->value(
        "SELECT COUNT({$order}) FROM {$order}"
        . " WHERE ({$order->total} > :min_total)"
        . " AND ({$order->customer_id} = {$customer->id})",
        "num_orders"
    )
    ->bind("min_total", 100);

一种方法并不比另一种方法“更好”或“更差” - 以这种方式构建内联SQL语句产生相同的SQL查询,所以这主要是一个问题,即子查询是动态的还是静态的。

执行查询

要直接执行查询,只需将其传递给Connection::execute()

$connection->execute(
    $db->sql("DELETE FROM order WHERE id = :id")->bind("id", 123)
);

execute()方法在运行后返回PreparedStatement实例,这使得可以随后计算受INSERT、UPDATE或DELETE影响的行数。

您可以使用此方法检查DELETE是否成功

$delete = $db->sql("DELETE FROM order WHERE id = :id")->bind("id", 123);

if ($connection->execute($delete)->getRowsAffected() !== 1) {
    // delete failed!
}

获取结果

Connection::fetch()方法生成一个可迭代的Result实例。

这使得获取结果并遍历行变得容易

$query = $db->sql("SELECT * FROM user");

$result = $connection->fetch($query);

foreach ($result as $row) {
    var_dump($row["id"], $row["first_name"]);
}

请注意,没有内置的行模型:默认情况下,Result实例通过列名映射到投影值的方式产生简单的array值。(有关映射器的更多信息,它允许您将行映射到模型对象等。)

为了方便起见,提供了一些快捷方式来读取结果集,例如

  • $result->all()将整个结果集读入内存并返回一个array
  • $result->firstRow()读取第一行,例如对于仅产生单个记录的结果集,如简单的主键查询等。
  • $result->firstCol() 用于读取第一行的第一列,例如对于只生成一个单列记录的结果集,如 COUNT 查询等。

类型转换

为了启用将投影的 SQL 值转换为 PHP 类型,SELECT 查询构建器内部将投影值映射到由您的 表/列模型 定义的 Type 实现中。

例如,如果您有一个 user 表,其中 created 列的类型为 TimestampType,则获取此列将内部将 SQL 的 DATETIME 类型映射为整数时间戳。

$user = $schema->user;

$query = $db
    ->select($user)
    ->where("{$user->id} = :id")
    ->bind("id", 123);

$row = $connection->fetch($query)->firstRow();

var_dump($row["created"]); // => (int) 1553177264

映射结果

虽然基本类型转换已由内置的 Mapper 实现内部应用,但您还可以选择手动将行映射到自定义函数。

例如,要将 user 行映射到 User 模型实例,您可以使用 mapRecords() 应用一个简单的映射函数,如下所示

$user = $schema->user;

$query = $db
    ->select($user)
    ->mapRecords(function (array $row) {
        return new User($row["id"], $row["first_name"], $row["last_name"]);
    });

$results = $connection->fetch($query);

foreach ($results as $result) {
    var_dump($result); // class User#1 (0) { ... }
}

如果应用了多个映射器,它们将按添加的顺序应用 - 在此示例之后应用另一个映射器,下一个映射器将接收到 User 实例。因此,您可以链式执行任意多的操作,只要您确保下一个映射器期望的输入与上一个映射器产生的输出相匹配。

如果映射操作很常见,您可以通过实现 Mapper 接口以可重用的方式实现它 - 例如,我们可以将上面的映射函数重构为 Mapper,如下所示

class UserMapper implements Mapper
{
    public function map(array $rows)
    {
        foreach ($rows as $row) {
            yield new User($row["id"], $row["first_name"], $row["last_name"]);
        }
    }
}

要应用此映射器到查询,请使用 map() 而不是 mapRecords()

$query = $db
    ->select($user)
    ->map(new UserMapper());

请注意,映射器一次处理整个 的行 - 在此示例中,我们使用了 yield 语句创建一个 生成器,这比手动创建和追加到数组更方便,并且还可以让您自定义键,例如使用 yield $key => $value 语法。

分批处理结果

为了避免处理大型结果集时的内存开销,Result 模型内部以 的形式获取记录(并应用映射器等)。

默认的批大小是 1000 条记录,例如足够大,可以在单次往返中获取大多数正常查询的结果。

如果需要,您可以通过 Connection::fetch() 指定不同的批大小 - 批处理是内部的,因此当您使用 foreach 语句遍历 Result 时,差异在您的客户端代码中不可直接看到。

$query = $db
    ->select($user)
    ->map(new UserMapper());

$result = $connection->fetch($query, 100); // batches of 100

foreach ($result as $row) {
    // ...
}

因为 映射器 是应用于 的,所以此示例中的 UserMapper 内部对每 100 条记录一组进行调用 - 假设记录超出客户端代码的作用域,这意味着每次只有 100 个 User 实例将存在于内存中。

计数结果

SELECT 查询构建器能够重写自身为 SQL COUNT(*) 查询,删除 LIMITOFFSETORDER BY 子句,并忽略任何应用的映射器。

例如,如果您正在构建一个显示 20 条记录分页的搜索表单,您可以在执行实际查询之前计算总结果数(例如在某个地方显示)。

$query = $db
    ->select($user)
    ->where("{$user->name} LIKE :name")
    ->bind("name", "%rasmus%")
    ->page($page_no, 20); // $page_no is the base-1 page number

$count = $connection->count($query);  // total number of matching results (for display)

$num_pages = ceil($count / 20);       // total number of pages (for display)

$result = $connection->fetch($query); // 20 records of the requested page number

请注意,任何条件和 JOIN 等. 都将保留并按正常方式应用,只有查询的根投影被更改为 COUNT(*),并且查询将立即执行和获取。

事务

Connection 接口支持比裸 PDO 更安全、更原子的方式处理事务。

而不是使用不同的开始、提交和回滚方法,单个 transact() 方法接受一个回调,并且事务必须明确地立即提交或回滚。

在这个简化的例子中,我们原子性地更新一个 payment 并创建一个 subscription

$connection->transact(function () use ($connection, $db) {
    $connection->execute(
        $db->sql("UPDATE payment WHERE id = :payment_id SET paid = NOW()")->bind(...)
    );
    
    $connection->execute(
        $db->sql("INSERT INTO subscription (...) VALUES (...)")->bind(...);
    );
    
    return true; // COMMITS the transaction
});

回调函数 必须 明确返回 true 以提交事务,或返回 false 以回滚 - 返回任何非 bool 值都会回滚事务并生成异常。

如果在调用您的回调时抛出未处理的异常,事务将被回滚,并且异常将被重新抛出。

请注意,嵌套事务是可能的,例如通过在回调中调用 transact()。这样做的结果是在 transact() 的顶级调用周围的单个 SQL 事务,因此,所有 事务回调都必须返回 true 以提交 - 如果网络事务中的任何回调返回 false(或生成异常等),则事务将被回滚,并抛出 TransactionAbortedException。换句话说,任何嵌套事务都必须 同意 提交或回滚 - 这确保顶级事务要么整体成功,要么整体失败。

预编译语句

为了高效地多次执行相同的查询,您可以手动 prepare() 语句 - 例如,为了删除 order 记录列表

$delete = $connection->prepare($db->sql("DELETE FROM order WHERE id = :id"));

foreach ($ids as $id) {
    $delete->bind("id", $id);
    $delete->execute();
}

请注意,prepare() 方法会 急切地 将数组扩展到多个占位符 - 虽然您可以将 PreparedStatement 实例的占位符绑定到标量(intfloatstringboolnull)值,但将 array 值绑定到已预编译的语句是不可能的,因为这会改变查询本身的结构。(如果您的用例需要将占位符绑定到不同的 array 值,则请改用查询构建器的 bind() 方法,并避免重新绑定预编译语句。)

日志记录

查询的日志记录通过 Logger 接口支持 - 可以通过 addLogger() 方法将实例注入到 Connection 实例中。

有一个 BufferedPSRLogger 实现 - 这将缓冲执行的查询,直到您选择将它们刷新到 PSR-3 日志记录器,例如

$buffer = new BufferedPSRLogger();

$connection->addLogger($buffer);

// ... execute queries ...

$buffer->flushTo($psr_logger);

其中 $psr_logger 是您选择的 Psr\Log\LoggerInterface 实现。

您可以查看 kodus/chrome-logger,它可以通过 ChromeLogger 以表格形式渲染 SQL 查询日志。

性能

非常快。

包含了一个简单的 基准测试,以测试查询构建器的性能 - 一个简单的带有 ORDERLIMIT 子句的 SELECT 查询在 ~0.1 毫秒内构建,而一个更复杂的带有两个 JOIN 子句和许多条件和参数的 SELECT 查询在 ~0.5 毫秒内构建。(在我的 Windows 10 笔记本电脑上运行 PHP 7)

架构

本节包含给好奇心旺盛的读者的注释。

总体架构由高级 Query 模型和低级 PreparedStatement 抽象组成。

Query 层,值被管理为原生 PHP 值。简单值,如 intfloatstringboolnull,在内部管理,数组的使用通过扩展 PDO 风格的占位符来管理。

根据查询类型是否返回记录(SELECTINSERT..RETURNING 等)或否,Query 模型实现了 ExecutableReturningExecutable。 (INSERTDELETE 等)

《连接》抽象准备一个《语句》并生成一个《预处理语句》实例 - 在这一层,抽象依赖于连接,并且只支持标量值。

曾考虑过内部管理《PDOStatement》实例的创建,但这样会阻止消费者通过重复执行相同的预处理语句进行潜在的优化。通过从消费者(例如,在显式地重新准备语句时)隐藏《PDOStatement》的创建,性能影响将被隐藏 - 也就是说,《预处理语句》模型,由于其无法绑定除标量值之外的内容,准确反映了PDO中预处理语句的实际限制和性能影响。