
# SQL 镜像

`SqlMirror` 会将 storh 集合推送到 SQLite 或 MySQL 表中，以便你在 SQL 中执行联接、跨表排序、子字符串搜索和报告，同时文件仍是权威数据源。可以将其视为一个恰好采用数据库形式的二级索引：镜像是派生数据，可随时丢弃并重建。仅在使用时，才需要通过 `sqlite` 或 `mysql` 驱动程序连接 `ext-pdo`；核心软件包不依赖任何其他软件包。

## 设置

```php
use Storh\DocStore;
use Storh\Schema;
use Storh\SqlMirror;

$schema = Schema::collection('pages')
    ->string('slug')->unique()
    ->string('kind')->index()
    ->int('publishedAt')->range();

$pages  = new DocStore($root, 'pages', schema: $schema);
$events = new Storh\SegmentedLog($root, 'events');

$mirror = new SqlMirror(new PDO('sqlite:' . $root . '/mirror.db'), 'app_');
$mirror->collection($pages, 'pages', $schema);
$mirror->collection($events, 'events');

$mirror->install();
$mirror->push();
```

两种引擎都可以创建镜像：`DocStore` 集合和 `SegmentedLog` 流（只推送活动记录）。传入 `Schema` 是可选的，且独立于存储：声明为 `string`/`int`/`float`/`bool` 的字段会成为类型化索引列；其他所有内容都可通过 `data` JSON 列进行查询。`mixed` 字段不能设置索引标志。

## 连接

构造函数接受 `PDO` 句柄（`sqlite` 或 `mysql` 驱动程序，会自动检测）、`mysqli` 句柄，或任何 `SqlMirrorConnection` 实现：

```php
$mirror = new SqlMirror(new PDO('sqlite:' . $root . '/mirror.db'));
$mirror = new SqlMirror(new PDO('mysql:host=127.0.0.1;dbname=app;charset=utf8mb4', $user, $password));
$mirror = new SqlMirror(new mysqli('127.0.0.1', $user, $password, 'app'), 'wp_storh_');
```

mysqli 路径适用于任何一种 mysqli 错误报告模式，因此可直接重复使用主机应用程序中现有的句柄（例如 WordPress 的 `$wpdb->dbh`）。各驱动程序的行为一致；所有驱动程序在推送失败时都会回滚集合的事务。

每张表都有 `id`（主键）、`hash`（用于增量推送）、类型化列，以及 `data`（完整记录的 JSON）。

## 同步

```php
$mirror->push();                    // reconcile: insert, update, delete as needed
$mirror->push('pages');             // reconcile a single collection
$mirror->flush('pages', $ids);      // push or remove just these ids
$mirror->pull();                    // write mirror rows back into the store
$mirror->verify();                  // report missing, stale, and orphaned rows
$mirror->rebuild();                 // clear all rows and push from scratch
$mirror->uninstall();               // drop the mirror tables
```

`push()` 会将记录哈希与镜像进行比较，并在一个事务中写入每个集合，因此推送过程中发生崩溃或失败都不会留下只应用了一部分的批次。未更改的记录只需一次 `SELECT` 和一次记录扫描。定期执行 `push()`（例如在 cron 时触发）可使镜像保持同步；如果读者需要从镜像读取请求内刚写入的记录，请使用所写记录的 id 调用 `flush()`。

## 查询

使用纯 SQL 查询镜像。`table()` 会返回镜像表名：

```php
$pdo->query(
    'SELECT p.slug, COUNT(*) AS sales
     FROM ' . $mirror->table('purchases') . ' o
     INNER JOIN ' . $mirror->table('products') . ' p ON p.id = o.product_id
     GROUP BY p.slug
     ORDER BY sales DESC'
);
```

## 拉取回文件

`pull()` 会读取镜像行，并按 id 顺序将其写入已注册的存储。它支持两种流程：

- 恢复：文件已丢失，但镜像仍然存在。在同一个集合名称下注册一个新的存储，然后执行拉取。
- 初始化数据：通过外部工具载入镜像表的行会变成 storh 记录。行必须包含有效的 UUIDv7 id，并在 `data` 列中包含对象；拉取时会忽略类型化列，因为 `data` 就是完整记录。

```php
$restored = new Storh\DocStore($root, 'pages');
$mirror   = new SqlMirror($pdo);
$mirror->collection($restored, 'pages');
$mirror->pull();          // ['written' => n, 'unchanged' => m]
```

拉取写入会经过正常的存储处理流程，因此会应用架构验证和唯一索引，并且每条记录都会持久写入。`pull()` 不会删除本地记录；如果记录的数据已与镜像行一致，则会跳过。为手动插入的行初始化数据后，请运行一次 `push()`：它会以规范形式重写这些行，使其哈希值保持一致。

## 约定和限制

- 文件是权威数据源。镜像是只读输出；手动写入其中的任何内容都会被下一次 `push()` 覆盖或删除。
- 镜像中的唯一列是后备保护机制，不会在写入时强制执行：如果文件接受了重复值，就会导致推送失败并完整回滚。要在写入时强制执行，请使用 storh 唯一索引。
- 镜像最多会比文件落后一个推送间隔，但你明确 `flush()` 的 id 除外。
- 创建镜像不会为 storh 本身增加事务。跨多条记录的原子写入仍应交由作为权威数据源的真正数据库处理。
- 架构变更通过删除并重新安装镜像来应用：`uninstall()`、`install()`、`push()`。
