Skip to content

Commit 1ee8060

Browse files
committed
Introduce ClassLocator to find classes names for attribute drivers
1 parent a241c82 commit 1ee8060

File tree

10 files changed

+432
-113
lines changed

10 files changed

+432
-113
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
"phpstan/phpstan-strict-rules": "^1.1",
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": {
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: 17 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,17 @@
55
namespace Doctrine\Persistence\Mapping\Driver;
66

77
use Doctrine\Persistence\Mapping\MappingException;
8-
use ReflectionClass;
98

9+
use function array_filter;
1010
use function array_merge;
1111
use function array_unique;
12-
use function assert;
13-
use function get_declared_classes;
14-
use function preg_match;
15-
use function realpath;
16-
use function str_contains;
17-
use function str_replace;
1812

1913
/**
2014
* The ColocatedMappingDriver reads the mapping metadata located near the code.
2115
*/
2216
trait ColocatedMappingDriver
2317
{
24-
/** @var iterable<array-key,string> */
25-
private iterable $filePaths;
18+
private ClassLocator $classLocator;
2619

2720
/**
2821
* The directory paths where to look for mapping files.
@@ -123,75 +116,24 @@ public function getAllClassNames(): array
123116
return $this->classNames;
124117
}
125118

126-
if ($this->paths === [] && ! isset($this->filePaths)) {
127-
throw MappingException::pathRequiredForDriver(static::class);
128-
}
129-
130-
$dirFilesIterator = new DirectoryFilesIterator($this->paths, $this->fileExtension);
131-
132-
/** @var iterable<string> $filePathsIterator */
133-
$filePathsIterator = $this->concatIterables(
134-
$this->filePaths ?? [],
135-
new FilePathNameIterator($dirFilesIterator),
136-
);
137-
138-
/** @var array<string,true> $includedFiles */
139-
$includedFiles = [];
140-
141-
foreach ($filePathsIterator as $sourceFile) {
142-
if (preg_match('(^phar:)i', $sourceFile) === 0) {
143-
$sourceFile = realpath($sourceFile);
144-
assert($sourceFile !== false);
145-
}
146-
147-
foreach ($this->excludePaths as $excludePath) {
148-
$realExcludePath = realpath($excludePath);
149-
assert($realExcludePath !== false);
150-
$exclude = str_replace('\\', '/', $realExcludePath);
151-
$current = str_replace('\\', '/', $sourceFile);
152-
153-
if (str_contains($current, $exclude)) {
154-
continue 2;
155-
}
156-
}
157-
158-
require_once $sourceFile;
159-
160-
$includedFiles[$sourceFile] = true;
161-
}
119+
if ($this->paths !== []) {
120+
$classNames = FileClassLocator::createFromDirectories($this->paths, $this->excludePaths, $this->fileExtension)->getClassNames();
162121

163-
$classes = [];
164-
$declared = get_declared_classes();
165-
166-
foreach ($declared as $className) {
167-
$rc = new ReflectionClass($className);
168-
169-
$sourceFile = $rc->getFileName();
170-
171-
if (! isset($includedFiles[$sourceFile]) || $this->isTransient($className)) {
172-
continue;
122+
if (isset($this->classLocator)) {
123+
$classNames = array_unique([
124+
...$classNames,
125+
...$this->classLocator->getClassNames(),
126+
]);
173127
}
174-
175-
$classes[] = $className;
128+
} elseif (isset($this->classLocator)) {
129+
$classNames = $this->classLocator->getClassNames();
130+
} else {
131+
throw MappingException::pathRequiredForDriver(static::class);
176132
}
177133

178-
$this->classNames = $classes;
179-
180-
return $classes;
181-
}
182-
183-
/**
184-
* @param iterable<TKey, T> $iterable1
185-
* @param iterable<TKey, T> $iterable2
186-
*
187-
* @return iterable<TKey, T>
188-
*
189-
* @template TKey
190-
* @template T
191-
*/
192-
private function concatIterables(iterable $iterable1, iterable $iterable2): iterable
193-
{
194-
yield from $iterable1;
195-
yield from $iterable2;
134+
return $this->classNames = array_filter(
135+
$classNames,
136+
fn (string $className): bool => ! $this->isTransient($className),
137+
);
196138
}
197139
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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 is_file;
26+
use function preg_quote;
27+
use function realpath;
28+
use function sprintf;
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+
/**
40+
* @param iterable<string> $fileNames An iterable of file names to include.
41+
*
42+
* @throws MappingException if any of the files do not exist.
43+
*/
44+
public function __construct(
45+
private iterable $fileNames,
46+
) {
47+
}
48+
49+
/** @return list<class-string> */
50+
public function getClassNames(): array
51+
{
52+
$includedFiles = [];
53+
54+
foreach ($this->fileNames as $fileName) {
55+
if (! is_file($fileName)) {
56+
throw MappingException::fileDoesNotExist($fileName);
57+
}
58+
59+
// realpath() can return false if the file is in a phar archive
60+
// @phpstan-ignore ternary.shortNotAllowed
61+
$fileName = realpath($fileName) ?: $fileName;
62+
if (isset($includedFiles[$fileName])) {
63+
continue;
64+
}
65+
66+
$includedFiles[$fileName] = true;
67+
require_once $fileName;
68+
}
69+
70+
$classes = [];
71+
foreach (get_declared_classes() as $className) {
72+
$fileName = (new ReflectionClass($className))->getFileName();
73+
74+
if ($fileName === false || ! array_key_exists($fileName, $includedFiles)) {
75+
continue;
76+
}
77+
78+
$classes[] = $className;
79+
}
80+
81+
return $classes;
82+
}
83+
84+
/**
85+
* Creates a FileClassLocator from an array of directories.
86+
*
87+
* @param list<string> $directories
88+
* @param list<string> $excludedDirectories Directories to exclude from the search.
89+
* @param string $fileExtension The file extension to look for (default is '.php').
90+
*
91+
* @throws MappingException if any of the directories are not valid.
92+
*/
93+
public static function createFromDirectories(
94+
array $directories,
95+
array $excludedDirectories = [],
96+
string $fileExtension = '.php',
97+
): self {
98+
$filesIterator = new AppendIterator();
99+
100+
foreach ($directories as $directory) {
101+
if (! is_dir($directory)) {
102+
throw MappingException::fileMappingDriversRequireConfiguredDirectoryPath($directory);
103+
}
104+
105+
/** @var Iterator<array-key,SplFileInfo> $iterator */
106+
$iterator = new RegexIterator(
107+
new RecursiveIteratorIterator(
108+
new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
109+
RecursiveIteratorIterator::LEAVES_ONLY,
110+
),
111+
sprintf('/%s$/', preg_quote($fileExtension, '/')),
112+
RegexIterator::MATCH,
113+
);
114+
115+
$filesIterator->append($iterator);
116+
}
117+
118+
if ($excludedDirectories !== []) {
119+
// Get
120+
$excludedDirectories = array_map(
121+
// @phpstan-ignore ternary.shortNotAllowed
122+
static fn (string $dir): string => realpath($dir) ?: $dir,
123+
$excludedDirectories,
124+
);
125+
126+
$filesIterator = new CallbackFilterIterator(
127+
$filesIterator,
128+
static function (SplFileInfo $file) use ($excludedDirectories): bool {
129+
if (str_starts_with($file->getPath(), 'phar:')) {
130+
$sourceFile = $file->getPathname();
131+
} else {
132+
$sourceFile = $file->getRealPath();
133+
}
134+
135+
foreach ($excludedDirectories as $excludedDirectory) {
136+
if (str_starts_with($sourceFile, $excludedDirectory)) {
137+
return false;
138+
}
139+
}
140+
141+
return true;
142+
},
143+
);
144+
}
145+
146+
return self::createFromSplFiles($filesIterator);
147+
}
148+
149+
/**
150+
* Creates a FileClassLocator from an iterable of SplFileInfo objects.
151+
*
152+
* This method can be used with a Symfony Finder or any other iterable
153+
*
154+
* @param iterable<SplFileInfo> $files
155+
*/
156+
public static function createFromSplFiles(iterable $files): self
157+
{
158+
return new self((static function ($files) {
159+
foreach ($files as $file) {
160+
// @phpstan-ignore function.alreadyNarrowedType, instanceof.alwaysTrue
161+
assert($file instanceof SplFileInfo, new InvalidArgumentException(sprintf('Expected an iterable of SplFileInfo, got %s', get_debug_type($file))));
162+
163+
// Skip directories or non-file entries
164+
if (! $file->isFile()) {
165+
continue;
166+
}
167+
168+
// Files in phar does not have a real path, so we use the pathname
169+
// @phpstan-ignore ternary.shortNotAllowed
170+
yield $file->getRealPath() ?: $file->getPathname();
171+
}
172+
})($files));
173+
}
174+
}

src/Persistence/Mapping/Driver/FilePathNameIterator.php

Lines changed: 0 additions & 27 deletions
This file was deleted.

src/Persistence/Mapping/MappingException.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ public static function invalidMappingFile(string $entityName, string $fileName):
6767
));
6868
}
6969

70+
public static function fileDoesNotExist(string $fileName): self
71+
{
72+
return new self(sprintf("File '%s' does not exist", $fileName));
73+
}
74+
7075
public static function nonExistingClass(string $className): self
7176
{
7277
return new self(sprintf("Class '%s' does not exist", $className));

0 commit comments

Comments
 (0)