'PHP call class Closure with bound `$this` for decoration of dependent methods
More and more often than before I meet vendor code which is hard to extend. Even if there's interface for every necessary class, it doesn't help a lot. The worst case which I see is when one public method uses another public method rather than dedicated class.
As a simple example let's imagine online shop application and Order entity. Typically Doctrine is used as ORM and we would have a repository class. Interface for it is shown below:
interface ProductRepository
{
public function findReferenceBySku(string $sku): Product;
public function findIdBySku(string $sku): int;
}
Method findIdBySku will make one query into database in order to find corresponding id of product by it's sku. while method findReferenceBySku will return either existing object stored in identity map of unit-of-work or generate proxy class. Proxy is necessary because for our specific use case we don't want load all product attributes (around hundred), but Product entity is necessary in order to use it as related entity (for Price for instance).
For now, let the Product look like this:
class Product
{
public function __construct(
public int $id,
) { }
}
Repository implementation using database evidently will be such that findReferenceBySku uses findIdBySku.
class ProductDatabaseRepository implements ProductRepository
{
public function findReferenceBySku(string $sku): Product
{
$id = $this->findIdBySku($sku);
return $this->getProductReference($id);
}
private function getProductReference(int $id)
{
// check entity manager for existing object
// and generate proxy if not found
return new Product($id);
}
public function findIdBySku(string $sku): int
{
$this->queryDatabase();
return match($sku) {
'sku1' => 1,
};
}
protected function queryDatabase()
{
// note this method is not public
var_dump('query database');
}
}
During prices load it turned out to be that source has duplicated product skus and every time database query is made to correlate sku with id again and again.
To sort out such issue we'd decorate original repository and add caching logic to decorator.
$repository = (new ProductCachedRepository(new ProductDatabaseRepository()));
var_dump('call1', $repository->findReferenceBySku('sku1'));
var_dump('call2', $repository->findReferenceBySku('sku1')); // cache hit
var_dump('call2', $repository->findReferenceBySku('sku1')); // cache hit
The final step would be to implement decorator class - that's where all issues show up! We can't stick to obvious implementation of ProductCachedRepository::findReferenceBySku() which would be to pass call to inner repository. Decorated object won't use our extended findIdBySku() method which gives us zero profit for a lot of effort.
class ProductCachedRepository implements ProductRepository
{
private array $cachedIds = [];
public function __construct(
private ProductRepository $inner
) {}
public function findReferenceBySku(string $sku): Product
{
// not able to delegate this logic directly to inner class
// (loss of findIdBySku() overridden logic)
// return $this->inner->findReferenceBySku($sku);
// as well not able call decorated method with substitude this
// return $this->inner->findReferenceBySku(...)->call($this, $sku);
// therefore copy-paste like this
// $id = $this->findIdBySku($sku);
// return $this->getProductReference($id);
// the only missing thing is the way to create closure from method,
// forget bounded $this and call it with new one
// just like creation of closure identical to method
// with only difference - it is allowed to bind anywhere
// return (function (string $sku): Product {
// $id = $this->findIdBySku($sku);
//
// return $this->getProductReference($id);
//})->call($this, $sku);
}
public function findIdBySku(string $sku): int
{
var_dump('check cache');
return $this->cachedIds[$sku]
??= $this->inner->findIdBySku($sku);
}
public function __call($name, $arguments)
{
// all not public methods of inner repository
// which are not declared in current class
// are delegated to inner repository
return (fn() => $this->$name(...$arguments))
->call($this->inner);
}
}
Is there any way of creation closure identical to method. I firstly thought of return $this->inner->findReferenceBySku(...)->call($this, $sku);, however it fails with warning and doesn't call method with new $this.
Warning: Cannot bind method ProductDatabaseRepository::findReferenceBySku() to object of class ProductCachedRepository.
Do you have any thoughts on this matter? How would you work around this issue?
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|
