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