Implement ObjectProxy class

This commit is contained in:
Dan Jones 2021-10-15 10:43:30 -05:00
commit d857775289
10 changed files with 417 additions and 79 deletions

22
.ac-php-conf.json Normal file
View file

@ -0,0 +1,22 @@
{
"use-cscope": null,
"tag-dir": null,
"filter": {
"php-file-ext-list": [
"php"
],
"php-path-list": [
"."
],
"ignore-ruleset": [
"# like .gitignore file ",
"/vendor/**/[tT]ests/**/*.php",
"/vendor/**/[Ee]xamples/**/*.php",
"/vendor/composer/*.php",
"/vendor/*.php",
"# not need php_codesniffer",
"/vendor/squizlabs/php_codesniffer/**/*.php",
"# -- end -- "
]
}
}

View file

@ -22,12 +22,8 @@ TODO: Make sure the following URLs are correct and working for your project.
## About
<!--
TODO: Use this space to provide more details about your package. Try to be
concise. This is the introduction to your package. Let others know what
your package does and how it can help them build applications.
-->
A debugging/testing tool which allows access to an object's or class's private
and protected properties and methods without the use of Reflection.
This project adheres to a [code of conduct](CODE_OF_CONDUCT.md).
By participating in this project and its community, you are expected to
@ -42,33 +38,57 @@ Install this package as a dependency using [Composer](https://getcomposer.org).
composer require danjones000/object-spy
```
<!--
## Usage
Provide a brief description or short example of how to use this library.
If you need to provide more detailed examples, use the `docs/` directory
and provide a link here to the documentation.
``` php
use Danjones000\Spy\Example;
use Danjones000\Spy\ObjectProxy;
$example = new Example();
echo $example->greet('fellow human');
$object = new Object(); // Can be any object.
$spy = new ObjectProxy($object);
// Access private property
echo $spy->privateProperty;
// Set private property
$spy->privateProperty = 42;
// Call private method
$spy->privateMethod();
// Call private method with parameters
$spy->protectedMethod(42, false);
// Run arbitry code on object, allows passing in parameters
// All arguments after the closure are passed as arguments to the closure itself
$spy->call(fn ($abc) => $this->someProp = $this->someMethod($abc) * static::MULTIPLIER, 500);
// PHP allows static methods to be called non-statically, so, this works as well
$spy->staticPrivateMethod();
// Can also access static properties/methods without an object instance
$staticSpy = new ObjectProxy(Object::class);
// If there is an Object::$privateStaticProperty
echo $staticSpy->privateStaticProperty;
$staticSpy->privateStaticProperty = 42;
$staticSpy->privateStaticMethod();
// No direct access to contstants, but we can do this
$staticSpy->call(fn () => static::PRIVATE_CONSTANT); // self also works, of course
```
-->
## Implementation notes
This class does not use reflections in its operation. Instead, if uses closures
that are bound to the instance or class.
## Contributing
Contributions are welcome! To contribute, please familiarize yourself with
[CONTRIBUTING.md](CONTRIBUTING.md).
## Copyright and License
The danjones000/object-spy library is copyright © [Dan Jones](https://danielrayjones.com/)

View file

@ -8,6 +8,6 @@
<file>./src</file>
<file>./tests</file>
<rule ref="Ramsey"/>
<rule ref="PSR12"/>
</ruleset>

View file

@ -4,3 +4,7 @@ parameters:
paths:
- ./src
- ./tests
ignoreErrors:
- '/Call to an undefined method/'
- '/Access to an undefined property/'
- '/Access to undefined constant/'

View file

@ -14,4 +14,26 @@
</ignoreFiles>
</projectFiles>
<issueHandlers>
<PropertyNotSetInConstructor>
<errorLevel type="suppress">
<file name="src/ObjectProxy.php" />
</errorLevel>
</PropertyNotSetInConstructor>
<RedundantPropertyInitializationCheck>
<errorLevel type="suppress">
<file name="src/ObjectProxy.php" />
</errorLevel>
</RedundantPropertyInitializationCheck>
<MissingClosureReturnType>
<errorLevel type="suppress">
<file name="src/ObjectProxy.php" />
</errorLevel>
</MissingClosureReturnType>
<MissingClosureParamType>
<errorLevel type="suppress">
<file name="src/ObjectProxy.php" />
</errorLevel>
</MissingClosureParamType>
</issueHandlers>
</psalm>

View file

@ -1,37 +0,0 @@
<?php
/**
* This file is part of danjones000/object-spy
*
* danjones000/object-spy is open source software: you can distribute
* it and/or modify it under the terms of the MIT License
* (the "License"). You may not use this file except in
* compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing
* permissions and limitations under the License.
*
* @copyright Copyright (c) Dan Jones <danjones@goodevilgenius.org>
* @license https://opensource.org/licenses/MIT MIT License
*/
declare(strict_types=1);
namespace Danjones000\Spy;
/**
* An example class to act as a starting point for developing your library
*/
class Example
{
/**
* Returns a greeting statement using the provided name
*/
public function greet(string $name = 'World'): string
{
return "Hello, {$name}!";
}
}

157
src/ObjectProxy.php Normal file
View file

@ -0,0 +1,157 @@
<?php
/**
* This file is part of danjones000/object-spy
*
* danjones000/object-spy is open source software: you can distribute
* it and/or modify it under the terms of the MIT License
* (the "License"). You may not use this file except in
* compliance with the License.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing
* permissions and limitations under the License.
*
* @copyright Copyright (c) Dan Jones <danjones@goodevilgenius.org>
* @license https://opensource.org/licenses/MIT MIT License
*/
declare(strict_types=1);
namespace Danjones000\Spy;
use Closure;
use InvalidArgumentException;
use function class_exists;
use function get_class;
use function is_null;
use function is_object;
use function is_string;
/**
* Proxies all calls to another object/class, via Closures.
*
* This allows access to private/protected methods and properties without Reflection.
*/
class ObjectProxy
{
protected ?string $class;
protected ?object $object;
protected Closure $caller;
protected Closure $getter;
protected Closure $setter;
/**
* @param string|object $classOrObject
*
* @psalm-suppress RedundantConditionGivenDocblockType
*/
public function __construct($classOrObject)
{
[$this->object, $this->class] = is_object($classOrObject) ?
[$classOrObject, get_class($classOrObject)] :
(
is_string($classOrObject) && class_exists($classOrObject) ?
[null, $classOrObject] :
[null, null]
);
$this->assertValid();
}
protected function assertValid(): void
{
if (is_null($this->class)) {
throw new InvalidArgumentException('Must specify a class, or object');
}
if (!class_exists($this->class)) {
throw new InvalidArgumentException('Must specify a valid class');
}
}
/**
* Get the closure used by $this->__call.
*
* @psalm-suppress PossiblyNullArgument
*/
protected function getCaller(): Closure
{
return $this->caller ??= (
$this->object ?
fn (string $method, array $args) => $this->$method(...$args) :
static fn (string $method, array $args) => static::$method(...$args)
)->bindTo($this->object, $this->class);
}
/**
* Get the closure used by $this->__get.
*
* @psalm-suppress PossiblyNullArgument
* @psalm-suppress UnusedClosureParam
*/
protected function getGetter(): Closure
{
return $this->getter ??= (
$this->object ?
fn (string $key) => $this->$key :
static fn (string $key) => static::$$key
)->bindTo($this->object, $this->class);
}
/**
* Get the closure used by $this->__set.
*
* @psalm-suppress PossiblyNullArgument
* @psalm-suppress MixedAssignment
*/
protected function getSetter(): Closure
{
return $this->setter ??= (
$this->object ?
fn (string $key, $value) => $this->$key = $value :
static fn (string $key, $value) => static::$$key = $value
)->bindTo($this->object, $this->class);
}
/**
* Run arbitry code on object, with access to private/protected props/methods.
*
* @param mixed[] $args
* @return mixed
* @psalm-suppress PossiblyNullArgument
* @psalm-suppress PossiblyInvalidFunctionCall
*/
public function call(callable $cb, array ...$args)
{
return (Closure::fromCallable($cb)->bindTo($this->object, $this->class))(...$args);
}
/**
* @param mixed[] $args
* @return mixed
*/
public function __call(string $method, array $args)
{
return ($this->getCaller())($method, $args);
}
/**
* @return mixed
*/
public function __get(string $key)
{
return ($this->getGetter())($key);
}
/**
* @param mixed $value
* @return mixed
*/
public function __set(string $key, $value)
{
return ($this->getSetter())($key, $value);
}
}

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Danjones000\Test\Spy;
use Danjones000\Spy\Example;
use Mockery\MockInterface;
class ExampleTest extends TestCase
{
public function testGreet(): void
{
/** @var Example & MockInterface $example */
$example = $this->mockery(Example::class);
$example->shouldReceive('greet')->passthru();
$this->assertSame('Hello, Friends!', $example->greet('Friends'));
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Danjones000\Test\Spy\Fixtures;
class Example
{
protected const PROT_CONST = 2;
private const PRIV_CONST = 3;
protected static int $protStatic = 4;
private static int $privStatic = 5;
protected int $prot = 6;
private int $priv = 7;
public static function reset(): void
{
static::$protStatic = 4;
static::$privStatic = 5;
}
protected function protMethod(): int
{
return $this->prot;
}
private function privMethod(int $priv): void
{
$this->priv = $priv;
}
protected static function protStatMethod(int $protStatic): void
{
static::$protStatic = $protStatic;
}
private static function privStatMethod(): int
{
return static::$privStatic;
}
}

129
tests/ObjectProxyTest.php Normal file
View file

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace Danjones000\Test\Spy;
use Danjones000\Spy\ObjectProxy;
use Mockery;
use Mockery\MockInterface;
class ObjectProxyTest extends TestCase
{
protected Fixtures\Example $example;
protected ObjectProxy $spy;
protected ObjectProxy $classSpy;
protected \ReflectionObject $reflection;
protected function setUp(): void
{
parent::setUp();
Fixtures\Example::reset();
$this->example = new Fixtures\Example();
$this->spy = new ObjectProxy($this->example);
$this->classSpy = new ObjectProxy(Fixtures\Example::class);
$this->reflection = new \ReflectionObject($this->example);
}
public function testCallingProtectedMethod(): void
{
$ret = $this->spy->protMethod();
$this->assertEquals(6, $ret);
}
public function testCallPrivateMethod(): void
{
$this->spy->privMethod(42);
$prop = $this->reflection->getProperty('priv');
$prop->setAccessible(true);
$this->assertEquals(42, $prop->getValue($this->example));
}
public function testCallProtectedStaticMethod(): void
{
$this->classSpy->protStatMethod(42);
$prop = $this->reflection->getProperty('protStatic');
$prop->setAccessible(true);
$this->assertEquals(42, $prop->getValue());
}
public function testCallPrivateStaticMethod(): void
{
$ret = $this->classSpy->privStatMethod();
$this->assertEquals(5, $ret);
}
public function testAccessPrivateProperty(): void
{
$this->assertEquals(7, $this->spy->priv);
}
public function testAccessProtectedProperty(): void
{
$this->assertEquals(6, $this->spy->prot);
}
public function testAccessPrivateStaticProperty(): void
{
$this->assertEquals(5, $this->classSpy->privStatic);
}
public function testAccessProtectedStaticProperty(): void
{
$this->assertEquals(4, $this->classSpy->protStatic);
}
public function testAccessPrivateConstantThroughCall(): void
{
$ret = $this->classSpy->call(fn () => static::PRIV_CONST);
$this->assertEquals(3, $ret);
$ret = $this->classSpy->call(fn () => self::PRIV_CONST);
$this->assertEquals(3, $ret);
}
public function testAccessProtectedConstantThroughCall(): void
{
$ret = $this->classSpy->call(fn () => static::PROT_CONST);
$this->assertEquals(2, $ret);
$ret = $this->classSpy->call(fn () => self::PROT_CONST);
$this->assertEquals(2, $ret);
}
public function testModifyPrivateProperty(): void
{
$this->spy->priv = 42;
$prop = $this->reflection->getProperty('priv');
$prop->setAccessible(true);
$this->assertEquals(42, $prop->getValue($this->example));
}
public function testModifyProtectedProperty(): void
{
$this->spy->prot = 42;
$prop = $this->reflection->getProperty('prot');
$prop->setAccessible(true);
$this->assertEquals(42, $prop->getValue($this->example));
}
public function testModifyPrivateStaticProperty(): void
{
$this->classSpy->privStatic = 42;
$prop = $this->reflection->getProperty('privStatic');
$prop->setAccessible(true);
$this->assertEquals(42, $prop->getValue());
}
public function testModifyProtectedStaticProperty(): void
{
$this->classSpy->protStatic = 42;
$prop = $this->reflection->getProperty('protStatic');
$prop->setAccessible(true);
$this->assertEquals(42, $prop->getValue());
}
}