packages/sql-catalog/tests/Unit/Core/Php/ProgramIndexTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Core\Php;
6
7use PhpParser\Node\Scalar\String_;
8use PHPUnit\Framework\Attributes\CoversClass;
9use PHPUnit\Framework\Attributes\UsesClass;
10use PHPUnit\Framework\TestCase;
11use SqlCatalog\Core\Php\ClassShape;
12use SqlCatalog\Core\Php\FunctionShape;
13use SqlCatalog\Core\Php\MethodShape;
14use SqlCatalog\Core\Php\ParsedFile;
15use SqlCatalog\Core\Php\ProgramIndex;
16use SqlCatalog\Core\Php\ProgramIndexBuilder;
17use SqlCatalog\Core\Php\SourceParser;
18use SqlCatalog\Core\Php\TypeReader;
19use SqlCatalog\Core\Type\TypeShape;
20
21#[CoversClass(ProgramIndex::class)]
22#[UsesClass(ClassShape::class)]
23#[UsesClass(FunctionShape::class)]
24#[UsesClass(MethodShape::class)]
25#[UsesClass(ParsedFile::class)]
26#[UsesClass(ProgramIndexBuilder::class)]
27#[UsesClass(SourceParser::class)]
28#[UsesClass(TypeReader::class)]
29#[UsesClass(TypeShape::class)]
30#[UsesClass(\SqlCatalog\Core\Php\ParameterShape::class)]
31final class ProgramIndexTest extends TestCase
32{
33 public function testFindClassIgnoresCaseAndLeadingBackslashes(): void
34 {
35 $index = new ProgramIndex(['app\\user' => new ClassShape('App\\User', null, [], [], false, [], [], [], [])]);
36 self::assertSame('App\\User', $index->findClass('\\APP\\User')?->name);
37 self::assertNull($index->findClass(null));
38 self::assertNull($index->findClass('App\\Missing'));
39 }
40
41 public function testFindFunctionIgnoresCase(): void
42 {
43 $index = new ProgramIndex([], ['app\\find' => new FunctionShape('App\\find', [], TypeShape::unknown())]);
44 self::assertSame('App\\find', $index->findFunction('App\\Find')?->name);
45 self::assertNull($index->findFunction('App\\missing'));
46 }
47
48 public function testFindConstantReadsTheDeclaredExpression(): void
49 {
50 $value = new String_('users');
51 $index = new ProgramIndex([], [], ['App\\TABLE' => $value]);
52 self::assertSame($value, $index->findConstant('\\App\\TABLE'));
53 self::assertNull($index->findConstant('App\\MISSING'));
54 }
55
56 public function testFindMethodFollowsTheParentClass(): void
57 {
58 $index = (new ProgramIndexBuilder())->build([(new SourceParser())->parse(
59 'a.php',
60 '<?php abstract class Base { public function run(): string { return "x"; } } class Child extends Base {}',
61 )]);
62 self::assertSame('Base::run', $index->findMethod('Child', 'run')?->qualifiedName());
63 self::assertNull($index->findMethod('Child', 'missing'));
64 }
65
66 public function testFindClassConstantFollowsTheParentClass(): void
67 {
68 $index = (new ProgramIndexBuilder())->build([(new SourceParser())->parse(
69 'a.php',
70 '<?php class Base { public const TABLE = "users"; } class Child extends Base {}',
71 )]);
72 self::assertInstanceOf(String_::class, $index->findClassConstant('Child', 'TABLE'));
73 self::assertNull($index->findClassConstant('Child', 'MISSING'));
74 }
75
76 public function testFindPropertyTypeFollowsTheParentClass(): void
77 {
78 $index = (new ProgramIndexBuilder())->build([(new SourceParser())->parse(
79 'a.php',
80 '<?php class Base { protected string $order = "id"; } class Child extends Base {}',
81 )]);
82 self::assertSame('string', $index->findPropertyType('Child', 'order')?->display());
83 self::assertNull($index->findPropertyType('Child', 'missing'));
84 }
85
86 public function testIsInstanceOfFollowsParentsAndInterfaces(): void
87 {
88 $index = (new ProgramIndexBuilder())->build([(new SourceParser())->parse(
89 'a.php',
90 '<?php interface Runs {} class Base implements Runs {} class Child extends Base {}',
91 )]);
92 self::assertTrue($index->isInstanceOf('Child', 'Base'));
93 self::assertTrue($index->isInstanceOf('Child', '\\Runs'));
94 self::assertTrue($index->isInstanceOf('Child', 'Child'));
95 self::assertFalse($index->isInstanceOf('Child', 'Other'));
96 self::assertFalse($index->isInstanceOf(null, 'Base'));
97 }
98
99 public function testLineageVisitsEachDeclarationOnce(): void
100 {
101 $index = (new ProgramIndexBuilder())->build([(new SourceParser())->parse(
102 'a.php',
103 '<?php interface Runs {} class Base implements Runs {} class Child extends Base implements Runs {}',
104 )]);
105 self::assertSame(['Child', 'Base', 'Runs'], array_map(
106 static fn (ClassShape $shape): string => $shape->name,
107 $index->lineage('Child'),
108 ));
109 self::assertSame([], $index->lineage('Missing'));
110 }
111
112 public function testImplementationsOfCollectsTheConcreteBodies(): void
113 {
114 $index = (new ProgramIndexBuilder())->build([(new SourceParser())->parse(
115 'a.php',
116 '<?php abstract class Base { abstract public function table(): string; }'
117 . ' class Users extends Base { public function table(): string { return "users"; } }'
118 . ' class Orders extends Base { public function table(): string { return "orders"; } }',
119 )]);
120 self::assertCount(2, $index->implementationsOf('Base', 'table'));
121 self::assertSame([], $index->implementationsOf(null, 'table'));
122 self::assertSame([], $index->implementationsOf('Base', 'table', 1));
123 }
124
125 public function testMergePrefersTheDeclarationsOfThisIndex(): void
126 {
127 $left = new ProgramIndex(['a' => new ClassShape('A', null, [], [], false, [], [], [], [])]);
128 $right = new ProgramIndex([
129 'a' => new ClassShape('Other', null, [], [], false, [], [], [], []),
130 'b' => new ClassShape('B', null, [], [], false, [], [], [], []),
131 ]);
132 $merged = $left->merge($right);
133 self::assertSame('A', $merged->findClass('A')?->name);
134 self::assertSame('B', $merged->findClass('B')?->name);
135 }
136}
137