feat(parser): add support for nested array unset operations

- Implemented parseNestedArrayUnset method to handle chained array dimension unsets
- Added logic to process nested ArrayDimFetch expressions in unset statements
- Created proper dimension parsing and code generation for nested array operations
- Maintained PHP evaluation order for key materialization in nested unsets
- Added comprehensive test coverage for nested unset chain behavior
- Extended compiler tests to verify correct handling of complex unset scenarios
master
韩天峰 3 weeks ago
parent ab01b2c5c9
commit 9bc716a54c
  1. 164
      examples/ssh-tunnel-qt/tests/start_all_test.php
  2. 40
      src/Parser/PropertyAccessTrait.php
  3. 66
      tests/compiler/basic/unset-nested-chain.phpt
  4. 37
      tests/compiler/basic/unset-typed-property-in-array.phpt

@ -0,0 +1,164 @@
<?php
require dirname(__DIR__) . '/app/TunnelRule.php';
require dirname(__DIR__) . '/app/TunnelRepository.php';
require dirname(__DIR__) . '/app/SshOutputParser.php';
$scriptedEvents = [];
$startedRuleIds = [];
$startAttempts = [];
$renderedRows = [];
$shownErrors = [];
function qt_tunnel_create(string $title): mixed
{
return new stdClass();
}
function qt_tunnel_is_open(mixed $window): bool
{
global $scriptedEvents;
return $scriptedEvents !== [];
}
function qt_tunnel_process_events(mixed $window): void {}
function qt_tunnel_poll_event(mixed $window): array
{
global $scriptedEvents;
return array_shift($scriptedEvents) ?? [];
}
function qt_tunnel_set_rules(mixed $window, array $rules): void
{
global $renderedRows;
$renderedRows = $rules;
}
function qt_tunnel_start_process(
mixed $window,
string $id,
string $program,
array $arguments
): bool {
global $startedRuleIds, $startAttempts;
$startAttempts[$id] = ($startAttempts[$id] ?? 0) + 1;
// The "broken" rule starts once so the application comes up, then fails
// on every later attempt to exercise start-all error isolation.
if ($id === 'broken' && $startAttempts[$id] > 1) {
return false;
}
$startedRuleIds[] = $id;
return true;
}
function qt_tunnel_stop_process(mixed $window, string $id): void {}
function qt_tunnel_append_log(mixed $window, string $id, string $message): void {}
function qt_tunnel_show_error(mixed $window, string $message): void
{
global $shownErrors;
$shownErrors[] = $message;
}
function qt_tunnel_destroy(mixed $window): void {}
require dirname(__DIR__) . '/app/TunnelApplication.php';
function start_all_rule(string $id): TunnelRule
{
return new TunnelRule([
'id' => $id,
'name' => $id,
'type' => TunnelRule::TYPE_LOCAL,
'ssh_host' => 'gateway.example.com',
'ssh_port' => 22,
'ssh_user' => 'deploy',
'identity_file' => '',
'local_host' => '127.0.0.1',
'local_port' => 8080,
'remote_host' => '127.0.0.1',
'remote_port' => 3000,
]);
}
function start_all_event(string $type, string $id): array
{
return $type === 'start_all' ? ['type' => $type] : ['type' => $type, 'id' => $id];
}
function statuses_by_id(array $rows): array
{
$statuses = [];
foreach ($rows as $row) {
$statuses[$row['id']] = $row['status'];
}
return $statuses;
}
$file = sys_get_temp_dir() . '/typephp-ssh-start-all-' . uniqid('', true) . '.json';
$repository = new TunnelRepository($file);
foreach (['a', 'b', 'c', 'broken'] as $id) {
$repository->create(start_all_rule($id));
}
$scriptedEvents = [
start_all_event('process_stopped', 'a'),
start_all_event('process_stopped', 'b'),
start_all_event('process_stopped', 'broken'),
start_all_event('start_all', ''),
];
$application = new TunnelApplication($repository);
$result = $application->run();
if ($result !== 0) {
throw new RuntimeException('application run must return 0');
}
if ($startedRuleIds !== ['a', 'b', 'c', 'broken', 'a', 'b']) {
throw new RuntimeException(
'start all must restart every stopped rule once and skip transitioning ones, got '
. var_export($startedRuleIds, true)
);
}
if (count($shownErrors) !== 1 || !str_contains($shownErrors[0], 'broken')) {
throw new RuntimeException(
'a failing rule must be reported without aborting the remaining starts, got '
. var_export($shownErrors, true)
);
}
if (statuses_by_id($renderedRows) !== [
'a' => 'starting',
'b' => 'starting',
'c' => 'starting',
'broken' => 'error',
]) {
throw new RuntimeException('unexpected statuses after start all: ' . var_export($renderedRows, true));
}
// A second start-all with every rule running must be a no-op: the button is
// disabled in the UI, and the controller must tolerate the event anyway.
$startedRuleIds = [];
$startAttempts = [];
$shownErrors = [];
$scriptedEvents = [
start_all_event('process_started', 'a'),
start_all_event('process_started', 'b'),
start_all_event('process_started', 'c'),
start_all_event('process_started', 'broken'),
start_all_event('start_all', ''),
];
$application = new TunnelApplication($repository);
$result = $application->run();
if ($result !== 0 || $startedRuleIds !== ['a', 'b', 'c', 'broken'] || $shownErrors !== []) {
throw new RuntimeException(
'start all must not touch running rules, got '
. var_export([$startedRuleIds, $shownErrors], true)
);
}
unlink($file);
unlink($file . '.bak');
echo "ssh-tunnel start-all tests passed\n";

