
# 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()`はレコードのハッシュをミラーと比較して差分を判定し、各コレクションを1つのトランザクションで書き込みます。そのため、クラッシュや取り込みの失敗でバッチの一部だけが適用されることはありません。変更のないレコードでは、レコードの走査に加えて`SELECT`が1回発生します。定期的に`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順で書き込みます。次の2つの用途に使えます。

- 復元：ファイルが失われても、ミラーが残っている場合。同じコレクション名で新しいストアを登録し、取り込みます。
- シード：外部ツールでミラーテーブルに読み込んだ行を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()`を1回実行すると、行が正規の形式で書き直され、ハッシュが一致します。

## 仕様と制限

- ファイルが正本です。ミラーは読み取り専用の出力であり、手動で書き込んだ内容は次の`push()`で上書きまたは削除されます。
- ミラーのユニーク列は最後の防止策であり、書き込み時の制約ではありません。ファイル側で許可された重複があると、取り込みに失敗し、すべてロールバックされます。書き込み時に重複を防ぐには、storhのユニークインデックスを使ってください。
- 明示的に`flush()`したIDを除き、ミラーは最大で1回の取り込み間隔分、ファイルより遅れます。
- ミラーリングによってstorh自体にトランザクションが追加されることはありません。複数レコードへのアトミックな書き込みには、引き続きシステムの正本となる実データベースが必要です。
- スキーマを変更するには、ミラーを削除して再インストールします：`uninstall()`、`install()`、`push()`。
