Skip to content
Merged
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ All notable changes to this extension will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.6.0] - 2026-04-26

### Removed

- `deepclone_hydrate()` no longer treats the special `"\0"` key as SPL
internal state. `ArrayObject`, `ArrayIterator`, and `SplObjectStorage`
all ship `__serialize` / `__unserialize` since PHP 7.4 — callers can
populate them by instantiating with `deepclone_hydrate()` and calling
`__unserialize()` with the documented array shape, or by round-tripping
via `deepclone_from_array()` which routes through `__unserialize`
natively. The mangled-key resolution path (`"propName"`, `"\0*\0prop"`,
`"\0Class\0prop"`) is unchanged.

This removes ~80 lines of bespoke SPL handling — `offsetSet` loops,
constructor invocation, packed-array shape validation, error paths —
that duplicated what the classes natively expose. Symfony's
`Hydrator::hydrate()` / `Instantiator::instantiate()` retain BC by
translating the legacy `"\0"` shape to `__unserialize()` in user-land.

## [0.5.1] - 2026-04-17

### Fixed
Expand Down
19 changes: 9 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ to keep them.
| `"propName"` | public, protected (any declaring class), or private declared on the object's own class |
| `"\0*\0propName"` | protected (the declaring class is resolved via the object) |
| `"\0ClassName\0propName"` | private declared on `ClassName` — must be the object's own class or a parent |
| `"\0"` | SPL internal state (SplObjectStorage / ArrayObject / ArrayIterator) |

Each key triggers one `properties_info` hash lookup followed by a direct
slot write.
Expand Down Expand Up @@ -172,18 +171,18 @@ strict-type errors. They run under every mode unless noted:
enum-typed properties accordingly receive the enum case, not the
raw scalar.

The special `"\0"` key sets the internal state of SPL classes:
SPL classes that hold internal state (`ArrayObject`, `ArrayIterator`,
`SplObjectStorage`, …) have shipped `__serialize` / `__unserialize` since
PHP 7.4. To populate them, instantiate with `deepclone_hydrate()` and call
`__unserialize()` with the array shape the class documents — or just use
`deepclone_from_array()`, which routes through `__unserialize` natively.

```php
// ArrayObject / ArrayIterator — ["\0" => [$array, $flags?, $iteratorClass?]]
$ao = deepclone_hydrate('ArrayObject', [
"\0" => [['x' => 1, 'y' => 2], ArrayObject::ARRAY_AS_PROPS],
]);
$ao = deepclone_hydrate('ArrayObject');
$ao->__unserialize([ArrayObject::ARRAY_AS_PROPS, ['x' => 1, 'y' => 2], []]);

// SplObjectStorage — ["\0" => [$obj1, $info1, $obj2, $info2, ...]]
$s = deepclone_hydrate('SplObjectStorage', [
"\0" => [$obj, 'metadata'],
]);
$s = deepclone_hydrate('SplObjectStorage');
$s->__unserialize([[$obj1, 'info1', $obj2, 'info2'], []]);
```

## What it preserves
Expand Down
89 changes: 0 additions & 89 deletions deepclone.c
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,6 @@
#include "Zend/zend_interfaces.h"
#include "ext/spl/spl_iterators.h"
#include "ext/spl/spl_exceptions.h"
#include "ext/spl/spl_array.h"
#include "ext/spl/spl_observer.h"

/* ext/reflection's class entries are PHPAPI but Debian's php-dev does not
* ship ext/reflection/php_reflection.h. Forward-declare what we use; the
Expand Down Expand Up @@ -3093,8 +3091,6 @@ PHP_FUNCTION(deepclone_hydrate)
* "\0*\0propName" → protected (declaring class resolved via obj_ce)
* "\0ClassName\0propName" → private declared on ClassName (must be obj_ce
* or a parent; interfaces are not valid scopes)
* "\0" → SPL internal state (SplObjectStorage/
* ArrayObject/ArrayIterator)
*/
zend_ulong prop_idx;
zend_string *prop_key;
Expand All @@ -3111,91 +3107,6 @@ PHP_FUNCTION(deepclone_hydrate)
const char *key = ZSTR_VAL(prop_key);
size_t key_len = ZSTR_LEN(prop_key);

