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
68 changes: 68 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,74 @@ 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.5.0] - 2026-04-15

### BC Break

- `deepclone_hydrate()` now interprets `$vars` exclusively as a flat
mangled-key array (the shape `(array) $obj` produces). The per-class
scoped shape (`[$class => ['prop' => $val]]`) is no longer supported —
callers passing the old shape will hit the `"invalid mangled key"` /
`"not a parent"` errors on NUL-prefixed keys, or silently create a
dynamic property named after the class on non-NUL keys. Migrate by
flattening: for each scope entry, use bare names for public / protected
/ most-derived-private, and `"\0ScopeClass\0prop"` for parent-private
props. Motivation: the two shapes were functionally equivalent (same
resolution path, same slot writes), and keeping both required an
intermediate scoped_props HashTable + a double-pass write. Dropping
scoped mode simplifies the dispatcher into a single key-parse + write
loop, and removes ~200 lines of C.
- `DEEPCLONE_HYDRATE_MANGLED_VARS` constant removed — flat mangled is
now the only mode, so the flag is redundant. Callers who were passing
the flag can simply drop it.
- `DEEPCLONE_HYDRATE_PRESERVE_REFS` flag value changed from `1 << 3` to
`1 << 2` (filling the slot vacated by `DEEPCLONE_HYDRATE_MANGLED_VARS`).
Symbolic references via the constant name are unaffected; anyone using
the raw integer value `4` now gets `PRESERVE_REFS` instead of the old
`MANGLED_VARS` — in practice both are the flags real callers pass, so
the arithmetic happens to line up.

### Fixed

- `deepclone_hydrate()` rejects the SPL-internal-state `"\0"` key on
objects that don't support it (anything other than `SplObjectStorage`,
`ArrayObject`, `ArrayIterator`) with a `ValueError`. Previously the
value silently landed in `obj->properties` as a NUL-named dynamic
property.
- `deepclone_hydrate()` rejects malformed SPL `"\0"` payloads: a
non-even-count pair stream for `SplObjectStorage` and a payload with
more than 3 ctor args for `ArrayObject` / `ArrayIterator`. Both were
previously tolerated silently (odd tail dropped; excess args truncated).
- `deepclone_hydrate()` no longer direct-writes `IS_PROP_UNINIT` to a
lazy object's slot via the `null` → uninitialized shortcut. The
shortcut is now gated on `zend_lazy_object_initialized(obj)`, so
`DEEPCLONE_HYDRATE_NO_LAZY_INIT` + lazy objects fall through to the
Reflection-based path instead of bypassing the lazy-props bookkeeping.
- `deepclone_from_array()` cross-validates `objectMeta` wakeup flags
against `states` entries: each state entry must match the sign
advertised in `objectMeta[id][1]` (positive → `__wakeup`, negative →
`__unserialize`), and any id flagged for state replay without a
matching entry is rejected. Closes a validation hole where payloads
with impossible meta like `[0, 999]` or `[0, -123]` were accepted.
- `deepclone_from_array()` routes writes to undeclared property names
on non-stdClass objects through `zend_update_property_ex()` instead
of `zend_std_write_property()`, respecting overridden `write_property`
handlers on internal classes and extensions. Matches the
`deepclone_hydrate()` path.
- `deepclone_from_array()` throws `ValueError` on out-of-range object
ids in `"properties"` entries (previously silently skipped).

### Changed

- `deepclone_from_array()` object-creation loop drops the pointer-scan
over `class_names[]` that recovered the class id per object. A
per-object `uint32_t class_id` is stored directly from the
`objectMeta` parse, turning an O(N × K) step into O(N) on payloads
with many objects across many classes.
- `deepclone_hydrate()` caches the `offsetSet` method lookup across
iterations on `SplObjectStorage` `"\0"` payloads (was re-resolved
by name on every entry).

## [0.4.0] - 2026-04-15

### BC Break
Expand Down
105 changes: 43 additions & 62 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,27 +56,23 @@ properties — including private, protected, and readonly ones — without calli
their constructor, faster than Reflection:

```php
// Scoped array — keyed by declaring class
// Flat bare-name array — ideal for hydrating from a flat row
// (e.g. a PDO result).
$user = deepclone_hydrate(User::class, [
User::class => ['id' => 42, 'name' => 'Alice'],
AbstractEntity::class => ['createdAt' => new \DateTimeImmutable()],
'id' => 42,
'name' => 'Alice',
'email' => 'alice@example.com',
]);

// Flat bare-name array — ideal for hydrating from a flat row
// (e.g. a PDO result), no scope grouping needed
$user = deepclone_hydrate(User::class,
['id' => 42, 'name' => 'Alice', 'email' => 'alice@example.com'],
DEEPCLONE_HYDRATE_MANGLED_VARS,
);

// Mangled keys (same format as (array) $obj cast)
$user = deepclone_hydrate(User::class,
['name' => 'Alice', "\0User\0email" => 'alice@example.com'],
DEEPCLONE_HYDRATE_MANGLED_VARS,
);
// Mangled keys for parent-declared private properties — same format as
// (array) $obj cast produces.
$user = deepclone_hydrate(User::class, [
'name' => 'Alice',
"\0AbstractEntity\0createdAt" => new \DateTimeImmutable(),
]);

// Hydrate an existing object
deepclone_hydrate($existingUser, ['User' => ['name' => 'Bob']]);
deepclone_hydrate($existingUser, ['name' => 'Bob']);
```

