packages/ztd-query-pdo-adapter/fuzz/Correctness/CorrectnessHarness.php
1<?php
2
3declare(strict_types=1);
4
5namespace Fuzz\Correctness;
6
7use Faker\Factory;
8use Faker\Generator;
9use PDO;
10use RuntimeException;
11use SqlFixture\FixtureProvider;
12use ZtdQuery\Adapter\Pdo\ZtdPdo;
13use ZtdQuery\Config\UnknownSchemaBehavior;
14use ZtdQuery\Config\UnsupportedSqlBehavior;
15use ZtdQuery\Config\ZtdConfig;
16
17/**
18 * @phpstan-type Row array<string, bool|float|int|string|null>
19 */
20final class CorrectnessHarness
21{
22 private PDO $rawPdo;
23 private ?ZtdPdo $ztdPdo = null;
24 private ?SchemaDefinition $currentSchema = null;
25 private string $dsn;
26 private string $user;
27 private string $pass;
28 private Generator $faker;
29 private FixtureProvider $fixtureProvider;
30
31 /** @var list<Row> */
32 private array $fixtureRows = [];
33
34 /**
35 * Binds the instance to what it will work from.
36 *
37 * @param string $host
38 * @param int $port
39 * @param string $dbName
40 * @param string $user
41 * @param string $pass
42 */
43 public function __construct(string $host, int $port, string $dbName, string $user, string $pass)
44 {
45 $this->dsn = "mysql:host=$host;port=$port;dbname=$dbName;charset=utf8mb4";
46 $this->user = $user;
47 $this->pass = $pass;
48 $this->rawPdo = new PDO($this->dsn, $user, $pass, [
49 PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
50 PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
51 ]);
52 $this->faker = Factory::create();
53 $this->faker->addProvider(new FixedDateTimeProvider());
54 $this->fixtureProvider = new FixtureProvider($this->faker);
55 }
56
57 /**
58 * Answers a generated row the harness can write and compare.
59 *
60 * The generator answers whatever the column's type maps to; a row both
61 * sides can be asked about holds nothing but scalars and nulls.
62 *
63 * @param string $createTableSql Declaration of the table to build a row for
64 *
65 * @return Row The row, keyed by column
66 *
67 * @throws RuntimeException When the generator answers something no comparison can read
68 */
69 public function fixtureRow(string $createTableSql): array
70 {
71 $row = [];
72 foreach ($this->fixtureProvider->fixture($createTableSql) as $column => $value) {
73 if ($value !== null && !is_scalar($value)) {
74 throw new RuntimeException(sprintf('The fixture generator answered %s for column "%s", which no comparison can read.', get_debug_type($value), $column));
75 }
76 $row[$column] = $value;
77 }
78
79 return $row;
80 }
81
82 /**
83 * Set up both connections with the same schema and data.
84 *
85 * @return list<Row> The fixture rows inserted
86 */
87 public function setup(SchemaDefinition $schema, int $seed, int $rowCount = 3): array
88 {
89 $this->currentSchema = $schema;
90 $this->faker->seed($seed);
91
92 $this->rawPdo->exec("DROP TABLE IF EXISTS `{$schema->name}`");
93 $this->rawPdo->exec($schema->sql);
94
95 $this->fixtureRows = [];
96 for ($i = 0; $i < $rowCount; $i++) {
97 $row = $this->fixtureRow($schema->sql);
98 if (count($schema->primaryKeys) === 1 && $schema->primaryKeys[0] === 'id') {
99 $row['id'] = $i + 1;
100 }
101 if ($schema->name === 'composite_pk') {
102 $row['order_id'] = $i + 1;
103 $row['product_id'] = ($i + 1) * 10;
104 }
105 $this->fixtureRows[] = $row;
106 }
107
108 foreach ($this->fixtureRows as $row) {
109 $this->insertRow($this->rawPdo, $schema->name, $row);
110 }
111
112 $this->ztdPdo = new ZtdPdo($this->dsn, $this->user, $this->pass, [
113 PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
114 PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
115 ], new ZtdConfig(UnsupportedSqlBehavior::Ignore, UnknownSchemaBehavior::Exception));
116
117 $this->ztdPdo->exec($schema->sql);
118 foreach ($this->fixtureRows as $row) {
119 $columns = array_keys($row);
120 $values = array_map(function ($v) {
121 if ($v === null) {
122 return 'NULL';
123 }
124 if (is_int($v) || is_float($v)) {
125 return (string) $v;
126 }
127 if (is_bool($v)) {
128 return $v ? '1' : '0';
129 }
130 return "'" . addslashes($v) . "'";
131 }, array_values($row));
132 $sql = sprintf(
133 'INSERT INTO `%s` (%s) VALUES (%s)',
134 $schema->name,
135 implode(', ', array_map(fn ($c) => "`$c`", $columns)),
136 implode(', ', $values)
137 );
138 $this->ztdPdo->exec($sql);
139 }
140
141 return $this->fixtureRows;
142 }
143
144 /**
145 * Teardown.
146 *
147 */
148 public function teardown(): void
149 {
150 if ($this->currentSchema !== null) {
151 $this->rawPdo->exec("DROP TABLE IF EXISTS `{$this->currentSchema->name}`");
152 }
153 $this->ztdPdo = null;
154 $this->currentSchema = null;
155 $this->fixtureRows = [];
156 }
157
158 /**
159 * Answers raw pdo.
160 *
161 * @return PDO
162 */
163 public function getRawPdo(): PDO
164 {
165 return $this->rawPdo;
166 }
167
168 /**
169 * @throws RuntimeException
170 */
171 public function getZtdPdo(): ZtdPdo
172 {
173 if ($this->ztdPdo === null) {
174 throw new RuntimeException('ZtdPdo not initialized. Call setup() first.');
175 }
176 return $this->ztdPdo;
177 }
178
179 /**
180 * @return list<Row>
181 */
182 public function getFixtureRows(): array
183 {
184 return $this->fixtureRows;
185 }
186
187 /**
188 * Answers current schema.
189 *
190 * @return ?SchemaDefinition
191 */
192 public function getCurrentSchema(): ?SchemaDefinition
193 {
194 return $this->currentSchema;
195 }
196
197 /**
198 * Writes one fixture row into the table both sides read.
199 *
200 * @param PDO $pdo The pdo
201 * @param string $table Table it belongs to
202 * @param Row $row Row to read
203 */
204 public function insertRow(PDO $pdo, string $table, array $row): void
205 {
206 $columns = array_keys($row);
207 $placeholders = array_fill(0, count($columns), '?');
208 $sql = sprintf(
209 'INSERT INTO `%s` (%s) VALUES (%s)',
210 $table,
211 implode(', ', array_map(fn ($c) => "`$c`", $columns)),
212 implode(', ', $placeholders)
213 );
214 $values = array_map(function ($v) {
215 if (is_bool($v)) {
216 return $v ? 1 : 0;
217 }
218 return $v;
219 }, array_values($row));
220 $stmt = $pdo->prepare($sql);
221 $stmt->execute($values);
222 }
223}
224