Merql

PHP API Reference

Full reference for all public classes and methods in merql.

Merql (facade)

Merql\Merql is the static entry point for common operations. It wraps the lower-level classes and manages snapshot persistence automatically.

use Merql\Merql;
use Merql\Connection;

$pdo = Connection::sqlite(':memory:');
Merql::init($pdo);

init(PDO $pdo): void

Initialize the facade with a database connection. Must be called before snapshot() or apply().

snapshot(string $name, array $tables = []): Snapshot

Capture the current database state and persist it to disk. Pass an array of table names to snapshot specific tables, or omit for all tables.

$snapshot = Merql::snapshot('baseline');
$snapshot = Merql::snapshot('baseline', ['posts', 'users']);

diff(string $base, string $current): Changeset

Load two named snapshots and compute the changeset between them. Does not require init() since it works from persisted snapshots.

$changeset = Merql::diff('before', 'after');

merge(string $base, string $ours, string $theirs): MergeResult

Three-way merge of three named snapshots. Returns a result containing clean operations and any conflicts.

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

patch(string $base, string $changes): MergeResult

Two-way merge: apply changes onto base. Equivalent to merge($base, $base, $changes). Always produces a clean result because there are no concurrent edits.

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

apply(MergeResult $result): ApplyResult

Execute a clean merge result against the database. Requires init(). Throws ConflictException if unresolved conflicts remain.

$applied = Merql::apply($result);
echo $applied->rowsAffected();

reset(): void

Clear the internal PDO and Snapshotter instances. Primarily used in tests to reset state between runs.


Connection

Merql\Connection builds PDO instances with sensible defaults (exceptions on error, associative fetch mode, no emulated prepares, stringified fetches).

mysql(string $host, string $database, string $username, string $password = '', int $port = 3306, string $charset = 'utf8mb4'): PDO

use Merql\Connection;

$pdo = Connection::mysql('127.0.0.1', 'my_app', 'root', 'secret');

sqlite(string $path = ':memory:'): PDO

Creates a SQLite connection. Enables foreign keys automatically via PRAGMA foreign_keys = ON.

$pdo = Connection::sqlite('/var/data/app.sqlite');
$pdo = Connection::sqlite(':memory:');

fromDsn(string $dsn, string $username = '', string $password = ''): PDO

Connect using any PDO DSN string.

$pdo = Connection::fromDsn('mysql:host=localhost;dbname=app', 'root', 'pass');

Snapshotter

Merql\Snapshot\Snapshotter captures database state. Unlike the facade, it gives you full control over filters and does not auto-persist.

__construct(PDO $pdo, ?Driver $driver = null)

The driver is auto-detected from the PDO connection if not provided.

capture(string $name, array $tables = [], array $filters = []): Snapshot

Capture current state. Pass table names to limit scope. Pass filter objects to exclude tables, columns, or rows.

use Merql\Snapshot\Snapshotter;
use Merql\Filter\TableFilter;
use Merql\Filter\ColumnFilter;
use Merql\Filter\RowFilter;

$snapshotter = new Snapshotter($pdo);

// All tables.
$snapshot = $snapshotter->capture('full');

// Specific tables with filters.
$snapshot = $snapshotter->capture('filtered', ['posts', 'users'], [
    TableFilter::exclude(['cache_*', 'sessions']),
    ColumnFilter::ignore(['updated_at', 'created_at']),
    RowFilter::create(fn(string $table, array $row) => $row['status'] !== 'trash'),
]);

fromData(string $name, array $tableData): Snapshot (static)

Build a snapshot from raw data without a database connection. Useful for testing or when importing data from another source.

use Merql\Snapshot\Snapshotter;
use Merql\Snapshot\TableSnapshotData;
use Merql\Schema\TableSchema;

$schema = new TableSchema('posts', ['id' => 'int', 'title' => 'varchar'], ['id']);

