packages/ztd-query-core/fuzz/Target/SessionTarget.php

1<?php
2
3declare(strict_types=1);
4
5namespace Fuzz\Target;
6
7use Error;
8use LogicException;
9use Tests\Fake\FakeConnection;
10use ZtdQuery\Connection\Exception\DatabaseException;
11use ZtdQuery\Connection\ResultSet;
12use ZtdQuery\Exception\DuplicateKeyException;
13use ZtdQuery\Exception\NotNullViolationException;
14use ZtdQuery\QueryExecutor;
15use ZtdQuery\Schema\TableDefinition;
16use ZtdQuery\Schema\TableDefinitionRegistry;
17use ZtdQuery\Shadow\Mutation\Row\DeleteMutation;
18use ZtdQuery\Shadow\Mutation\Row\InsertMutation;
19use ZtdQuery\Shadow\Mutation\Row\UpdateMutation;
20use ZtdQuery\Shadow\Mutation\Table\CreateTableMutation;
21use ZtdQuery\Shadow\Mutation\Table\DropTableMutation;
22use ZtdQuery\Shadow\ShadowStore;
23
24/**
25 * Compares two isolated core sessions with independent array models.
26 * Four bytes select session/operation, table, identity, and opaque value/savepoint.
27 */
28final class SessionTarget
29{
30    /**
31     * Actual virtual rows owned by this session.
32     */
33    private readonly ShadowStore $store;
34    /**
35     * Actual virtual schema owned by this session.
36     */
37    private readonly TableDefinitionRegistry $registry;
38    /**
39     * Core session under test.
40     */
41    private readonly QueryExecutor $executor;
42    /**
43     * Recording physical connection, which must receive no queries.
44     */
45    private readonly FakeConnection $connection;
46
47    /**
48     * Create one isolated core session with a recording connection.
49     */
50    public function __construct()
51    {
52        $this->store = new ShadowStore();
53        $this->registry = new TableDefinitionRegistry();
54        $this->connection = new FakeConnection();
55        foreach (['items', 'other'] as $table) {
56            $this->store->set($table, []);
57            $this->registry->register($table, self::definition());
58        }
59        $this->executor = new QueryExecutor($this->connection, new \Tests\Fake\FakePlatform(), session: new \ZtdQuery\Session($this->store, $this->registry));
60    }
61
62    /**
63     * Return the row contract used by core mutation operations.
64     */
65    public static function definition(): TableDefinition
66    {
67        return new TableDefinition(['id', 'value'], ['id' => 'INTEGER', 'value' => 'TEXT'], ['id'], ['id'], []);
68    }
69
70    /**
71     * Apply one mutation or transaction through the core session.
72     * @param int|string|null|array{opaque: int} $value
73     * @throws DatabaseException
74     * @throws LogicException
75     */
76    public function apply(int $operation, string $table, int $id, int|string|array|null $value, string $name): void
77    {
78        if ($operation >= 3 && $operation <= 8) {
79            match ($operation) {
80                3 => $this->executor->session()->beginTransaction(),
81                4 => $this->executor->session()->commitTransaction(),
82                5 => $this->executor->session()->rollBackTransaction(),
83                6 => $this->executor->session()->transactions()->savepoint($name),
84                7 => $this->executor->session()->transactions()->rollBackTo($name),
85                8 => $this->executor->session()->transactions()->release($name),
86            };
87            return;
88        }
89        $rows = [['id' => $id, 'value' => $value]];
90        if ($operation === 11 || $operation === 12) {
91            $rows = [['id' => 1000 + $id, 'value' => $value], ['id' => $operation === 11 ? 1000 + $id : null, 'value' => $value]];
92        }
93        $mutation = match ($operation) {
94            0, 11, 12 => new InsertMutation($table, ['id'], tableDefinition: self::definition(), validateConstraints: true),
95            1 => new UpdateMutation($table, ['id'], self::definition(), validateConstraints: true),
96            2 => new DeleteMutation($table, ['id']),
97            9 => new CreateTableMutation('temporary', self::definition(), $this->registry, 'CREATE', true),
98            10 => new DropTableMutation('temporary', $this->registry, 'DROP', true),
99            default => throw new LogicException('Unrecognized core operation.'),
100        };
101        $this->executor->applyShadow($mutation, new ResultSet($rows, []), 'fuzz operation');
102    }
103
104    /**
105     * @var array<string, array<int, array{id: int, value: int|string|null|array{opaque: int}}>>
106     */
107    private array $tables = ['items' => [], 'other' => []];
108    /**
109     * @var list<array{name: string|null, tables: array<string, array<int, array{id: int, value: int|string|null|array{opaque: int}}>>}>
110     */
111    private array $checkpoints = [];
112
113    /**
114     * Advance the plain-array model or return the required rejection category.
115     * @param int|string|null|array{opaque: int} $value
116     */
117    public function modelApply(int $operation, string $table, int $id, int|string|array|null $value, string $name): ?string
118    {
119        if ($operation === 0) {
120            if (isset($this->tables[$table][$id])) {
121                return 'unique';
122            }
123            $this->tables[$table][$id] = ['id' => $id, 'value' => $value];
124        } elseif ($operation === 1 && isset($this->tables[$table][$id])) {
125            $this->tables[$table][$id]['value'] = $value;
126        } elseif ($operation === 2) {
127            unset($this->tables[$table][$id]);
128        } elseif ($operation === 9) {
129            $this->tables['temporary'] ??= [];
130        } elseif ($operation === 10) {
131            unset($this->tables['temporary']);
132        } elseif ($operation === 11 || $operation === 12) {
133            return $operation === 11 ? 'unique' : 'not-null';
134        } elseif ($operation >= 3 && $operation <= 8) {
135            $this->modelTransaction($operation, $name);
136        }
137        return null;
138    }
139
140    /**
141     * Apply transaction and savepoint semantics to model checkpoints.
142     */
143    public function modelTransaction(int $operation, string $name): void
144    {
145        if ($operation === 3 && $this->checkpoints === []) {
146            $this->checkpoints[] = ['name' => null, 'tables' => $this->tables];
147        } elseif ($operation === 4) {
148            $this->checkpoints = [];
149        } elseif ($operation === 5 && $this->checkpoints !== []) {
150            $this->tables = $this->checkpoints[0]['tables'];
151            $this->checkpoints = [];
152        } elseif ($operation >= 6) {
153            $position = array_search($name, array_column($this->checkpoints, 'name'), true);
154            if ($position !== false) {
155                if ($operation === 7) {
156                    $this->tables = $this->checkpoints[$position]['tables'];
157                }
158                $this->checkpoints = array_slice($this->checkpoints, 0, $position + ($operation === 7 ? 1 : 0));
159            }
160            if ($operation === 6) {
161                $this->checkpoints[] = ['name' => $name, 'tables' => $this->tables];
162            }
163        }
164    }
165    /**
166     * Decode actions for two sessions and verify both after every operation.
167     * @throws Error
168     */
169    public function __invoke(string $input): void
170    {
171        $executors = [new self(), new self()];
172        foreach (str_split(($input === '' ? "\0" : substr($input, 0, 512)), 4) as $step => $chunk) {
173            $chunk = str_pad($chunk, 4, "\0");
174            $choice = ord($chunk[0]);
175            $executor = $choice % 2;
176            $operation = intdiv($choice, 2) % 13;
177            $table = (ord($chunk[1]) % 2) === 0 ? 'items' : 'other';
178            $id = (ord($chunk[2]) % 8) + 1;
179            $valueChoice = ord($chunk[3]);
180            $value = [$valueChoice, (string) $valueChoice, null, '', "O'Brien", ['opaque' => $valueChoice]][$valueChoice % 6];
181            $name = 'point_' . ($valueChoice % 3);
182            $expected = $executors[$executor]->modelApply($operation, $table, $id, $value, $name);
183            $failure = null;
184            try {
185                $executors[$executor]->apply($operation, $table, $id, $value, $name);
186            } catch (DatabaseException $error) {
187                $failure = $error;
188            }
189            if ($expected !== null) {
190                $cause = $failure?->getPrevious();
191                if (!(($expected === 'unique' && $cause instanceof DuplicateKeyException)
192                    || ($expected === 'not-null' && $cause instanceof NotNullViolationException))) {
193                    throw new Error('Wrong rejection for ' . $expected, 0, $failure);
194                }
195            } elseif ($failure !== null) {
196                throw new Error('Valid core operation was rejected.', 0, $failure);
197            }
198            foreach ($executors as $actual) {
199                $actual->verify();
200            }
201        }
202    }
203
204    /**
205     * Compare both row and catalog state, including after rejected batches.
206     * @throws Error When the core differs from the independent model
207     */
208    public function verify(): void
209    {
210        $expected = array_map(array_values(...), $this->tables);
211        if ($expected !== $this->store->getAll()
212            || array_keys($expected) !== array_keys($this->registry->getAll())) {
213            throw new Error('Core rows or catalog differ from the independent model.');
214        }
215        foreach (array_keys($expected) as $table) {
216            if ($this->executor->session()->tableDefinition($table)?->columns !== ['id', 'value']) {
217                throw new Error('Rollback lost the column definitions.');
218            }
219        }
220        if ($this->connection->queries !== []) {
221            throw new Error('A core state operation touched the physical connection.');
222        }
223    }
224}
225