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