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