## API
Expand All @@ -97,40 +93,32 @@ name to instantiate without calling its constructor. By default, PHP `&`
references in `$vars` are dropped on write; pass `DEEPCLONE_HYDRATE_PRESERVE_REFS`
to keep them.

By default, `$vars` is keyed by declaring class name; each value is an array
of property names to values:
`$vars` is a flat array keyed by property name — the exact shape
`(array) $obj` produces:

```php
$user = deepclone_hydrate(User::class, [
User::class => ['id' => 42, 'name' => 'Alice'],
AbstractEntity::class => ['createdAt' => new \DateTimeImmutable()],
]);
```
| key shape | target |
|-----------------------------|-----------------------------------------------------------|
| `"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) |

Pass `DEEPCLONE_HYDRATE_MANGLED_VARS` in `$flags` to interpret `$vars` as a
flat key array. Keys can be bare property names (auto-resolved to the
declaring class, the ideal shape for hydrating from a PDO row), or
mangled (`"\0ClassName\0prop"` for private, `"\0*\0prop"` for protected — the
same shape `(array) $object` produces). The two forms can be mixed:
Each key triggers one `properties_info` hash lookup followed by a direct
slot write.

```php
// Bare names — each is resolved to its declaring class automatically
$user = deepclone_hydrate(User::class,
['id' => 42, 'name' => 'Alice', 'email' => 'alice@example.com'],
DEEPCLONE_HYDRATE_MANGLED_VARS,
);

// Mangled keys, typical (array) cast shape
$user = deepclone_hydrate(User::class,
['name' => 'Alice', "\0User\0email" => 'alice@example.com'],
DEEPCLONE_HYDRATE_MANGLED_VARS,
);
$user = deepclone_hydrate(User::class, [
'id' => 42, // bare — public or own-private
'name' => 'Alice',
"\0*\0createdAt" => new \DateTimeImmutable(), // protected
"\0AbstractEntity\0metadata" => [...], // parent-private
]);
```

Both the scoped shape and the flat-bare-name shape take a direct
`properties_info` lookup per key followed by a direct slot write — there's
no meaningful performance difference between the two, pick whichever
representation your caller already has on hand.
Bare names are enough for every public, protected, or most-derived-private
property. Parent-declared private properties need the explicit
`"\0ClassName\0prop"` mangled form (the engine keys them that way in the
child's `properties_info`).

`$flags` selects the write semantics for declared-property assignments:

Expand All @@ -139,11 +127,10 @@ representation your caller already has on hand.
| `0` (default) | `ReflectionProperty::setRawValue` — bypass set hooks, type-check, respect readonly |
| `DEEPCLONE_HYDRATE_CALL_HOOKS` | `ReflectionProperty::setValue` — invoke set hooks |
| `DEEPCLONE_HYDRATE_NO_LAZY_INIT` | `ReflectionProperty::setRawValueWithoutLazyInitialization` — skip the lazy initializer; realize the object when the last lazy property is set |
| `DEEPCLONE_HYDRATE_MANGLED_VARS` | interpret `$vars` as a flat mangled-key array (above) |
| `DEEPCLONE_HYDRATE_PRESERVE_REFS` | preserve PHP `&` references from `$vars` onto the target property slots; by default, references are dropped (dereferenced) on write |

`DEEPCLONE_HYDRATE_CALL_HOOKS` and `DEEPCLONE_HYDRATE_NO_LAZY_INIT` are
mutually exclusive; `MANGLED_VARS` and `PRESERVE_REFS` compose with either.
mutually exclusive; `PRESERVE_REFS` composes with either.
`deepclone_from_array()` always uses the default setRawValue semantics,
mirroring `unserialize()`.

Expand Down Expand Up @@ -185,24 +172,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. In scoped
mode it goes inside a scope entry; in `MANGLED_VARS` mode it is a flat key:
The special `"\0"` key sets the internal state of SPL classes:

```php
// Scoped mode:
$ao = deepclone_hydrate('ArrayObject', ['ArrayObject' => ["\0" => [['x' => 1]]]]);

// MANGLED_VARS mode:
$ao = deepclone_hydrate('ArrayObject',
["\0" => [['x' => 1], ArrayObject::ARRAY_AS_PROPS]],
DEEPCLONE_HYDRATE_MANGLED_VARS,
);

// SplObjectStorage: "\0" => [$obj1, $info1, $obj2, $info2, ...]
$s = deepclone_hydrate('SplObjectStorage',
["\0" => [$obj, 'metadata']],
DEEPCLONE_HYDRATE_MANGLED_VARS,
);
// ArrayObject / ArrayIterator — ["\0" => [$array, $flags?, $iteratorClass?]]
$ao = deepclone_hydrate('ArrayObject', [
"\0" => [['x' => 1, 'y' => 2], ArrayObject::ARRAY_AS_PROPS],
]);

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

## What it preserves
Expand Down
Loading