packages/sql-faker/tests/Unit/Generation/Plan/GenerationPlanTest.php

1<?php
2
3declare (strict_types=1);
4
5namespace Tests\Unit\SqlFaker\Generation\Plan;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\DataProvider;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use SqlFaker\Generation\Plan\GenerationPlan;
12use SqlFaker\Generation\Plan\ProductionPattern;
13
14#[CoversClass(GenerationPlan::class)]
15#[UsesClass(ProductionPattern::class)]
16final class GenerationPlanTest extends TestCase
17{
18    public function testAllCoversTheGrammarWithoutProductionConstraints(): void
19    {
20        $plan = GenerationPlan::all();
21        self::assertNull($plan->startRule());
22        self::assertNull($plan->patternAt('statement', 0));
23        self::assertSame(PHP_INT_MAX, $plan->maxDepth());
24    }
25
26    public function testFromRuleRestrictsTheGenerationRangeWithoutDirectingProductions(): void
27    {
28        $plan = GenerationPlan::fromRule('select_statement');
29        self::assertSame('select_statement', $plan->startRule());
30        self::assertNull($plan->patternAt('select_statement', 0));
31    }
32
33    public function testConstrainedDirectsEachRuleOccurrence(): void
34    {
35        $first = ProductionPattern::containing('CONSTRAINT');
36        $second = ProductionPattern::containing('FOREIGN', 'KEY');
37        $plan = GenerationPlan::constrained('create_table', ['constraint' => [$first, $second]]);
38        self::assertSame('create_table', $plan->startRule());
39        self::assertSame($first, $plan->patternAt('constraint', 0));
40        self::assertSame($second, $plan->patternAt('constraint', 1));
41        self::assertNull($plan->patternAt('constraint', 2));
42        self::assertNull($plan->patternAt('unknown', 0));
43    }
44
45    public function testWithPatternForEveryOccurrenceProducesANewPlanAndActsAsFallback(): void
46    {
47        $specific = ProductionPattern::exactly();
48        $recurring = ProductionPattern::nonEmpty();
49        $plan = GenerationPlan::constrained('insert', ['opt_values' => [$specific]]);
50        $directed = $plan->withPatternForEveryOccurrence('opt_values', $recurring);
51        self::assertNotSame($plan, $directed);
52        self::assertNull($plan->patternAt('opt_values', 1));
53        self::assertSame($specific, $directed->patternAt('opt_values', 0));
54        self::assertSame($recurring, $directed->patternAt('opt_values', 1));
55        self::assertSame($recurring, $directed->patternAt('opt_values', 100));
56        self::assertNull($directed->patternAt('unknown', 0));
57    }
58
59    public function testWithPatternForEveryOccurrenceAccumulatesAcrossRules(): void
60    {
61        $values = ProductionPattern::nonEmpty();
62        $columns = ProductionPattern::containing('IDENT');
63        $plan = GenerationPlan::all()->withPatternForEveryOccurrence('opt_values', $values)->withPatternForEveryOccurrence('opt_columns', $columns);
64        self::assertSame($values, $plan->patternAt('opt_values', 100));
65        self::assertSame($columns, $plan->patternAt('opt_columns', 100));
66    }
67
68    public function testRequiringNonEmptyProducesANewPlan(): void
69    {
70        $plan = GenerationPlan::fromRule('statement');
71        $required = $plan->requiringNonEmpty();
72        self::assertNotSame($plan, $required);
73        self::assertSame('statement', $required->startRule());
74    }
75
76    public function testRequiresNonEmptyAnswersWhatThePlanWasBuiltWith(): void
77    {
78        /** @param GenerationPlan<bool> $plan */
79        $requiresNonEmpty = static fn (GenerationPlan $plan): bool => $plan->requiresNonEmpty();
80        self::assertFalse($requiresNonEmpty(GenerationPlan::all()));
81        self::assertFalse($requiresNonEmpty(GenerationPlan::fromRule('statement')));
82        self::assertFalse($requiresNonEmpty(GenerationPlan::constrained('statement', ['statement' => [ProductionPattern::nonEmpty()]])));
83        self::assertTrue($requiresNonEmpty(GenerationPlan::all()->requiringNonEmpty()));
84    }
85
86    public function testWithMaxDepthProducesANewPlanAndNormalizesItsLowerBound(): void
87    {
88        $plan = GenerationPlan::fromRule('statement');
89        $limited = $plan->withMaxDepth(5);
90        $minimum = $plan->withMaxDepth(0);
91        self::assertNotSame($plan, $limited);
92        self::assertSame(PHP_INT_MAX, $plan->maxDepth());
93        self::assertSame(5, $limited->maxDepth());
94        self::assertSame(1, $minimum->maxDepth());
95    }
96
97    public function testWithLexemesDirectsEachTerminalOccurrenceWithoutMutableState(): void
98    {
99        $plan = GenerationPlan::fromRule('statement')->withLexemes(['operator' => ['@@', '?|']]);
100        self::assertSame('@@', $plan->lexemeAt('operator', 0));
101        self::assertSame('?|', $plan->lexemeAt('operator', 1));
102        self::assertNull($plan->lexemeAt('operator', 2));
103        self::assertNull($plan->lexemeAt('unknown', 0));
104    }
105
106    public function testLexicalSelectsOneTargetWithParameters(): void
107    {
108        $plan = GenerationPlan::lexical('quoted_identifier', ['minLength' => 2, 'maxLength' => 8]);
109        self::assertNull($plan->startRule());
110        self::assertSame('quoted_identifier', $plan->lexicalTarget());
111        self::assertSame(['minLength' => 2, 'maxLength' => 8], $plan->parameters());
112    }
113
114    public function testStartRuleAnswersNothingWhenTheWalkBeginsAtTheGrammarEntryPoint(): void
115    {
116        self::assertNull(GenerationPlan::all()->startRule());
117    }
118
119    public function testPatternAtPrefersTheOccurrenceNamedDirectly(): void
120    {
121        $named = ProductionPattern::containing('CONSTRAINT');
122        $fallback = ProductionPattern::nonEmpty();
123        $plan = GenerationPlan::constrained('create_table', ['constraint' => [$named]])->withPatternForEveryOccurrence('constraint', $fallback);
124        self::assertSame($named, $plan->patternAt('constraint', 0));
125        self::assertSame($fallback, $plan->patternAt('constraint', 1));
126    }
127
128    public function testLexemeAtAnswersNothingForATerminalThePlanDoesNotDirect(): void
129    {
130        self::assertNull(GenerationPlan::all()->lexemeAt('IDENT', 0));
131    }
132
133    public function testLexicalTargetAnswersNothingWhenTheGrammarIsWalked(): void
134    {
135        self::assertNull(GenerationPlan::all()->lexicalTarget());
136    }
137
138    public function testParametersAnswerNothingWhenTheGrammarIsWalked(): void
139    {
140        self::assertSame([], GenerationPlan::all()->parameters());
141    }
142
143    public function testMaxDepthIsUnboundedUntilTheCallerBoundsIt(): void
144    {
145        self::assertSame(PHP_INT_MAX, GenerationPlan::all()->maxDepth());
146    }
147
148    public function testWithStepBudgetPreservesPolicyAcrossEveryRefinement(): void
149    {
150        $original = GenerationPlan::fromRule('stmt');
151        $bounded = $original->withStepBudget()->requiringNonEmpty()->withLexemes(['TOKEN' => ['literal']])->withMaxDepth(2)->withPatternForEveryOccurrence('stmt', ProductionPattern::exactly('TOKEN'));
152        self::assertFalse($original->usesStepBudget());
153        self::assertTrue($bounded->usesStepBudget());
154        self::assertSame('stmt', $bounded->startRule());
155        self::assertSame('literal', $bounded->lexemeAt('TOKEN', 0));
156        self::assertSame(2, $bounded->maxDepth());
157        self::assertNotNull($bounded->patternAt('stmt', 0));
158    }
159
160    public function testUsesStepBudgetDefaultsToFalse(): void
161    {
162        self::assertFalse(GenerationPlan::all()->usesStepBudget());
163        self::assertFalse(GenerationPlan::lexical('identifier', [])->usesStepBudget());
164    }
165
166    public function testStatementBoundsTheWalkAtTheRuleItIsGrownFrom(): void
167    {
168        $plan = GenerationPlan::statement('select_stmt', 12);
169        self::assertSame('select_stmt', $plan->startRule());
170        self::assertSame(12, $plan->maxDepth());
171    }
172
173    public function testStatementWalksTheWholeGrammarWhenNoRuleIsNamed(): void
174    {
175        self::assertNull(GenerationPlan::statement(null, 12)->startRule());
176    }
177
178    public function testWithExpansionBudgetPreservesTheOtherConstraints(): void
179    {
180        $base = GenerationPlan::all()->withMaxDepth(7);
181        $plan = $base->withExpansionBudget(300)->requiringNonEmpty()->withCandidateKeys(['T' => ['candidate']]);
182        self::assertNull($base->expansionBudget());
183        self::assertSame(300, $plan->expansionBudget());
184        self::assertSame(7, $plan->maxDepth());
185    }
186
187    /**
188     * @return list<array{GenerationPlan<bool>}>
189     */
190
191    public static function providerRefinedPlans(): array
192    {
193        $base = GenerationPlan::constrained('stmt', ['stmt' => [ProductionPattern::at(1)]])->withLexemes(['T' => ['name']])->withCandidateKeys(['T' => ['candidate']]);
194        return [
195            [$base->requiringNonEmpty()],
196            [$base->withMaxDepth(7)],
197            [$base->withExpansionBudget(9)],
198            [$base->withStepBudget()],
199            [$base->withLexemes(['T' => ['name']])],
200            [$base->withPatternForEveryOccurrence('tail', ProductionPattern::exactly())],
201        ];
202    }
203
204    /**
205     * @param GenerationPlan<bool> $plan
206     */
207    #[DataProvider('providerRefinedPlans')]
208
209    public function testWithCandidateKeysPreservesExplicitInstructionsAcrossRefinements(GenerationPlan $plan): void
210    {
211        self::assertSame('stmt', $plan->startRule());
212        self::assertEquals(ProductionPattern::at(1), $plan->patternAt('stmt', 0));
213        self::assertSame('name', $plan->lexemeAt('T', 0));
214        self::assertSame('candidate', $plan->candidateKeyAt('T', 0));
215    }
216
217    public function testCandidateKeyAtDistinguishesPinnedAndUnspecifiedOccurrences(): void
218    {
219        self::assertNull(GenerationPlan::all()->candidateKeyAt('T', 0));
220        $plan = GenerationPlan::all()->withCandidateKeys(['T' => ['one', 'two']]);
221        self::assertSame('one', $plan->candidateKeyAt('T', 0));
222        self::assertSame('two', $plan->candidateKeyAt('T', 1));
223        self::assertNull($plan->candidateKeyAt('T', 2));
224        self::assertNull($plan->candidateKeyAt('U', 0));
225    }
226
227    public function testExpansionBudgetIsOptionalForExistingPlans(): void
228    {
229        self::assertNull(GenerationPlan::all()->expansionBudget());
230    }
231
232    public function testHasRemainingPatternsRetainsRecurringConstraintsAfterExplicitOccurrences(): void
233    {
234        $plan = GenerationPlan::constrained('root', ['leaf' => [ProductionPattern::at(0), ProductionPattern::at(1)]]);
235        self::assertTrue($plan->hasRemainingPatterns([]));
236        self::assertTrue($plan->hasRemainingPatterns(['leaf' => 1]));
237        self::assertFalse($plan->hasRemainingPatterns(['leaf' => 2]));
238        self::assertTrue($plan->withPatternForEveryOccurrence('leaf', ProductionPattern::at(0))->hasRemainingPatterns(['leaf' => 2]));
239        self::assertFalse(GenerationPlan::all()->hasRemainingPatterns([]));
240    }
241
242    public function testPatternStateCapsOnlyEquivalentFutureOccurrenceCounters(): void
243    {
244        $plan = GenerationPlan::constrained('root', ['leaf' => [ProductionPattern::at(0), ProductionPattern::at(1)]])->withPatternForEveryOccurrence('other', ProductionPattern::at(0));
245        self::assertSame(['leaf' => 0, 'other' => 0], $plan->patternState([]));
246        self::assertSame(['leaf' => 1, 'other' => 0], $plan->patternState(['leaf' => 1, 'other' => 8, 'unused' => 4]));
247        self::assertSame(['leaf' => 2, 'other' => 0], $plan->patternState(['leaf' => 8]));
248    }
249}
250