Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions docs/1-essentials/03-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
238 changes: 238 additions & 0 deletions packages/reflection/src/DocblockContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
<?php

declare(strict_types=1);

namespace Tempest\Reflection;

use PhpToken;
use ReflectionClass;
use ReflectionProperty;

/** @internal */
final class DocblockContext
{
/** @return array{namespace: string, aliases: array<string, string>} */
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<object> $class
* @return array{namespace: string, aliases: array<string, string>, properties: array<string, true>}
*/
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<object> $class
* @return array{namespace: string, aliases: array<string, string>, properties: array<string, true>}|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+(?<namespace>[\w\x5c]+)\s*;\s*)?
(?<imports>(?:use\s+[\w\x5c]+(?:\s+as\s+\w+)?\s*;\s*)*)
(?:(?:final|abstract|readonly)\s+)*class\s+(?<class>\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<string, array{namespace: string, aliases: array<string, string>, properties: array<string, true>}> */
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<PhpToken> $tokens
* @return array<string, string>
*/
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;
}
}
59 changes: 56 additions & 3 deletions packages/reflection/src/PropertyReflector.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,21 +97,74 @@ public function getIterableType(): ?TypeReflector
{
$doc = $this->reflectionProperty->getDocComment();

if (! $doc) {
if ($doc === false) {
return null;
}

/** @var array<class-string, array<string, string|null>> $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);
Expand Down
26 changes: 26 additions & 0 deletions packages/reflection/tests/Fixtures/DocblockAnonymous.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace DocblockAnonymous;

use Tempest\Reflection\Tests\Fixtures\Imports\DocblockImportTarget as Book;

$className = Book::class;

return new class(
new class {},
// @mago-ignore lint:prefer-arrow-function
(static function () {
return null;
})(),
) {
public function __construct(object $argument, mixed $other)
{
assert($argument !== $this, 'The argument is the nested anonymous class instance.');
assert($other === null, 'The nested closure returns null.');
}

/** @var Book[] */
public array $items;
};
31 changes: 31 additions & 0 deletions packages/reflection/tests/Fixtures/DocblockCollectionModel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Tempest\Reflection\Tests\Fixtures;

use Tempest\Reflection\Tests\Fixtures\Imports\DocblockImportTarget;
use Tempest\Reflection\Tests\Fixtures\Imports\DocblockImportTarget as DocblockImportTargetAlias;

final class DocblockCollectionModel
{
/** @var DocblockImportTarget[] */
public array $imported = [];

/** @var DocblockImportTargetAlias[] */
public array $aliased = [];

/** @var \Tempest\Reflection\Tests\Fixtures\Imports\DocblockImportTarget[] */
public array $fullyQualified = [];

/** @var list<DocblockImportTarget> */
public array $list = [];

/** @var array<DocblockImportTarget> */
public array $arrayType = [];

/** @var DocblockRelativeTarget[] */
public array $relative = [];

public array $noDocblock = [];
}
Loading
Loading