
# 行の識別

行の識別は、merqlがスナップショット間で行を照合する方法を決めます。baseとoursを比較する際、一部の列の値が変わっていても、baseの行Xとoursの行Yが同じ論理行だと判定する必要があります。識別ルールは、行キーを構成する列を指定します。

```php
use Merql\Identity\PrimaryKeyIdentity;

$identity = new PrimaryKeyIdentity(['id']);
$key = $identity->key(['id' => 42, 'title' => 'Hello']);
// "42"
```

## RowIdentityインターフェース

すべての識別方式は、次の2つのメソッドを実装します。

```php
interface RowIdentity
{
    public function key(array $row): string;
    public function columns(): array;
}
```

`key()`は行データから一意の文字列識別子を作成します。`columns()`は識別に使う列名を返します。

## PrimaryKeyIdentity

デフォルトの方式です。主キー列を使って行を照合します。

```php
use Merql\Identity\PrimaryKeyIdentity;

// Single-column PK.
$identity = new PrimaryKeyIdentity(['id']);
$key = $identity->key(['id' => 42, 'title' => 'Hello']);
// "42"

// Composite PK.
$identity = new PrimaryKeyIdentity(['tenant_id', 'user_id']);
$key = $identity->key(['tenant_id' => 1, 'user_id' => 99, 'name' => 'Alice']);
// "1\x1F99" (unit separator between parts)
```

テーブルのスキーマで主キーが定義されている場合、merqlはデフォルトでこの方式を使います。

## NaturalKeyIdentity

自動採番の主キーがないテーブルでは、一意制約列を使って行を照合します。主キーがUUIDの場合や、テーブルで複合一意制約を論理的な識別子として使う場合に便利です。

```php
use Merql\Identity\NaturalKeyIdentity;

$identity = new NaturalKeyIdentity(['email']);
$key = $identity->key(['id' => 1, 'email' => 'alice@example.com', 'name' => 'Alice']);
// "alice@example.com"

$identity = new NaturalKeyIdentity(['country', 'postal_code']);
$key = $identity->key(['country' => 'US', 'postal_code' => '90210', 'city' => 'Beverly Hills']);
// "US\x1F90210"
```

## ContentHashIdentity

主キーも一意制約もないテーブルのためのフォールバックです。すべての列の値からSHA-256ハッシュを計算します。すべての列が一致すれば、2つの行は同一と判定されます。

```php
use Merql\Identity\ContentHashIdentity;

$identity = new ContentHashIdentity(['name', 'value']);
$key = $identity->key(['name' => 'color', 'value' => 'blue']);
// "a3f2..." (SHA-256 hash)
```

コンテンツハッシュによる識別には制限があります。行の内容が変わるとハッシュも変わるため、更新ではなく、古いハッシュの行の削除と新しいハッシュの行の挿入として扱われます。安定した識別列がない場合に避けられないトレードオフです。

## IdentityRule

`IdentityRule`は、スナップショットの取得やマージ計画で使う、シリアライズ可能な識別設定です。

```php
use Merql\Identity\IdentityRule;

$rule = IdentityRule::natural(['option_name']);
$key = $rule->key(['option_name' => 'siteurl']);
// "siteurl"
```

ルールの種類：

| 種類 | 意味 |
|---|---|
| `primary` | データベースの主キー列 |
| `natural` | 安定した一意の業務列 |
| `composite` | 複合論理識別列 |
| `content` | コンテンツハッシュによるフォールバック |

`IdentityRule::forSchema($schema)`はデフォルトのルールを次のように解決します。

1. テーブルに主キーがある場合は、主キー列を使います。
2. 一意キーがある場合は、最初の一意キーの列を使います。
3. どちらもない場合は、全列を使います（コンテンツハッシュによるフォールバック）。

## IdentityRuleSet

ホストアプリケーションがスキーマのデフォルトより適切な識別ルールを把握している場合は、`IdentityRuleSet`を使います。

