Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions class/xion/SchemaCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ public static function sqliteTriggerStatements(): array
* @param string $name Table name.
* @param array<string,mixed> $table Table definition.
*/
private static function mysqlCreateTable(string $name, array $table): string
public static function mysqlCreateTable(string $name, array $table): string
{
$lines = [];
foreach ($table['columns'] as $columnName => $column) {
Expand Down Expand Up @@ -133,7 +133,7 @@ private static function mysqlCreateTable(string $name, array $table): string
* @param string $name Table name.
* @param array<string,mixed> $table Table definition.
*/
private static function sqliteCreateTable(string $name, array $table): string
public static function sqliteCreateTable(string $name, array $table): string
{
$lines = [];
foreach ($table['columns'] as $columnName => $column) {
Expand All @@ -155,7 +155,7 @@ private static function sqliteCreateTable(string $name, array $table): string
/**
* @param array<string,mixed> $column
*/
private static function mysqlColumn(string $name, array $column): string
public static function mysqlColumn(string $name, array $column): string
{
$type = (string)($column['type'] ?? '');
return match (true) {
Expand All @@ -173,7 +173,7 @@ private static function mysqlColumn(string $name, array $column): string
/**
* @param array<string,mixed> $column
*/
private static function sqliteColumn(string $name, array $column): string
public static function sqliteColumn(string $name, array $column): string
{
$type = (string)($column['type'] ?? '');
return match (true) {
Expand Down
126 changes: 126 additions & 0 deletions class/xion/SchemaDiffer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

declare(strict_types=1);

namespace Nene\Xion;

/**
* Schema-diff engine — compares a live database introspection against
* {@see SchemaDefinition::tables()} and emits the SQL needed to converge
* the live DB to the definition. Adopted via ADR-0009 (#409) as
* Option C: operator-applied diff, no auto-apply.
*
* Scope (per ADR-0009):
*
* - Adds emitted as SQL:
* - new tables (full `CREATE TABLE` via {@see SchemaCompiler})
* - new columns (`ALTER TABLE … ADD COLUMN …`)
* - new SQLite indexes (deferred to follow-up — current scope is
* MySQL-first; SQLite index re-creation is operator-side)
* - Drops, renames, type changes, constraint changes: **warning only**.
* The operator hand-writes those — destructive ops should never be
* produced by an automated tool without data semantics.
*
* `SchemaDiffer` is pure-static and DB-agnostic. The CLI
* (`cli/schemaDiff.php`) handles introspection via PDO and feeds
* the resulting arrays into {@see diff()}.
*/
final class SchemaDiffer
{
public const DRIVER_MYSQL = 'mysql';
public const DRIVER_SQLITE = 'sqlite';

/**
* Compute the diff plan.
*
* `$liveTables` shape:
*
* [
* 'users' => ['columns' => ['id' => [...], ...]],
* 'todos' => ['columns' => [...]],
* ]
*
* `$definitionTables` shape: `SchemaDefinition::tables()` output.
*
* @param array<string,array<string,mixed>> $liveTables Introspected live schema.
* @param array<string,array<string,mixed>> $definitionTables Source of truth.
* @param string $driver `mysql` or `sqlite`.
*
* @return array{
* newTables: array<string,string>,
* newColumns: array<int,array{table:string,column:string,sql:string}>,
* warnings: array<int,string>,
* inSync: bool
* }
*/
public static function diff(array $liveTables, array $definitionTables, string $driver): array
{
$newTables = [];
$newColumns = [];
$warnings = [];

foreach ($definitionTables as $tableName => $tableSpec) {
if (!isset($liveTables[$tableName])) {
$newTables[$tableName] = self::createTableSql($tableName, $tableSpec, $driver);
continue;
}
$liveCols = $liveTables[$tableName]['columns'] ?? [];
$defCols = $tableSpec['columns'] ?? [];
foreach ($defCols as $colName => $colSpec) {
if (!isset($liveCols[$colName])) {
$newColumns[] = [
'table' => $tableName,
'column' => $colName,
'sql' => self::addColumnSql($tableName, $colName, $colSpec, $driver),
];
}
}
foreach (array_keys($liveCols) as $liveCol) {
if (!isset($defCols[$liveCol])) {
$warnings[] = sprintf(
'column `%s.%s` exists in the live database but not in SchemaDefinition — drop SQL must be hand-written (ADR-0009 destructive-op rule).',
$tableName,
$liveCol
);
}
}
}
foreach (array_keys($liveTables) as $liveTable) {
if (!isset($definitionTables[$liveTable])) {
$warnings[] = sprintf(
'table `%s` exists in the live database but not in SchemaDefinition — drop SQL must be hand-written (ADR-0009 destructive-op rule).',
$liveTable
);
}
}

return [
'newTables' => $newTables,
'newColumns' => $newColumns,
'warnings' => $warnings,
'inSync' => $newTables === [] && $newColumns === [],
];
}

/**
* @param array<string,mixed> $tableSpec
*/
private static function createTableSql(string $name, array $tableSpec, string $driver): string
{
return match ($driver) {
self::DRIVER_SQLITE => SchemaCompiler::sqliteCreateTable($name, $tableSpec) . ';',
default => SchemaCompiler::mysqlCreateTable($name, $tableSpec) . ';',
};
}

/**
* @param array<string,mixed> $colSpec
*/
private static function addColumnSql(string $table, string $column, array $colSpec, string $driver): string
{
$columnDdl = $driver === self::DRIVER_SQLITE
? SchemaCompiler::sqliteColumn($column, $colSpec)
: SchemaCompiler::mysqlColumn($column, $colSpec);
return 'ALTER TABLE ' . $table . ' ADD COLUMN ' . $columnDdl . ';';
}
}
191 changes: 191 additions & 0 deletions cli/schemaDiff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
<?php

declare(strict_types=1);

/**
* Compare a running database's schema against
* {@see \Nene\Xion\SchemaDefinition} and emit the SQL that would
* converge the live DB to the definition.
*
* **The CLI never applies SQL.** The operator pipes the output to a
* file, reviews it, and runs `mysql` / `sqlite3` themselves. This is
* the deliberate design from ADR-0009 (Option C): destructive ops stay
* the operator's call, and rollback fantasies are not promoted.
*
* Scope (ADR-0009 initial):
*
* - Add table (`CREATE TABLE …`)
* - Add column (`ALTER TABLE … ADD COLUMN …`)
* - Add index (deferred to follow-up)
*
* Warnings on (operator hand-writes the SQL):
*
* - Drop table / drop column / drop index
* - Column type change
* - Column rename
* - Constraint changes
*
* Usage:
*
* # MySQL
* php cli/schemaDiff.php --dsn=mysql:host=127.0.0.1;port=3306;dbname=nene \
* --user=nene --pass=nene
*
* # SQLite
* php cli/schemaDiff.php --dsn=sqlite:/var/www/html/data/nene.sqlite
*
* Options:
*
* --dsn=… PDO DSN (required).
* --user=… Username (MySQL only).
* --pass=… Password (MySQL only).
* --help Show this help.
*
* Exit codes:
*
* 0 schema is in sync, OR diff was emitted successfully
* 1 CLI usage error
* 2 database connection / introspection error
*/

use Nene\Xion\SchemaDefinition;
use Nene\Xion\SchemaDiffer;

require_once dirname(__DIR__) . '/vendor/autoload.php';

/** @var array<string,string|bool> $options */
$options = getopt('', ['dsn:', 'user:', 'pass:', 'help']) ?: [];

if (isset($options['help'])) {
fwrite(STDOUT, file_get_contents(__FILE__) ? "See the docblock at the top of cli/schemaDiff.php for full usage.\n" : '');
exit(0);
}

$dsn = isset($options['dsn']) ? (string)$options['dsn'] : '';
if ($dsn === '') {
fwrite(STDERR, "error: --dsn=… is required. Run `php cli/schemaDiff.php --help` for usage.\n");
exit(1);
}

$user = isset($options['user']) ? (string)$options['user'] : null;
$pass = isset($options['pass']) ? (string)$options['pass'] : null;

try {
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
} catch (PDOException $exception) {
fwrite(STDERR, 'error: database connection failed: ' . $exception->getMessage() . "\n");
exit(2);
}

$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
if ($driver !== SchemaDiffer::DRIVER_MYSQL && $driver !== SchemaDiffer::DRIVER_SQLITE) {
fwrite(STDERR, "error: unsupported driver `$driver`. Supported: mysql, sqlite.\n");
exit(1);
}

try {
$live = introspect($pdo, $driver);
} catch (PDOException $exception) {
fwrite(STDERR, 'error: schema introspection failed: ' . $exception->getMessage() . "\n");
exit(2);
}

$diff = SchemaDiffer::diff($live, SchemaDefinition::tables(), $driver);

if ($diff['inSync']) {
fwrite(STDERR, "-- schema is in sync with SchemaDefinition (driver=$driver)\n");
foreach ($diff['warnings'] as $warning) {
fwrite(STDERR, "-- warning: $warning\n");
}
exit(0);
}

fwrite(STDERR, "-- schema diff for driver=$driver (review before applying)\n");
foreach ($diff['warnings'] as $warning) {
fwrite(STDERR, "-- warning: $warning\n");
}
fwrite(STDOUT, "-- generated by cli/schemaDiff.php (ADR-0009)\n");
fwrite(STDOUT, "-- driver: $driver\n");
fwrite(STDOUT, "-- review every statement before applying; this tool only emits add-only changes.\n");
foreach ($diff['newTables'] as $tableName => $sql) {
fwrite(STDOUT, "\n-- new table: $tableName\n");
fwrite(STDOUT, $sql . "\n");
}
foreach ($diff['newColumns'] as $entry) {
fwrite(STDOUT, "\n-- new column: {$entry['table']}.{$entry['column']}\n");
fwrite(STDOUT, $entry['sql'] . "\n");
}

exit(0);

/**
* @return array<string,array<string,mixed>>
*/
function introspect(PDO $pdo, string $driver): array
{
if ($driver === SchemaDiffer::DRIVER_SQLITE) {
return introspectSqlite($pdo);
}
return introspectMysql($pdo);
}

/**
* @return array<string,array<string,mixed>>
*/
function introspectMysql(PDO $pdo): array
{
$database = (string)$pdo->query('SELECT DATABASE()')->fetchColumn();
if ($database === '') {
throw new RuntimeException('MySQL DSN did not specify a database (no DATABASE() in session).');
}

$tableStmt = $pdo->prepare(
'SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES '
. 'WHERE TABLE_SCHEMA = :schema AND TABLE_TYPE = "BASE TABLE"'
);
$tableStmt->execute([':schema' => $database]);
/** @var array<int,string> $tables */
$tables = $tableStmt->fetchAll(PDO::FETCH_COLUMN);

$columnStmt = $pdo->prepare(
'SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS '
. 'WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table'
);

$result = [];
foreach ($tables as $table) {
$columnStmt->execute([':schema' => $database, ':table' => $table]);
$columns = [];
foreach ($columnStmt->fetchAll() as $row) {
$name = (string)$row['COLUMN_NAME'];
$columns[$name] = ['data_type' => (string)$row['DATA_TYPE']];
}
$result[$table] = ['columns' => $columns];
}
return $result;
}

/**
* @return array<string,array<string,mixed>>
*/
function introspectSqlite(PDO $pdo): array
{
$tableStmt = $pdo->query('SELECT name FROM sqlite_master WHERE type = "table" AND name NOT LIKE "sqlite_%"');
/** @var array<int,string> $tables */
$tables = $tableStmt->fetchAll(PDO::FETCH_COLUMN);

$result = [];
foreach ($tables as $table) {
$columnStmt = $pdo->query('PRAGMA table_info(' . $table . ')');
$columns = [];
foreach ($columnStmt->fetchAll() as $row) {
$name = (string)$row['name'];
$columns[$name] = ['data_type' => (string)$row['type']];
}
$result[$table] = ['columns' => $columns];
}
return $result;
}
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"setup": "@php cli/setupDatabase.php --env=.env --yes",
"schema:generate": "@php cli/generateSchemaSql.php",
"schema:check": "@php cli/generateSchemaSql.php --check",
"schema:diff": "@php cli/schemaDiff.php",
"test": "phpunit --testsuite unit",
"test:http": [
"@php tools/test-http-preflight.php",
Expand Down
6 changes: 4 additions & 2 deletions docs/adr/0009-schema-migration-story.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ Neutral:

## Implementation tracking

- This ADR is **drafted in parallel with #409** (the eval-report-derived issue) but the **implementation lands as a separate trial / PR**, not in this ADR's branch. The next FT (FT17 candidate: "schema-diff CLI") picks up the implementation when the trial-clone bandwidth is available.
- **Implemented by FT17** (`docs/field-trials/2026-05-field-trial-17.md`, Trial Issue #417). The implementation PR is #419 (feat) — adds `Nene\Xion\SchemaDiffer`, `cli/schemaDiff.php`, promotes four `SchemaCompiler` helpers to `public static`, and ships `composer schema:diff` as the operator-facing entry point.
- The docs / workflow side ships in #420 — `docs/development/schema-migrations.md`.
- The **add-index path** is the only ADR-0009 scope item not in #419; it ships as the small follow-up Issue #421 (no new trial needed).
- A future ADR may extend this surface to (a) include Phinx as an optional dep, or (b) move to Option B (auto-generated `ALTER TABLE` via versioned `SchemaDefinition`) if the operator-side friction surfaces again.
- ADR-0005's Consequences section gets a cross-link to ADR-0009 ("Schema migrations: addressed by ADR-0009").
- ADR-0005's Consequences section already cross-links to this ADR ("Schema migrations: addressed by ADR-0009").
Loading
Loading