feat(preprocessor): 添加属性参数中数组和新表达式的校验

pull/14/head
韩天峰 2 months ago
parent 4fc0acc47f
commit 7d20c4e994
  1. 2
      docs/INCOMPATIBLE_PHP_FEATURES.md
  2. 14
      phpunit/code/preprocessor/attribute_array_argument.php
  3. 27
      phpunit/code/preprocessor/attribute_new_expression_argument.php
  4. 18
      phpunit/src/PreprocessorTest.php
  5. 21
      src/Php/Preprocessor.php
  6. 28
      tests/aot/symfony/anonymous-exception-interface.phpt
  7. 56
      tests/aot/symfony/array-map-first-class-object-method.phpt
  8. 44
      tests/aot/symfony/array-map-first-class-static-method.phpt
  9. 35
      tests/aot/symfony/attribute-array-arguments.phpt
  10. 35
      tests/aot/symfony/attribute-class-constant-args.phpt
  11. 40
      tests/aot/symfony/filter-coalesce-throw.phpt
  12. 43
      tests/aot/symfony/match-array-filter-union.phpt
  13. 41
      tests/aot/symfony/uasort-spaceship-elvis.phpt

@ -22,7 +22,7 @@
- 不支持引用可变参数 `&...$args`
- 联合类型、交叉类型、`nullable` 类型在静态编译阶段按 `mixed/any` 处理,只保留运行时 type check。
- 局部变量类型一旦被静态推断为具体 native 类型,不支持在同一作用域内重新赋值为不兼容类型。
- attribute 参数不支持数组值。
- attribute 参数不支持数组值`new` 表达式
## declare

@ -0,0 +1,14 @@
<?php
#[Attribute(Attribute::TARGET_CLASS)]
final class PreprocessorAttributeArrayArgument
{
public function __construct(public array $methods = [])
{
}
}
#[PreprocessorAttributeArrayArgument(methods: ['GET', 'POST'])]
class PreprocessorAttributeArrayArgumentController
{
}

@ -0,0 +1,27 @@
<?php
#[Attribute(Attribute::TARGET_METHOD)]
class PreprocessorAttributeSubscribedService
{
public function __construct(
public string $key,
public ?PreprocessorAttributeRequired $attribute = null,
) {
}
}
class PreprocessorAttributeRequired
{
public function __construct(public bool $enabled = true)
{
}
}
class PreprocessorAttributeSubscriber
{
#[PreprocessorAttributeSubscribedService(key: 'logger', attribute: new PreprocessorAttributeRequired(false))]
public function logger(): string
{
return 'logger';
}
}

