
# 행 식별성

행 식별성은 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`에 저장되며 이후의 모든 diff, merge, plan, 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가 달라집니다. 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 절을 구성합니다. 병합 계획은 디코딩된 식별 값을 제공해 검토, 선택, 보호된 적용, 롤백에 사용합니다.
