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