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
53 changes: 53 additions & 0 deletions phpunit/code/arrayaccess-coalesce-assign-codegen.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

class CodegenArrayAccessBag implements ArrayAccess
{
public function offsetExists(mixed $offset): bool { return false; }
public function offsetGet(mixed $offset): mixed { return null; }
public function offsetSet(mixed $offset, mixed $value): void {}
public function offsetUnset(mixed $offset): void {}
}

class CodegenArrayAccessHolder
{
public function __construct(public mixed $value) {}

public function __get(string $name): mixed
{
return $this->value;
}
}

function coalesceArrayAccess(CodegenArrayAccessBag $bag, string $key): mixed
{
return $bag[$key] ??= 42;
}

function coalesceMixedArrayAccess(mixed &$container, string $key): mixed
{
return $container[$key] ??= 43;
}

function coalesceMagicArrayAccess(CodegenArrayAccessHolder $holder, string $key): mixed
{
return $holder->virtual[$key] ??= 44;
}

function coalesceFixedArray(string $key): mixed
{
$container = [];
return $container[$key] ??= 45;
}

function replaceCodegenArray(mixed &$container, string &$key): int
{
$container = new CodegenArrayAccessBag();
$key = 'replacement';
return 46;
}

function coalesceMutableFixedArray(string $key): mixed
{
$container = [];
return $container[$key] ??= replaceCodegenArray($container, $key);
}
80 changes: 80 additions & 0 deletions phpunit/src/ArrayAccessCoalesceAssignCodegenTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

use TypePhp\CompilerTest;

final class ArrayAccessCoalesceAssignCodegenTest extends \BaseTest
{
public function testObjectTargetSeparatesPresenceReadAndWrite(): void
{
$code = $this->compileFixture();
$body = $this->extractFunctionBody($code, 'php::Var php_coalescearrayaccess(');

self::assertSame(1, substr_count($body, '.offsetExists('));
self::assertSame(1, substr_count($body, '.offsetGet('));
self::assertSame(1, substr_count($body, '.offsetSet(key,'));
self::assertStringContainsString('.isObject()', $body);
self::assertStringContainsString('.isArray()', $body);
}

public function testMixedTargetRetainsArrayAndObjectDispatch(): void
{
$code = $this->compileFixture();
$body = $this->extractFunctionBody($code, 'php::Var php_coalescemixedarrayaccess(');

self::assertStringContainsString('.isObject()', $body);
self::assertStringContainsString('php::exists(', $body);
self::assertStringContainsString('.isArray()', $body);
self::assertStringContainsString('.offsetSet(key,', $body);
}

public function testMagicContainerIsEvaluatedOncePerReadAndWritePhase(): void
{
$code = $this->compileFixture();
$body = $this->extractFunctionBody($code, 'php::Var php_coalescemagicarrayaccess(');

self::assertSame(2, substr_count($body, 'typephp_read_property_cached(holder,'));
self::assertStringContainsString('[&](auto &&', $body);
}

public function testFixedArrayKeepsDirectFastPathWhenItsTypeCannotChange(): void
{
$code = $this->compileFixture();
$body = $this->extractFunctionBody($code, 'php::Var php_coalescefixedarray(');

self::assertStringContainsString('.item(key, true)', $body);
self::assertStringNotContainsString('.isObject()', $body);
self::assertStringNotContainsString('.isArray()', $body);
}

public function testFixedArrayUsesRuntimeDispatchWhenRhsCanReplaceIt(): void
{
$code = $this->compileFixture();
$body = $this->extractFunctionBody($code, 'php::Var php_coalescemutablefixedarray(');

self::assertStringContainsString('.isObject()', $body);
self::assertStringContainsString('.isArray()', $body);
self::assertStringContainsString('.offsetSet(key,', $body);
}

private function extractFunctionBody(string $code, string $signature): string
{
$start = strpos($code, $signature);
self::assertIsInt($start, "missing function: {$signature}");
$end = strpos($code, "\n}", $start);
self::assertIsInt($end);
return substr($code, $start, $end - $start);
}

private function compileFixture(): string
{
$compiler = CompilerTest::create(TYPEPHP_ROOT_PATH);
$source = TYPEPHP_ROOT_PATH . '/phpunit/code/arrayaccess-coalesce-assign-codegen.php';
$compiler->addFiles([$source]);
$compiler->prepareFile($source);
$generated = $compiler->convertFile($source);
$code = file_get_contents($generated);

self::assertIsString($code);
return $code;
}
}
189 changes: 136 additions & 53 deletions src/Parser/AssignOpTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -1625,6 +1625,12 @@ protected function parseAssignOpCoalesce(Expr\AssignOp\Coalesce $expr): string
$this->assertNativeArrayAccessDirectWrite($expr->var, false);
$this->checkLeftValue($expr->var);