@ -1023,6 +1023,8 @@ trait PropertyAccessTrait
$lines[] = $this->parseTypedArrayUnset($var) . ';';
} elseif ($this->isStdContainerExpr($var)) {
$lines[] = $this->parseStdContainerOffsetUnset($var) . ';';
} elseif ($var->var instanceof Expr\ArrayDimFetch) {
$lines[] = $this->parseNestedArrayUnset($var) . ';';
} else {
$array = $this->parseIdentifier($var->var);
$dim = $this->parseIdentifier($var->dim);
@ -1122,6 +1124,44 @@ trait PropertyAccessTrait
return implode(PHP_EOL . $this->getIndent(), $lines);
}
/**
* Lower nested unset as one operation chain. Unlike an assignment, unset
* must neither copy an intermediate array nor create a missing offset.
* Materialize the root and every key in PHP evaluation order, even when
* a missing intermediate slot later makes the mutation a no-op.
*/
private function parseNestedArrayUnset(Expr\ArrayDimFetch $expr): string
{
$dimensions = [];
$root = $expr;
while ($root instanceof Expr\ArrayDimFetch) {
if ($root->dim === null) {
$this->fatalError($root, 'Cannot use [] for array unset');
}
array_unshift($dimensions, $root->dim);
$root = $root->var;
}
[$rootCode, $rootBefore, $rootAfter] = $this->parseExprWithCapturedStmts($root);
$rootName = $this->genTmpVarName();
$body = $rootBefore;
$body[] = 'auto &&' . $rootName . ' = ' . $rootCode . ';';
$operations = [];
$cleanup = [];
foreach ($dimensions as $dimension) {
[$keyCode, $keyBefore, $keyAfter] = $this->parseExprWithCapturedStmts($dimension);
array_push($body, ...$keyBefore);
$keyName = $this->genTmpVarName();
$body[] = 'php::Var ' . $keyName . ' = ' . $keyCode . ';';
$operations[] = '{php::ArrayDimFetch, ' . $keyName . '}';
array_push($cleanup, ...$keyAfter);
}
$body[] = 'php::unset(' . $rootName . ', {' . implode(', ', $operations) . '});';
array_push($body, ...$cleanup, ...$rootAfter);
return '[&]() {' . PHP_EOL . implode(PHP_EOL, $body) . PHP_EOL . '}()';
}
protected function getPropertyIdentifier(Expr\PropertyFetch $expr, NodeAbstract $object, NodeAbstract $property): ?string
{
$target = $this->resolveInstancePropertyFetchTarget($object, $property);

@ -0,0 +1,66 @@
--TEST--
unset nested array offsets mutates original, skips missing parents, and evaluates keys in order
--FILE--
<?php
class NestedUnsetHolder {
public array $items = ['outer' => ['remove' => 1, 'keep' => 2]];
}
function unset_chain_key(string $key): string {
echo "key:$key\n";
return $key;
}
function main(): void {
$items = ['outer' => ['deep' => ['remove' => 1, 'keep' => 2]]];
$copy = $items;
unset($items['outer']['deep']['remove']);
var_dump($items['outer']['deep']);
var_dump($copy['outer']['deep']['remove']);
unset($items[unset_chain_key('missing')][unset_chain_key('leaf')]);
var_dump(isset($items['missing']));
$items['empty'] = null;
unset($items['empty']['leaf']);
var_dump($items['empty']);
$items[''] = ['remove' => 9];
unset($items[null]['remove']);
var_dump($items['']);
$holder = new NestedUnsetHolder();
unset($holder->items['outer']['remove']);
var_dump($holder->items['outer']);
unset($items[unset_chain_key('outer')][unset_chain_key('deep')][unset_chain_key('keep')],
$holder->items[unset_chain_key('outer')][unset_chain_key('keep')]);
var_dump($items['outer']['deep'], $holder->items['outer']);
}
?>
--EXPECT--
array(1) {
["keep"]=>
int(2)
}
int(1)
key:missing
key:leaf
bool(false)
NULL
array(0) {
}
array(1) {
["keep"]=>
int(2)
}
key:outer
key:deep
key:keep
key:outer
key:keep
array(0) {
}
array(0) {
}

@ -0,0 +1,37 @@
--TEST--
unset fixed scalar and array properties of objects stored in arrays restores empty values
--FILE--
<?php
class ArrayElementWithTypedProperties {
public int $number = 9;
public float $fraction = 1.5;
public bool $enabled = true;
public string $label = 'initial';
public array $values = [1, 2];
}
function main(): void {
$objects = [new ArrayElementWithTypedProperties()];
$nested = ['group' => $objects];
unset($objects[0]->number);
unset($objects[0]->fraction);
unset($objects[0]->enabled);
unset($nested['group'][0]->label);
unset($nested['group'][0]->values);
var_dump($objects[0]->number, $objects[0]->fraction, $objects[0]->enabled);
var_dump($objects[0]->label, $objects[0]->values);
var_dump(isset($objects[0]->number), isset($objects[0]->label));
}
?>
--EXPECT--
int(0)
float(0)
bool(false)
string(0) ""
array(0) {
}
bool(true)
bool(true)
Loading…
Cancel
Save