diff --git a/.ac-php-conf.json b/.ac-php-conf.json new file mode 100644 index 0000000..7edd623 --- /dev/null +++ b/.ac-php-conf.json @@ -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 -- " + ] + } +} \ No newline at end of file diff --git a/README.md b/README.md index 0511278..b27f740 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,8 @@ TODO: Make sure the following URLs are correct and working for your project. ## About - - +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 ``` - +## 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/) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index f76cd66..10906e3 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -8,6 +8,6 @@ ./src ./tests - + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 7cad6b1..7061e15 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,3 +4,7 @@ parameters: paths: - ./src - ./tests + ignoreErrors: + - '/Call to an undefined method/' + - '/Access to an undefined property/' + - '/Access to undefined constant/' diff --git a/psalm.xml b/psalm.xml index cba5289..471f8b4 100644 --- a/psalm.xml +++ b/psalm.xml @@ -14,4 +14,26 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Example.php b/src/Example.php deleted file mode 100644 index 89fa948..0000000 --- a/src/Example.php +++ /dev/null @@ -1,37 +0,0 @@ - - * @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}!"; - } -} diff --git a/src/ObjectProxy.php b/src/ObjectProxy.php new file mode 100644 index 0000000..fcb64c8 --- /dev/null +++ b/src/ObjectProxy.php @@ -0,0 +1,157 @@ + + * @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); + } +} diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php deleted file mode 100644 index a572c15..0000000 --- a/tests/ExampleTest.php +++ /dev/null @@ -1,20 +0,0 @@ -mockery(Example::class); - $example->shouldReceive('greet')->passthru(); - - $this->assertSame('Hello, Friends!', $example->greet('Friends')); - } -} diff --git a/tests/Fixtures/Example.php b/tests/Fixtures/Example.php new file mode 100644 index 0000000..2aba996 --- /dev/null +++ b/tests/Fixtures/Example.php @@ -0,0 +1,41 @@ +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; + } +} diff --git a/tests/ObjectProxyTest.php b/tests/ObjectProxyTest.php new file mode 100644 index 0000000..fbc9c0d --- /dev/null +++ b/tests/ObjectProxyTest.php @@ -0,0 +1,129 @@ +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()); + } +}