$arrayContainerCanChange = $expr->var instanceof Expr\ArrayDimFetch
&& $expr->var->dim !== null
&& ($this->shouldMaterializeOrderedOperand($expr->var->var)
|| $this->shouldMaterializeOrderedOperand($expr->var->dim)
|| $this->shouldMaterializeOrderedOperand($expr->expr));

// Zend evaluates the target's receiver and array keys exactly once,
// before the isset check and regardless of its outcome. The lowering
// below mentions the target several times (isset, read, write), so
Expand Down Expand Up @@ -1686,11 +1692,19 @@ protected function parseAssignOpCoalesce(Expr\AssignOp\Coalesce $expr): string
}
}

$isset = $var !== null && $this->isNativeObjectVar($var)
? $var . ' != nullptr'
: $this->parseChainedExpr($expr->var, self::OP_ISSET);

$var ??= $this->parseWritableIdentifier($expr->var);
$arrayAccessTarget = $this->resolveCoalesceArrayAccessTarget(
$expr->var,
$arrayContainerCanChange,
);
if ($var !== null && $this->isNativeObjectVar($var)) {
$isset = $var . ' != nullptr';
} elseif ($arrayAccessTarget !== null) {
$var = $this->addTmpVar(Type::VAR);
$isset = $this->parseArrayAccessCoalescePresence($arrayAccessTarget, $var);
} else {
$isset = $this->parseChainedExpr($expr->var, self::OP_ISSET);
$var ??= $this->parseWritableIdentifier($expr->var);
}
$propertyWriteTarget = $this->preparePropertyWriteTarget($expr->var);

if ($propertyWriteTarget !== null) {
Expand Down Expand Up @@ -1719,7 +1733,6 @@ protected function parseAssignOpCoalesce(Expr\AssignOp\Coalesce $expr): string
$this->errorUndefinedVariable($expr->expr);
}