$snapshot = Snapshotter::fromData('test', [
    'posts' => new TableSnapshotData($schema, [
        ['id' => '1', 'title' => 'Hello'],
        ['id' => '2', 'title' => 'World'],
    ], ['id']),
]);

Snapshot

Merql\Snapshot\Snapshot is a readonly value object representing a complete database state.

$snapshot->name;                    // string: snapshot identifier
$snapshot->tables;                  // array<string, TableSnapshot>
$snapshot->tableNames();            // list<string>
$snapshot->hasTable('posts');       // bool
$snapshot->getTable('posts');       // ?TableSnapshot

TableSnapshot

Merql\Snapshot\TableSnapshot holds one table's rows, fingerprints, and schema.

$table = $snapshot->getTable('posts');

$table->schema;                    // TableSchema
$table->identityColumns;           // list<string>: columns used for row identity
$table->fingerprints;              // array<string, string>: row key to hash
$table->rows;                      // array<string, array<string, mixed>>

$table->rowKeys();                 // list<string>
$table->rowCount();                // int
$table->hasRow('42');              // bool
$table->getRow('42');              // ?array<string, mixed>
$table->getFingerprint('42');      // ?string

TableSnapshotData

Merql\Snapshot\TableSnapshotData is input data for Snapshotter::fromData().

use Merql\Snapshot\TableSnapshotData;
use Merql\Schema\TableSchema;

$data = new TableSnapshotData(
    schema: new TableSchema('posts', ['id' => 'int', 'title' => 'varchar'], ['id']),
    rows: [
        ['id' => '1', 'title' => 'First Post'],
    ],
    identityColumns: ['id'],
);

SnapshotStore

Merql\Snapshot\SnapshotStore persists and loads snapshots as JSON files.

setDirectory(string $directory): void

Set the storage directory. Default is .merql/snapshots.

save(Snapshot $snapshot): void

Write a snapshot to disk.

load(string $name): Snapshot

Load a snapshot by name. Throws SnapshotException if not found.

exists(string $name): bool

Check whether a snapshot file exists.

delete(string $name): void

Remove a snapshot file.


Differ

Merql\Diff\Differ compares two snapshots and produces a changeset.

diff(Snapshot $base, Snapshot $current): Changeset

use Merql\Diff\Differ;

$differ = new Differ();
$changeset = $differ->diff($baseSnapshot, $currentSnapshot);

valuesEqual(mixed $a, mixed $b): bool (static)

Compare two values using string coercion. NULL is treated as a distinct value: NULL === NULL is true, NULL === '' is false.


Changeset

Merql\Diff\Changeset is a readonly collection of row-level operations.

$changeset->inserts();             // list<RowInsert>
$changeset->updates();             // list<RowUpdate>
$changeset->deletes();             // list<RowDelete>
$changeset->isEmpty();             // bool
$changeset->count();               // int: total operations
$changeset->forTable('posts');     // Changeset: filtered to one table

RowInsert, RowUpdate, RowDelete

Readonly value objects representing individual row changes.

RowInsert

$insert->table;                    // string
$insert->rowKey;                   // string
$insert->values;                   // array<string, mixed>

RowUpdate

$update->table;                    // string
$update->rowKey;                   // string
$update->columnDiffs;              // list<ColumnDiff>
$update->fullRow;                  // array<string, mixed>: complete current row
$update->columnDiffMap();          // array<string, ColumnDiff>: keyed by column name

RowDelete

$delete->table;                    // string
$delete->rowKey;                   // string
$delete->oldValues;                // array<string, mixed>: values before deletion

ColumnDiff

Merql\Diff\ColumnDiff represents a single column change within a row update.

$diff->column;                     // string
$diff->oldValue;                   // mixed
$diff->newValue;                   // mixed

ThreeWayMerge

Merql\Merge\ThreeWayMerge is the core merge algorithm.

__construct(?CellMergeConfig $cellMergeConfig = null)

Pass a CellMergeConfig to enable cell-level merging for TEXT and JSON columns.

use Merql\Merge\ThreeWayMerge;
use Merql\CellMerge\CellMergeConfig;

// Default: column-level merge only.
$merge = new ThreeWayMerge();

// With cell-level merge for TEXT and JSON columns.
$merge = new ThreeWayMerge(CellMergeConfig::auto());

merge(Snapshot $base, Snapshot $ours, Snapshot $theirs): MergeResult

Perform a three-way merge. Computes changesets internally (base to ours, base to theirs) and merges them.

$result = $merge->merge($baseSnapshot, $oursSnapshot, $theirsSnapshot);

patch(Snapshot $base, Snapshot $changes): MergeResult

Two-way merge shortcut. Equivalent to merge($base, $base, $changes).

schemaMismatches(): array

Returns SchemaException[] for any schema differences detected during the last merge (columns added or removed between snapshots).


MergeResult

Merql\Merge\MergeResult is a readonly value object containing the merge outcome.

$result->isClean();                // bool: no conflicts
$result->operations();             // list<MergeOperation>
$result->conflicts();              // list<Conflict>
$result->operationCount();         // int
$result->conflictCount();          // int
$result->baseSnapshot();           // ?Snapshot
$result->schemaMismatches();       // list<SchemaException>
$result->hasSchemaMismatches();    // bool

MergeOperation

Merql\Merge\MergeOperation is a single resolved operation from the merge.

$op->type;                         // string: 'insert' | 'update' | 'delete'
$op->table;                        // string
$op->rowKey;                       // string
$op->values;                       // array<string, mixed>
$op->source;                       // string: 'ours' | 'theirs' | 'merged'

Type constants:

MergeOperation::TYPE_INSERT;       // 'insert'
MergeOperation::TYPE_UPDATE;       // 'update'
MergeOperation::TYPE_DELETE;       // 'delete'

Conflict

Merql\Merge\Conflict represents an unresolved merge conflict.

$conflict->table();                // string
$conflict->rowKey();               // string
$conflict->type();                 // string: 'update_update' | 'update_delete' | 'delete_update' | 'insert_insert'
$conflict->column();               // ?string (null for structural conflicts)
$conflict->oursValue();            // mixed
$conflict->theirsValue();          // mixed
$conflict->baseValue();            // mixed
$conflict->primaryKey(['id']);     // array<string, string>: decoded from rowKey

Conflict types

TypeMeaning
update_updateBoth sides changed the same column to different values
update_deleteOurs updated a row that theirs deleted
delete_updateOurs deleted a row that theirs updated
insert_insertBoth sides inserted a row with the same primary key

ConflictResolver

Merql\Merge\ConflictResolver applies a resolution policy to all conflicts in a merge result.

resolve(MergeResult $result, ConflictPolicy $policy): MergeResult (static)

Returns a new MergeResult with conflicts resolved according to the policy.

use Merql\Merge\ConflictResolver;
use Merql\Merge\ConflictPolicy;

// Accept all "theirs" values when conflicts arise.
$resolved = ConflictResolver::resolve($result, ConflictPolicy::TheirsWins);

// Accept all "ours" values.
$resolved = ConflictResolver::resolve($result, ConflictPolicy::OursWins);

// Return unchanged (for manual inspection).
$resolved = ConflictResolver::resolve($result, ConflictPolicy::Manual);

ConflictPolicy

Merql\Merge\ConflictPolicy is a backed enum with three cases.

use Merql\Merge\ConflictPolicy;

ConflictPolicy::OursWins;          // Resolve all conflicts in favor of "ours"
ConflictPolicy::TheirsWins;        // Resolve all conflicts in favor of "theirs"
ConflictPolicy::Manual;            // Leave conflicts unresolved for manual handling

ColumnMerge

Merql\Merge\ColumnMerge performs per-column merge logic for a single row. Used internally by ThreeWayMerge.

merge(string $table, string $rowKey, array $base, array $ours, array $theirs, ?CellMergeConfig $cellMergeConfig = null, array $columnTypes = []): array (static)

Returns ['values' => array, 'conflicts' => list<Conflict>].

use Merql\Merge\ColumnMerge;

$result = ColumnMerge::merge(
    'posts',
    '42',
    ['title' => 'Hello', 'status' => 'draft'],     // base
    ['title' => 'Hello', 'status' => 'published'],  // ours
    ['title' => 'New Title', 'status' => 'draft'],   // theirs
);

// $result['values'] = ['title' => 'New Title', 'status' => 'published']
// $result['conflicts'] = [] (clean merge)

Applier

Merql\Apply\Applier executes a merge result against a database.

__construct(PDO $pdo, ?Driver $driver = null)

apply(MergeResult $result, ?Snapshot $base = null): ApplyResult

Executes all operations in a single transaction. Rolls back on any error. Throws ConflictException if the result has unresolved conflicts.

use Merql\Apply\Applier;

$applier = new Applier($pdo);
$applied = $applier->apply($result);

echo "Rows affected: {$applied->rowsAffected()}\n";
if ($applied->hasErrors()) {
    print_r($applied->errors());
}

Foreign key ordering is handled automatically: inserts are sorted in dependency order, deletes in reverse dependency order.


ApplyResult

Merql\Apply\ApplyResult is a readonly value object returned by the applier.

$applied->rowsAffected();         // int
$applied->errors();               // list<string>
$applied->hasErrors();            // bool

DryRun

Merql\Apply\DryRun generates human-readable SQL without executing anything.

generate(MergeResult $result, ?Snapshot $base = null, array $fkDependencies = [], ?Driver $driver = null): array (static)

Returns a list of SQL strings with values inlined (for display purposes only).

use Merql\Apply\DryRun;

$statements = DryRun::generate($result);

foreach ($statements as $sql) {
    echo "{$sql};\n";
}

// Output:
// INSERT INTO `posts` (`id`, `title`) VALUES ('10', 'New Post');
// UPDATE `posts` SET `title` = 'Updated' WHERE `id` = '5';
// DELETE FROM `comments` WHERE `id` = '3';

SqlGenerator

Merql\Apply\SqlGenerator generates parameterized SQL statements. Used internally by both Applier and DryRun.

generate(MergeResult $result, ?Snapshot $base = null, array $fkDependencies = [], ?Driver $driver = null): array (static)

Returns list<array{sql: string, params: list<mixed>}>.

use Merql\Apply\SqlGenerator;

$statements = SqlGenerator::generate($result);

foreach ($statements as $stmt) {
    $prepared = $pdo->prepare($stmt['sql']);
    $prepared->execute($stmt['params']);
}

CellMergeConfig

Merql\CellMerge\CellMergeConfig configures cell-level merge strategies for specific columns or column types.

auto(): self (static)

Convenience factory that assigns TextCellMerger to TEXT/LONGTEXT columns and JsonCellMerger to JSON columns.

use Merql\CellMerge\CellMergeConfig;

$config = CellMergeConfig::auto();

forColumn(string $column, CellMerger $merger): self

Assign a merger to a specific column. Use table.column for qualified names or just column for all tables.

use Merql\CellMerge\CellMergeConfig;
use Merql\CellMerge\TextCellMerger;

$config = (new CellMergeConfig())
    ->forColumn('posts.content', new TextCellMerger())
    ->forColumn('description', new TextCellMerger());

forType(string $typePattern, CellMerger $merger): self

Assign a merger to all columns matching a type pattern (case-insensitive substring match).

use Merql\CellMerge\CellMergeConfig;
use Merql\CellMerge\JsonCellMerger;

$config = (new CellMergeConfig())
    ->forType('json', new JsonCellMerger());

getMerger(string $table, string $column, string $columnType = ''): CellMerger

Look up the merger for a given table, column, and type. Priority: qualified column name, unqualified column name, type pattern, default (opaque).


CellMerger (interface)

Merql\CellMerge\CellMerger is the interface for cell-level merge strategies.

use Merql\CellMerge\CellMerger;
use Merql\CellMerge\CellMergeResult;

interface CellMerger
{
    public function merge(mixed $base, mixed $ours, mixed $theirs): CellMergeResult;
}

Three built-in implementations are provided.

TextCellMerger

Merql\CellMerge\TextCellMerger merges multi-line text using a three-way line-by-line diff (the same algorithm git uses for file content). When both sides edit different lines, the merge is clean. When both sides edit the same line differently, it reports a conflict.

use Merql\CellMerge\TextCellMerger;

$merger = new TextCellMerger();
$result = $merger->merge(
    "line 1\nline 2\nline 3",
    "line 1\nline 2 modified\nline 3",
    "line 1\nline 2\nline 3 modified",
);
// $result->clean === true
// $result->value === "line 1\nline 2 modified\nline 3 modified"

JsonCellMerger

Merql\CellMerge\JsonCellMerger merges JSON values by comparing top-level keys independently. Each key is merged using three-way logic. Nested objects are compared as opaque JSON strings (not recursively merged).

use Merql\CellMerge\JsonCellMerger;

$merger = new JsonCellMerger();
$result = $merger->merge(
    '{"a": 1, "b": 2}',
    '{"a": 10, "b": 2}',        // changed "a"
    '{"a": 1, "b": 20}',         // changed "b"
);
// $result->clean === true
// $result->value === '{"a":10,"b":20}'

OpaqueCellMerger

Merql\CellMerge\OpaqueCellMerger is the default. It treats values as opaque strings and always reports a conflict when they differ.


CellMergeResult

Merql\CellMerge\CellMergeResult is the return type of all cell mergers.

$result->clean;                    // bool: true if merge succeeded
$result->value;                    // mixed: merged value (or ours on conflict)
$result->conflicts;                // int: number of conflicts

// Factory methods.
CellMergeResult::resolved($value);           // clean merge
CellMergeResult::conflict($oursValue, $n);   // conflict with count

TableSchema

Merql\Schema\TableSchema is a readonly representation of table structure.

use Merql\Schema\TableSchema;

$schema = new TableSchema(
    name: 'posts',
    columns: ['id' => 'int', 'title' => 'varchar(255)', 'content' => 'text'],
    primaryKey: ['id'],
    uniqueKeys: [['slug']],
);

$schema->name;                     // string
$schema->columns;                  // array<string, string>: name to type
$schema->primaryKey;               // list<string>
$schema->uniqueKeys;               // list<list<string>>
$schema->hasPrimaryKey();          // bool
$schema->hasUniqueKeys();          // bool
$schema->columnNames();            // list<string>

Driver system

merql uses a pluggable driver system to abstract database-specific operations. Drivers are auto-detected from the PDO connection.

Driver (interface)

Merql\Driver\Driver defines the contract all database drivers implement.

interface Driver
{
    public function quoteIdentifier(string $name): string;
    public function listTables(PDO $pdo): array;
    public function readSchema(PDO $pdo, string $table): TableSchema;
    public function readForeignKeys(PDO $pdo): array;
    public function selectAll(string $table): string;
}

Built-in implementations: MysqlDriver and SqliteDriver.

DriverFactory

Merql\Driver\DriverFactory auto-detects the driver from a PDO connection.

use Merql\Driver\DriverFactory;

$driver = DriverFactory::create($pdo);

Register a custom driver for other databases:

DriverFactory::register('pgsql', MyPostgresDriver::class);

Filters

TableFilter

Merql\Filter\TableFilter includes or excludes tables using glob patterns.

use Merql\Filter\TableFilter;

$filter = TableFilter::exclude(['cache_*', 'sessions', 'temp_*']);
$filter = TableFilter::include(['posts', 'users', 'comments']);

ColumnFilter

Merql\Filter\ColumnFilter removes specific columns from snapshots.

use Merql\Filter\ColumnFilter;

$filter = ColumnFilter::ignore(['updated_at', 'created_at', 'modified_date']);

RowFilter

