'CakePHP Entity vs EntityInterface

Because PHPStan is driving me nuts about this, I have to ask and hope someone has an answer: when querying the database, the result set is a list of EntityInterface objects. Then when I attempt to work with the results, PHPStan gets suspicious of the objects and whether they contain the right fields or not. If I pass the following to a function:

$this->Carts->get($cart->id);

The error PHPStan give is phpstan: Cannot access property $id on array|Cake\Datasource\EntityInterface|null. This gets especially aggravating when I'm working with users which are also Authentication objects.

In the end, I have a ton of clauses in my code that look like this to bypass PHPStan's error messages:

if (is_object($cart) && is_a($cart, 'Cake\Datasource\EntityInterface')) {
   $this->CartManager->pruneCarts($user->id, (int)$cart->id);
}

But if I'm verifying that a given object is an EntityInterface, then PHPStan will tell me that EntityInterface does not have a property called id.

It seems like there must be some larger check I should be doing where I verify that the object IS and object and IS an EntityInterface AND IS the specific type of Entity that I know I've selected. But do I really need to wrap every such query in three layers of checks?

I have both of the following plugins installed, which should be mitigating the problem, but are not:

  • "dereuromark/cakephp-ide-helper": "^1.13",
  • "cakedc/cakephp-phpstan": "^2.0",

Currently, the get() function is documented in the following manner, in every table:

@method \Visualize\Model\Entity\Cart get($primaryKey, $options = [])

Here is a more fulsome example of the problem I'm having. All the way down, when I check the properties of $cart in this example function, it is declared as $cart array|Cake\Datasource\EntityInterface|null and ultimately, my function is telling me that I'm returning the wrong thing. I don't know how to remove the uncertainty of array|Cake\Datasource\EntityInterface|null and collapse it down to a single thing?

    /**
     * Returns the most recent active cart, else creates a new empty one.
     *
     * @param \Visualize\Model\Entity\User $user The User entity.
     * @return \Cake\Datasource\EntityInterface
     */
    public function getUserCart(User $user)
    {
        $query = $this->Carts->find('userCart', ['user_id' => $user->id]);
        if (!$query->isEmpty()) {
            $cart = $query->first();
            if (!empty($cart->id)) {
                $this->updateSessionCart((int)$cart->id);
            } else {
                Log::error('User shopping cart does not exist.');
            }
        } else {
            $cart = $this->Carts->newEmptyEntity();
            $cart->set('user_id', $user->id);
            $cart->set('cart_total', 0);
            $cart->set('cart_status', Configure::read('Orders.NewCart'));
            $this->Carts->save($cart);
            $this->updateSessionCart((int)$cart->id);
        }

        return $this->Carts->get($cart->id);
    }

EDIT

Here is the entire CartManagerComponent for the sake of completeness. @mark will note that the $Carts variable IS set to the CartsTable object.

<?php
declare(strict_types=1);

namespace Visualize\Controller\Component;

use Cake\Controller\Component;
use Cake\Core\Configure;
use Cake\Log\Log;
use Cake\ORM\TableRegistry;
use Visualize\Model\Entity\User;

/**
 * CartManager component
 *
 * @method \Visualize\Controller\AppController getController()
 * @property \Visualize\Controller\AppController $Controller
 * @property \Visualize\Model\Table\CartsTable $Carts
 * @property \Visualize\Model\Table\CartLinesTable $CartLines
 */
class CartManagerComponent extends Component
{
    /**
     * Default configuration.
     *
     * @var array<string, mixed>
     */
    protected $_defaultConfig = [];

    /**
     * @var \Visualize\Controller\AppController
     */
    protected $Controller;

    /**
     * @var \Cake\ORM\Table
     */
    protected $Carts;

    /**
     * @var \Cake\ORM\Table
     */
    protected $CartLines;

    /**
     * @var \Cake\Http\Session
     */
    protected $Session;

    /**
     * @param array $config The current configuration array
     * @return void
     */
    public function initialize(array $config): void
    {
        parent::initialize($config);
        $this->Controller = $this->getController();
        $this->Carts = TableRegistry::getTableLocator()->get('Carts');
        $this->CartLines = TableRegistry::getTableLocator()->get('CartLines');
        $this->Session = $this->Controller->getRequest()->getSession();
    }

    /**
     * Returns the most recent active cart, else creates a new empty one.
     *
     * @param \Visualize\Model\Entity\User $user The User entity.
     * @return \Cake\Datasource\EntityInterface
     */
    public function getUserCart(User $user)
    {
        $query = $this->Carts->find('userCart', ['user_id' => $user->id]);
        if (!$query->isEmpty()) {
            $cart = $query->first();
            if (!empty($cart->id)) {
                $this->updateSessionCart((int)$cart->id);
            } else {
                Log::error('User shopping cart does not exist.');
            }
        } else {
            $cart = $this->Carts->newEmptyEntity();
            $cart->set('user_id', $user->id);
            $cart->set('cart_total', 0);
            $cart->set('cart_status', Configure::read('Orders.NewCart'));
            $this->Carts->save($cart);
            $this->updateSessionCart((int)$cart->id);
        }

        return $this->Carts->get(1);
    }

    /**
     * Abandons carts
     *
     * @param int $user_id The associated user ID
     * @param int $cart_id The current cart ID
     * @return void
     */
    public function pruneCarts(int $user_id, int $cart_id): void
    {
        if (!empty($this->Controller->Carts) && is_a($this->Controller->Carts, '\Visualize\Model\Table\CartsTable')) {
            // Find all the carts we didn't just create:
            $userCarts = $this->Controller->Carts->find('all', ['fields' => ['id', 'user_id', 'cart_status']])
                ->where([
                    'id !=' => $cart_id,
                    'user_id' => $user_id,
                    'cart_status' => 'active',
                ]);
            if (!$userCarts->isEmpty()) {
                $count = 0;
                foreach ($userCarts as $cart) {
                    if ($count < 5) {
                        $record = $this->Controller->Carts->newEmptyEntity();
                        $record = $this->Controller->Carts->patchEntity($record, $cart->toArray());
                        $record->set('id', $cart->id);
                        $record->set('cart_status', Configure::read('Orders.AbandonedCart'));
                        if (!$this->Controller->Carts->save($record)) {
                            Log::error('Error abandoning cart');
                        }
                    } else {
                        $this->Controller->Carts->delete($cart);
                    }
                    $count++;
                }
            }
        }
    }

    /**
     * Update the current session cart line count.
     *
     * @param int $cart_id The current cart id.
     * @return void
     */
    public function updateSessionCart(int $cart_id): void
    {
        $count = $this->CartLines->find('all')
            ->where(['cart_id' => $cart_id])
            ->count();
        $this->Session->write('Cart.id', $cart_id);
        $this->Session->write('Cart.count', $count);
    }

    /**
     * Remove any carts from the user's session.
     *
     * @return void
     */
    public function removeSessionCart(): void
    {
        $this->Session->delete('Cart');
    }
}


Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source