$arrayAccessTarget = $this->resolveCoalesceArrayAccessTarget($expr->var);
if ($arrayAccessTarget !== null) {
return $this->emitCoalesceArrayAccessAssignment(
$arrayAccessTarget,
Expand Down Expand Up @@ -1775,79 +1788,149 @@ protected function parseAssignOpCoalesce(Expr\AssignOp\Coalesce $expr): string
}

/**
* ArrayAccess dimensions do not expose writable buckets: offsetGet()
* returns a value, while a write must dispatch through offsetSet(). Keep
* ordinary arrays on the existing lvalue path so assignments into array
* references continue to update the referenced bucket in place.
*
* @return array{container: string, key: string, objectCondition: string}|null
* A fixed php::Array exposes a real bucket lvalue through item(), but an
* object or dynamically represented container may dispatch [] through
* ArrayAccess. Its offsetGet() result is a value, not the write target;
* the not-set branch must therefore use offsetSet().
*/
private function resolveCoalesceArrayAccessTarget(Expr $target): ?array
{
private function resolveCoalesceArrayAccessTarget(
Expr $target,
bool $includeFixedArray,
): ?Expr\ArrayDimFetch {
if (!$target instanceof Expr\ArrayDimFetch
|| $target->dim === null
|| !$this->isVarExpr($target->var)
|| $this->isStdContainerExpr($target)
|| $this->isNativeObjectClass($this->detectClassOfExpr($target->var))
) {
return null;
}

$container = $this->parseIdentifier($target->var);
$containerType = $this->getVarType($container);
if (!in_array($containerType, [Type::OBJECT, Type::VAR, Type::REF], true)) {
return null;
$types = [Type::OBJECT, Type::VAR, Type::REF];
if ($includeFixedArray) {
// A key/container expression or the RHS can pass the source
// variable by reference and replace an inferred php::Array with an
// ArrayAccess object. Keep the direct array lvalue fast path only
// when no intervening expression can change its representation.
$types[] = Type::ARRAY;
}

return [
'container' => $container,
'key' => $this->parseIdentifier($target->dim),
// Even a statically object-typed PHP variable may currently hold
// null. PHP converts that null to an array on dimension write, so
// only the runtime object case may dispatch through offsetSet().
'objectCondition' => $container . '.isObject()',
];
return in_array($this->detectTypeOfExpr($target->var), $types, true)
? $target
: null;
}

/**
* @param array{container: string, key: string, objectCondition: string} $target
* Keep the coalesce read and write operations separate for a target that
* can be ArrayAccess at runtime. The presence expression supplies the
* already-read hit value. The miss branch completes the RHS before
* dispatching the write, exactly like the general captured-RHS path.
*
* @param list<string> $rightBefore
* @param list<string> $rightAfter
*/
private function emitCoalesceArrayAccessAssignment(
array $target,
Expr\ArrayDimFetch $target,
string $isset,
string $readTarget,
string $selectedValue,
string $right,
array $rightBefore,
array $rightAfter,
): string {
$current = $this->genTmpVarName();
$rhs = $this->genTmpVarName();
$container = $target['container'];
$key = $target['key'];
$isObject = $this->genTmpVarName();
$rhs = $this->addTmpVar(Type::VAR);
$store = $this->parseArrayAccessCoalesceStore($target, $rhs);

$code = '[&]() -> php::Var {' . PHP_EOL;
$code .= $this->getIndent() . 'const bool ' . $isObject . ' = '
. $target['objectCondition'] . ';' . PHP_EOL;
$code .= $this->getIndent() . 'if (' . $isset . ') {' . PHP_EOL;
$code .= $this->getIndent(2) . 'php::Var ' . $current . ' = ' . $isObject
. ' ? ' . $container . '.offsetGet(' . $key . ') : ' . $readTarget . ';' . PHP_EOL;
$code .= $this->getIndent(2) . 'if (!' . $current . '.isNull()) {' . PHP_EOL;
$code .= $this->getIndent(3) . 'return ' . $current . ';' . PHP_EOL;
$code .= $this->getIndent(2) . '}' . PHP_EOL;
$code .= $this->getIndent() . '}' . PHP_EOL;
$code .= $this->getIndent() . 'if (' . $isset . ') { return ' . $selectedValue . '; }' . PHP_EOL;
$code .= $this->formatCapturedStmtLines($rightBefore);
$code .= $this->getIndent() . 'php::Var ' . $rhs . ' = ' . $right . ';' . PHP_EOL;
$code .= $this->getIndent() . $rhs . ' = ' . $right . ';' . PHP_EOL;
$code .= $this->formatCapturedStmtLines($rightAfter);
$code .= $this->getIndent() . 'if (' . $isObject . ') {' . PHP_EOL;
$code .= $this->getIndent(2) . $container . '.offsetSet(' . $key . ', ' . $rhs . ');' . PHP_EOL;
$code .= $this->getIndent() . '} else {' . PHP_EOL;
$code .= $this->getIndent(2) . $readTarget . ' = ' . $rhs . ';' . PHP_EOL;
$code .= $this->getIndent() . '}' . PHP_EOL;
$code .= $this->getIndent() . $store . ';' . PHP_EOL;
$code .= $this->getIndent() . 'return ' . $rhs . ';' . PHP_EOL;
$code .= $this->getIndent() . '}()';
return $code;
return $code . $this->getIndent() . '}()';
}

/**
* Read a stabilized ArrayAccess-capable target without routing the object
* through php::exists(..., result). That generic chain first invokes the
* object's has-dimension handler and then performs an IS-mode read, which
* invokes offsetExists() a second time. Zend's ??= calls offsetExists()
* once, calls offsetGet() only after a positive result, and still treats a
* null offsetGet() result as a miss.
*
* A referenced source variable can also change representation while a key
* expression or offsetExists() runs. Keep non-object values on the generic
* chain path; only the phase snapshot's runtime object uses the explicit
* ArrayAccess sequence.
*/
private function parseArrayAccessCoalescePresence(
Expr\ArrayDimFetch $target,
string $selectedValue,
): string {
if ($this->isVarExpr($target->var)) {
$container = $this->parseIdentifier($target->var);
$this->checkVarMustExist($target->var, $container);
$containerPresence = null;
} elseif ($target->var instanceof Expr\ArrayDimFetch && $target->var->dim !== null) {
// Apply the same phase semantics recursively. The generic chain
// walker performs an IS-mode read after its has-dimension check,
// which invokes offsetExists() twice on intermediate ArrayAccess.
$container = $this->addTmpVar(Type::VAR);
$containerPresence = $this->parseArrayAccessCoalescePresence(
$target->var,
$container,
);
} else {
// A property/call result is evaluated as a value. A writable read
// here would create missing outer array buckets before the RHS.
$container = $this->parseIdentifier($target->var);
$containerPresence = null;
}
$key = $this->parseIdentifier($target->dim);

// Snapshot the container and key for the complete presence/read
// phase. offsetExists() may rebind either source variable, but Zend
// still invokes offsetGet() on the same object with the same key. The
// write phase deliberately evaluates the source expressions again.
$stableContainer = $this->genTmpVarName();
$stableKey = $this->genTmpVarName();

$objectPresence = '(' . $stableContainer . '.offsetExists(' . $stableKey . ')'
. ' && ((' . $selectedValue . ' = ' . $stableContainer . '.offsetGet(' . $stableKey . ')),'
. ' !' . $selectedValue . '.isNull()))';
$otherPresence = 'php::exists(' . $stableContainer . ', '
. '{{php::ArrayDimFetch, ' . Type::VAR . '(' . $stableKey . ')}}, '
. $selectedValue . ')';
$presence = '(' . $stableContainer . '.isObject() ? '
. $objectPresence . ' : ' . $otherPresence . ')';

$probe = '([&](' . Type::VAR . ' ' . $stableContainer . ') { '
. Type::VAR . ' ' . $stableKey . ' = ' . $key . '; '
. 'return ' . $presence . '; })(' . $container . ')';
return $containerPresence === null
? $probe
: '(' . $containerPresence . ' && ' . $probe . ')';
}

/**
* Write a stabilized array-dimension target without treating offsetGet()
* as an lvalue. The key expression, offsetExists(), or RHS may replace the
* source container through an alias, so dispatch using the value that is
* current at the write phase. item(..., true) retains reference-bucket
* semantics for the runtime array case.
*/
private function parseArrayAccessCoalesceStore(Expr\ArrayDimFetch $target, string $value): string
{
$container = $this->parseWritableIdentifier($target->var);
$key = $this->parseIdentifier($target->dim);
$stableContainer = $this->genTmpVarName();

$arrayWrite = 'static_cast<void>(' . $stableContainer . '.item('
. $key . ', true) = ' . $value . ')';
$objectWrite = $stableContainer . '.offsetSet(' . $key . ', ' . $value . ')';
$store = '(' . $stableContainer . '.isArray() ? '
. $arrayWrite . ' : ' . $objectWrite . ')';

return '[&](auto &&' . $stableContainer . ') { ' . $store . '; }('
. $container . ')';
}

/**
Expand Down
Loading
Loading