Skip to content

Commit 8e8ac40

Browse files
authored
Merge pull request #433 from GromNaN/class-locator
Introduce `ClassLocator` to find class names for attribute drivers
2 parents c392605 + 49858ad commit 8e8ac40

File tree

10 files changed

+436
-111
lines changed

10 files changed

+436
-111
lines changed

composer.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@
2727
"require-dev": {
2828
"phpstan/phpstan": "1.12.7",
2929
"phpstan/phpstan-phpunit": "^1",
30-
"phpstan/phpstan-strict-rules": "^1.1",
30+
"phpstan/phpstan-strict-rules": "^1.6",
3131
"doctrine/coding-standard": "^12",
3232
"phpunit/phpunit": "^9.6",
33-
"symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0"
33+
"symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0",
34+
"symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0"
3435
},
3536
"autoload": {
3637
"psr-4": {

phpstan.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ parameters:
1111
paths:
1212
- src
1313
- tests
14+
- tests/Persistence/Mapping/_files/colocated/Foo.mphp
1415

1516
excludePaths:
1617
- tests/Persistence/Mapping/_files/Doctrine.Tests.Persistence.Mapping.PHPTestEntity.php
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\Persistence\Mapping\Driver;
6+
7+
/**
8+
* ClassLocator is an interface for classes that can provide a list of class names.
9+
*/
10+
interface ClassLocator
11+
{
12+
/** @return list<class-string> */
13+
public function getClassNames(): array;
14+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\Persistence\Mapping\Driver;
6+
7+
/**
8+
* Basic implementation of ClassLocator that passes a list of class names.
9+
*/
10+
final class ClassNames implements ClassLocator
11+
{
12+
/** @param list<class-string> $classNames */
13+
public function __construct(
14+
private array $classNames,
15+
) {
16+
}
17+
18+
/** @return list<class-string> */
19+
public function getClassNames(): array
20+
{
21+
return $this->classNames;
22+
}
23+
}

src/Persistence/Mapping/Driver/ColocatedMappingDriver.php

Lines changed: 18 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,22 @@
44

55
namespace Doctrine\Persistence\Mapping\Driver;
66

7-
use AppendIterator;
87
use Doctrine\Persistence\Mapping\MappingException;
9-
use FilesystemIterator;
10-
use Generator;
11-
use Iterator;
12-
use RecursiveDirectoryIterator;
13-
use RecursiveIteratorIterator;
14-
use ReflectionClass;
15-
use RegexIterator;
16-
use SplFileInfo;
178

9+
use function array_filter;
1810
use function array_merge;
1911
use function array_unique;
20-
use function assert;
21-
use function get_declared_classes;
22-
use function in_array;
23-
use function is_dir;
24-
use function preg_match;
25-
use function preg_quote;
26-
use function realpath;
27-
use function sprintf;
28-
use function str_contains;
29-
use function str_replace;
12+
use function array_values;
3013

3114
/**
3215
* The ColocatedMappingDriver reads the mapping metadata located near the code.
3316
*/
3417
trait ColocatedMappingDriver
3518
{
19+
private ClassLocator|null $classLocator = null;
20+
3621
/**
37-
* The paths where to look for mapping files.
22+
* The directory paths where to look for mapping files.
3823
*
3924
* @var array<int, string>
4025
*/
@@ -51,7 +36,7 @@ trait ColocatedMappingDriver
5136
protected string $fileExtension = '.php';
5237

5338
/**
54-
* Cache for getAllClassNames().
39+
* Cache for {@see getAllClassNames()}.
5540
*
5641
* @var array<int, string>|null
5742
* @phpstan-var list<class-string>|null
@@ -79,7 +64,7 @@ public function getPaths(): array
7964
}
8065

8166
/**
82-
* Append exclude lookup paths to metadata driver.
67+
* Append exclude lookup paths to a metadata driver.
8368
*
8469
* @param string[] $paths
8570
*/
@@ -132,85 +117,22 @@ public function getAllClassNames(): array
132117
return $this->classNames;
133118
}
134119

135-
if ($this->paths === []) {
120+
if ($this->paths === [] && $this->classLocator === null) {
136121
throw MappingException::pathRequiredForDriver(static::class);
137122
}
138123

139-
/** @var AppendIterator<array-key,SplFileInfo,Iterator<array-key,SplFileInfo>> $filesIterator */
140-
$filesIterator = new AppendIterator();
141-
142-
foreach ($this->paths as $path) {
143-
if (! is_dir($path)) {
144-
throw MappingException::fileMappingDriversRequireConfiguredDirectoryPath($path);
145-
}
146-
147-
/** @var Iterator<array-key,SplFileInfo> $iterator */
148-
$iterator = new RegexIterator(
149-
new RecursiveIteratorIterator(
150-
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
151-
RecursiveIteratorIterator::LEAVES_ONLY,
152-
),
153-
sprintf('/%s$/', preg_quote($this->fileExtension, '/')),
154-
RegexIterator::MATCH,
155-
);
156-
157-
$filesIterator->append($iterator);
158-
}
159-
160-
$sourceFilePathNames = $this->pathNameIterator($filesIterator);
161-
$includedFiles = [];
162-
163-
foreach ($sourceFilePathNames as $sourceFile) {
164-
if (preg_match('(^phar:)i', $sourceFile) === 0) {
165-
$sourceFile = realpath($sourceFile);
166-
assert($sourceFile !== false);
167-
}
168-
169-
foreach ($this->excludePaths as $excludePath) {
170-
$realExcludePath = realpath($excludePath);
171-
assert($realExcludePath !== false);
172-
$exclude = str_replace('\\', '/', $realExcludePath);
173-
$current = str_replace('\\', '/', $sourceFile);
174-
175-
if (str_contains($current, $exclude)) {
176-
continue 2;
177-
}
178-
}
124+
$classNames = $this->classLocator?->getClassNames() ?? [];
179125

180-
require_once $sourceFile;
181-
182-
$includedFiles[] = $sourceFile;
126+
if ($this->paths !== []) {
127+
$classNames = array_unique([
128+
...FileClassLocator::createFromDirectories($this->paths, $this->excludePaths, $this->fileExtension)->getClassNames(),
129+
...$classNames,
130+
]);
183131
}
184132

185-
$classes = [];
186-
$declared = get_declared_classes();
187-
188-
foreach ($declared as $className) {
189-
$rc = new ReflectionClass($className);
190-
191-
$sourceFile = $rc->getFileName();
192-
193-
if (! in_array($sourceFile, $includedFiles, true) || $this->isTransient($className)) {
194-
continue;
195-
}
196-
197-
$classes[] = $className;
198-
}
199-
200-
$this->classNames = $classes;
201-
202-
return $classes;
203-
}
204-
205-
/**
206-
* @param iterable<SplFileInfo> $filesIterator
207-
*
208-
* @return Generator<int,string>
209-
*/
210-
private function pathNameIterator(iterable $filesIterator): Generator
211-
{
212-
foreach ($filesIterator as $file) {
213-
yield $file->getPathname();
214-
}
133+
return $this->classNames = array_values(array_filter(
134+
$classNames,
135+
fn (string $className): bool => ! $this->isTransient($className),
136+
));
215137
}
216138
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\Persistence\Mapping\Driver;
6+
7+
use AppendIterator;
8+
use CallbackFilterIterator;
9+
use Doctrine\Persistence\Mapping\MappingException;
10+
use FilesystemIterator;
11+
use InvalidArgumentException;
12+
use Iterator;
13+
use RecursiveDirectoryIterator;
14+
use RecursiveIteratorIterator;
15+
use ReflectionClass;
16+
use RegexIterator;
17+
use SplFileInfo;
18+
19+
use function array_key_exists;
20+
use function array_map;
21+
use function assert;
22+
use function get_debug_type;
23+
use function get_declared_classes;
24+
use function is_dir;
25+
use function preg_quote;
26+
use function realpath;
27+
use function sprintf;
28+
use function str_replace;
29+
use function str_starts_with;
30+
31+
/**
32+
* ClassLocator implementation that uses a list of file names to locate PHP files
33+
* and extract class names from them.
34+
*
35+
* It is compatible with the Symfony Finder component, but does not require it.
36+
*/
37+
final class FileClassLocator implements ClassLocator
38+
{
39+
/** @param iterable<SplFileInfo> $files An iterable of files to include. */
40+
public function __construct(
41+
private iterable $files,
42+
) {
43+
}
44+
45+
/** @return list<class-string> */
46+
public function getClassNames(): array
47+
{
48+
$includedFiles = [];
49+
50+
foreach ($this->files as $file) {
51+
// @phpstan-ignore function.alreadyNarrowedType, instanceof.alwaysTrue
52+
assert($file instanceof SplFileInfo, new InvalidArgumentException(sprintf('Expected an iterable of SplFileInfo, got %s', get_debug_type($file))));
53+
54+
// Skip non-files
55+
if (! $file->isFile()) {
56+
continue;
57+
}
58+
59+
// getRealPath() returns false if the file is in a phar archive
60+
// @phpstan-ignore ternary.shortNotAllowed (false is the only falsy value getRealPath() may return)
61+
$fileName = $file->getRealPath() ?: $file->getPathname();
62+
63+
$includedFiles[$fileName] = true;
64+
require_once $fileName;
65+
}
66+
67+
$classes = [];
68+
foreach (get_declared_classes() as $className) {
69+
$fileName = (new ReflectionClass($className))->getFileName();
70+
71+
if ($fileName === false || ! array_key_exists($fileName, $includedFiles)) {
72+
continue;
73+
}
74+
75+
$classes[] = $className;
76+
}
77+
78+
return $classes;
79+
}
80+
81+
/**
82+
* Creates a FileClassLocator from an array of directories.
83+
*
84+
* @param list<string> $directories
85+
* @param list<string> $excludedDirectories Directories to exclude from the search.
86+
* @param string $fileExtension The file extension to look for (default is '.php').
87+
*
88+
* @throws MappingException if any of the directories are not valid.
89+
*/
90+
public static function createFromDirectories(
91+
array $directories,
92+
array $excludedDirectories = [],
93+
string $fileExtension = '.php',
94+
): self {
95+
$filesIterator = new AppendIterator();
96+
97+
foreach ($directories as $directory) {
98+
if (! is_dir($directory)) {
99+
throw MappingException::fileMappingDriversRequireConfiguredDirectoryPath($directory);
100+
}
101+
102+
/** @var Iterator<array-key,SplFileInfo> $iterator */
103+
$iterator = new RegexIterator(
104+
new RecursiveIteratorIterator(
105+
new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
106+
RecursiveIteratorIterator::LEAVES_ONLY,
107+
),
108+
sprintf('/%s$/', preg_quote($fileExtension, '/')),
109+
RegexIterator::MATCH,
110+
);
111+
112+
$filesIterator->append($iterator);
113+
}
114+
115+
if ($excludedDirectories !== []) {
116+
$excludedDirectories = array_map(
117+
// realpath() returns false if the file is in a phar archive
118+
// @phpstan-ignore ternary.shortNotAllowed (false is the only falsy value realpath() may return)
119+
static fn (string $dir): string => str_replace('\\', '/', realpath($dir) ?: $dir),
120+
$excludedDirectories,
121+
);
122+
123+
$filesIterator = new CallbackFilterIterator(
124+
$filesIterator,
125+
static function (SplFileInfo $file) use ($excludedDirectories): bool {
126+
// getRealPath() returns false if the file is in a phar archive
127+
// @phpstan-ignore ternary.shortNotAllowed (false is the only falsy value getRealPath() may return)
128+
$sourceFile = str_replace('\\', '/', $file->getRealPath() ?: $file->getPathname());
129+
130+
foreach ($excludedDirectories as $excludedDirectory) {
131+
if (str_starts_with($sourceFile, $excludedDirectory)) {
132+
return false;
133+
}
134+
}
135+
136+
return true;
137+
},
138+
);
139+
}
140+
141+
return new self($filesIterator);
142+
}
143+
}

src/Persistence/Mapping/MappingException.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public static function classNotFoundInNamespaces(
3030
public static function pathRequiredForDriver(string $driverClassName): self
3131
{
3232
return new self(sprintf(
33-
'Specifying the paths to your entities is required when using %s to retrieve all class names.',
33+
'Specifying source file paths to your entities is required when using %s to retrieve all class names.',
3434
$driverClassName,
3535
));
3636
}

0 commit comments

Comments
 (0)