diff --git a/examples/ssh-tunnel-qt/tests/start_all_test.php b/examples/ssh-tunnel-qt/tests/start_all_test.php new file mode 100644 index 00000000..822df166 --- /dev/null +++ b/examples/ssh-tunnel-qt/tests/start_all_test.php @@ -0,0 +1,164 @@ + 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"; diff --git a/src/Parser/PropertyAccessTrait.php b/src/Parser/PropertyAccessTrait.php index 29157990..7cdbba18 100644 --- a/src/Parser/PropertyAccessTrait.php +++ b/src/Parser/PropertyAccessTrait.php @@ -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); diff --git a/tests/compiler/basic/unset-nested-chain.phpt b/tests/compiler/basic/unset-nested-chain.phpt new file mode 100644 index 00000000..8cf33b23 --- /dev/null +++ b/tests/compiler/basic/unset-nested-chain.phpt @@ -0,0 +1,66 @@ +--TEST-- +unset nested array offsets mutates original, skips missing parents, and evaluates keys in order +--FILE-- + ['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) { +} diff --git a/tests/compiler/basic/unset-typed-property-in-array.phpt b/tests/compiler/basic/unset-typed-property-in-array.phpt new file mode 100644 index 00000000..68793ace --- /dev/null +++ b/tests/compiler/basic/unset-typed-property-in-array.phpt @@ -0,0 +1,37 @@ +--TEST-- +unset fixed scalar and array properties of objects stored in arrays restores empty values +--FILE-- + $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)