packages/ztd-query-pdo-adapter/fuzz/Correctness/Postgres/PgCorrectnessHarness.php

1<?php
2
3declare(strict_types=1);
4
5namespace Fuzz\Correctness\Postgres;
6
7use Faker\Factory;
8use Faker\Generator;
9use Fuzz\Correctness\SchemaDefinition;
10use PDO;
11use RuntimeException;
12use ZtdQuery\Adapter\Pdo\ZtdPdo;
13use ZtdQuery\Config\UnknownSchemaBehavior;
14use ZtdQuery\Config\UnsupportedSqlBehavior;
15use ZtdQuery\Config\ZtdConfig;
16
17/**
18 * @phpstan-import-type Row from \Fuzz\Correctness\CorrectnessHarness
19 */
20final class PgCorrectnessHarness
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
30    /** @var list<Row> */
31    private array $fixtureRows = [];
32
33    /**
34     * Binds the instance to what it will work from.
35     *
36     * @param string $host
37     * @param int $port
38     * @param string $dbName
39     * @param string $user
40     * @param string $pass
41     */
42    public function __construct(string $host, int $port, string $dbName, string $user, string $pass)
43    {
44        $this->dsn = "pgsql:host=$host;port=$port;dbname=$dbName";
45        $this->user = $user;
46        $this->pass = $pass;
47        $this->rawPdo = new PDO($this->dsn, $user, $pass, [
48            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
49            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
50        ]);
51        $this->faker = Factory::create();
52        $this->faker->addProvider(new \Fuzz\Correctness\FixedDateTimeProvider());
53    }
54
55    /**
56     * Set up both connections with the same schema and data.
57     *
58     * @return list<Row> The fixture rows inserted
59     */
60    public function setup(SchemaDefinition $schema, int $seed, int $rowCount = 3): array
61    {
62        $this->currentSchema = $schema;
63        $this->faker->seed($seed);
64
65        $this->rawPdo->exec("DROP TABLE IF EXISTS \"{$schema->name}\" CASCADE");
66        $this->rawPdo->exec($schema->sql);
67
68        $this->fixtureRows = [];
69        for ($i = 0; $i < $rowCount; $i++) {
70            $row = $this->generateFixtureRow($schema, $i);
71            $this->fixtureRows[] = $row;
72        }
73
74        foreach ($this->fixtureRows as $row) {
75            $this->insertRow($this->rawPdo, $schema->name, $row);
76        }
77
78        $this->ztdPdo = new ZtdPdo($this->dsn, $this->user, $this->pass, [
79            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
80            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
81        ], new ZtdConfig(UnsupportedSqlBehavior::Ignore, UnknownSchemaBehavior::Exception));
82
83        $this->ztdPdo->exec($schema->sql);
84        foreach ($this->fixtureRows as $row) {
85            $columns = array_keys($row);
86            $values = array_map(function ($v) {
87                if ($v === null) {
88                    return 'NULL';
89                }
90                if (is_int($v) || is_float($v)) {
91                    return (string) $v;
92                }
93                if (is_bool($v)) {
94                    return $v ? 'TRUE' : 'FALSE';
95                }
96                return "'" . str_replace("'", "''", $v) . "'";
97            }, array_values($row));
98            $sql = sprintf(
99                'INSERT INTO "%s" (%s) VALUES (%s)',
100                str_replace('"', '""', $schema->name),
101                implode(', ', array_map(fn ($c) => '"' . str_replace('"', '""', $c) . '"', $columns)),
102                implode(', ', $values)
103            );
104            $this->ztdPdo->exec($sql);
105        }
106
107        return $this->fixtureRows;
108    }
109
110    /**
111     * Teardown.
112     *
113     */
114    public function teardown(): void
115    {
116        if ($this->currentSchema !== null) {
117            $this->rawPdo->exec("DROP TABLE IF EXISTS \"{$this->currentSchema->name}\" CASCADE");
118        }
119        $this->ztdPdo = null;
120        $this->currentSchema = null;
121        $this->fixtureRows = [];
122    }
123
124    /**
125     * Answers raw pdo.
126     *
127     * @return PDO
128     */
129    public function getRawPdo(): PDO
130    {
131        return $this->rawPdo;
132    }
133
134    /**
135     * @throws RuntimeException
136     */
137    public function getZtdPdo(): ZtdPdo
138    {
139        if ($this->ztdPdo === null) {
140            throw new RuntimeException('ZtdPdo not initialized. Call setup() first.');
141        }
142        return $this->ztdPdo;
143    }
144
145    /**
146     * @return list<Row>
147     */
148    public function getFixtureRows(): array
149    {
150        return $this->fixtureRows;
151    }
152
153    /**
154     * Answers current schema.
155     *
156     * @return ?SchemaDefinition
157     */
158    public function getCurrentSchema(): ?SchemaDefinition
159    {
160        return $this->currentSchema;
161    }
162
163    /**
164     * Answers one fixture row for the schema, made from the index so a run repeats.
165     *
166     * @param SchemaDefinition $schema The schema
167     * @param int $index Where to read
168     *
169     * @return Row What it answers
170     */
171    public function generateFixtureRow(SchemaDefinition $schema, int $index): array
172    {
173        $row = [];
174        foreach ($schema->columns as $col) {
175            $colLower = strtolower($col);
176
177            if ($col === 'id') {
178                continue;
179            }
180
181            if (str_ends_with($colLower, '_id')) {
182                $row[$col] = $index + 1;
183            } elseif (str_contains($colLower, 'real') || str_contains($colLower, 'float') || str_contains($colLower, 'double')) {
184                $row[$col] = round($this->faker->randomFloat(2, 0, 999), 2);
185            } elseif (str_contains($colLower, 'bit')) {
186                $row[$col] = $this->faker->regexify('[01]{8}');
187            } elseif (str_contains($colLower, 'bool')) {
188                $row[$col] = $this->faker->boolean();
189            } elseif (str_contains($colLower, 'smallint') || str_contains($colLower, 'int') || str_contains($colLower, 'quantity') || str_contains($colLower, 'bigint')) {
190                $row[$col] = $this->faker->numberBetween(1, 100);
191            } elseif (str_contains($colLower, 'numeric') || str_contains($colLower, 'decimal')) {
192                $row[$col] = round($this->faker->randomFloat(2, 0, 9999), 2);
193            } elseif (str_contains($colLower, 'char')) {
194                $row[$col] = $this->faker->lexify('????');
195            } else {
196                $row[$col] = $this->faker->lexify('????');
197            }
198        }
199
200        if ($schema->name === 'composite_pk') {
201            $row['order_id'] = $index + 1;
202            $row['product_id'] = ($index + 1) * 10;
203        }
204
205        return $row;
206    }
207
208    /**
209     * Writes one fixture row into the table both sides read.
210     *
211     * @param PDO $pdo The pdo
212     * @param string $table Table it belongs to
213     * @param Row $row Row to read
214     */
215    public function insertRow(PDO $pdo, string $table, array $row): void
216    {
217        $columns = array_keys($row);
218        $placeholders = array_fill(0, count($columns), '?');
219        $sql = sprintf(
220            'INSERT INTO "%s" (%s) VALUES (%s)',
221            str_replace('"', '""', $table),
222            implode(', ', array_map(fn ($c) => '"' . str_replace('"', '""', $c) . '"', $columns)),
223            implode(', ', $placeholders)
224        );
225        $values = array_map(function ($v) {
226            if (is_bool($v)) {
227                return $v ? 1 : 0;
228            }
229            return $v;
230        }, array_values($row));
231        $stmt = $pdo->prepare($sql);
232        $stmt->execute($values);
233    }
234}
235