
# 行标识

行标识决定 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 接口

所有标识策略都实现两个方法：

```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

对于没有自增主键的表，根据唯一约束列匹配行。如果 PK 是 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 哈希。所有列都匹配时，两行即为相同。

```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` 中，并用于后续所有差异比较、合并、计划、SQL 和回滚操作。

使用静态门面时，`Merql::init()` 可接收相同的规则集：

```php
use Merql\Merql;

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

规则集具有稳定的数组序列化格式：

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

## 模糊标识

如果捕获的两行生成了相同的标识键，捕获会失败，而不会用一行覆盖另一行。

```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 会不同。我们这边的行 #100 可能与他们那边的行 #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 子句，合并计划也使用它们来公开解码后的标识值，以便审查、选择、受保护地应用和回滚。
