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