/* "\0" = internal state for SplObjectStorage/ArrayObject/ArrayIterator */
if (key_len == 1 && key[0] == '\0') {
if (UNEXPECTED(Z_TYPE_P(prop_val) != IS_ARRAY)) {
zend_value_error("deepclone_hydrate(): Argument #2 ($vars) special \"\\0\" value must be of type array, %s given",
zend_zval_value_name(prop_val));
if (prop_key_owned) zend_string_release(prop_key);
zval_ptr_dtor(&obj_zval);
RETURN_THROWS();
}
if (instanceof_function(obj_ce, spl_ce_SplObjectStorage)) {
/* [$obj1, $info1, $obj2, $info2, ...] → offsetSet(obj, info).
* Count must be even (pair-aligned); packed/contiguous indexing
* is required since the format is a flat pair stream. */
uint32_t count = zend_hash_num_elements(Z_ARRVAL_P(prop_val));
if (UNEXPECTED(count & 1)) {
zend_value_error("deepclone_hydrate(): Argument #2 ($vars) special \"\\0\" value for %s must have an even number of entries, %u given",
ZSTR_VAL(obj_ce->name), count);
if (prop_key_owned) zend_string_release(prop_key);
zval_ptr_dtor(&obj_zval);
RETURN_THROWS();
}
zend_function *offset_set_fn = NULL;
for (uint32_t i = 0; i < count; i += 2) {
zval *zkey = zend_hash_index_find(Z_ARRVAL_P(prop_val), i);
zval *zinfo = zend_hash_index_find(Z_ARRVAL_P(prop_val), i + 1);
if (UNEXPECTED(!zkey || !zinfo)) {
zend_value_error("deepclone_hydrate(): Argument #2 ($vars) special \"\\0\" value for %s must be a packed array of [obj, info] pairs",
ZSTR_VAL(obj_ce->name));
if (prop_key_owned) zend_string_release(prop_key);
zval_ptr_dtor(&obj_zval);
RETURN_THROWS();
}
zend_call_method_with_2_params(obj, obj_ce, &offset_set_fn, "offsetset", NULL, zkey, zinfo);
if (UNEXPECTED(EG(exception))) {
if (prop_key_owned) zend_string_release(prop_key);
zval_ptr_dtor(&obj_zval);
RETURN_THROWS();
}
}
} else if (instanceof_function(obj_ce, spl_ce_ArrayObject)
|| instanceof_function(obj_ce, spl_ce_ArrayIterator)) {
/* [$array, $flags?, $iteratorClass?] — call constructor */
uint32_t argc = zend_hash_num_elements(Z_ARRVAL_P(prop_val));
if (UNEXPECTED(argc > 3)) {
zend_value_error("deepclone_hydrate(): Argument #2 ($vars) special \"\\0\" value for %s accepts at most 3 entries, %u given",
ZSTR_VAL(obj_ce->name), argc);
if (prop_key_owned) zend_string_release(prop_key);
zval_ptr_dtor(&obj_zval);
RETURN_THROWS();
}
zend_function *ctor = obj_ce->constructor;
if (ctor) {
zval args[3];
for (uint32_t a = 0; a < argc; a++) {
zval *arg = zend_hash_index_find(Z_ARRVAL_P(prop_val), a);
if (UNEXPECTED(!arg)) {
zend_value_error("deepclone_hydrate(): Argument #2 ($vars) special \"\\0\" value for %s must be a packed array",
ZSTR_VAL(obj_ce->name));
if (prop_key_owned) zend_string_release(prop_key);
zval_ptr_dtor(&obj_zval);
RETURN_THROWS();
}
ZVAL_COPY_VALUE(&args[a], arg);
}
zval retval;
ZVAL_UNDEF(&retval);
zend_call_known_function(ctor, obj, obj_ce, &retval, argc, args, NULL);
zval_ptr_dtor(&retval);
if (UNEXPECTED(EG(exception))) {
if (prop_key_owned) zend_string_release(prop_key);
zval_ptr_dtor(&obj_zval);
RETURN_THROWS();
}
}
} else {
zend_value_error("deepclone_hydrate(): Argument #2 ($vars) uses the special \"\\0\" key, which is only supported for SplObjectStorage, ArrayObject, and ArrayIterator; got \"%s\"",
ZSTR_VAL(obj_ce->name));
if (prop_key_owned) zend_string_release(prop_key);
zval_ptr_dtor(&obj_zval);
RETURN_THROWS();
}
if (prop_key_owned) zend_string_release(prop_key);
continue;
}

