diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md index c6909da016..86f9d18aaf 100644 --- a/docs/1-essentials/03-database.md +++ b/docs/1-essentials/03-database.md @@ -193,10 +193,6 @@ final class Book } ``` -:::warning -Due to a restriction with reflection, relation types in docblocks must always be fully qualified. Short class names are not supported. -::: - ### Relation attributes Tempest infers all information needed to build queries. When property names and type information do not map one-to-one to the database schema, dedicated attributes can be used to define relations. diff --git a/packages/reflection/src/DocblockContext.php b/packages/reflection/src/DocblockContext.php new file mode 100644 index 0000000000..c12e9b96e4 --- /dev/null +++ b/packages/reflection/src/DocblockContext.php @@ -0,0 +1,238 @@ +} */ + public static function forProperty(ReflectionProperty $property): array + { + $class = $property->getDeclaringClass(); + $name = $property->getName(); + $context = self::forClass($class); + + // A class declaration wins over a compatible property contributed by a trait. + if (! $property->isPromoted() && ! isset($context['properties'][$name])) { + foreach ($class->getTraits() as $trait) { + if ($trait->hasProperty($name)) { + return self::forProperty($trait->getProperty($name)); + } + } + } + + return ['namespace' => $context['namespace'], 'aliases' => $context['aliases']]; + } + + /** + * @param ReflectionClass $class + * @return array{namespace: string, aliases: array, properties: array} + */ + private static function forClass(ReflectionClass $class): array + { + static $files = []; + + $file = $class->getFileName(); + // shortcut: same-line anonymous classes share metadata; disambiguate if they need different trait origins. + $key = $class->isAnonymous() ? '@' . $class->getStartLine() : $class->getName(); + + if ($file !== false) { + if (! isset($files[$file][$key])) { + $source = @file_get_contents($file); + + if ($source !== false) { + $context = self::parseHeader($source, $class); + + if ($context === null) { + $files[$file] = self::parseSource($source); + } else { + $files[$file][$key] = $context; + } + } + + $files[$file][$key] ??= ['namespace' => $class->getNamespaceName(), 'aliases' => [], 'properties' => []]; + } + + return $files[$file][$key]; + } + + return ['namespace' => $class->getNamespaceName(), 'aliases' => [], 'properties' => []]; + } + + /** + * @param ReflectionClass $class + * @return array{namespace: string, aliases: array, properties: array}|null + */ + private static function parseHeader(string $source, ReflectionClass $class): ?array + { + // Only a complete simple header can bypass tokenization; comments and other PHP syntax fall back. + if ($class->isAnonymous() || preg_match(<<<'REGEX' + ~\A<\?php\s+ + (?:declare\s*\(\s*strict_types\s*=\s*1\s*\)\s*;\s*)? + (?:namespace\s+(?[\w\x5c]+)\s*;\s*)? + (?(?:use\s+[\w\x5c]+(?:\s+as\s+\w+)?\s*;\s*)*) + (?:(?:final|abstract|readonly)\s+)*class\s+(?\w+)\b + ~ix + REGEX, $source, $match) !== 1) { + return null; + } + + $namespace = $match['namespace']; + + if (ltrim($namespace . '\\' . $match['class'], '\\') !== $class->getName() || $class->getTraits() !== []) { + return null; + } + + preg_match_all('/use\s+([\w\x5c]+)(?:\s+as\s+(\w+))?\s*;/i', $match['imports'], $imports, PREG_SET_ORDER); + $aliases = []; + + foreach ($imports as $import) { + $name = ltrim($import[1], '\\'); + $alias = $import[2] ?? substr(strrchr('\\' . $name, '\\'), 1); + $aliases[strtolower($alias)] = $name; + } + + return ['namespace' => $namespace, 'aliases' => $aliases, 'properties' => []]; + } + + /** @return array, properties: array}> */ + private static function parseSource(string $source): array + { + $tokens = array_values(array_filter(PhpToken::tokenize($source), fn (PhpToken $token) => ! $token->isIgnorable())); + $contexts = []; + $namespace = ''; + $aliases = []; + $depth = 0; + $namespaceDepth = 0; + $parentheses = 0; + $classes = []; + $pendingClasses = []; + + for ($i = 0, $count = count($tokens); $i < $count; $i++) { + $token = $tokens[$i]; + $symbol = $token->id < 256 ? $token->text : null; + + if ($token->id === T_NAMESPACE && $depth === 0) { + $namespace = ''; + $aliases = []; + + while (! $tokens[++$i]->is([';', '{'])) { + $namespace .= $tokens[$i]->text; + } + + $namespaceDepth = $tokens[$i]->text === '{' ? ++$depth : 0; + continue; + } + + if ($token->id === T_USE && $depth === $namespaceDepth && ($tokens[$i + 1]->text ?? null) !== '(') { + $aliases = array_replace($aliases, self::parseImport($tokens, $i)); + continue; + } + + if (in_array($token->id, [T_CLASS, T_TRAIT, T_INTERFACE, T_ENUM], true) && ($tokens[$i - 1]->id ?? null) !== T_DOUBLE_COLON) { + $name = ($tokens[$i + 1]->id ?? null) === T_STRING ? $tokens[$i + 1]->text : null; + $key = $name === null ? '@' . $token->line : ltrim($namespace . '\\' . $name, '\\'); + $contexts[$key] = ['namespace' => $namespace, 'aliases' => $aliases, 'properties' => []]; + $pendingClasses[] = ['key' => $key, 'depth' => $depth, 'parentheses' => $parentheses]; + } + + if ($symbol === '{') { + $pending = end($pendingClasses); + + // Anonymous-class arguments can themselves contain closures or classes. + if ($pending !== false && $pending['depth'] === $depth && $pending['parentheses'] === $parentheses) { + $classes[] = array_pop($pendingClasses); + } + + $depth++; + } elseif ($token->id === T_CURLY_OPEN || $token->id === T_DOLLAR_OPEN_CURLY_BRACES) { + $depth++; + } elseif ($symbol === '}') { + $depth--; + + if ($classes !== [] && end($classes)['depth'] === $depth) { + array_pop($classes); + } + + if ($depth < $namespaceDepth) { + $namespace = ''; + $aliases = []; + $namespaceDepth = 0; + } + } elseif ($symbol === '(') { + $parentheses++; + } elseif ($symbol === ')') { + $parentheses--; + } + + $class = end($classes); + + if ($class === false || $depth !== ($class['depth'] + 1)) { + continue; + } + + if ($token->id === T_VARIABLE && $parentheses === $class['parentheses']) { + $contexts[$class['key']]['properties'][substr($token->text, 1)] = true; + } + } + + return $contexts; + } + + /** + * @param list $tokens + * @return array + */ + private static function parseImport(array $tokens, int &$i): array + { + $aliases = []; + $prefix = ''; + $name = ''; + $alias = ''; + $aliased = false; + $statementClassImport = ! $tokens[$i + 1]->is([T_FUNCTION, T_CONST]); + $classImport = $statementClassImport; + + while (isset($tokens[++$i])) { + $token = $tokens[$i]; + + if ($token->is([T_FUNCTION, T_CONST])) { + $classImport = false; + } elseif ($token->id === T_AS) { + $aliased = true; + } elseif ($token->text === '{') { + $prefix = $name; + $name = ''; + } elseif ($token->is([',', '}', ';'])) { + if ($classImport && $name !== '') { + $fullName = ltrim($prefix . $name, '\\'); + $alias = $aliased ? $alias : substr(strrchr('\\' . $name, '\\'), 1); + $aliases[strtolower($alias)] = $fullName; + } + + $name = ''; + $alias = ''; + $aliased = false; + $classImport = $statementClassImport; + + if ($token->text === '}') { + $prefix = ''; + } elseif ($token->text === ';') { + break; + } + } elseif ($aliased) { + $alias .= $token->text; + } else { + $name .= $token->text; + } + } + + return $aliases; + } +} diff --git a/packages/reflection/src/PropertyReflector.php b/packages/reflection/src/PropertyReflector.php index f98a6330d9..4691c48f28 100644 --- a/packages/reflection/src/PropertyReflector.php +++ b/packages/reflection/src/PropertyReflector.php @@ -97,21 +97,74 @@ public function getIterableType(): ?TypeReflector { $doc = $this->reflectionProperty->getDocComment(); - if (! $doc) { + if ($doc === false) { return null; } + /** @var array> $types */ + static $types = []; + + $class = $this->reflectionProperty->getDeclaringClass()->getName(); + $property = $this->reflectionProperty->getName(); + + if (! array_key_exists($property, $types[$class] ?? [])) { + $types[$class][$property] = $this->resolveIterableTypeName($doc); + } + + $name = $types[$class][$property]; + + return $name === null ? null : new TypeReflector($name); + } + + private function resolveIterableTypeName(string $doc): ?string + { if (preg_match('/@var\s+([\\\\\w]+)\[\]/', $doc, $match)) { - return new TypeReflector(ltrim($match[1], '\\')); + return $this->resolveDocblockClassName($match[1]); } if (preg_match('/@var\s+(?:list|array)<([\\\\\w]+)>/', $doc, $match)) { - return new TypeReflector(ltrim($match[1], '\\')); + return $this->resolveDocblockClassName($match[1]); } return null; } + private function resolveDocblockClassName(string $name): string + { + if (in_array( + $name, + ['array', 'bool', 'callable', 'false', 'float', 'int', 'iterable', 'mixed', 'never', 'null', 'object', 'resource', 'string', 'true', 'void'], + strict: true, + )) { + return $name; + } + + if (str_starts_with($name, '\\')) { + return ltrim($name, '\\'); + } + + $segments = explode('\\', $name); + $firstSegment = strtolower($segments[0]); + $context = DocblockContext::forProperty($this->reflectionProperty); + $aliases = $context['aliases']; + + if ($firstSegment === 'namespace') { + $name = implode('\\', array_slice($segments, 1)); + } + + if ($firstSegment !== 'namespace' && isset($aliases[$firstSegment])) { + return implode('\\', [$aliases[$firstSegment], ...array_slice($segments, 1)]); + } + + $namespace = $context['namespace']; + + if ($namespace === '') { + return $name; + } + + return $namespace . '\\' . $name; + } + public function isUninitialized(object $object): bool { return ! $this->reflectionProperty->isInitialized($object); diff --git a/packages/reflection/tests/Fixtures/DocblockAnonymous.php b/packages/reflection/tests/Fixtures/DocblockAnonymous.php new file mode 100644 index 0000000000..464611d153 --- /dev/null +++ b/packages/reflection/tests/Fixtures/DocblockAnonymous.php @@ -0,0 +1,26 @@ + */ + public array $list = []; + + /** @var array */ + public array $arrayType = []; + + /** @var DocblockRelativeTarget[] */ + public array $relative = []; + + public array $noDocblock = []; +} diff --git a/packages/reflection/tests/Fixtures/DocblockCommentImport.php b/packages/reflection/tests/Fixtures/DocblockCommentImport.php new file mode 100644 index 0000000000..9d513021dc --- /dev/null +++ b/packages/reflection/tests/Fixtures/DocblockCommentImport.php @@ -0,0 +1,16 @@ +assertSame($expected, $reflector->getIterableType()?->getName()); + $this->assertSame($expected, $reflector->getIterableType()?->getName()); + $this->assertSame($expected, PropertyReflector::fromParts(DocblockMemoizationModel::class, $name)->getIterableType()?->getName()); + } + + public static function iterable_types(): iterable + { + yield 'class name' => ['objects', 'stdClass']; + yield 'builtin' => ['integers', 'int']; + yield 'non-iterable docblock' => ['notIterable', null]; + } + + /** @param class-string $class */ + #[Test] + #[DataProvider('docblock_contexts')] + public function iterable_type_uses_the_docblocks_lexical_context(string $fixture, string $class, string $expected): void + { + require_once __DIR__ . '/Fixtures/' . $fixture . '.php'; + + for ($i = 0; $i < 2; $i++) { + $reflector = PropertyReflector::fromParts($class, 'items'); + $this->assertSame($expected, $reflector->getIterableType()?->getName()); + } + } + + public static function docblock_contexts(): iterable + { + yield 'bracketed namespace' => ['DocblockNamespaces', 'DocblockBracketed\\First', DocblockImportTarget::class]; + yield 'other bracketed namespace' => ['DocblockNamespaces', 'DocblockOtherBracketed\\Second', DocblockGroupTargetB::class]; + yield 'global namespace block' => ['DocblockNamespaces', 'DocblockGlobalHolder', DocblockGroupTargetA::class]; + yield 'uppercase AS' => ['DocblockImports', 'DocblockImports\\UppercaseAlias', DocblockImportTarget::class]; + yield 'case-insensitive alias' => ['DocblockImports', 'DocblockImports\\LowercaseReference', DocblockImportTarget::class]; + yield 'grouped imports' => ['DocblockImports', 'DocblockImports\\Grouped', DocblockImportTarget::class]; + yield 'grouped namespace alias' => ['DocblockImports', 'DocblockImports\\GroupedPrefix', DocblockGroupTargetA::class]; + yield 'mixed grouped imports' => ['DocblockImports', 'DocblockImports\\MixedGrouped', DocblockGroupTargetB::class]; + yield 'separate function aliases' => ['DocblockImports', 'DocblockImports\\MixedGroupedDoesNotShadowClass', DocblockImportTarget::class]; + yield 'closure use and interpolation' => ['DocblockImports', 'DocblockImports\\AfterClosure', DocblockGroupTargetA::class]; + yield 'namespace-relative name' => ['DocblockImports', 'DocblockImports\\NamespaceRelative', 'DocblockImports\\NamespaceBook']; + yield 'function import list' => ['DocblockImports', 'DocblockImports\\FunctionList', DocblockImportTarget::class]; + yield 'constant import list' => ['DocblockImports', 'DocblockImports\\ConstantList', DocblockGroupTargetA::class]; + yield 'literal brace in interpolated string' => ['DocblockImports', 'DocblockImports\\AfterString', DocblockGroupTargetB::class]; + yield 'second namespace' => ['DocblockImports', 'DocblockOtherImports\\OtherNamespace', DocblockGroupTargetB::class]; + yield 'import text in a comment' => ['DocblockCommentImport', DocblockCommentImport::class, DocblockImportTarget::class]; + yield 'import text in a string' => ['DocblockStringImport', DocblockStringImport::class, DocblockImportTarget::class]; + yield 'simple header before another class' => ['DocblockHeaderClasses', 'DocblockHeaderFirst\\First', DocblockImportTarget::class]; + yield 'another class after a cached header' => ['DocblockHeaderClasses', 'DocblockHeaderSecond\\Second', DocblockGroupTargetB::class]; + yield 'nested trait' => ['DocblockTraits', 'DocblockTraitConsumer\\Holder', DocblockImportTarget::class]; + yield 'inherited trait property' => ['DocblockTraits', 'DocblockTraitConsumer\\Child', DocblockImportTarget::class]; + yield 'class overrides trait property' => ['DocblockTraits', 'DocblockTraitConsumer\\Override', DocblockGroupTargetB::class]; + yield 'promoted property overrides trait' => ['DocblockTraits', 'DocblockTraitConsumer\\PromotedOverride', DocblockGroupTargetB::class]; + yield 'readonly promoted property overrides trait' => ['DocblockTraits', 'DocblockTraitConsumer\\ReadonlyPromotedOverride', DocblockGroupTargetB::class]; + } + + #[Test] + public function iterable_type_resolves_anonymous_classes_with_nested_arguments(): void + { + /** @var object $object */ + $object = require __DIR__ . '/Fixtures/DocblockAnonymous.php'; + + $this->assertSame(DocblockImportTarget::class, PropertyReflector::fromParts($object, 'items')->getIterableType()?->getName()); + } + + #[Test] + public function iterable_type_keeps_imports_local_to_repeated_namespace_blocks(): void + { + // PHPStan rejects these legal repeated aliases, so compile this regression fixture at runtime. + $file = sys_get_temp_dir() . '/tempest-repeated-namespace-' . bin2hex(random_bytes(8)) . '.php'; + $namespace = 'DocblockRepeated' . bin2hex(random_bytes(8)); + $first = DocblockImportTarget::class; + $second = DocblockGroupTargetB::class; + + file_put_contents($file, << $first, 'Second' => $second] as $name => $expected) { + $this->assertSame( + $expected, + PropertyReflector::fromParts($namespace . '\\' . $name, 'items') + ->getIterableType() + ?->getName(), + ); + } + } finally { + unlink($file); + } + } + + #[Test] + public function iterable_type_resolves_imported_short_names(): void + { + $model = new ClassReflector(DocblockCollectionModel::class); + + $this->assertSame(DocblockImportTarget::class, $model->getProperty('imported')->getIterableType()->getName()); + $this->assertSame(DocblockImportTarget::class, $model->getProperty('aliased')->getIterableType()->getName()); + } + + #[Test] + public function iterable_type_resolves_relative_and_fully_qualified_names(): void + { + $model = new ClassReflector(DocblockCollectionModel::class); + + $this->assertSame(DocblockRelativeTarget::class, $model->getProperty('relative')->getIterableType()->getName()); + $this->assertSame(DocblockImportTarget::class, $model->getProperty('fullyQualified')->getIterableType()->getName()); + $this->assertSame(DocblockImportTarget::class, $model->getProperty('list')->getIterableType()->getName()); + $this->assertSame(DocblockImportTarget::class, $model->getProperty('arrayType')->getIterableType()->getName()); + } + + #[Test] + public function iterable_type_returns_null_without_a_docblock(): void + { + $model = new ClassReflector(DocblockCollectionModel::class); + + $this->assertNull($model->getProperty('noDocblock')->getIterableType()); + } + + #[Test] + public function iterable_type_on_unreadable_file_falls_back_to_namespace_resolution(): void + { + $file = sys_get_temp_dir() . '/tempest-unreadable-probe-' . bin2hex(random_bytes(8)) . '.php'; + file_put_contents($file, <<<'PHP' + assertSame('TempestEvalProbe\\Missing', $property->getIterableType()->getName()); + } +} diff --git a/tests/Benchmark/Reflection/Fixtures/Book.php b/tests/Benchmark/Reflection/Fixtures/Book.php new file mode 100644 index 0000000000..9944bd0a20 --- /dev/null +++ b/tests/Benchmark/Reflection/Fixtures/Book.php @@ -0,0 +1,16 @@ + */ + public array $strings; +} diff --git a/tests/Benchmark/Reflection/Fixtures/Short/ShortNameModels.php b/tests/Benchmark/Reflection/Fixtures/Short/ShortNameModels.php new file mode 100644 index 0000000000..3b272b5c36 --- /dev/null +++ b/tests/Benchmark/Reflection/Fixtures/Short/ShortNameModels.php @@ -0,0 +1,13 @@ + */ + private array $coldReflectors = []; + + /** @var list */ + private array $coldFiles = []; + + private int $coldIndex = 0; + + /** @param array{class: class-string, property: string, type: string|null} $params */ + public function setUpWarm(array $params): void + { + $this->reflector = PropertyReflector::fromParts($params['class'], $params['property']); + $this->expectedType = $params['type']; + } + + #[BeforeMethods('setUpWarm')] + #[AfterMethods('assertWarmType')] + #[ParamProviders('warmSources')] + #[Revs(50_000)] + #[Warmup(5_000)] + public function benchWarmLookup(): void + { + $this->reflector->getIterableType(); + } + + /** @return Generator */ + public function warmSources(): Generator + { + yield 'short-name' => ['class' => ShortNameModels::class, 'property' => 'one', 'type' => Book::class]; + yield 'fqcn' => ['class' => FqcnModels::class, 'property' => 'one', 'type' => Book::class]; + yield 'int[]' => ['class' => PlainModels::class, 'property' => 'integers', 'type' => 'int']; + yield 'list' => ['class' => PlainModels::class, 'property' => 'strings', 'type' => 'string']; + yield 'no-docblock' => ['class' => PlainModels::class, 'property' => 'noDocblock', 'type' => null]; + } + + public function assertWarmType(): void + { + Assert::assertSame($this->expectedType, $this->reflector->getIterableType()?->getName()); + } + + // Cold means uncached source metadata, not an empty OS filesystem cache. + #[BeforeMethods('setUpFirstLookup')] + #[AfterMethods('tearDownCold')] + #[ParamProviders('firstLookupSources')] + #[Revs(self::COLD_REVS)] + #[Warmup(0)] + public function benchFirstLookup(): void + { + $this->coldReflectors[$this->coldIndex++]->getIterableType(); + } + + /** @return Generator */ + public function firstLookupSources(): Generator + { + yield 'cold-small' => ['extraProperties' => 0, 'sourceCached' => false, 'groupedImport' => false, 'propertiesPerFile' => 1]; + yield 'cold-100-properties' => ['extraProperties' => 100, 'sourceCached' => false, 'groupedImport' => false, 'propertiesPerFile' => 1]; + yield 'cached-source' => ['extraProperties' => 0, 'sourceCached' => true, 'groupedImport' => false, 'propertiesPerFile' => 1]; + yield 'cold-grouped-import' => ['extraProperties' => 0, 'sourceCached' => false, 'groupedImport' => true, 'propertiesPerFile' => 1]; + yield 'cold-20-properties-one-file' => ['extraProperties' => 0, 'sourceCached' => false, 'groupedImport' => false, 'propertiesPerFile' => self::COLD_REVS]; + } + + /** @param array{extraProperties: int, sourceCached: bool, groupedImport: bool, propertiesPerFile: int} $params */ + public function setUpFirstLookup(array $params): void + { + // Load resolver dependencies without touching any of the measured source files. + PropertyReflector::fromParts(ShortNameModels::class, 'one')->getIterableType(); + $run = 'TempestParseBench' . bin2hex(random_bytes(8)); + + try { + for ($i = 0; $i < self::COLD_REVS; $i += $params['propertiesPerFile']) { + $file = tempnam(sys_get_temp_dir(), 'tempest-reflection-'); + + if ($file === false) { + throw new RuntimeException('Could not create a cold benchmark source file.'); + } + + $this->coldFiles[] = $file; + $namespace = $run . '\\Case' . $i; + + if (file_put_contents($file, $this->buildColdSource($namespace, $params['extraProperties'], $params['groupedImport'], $params['propertiesPerFile'])) === false) { + throw new RuntimeException('Could not write a cold benchmark source file.'); + } + + require $file; + + for ($property = 0; $property < $params['propertiesPerFile']; $property++) { + $this->coldReflectors[] = PropertyReflector::fromParts($namespace . '\\BooksHolder', $property === 0 ? 'books' : 'books' . $property); + } + } + + if ($params['sourceCached']) { + foreach ($this->coldReflectors as $reflector) { + Assert::assertSame(Book::class, $reflector->getClass()->getProperty('warmupBooks')->getIterableType()?->getName()); + } + } + } catch (Throwable $exception) { + $this->removeColdFiles(); + throw $exception; + } + } + + public function tearDownCold(): void + { + try { + Assert::assertSame(self::COLD_REVS, $this->coldIndex, 'First-lookup subjects require exactly 20 revolutions and no warmup.'); + + foreach ($this->coldReflectors as $reflector) { + Assert::assertSame(Book::class, $reflector->getIterableType()?->getName()); + } + } finally { + $this->removeColdFiles(); + } + } + + private function removeColdFiles(): void + { + foreach ($this->coldFiles as $file) { + unlink($file); + } + + $this->coldFiles = []; + $this->coldReflectors = []; + $this->coldIndex = 0; + } + + private function buildColdSource(string $namespace, int $extraProperties, bool $groupedImport, int $propertiesPerFile): string + { + $bookClass = Book::class; + $bookNamespace = substr($bookClass, 0, strrpos($bookClass, '\\')); + $import = $groupedImport ? "use {$bookNamespace}\\{Book};" : "use {$bookClass};"; + $properties = ''; + + for ($i = 0; $i < $extraProperties; $i++) { + $properties .= " public string \$field{$i};\n"; + } + + for ($i = 1; $i < $propertiesPerFile; $i++) { + $properties .= " /** @var Book[] */\n public array \$books{$i};\n"; + } + + return <<assertSame('b', $object->b); } + #[Test] + public function map_collection_of_objects_from_short_docblock_name(): void + { + $object = map(['items' => [['name' => 'a'], ['name' => 'b']]])->to(Library::class); + + $this->assertCount(2, $object->items); + $this->assertContainsOnlyInstancesOf(LibraryItem::class, $object->items); + $this->assertSame('a', $object->items[0]->name); + } + #[Test] public function caster_on_property(): void {