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: /**
105: * Store the fields which should be ignored
106: *
107: * @var array
108: */
109: protected $_ignoreDirty = [];
110:
111: /**
112: * beforeSave callback.
113: *
114: * Check if a field, which should be ignored, is dirty
115: *
116: * @param \Cake\Event\Event $event The beforeSave event that was fired
117: * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved
118: * @param \ArrayObject $options The options for the query
119: * @return void
120: */
121: public function beforeSave(Event $event, EntityInterface $entity, $options)
122: {
123: if (isset($options['ignoreCounterCache']) && $options['ignoreCounterCache'] === true) {
124: return;
125: }
126:
127: foreach ($this->_config as $assoc => $settings) {
128: $assoc = $this->_table->getAssociation($assoc);
129: foreach ($settings as $field => $config) {
130: if (is_int($field)) {
131: continue;
132: }
133:
134: $registryAlias = $assoc->getTarget()->getRegistryAlias();
135: $entityAlias = $assoc->getProperty();
136:
137: if (!is_callable($config) &&
138: isset($config['ignoreDirty']) &&
139: $config['ignoreDirty'] === true &&
140: $entity->$entityAlias->isDirty($field)
141: ) {
142: $this->_ignoreDirty[$registryAlias][$field] = true;
143: }
144: }
145: }
146: }
147:
148: /**
149: * afterSave callback.
150: *
151: * Makes sure to update counter cache when a new record is created or updated.
152: *
153: * @param \Cake\Event\Event $event The afterSave event that was fired.
154: * @param \Cake\Datasource\EntityInterface $entity The entity that was saved.
155: * @param \ArrayObject $options The options for the query
156: * @return void
157: */
158: public function afterSave(Event $event, EntityInterface $entity, $options)
159: {
160: if (isset($options['ignoreCounterCache']) && $options['ignoreCounterCache'] === true) {
161: return;
162: }
163:
164: $this->_processAssociations($event, $entity);
165: $this->_ignoreDirty = [];
166: }
167:
168: /**
169: * afterDelete callback.
170: *
171: * Makes sure to update counter cache when a record is deleted.
172: *
173: * @param \Cake\Event\Event $event The afterDelete event that was fired.
174: * @param \Cake\Datasource\EntityInterface $entity The entity that was deleted.
175: * @param \ArrayObject $options The options for the query
176: * @return void
177: */
178: public function afterDelete(Event $event, EntityInterface $entity, $options)
179: {
180: if (isset($options['ignoreCounterCache']) && $options['ignoreCounterCache'] === true) {
181: return;
182: }
183:
184: $this->_processAssociations($event, $entity);
185: }
186:
187: /**
188: * Iterate all associations and update counter caches.
189: *
190: * @param \Cake\Event\Event $event Event instance.
191: * @param \Cake\Datasource\EntityInterface $entity Entity.
192: * @return void
193: */
194: protected function _processAssociations(Event $event, EntityInterface $entity)
195: {
196: foreach ($this->_config as $assoc => $settings) {
197: $assoc = $this->_table->getAssociation($assoc);
198: $this->_processAssociation($event, $entity, $assoc, $settings);
199: }
200: }
201:
202: /**
203: * Updates counter cache for a single association
204: *
205: * @param \Cake\Event\Event $event Event instance.
206: * @param \Cake\Datasource\EntityInterface $entity Entity
207: * @param \Cake\ORM\Association $assoc The association object
208: * @param array $settings The settings for for counter cache for this association
209: * @return void
210: * @throws \RuntimeException If invalid callable is passed.
211: */
212: protected function _processAssociation(Event $event, EntityInterface $entity, Association $assoc, array $settings)
213: {
214: $foreignKeys = (array)$assoc->getForeignKey();
215: $primaryKeys = (array)$assoc->getBindingKey();
216: $countConditions = $entity->extract($foreignKeys);
217: $updateConditions = array_combine($primaryKeys, $countConditions);
218: $countOriginalConditions = $entity->extractOriginalChanged($foreignKeys);
219:
220: if ($countOriginalConditions !== []) {
221: $updateOriginalConditions = array_combine($primaryKeys, $countOriginalConditions);
222: }
223:
224: foreach ($settings as $field => $config) {
225: if (is_int($field)) {
226: $field = $config;
227: $config = [];
228: }
229:
230: if (isset($this->_ignoreDirty[$assoc->getTarget()->getRegistryAlias()][$field]) &&
231: $this->_ignoreDirty[$assoc->getTarget()->getRegistryAlias()][$field] === true
232: ) {
233: continue;
234: }
235:
236: if (is_callable($config)) {
237: if (is_string($config)) {
238: throw new RuntimeException('You must not use a string as callable.');
239: }
240: $count = $config($event, $entity, $this->_table, false);
241: } else {
242: $count = $this->_getCount($config, $countConditions);
243: }
244: if ($count !== false) {
245: $assoc->getTarget()->updateAll([$field => $count], $updateConditions);
246: }
247:
248: if (isset($updateOriginalConditions)) {
249: if (is_callable($config)) {
250: if (is_string($config)) {
251: throw new RuntimeException('You must not use a string as callable.');
252: }
253: $count = $config($event, $entity, $this->_table, true);
254: } else {
255: $count = $this->_getCount($config, $countOriginalConditions);
256: }
257: if ($count !== false) {
258: $assoc->getTarget()->updateAll([$field => $count], $updateOriginalConditions);
259: }
260: }
261: }
262: }
263:
264: /**
265: * Fetches and returns the count for a single field in an association
266: *
267: * @param array $config The counter cache configuration for a single field
268: * @param array $conditions Additional conditions given to the query
269: * @return int The number of relations matching the given config and conditions
270: */
271: protected function _getCount(array $config, array $conditions)
272: {
273: $finder = 'all';
274: if (!empty($config['finder'])) {
275: $finder = $config['finder'];
276: unset($config['finder']);
277: }
278:
279: if (!isset($config['conditions'])) {
280: $config['conditions'] = [];
281: }
282: $config['conditions'] = array_merge($conditions, $config['conditions']);
283: $query = $this->_table->find($finder, $config);
284:
285: return $query->count();
286: }
287: }
288: