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'); // ?TableSnapshotTableSnapshot
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'); // ?stringTableSnapshotData
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 tableRowInsert, 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 nameRowDelete
$delete->table; // string
$delete->rowKey; // string
$delete->oldValues; // array<string, mixed>: values before deletionColumnDiff
Merql\Diff\ColumnDiff represents a single column change within a row update.
$diff->column; // string
$diff->oldValue; // mixed
$diff->newValue; // mixedThreeWayMerge
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(); // boolMergeOperation
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 rowKeyConflict types
| Type | Meaning |
|---|---|
update_update | Both sides changed the same column to different values |
update_delete | Ours updated a row that theirs deleted |
delete_update | Ours deleted a row that theirs updated |
insert_insert | Both 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 handlingColumnMerge
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(); // boolDryRun
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 countTableSchema
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.