1: <?php
2: /**
3: * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
4: * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
5: *
6: * Licensed under The MIT License
7: * For full copyright and license information, please see the LICENSE.txt
8: * Redistributions of files must retain the above copyright notice.
9: *
10: * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
11: * @link https://cakephp.org CakePHP(tm) Project
12: * @since 3.0.0
13: * @license https://opensource.org/licenses/mit-license.php MIT License
14: */
15: namespace Cake\ORM\Behavior;
16:
17: use Cake\Datasource\EntityInterface;
18: use Cake\Event\Event;
19: use Cake\ORM\Association;
20: use Cake\ORM\Behavior;
21: use RuntimeException;
22:
23: /**
24: * CounterCache behavior
25: *
26: * Enables models to cache the amount of connections in a given relation.
27: *
28: * Examples with Post model belonging to User model
29: *
30: * Regular counter cache
31: * ```
32: * [
33: * 'Users' => [
34: * 'post_count'
35: * ]
36: * ]
37: * ```
38: *
39: * Counter cache with scope
40: * ```
41: * [
42: * 'Users' => [
43: * 'posts_published' => [
44: * 'conditions' => [
45: * 'published' => true
46: * ]
47: * ]
48: * ]
49: * ]
50: * ```
51: *
52: * Counter cache using custom find
53: * ```
54: * [
55: * 'Users' => [
56: * 'posts_published' => [
57: * 'finder' => 'published' // Will be using findPublished()
58: * ]
59: * ]
60: * ]
61: * ```
62: *
63: * Counter cache using lambda function returning the count
64: * This is equivalent to example #2
65: *
66: * ```
67: * [
68: * 'Users' => [
69: * 'posts_published' => function (Event $event, EntityInterface $entity, Table $table) {
70: * $query = $table->find('all')->where([
71: * 'published' => true,
72: * 'user_id' => $entity->get('user_id')
73: * ]);
74: * return $query->count();
75: * }
76: * ]
77: * ]
78: * ```
79: *
80: * When using a lambda function you can return `false` to disable updating the counter value
81: * for the current operation.
82: *
83: * Ignore updating the field if it is dirty
84: * ```
85: * [
86: * 'Users' => [
87: * 'posts_published' => [
88: * 'ignoreDirty' => true
89: * ]
90: * ]
91: * ]
92: * ```
93: *
94: * You can disable counter updates entirely by sending the `ignoreCounterCache` option
95: * to your save operation:
96: *
97: * ```
98: * $this->Articles->save($article, ['ignoreCounterCache' => true]);
99: * ```
100: */
101: class CounterCacheBehavior extends Behavior
102: {
103: /**
104: * Store the fields which should be ignored
105: *
106: * @var array
107: */
108: protected $_ignoreDirty = [];
109:
110: /**
111: * beforeSave callback.
112: *
113: * Check if a field, which should be ignored, is dirty
114: *
115: * @param \Cake\Event\Event $event The beforeSave event that was fired
116: * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved
117: * @param \ArrayObject $options The options for the query
118: * @return void
119: */
120: public function beforeSave(Event $event, EntityInterface $entity, $options)
121: {
122: if (isset($options['ignoreCounterCache']) && $options['ignoreCounterCache'] === true) {
123: return;
124: }
125:
126: foreach ($this->_config as $assoc => $settings) {
127: $assoc = $this->_table->getAssociation($assoc);
128: foreach ($settings as $field => $config) {
129: if (is_int($field)) {
130: continue;
131: }
132:
133: $registryAlias = $assoc->getTarget()->getRegistryAlias();
134: $entityAlias = $assoc->getProperty();
135:
136: if (!is_callable($config) &&
137: isset($config['ignoreDirty']) &&
138: $config['ignoreDirty'] === true &&
139: $entity->$entityAlias->isDirty($field)
140: ) {
141: $this->_ignoreDirty[$registryAlias][$field] = true;
142: }
143: }
144: }
145: }
146:
147: /**
148: * afterSave callback.
149: *
150: * Makes sure to update counter cache when a new record is created or updated.
151: *
152: * @param \Cake\Event\Event $event The afterSave event that was fired.
153: * @param \Cake\Datasource\EntityInterface $entity The entity that was saved.
154: * @param \ArrayObject $options The options for the query
155: * @return void
156: */
157: public function afterSave(Event $event, EntityInterface $entity, $options)
158: {
159: if (isset($options['ignoreCounterCache']) && $options['ignoreCounterCache'] === true) {
160: return;
161: }
162:
163: $this->_processAssociations($event, $entity);
164: $this->_ignoreDirty = [];
165: }
166:
167: /**
168: * afterDelete callback.
169: *
170: * Makes sure to update counter cache when a record is deleted.
171: *
172: * @param \Cake\Event\Event $event The afterDelete event that was fired.
173: * @param \Cake\Datasource\EntityInterface $entity The entity that was deleted.
174: * @param \ArrayObject $options The options for the query
175: * @return void
176: */
177: public function afterDelete(Event $event, EntityInterface $entity, $options)
178: {
179: if (isset($options['ignoreCounterCache']) && $options['ignoreCounterCache'] === true) {
180: return;
181: }
182:
183: $this->_processAssociations($event, $entity);
184: }
185:
186: /**
187: * Iterate all associations and update counter caches.
188: *
189: * @param \Cake\Event\Event $event Event instance.
190: * @param \Cake\Datasource\EntityInterface $entity Entity.
191: * @return void
192: */
193: protected function _processAssociations(Event $event, EntityInterface $entity)
194: {
195: foreach ($this->_config as $assoc => $settings) {
196: $assoc = $this->_table->getAssociation($assoc);
197: $this->_processAssociation($event, $entity, $assoc, $settings);
198: }
199: }
200:
201: /**
202: * Updates counter cache for a single association
203: *
204: * @param \Cake\Event\Event $event Event instance.
205: * @param \Cake\Datasource\EntityInterface $entity Entity
206: * @param \Cake\ORM\Association $assoc The association object
207: * @param array $settings The settings for for counter cache for this association
208: * @return void
209: * @throws \RuntimeException If invalid callable is passed.
210: */
211: protected function _processAssociation(Event $event, EntityInterface $entity, Association $assoc, array $settings)
212: {
213: $foreignKeys = (array)$assoc->getForeignKey();
214: $primaryKeys = (array)$assoc->getBindingKey();
215: $countConditions = $entity->extract($foreignKeys);
216: $updateConditions = array_combine($primaryKeys, $countConditions);
217: $countOriginalConditions = $entity->extractOriginalChanged($foreignKeys);
218:
219: if ($countOriginalConditions !== []) {
220: $updateOriginalConditions = array_combine($primaryKeys, $countOriginalConditions);
221: }
222:
223: foreach ($settings as $field => $config) {
224: if (is_int($field)) {
225: $field = $config;
226: $config = [];
227: }
228:
229: if (isset($this->_ignoreDirty[$assoc->getTarget()->getRegistryAlias()][$field]) &&
230: $this->_ignoreDirty[$assoc->getTarget()->getRegistryAlias()][$field] === true
231: ) {
232: continue;
233: }
234:
235: if (is_callable($config)) {
236: if (is_string($config)) {
237: throw new RuntimeException('You must not use a string as callable.');
238: }
239: $count = $config($event, $entity, $this->_table, false);
240: } else {
241: $count = $this->_getCount($config, $countConditions);
242: }
243: if ($count !== false) {
244: $assoc->getTarget()->updateAll([$field => $count], $updateConditions);
245: }
246:
247: if (isset($updateOriginalConditions)) {
248: if (is_callable($config)) {
249: if (is_string($config)) {
250: throw new RuntimeException('You must not use a string as callable.');
251: }
252: $count = $config($event, $entity, $this->_table, true);
253: } else {
254: $count = $this->_getCount($config, $countOriginalConditions);
255: }
256: if ($count !== false) {
257: $assoc->getTarget()->updateAll([$field => $count], $updateOriginalConditions);
258: }
259: }
260: }
261: }
262:
263: /**
264: * Fetches and returns the count for a single field in an association
265: *
266: * @param array $config The counter cache configuration for a single field
267: * @param array $conditions Additional conditions given to the query
268: * @return int The number of relations matching the given config and conditions
269: */
270: protected function _getCount(array $config, array $conditions)
271: {
272: $finder = 'all';
273: if (!empty($config['finder'])) {
274: $finder = $config['finder'];
275: unset($config['finder']);
276: }
277:
278: if (!isset($config['conditions'])) {
279: $config['conditions'] = [];
280: }
281: $config['conditions'] = array_merge($conditions, $config['conditions']);
282: $query = $this->_table->find($finder, $config);
283:
284: return $query->count();
285: }
286: }
287: