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