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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
### Unreleased

### v2.4.0 (2025-06-05)

* Fix `ObjectPropertyRipper` to handle `stdClass` objects

### v2.3.1 (2025-03-12)

* Support option to customize EOL character in CSVWriter
Expand Down
45 changes: 29 additions & 16 deletions src/Object/ObjectPropertyRipper.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,9 @@ public static function ripAll(object $object): array
// We also shouldn't cache, as individual objects may have variable field names (e.g. with public vars)
// that are not present on other instances of the same class

$props = (\Closure::bind(
$props = (self::bindScopedClosure(
fn() => \get_object_vars($object),
NULL,
$object
$object,
))();

// Safety check - the method above is efficient but can't return private props from parent classes
Expand Down Expand Up @@ -92,21 +91,35 @@ public static function ripOne($object, $property)
*/
protected static function getRipper($class)
{
if ( ! isset(static::$rippers[$class])) {
static::$rippers[$class] = \Closure::bind(
function ($object, $properties) {
$values = [];
foreach ($properties as $property) {
$values[$property] = $object->$property;
}
static::$rippers[$class] ??= self::bindScopedClosure(
function ($object, $properties) {
$values = [];
foreach ($properties as $property) {
$values[$property] = $object->$property;
}

return $values;
},
NULL,
$class
);
}
return $values;
},
$class,
);

return static::$rippers[$class];
}

/**
* @param object|class-string<object> $scope
*/
private static function bindScopedClosure(callable $callback, object|string $scope): \Closure
{
$scope_class = \is_object($scope) ? $scope::class : $scope;
if ($scope_class === \stdClass::class) {
// Cannot bind to the scope of an internal class (e.g. stdClass), and there is no need to do so since
// all stdClass properties are public.
// Note that this is the *not* the case for a user-defined class that extends stdClass, hence checking
// for the exact class name rather than `instanceof`.
$scope = null;
}

return \Closure::bind($callback, newThis: null, newScope: $scope);
}
}
42 changes: 42 additions & 0 deletions test/unit/Object/ObjectPropertyRipperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

use Ingenerator\PHPUtils\Object\ObjectPropertyRipper;
use PHPUnit\Framework\TestCase;
use stdClass;

class ObjectPropertyRipperTest extends TestCase
{
Expand Down Expand Up @@ -44,6 +45,47 @@ public function test_it_rips_all_properties()
);
}

public function test_it_can_rip_from_stdclass()
{
$c = new stdClass();
$c->data = 'something';
$c->other = 1;

$this->assertSame(
[
'data' => 'something',
'other' => 1,
],
ObjectPropertyRipper::ripAll($c),
);

$this->assertSame('something', ObjectPropertyRipper::ripOne($c, 'data'));
$this->assertSame(['other' => 1], ObjectPropertyRipper::rip($c, ['other']));
}

public function test_it_can_rip_from_class_that_inherits_from_stdclass()
{
// This is an edge case and should be fairly unlikely IRL, but it is valid.

$c = new class extends stdClass {
private string $hidden = 'whatever';
};
$c->data = 'something';
$c->other = 1;

$this->assertSame(
[
'hidden' => 'whatever',
'data' => 'something',
'other' => 1,
],
ObjectPropertyRipper::ripAll($c),
);

$this->assertSame('whatever', ObjectPropertyRipper::ripOne($c, 'hidden'));
$this->assertSame(['hidden' => 'whatever', 'other' => 1], ObjectPropertyRipper::rip($c, ['hidden', 'other']));
}

public function test_it_throws_if_ripping_all_from_an_object_with_private_parent_properties()
{
$this->expectException(\DomainException::class);
Expand Down
Loading