test: 添加 Symfony 模式及引用相关测试

pull/14/head
韩天峰 2 months ago
parent 952210f1ff
commit 6baaa9a221
  1. 3
      docs/INCOMPATIBLE_PHP_FEATURES.md
  2. 26
      tests/aot/ref/unset.phpt
  3. 43
      tests/aot/symfony/closure-bind-reference-property.phpt
  4. 57
      tests/aot/symfony/configurator-magic-call-dynamic-setter.phpt
  5. 66
      tests/aot/symfony/container-dynamic-registry-coalesce.phpt
  6. 49
      tests/aot/symfony/container-static-first-class-fallback.phpt
  7. 35
      tests/aot/symfony/dynamic-first-class-callable-branch.phpt
  8. 56
      tests/aot/symfony/lazy-string-array-callable.phpt
  9. 54
      tests/aot/symfony/routing-object-vars-json-tail.phpt
  10. 58
      tests/aot/symfony/serialize-unserialize-private-state.phpt
  11. 29
      tests/aot/symfony/uid-binary-string-carry.phpt
  12. 61
      tests/aot/symfony/validator-serializer-array-callbacks.phpt
  13. 49
      tests/aot/symfony/var-exporter-clone-cache.phpt
  14. 53
      tests/aot/symfony/var-exporter-reference-clone-cache.phpt
  15. 63
      tests/aot/symfony/vardumper-static-property-default-cache.phpt

@ -16,7 +16,7 @@
- 不支持 `yield` / `yield from`
- 不支持可变变量 `$$var`
- 不支持 PHP 8.4 property hooks。
- 不支持函数或方法按引用返回。
- 不支持函数、方法、闭包或箭头函数按引用返回。
- `__construct()` 不允许返回值。
- 参数默认值不允许出现在必填参数之前(`PHP`允许,但会直接丢弃此默认参数)。
- 不支持引用可变参数 `&...$args`
@ -34,6 +34,7 @@
## 调用与引用
- 闭包和箭头函数不支持引用参数。
- 引用赋值的右侧必须是编译器可直接定位的变量、数组元素或对象属性;不支持从调用结果或复杂静态属性表达式建立引用。
- 动态调用、闭包调用等编译期无法确定参数签名的调用,不能自动转换引用参数;需要显式使用 `refval()`
- `refval()` 只接受变量、数组元素或对象属性。
- 带 unpack 且尾部追加 named arguments 的调用会退化为动态调用,不能使用 native call。

@ -0,0 +1,26 @@
--TEST--
object link operator
--FILE--
<?php
function main()
{
$a = 1;
$b = &$a;
$b = 2;
var_dump($a, $b);
unset($b);
var_dump($a);
var_dump(isset($b));
// var_dump($b ?? 123); // 修复前这个报错
$b = 1;
var_dump($b, $a);
}
?>
--EXPECT--
int(2)
int(2)
int(2)
bool(false)
int(1)
int(2)

@ -0,0 +1,43 @@
--TEST--
Symfony pattern: Closure::bind returns reference to private property
--SKIPIF--
<?php
exit("skip: returning by reference from a closure is not supported in AOT");
?>
--FILE--
<?php
final class ReferenceConfigurator
{
private array $instanceof = [
'base' => 'service',
];
public function getReferenceAccessor(): Closure
{
return Closure::bind(fn &() => $this->instanceof, $this, self::class);
}
public function dump(): void
{
foreach ($this->instanceof as $key => $value) {
var_dump($key.':'.$value);
}
}
}
function main(): void
{
$configurator = new ReferenceConfigurator();
$accessor = $configurator->getReferenceAccessor();
$instanceof = &$accessor();
$instanceof['extra'] = 'listener';
$instanceof['base'] = strtoupper($instanceof['base']);
$configurator->dump();
}
?>
--EXPECT--
string(12) "base:SERVICE"
string(14) "extra:listener"

@ -0,0 +1,57 @@
--TEST--
Symfony pattern: __call forwards to concatenated dynamic setter with unpack
--FILE--
<?php
final class Configurator
{
private array $values = [];
public function __call(string $method, array $args): mixed
{
if (method_exists($this, 'set'.$method)) {
return $this->{'set'.$method}(...$args);
}
throw new BadMethodCallException(sprintf('Call to undefined method "%s::%s()".', static::class, $method));
}
private function setOption(string $name, mixed $value, bool $append = false): static
{
if ($append) {
$this->values[$name][] = $value;
} else {
$this->values[$name] = $value;
}
return $this;
}
public function all(): array
{
return $this->values;
}
}
function main(): void
{
$configurator = new Configurator();
$configurator->Option('debug', true);
$configurator->Option('tags', 'console', true);
$configurator->Option(...['tags', 'worker', true]);
var_dump($configurator->all());
}
?>
--EXPECT--
array(2) {
["debug"]=>
bool(true)
["tags"]=>
array(2) {
[0]=>
string(7) "console"
[1]=>
string(6) "worker"
}
}

