PostingBehavior::postingsForThread()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 8
nc 2
nop 3
dl 0
loc 15
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * Saito - The Threaded Web Forum
7
 *
8
 * @copyright Copyright (c) the Saito Project Developers
9
 * @link https://github.com/Schlaefer/Saito
10
 * @license http://opensource.org/licenses/MIT
11
 */
12
13
namespace App\Model\Behavior;
14
15
use App\Model\Table\EntriesTable;
16
use Cake\Cache\Cache;
17
use Cake\Datasource\Exception\RecordNotFoundException;
18
use Cake\Event\Event;
19
use Cake\ORM\Behavior;
20
use Cake\ORM\Entity;
21
use Cake\ORM\Query;
22
use Cake\ORM\RulesChecker;
23
use Saito\Posting\Posting;
24
use Saito\Posting\PostingInterface;
25
use Saito\Posting\TreeBuilder;
26
use Saito\User\CurrentUser\CurrentUserInterface;
27
use Stopwatch\Lib\Stopwatch;
28
use Traversable;
29
30
class PostingBehavior extends Behavior
31
{
32
    /**
33
     * {@inheritDoc}
34
     */
35
    public function buildRules(Event $event, RulesChecker $rules)
0 ignored issues
show
Unused Code introduced by
The parameter $event is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

35
    public function buildRules(/** @scrutinizer ignore-unused */ Event $event, RulesChecker $rules)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
36
    {
37
        $rules->add(
38
            function ($entity) {
39
                return $entity->isDirty('locked') ? ($entity->get('pid') === 0) : true;
40
            },
41
            'checkOnlyRootCanBeLocked',
42
            [
43
                'errorField' => 'locked',
44
                'message' => 'Only a root posting can be locked.',
45
            ]
46
        );
47
48
        $rules->addUpdate(
49
            function ($entity) {
50
                if ($entity->isDirty('category_id')) {
51
                    return $entity->isRoot();
52
                }
53
54
                return true;
55
            },
56
            'checkCategoryChangeOnlyOnRootPostings',
57
            [
58
                'errorField' => 'category_id',
59
                'message' => 'Cannot change category on non-root-postings.',
60
            ]
61
        );
62
63
        $rules->add($rules->existsIn('category_id', 'Categories'));
64
65
        return $rules;
66
    }
67
68
    /**
69
     * {@inheritDoc}
70
     */
71
    public function beforeSave(Event $event, Entity $entity)
72
    {
73
        $success = true;
74
75
        /// change category of thread if category of root entry changed
76
        if (!$entity->isNew() && $entity->isDirty('category_id')) {
77
            $success &= $this->threadChangeCategory(
78
                $entity->get('id'),
79
                $entity->get('category_id')
80
            );
81
        }
82
83
        if (!$success) {
84
            $event->stopPropagation();
85
        }
86
    }
87
88
    /**
89
     * {@inheritDoc}
90
     */
91
    public function afterSave(Event $event, Entity $entity)
0 ignored issues
show
Unused Code introduced by
The parameter $event is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

91
    public function afterSave(/** @scrutinizer ignore-unused */ Event $event, Entity $entity)

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
92
    {
93
        if ($entity->isDirty('locked')) {
94
            $this->lockThread($entity->get('tid'), $entity->get('locked'));
95
        }
96
    }
97
98
    /**
99
     * Get an array of postings for threads
100
     *
101
     * @param array $tids Thread-IDs
102
     * @param array|null $order Thread sort order
103
     * @param CurrentUserInterface $CU Current User
104
     * @return array<PostingInterface> Array of postings found
105
     * @throws RecordNotFoundException If no thread is found
106
     */
107
    public function postingsForThreads(array $tids, ?array $order = null, CurrentUserInterface $CU = null): array
108
    {
109
        $entries = $this->getTable()
110
            ->find('entriesForThreads', ['threadOrder' => $order, 'tids' => $tids])
111
            ->all();
112
113
        if (!count($entries)) {
114
            throw new RecordNotFoundException(
115
                sprintf('No postings for thread-IDs "%s".', implode(', ', $tids))
116
            );
117
        }
118
119
        return $this->entriesToPostings($entries, $CU);
120
    }
121
122
    /**
123
     * Get a posting for a thread
124
     *
125
     * @param int $tid Thread-ID
126
     * @param bool $complete complete fieldset
127
     * @param CurrentUserInterface|null $CurrentUser CurrentUser
128
     * @return PostingInterface
129
     * @throws RecordNotFoundException If thread isn't found
130
     */
131
    public function postingsForThread(int $tid, bool $complete = false, ?CurrentUserInterface $CurrentUser = null): PostingInterface
132
    {
133
        $entries = $this->getTable()
134
            ->find('entriesForThreads', ['complete' => $complete, 'tids' => [$tid]])
135
            ->all();
136
137
        if (!count($entries)) {
138
            throw new RecordNotFoundException(
139
                sprintf('No postings for thread-ID "%s".', $tid)
140
            );
141
        }
142
143
        $postings = $this->entriesToPostings($entries, $CurrentUser);
144
145
        return reset($postings);
146
    }
147
148
    /**
149
     * Delete a node
150
     *
151
     * @param int $id the node id
152
     * @return bool
153
     */
154
    public function deletePosting(int $id): bool
155
    {
156
        $root = $this->postingsForNode($id);
157
        if (empty($root)) {
158
            throw new \InvalidArgumentException();
159
        }
160
161
        $nodesToDelete[] = $root;
0 ignored issues
show
Comprehensibility Best Practice introduced by
$nodesToDelete was never initialized. Although not strictly required by PHP, it is generally a good practice to add $nodesToDelete = array(); before regardless.
Loading history...
162
        $nodesToDelete = array_merge($nodesToDelete, $root->getAllChildren());
163
164
        $idsToDelete = [];
165
        foreach ($nodesToDelete as $node) {
166
            $idsToDelete[] = $node->get('id');
167
        };
168
169
        /** @var EntriesTable */
170
        $table = $this->getTable();
171
172
        return $table->deleteWithIds($idsToDelete);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $table->deleteWithIds($idsToDelete) could return the type Cake\ORM\Query which is incompatible with the type-hinted return boolean. Consider adding an additional type-check to rule them out.
Loading history...
173
    }
174
175
    /**
176
     * Get recent postings
177
     *
178
     * ### Options:
179
     *
180
     * - `user_id` int|<null> If provided finds only postings of that user.
181
     * - `limit` int <10> Number of postings to find.
182
     *
183
     * @param CurrentUserInterface $User User who has access to postings
184
     * @param array $options find options
185
     *
186
     * @return array<PostingInterface> Array of Postings
187
     */
188
    public function getRecentPostings(CurrentUserInterface $User, array $options = []): array
189
    {
190
        Stopwatch::start('PostingBehavior::getRecentPostings');
191
192
        $options += [
193
            'user_id' => null,
194
            'limit' => 10,
195
        ];
196
197
        $options['category_id'] = $User->getCategories()->getAll('read');
198
199
        $read = function () use ($options) {
200
            $conditions = [];
201
            if ($options['user_id'] !== null) {
202
                $conditions[]['Entries.user_id'] = $options['user_id'];
203
            }
204
            if ($options['category_id'] !== null) {
205
                $conditions[]['Entries.category_id IN'] = $options['category_id'];
206
            };
207
208
            $result = $this
209
                ->getTable()
210
                ->find(
211
                    'entry',
212
                    [
213
                        'conditions' => $conditions,
214
                        'limit' => $options['limit'],
215
                        'order' => ['time' => 'DESC'],
216
                    ]
217
                )
218
                // hydrating kills performance
219
                ->enableHydration(false)
220
                ->all();
221
222
            return $result;
223
        };
224
225
        $key = 'Entry.recentEntries-' . md5(serialize($options));
226
        $results = Cache::remember($key, $read, 'entries');
227
228
        $threads = [];
229
        foreach ($results as $result) {
230
            $threads[$result['id']] = (new Posting($result))->withCurrentUser($User);
231
        }
232
233
        Stopwatch::stop('PostingBehavior::getRecentPostings');
234
235
        return $threads;
236
    }
237
238
    /**
239
     * Convert array with Entry entities to array with Postings
240
     *
241
     * @param Traversable $entries Entry array
242
     * @param CurrentUserInterface|null $CurrentUser The current user
243
     * @return array<PostingInterface>
244
     */
245
    protected function entriesToPostings(Traversable $entries, ?CurrentUserInterface $CurrentUser = null): array
246
    {
247
        Stopwatch::start('PostingBehavior::entriesToPostings');
248
        $threads = [];
249
        $postings = (new TreeBuilder())->build($entries);
0 ignored issues
show
Bug introduced by
$entries of type Traversable is incompatible with the type array expected by parameter $postings of Saito\Posting\TreeBuilder::build(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

249
        $postings = (new TreeBuilder())->build(/** @scrutinizer ignore-type */ $entries);
Loading history...
250
        foreach ($postings as $thread) {
251
            $posting = new Posting($thread);
252
            if ($CurrentUser) {
253
                $posting->withCurrentUser($CurrentUser);
254
            }
255
            $threads[$thread['tid']] = $posting;
256
        }
257
        Stopwatch::stop('PostingBehavior::entriesToPostings');
258
259
        return $threads;
260
    }
261
262
    /**
263
     * tree of a single node and its subentries
264
     *
265
     * @param int $id id
266
     * @return PostingInterface|null tree or null if nothing found
267
     */
268
    protected function postingsForNode(int $id): ?PostingInterface
269
    {
270
        /** @var EntriesTable */
271
        $table = $this->getTable();
272
        $tid = $table->getThreadId($id);
273
        $postings = $this->postingsForThreads([$tid]);
274
        $postings = array_shift($postings);
275
276
        return $postings->getThread()->get($id);
277
    }
278
279
    /**
280
     * Finder to get all entries for threads
281
     *
282
     * @param Query $query Query
283
     * @param array $options Options
284
     * - 'tids' array required thread-IDs
285
     * - 'complete' fieldset
286
     * - 'threadOrder' order
287
     * @return Query
288
     */
289
    public function findEntriesForThreads(Query $query, array $options): Query
290
    {
291
        Stopwatch::start('PostingBehavior::findEntriesForThreads');
292
        $options += [
293
            'complete' => false,
294
            'threadOrder' => ['last_answer' => 'ASC'],
295
        ];
296
        if (empty($options['tids'])) {
297
            throw new \InvalidArgumentException('Not threads to find.');
298
        }
299
        $tids = $options['tids'];
300
        $order = $options['threadOrder'];
301
        unset($options['threadOrder'], $options['tids']);
302
303
        $query = $query->find('entry', $options)
304
            ->where(['tid IN' => $tids])
305
            ->order($order)
306
            // hydrating kills performance
307
            ->enableHydration(false);
308
        Stopwatch::stop('PostingBehavior::findEntriesForThreads');
309
310
        return $query;
311
    }
312
313
    /**
314
     * Locks or unlocks a thread
315
     *
316
     * The lock operation is supposed to be done on the root entry.
317
     * All other entries in the same thread are set to locked too.
318
     *
319
     * @param int $tid ID of the thread to lock
320
     * @param bool $locked True to lock, false to unlock
321
     * @return void
322
     */
323
    protected function lockThread(int $tid, $locked = true)
324
    {
325
        $this->getTable()->updateAll(['locked' => $locked], ['tid' => $tid]);
326
    }
327
328
    /**
329
     * Changes the category of a thread.
330
     *
331
     * Assigns the new category-id to all postings in that thread.
332
     *
333
     * @param int $tid thread-ID
334
     * @param int $newCategoryId id for new category
335
     * @return bool success
336
     * @throws RecordNotFoundException
337
     */
338
    protected function threadChangeCategory(int $tid, int $newCategoryId): bool
339
    {
340
        $affected = $this->getTable()->updateAll(
341
            ['category_id' => $newCategoryId],
342
            ['tid' => $tid]
343
        );
344
345
        return $affected > 0;
346
    }
347
348
    /**
349
     * Merge thread on to entry $targetId
350
     *
351
     * @param int $sourceId root-id of the posting that is merged onto another
352
     *     thread
353
     * @param int $targetId id of the posting the source-thread should be
354
     *     appended to
355
     * @return bool true if merge was successfull false otherwise
356
     */
357
    public function threadMerge(int $sourceId, int $targetId): bool
358
    {
359
        /** @var EntriesTable */
360
        $table = $this->getTable();
361
362
        $sourcePosting = $table->get($sourceId, ['return' => 'Entity']);
363
364
        // check that source is thread-root and not an subposting
365
        if (!$sourcePosting->isRoot()) {
0 ignored issues
show
Bug introduced by
The method isRoot() does not exist on Cake\Datasource\EntityInterface. It seems like you code against a sub-type of Cake\Datasource\EntityInterface such as App\Model\Entity\Entry. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

365
        if (!$sourcePosting->/** @scrutinizer ignore-call */ isRoot()) {
Loading history...
366
            return false;
367
        }
368
369
        $targetPosting = $table->get($targetId);
370
371
        // check that target exists
372
        if (!$targetPosting) {
0 ignored issues
show
introduced by
$targetPosting is of type Cake\Datasource\EntityInterface, thus it always evaluated to true.
Loading history...
373
            return false;
374
        }
375
376
        // check that a thread is not merged onto itself
377
        if ($targetPosting->get('tid') === $sourcePosting->get('tid')) {
378
            return false;
379
        }
380
381
        // set target entry as new parent entry
382
        $table->patchEntity(
383
            $sourcePosting,
384
            ['pid' => $targetPosting->get('id')]
385
        );
386
        if ($table->save($sourcePosting)) {
387
            // associate all entries in source thread to target thread
388
            $table->updateAll(
389
                ['tid' => $targetPosting->get('tid')],
390
                ['tid' => $sourcePosting->get('tid')]
391
            );
392
393
            // appended source entries get category of target thread
394
            $this->threadChangeCategory(
395
                $targetPosting->get('tid'),
396
                $targetPosting->get('category_id')
397
            );
398
399
            // update target thread last answer if source is newer
400
            $sourceLastAnswer = $sourcePosting->get('last_answer');
401
            $targetLastAnswer = $targetPosting->get('last_answer');
402
            if ($sourceLastAnswer->gt($targetLastAnswer)) {
403
                $targetRoot = $table->get(
404
                    $targetPosting->get('tid'),
405
                    ['return' => 'Entity']
406
                );
407
                $targetRoot = $table->patchEntity(
408
                    $targetRoot,
409
                    ['last_answer' => $sourceLastAnswer]
410
                );
411
                $table->save($targetRoot);
412
            }
413
414
            // propagate pinned property from target to source
415
            $isTargetPinned = $targetPosting->isLocked();
0 ignored issues
show
Bug introduced by
The method isLocked() does not exist on Cake\Datasource\EntityInterface. It seems like you code against a sub-type of Cake\Datasource\EntityInterface such as App\Model\Entity\User or App\Model\Entity\Entry. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

415
            /** @scrutinizer ignore-call */ 
416
            $isTargetPinned = $targetPosting->isLocked();
Loading history...
416
            $isSourcePinned = $sourcePosting->isLocked();
417
            if ($isSourcePinned !== $isTargetPinned) {
418
                $this->lockThread($targetPosting->get('tid'), $isTargetPinned);
419
            }
420
421
            $table->dispatchDbEvent(
422
                'Model.Thread.change',
423
                ['subject' => $targetPosting->get('tid')]
424
            );
425
426
            return true;
427
        }
428
429
        return false;
430
    }
431
}
432