@ -385,6 +385,24 @@ class PreprocessorTest extends TestCase
$this->compiler->prepareFile($file);
}
public function testPrepareFileRejectsAttributeArrayArguments(): void
{
$this->expectException(TestError::class);
$this->expectExceptionMessage('Array arguments to attributes are not supported');
$file = __DIR__ . '/../code/preprocessor/attribute_array_argument.php';
$this->compiler->prepareFile($file);
}
public function testPrepareFileRejectsAttributeNewExpressionArguments(): void
{
$this->expectException(TestError::class);
$this->expectExceptionMessage('New expressions in attribute arguments are not supported');
$file = __DIR__ . '/../code/preprocessor/attribute_new_expression_argument.php';
$this->compiler->prepareFile($file);
}
public function testIntersectionParamDeclFallsBackToVarWithRuntimeCheck(): void
{
$fn = $this->parseFunctionNode('<?php interface A {} interface B {} function demo(A&B $value): void {}');

@ -114,6 +114,7 @@ class Preprocessor extends CompilerBase
$traverser = new NodeTraverser();
$traverser->addVisitor(new Visitor());
$stmts = $traverser->traverse($ast);
$this->validateUnsupportedAttributeArguments($stmts);
foreach ($stmts as $v) {
$type = $v->getType();
@ -157,6 +158,26 @@ class Preprocessor extends CompilerBase
}
}
/**
* @param array<Node> $stmts
*/
private function validateUnsupportedAttributeArguments(array $stmts): void
{
$nodeFinder = new NodeFinder();
$attributes = $nodeFinder->findInstanceOf($stmts, Node\Attribute::class);
foreach ($attributes as $attribute) {
foreach ($attribute->args as $arg) {
if ($arg->value instanceof Node\Expr\Array_ && count($arg->value->items) > 0) {
$this->fatalError($arg, 'Array arguments to attributes are not supported');
}
if ($arg->value instanceof Node\Expr\New_) {
$this->fatalError($arg, 'New expressions in attribute arguments are not supported');
}
}
}
}
protected function findSymbolUsing(NodeAbstract $ast)
{
$nodeFinder = new NodeFinder();

@ -0,0 +1,28 @@
--TEST--
Symfony pattern: anonymous exception class implements marker interface
--FILE--
<?php
interface SymfonyLikeNotFoundException
{
}
function createNotFoundException(string $id): Throwable
{
return new class(sprintf('Service "%s" not found.', $id)) extends InvalidArgumentException implements SymfonyLikeNotFoundException {
};
}
function main(): void
{
$exception = createNotFoundException('mailer');
var_dump($exception instanceof InvalidArgumentException);
var_dump($exception instanceof SymfonyLikeNotFoundException);
var_dump($exception->getMessage());
}
?>
--EXPECT--
bool(true)
bool(true)
string(27) "Service "mailer" not found."

@ -0,0 +1,56 @@
--TEST--
Symfony pattern: array_map with first-class object method callable
--FILE--
<?php
class SymfonyLikeParameter
{
public function __construct(private string $name, private int $position)
{
}
public function toArray(): array
{
return [$this->name, $this->position];
}
}
class SymfonyLikeParameterNormalizer
{
public function normalize(SymfonyLikeParameter $parameter): string
{
[$name, $position] = $parameter->toArray();
return $position.':'.$name;
}
}
class SymfonyLikeDescriptor
{
public function __construct(private SymfonyLikeParameterNormalizer $normalizer)
{
}
public function describe(array $parameters): array
{
return array_map($this->normalizer->normalize(...), $parameters);
}
}
function main(): void
{
$descriptor = new SymfonyLikeDescriptor(new SymfonyLikeParameterNormalizer());
var_dump($descriptor->describe([
new SymfonyLikeParameter('request', 0),
new SymfonyLikeParameter('format', 1),
]));
}
?>
--EXPECT--
array(2) {
[0]=>
string(9) "0:request"
[1]=>
string(8) "1:format"
}

@ -0,0 +1,44 @@
--TEST--
Symfony pattern: array_map with first-class static method callable
--FILE--
<?php
class SymfonyLikeScheduleRenderer
{
public static function render(string $message, DateTimeImmutable $date, bool $all): ?array
{
if (!$all && str_starts_with($message, 'skip')) {
return null;
}
return [$message, $date->format('Y-m-d'), $all];
}
public static function renderAll(array $messages, DateTimeImmutable $date, bool $all): array
{
return array_filter(array_map(
self::render(...),
$messages,
array_fill(0, count($messages), $date),
array_fill(0, count($messages), $all)
));
}
}
function main(): void
{
var_dump(SymfonyLikeScheduleRenderer::renderAll(['first', 'skip-second'], new DateTimeImmutable('2026-07-07'), false));
}
?>
--EXPECT--
array(1) {
[0]=>
array(3) {
[0]=>
string(5) "first"
[1]=>
string(10) "2026-07-07"
[2]=>
bool(false)
}
}

@ -1,35 +0,0 @@
--TEST--
Symfony pattern: attribute array arguments
--SKIPIF--
<?php
exit('skip Array arguments to attributes are not supported by the AOT compiler');
?>
--FILE--
<?php
#[Attribute(Attribute::TARGET_CLASS)]
final class SymfonyLikeRoute
{
public function __construct(public array $methods = [])
{
}
}
#[SymfonyLikeRoute(methods: ['GET', 'POST'])]
class SymfonyLikeController
{
}
function main(): void
{
$route = (new ReflectionClass(SymfonyLikeController::class))->getAttributes(SymfonyLikeRoute::class)[0]->newInstance();
var_dump($route->methods);
}
?>
--EXPECT--
array(2) {
[0]=>
string(3) "GET"
[1]=>
string(4) "POST"
}

@ -0,0 +1,35 @@
--TEST--
Symfony pattern: attribute named arguments with class constant values
--FILE--
<?php
#[Attribute(Attribute::TARGET_CLASS)]
class SymfonyLikeAlias
{
public function __construct(
public string $id,
public ?string $when = null,
) {
}
}
interface SymfonyLikeContract
{
}
#[SymfonyLikeAlias(id: SymfonyLikeContract::class, when: 'dev')]
class SymfonyLikeImplementation implements SymfonyLikeContract
{
}
function main(): void
{
$attribute = (new ReflectionClass(SymfonyLikeImplementation::class))->getAttributes(SymfonyLikeAlias::class)[0]->newInstance();
var_dump($attribute->id);
var_dump($attribute->when);
}
?>
--EXPECT--
string(19) "SymfonyLikeContract"
string(3) "dev"

@ -0,0 +1,40 @@
--TEST--
Symfony pattern: filter result with coalesce throw expression
--FILE--
<?php
class SymfonyLikeParameterBag
{
public function __construct(private array $parameters)
{
}
public function filter(string $key, mixed $default, int $filter, array $options): mixed
{
return filter_var($this->parameters[$key] ?? $default, $filter, $options);
}
public function getInt(string $key, int $default = 0): int
{
return $this->filter($key, $default, FILTER_VALIDATE_INT, ['flags' => FILTER_REQUIRE_SCALAR | FILTER_NULL_ON_FAILURE])
?? throw new UnexpectedValueException(sprintf('Parameter value "%s" cannot be converted to "int".', $key));
}
}
function main(): void
{
$bag = new SymfonyLikeParameterBag(['limit' => '42', 'bad' => 'nope']);
var_dump($bag->getInt('limit'));
try {
$bag->getInt('bad');
} catch (Throwable $e) {
var_dump($e::class);
var_dump($e->getMessage());
}
}
?>
--EXPECT--
int(42)
string(24) "UnexpectedValueException"
string(51) "Parameter value "bad" cannot be converted to "int"."

@ -0,0 +1,43 @@
--TEST--
Symfony pattern: match array filtered then unioned with base arguments
--FILE--
<?php
function schedulerArgs(array $tagAttributes, string $serviceId): array
{
return [
'$message' => $serviceId,
] + array_filter(match ($tagAttributes['trigger'] ?? throw new InvalidArgumentException(sprintf('missing trigger for "%s"', $serviceId))) {
'every' => [
'$frequency' => $tagAttributes['frequency'] ?? throw new InvalidArgumentException(sprintf('missing frequency for "%s"', $serviceId)),
'$from' => $tagAttributes['from'] ?? null,
'$until' => $tagAttributes['until'] ?? null,
],
'cron' => [
'$expression' => $tagAttributes['expression'] ?? throw new InvalidArgumentException(sprintf('missing expression for "%s"', $serviceId)),
'$timezone' => $tagAttributes['timezone'] ?? null,
],
}, static fn ($value) => null !== $value);
}
function main(): void
{
var_dump(schedulerArgs(['trigger' => 'every', 'frequency' => '1 hour', 'from' => null], 'task.one'));
var_dump(schedulerArgs(['trigger' => 'cron', 'expression' => '* * * * *', 'timezone' => 'UTC'], 'task.two'));
}
?>
--EXPECT--
array(2) {
["$message"]=>
string(8) "task.one"
["$frequency"]=>
string(6) "1 hour"
}
array(3) {
["$message"]=>
string(8) "task.two"
["$expression"]=>
string(9) "* * * * *"
["$timezone"]=>
string(3) "UTC"
}

@ -0,0 +1,41 @@
--TEST--
Symfony pattern: uasort with spaceship and elvis fallback comparison
--FILE--
<?php
class SymfonyLikeAcceptItem
{
public function __construct(private float $quality, private int $index, public string $name)
{
}
public function getQuality(): float
{
return $this->quality;
}
public function getIndex(): int
{
return $this->index;
}
}
function main(): void
{
$items = [
'json' => new SymfonyLikeAcceptItem(0.9, 2, 'json'),
'html' => new SymfonyLikeAcceptItem(1.0, 1, 'html'),
'xml' => new SymfonyLikeAcceptItem(0.9, 0, 'xml'),
];
uasort($items, static fn ($a, $b) => $b->getQuality() <=> $a->getQuality() ?: $a->getIndex() <=> $b->getIndex());
foreach ($items as $item) {
var_dump($item->name);
}
}
?>
--EXPECT--
string(4) "html"
string(3) "xml"
string(4) "json"
Loading…
Cancel
Save