@ -0,0 +1,66 @@
--TEST--
Symfony pattern: dynamic registry property with array coalesce assignment
--FILE--
<?php
final class RegistryContainer
{
public array $services = [];
public array $privates = [];
public array $factories = [];
public function getService(string|false $registry, string $id, ?string $method, string|bool $load): mixed
{
if ('service_container' === $id) {
return $this;
}
if (is_string($load)) {
throw new RuntimeException($load);
}
if (null === $method) {
return false !== $registry ? $this->{$registry}[$id] ?? null : null;
}
if (false !== $registry) {
return $this->{$registry}[$id] ??= $load ? $this->load($method) : $this->{$method}($this);
}
if (!$load) {
return $this->{$method}($this);
}
return ($factory = $this->factories[$id] ?? $this->factories['service_container'][$id] ?? null) ? $factory($this) : $this->load($method);
}
private function load(string $method): object
{
return $this->{$method}($this);
}
private function createLogger(self $container): object
{
return (object) ['id' => 'logger', 'count' => count($container->services) + count($container->privates)];
}
}
function main(): void
{
$container = new RegistryContainer();
$container->factories['service_container']['mailer'] = static fn (RegistryContainer $container): object => (object) ['id' => 'mailer'];
$logger = $container->getService('services', 'logger', 'createLogger', false);
$sameLogger = $container->getService('services', 'logger', 'createLogger', false);
$privateLogger = $container->getService('privates', 'logger', 'createLogger', true);
$mailer = $container->getService(false, 'mailer', 'createLogger', true);
var_dump($logger === $sameLogger);
var_dump($logger->id, $logger->count);
var_dump($privateLogger->id, $privateLogger->count);
var_dump($mailer->id);
}
?>
--EXPECT--
bool(true)
string(6) "logger"
int(0)
string(6) "logger"
int(1)
string(6) "mailer"

@ -0,0 +1,49 @@
--TEST--
Symfony pattern: container fallback caches static first-class callable
--FILE--
<?php
final class MiniContainer
{
public array $services = [];
public array $aliases = [];
public array $factories = [];
public static mixed $make = null;
public function __construct()
{
$this->factories['custom'] = static fn (self $container): object => (object) ['id' => 'custom'];
}
public function get(string $id): object
{
return $this->services[$id]
?? $this->services[$id = $this->aliases[$id] ?? $id]
?? ('service_container' === $id ? $this : ($this->factories[$id] ?? self::$make ??= self::make(...))($this, $id));
}
public static function make(self $container, string $id): object
{
$service = (object) ['id' => $id];
$container->services[$id] = $service;
return $service;
}
}
function main(): void
{
$container = new MiniContainer();
$container->aliases['logger'] = 'monolog';
var_dump($container->get('logger')->id);
var_dump($container->get('monolog')->id);
var_dump($container->get('custom')->id);
var_dump(MiniContainer::$make instanceof Closure);
}
?>
--EXPECT--
string(7) "monolog"
string(7) "monolog"
string(6) "custom"
bool(true)

@ -0,0 +1,35 @@
--TEST--
Symfony pattern: dynamic first-class callable branch
--FILE--
<?php
final class CallableTarget
{
public function format(string $value): string
{
return 'object:'.strtoupper($value);
}
}
function format_global(string $value): string
{
return 'function:'.strtolower($value);
}
function makeCallable(array $callable): Closure
{
return $callable[0] ? $callable[0]->{$callable[1]}(...) : $callable[1](...);
}
function main(): void
{
$objectCallable = makeCallable([new CallableTarget(), 'format']);
$functionCallable = makeCallable([null, 'format_global']);
var_dump($objectCallable('symfony'));
var_dump($functionCallable('AOT'));
}
?>
--EXPECT--
string(14) "object:SYMFONY"
string(12) "function:aot"