/* Resolve (scope_ce, real_name) from the mangled key shape.
* is_mangled is set when the key started with NUL — if the resolved
* property_info lookup then misses, we reject the key rather than
Expand Down
2 changes: 1 addition & 1 deletion php_deepclone.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
extern zend_module_entry deepclone_module_entry;
#define phpext_deepclone_ptr &deepclone_module_entry

#define PHP_DEEPCLONE_VERSION "0.5.1"
#define PHP_DEEPCLONE_VERSION "0.6.0"

ZEND_BEGIN_MODULE_GLOBALS(deepclone)
HashTable hydrate_cache;
Expand Down
76 changes: 18 additions & 58 deletions tests/deepclone_hydrate.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -61,34 +61,31 @@ var_dump($o->y === 'hi');
$o = deepclone_hydrate('STDcLASS', ['p' => 123]);
var_dump($o->p === 123);

// === "\0" key = SPL internal state ===

// SplObjectStorage via "\0" key
$s = new SplObjectStorage();
$o1 = new stdClass(); $o1->name = 'a';
$o2 = new stdClass(); $o2->name = 'b';
$result = deepclone_hydrate($s, ["\0" => [$o1, 'info1', $o2, 'info2']]);
var_dump($result === $s);
var_dump($result->count() === 2);
$result->rewind();
var_dump($result->current() === $o1);
var_dump($result->getInfo() === 'info1');

$o3 = new stdClass();
$s = deepclone_hydrate('SplObjectStorage', ["\0" => [$o3, 'data']]);
var_dump($s->count() === 1);

// ArrayObject via "\0" key
$ao = deepclone_hydrate('ArrayObject', ["\0" => [['x' => 1, 'y' => 2], ArrayObject::ARRAY_AS_PROPS]]);
// === SPL classes (ArrayObject, ArrayIterator, SplObjectStorage) ===
// These ship __serialize/__unserialize since PHP 7.4. deepclone_hydrate()
// instantiates them; callers wire up state by calling __unserialize() with
// the array shape these classes document, or by going through
// deepclone_from_array() which handles it natively.

// ArrayObject + __unserialize
$ao = deepclone_hydrate('ArrayObject');
$ao->__unserialize([ArrayObject::ARRAY_AS_PROPS, ['x' => 1, 'y' => 2], []]);
var_dump($ao instanceof ArrayObject);
var_dump($ao['x'] === 1);
var_dump($ao->getFlags() === ArrayObject::ARRAY_AS_PROPS);

// ArrayIterator via "\0" key
$ai = deepclone_hydrate('ArrayIterator', ["\0" => [['a', 'b', 'c']]]);
// ArrayIterator + __unserialize
$ai = deepclone_hydrate('ArrayIterator');
$ai->__unserialize([0, ['a', 'b', 'c'], []]);
var_dump($ai instanceof ArrayIterator);
var_dump(count($ai) === 3);

// SplObjectStorage + __unserialize
$o1 = new stdClass(); $o2 = new stdClass();
$s = deepclone_hydrate('SplObjectStorage');
$s->__unserialize([[$o1, 'info1', $o2, 'info2'], []]);
var_dump($s->count() === 2);

// === (array) $obj round-trip — all four mangled-key shapes ===

$expected = [
Expand Down Expand Up @@ -265,35 +262,6 @@ try {
var_dump(str_contains($e->getMessage(), 'invalid mangled key'));
}

// "\0" special key with a non-array value → ValueError
try {
deepclone_hydrate('SplObjectStorage', ["\0" => 42]);
} catch (\ValueError $e) {
var_dump(str_contains($e->getMessage(), 'must be of type array'));
}

// "\0" special key on a class that doesn't support it → ValueError
class NoSpl { public int $x = 0; }
try {
deepclone_hydrate('NoSpl', ["\0" => [1, 2]]);
} catch (\ValueError $e) {
var_dump(str_contains($e->getMessage(), 'SplObjectStorage'));
}

// SplObjectStorage "\0" payload with odd count → ValueError
try {
deepclone_hydrate('SplObjectStorage', ["\0" => [new stdClass()]]);
} catch (\ValueError $e) {
var_dump(str_contains($e->getMessage(), 'even number of entries'));
}

// ArrayObject "\0" payload with more than 3 args → ValueError
try {
deepclone_hydrate('ArrayObject', ["\0" => [[], 0, 'ArrayIterator', 'extra']]);
} catch (\ValueError $e) {
var_dump(str_contains($e->getMessage(), 'at most 3 entries'));
}

// Mangled-private key referencing a non-parent class → ValueError
class HydrateUnrelated { public int $x = 0; }
try {
Expand Down Expand Up @@ -400,12 +368,4 @@ bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
Done