packages/ztd-query-pdo-adapter/fuzz/Robustness/ExecutionCheck.php
1<?php
2
3declare(strict_types=1);
4
5namespace Fuzz\Robustness;
6
7use Error;
8use Fuzz\Correctness\PhysicalTableSnapshot;
9use PDO;
10use PDOException;
11use ZtdQuery\Adapter\Pdo\ZtdPdo;
12use ZtdQuery\Config\UnknownSchemaBehavior;
13use ZtdQuery\Config\UnsupportedSqlBehavior;
14use ZtdQuery\Config\ZtdConfig;
15use ZtdQuery\Exception\ColumnAlreadyExistsException;
16use ZtdQuery\Exception\ColumnNotFoundException;
17use ZtdQuery\Exception\DuplicateKeyException;
18use ZtdQuery\Exception\ForeignKeyViolationException;
19use ZtdQuery\Exception\NotNullViolationException;
20use ZtdQuery\Exception\SchemaNotFoundException;
21use ZtdQuery\Exception\SqlParseException;
22use ZtdQuery\Exception\TableAlreadyExistsException;
23use ZtdQuery\Exception\UnknownSchemaException;
24use ZtdQuery\Exception\UnsupportedSqlException;
25
26/**
27 * Executes grammar-generated SQL through the public adapter and checks physical isolation.
28 * Name resolution and supported domain rejections are expected for arbitrary grammar output.
29 * Unexpected driver errors and PHP Errors remain findings with the exact input attached.
30 */
31final class ExecutionCheck
32{
33 /**
34 * Retain the native connection used to inspect physical rows.
35 */
36 public function __construct(private readonly PDO $native)
37 {
38 }
39
40 /**
41 * Execute generated SQL and compare the physical tables afterward.
42 *
43 * @throws Error When the adapter leaks an unexpected error or modifies physical rows.
44 */
45 public function verify(string $sql, string $input): void
46 {
47 $snapshots = [];
48 foreach (['users', 'orders', 'order_items', 'products'] as $table) {
49 $snapshots[$table] = PhysicalTableSnapshot::capture($this->native, $table);
50 }
51 $pdo = ZtdPdo::fromPdo($this->native, new ZtdConfig(UnsupportedSqlBehavior::Exception, UnknownSchemaBehavior::Exception));
52 try {
53 $pdo->query($sql);
54 } catch (PDOException $failure) {
55 $domainErrors = [UnsupportedSqlException::class, UnknownSchemaException::class, SchemaNotFoundException::class, ColumnNotFoundException::class, TableAlreadyExistsException::class, ColumnAlreadyExistsException::class, DuplicateKeyException::class, ForeignKeyViolationException::class, NotNullViolationException::class, SqlParseException::class];
56 for ($cause = $failure; $cause !== null; $cause = $cause->getPrevious()) {
57 if ($cause instanceof PDOException && in_array($cause->errorInfo[1] ?? null, [1040, 2002, 2006, 2013], true)) {
58 fwrite(STDERR, 'MySQL connection failed: ' . $cause->getMessage() . PHP_EOL);
59 exit(2);
60 }
61 foreach ($domainErrors as $domainError) {
62 if ($cause instanceof $domainError) {
63 return;
64 }
65 }
66 }
67 $allowed = [
68 ['42S22', 1054], // Grammar-generated column names need not exist.
69 ['42S02', 1146], // Grammar-generated table names need not exist.
70 ['42S02', 1109], // Multi-table references may name an absent table.
71 ['42000', 1327], // Grammar-generated variables are not declared.
72 ];
73 if (!in_array([$failure->errorInfo[0] ?? null, $failure->errorInfo[1] ?? null], $allowed, true)) {
74 throw new Error("Unexpected adapter rejection\nInput: " . bin2hex($input) . "\nSQL: $sql\n" . $failure->getMessage(), 0, $failure);
75 }
76 } finally {
77 try {
78 foreach ($snapshots as $table => $snapshot) {
79 PhysicalTableSnapshot::assertUnchanged($this->native, $table, $snapshot, $sql, crc32($input));
80 }
81 } finally {
82 if ($this->native->inTransaction()) {
83 $this->native->rollBack();
84 }
85 }
86 }
87 }
88}
89