@ -0,0 +1,56 @@
--TEST--
Symfony pattern: lazy string resolves array callable with ??= method name
--FILE--
<?php
final class Formatter
{
public function __invoke(string $value, string $prefix = ''): string
{
return $prefix.strtoupper($value);
}
}
final class LazyString
{
public mixed $value;
public static function fromCallable(array|callable $callback, array $arguments): self
{
$lazyString = new self();
$lazyString->value = static function () use (&$callback, &$arguments): string {
static $value;
if (null !== $arguments) {
if (!is_callable($callback)) {
$callback[0] = $callback[0]();
$callback[1] ??= '__invoke';
}
$value = $callback(...$arguments);
$callback = 'callable';
$arguments = null;
}
return $value;
};
return $lazyString;
}
public function __toString(): string
{
return ($this->value)();
}
}
function main(): void
{
$lazy = LazyString::fromCallable([static fn () => new Formatter()], ['symfony', 'app:']);
var_dump((string) $lazy);
var_dump((string) $lazy);
}
?>
--EXPECT--
string(11) "app:SYMFONY"
string(11) "app:SYMFONY"

@ -0,0 +1,54 @@
--TEST--
Symfony pattern: get_object_vars parameters and array_key_last tail output
--FILE--
<?php
final class RouteParam
{
public function __construct(
public string $slug,
public int $page = 1,
private string $internal = 'hidden',
) {
}
}
function mergeRouteParams(array $defaults, array $parameters): array
{
foreach ($parameters as $key => $value) {
if (is_object($value) && $vars = get_object_vars($value)) {
unset($parameters[$key]);
$parameters += $vars;
}
}
return $parameters + $defaults;
}
function streamJsonParts(array $jsonParts): void
{
echo $jsonParts[array_key_last($jsonParts)];
}
function main(): void
{
var_dump(mergeRouteParams(['_route' => 'blog_show', 'page' => 1], [
'post' => new RouteParam('symfony-aot', 3),
'format' => 'json',
]));
streamJsonParts(['{"items":', '[1,2,3]', '}']);
}
?>
--EXPECT--
array(4) {
["format"]=>
string(4) "json"
["slug"]=>
string(11) "symfony-aot"
["page"]=>
int(3)
["_route"]=>
string(9) "blog_show"
}
}

@ -0,0 +1,58 @@
--TEST--
Symfony pattern: __serialize and __unserialize restore private state
--FILE--
<?php
final class SerializableState
{
private string $resource;
private ?float $expiresAt = null;
private array $state = [];
public function __construct(string $resource)
{
$this->resource = $resource;
}
public function setState(string $store, mixed $value): void
{
$this->state[$store] = $value;
}
public function __serialize(): array
{
return [
'resource' => $this->resource,
'expiresAt' => $this->expiresAt,
'state' => $this->state,
];
}
public function __unserialize(array $data): void
{
$this->resource = $data['resource'];
$this->expiresAt = $data['expiresAt'] ?? null;
$this->state = $data['state'] ?? [];
}
public function describe(): string
{
return $this->resource.':'.implode(',', array_keys($this->state));
}
}
function main(): void
{
$state = new SerializableState('lock-key');
$state->setState('redis', ['token' => 'abc']);
$state->setState('pdo', ['token' => 'def']);
$copy = unserialize(serialize($state));
var_dump($copy instanceof SerializableState);
var_dump($copy->describe());
}
?>
--EXPECT--
bool(true)
string(18) "lock-key:redis,pdo"

@ -0,0 +1,29 @@
--TEST--
Symfony pattern: binary string offset mutation with ord/chr carry
--FILE--
<?php
final class BinaryUtil
{
public static function add(string $a, string $b): string
{
$carry = 0;
for ($i = 7; 0 <= $i; --$i) {
$carry += ord($a[$i]) + ord($b[$i]);
$a[$i] = chr($carry & 0xFF);
$carry >>= 8;
}
return $a;
}
}
function main(): void
{
var_dump(bin2hex(BinaryUtil::add(hex2bin('00000000000000ff'), hex2bin('0000000000000001'))));
var_dump(bin2hex(BinaryUtil::add(hex2bin('ffffffffffffffff'), hex2bin('0000000000000001'))));
}
?>
--EXPECT--
string(16) "0000000000000100"
string(16) "0000000000000000"

