packages/sql-catalog/tests/Unit/Extension/Laravel/ModelMetadataTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Extension\Laravel;
6
7use PHPUnit\Framework\Attributes\CoversClass;
8use PHPUnit\Framework\Attributes\UsesClass;
9use PHPUnit\Framework\TestCase;
10use SqlCatalog\Core\Evaluation\ArrayEntry;
11use SqlCatalog\Core\Evaluation\ArrayTerm;
12use SqlCatalog\Core\Evaluation\Domain;
13use SqlCatalog\Core\Evaluation\LiteralTerm;
14use SqlCatalog\Core\Evaluation\ObjectTerm;
15use SqlCatalog\Core\Evaluation\OpaqueTerm;
16use SqlCatalog\Core\Evaluation\PatternTerm;
17use SqlCatalog\Core\Php\ClassShape;
18use SqlCatalog\Core\Php\MethodShape;
19use SqlCatalog\Core\Php\ParameterShape;
20use SqlCatalog\Core\Php\ParsedFile;
21use SqlCatalog\Core\Php\ProgramIndex;
22use SqlCatalog\Core\Php\ProgramIndexBuilder;
23use SqlCatalog\Core\Php\SourceParser;
24use SqlCatalog\Core\Php\TypeReader;
25use SqlCatalog\Core\Text\LiteralText;
26use SqlCatalog\Core\Text\TextGeneralization;
27use SqlCatalog\Core\Text\TextHole;
28use SqlCatalog\Core\Text\TextPattern;
29use SqlCatalog\Core\Type\TypeShape;
30use SqlCatalog\Extension\Laravel\ModelMetadata;
31use SqlCatalog\Extension\Laravel\QueryState;
32
33#[CoversClass(ModelMetadata::class)]
34#[UsesClass(Domain::class)]
35#[UsesClass(ArrayTerm::class)]
36#[UsesClass(ArrayEntry::class)]
37#[UsesClass(ObjectTerm::class)]
38#[UsesClass(LiteralTerm::class)]
39#[UsesClass(OpaqueTerm::class)]
40#[UsesClass(PatternTerm::class)]
41#[UsesClass(TextPattern::class)]
42#[UsesClass(TextHole::class)]
43#[UsesClass(LiteralText::class)]
44#[UsesClass(TextGeneralization::class)]
45#[UsesClass(TypeShape::class)]
46#[UsesClass(QueryState::class)]
47#[UsesClass(ProgramIndex::class)]
48#[UsesClass(ProgramIndexBuilder::class)]
49#[UsesClass(SourceParser::class)]
50#[UsesClass(ClassShape::class)]
51#[UsesClass(MethodShape::class)]
52#[UsesClass(ParameterShape::class)]
53#[UsesClass(ParsedFile::class)]
54#[UsesClass(TypeReader::class)]
55final class ModelMetadataTest extends TestCase
56{
57 public function testStateReadsInheritedTableKeyAndSoftDeletes(): void
58 {
59 $file = (new SourceParser())->parse('model.php', '<?php class Base extends \\Illuminate\\Database\\Eloquent\\Model { protected $table = "people"; protected $primaryKey = "uuid"; public $timestamps = false; } class User extends Base { use \\Illuminate\\Database\\Eloquent\\SoftDeletes; const DELETED_AT = "removed_at"; }');
60 $model = new ModelMetadata((new ProgramIndexBuilder())->build([$file]));
61 $state = $model->state('User', 'pgsql');
62 self::assertSame('people', $state->string('table'));
63 self::assertSame('people.uuid', $state->string('key'));
64 self::assertSame('removed_at', $state->string('deletedColumn'));
65 self::assertTrue($state->get('softDeletes')->soleLiteral()?->value);
66 self::assertFalse($state->get('timestamps')->soleLiteral()?->value);
67 self::assertSame(15, $state->get('perPage')->soleLiteral()?->value);
68 self::assertSame('int', $state->string('keyType'));
69 self::assertArrayNotHasKey('problem', $state->fields);
70 $tenant = (new SourceParser())->parse('tenant.php', '<?php class Tenant extends \\Illuminate\\Database\\Eloquent\\Model { protected $table = "tenants"; protected $perPage = 7; protected $keyType = "string"; }');
71 $state = (new ModelMetadata((new ProgramIndexBuilder())->build([$tenant])))->state('Tenant', 'pgsql');
72 self::assertSame(7, $state->get('perPage')->soleLiteral()?->value);
73 self::assertSame('string', $state->string('keyType'));
74 }
75
76 #[\PHPUnit\Framework\Attributes\DataProvider('providerGuardLeavesHooksTraitsAndMutableMetadataOpen')]
77 public function testGuardLeavesHooksTraitsAndMutableMetadataOpen(string $body): void
78 {
79 $file = (new SourceParser())->parse('model.php', '<?php class User extends \\Illuminate\\Database\\Eloquent\\Model { '.$body.' }');
80 $model = new ModelMetadata((new ProgramIndexBuilder())->build([$file]));
81 self::assertFalse($model->guard('User', new QueryState())->get('problem')->isExact());
82 }
83
84 /**
85 * @return iterable<array{string}>
86 */
87 public static function providerGuardLeavesHooksTraitsAndMutableMetadataOpen(): iterable
88 {
89 foreach (['protected static function booted() {}', 'use AppTrait;', 'public function rename() { $this->table = "other"; }', 'public function where() {}'] as $body) {
90 yield [$body];
91 }
92 }
93
94 #[\PHPUnit\Framework\Attributes\DataProvider('providerGuardDefaultsRejectsUnknownMetadataAndImplicitEagerLoads')]
95 public function testGuardDefaultsRejectsUnknownMetadataAndImplicitEagerLoads(string $body): void
96 {
97 $file = (new SourceParser())->parse('model.php', '<?php class User extends \\Illuminate\\Database\\Eloquent\\Model { '.$body.' }');
98 $index = (new ProgramIndexBuilder())->build([$file]);
99 $shape = $index->findClass('User');
100 self::assertNotNull($shape);
101 self::assertFalse((new ModelMetadata($index))->guardDefaults($shape, new QueryState())->get('problem')->isExact());
102 }
103
104 /**
105 * @return iterable<array{string}>
106 */
107 public static function providerGuardDefaultsRejectsUnknownMetadataAndImplicitEagerLoads(): iterable
108 {
109 foreach (['protected $table = UNKNOWN_TABLE;', 'protected $with = ["posts"];', 'protected $withCount = ["posts"];', 'const DELETED_AT = UNKNOWN_COLUMN;'] as $body) {
110 yield [$body];
111 }
112 }
113
114 public function testStateRejectsCustomizingAttributesAndUnknownTableConventions(): void
115 {
116 $file = (new SourceParser())->parse('model.php', '<?php #[CustomBuilder] class User extends \\Illuminate\\Database\\Eloquent\\Model {} class Fish extends \\Illuminate\\Database\\Eloquent\\Model {}');
117 $model = new ModelMetadata((new ProgramIndexBuilder())->build([$file]));
118 self::assertFalse($model->state('User', 'sqlite')->get('problem')->isExact());
119 self::assertFalse($model->state('Fish', 'sqlite')->get('table')->isExact());
120 }
121
122 public function testRecognizesFrameworkAuthenticationModelsWithoutLoadingVendorCode(): void
123 {
124 $file = (new SourceParser())->parse('model.php', '<?php class User extends \\Illuminate\\Foundation\\Auth\\User {}');
125 $model = new ModelMetadata((new ProgramIndexBuilder())->build([$file]));
126 self::assertTrue($model->recognizes('User'));
127 self::assertFalse($model->recognizes('Other'));
128 self::assertFalse($model->recognizes(null));
129 }
130
131 public function testPropertyPrefersTheNearestDeclaration(): void
132 {
133 $file = (new SourceParser())->parse('model.php', '<?php class Base { protected $table = "old"; } class User extends Base { protected $table = "users"; }');
134 $model = new ModelMetadata((new ProgramIndexBuilder())->build([$file]));
135 self::assertSame('users', $model->property('User', 'table'));
136 self::assertNull($model->property('User', 'missing'));
137 }
138
139 public function testConstantReadsOnlyLiteralDefaults(): void
140 {
141 $file = (new SourceParser())->parse('model.php', '<?php class User { const COLUMN = "removed_at"; }');
142 $model = new ModelMetadata((new ProgramIndexBuilder())->build([$file]));
143 self::assertSame('removed_at', $model->constant('User', 'COLUMN'));
144 self::assertNull($model->constant('User', 'MISSING'));
145 }
146
147 public function testScalarDoesNotEvaluateApplicationCalls(): void
148 {
149 $model = new ModelMetadata(new ProgramIndex());
150 self::assertSame('users', $model->scalar(new \PhpParser\Node\Scalar\String_('users')));
151 self::assertNull($model->scalar(new \PhpParser\Node\Expr\FuncCall(new \PhpParser\Node\Name('tableName'))));
152 }
153
154 public function testConventionalTableUsesKnownEnglishNamesAndRequiresExplicitOtherInflections(): void
155 {
156 $model = new ModelMetadata(new ProgramIndex());
157 self::assertSame('admin_users', $model->conventionalTable('App\AdminUser'));
158 self::assertNull($model->conventionalTable('App\Person'));
159 }
160
161 #[\PHPUnit\Framework\Attributes\DataProvider('providerMetadataGaps')]
162 public function testGuardExplainsTheSpecificUnmodelledModelEffect(string $body, string $expected): void
163 {
164 $file = (new SourceParser())->parse('model.php', '<?php class User extends \\Illuminate\\Database\\Eloquent\\Model { '.$body.' }');
165 $model = new ModelMetadata((new ProgramIndexBuilder())->build([$file]));
166 $term = $model->state('User', 'sqlite')->get('problem')->terms[0];
167 self::assertInstanceOf(OpaqueTerm::class, $term);
168 self::assertSame($expected, $term->expression);
169 }
170
171 /**
172 * @return iterable<array{string, string}>
173 */
174 public static function providerMetadataGaps(): iterable
175 {
176 yield ['use CustomTrait;', 'Unmodelled Eloquent trait: CustomTrait'];
177 yield ['protected static function boot() {}', 'Unmodelled Eloquent override or boot hook: User::boot'];
178 yield ['public function rename() { $this->table = "other"; }', 'Mutable Eloquent metadata: table'];
179 yield ['protected $table = UNKNOWN_TABLE;', 'Unresolved Eloquent metadata: table'];
180 yield ['protected $primaryKey = [];', 'Invalid Eloquent metadata: primaryKey'];
181 }
182
183 public function testStateKeepsStandardEmptyDefaultsAndFactoryTraitsClosed(): void
184 {
185 $file = (new SourceParser())->parse('model.php', '<?php class User extends \\Illuminate\\Database\\Eloquent\\Model { use \\Illuminate\\Database\\Eloquent\\Factories\\HasFactory; protected $with = []; protected $withCount = []; protected $table = null; }');
186 $state = (new ModelMetadata((new ProgramIndexBuilder())->build([$file])))->state('User', 'sqlite');
187 self::assertArrayNotHasKey('problem', $state->fields);
188 self::assertSame('users', $state->string('table'));
189 self::assertSame('users.id', $state->string('key'));
190 }
191}
192