Merql
Merge

Three-Way Merge

A three-way merge compares two sets of changes against a common ancestor and combines them into one result. merql applies this algorithm to database rows instead of file lines.

use Merql\Merge\ThreeWayMerge;

$merge = new ThreeWayMerge();
$result = $merge->merge($base, $ours, $theirs);

echo $result->operationCount(); // resolved operations
echo $result->conflictCount();  // unresolved conflicts

The three states

Every merge requires exactly three snapshots:

  • Base -- the database state at the point both branches diverged.
  • Ours -- the current database state (may have changed since base).
  • Theirs -- the incoming changes to merge in.
         Base (snapshot at fork time)
        /                              \
   Ours (our changes)                   Theirs (their changes)
        \                              /
         --------- MERGE ----------
                     |
              Merged result
              + conflicts (if any)

Without a common base, you cannot distinguish "added" from "unchanged." Two-way comparison sees that side A has a value and side B does not, but cannot determine whether A added it or B deleted it. The base answers that question.

How changesets are computed

The Differ compares each snapshot against the base to produce two changesets. Each changeset contains three kinds of operations:

BaseCurrentOperation
Row existsRow exists, same fingerprintNo change
Row existsRow exists, different fingerprintUPDATE (per-column diff)
Row existsRow missingDELETE
Row missingRow existsINSERT

Fingerprints (SHA-256 hashes of normalized row content) provide a fast path. If two fingerprints match, the row is unchanged and no column-level comparison is needed.

use Merql\Diff\Differ;

$differ = new Differ();
$oursChangeset = $differ->diff($base, $ours);
$theirsChangeset = $differ->diff($base, $theirs);

Merge rules

The two changesets are then merged operation by operation. This table defines the outcome for every combination:

OursTheirsResult
No changeNo changeKeep base
No changeUpdatedAccept theirs
UpdatedNo changeAccept ours
Updated (same value)Updated (same value)Accept (both agree)
Updated (different value)Updated (different value)Conflict
No changeDeletedAccept delete
DeletedNo changeAccept delete
DeletedDeletedAccept delete (both agree)
UpdatedDeletedConflict
DeletedUpdatedConflict
Inserted (ours only)--Accept insert
--Inserted (theirs only)Accept insert
Inserted (same PK)Inserted (same PK)Conflict

When both sides update the same row, merql does not immediately declare a conflict. It drops to column-level merge, comparing each column independently. See Column-Level Merge for details.

Using the facade

The Merql facade operates on named snapshots that have been persisted to disk:

use Merql\Merql;

Merql::init($pdo);

$base   = Merql::snapshot('base');
// ... changes happen ...
$ours   = Merql::snapshot('ours');
// ... other changes ...
$theirs = Merql::snapshot('theirs');

$result = Merql::merge('base', 'ours', 'theirs');

Using the classes directly

For more control, instantiate the components yourself:

use Merql\Snapshot\Snapshotter;
use Merql\Merge\ThreeWayMerge;

$snapshotter = new Snapshotter($pdo);
$base   = $snapshotter->capture('base');
$ours   = $snapshotter->capture('ours');
$theirs = $snapshotter->capture('theirs');

$merge = new ThreeWayMerge();
$result = $merge->merge($base, $ours, $theirs);

The patch shortcut

When only one side has changes (no concurrent modifications), use patch(). This is a two-way merge where ours equals base, so conflicts are impossible:

$result = $merge->patch($base, $changes);
// Always clean: ours is identical to base.

Through the facade:

$result = Merql::patch('base', 'changes');

patch() is equivalent to merge($base, $base, $changes). Since ours never diverged from base, every change from theirs is accepted without conflict.

Schema mismatch detection

If the table structure changed between snapshots (columns added, removed, or retyped), the merge still proceeds but records the mismatches:

$result = $merge->merge($base, $ours, $theirs);

if ($result->hasSchemaMismatches()) {
    foreach ($result->schemaMismatches() as $mismatch) {
        echo $mismatch->getMessage() . "\n";
    }
}

merql does not auto-migrate schemas. Schema changes are the caller's responsibility. The merge reports them so you can decide how to proceed.

The MergeResult

The result object carries everything you need:

$result->isClean();            // true if no conflicts
$result->operations();         // list of MergeOperation objects
$result->conflicts();          // list of Conflict objects
$result->operationCount();     // number of resolved operations
$result->conflictCount();      // number of unresolved conflicts
$result->hasSchemaMismatches(); // true if schemas differ
$result->baseSnapshot();       // the base snapshot used for the merge

Each MergeOperation has a type (insert, update, or delete), a table name, a row key, the column values, and a source (ours, theirs, or merged).

On this page