Merql\Filter\RowFilter filters rows using a predicate function.

use Merql\Filter\RowFilter;

$filter = RowFilter::create(function (string $table, array $row): bool {
    // Exclude trashed posts.
    if ($table === 'posts' && ($row['status'] ?? '') === 'trash') {
        return false;
    }
    return true;
});

Exceptions

SnapshotException

Merql\Exceptions\SnapshotException is thrown when a snapshot cannot be found or has an invalid name.

SnapshotException::notFound('baseline', '/path/to/baseline.json');

MergeException

Merql\Exceptions\MergeException is thrown when a required snapshot is missing for a merge.

MergeException::snapshotRequired('base');

ConflictException

Merql\Exceptions\ConflictException is thrown when attempting to apply a merge result that has unresolved conflicts.

ConflictException::unresolved(3);
// Message: "Cannot apply merge with 3 unresolved conflict(s)"

SchemaException

Merql\Exceptions\SchemaException is thrown (or collected) when schemas differ between snapshots.

SchemaException::mismatch('posts', 'column "tags" exists in current but not in base');

Schema mismatches do not stop the merge. They are collected and available via $result->schemaMismatches() for inspection.

On this page

PHP API ReferenceMerql (facade)init(PDO $pdo): voidsnapshot(string $name, array $tables = []): Snapshotdiff(string $base, string $current): Changesetmerge(string $base, string $ours, string $theirs): MergeResultpatch(string $base, string $changes): MergeResultapply(MergeResult $result): ApplyResultreset(): voidConnectionmysql(string $host, string $database, string $username, string $password = '', int $port = 3306, string $charset = 'utf8mb4'): PDOsqlite(string $path = ':memory:'): PDOfromDsn(string $dsn, string $username = '', string $password = ''): PDOSnapshotter__construct(PDO $pdo, ?Driver $driver = null)capture(string $name, array $tables = [], array $filters = []): SnapshotfromData(string $name, array $tableData): Snapshot (static)SnapshotTableSnapshotTableSnapshotDataSnapshotStoresetDirectory(string $directory): voidsave(Snapshot $snapshot): voidload(string $name): Snapshotexists(string $name): booldelete(string $name): voidDifferdiff(Snapshot $base, Snapshot $current): ChangesetvaluesEqual(mixed $a, mixed $b): bool (static)ChangesetRowInsert, RowUpdate, RowDeleteRowInsertRowUpdateRowDeleteColumnDiffThreeWayMerge__construct(?CellMergeConfig $cellMergeConfig = null)merge(Snapshot $base, Snapshot $ours, Snapshot $theirs): MergeResultpatch(Snapshot $base, Snapshot $changes): MergeResultschemaMismatches(): arrayMergeResultMergeOperationConflictConflict typesConflictResolverresolve(MergeResult $result, ConflictPolicy $policy): MergeResult (static)ConflictPolicyColumnMergemerge(string $table, string $rowKey, array $base, array $ours, array $theirs, ?CellMergeConfig $cellMergeConfig = null, array $columnTypes = []): array (static)Applier__construct(PDO $pdo, ?Driver $driver = null)apply(MergeResult $result, ?Snapshot $base = null): ApplyResultApplyResultDryRungenerate(MergeResult $result, ?Snapshot $base = null, array $fkDependencies = [], ?Driver $driver = null): array (static)SqlGeneratorgenerate(MergeResult $result, ?Snapshot $base = null, array $fkDependencies = [], ?Driver $driver = null): array (static)CellMergeConfigauto(): self (static)forColumn(string $column, CellMerger $merger): selfforType(string $typePattern, CellMerger $merger): selfgetMerger(string $table, string $column, string $columnType = ''): CellMergerCellMerger (interface)TextCellMergerJsonCellMergerOpaqueCellMergerCellMergeResultTableSchemaDriver systemDriver (interface)DriverFactoryFiltersTableFilterColumnFilterRowFilterExceptionsSnapshotExceptionMergeExceptionConflictExceptionSchemaException