Browse Source

Merge pull request #17967 from cakephp/feat/counter-cache-update

Add method to update counter cache values for all records.
ADmad 1 year ago
parent
commit
99519d125f

+ 97 - 0
src/ORM/Behavior/CounterCacheBehavior.php

@@ -20,6 +20,7 @@ use ArrayObject;
 use Cake\Datasource\EntityInterface;
 use Cake\Event\EventInterface;
 use Cake\ORM\Association;
+use Cake\ORM\Association\BelongsTo;
 use Cake\ORM\Behavior;
 use Cake\ORM\Query\SelectQuery;
 use Closure;
@@ -192,6 +193,102 @@ class CounterCacheBehavior extends Behavior
     }
 
     /**
+     * Update counter cache for a batch of records.
+     *
+     * Counter caches configured to use closures will not be updated by the method.
+     *
+     * @param string|null $assocName The association name to update counter cache for.
+     *  If null, all configured associations will be processed.
+     * @param int $limit The number of records to update per page/iteration.
+     * @param int|null $page The page/iteration number. If null (default), all
+     *   records will be updated one page at a time.
+     * @return void
+     * @since 5.2.0
+     */
+    public function updateCounterCache(?string $assocName = null, int $limit = 100, ?int $page = null): void
+    {
+        $config = $this->_config;
+        if ($assocName !== null) {
+            $config = [$assocName => $config[$assocName]];
+        }
+
+        foreach ($config as $assoc => $settings) {
+            /** @var \Cake\ORM\Association\BelongsTo $belongsTo */
+            $belongsTo = $this->_table->getAssociation($assoc);
+
+            foreach ($settings as $field => $config) {
+                if ($config instanceof Closure) {
+                    // Cannot update counter cache which use a closure
+                    return;
+                }
+
+                if (is_int($field)) {
+                    $field = $config;
+                    $config = [];
+                }
+
+                $this->updateCountForAssociation($belongsTo, $field, $config, $limit, $page);
+            }
+        }
+    }
+
+    /**
+     * Update counter cache for the given association.
+     *
+     * @param \Cake\ORM\Association\BelongsTo $assoc The association object.
+     * @param string $field Counter cache field.
+     * @param array $config Config array.
+     * @param int $limit Limit.
+     * @param int|null $page Page number.
+     * @return void
+     */
+    protected function updateCountForAssociation(
+        BelongsTo $assoc,
+        string $field,
+        array $config,
+        int $limit = 100,
+        ?int $page = null
+    ): void {
+        $primaryKeys = (array)$assoc->getBindingKey();
+        /** @var list<string> $foreignKeys */
+        $foreignKeys = (array)$assoc->getForeignKey();
+
+        $query = $assoc->getTarget()->find()
+            ->select($primaryKeys)
+            ->limit($limit);
+
+        foreach ($primaryKeys as $key) {
+            $query->orderByAsc($key);
+        }
+
+        $singlePage = $page !== null;
+        $page ??= 1;
+
+        do {
+            $results = $query
+                ->page($page++)
+                ->all();
+
+            /** @var \Cake\Datasource\EntityInterface $entity */
+            foreach ($results as $entity) {
+                $updateConditions = $entity->extract($primaryKeys);
+
+                foreach ($updateConditions as $f => $value) {
+                    if ($value === null) {
+                        $updateConditions[$f . ' IS'] = $value;
+                        unset($updateConditions[$f]);
+                    }
+                }
+
+                $countConditions = array_combine($foreignKeys, $updateConditions);
+
+                $count = $this->_getCount($config, $countConditions);
+                $assoc->getTarget()->updateAll([$field => $count], $updateConditions);
+            }
+        } while (!$singlePage && $results->count() === $limit);
+    }
+
+    /**
      * Iterate all associations and update counter caches.
      *
      * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Event instance.

+ 35 - 0
tests/TestCase/ORM/Behavior/CounterCacheBehaviorTest.php

@@ -23,6 +23,7 @@ use Cake\Event\EventInterface;
 use Cake\ORM\Entity;
 use Cake\ORM\Table;
 use Cake\TestSuite\TestCase;
+use Exception;
 use TestApp\Model\Table\PublishedPostsTable;
 
 /**
@@ -620,6 +621,40 @@ class CounterCacheBehaviorTest extends TestCase
         $this->assertSame(1, $user->get('posts_published'));
     }
 
+    public function testUpdateCounterCache(): void
+    {
+        $this->post->belongsTo('Users');
+        $this->post->addBehavior('CounterCache', [
+            'Users' => [
+                'post_count',
+                'dummy' => function () {
+                    throw new Exception('Closures are never called by "updateCounterCache()"');
+                },
+            ],
+        ]);
+
+        $this->user->updateAll(['post_count' => 0], []);
+
+        $user = $this->_getUser(1);
+        $this->assertSame(0, $user->get('post_count'));
+
+        $this->post->updateCounterCache('Users');
+
+        $user = $this->_getUser(1);
+        $this->assertSame(2, $user->get('post_count'));
+        $user = $this->_getUser(2);
+        $this->assertSame(1, $user->get('post_count'));
+
+        $this->user->updateAll(['post_count' => 0], []);
+
+        $this->post->updateCounterCache(limit: 1, page: 2);
+
+        $user = $this->_getUser(1);
+        $this->assertSame(0, $user->get('post_count'));
+        $user = $this->_getUser(2);
+        $this->assertSame(1, $user->get('post_count'));
+    }
+
     /**
      * Get a new Entity
      */