Skip to content

Commit b73a201

Browse files
committed
bug: Fix ObjectPropertyRipper to handle stdClass objects
Currently, attempting to rip from a `stdClass` will fail with an error that it is not possible to bind a closure to the scope of an internal object. It should not normally be necessary to call any of the `rip` methods for a `stdClass`, because by definition all props are public so the result will be the same as `$vars = (array) $object`. However, it can cause a problem if e.g. calling the ObjectPropertyRipper recursively to dump an entire object graph where the tree may include some stdClass instances. Our methods are typed to accept any kind of object, so should work with `stdClass` rather than requiring the caller to check this externally.
1 parent 5635024 commit b73a201

File tree

3 files changed

+75
-16
lines changed

3 files changed

+75
-16
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
### Unreleased
22

3+
### v2.4.0 (2025-06-05)
4+
5+
* Fix `ObjectPropertyRipper` to handle `stdClass` objects
6+
37
### v2.3.1 (2025-03-12)
48

59
* Support option to customize EOL character in CSVWriter

src/Object/ObjectPropertyRipper.php

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,9 @@ public static function ripAll(object $object): array
4949
// We also shouldn't cache, as individual objects may have variable field names (e.g. with public vars)
5050
// that are not present on other instances of the same class
5151

52-
$props = (\Closure::bind(
52+
$props = (self::bindScopedClosure(
5353
fn() => \get_object_vars($object),
54-
NULL,
55-
$object
54+
$object,
5655
))();
5756

5857
// Safety check - the method above is efficient but can't return private props from parent classes
@@ -92,21 +91,35 @@ public static function ripOne($object, $property)
9291
*/
9392
protected static function getRipper($class)
9493
{
95-
if ( ! isset(static::$rippers[$class])) {
96-
static::$rippers[$class] = \Closure::bind(
97-
function ($object, $properties) {
98-
$values = [];
99-
foreach ($properties as $property) {
100-
$values[$property] = $object->$property;
101-
}
94+
static::$rippers[$class] ??= self::bindScopedClosure(
95+
function ($object, $properties) {
96+
$values = [];
97+
foreach ($properties as $property) {
98+
$values[$property] = $object->$property;
99+
}
102100

103-
return $values;
104-
},
105-
NULL,
106-
$class
107-
);
108-
}
101+
return $values;
102+
},
103+
$class,
104+
);
109105

110106
return static::$rippers[$class];
111107
}
108+
109+
/**
110+
* @param object|class-string<object> $scope
111+
*/
112+
private static function bindScopedClosure(callable $callback, object|string $scope): \Closure
113+
{
114+
$scope_class = \is_object($scope) ? $scope::class : $scope;
115+
if ($scope_class === \stdClass::class) {
116+
// Cannot bind to the scope of an internal class (e.g. stdClass), and there is no need to do so since
117+
// all stdClass properties are public.
118+
// Note that this is the *not* the case for a user-defined class that extends stdClass, hence checking
119+
// for the exact class name rather than `instanceof`.
120+
$scope = null;
121+
}
122+
123+
return \Closure::bind($callback, newThis: null, newScope: $scope);
124+
}
112125
}

test/unit/Object/ObjectPropertyRipperTest.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
use Ingenerator\PHPUtils\Object\ObjectPropertyRipper;
1111
use PHPUnit\Framework\TestCase;
12+
use stdClass;
1213

1314
class ObjectPropertyRipperTest extends TestCase
1415
{
@@ -44,6 +45,47 @@ public function test_it_rips_all_properties()
4445
);
4546
}
4647

48+
public function test_it_can_rip_from_stdclass()
49+
{
50+
$c = new stdClass();
51+
$c->data = 'something';
52+
$c->other = 1;
53+
54+
$this->assertSame(
55+
[
56+
'data' => 'something',
57+
'other' => 1,
58+
],
59+
ObjectPropertyRipper::ripAll($c),
60+
);
61+
62+
$this->assertSame('something', ObjectPropertyRipper::ripOne($c, 'data'));
63+
$this->assertSame(['other' => 1], ObjectPropertyRipper::rip($c, ['other']));
64+
}
65+
66+
public function test_it_can_rip_from_class_that_inherits_from_stdclass()
67+
{
68+
// This is an edge case and should be fairly unlikely IRL, but it is valid.
69+
70+
$c = new class extends stdClass {
71+
private string $hidden = 'whatever';
72+
};
73+
$c->data = 'something';
74+
$c->other = 1;
75+
76+
$this->assertSame(
77+
[
78+
'hidden' => 'whatever',
79+
'data' => 'something',
80+
'other' => 1,
81+
],
82+
ObjectPropertyRipper::ripAll($c),
83+
);
84+
85+
$this->assertSame('whatever', ObjectPropertyRipper::ripOne($c, 'hidden'));
86+
$this->assertSame(['hidden' => 'whatever', 'other' => 1], ObjectPropertyRipper::rip($c, ['hidden', 'other']));
87+
}
88+
4789
public function test_it_throws_if_ripping_all_from_an_object_with_private_parent_properties()
4890
{
4991
$this->expectException(\DomainException::class);

0 commit comments

Comments
 (0)