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

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