@ -0,0 +1,61 @@
--TEST--
Symfony pattern: first-class builtin array_map and variadic context merge
--FILE--
<?php
final class AttributeMetadata
{
private array $normalizationContexts = [];
public function setNormalizationContextForGroups(array $context, array $groups = []): void
{
if (!$groups) {
$this->normalizationContexts['*'] = $context;
}
foreach ($groups as $group) {
$this->normalizationContexts[$group] = $context;
}
}
public function getNormalizationContextForGroups(array $groups): array
{
$contexts = [];
foreach ($groups as $group) {
$contexts[] = $this->normalizationContexts[$group] ?? [];
}
return array_merge($this->normalizationContexts['*'] ?? [], ...$contexts);
}
}
function countWords(array $words): int
{
return count(array_filter(array_map(trim(...), $words), static fn ($word) => '' !== $word));
}
function main(): void
{
var_dump(countWords([' one ', '', " \t ", 'two', ' three ']));
$metadata = new AttributeMetadata();
$metadata->setNormalizationContextForGroups(['skip_null' => true]);
$metadata->setNormalizationContextForGroups(['groups' => ['public']], ['read']);
$metadata->setNormalizationContextForGroups(['max_depth' => 2], ['detail']);
var_dump($metadata->getNormalizationContextForGroups(['read', 'missing', 'detail']));
}
?>
--EXPECT--
int(3)
array(3) {
["skip_null"]=>
bool(true)
["groups"]=>
array(1) {
[0]=>
string(6) "public"
}
["max_depth"]=>
int(2)
}

@ -0,0 +1,49 @@
--TEST--
Symfony pattern: clone object from static cache with null coalescing
--FILE--
<?php
final class CloneRegistry
{
public static array $prototypes = [];
public static function create(string $class): object
{
echo "create:$class\n";
return new $class('created');
}
}
final class CloneableService
{
public function __construct(public string $name)
{
}
}
function getCachedPrototype(string $class): object
{
return clone (CloneRegistry::$prototypes[$class] ?? CloneRegistry::create($class));
}
function main(): void
{
$class = CloneableService::class;
$first = getCachedPrototype($class);
CloneRegistry::$prototypes[$class] = new CloneableService('cached');
$second = getCachedPrototype($class);
$first->name = 'mutated';
$second->name = 'changed';
var_dump($first->name);
var_dump($second->name);
var_dump(CloneRegistry::$prototypes[$class]->name);
}
?>
--EXPECT--
create:CloneableService
string(7) "mutated"
string(7) "changed"
string(6) "cached"

@ -0,0 +1,53 @@
--TEST--
Symfony pattern: reference cache with clone and null coalescing
--SKIPIF--
<?php
exit("skip: assigning a reference from a complex static property expression is not supported in AOT");
?>
--FILE--
<?php
final class PrototypeRegistry
{
public static array $prototypes = [];
public static function create(string $class): object
{
echo "create:$class\n";
return new $class('created');
}
}
final class ExportedService
{
public function __construct(public string $name)
{
}
}
function getPrototype(string $class): object
{
return clone (($p = &PrototypeRegistry::$prototypes)[$class] ?? PrototypeRegistry::create($class));
}
function main(): void
{
$class = ExportedService::class;
$first = getPrototype($class);
PrototypeRegistry::$prototypes[$class] = new ExportedService('cached');
$second = getPrototype($class);
$first->name = 'mutated';
$second->name = 'changed';
var_dump($first->name);
var_dump($second->name);
var_dump(PrototypeRegistry::$prototypes[$class]->name);
}
?>
--EXPECT--
create:ExportedService
string(7) "mutated"
string(7) "changed"
string(6) "cached"

@ -0,0 +1,63 @@
--TEST--
Symfony pattern: static property default cache keyed by runtime class
--XFAIL--
Known AOT bug: coalesce assignment with a ternary right-hand expression can cache the condition result instead of the full ternary value.
--FILE--
<?php
class StubLike
{
private static array $propertyDefaults = [];
public string $name = 'default';
public ?int $count;
public mixed $extra = null;
public function __construct(string $name, ?int $count, mixed $extra = null)
{
$this->name = $name;
$this->count = $count;
$this->extra = $extra;
}
public function __serialize(): array
{
static $noDefault = new stdClass();
$data = [];
foreach ($this as $k => $v) {
$default = self::$propertyDefaults[$this::class][$k] ??= ($p = new ReflectionProperty($this, $k))->hasDefaultValue()
? $p->getDefaultValue()
: ($p->hasType() ? $noDefault : null);
if ($noDefault === $default || $default !== $v) {
$data[$k] = $v;
}
}
return $data;
}
}
function main(): void
{
var_dump((new StubLike('default', null))->__serialize());
var_dump((new StubLike('changed', 3, ['tag']))->__serialize());
}
?>
--EXPECT--
array(1) {
["count"]=>
NULL
}
array(3) {
["name"]=>
string(7) "changed"
["count"]=>
int(3)
["extra"]=>
array(1) {
[0]=>
string(3) "tag"
}
}
Loading…
Cancel
Save