```php
use Merql\Identity\IdentityRule;
use Merql\Identity\IdentityRuleSet;
use Merql\Snapshot\Snapshotter;

$rules = new IdentityRuleSet([
    'wp_options' => IdentityRule::natural(['option_name']),
    'wp_term_relationships' => IdentityRule::composite(['object_id', 'term_taxonomy_id']),
]);

$snapshotter = new Snapshotter($connection, identityRules: $rules);
$snapshot = $snapshotter->capture('live', ['wp_options', 'wp_term_relationships']);
```

`Snapshotter`は、キャプチャした各テーブルの`IdentityRule`を解決します。選択したカラムは`TableSnapshot::identityColumns`に保存され、その後のdiff、merge、plan、SQL、rollback操作で使用されます。

静的ファサードを使用する場合も、`Merql::init()`に同じルールセットを渡せます。

```php
use Merql\Merql;

Merql::init($connection, identityRules: $rules);
Merql::snapshot('live', ['wp_options']);
```

ルールセットの配列シリアライズは安定しています。

```php
$payload = $rules->toArray();
$rules = IdentityRuleSet::fromArray($payload);
```

## 曖昧な識別

キャプチャした2つの行が同じ識別キーを生成すると、片方で他方を上書きせず、キャプチャは失敗します。

```php
use Merql\Exceptions\SnapshotException;

try {
    $snapshotter->capture('live', ['wp_postmeta']);
} catch (SnapshotException $e) {
    // The configured identity rule matched multiple rows.
}
```

キャプチャ前に曖昧さを報告する場合、ホストアプリケーションは`IdentityRuleSet::conflictsFor()`で未加工の行を事前検証できます。

```php
$conflicts = $rules->conflictsFor(
    'wp_postmeta',
    IdentityRule::natural(['post_id', 'meta_key']),
    $rows,
);
```

WordPressのメタテーブルでは、`post_id + meta_key`はグループ化や表示には通常便利ですが、行の識別子として安全ではありません。メタキーの重複は有効だからです。ホストアプリケーションが自然キーの一意性を証明できない限り、行の識別にはテーブルの主キーを使用してください。

## 行キーのエンコード

行キーは、複数のカラム値をユニットセパレーター（`\x1F`）で結合したエンコード済み文字列です。衝突を防ぐため、セパレーターまたはパーセント記号を含む値はエスケープされます。

```php
use Merql\Snapshot\Snapshotter;

// Encode.
$key = Snapshotter::buildRowKey(
    ['tenant_id' => '1', 'user_id' => '99'],
    ['tenant_id', 'user_id'],
);
// "1\x1F99"

// Decode.
$parts = Snapshotter::decodeRowKey($key);
// ['1', '99']
```

エスケープ規則：

| 元の値 | エスケープ後 |
|---|---|
| `%` | `%25` |
| `\x1F`（ユニットセパレーター） | `%1F` |

これにより、`\x1F`を含む値が複数のキー部分に分割されるのを防ぎます。このエンコードは決定的で、元に戻せます。

## 自動採番の不一致

両方の側で新しい行が挿入されると、ブランチ間で自動採番IDが異なります。oursの行#100は、theirsの行#100とはまったく別のレコードである可能性があります。

merqlは主キーで照合します。両方の側で同じ自動採番IDの行が挿入された場合は、`insert_insert`競合としてフラグが立ちます。これを避けるには、同時挿入が想定されるテーブルで、ブランチ間で変わらない一意のカラムである自然キーを使用してください。

```php
// A natural key on (slug) avoids auto-increment collisions.
$identity = new NaturalKeyIdentity(['slug']);
```

## スナップショット内の識別情報

キャプチャ時に選択された識別カラムは、各`TableSnapshot`とともに保存されます。

```php
$tableSnapshot = $snapshot->getTable('posts');
$tableSnapshot->identityColumns;
// ['id']
```

`SqlGenerator`は、UPDATE文とDELETE文のWHERE句の作成にこれらのカラムを使用します。また、マージプランでは、確認、選択、条件付き適用、ロールバックのために、デコード済みの識別値を表示します。
