packages/ztd-query-postgres/fuzz/RewriteCheck.php
1<?php
2
3declare(strict_types=1);
4
5namespace Fuzz;
6
7use Error;
8use Fuzz\Input\TruncateTargets;
9use ZtdQuery\Exception\UnknownSchemaException;
10use ZtdQuery\Exception\UnsupportedSqlException;
11use ZtdQuery\Platform\Postgres\Rewrite\PgSqlQueryGuard;
12use ZtdQuery\Platform\Postgres\Rewrite\PgSqlRewriter;
13use ZtdQuery\Platform\Postgres\Rewrite\Transformer\DeleteTransformer;
14use ZtdQuery\Platform\Postgres\Rewrite\Transformer\InsertTransformer;
15use ZtdQuery\Platform\Postgres\Rewrite\Transformer\PgSqlTransformer;
16use ZtdQuery\Platform\Postgres\Rewrite\Transformer\SelectTransformer;
17use ZtdQuery\Platform\Postgres\Rewrite\Transformer\UpdateTransformer;
18use ZtdQuery\Platform\Postgres\Schema\PgSqlSchemaParser;
19use ZtdQuery\Platform\Postgres\Shadow\PgSqlMutationResolver;
20use ZtdQuery\Platform\Postgres\Sql\PgSqlIdentifierQuoter;
21use ZtdQuery\Platform\Postgres\Sql\PgSqlParser;
22use ZtdQuery\Platform\Postgres\Sql\Value\PgSqlCastRenderer;
23use ZtdQuery\Rewrite\QueryKind;
24use ZtdQuery\Schema\Key\PartialUniqueIndex;
25use ZtdQuery\Schema\TableDefinitionRegistry;
26use ZtdQuery\Shadow\Mutation\Table\MultiTruncateMutation;
27use ZtdQuery\Shadow\ShadowStore;
28
29/**
30 * Checks one rewrite against its routing and mutation contracts with isolated state.
31 */
32final class RewriteCheck
33{
34 /**
35 * Accepts only documented unsupported SQL and missing-schema rejections.
36 *
37 * SQLFaker guarantees grammar generation, not names or result rows for this catalog.
38 * The full target supplies an empty result set to exercise zero-row mutation handling;
39 * it does not assert equivalence with PostgreSQL execution.
40 *
41 * @throws Error When an accepted rewrite violates its public plan contract
42 */
43 public function verify(string $sql, bool $applyMutation): void
44 {
45 $store = new ShadowStore();
46 $registry = new TableDefinitionRegistry();
47 $schemaParser = new PgSqlSchemaParser();
48 $schemas = ['users' => 'CREATE TABLE users (id INTEGER PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255), status VARCHAR(50))', 'orders' => 'CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, amount NUMERIC(10,2), created_at TIMESTAMP)', 'order_items' => 'CREATE TABLE order_items (order_id INTEGER NOT NULL, product_id INTEGER NOT NULL, quantity INTEGER NOT NULL DEFAULT 1, PRIMARY KEY (order_id, product_id))', 'products' => 'CREATE TABLE products (id INTEGER PRIMARY KEY, name VARCHAR(255) NOT NULL, price NUMERIC(10,2), category VARCHAR(100))', 'logs' => 'CREATE TABLE logs (id INTEGER NOT NULL, log_date DATE NOT NULL, level TEXT NOT NULL, PRIMARY KEY (id, log_date)) PARTITION BY RANGE (log_date)', 'contacts' => 'CREATE TABLE contacts (id INTEGER PRIMARY KEY, age "ztd_fuzz"."positive_int", satisfaction "ztd_fuzz"."percentage")'];
49 foreach ($schemas as $tableName => $createSql) {
50 $definition = $schemaParser->parse($createSql);
51 if ($definition !== null) {
52 if ($tableName === 'users') {
53 $definition = $definition->withPartialUniqueIndex(new PartialUniqueIndex('users_active_email', ['email'], "status = 'active'"));
54 }
55 $registry->register($tableName, $definition);
56 }
57 }
58 $store->set('users', [['id' => '1', 'name' => 'Alice', 'email' => 'alice@example.com', 'status' => 'active'], ['id' => '2', 'name' => 'Bob', 'email' => 'bob@example.com', 'status' => 'pending'], ['id' => '3', 'name' => 'Charlie', 'email' => null, 'status' => 'active']]);
59 $store->set('orders', [['id' => '1', 'user_id' => '1', 'amount' => '100.00', 'created_at' => '2024-01-01 00:00:00'], ['id' => '2', 'user_id' => '2', 'amount' => '250.50', 'created_at' => '2024-01-02 12:30:00']]);
60 $store->set('order_items', [['order_id' => '1', 'product_id' => '1', 'quantity' => '2'], ['order_id' => '1', 'product_id' => '2', 'quantity' => '1'], ['order_id' => '2', 'product_id' => '1', 'quantity' => '3']]);
61 $store->set('products', [['id' => '1', 'name' => 'Widget', 'price' => '19.99', 'category' => 'tools'], ['id' => '2', 'name' => 'Gadget', 'price' => '49.99', 'category' => 'electronics']]);
62 $store->set('logs', [['id' => '1', 'log_date' => '2024-05-01', 'level' => 'INFO'], ['id' => '2', 'log_date' => '2025-05-01', 'level' => 'WARN']]);
63 $store->set('contacts', [['id' => '1', 'age' => '30', 'satisfaction' => '85.50']]);
64 $parser = new PgSqlParser();
65 $guard = new PgSqlQueryGuard($parser);
66 $castRenderer = new PgSqlCastRenderer();
67 $quoter = new PgSqlIdentifierQuoter();
68 $selectTransformer = new SelectTransformer($castRenderer, $quoter);
69 $insertTransformer = new InsertTransformer($parser, $selectTransformer);
70 $updateTransformer = new UpdateTransformer($parser, $selectTransformer);
71 $deleteTransformer = new DeleteTransformer($parser, $selectTransformer);
72 $transformer = new PgSqlTransformer($parser, $selectTransformer, $insertTransformer, $updateTransformer, $deleteTransformer);
73 $schemaParser = new PgSqlSchemaParser();
74 $mutationResolver = new PgSqlMutationResolver($store, $registry, $schemaParser, $parser);
75 $rewriter = new PgSqlRewriter($guard, $store, $registry, $transformer, $mutationResolver, $parser);
76 $kind = (new PgSqlQueryGuard(new PgSqlParser()))->classify($sql);
77 try {
78 $plan = $rewriter->rewrite($sql);
79 } catch (UnsupportedSqlException|UnknownSchemaException) {
80 return;
81 }
82 if ($plan->kind() !== $kind || $plan->sql() === '') {
83 throw new Error('The accepted rewrite must preserve classification and return nonempty SQL.');
84 }
85 $mutation = $plan->mutation();
86 $writes = $kind === QueryKind::WRITE_SIMULATED || $kind === QueryKind::DDL_SIMULATED;
87 if ($writes !== ($mutation !== null)) {
88 throw new Error('Only a simulated write or DDL plan may own a mutation.');
89 }
90 $targets = (new TruncateTargets())->targetCount($sql);
91 if ($targets !== null && $targets > 1 && (!$mutation instanceof MultiTruncateMutation || count($mutation->tableNames()) !== $targets)) {
92 throw new Error('TRUNCATE must retain every explicit target in its mutation.');
93 }
94 if ($applyMutation && $mutation !== null) {
95 $mutation->apply($store, []);
96 $rewriter->commitRewriteState();
97 if (array_key_exists('', $store->getAll())) {
98 throw new Error('Applying a mutation must not introduce an empty table name.');
99 }
100 }
101 }
102}
103