From c3a2a1b919c458c23f42660f68df80aa23d8972e Mon Sep 17 00:00:00 2001 From: dnnsjsk Date: Tue, 19 May 2026 00:25:45 +0200 Subject: [PATCH] Raise PHPStan to level 10 Narrow row data to scalar|null end-to-end (matches PDO::FETCH_ASSOC's actual contract), add runtime validation at PDO and JSON boundaries, and tighten cell/conflict value types accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- phpstan.neon | 2 +- src/Apply/DryRun.php | 2 +- src/Apply/SqlGenerator.php | 8 +- src/CellMerge/CellMergeResult.php | 6 +- src/CellMerge/CellMerger.php | 6 +- src/CellMerge/JsonCellMerger.php | 11 +- src/CellMerge/OpaqueCellMerger.php | 7 +- src/CellMerge/TextCellMerger.php | 7 +- src/Diff/ColumnDiff.php | 4 +- src/Diff/Differ.php | 6 +- src/Diff/RowDelete.php | 2 +- src/Diff/RowInsert.php | 2 +- src/Diff/RowUpdate.php | 2 +- src/Driver/DriverFactory.php | 4 + src/Driver/MysqlDriver.php | 30 +++++- src/Driver/SqliteDriver.php | 58 ++++++++-- src/Filter/ColumnFilter.php | 4 +- src/Filter/RowFilter.php | 6 +- src/Identity/RowIdentity.php | 2 +- src/Merge/ColumnMerge.php | 8 +- src/Merge/Conflict.php | 26 +++-- src/Merge/ConflictResolver.php | 51 +++++++-- src/Merge/MergeOperation.php | 2 +- src/Snapshot/RowFingerprint.php | 4 +- src/Snapshot/SnapshotStore.php | 167 ++++++++++++++++++++++++++--- src/Snapshot/Snapshotter.php | 28 ++++- src/Snapshot/TableSnapshot.php | 4 +- src/Snapshot/TableSnapshotData.php | 2 +- 28 files changed, 379 insertions(+), 82 deletions(-) diff --git a/phpstan.neon b/phpstan.neon index 8e4b033..6437737 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ parameters: - level: 8 + level: 10 treatPhpDocTypesAsCertain: false paths: - src diff --git a/src/Apply/DryRun.php b/src/Apply/DryRun.php index e9c9e45..eed72f7 100644 --- a/src/Apply/DryRun.php +++ b/src/Apply/DryRun.php @@ -43,7 +43,7 @@ public static function generate( return "'" . str_replace( ['\\', "'", "\0", "\n", "\r"], ['\\\\', "''", '\\0', '\\n', '\\r'], - (string) $val, + is_bool($val) ? ($val ? '1' : '0') : (string) $val, ) . "'"; }, $sql); $output[] = (string) $replaced; diff --git a/src/Apply/SqlGenerator.php b/src/Apply/SqlGenerator.php index 853767a..2037eeb 100644 --- a/src/Apply/SqlGenerator.php +++ b/src/Apply/SqlGenerator.php @@ -19,7 +19,7 @@ final class SqlGenerator * Generate SQL statements for all operations. * * @param array> $fkDependencies FK dependency map (child to parents). - * @return list}> + * @return list}> */ public static function generate( MergeResult $result, @@ -75,7 +75,7 @@ public static function generate( /** * @param \Closure(string): string $q Identifier quoter. - * @return array{sql: string, params: list} + * @return array{sql: string, params: list} */ private static function generateInsert(MergeOperation $op, \Closure $q): array { @@ -95,7 +95,7 @@ private static function generateInsert(MergeOperation $op, \Closure $q): array /** * @param \Closure(string): string $q Identifier quoter. - * @return array{sql: string, params: list}|null + * @return array{sql: string, params: list}|null */ private static function generateUpdate(MergeOperation $op, ?Snapshot $base, \Closure $q): ?array { @@ -135,7 +135,7 @@ private static function generateUpdate(MergeOperation $op, ?Snapshot $base, \Clo /** * @param \Closure(string): string $q Identifier quoter. - * @return array{sql: string, params: list} + * @return array{sql: string, params: list} */ private static function generateDelete(MergeOperation $op, ?Snapshot $base, \Closure $q): array { diff --git a/src/CellMerge/CellMergeResult.php b/src/CellMerge/CellMergeResult.php index a9cbcf0..ff59ccd 100644 --- a/src/CellMerge/CellMergeResult.php +++ b/src/CellMerge/CellMergeResult.php @@ -11,17 +11,17 @@ { private function __construct( public bool $clean, - public mixed $value, + public string|int|float|bool|null $value, public int $conflicts, ) { } - public static function resolved(mixed $value): self + public static function resolved(string|int|float|bool|null $value): self { return new self(true, $value, 0); } - public static function conflict(mixed $oursValue, int $conflicts = 1): self + public static function conflict(string|int|float|bool|null $oursValue, int $conflicts = 1): self { return new self(false, $oursValue, $conflicts); } diff --git a/src/CellMerge/CellMerger.php b/src/CellMerge/CellMerger.php index f7eda21..41aae28 100644 --- a/src/CellMerge/CellMerger.php +++ b/src/CellMerge/CellMerger.php @@ -19,5 +19,9 @@ interface CellMerger * Return CellMergeResult::resolved() if the merge is clean, * or CellMergeResult::conflict() if it cannot be resolved. */ - public function merge(mixed $base, mixed $ours, mixed $theirs): CellMergeResult; + public function merge( + string|int|float|bool|null $base, + string|int|float|bool|null $ours, + string|int|float|bool|null $theirs, + ): CellMergeResult; } diff --git a/src/CellMerge/JsonCellMerger.php b/src/CellMerge/JsonCellMerger.php index 964420e..20ad0a1 100644 --- a/src/CellMerge/JsonCellMerger.php +++ b/src/CellMerge/JsonCellMerger.php @@ -19,8 +19,11 @@ */ final class JsonCellMerger implements CellMerger { - public function merge(mixed $base, mixed $ours, mixed $theirs): CellMergeResult - { + public function merge( + string|int|float|bool|null $base, + string|int|float|bool|null $ours, + string|int|float|bool|null $theirs, + ): CellMergeResult { $baseObj = self::decode($base); $oursObj = self::decode($ours); $theirsObj = self::decode($theirs); @@ -101,6 +104,10 @@ private static function decode(mixed $value): mixed return null; } + if (!is_scalar($value)) { + return null; + } + try { return json_decode((string) $value, true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException) { diff --git a/src/CellMerge/OpaqueCellMerger.php b/src/CellMerge/OpaqueCellMerger.php index ed65933..49260f9 100644 --- a/src/CellMerge/OpaqueCellMerger.php +++ b/src/CellMerge/OpaqueCellMerger.php @@ -10,8 +10,11 @@ */ final class OpaqueCellMerger implements CellMerger { - public function merge(mixed $base, mixed $ours, mixed $theirs): CellMergeResult - { + public function merge( + string|int|float|bool|null $base, + string|int|float|bool|null $ours, + string|int|float|bool|null $theirs, + ): CellMergeResult { return CellMergeResult::conflict($ours); } } diff --git a/src/CellMerge/TextCellMerger.php b/src/CellMerge/TextCellMerger.php index bd680e9..d5b2910 100644 --- a/src/CellMerge/TextCellMerger.php +++ b/src/CellMerge/TextCellMerger.php @@ -12,8 +12,11 @@ */ final class TextCellMerger implements CellMerger { - public function merge(mixed $base, mixed $ours, mixed $theirs): CellMergeResult - { + public function merge( + string|int|float|bool|null $base, + string|int|float|bool|null $ours, + string|int|float|bool|null $theirs, + ): CellMergeResult { $result = ThreeWayMerge::merge( (string) ($base ?? ''), (string) ($ours ?? ''), diff --git a/src/Diff/ColumnDiff.php b/src/Diff/ColumnDiff.php index 9377e60..3d4d31c 100644 --- a/src/Diff/ColumnDiff.php +++ b/src/Diff/ColumnDiff.php @@ -11,8 +11,8 @@ { public function __construct( public string $column, - public mixed $oldValue, - public mixed $newValue, + public string|int|float|bool|null $oldValue, + public string|int|float|bool|null $newValue, ) { } } diff --git a/src/Diff/Differ.php b/src/Diff/Differ.php index a607b2f..0bb749e 100644 --- a/src/Diff/Differ.php +++ b/src/Diff/Differ.php @@ -92,8 +92,8 @@ private function diffTable( } /** - * @param array $baseRow - * @param array $currentRow + * @param array $baseRow + * @param array $currentRow * @return list */ private function diffColumns(array $baseRow, array $currentRow): array @@ -116,7 +116,7 @@ private function diffColumns(array $baseRow, array $currentRow): array /** * Compare two values, treating NULL as a distinct value. */ - public static function valuesEqual(mixed $a, mixed $b): bool + public static function valuesEqual(string|int|float|bool|null $a, string|int|float|bool|null $b): bool { if ($a === null && $b === null) { return true; diff --git a/src/Diff/RowDelete.php b/src/Diff/RowDelete.php index 621b214..0952a10 100644 --- a/src/Diff/RowDelete.php +++ b/src/Diff/RowDelete.php @@ -12,7 +12,7 @@ /** * @param string $table Table name. * @param string $rowKey Row identity key. - * @param array $oldValues Column values before deletion. + * @param array $oldValues Column values before deletion. */ public function __construct( public string $table, diff --git a/src/Diff/RowInsert.php b/src/Diff/RowInsert.php index 81374c4..29796eb 100644 --- a/src/Diff/RowInsert.php +++ b/src/Diff/RowInsert.php @@ -12,7 +12,7 @@ /** * @param string $table Table name. * @param string $rowKey Row identity key. - * @param array $values All column values. + * @param array $values All column values. */ public function __construct( public string $table, diff --git a/src/Diff/RowUpdate.php b/src/Diff/RowUpdate.php index b76f44a..b9eef19 100644 --- a/src/Diff/RowUpdate.php +++ b/src/Diff/RowUpdate.php @@ -13,7 +13,7 @@ * @param string $table Table name. * @param string $rowKey Row identity key. * @param list $columnDiffs Per-column changes. - * @param array $fullRow Complete current row data. + * @param array $fullRow Complete current row data. */ public function __construct( public string $table, diff --git a/src/Driver/DriverFactory.php b/src/Driver/DriverFactory.php index 3c33e38..680516d 100644 --- a/src/Driver/DriverFactory.php +++ b/src/Driver/DriverFactory.php @@ -36,6 +36,10 @@ public static function create(PDO $pdo): Driver { $pdoDriver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + if (!is_string($pdoDriver)) { + throw new \RuntimeException('Unable to determine PDO driver name.'); + } + $class = self::$drivers[$pdoDriver] ?? null; if ($class === null) { throw new \RuntimeException("Unsupported database driver: {$pdoDriver}"); diff --git a/src/Driver/MysqlDriver.php b/src/Driver/MysqlDriver.php index 877257c..dfa5c15 100644 --- a/src/Driver/MysqlDriver.php +++ b/src/Driver/MysqlDriver.php @@ -55,7 +55,15 @@ public function readForeignKeys(PDO $pdo): array $deps = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { - $deps[$row['TABLE_NAME']][] = $row['REFERENCED_TABLE_NAME']; + if (!is_array($row)) { + continue; + } + $tableName = $row['TABLE_NAME'] ?? null; + $referenced = $row['REFERENCED_TABLE_NAME'] ?? null; + if (!is_string($tableName) || !is_string($referenced)) { + continue; + } + $deps[$tableName][] = $referenced; } return $deps; @@ -79,7 +87,15 @@ private function readColumns(PDO $pdo, string $db, string $table): array $columns = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { - $columns[$row['COLUMN_NAME']] = $row['COLUMN_TYPE']; + if (!is_array($row)) { + continue; + } + $name = $row['COLUMN_NAME'] ?? null; + $type = $row['COLUMN_TYPE'] ?? null; + if (!is_string($name) || !is_string($type)) { + continue; + } + $columns[$name] = $type; } return $columns; @@ -125,7 +141,15 @@ private function readUniqueKeys(PDO $pdo, string $db, string $table): array $keys = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { - $keys[$row['CONSTRAINT_NAME']][] = $row['COLUMN_NAME']; + if (!is_array($row)) { + continue; + } + $constraint = $row['CONSTRAINT_NAME'] ?? null; + $column = $row['COLUMN_NAME'] ?? null; + if (!is_string($constraint) || !is_string($column)) { + continue; + } + $keys[$constraint][] = $column; } return array_values(array_map('array_values', $keys)); diff --git a/src/Driver/SqliteDriver.php b/src/Driver/SqliteDriver.php index 4d8f87b..53c0b36 100644 --- a/src/Driver/SqliteDriver.php +++ b/src/Driver/SqliteDriver.php @@ -51,12 +51,19 @@ public function readForeignKeys(PDO $pdo): array $parents = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { - $parents[] = $row['table']; + if (!is_array($row)) { + continue; + } + $parent = $row['table'] ?? null; + if (!is_string($parent)) { + continue; + } + $parents[] = $parent; } - $parents = array_unique($parents); + $parents = array_values(array_unique($parents)); if ($parents !== []) { - $deps[$table] = array_values($parents); + $deps[$table] = $parents; } } @@ -80,7 +87,15 @@ private function readColumns(PDO $pdo, string $table): array $columns = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { - $columns[$row['name']] = strtolower($row['type'] ?: 'text'); + if (!is_array($row)) { + continue; + } + $name = $row['name'] ?? null; + $type = $row['type'] ?? null; + if (!is_string($name)) { + continue; + } + $columns[$name] = strtolower(is_string($type) && $type !== '' ? $type : 'text'); } return $columns; @@ -98,8 +113,17 @@ private function readPrimaryKey(PDO $pdo, string $table): array $pkColumns = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { - if ((int) $row['pk'] > 0) { - $pkColumns[(int) $row['pk']] = $row['name']; + if (!is_array($row)) { + continue; + } + $pk = $row['pk'] ?? null; + $name = $row['name'] ?? null; + if (!is_numeric($pk) || !is_string($name)) { + continue; + } + $pkOrder = (int) $pk; + if ($pkOrder > 0) { + $pkColumns[$pkOrder] = $name; } } @@ -120,7 +144,11 @@ private function readUniqueKeys(PDO $pdo, string $table): array $uniqueKeys = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $index) { - if ((int) $index['unique'] !== 1) { + if (!is_array($index)) { + continue; + } + $unique = $index['unique'] ?? null; + if (!is_numeric($unique) || (int) $unique !== 1) { continue; } @@ -128,14 +156,26 @@ private function readUniqueKeys(PDO $pdo, string $table): array continue; } - $colStmt = $pdo->query("PRAGMA index_info(" . $this->quoteIdentifier($index['name']) . ")"); + $indexName = $index['name'] ?? null; + if (!is_string($indexName)) { + continue; + } + + $colStmt = $pdo->query("PRAGMA index_info(" . $this->quoteIdentifier($indexName) . ")"); if ($colStmt === false) { continue; } $cols = []; foreach ($colStmt->fetchAll(PDO::FETCH_ASSOC) as $col) { - $cols[] = $col['name']; + if (!is_array($col)) { + continue; + } + $colName = $col['name'] ?? null; + if (!is_string($colName)) { + continue; + } + $cols[] = $colName; } if ($cols !== []) { diff --git a/src/Filter/ColumnFilter.php b/src/Filter/ColumnFilter.php index 63a718f..a3a305b 100644 --- a/src/Filter/ColumnFilter.php +++ b/src/Filter/ColumnFilter.php @@ -28,8 +28,8 @@ public static function ignore(array $columns): self /** * Remove ignored columns from a row. * - * @param array $row - * @return array + * @param array $row + * @return array */ public function applyToRow(array $row): array { diff --git a/src/Filter/RowFilter.php b/src/Filter/RowFilter.php index e72057a..e57e31c 100644 --- a/src/Filter/RowFilter.php +++ b/src/Filter/RowFilter.php @@ -9,7 +9,7 @@ */ final class RowFilter { - /** @var \Closure(string, array): bool */ + /** @var \Closure(string, array): bool */ private \Closure $predicate; private function __construct(\Closure $predicate) @@ -20,7 +20,7 @@ private function __construct(\Closure $predicate) /** * Create a row filter from a predicate. * - * @param callable(string, array): bool $predicate + * @param callable(string, array): bool $predicate * Receives table name and row data, returns true to include the row. */ public static function create(callable $predicate): self @@ -29,7 +29,7 @@ public static function create(callable $predicate): self } /** - * @param array $row + * @param array $row */ public function shouldInclude(string $table, array $row): bool { diff --git a/src/Identity/RowIdentity.php b/src/Identity/RowIdentity.php index 64219a1..9a86e0c 100644 --- a/src/Identity/RowIdentity.php +++ b/src/Identity/RowIdentity.php @@ -12,7 +12,7 @@ interface RowIdentity /** * Build a unique key for a row. * - * @param array $row Column values. + * @param array $row Column values. * @return string Unique key identifying this row. */ public function key(array $row): string; diff --git a/src/Merge/ColumnMerge.php b/src/Merge/ColumnMerge.php index 6543820..f2b67ca 100644 --- a/src/Merge/ColumnMerge.php +++ b/src/Merge/ColumnMerge.php @@ -15,11 +15,11 @@ final class ColumnMerge /** * Merge two versions of a row at the column level. * - * @param array $base Base row data. - * @param array $ours Our version of the row. - * @param array $theirs Their version of the row. + * @param array $base Base row data. + * @param array $ours Our version of the row. + * @param array $theirs Their version of the row. * @param array $columnTypes Column name to type mapping (for cell merger lookup). - * @return array{values: array, conflicts: list} + * @return array{values: array, conflicts: list} */ public static function merge( string $table, diff --git a/src/Merge/Conflict.php b/src/Merge/Conflict.php index e58a29c..8d4f473 100644 --- a/src/Merge/Conflict.php +++ b/src/Merge/Conflict.php @@ -9,14 +9,19 @@ */ final readonly class Conflict { + /** + * @param scalar|null|array $oursValue + * @param scalar|null|array $theirsValue + * @param scalar|null|array $baseValue + */ public function __construct( private string $table, private string $rowKey, private string $type, private ?string $column = null, - private mixed $oursValue = null, - private mixed $theirsValue = null, - private mixed $baseValue = null, + private string|int|float|bool|array|null $oursValue = null, + private string|int|float|bool|array|null $theirsValue = null, + private string|int|float|bool|array|null $baseValue = null, ) { } @@ -41,17 +46,26 @@ public function column(): ?string return $this->column; } - public function oursValue(): mixed + /** + * @return scalar|null|array + */ + public function oursValue(): string|int|float|bool|array|null { return $this->oursValue; } - public function theirsValue(): mixed + /** + * @return scalar|null|array + */ + public function theirsValue(): string|int|float|bool|array|null { return $this->theirsValue; } - public function baseValue(): mixed + /** + * @return scalar|null|array + */ + public function baseValue(): string|int|float|bool|array|null { return $this->baseValue; } diff --git a/src/Merge/ConflictResolver.php b/src/Merge/ConflictResolver.php index 5992013..c88252d 100644 --- a/src/Merge/ConflictResolver.php +++ b/src/Merge/ConflictResolver.php @@ -66,7 +66,7 @@ public static function resolve(MergeResult $result, ConflictPolicy $policy): Mer * Build column value patches from update_update conflicts. * * @param list $conflicts - * @return array> + * @return array> */ private static function buildColumnPatches(array $conflicts, bool $useOurs): array { @@ -76,10 +76,14 @@ private static function buildColumnPatches(array $conflicts, bool $useOurs): arr continue; } + $value = $useOurs ? $conflict->oursValue() : $conflict->theirsValue(); + // Column-level conflicts carry scalar values; structural ones carry arrays. + if (is_array($value)) { + continue; + } + $key = $conflict->table() . "\x00" . $conflict->rowKey(); - $patches[$key][$conflict->column()] = $useOurs - ? $conflict->oursValue() - : $conflict->theirsValue(); + $patches[$key][$conflict->column()] = $value; } return $patches; @@ -94,20 +98,49 @@ private static function resolveStructuralConflict( return match ($conflict->type()) { 'update_delete' => $useOurs - ? self::op(MergeOperation::TYPE_UPDATE, $table, $rowKey, (array) $conflict->oursValue(), 'ours') + ? self::op(MergeOperation::TYPE_UPDATE, $table, $rowKey, self::asRow($conflict->oursValue()), 'ours') : self::op(MergeOperation::TYPE_DELETE, $table, $rowKey, [], 'theirs'), 'delete_update' => $useOurs ? self::op(MergeOperation::TYPE_DELETE, $table, $rowKey, [], 'ours') - : self::op(MergeOperation::TYPE_UPDATE, $table, $rowKey, (array) $conflict->theirsValue(), 'theirs'), + : self::op( + MergeOperation::TYPE_UPDATE, + $table, + $rowKey, + self::asRow($conflict->theirsValue()), + 'theirs', + ), 'insert_insert' => $useOurs - ? self::op(MergeOperation::TYPE_INSERT, $table, $rowKey, (array) $conflict->oursValue(), 'ours') - : self::op(MergeOperation::TYPE_INSERT, $table, $rowKey, (array) $conflict->theirsValue(), 'theirs'), + ? self::op( + MergeOperation::TYPE_INSERT, + $table, + $rowKey, + self::asRow($conflict->oursValue()), + 'ours', + ) + : self::op( + MergeOperation::TYPE_INSERT, + $table, + $rowKey, + self::asRow($conflict->theirsValue()), + 'theirs', + ), default => null, }; } /** - * @param array $values + * Narrow a structural conflict value (which always carries a row array) to its row type. + * + * @param scalar|null|array $value + * @return array + */ + private static function asRow(string|int|float|bool|array|null $value): array + { + return is_array($value) ? $value : []; + } + + /** + * @param array $values */ private static function op( string $type, diff --git a/src/Merge/MergeOperation.php b/src/Merge/MergeOperation.php index 04b79af..358485f 100644 --- a/src/Merge/MergeOperation.php +++ b/src/Merge/MergeOperation.php @@ -17,7 +17,7 @@ * @param string $type insert|update|delete * @param string $table Table name. * @param string $rowKey Row identity key. - * @param array $values Column values for the operation. + * @param array $values Column values for the operation. * @param string $source "ours"|"theirs"|"merged" */ public function __construct( diff --git a/src/Snapshot/RowFingerprint.php b/src/Snapshot/RowFingerprint.php index 0570e8f..7e1d4f7 100644 --- a/src/Snapshot/RowFingerprint.php +++ b/src/Snapshot/RowFingerprint.php @@ -12,7 +12,7 @@ final class RowFingerprint /** * Compute a fingerprint for a row's data. * - * @param array $data Column name to value mapping. + * @param array $data Column name to value mapping. */ public static function compute(array $data, string $algo = 'sha256'): string { @@ -25,7 +25,7 @@ public static function compute(array $data, string $algo = 'sha256'): string * Normalize row data to a deterministic string representation. * NULL is represented distinctly from empty string or "null". * - * @param array $data + * @param array $data */ private static function normalize(array $data): string { diff --git a/src/Snapshot/SnapshotStore.php b/src/Snapshot/SnapshotStore.php index 4f9977d..e9e2e13 100644 --- a/src/Snapshot/SnapshotStore.php +++ b/src/Snapshot/SnapshotStore.php @@ -52,6 +52,10 @@ public static function load(string $name): Snapshot $data = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($data)) { + throw new SnapshotException("Snapshot '{$name}' is malformed: expected object at root."); + } + return self::deserialize($name, $data); } @@ -108,27 +112,164 @@ private static function serialize(Snapshot $snapshot): array } /** - * @param array $data + * @param array $data */ private static function deserialize(string $name, array $data): Snapshot { + $tablesData = $data['tables'] ?? null; + if (!is_array($tablesData)) { + throw new SnapshotException("Snapshot '{$name}' is malformed: missing 'tables'."); + } + $tables = []; - foreach ($data['tables'] as $tableName => $tableData) { - $schema = new TableSchema( - $tableData['schema']['name'], - $tableData['schema']['columns'], - $tableData['schema']['primaryKey'], - $tableData['schema']['uniqueKeys'], + foreach ($tablesData as $tableName => $tableData) { + if (!is_string($tableName) || !is_array($tableData)) { + continue; + } + $tables[$tableName] = self::deserializeTable($name, $tableName, $tableData); + } + + return new Snapshot($name, $tables); + } + + /** + * @param array $tableData + */ + private static function deserializeTable(string $snapshot, string $tableName, array $tableData): TableSnapshot + { + $schemaData = $tableData['schema'] ?? null; + if (!is_array($schemaData)) { + throw new SnapshotException( + "Snapshot '{$snapshot}' table '{$tableName}' is malformed: missing 'schema'.", ); + } + + $schemaName = $schemaData['name'] ?? null; + $schemaColumns = $schemaData['columns'] ?? null; + $schemaPrimaryKey = $schemaData['primaryKey'] ?? null; + $schemaUniqueKeys = $schemaData['uniqueKeys'] ?? null; - $tables[$tableName] = new TableSnapshot( - $schema, - $tableData['fingerprints'], - $tableData['rows'], - $tableData['identityColumns'], + if ( + !is_string($schemaName) + || !is_array($schemaColumns) + || !is_array($schemaPrimaryKey) + || !is_array($schemaUniqueKeys) + ) { + throw new SnapshotException( + "Snapshot '{$snapshot}' table '{$tableName}' has invalid schema fields.", ); } - return new Snapshot($name, $tables); + $schema = new TableSchema( + $schemaName, + self::stringMap($schemaColumns), + self::stringList($schemaPrimaryKey), + self::stringListList($schemaUniqueKeys), + ); + + $fingerprints = $tableData['fingerprints'] ?? null; + $rows = $tableData['rows'] ?? null; + $identityColumns = $tableData['identityColumns'] ?? null; + + if (!is_array($fingerprints) || !is_array($rows) || !is_array($identityColumns)) { + throw new SnapshotException( + "Snapshot '{$snapshot}' table '{$tableName}' has invalid row data.", + ); + } + + return new TableSnapshot( + $schema, + self::fingerprintMap($fingerprints), + self::rowMap($rows), + self::stringList($identityColumns), + ); + } + + /** + * @param array $raw + * @return array + */ + private static function stringMap(array $raw): array + { + $out = []; + foreach ($raw as $k => $v) { + if (is_string($k) && is_string($v)) { + $out[$k] = $v; + } + } + + return $out; + } + + /** + * @param array $raw + * @return list + */ + private static function stringList(array $raw): array + { + $out = []; + foreach ($raw as $v) { + if (is_string($v)) { + $out[] = $v; + } + } + + return $out; + } + + /** + * @param array $raw + * @return list> + */ + private static function stringListList(array $raw): array + { + $out = []; + foreach ($raw as $inner) { + if (is_array($inner)) { + $out[] = self::stringList($inner); + } + } + + return $out; + } + + /** + * @param array $raw + * @return array + */ + private static function fingerprintMap(array $raw): array + { + $out = []; + foreach ($raw as $k => $v) { + if (is_string($v)) { + $out[$k] = $v; + } + } + + return $out; + } + + /** + * @param array $raw + * @return array> + */ + private static function rowMap(array $raw): array + { + $out = []; + foreach ($raw as $k => $row) { + if (!is_array($row)) { + continue; + } + $rowOut = []; + foreach ($row as $col => $val) { + if (!is_string($col)) { + continue; + } + $rowOut[$col] = is_scalar($val) || $val === null ? $val : null; + } + $out[$k] = $rowOut; + } + + return $out; } } diff --git a/src/Snapshot/Snapshotter.php b/src/Snapshot/Snapshotter.php index d777598..1403f58 100644 --- a/src/Snapshot/Snapshotter.php +++ b/src/Snapshot/Snapshotter.php @@ -88,7 +88,9 @@ private function captureTable( $fingerprints = []; $rows = []; - foreach ($allRows as $row) { + foreach ($allRows as $rawRow) { + $row = self::normalizeRow($rawRow); + if ($rowFilter !== null && !$rowFilter->shouldInclude($tableName, $row)) { continue; } @@ -107,6 +109,28 @@ private function captureTable( return new TableSnapshot($schema, $fingerprints, $rows, $identityColumns); } + /** + * Normalize a PDO::FETCH_ASSOC row to scalar|null values. + * + * @return array + */ + private static function normalizeRow(mixed $rawRow): array + { + if (!is_array($rawRow)) { + return []; + } + + $row = []; + foreach ($rawRow as $col => $val) { + if (!is_string($col)) { + continue; + } + $row[$col] = is_scalar($val) || $val === null ? $val : null; + } + + return $row; + } + private static function buildTableSnapshot(TableSnapshotData $data): TableSnapshot { $fingerprints = []; @@ -122,7 +146,7 @@ private static function buildTableSnapshot(TableSnapshotData $data): TableSnapsh } /** - * @param array $row + * @param array $row * @param list $identityColumns */ public static function buildRowKey(array $row, array $identityColumns): string diff --git a/src/Snapshot/TableSnapshot.php b/src/Snapshot/TableSnapshot.php index 1299b59..8614515 100644 --- a/src/Snapshot/TableSnapshot.php +++ b/src/Snapshot/TableSnapshot.php @@ -14,7 +14,7 @@ /** * @param TableSchema $schema Table structure. * @param array $fingerprints Row identity key to fingerprint hash. - * @param array> $rows Row identity key to column data. + * @param array> $rows Row identity key to column data. * @param list $identityColumns Columns used for row identity. */ public function __construct( @@ -32,7 +32,7 @@ public function hasRow(string $key): bool } /** - * @return array|null + * @return array|null */ public function getRow(string $key): ?array { diff --git a/src/Snapshot/TableSnapshotData.php b/src/Snapshot/TableSnapshotData.php index 169f3a8..78522fa 100644 --- a/src/Snapshot/TableSnapshotData.php +++ b/src/Snapshot/TableSnapshotData.php @@ -13,7 +13,7 @@ { /** * @param TableSchema $schema Table structure. - * @param list> $rows Row data. + * @param list> $rows Row data. * @param list $identityColumns Columns used for row identity. */ public function __construct(