Passed
Push — develop ( 963cc8...d9b475 )
by Schlaefer
04:33
created

PostingBehavior   A

Complexity

Total Complexity 29

Size/Duplication

Total Lines 380
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 138
dl 0
loc 380
rs 10
c 1
b 0
f 0
wmc 29

14 Methods

Rating   Name   Duplication   Size   Complexity  
A postingsForNode() 0 9 1
A entriesToPostings() 0 15 3
A getRecentPostings() 0 48 4
A deletePosting() 0 19 3
A postingsForThreads() 0 13 2
A validatorSetup() 0 8 1
A updatePosting() 0 32 2
A createPosting() 0 31 3
A prepareChildPosting() 0 11 2
A validateEditingAllowed() 0 5 1
A findEntriesForThreads() 0 22 2
A postingsForThread() 0 15 2
A initialize() 0 5 1
A validateCategoryIsAllowed() 0 7 2
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\Lib\Model\Table\FieldFilter;
16
use App\Model\Entity\Entry;
17
use App\Model\Table\EntriesTable;
18
use Cake\Cache\Cache;
19
use Cake\Datasource\Exception\RecordNotFoundException;
20
use Cake\ORM\Behavior;
21
use Cake\ORM\Query;
22
use Saito\Posting\Basic\BasicPostingInterface;
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
    /** @var CurrentUserInterface */
33
    private $CurrentUser;
34
35
    /** @var FieldFilter */
36
    private $fieldFilter;
37
38
    /**
39
     * {@inheritDoc}
40
     */
41
    public function initialize(array $config)
42
    {
43
        $this->fieldFilter = (new fieldfilter())
44
            ->setConfig('create', ['category_id', 'pid', 'subject', 'text'])
45
            ->setConfig('update', ['category_id', 'subject', 'text']);
46
    }
47
48
    /**
49
     * Creates a new posting from user
50
     *
51
     * @param array $data raw posting data
52
     * @param CurrentUserInterface $CurrentUser the current user
53
     * @return Entry|null on success, null otherwise
54
     */
55
    public function createPosting(array $data, CurrentUserInterface $CurrentUser): ?Entry
56
    {
57
        $data = $this->fieldFilter->filterFields($data, 'create');
58
59
        if (!empty($data['pid'])) {
60
            /// new posting is answer to existing posting
61
            $parent = $this->getTable()->get($data['pid']);
62
63
            if (empty($parent)) {
64
                throw new \InvalidArgumentException(
65
                    'Parent posting for creating a new answer not found.',
66
                    1564756571
67
                );
68
            }
69
70
            $data = $this->prepareChildPosting($parent, $data);
0 ignored issues
show
Bug introduced by
$parent of type Cake\Datasource\EntityInterface is incompatible with the type Saito\Posting\Basic\BasicPostingInterface expected by parameter $parent of App\Model\Behavior\Posti...::prepareChildPosting(). ( Ignorable by Annotation )

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

70
            $data = $this->prepareChildPosting(/** @scrutinizer ignore-type */ $parent, $data);
Loading history...
71
        } else {
72
            /// if no pid is provided the new posting is root-posting
73
            $data['pid'] = 0;
74
        }
75
76
        /// set user who created the posting
77
        $data['user_id'] = $CurrentUser->getId();
78
        $data['name'] = $CurrentUser->get('username');
79
80
        $this->validatorSetup($CurrentUser);
81
82
        /** @var EntriesTable */
83
        $table = $this->getTable();
84
85
        return $table->createEntry($data);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $table->createEntry($data) could return the type Cake\ORM\Query which is incompatible with the type-hinted return App\Model\Entity\Entry|null. Consider adding an additional type-check to rule them out.
Loading history...
86
    }
87
88
    /**
89
     * Updates an existing posting
90
     *
91
     * @param Entry $posting the posting to update
92
     * @param array $data data the posting should be updated with
93
     * @param CurrentUserInterface $CurrentUser the current-user
94
     * @return Entry|null the posting which was asked to update
95
     */
96
    public function updatePosting(Entry $posting, array $data, CurrentUserInterface $CurrentUser): ?Entry
97
    {
98
        $data = $this->fieldFilter->filterFields($data, 'update');
99
        $isRoot = $posting->isRoot();
100
101
        if (!$isRoot) {
102
            $parent = $this->getTable()->get($posting->get('pid'));
103
            $data = $this->prepareChildPosting($parent, $data);
0 ignored issues
show
Bug introduced by
$parent of type Cake\Datasource\EntityInterface is incompatible with the type Saito\Posting\Basic\BasicPostingInterface expected by parameter $parent of App\Model\Behavior\Posti...::prepareChildPosting(). ( Ignorable by Annotation )

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

103
            $data = $this->prepareChildPosting(/** @scrutinizer ignore-type */ $parent, $data);
Loading history...
104
        }
105
106
        $data['edited_by'] = $CurrentUser->get('username');
107
108
        /// must be set for validation
109
        $data['locked'] = $posting->get('locked');
110
        $data['fixed'] = $posting->get('fixed');
111
        $data['category_id'] = $data['category_id'] ?? $posting->get('category_id');
112
113
        $data['pid'] = $posting->get('pid');
114
        $data['time'] = $posting->get('time');
115
        $data['user_id'] = $posting->get('user_id');
116
117
        $this->validatorSetup($CurrentUser);
118
        $this->getTable()->getValidator()->add(
119
            'edited_by',
120
            'isEditingAllowed',
121
            ['rule' => [$this, 'validateEditingAllowed']]
122
        );
123
124
        /** @var EntriesTable */
125
        $table = $this->getTable();
126
127
        return $table->updateEntry($posting, $data);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $table->updateEntry($posting, $data) could return the type Cake\ORM\Query which is incompatible with the type-hinted return App\Model\Entity\Entry|null. Consider adding an additional type-check to rule them out.
Loading history...
128
    }
129
130
    /**
131
     * Populates data of an answer derived from parent the parent-posting
132
     *
133
     * @param BasicPostingInterface $parent parent data
134
     * @param array $data current posting data
135
     * @return array populated $data
136
     */
137
    public function prepareChildPosting(BasicPostingInterface $parent, array $data): array
138
    {
139
        if (empty($data['subject'])) {
140
            // if new subject is empty use the parent's subject
141
            $data['subject'] = $parent->get('subject');
142
        }
143
144
        $data['category_id'] = $data['category_id'] ?? $parent->get('category_id');
145
        $data['tid'] = $parent->get('tid');
146
147
        return $data;
148
    }
149
150
    /**
151
     * Sets-up validator for the table
152
     *
153
     * @param CurrentUserInterface $CurrentUser current user
154
     * @return void
155
     */
156
    private function validatorSetup(CurrentUserInterface $CurrentUser): void
157
    {
158
        $this->CurrentUser = $CurrentUser;
159
160
        $this->getTable()->getValidator()->add(
161
            'category_id',
162
            'isAllowed',
163
            ['rule' => [$this, 'validateCategoryIsAllowed']]
164
        );
165
    }
166
167
    /**
168
     * check that entries are only in existing and allowed categories
169
     *
170
     * @param mixed $categoryId value
171
     * @param array $context context
172
     * @return bool
173
     */
174
    public function validateCategoryIsAllowed($categoryId, $context): bool
175
    {
176
        $isRoot = $context['data']['pid'] == 0;
177
        $action = $isRoot ? 'thread' : 'answer';
178
179
        // @td better return !$posting->isAnsweringForbidden();
180
        return $this->CurrentUser->getCategories()->permission($action, $categoryId);
181
    }
182
183
    /**
184
     * check editing allowed
185
     *
186
     * @param mixed $check value
187
     * @param array $context context
188
     * @return bool
189
     */
190
    public function validateEditingAllowed($check, $context): bool
191
    {
192
        $posting = (new Posting($context['data']))->withCurrentUser($this->CurrentUser);
193
194
        return $posting->isEditingAllowed();
195
    }
196
197
    /**
198
     * Get an array of postings for threads
199
     *
200
     * @param array $tids Thread-IDs
201
     * @param array|null $order Thread sort order
202
     * @param CurrentUserInterface $CU Current User
203
     * @return array<PostingInterface> Array of postings found
204
     * @throws RecordNotFoundException If no thread is found
205
     */
206
    public function postingsForThreads(array $tids, ?array $order = null, CurrentUserInterface $CU = null): array
207
    {
208
        $entries = $this->getTable()
209
            ->find('entriesForThreads', ['threadOrder' => $order, 'tids' => $tids])
210
            ->all();
211
212
        if (!count($entries)) {
213
            throw new RecordNotFoundException(
214
                sprintf('No postings for thread-IDs "%s".', implode(', ', $tids))
215
            );
216
        }
217
218
        return $this->entriesToPostings($entries, $CU);
219
    }
220
221
    /**
222
     * Get a posting for a thread
223
     *
224
     * @param int $tid Thread-ID
225
     * @param bool $complete complete fieldset
226
     * @param CurrentUserInterface|null $CurrentUser CurrentUser
227
     * @return PostingInterface
228
     * @throws RecordNotFoundException If thread isn't found
229
     */
230
    public function postingsForThread(int $tid, bool $complete = false, ?CurrentUserInterface $CurrentUser = null): PostingInterface
231
    {
232
        $entries = $this->getTable()
233
            ->find('entriesForThreads', ['complete' => $complete, 'tids' => [$tid]])
234
            ->all();
235
236
        if (!count($entries)) {
237
            throw new RecordNotFoundException(
238
                sprintf('No postings for thread-ID "%s".', $tid)
239
            );
240
        }
241
242
        $postings = $this->entriesToPostings($entries, $CurrentUser);
243
244
        return reset($postings);
245
    }
246
247
    /**
248
     * Delete a node
249
     *
250
     * @param int $id the node id
251
     * @return bool
252
     */
253
    public function deletePosting(int $id): bool
254
    {
255
        $root = $this->postingsForNode($id);
256
        if (empty($root)) {
257
            throw new \InvalidArgumentException();
258
        }
259
260
        $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...
261
        $nodesToDelete = array_merge($nodesToDelete, $root->getAllChildren());
262
263
        $idsToDelete = [];
264
        foreach ($nodesToDelete as $node) {
265
            $idsToDelete[] = $node->get('id');
266
        };
267
268
        /** @var EntriesTable */
269
        $table = $this->getTable();
270
271
        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...
272
    }
273
274
    /**
275
     * Get recent postings
276
     *
277
     * ### Options:
278
     *
279
     * - `user_id` int|<null> If provided finds only postings of that user.
280
     * - `limit` int <10> Number of postings to find.
281
     *
282
     * @param CurrentUserInterface $User User who has access to postings
283
     * @param array $options find options
284
     *
285
     * @return array<PostingInterface> Array of Postings
286
     */
287
    public function getRecentPostings(CurrentUserInterface $User, array $options = []): array
288
    {
289
        Stopwatch::start('PostingBehavior::getRecentPostings');
290
291
        $options += [
292
            'user_id' => null,
293
            'limit' => 10,
294
        ];
295
296
        $options['category_id'] = $User->getCategories()->getAll('read');
297
298
        $read = function () use ($options) {
299
            $conditions = [];
300
            if ($options['user_id'] !== null) {
301
                $conditions[]['Entries.user_id'] = $options['user_id'];
302
            }
303
            if ($options['category_id'] !== null) {
304
                $conditions[]['Entries.category_id IN'] = $options['category_id'];
305
            };
306
307
            $result = $this
308
                ->getTable()
309
                ->find(
310
                    'entry',
311
                    [
312
                        'conditions' => $conditions,
313
                        'limit' => $options['limit'],
314
                        'order' => ['time' => 'DESC']
315
                    ]
316
                )
317
                // hydrating kills performance
318
                ->enableHydration(false)
319
                ->all();
320
321
            return $result;
322
        };
323
324
        $key = 'Entry.recentEntries-' . md5(serialize($options));
325
        $results = Cache::remember($key, $read, 'entries');
326
327
        $threads = [];
328
        foreach ($results as $result) {
329
            $threads[$result['id']] = (new Posting($result))->withCurrentUser($User);
330
        }
331
332
        Stopwatch::stop('PostingBehavior::getRecentPostings');
333
334
        return $threads;
335
    }
336
337
    /**
338
     * Convert array with Entry entities to array with Postings
339
     *
340
     * @param Traversable $entries Entry array
341
     * @param CurrentUserInterface|null $CurrentUser The current user
342
     * @return array<PostingInterface>
343
     */
344
    protected function entriesToPostings(Traversable $entries, ?CurrentUserInterface $CurrentUser = null): array
345
    {
346
        Stopwatch::start('PostingBehavior::entriesToPostings');
347
        $threads = [];
348
        $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

348
        $postings = (new TreeBuilder())->build(/** @scrutinizer ignore-type */ $entries);
Loading history...
349
        foreach ($postings as $thread) {
350
            $posting = new Posting($thread);
351
            if ($CurrentUser) {
352
                $posting->withCurrentUser($CurrentUser);
353
            }
354
            $threads[$thread['tid']] = $posting;
355
        }
356
        Stopwatch::stop('PostingBehavior::entriesToPostings');
357
358
        return $threads;
359
    }
360
361
    /**
362
     * tree of a single node and its subentries
363
     *
364
     * @param int $id id
365
     * @return PostingInterface|null tree or null if nothing found
366
     */
367
    protected function postingsForNode(int $id) : ?PostingInterface
368
    {
369
        /** @var EntriesTable */
370
        $table = $this->getTable();
371
        $tid = $table->getThreadId($id);
372
        $postings = $this->postingsForThreads([$tid]);
373
        $postings = array_shift($postings);
374
375
        return $postings->getThread()->get($id);
376
    }
377
378
    /**
379
     * Finder to get all entries for threads
380
     *
381
     * @param Query $query Query
382
     * @param array $options Options
383
     * - 'tids' array required thread-IDs
384
     * - 'complete' fieldset
385
     * - 'threadOrder' order
386
     * @return Query
387
     */
388
    public function findEntriesForThreads(Query $query, array $options): Query
389
    {
390
        Stopwatch::start('PostingBehavior::findEntriesForThreads');
391
        $options += [
392
            'complete' => false,
393
            'threadOrder' => ['last_answer' => 'ASC'],
394
        ];
395
        if (empty($options['tids'])) {
396
            throw new \InvalidArgumentException('Not threads to find.');
397
        }
398
        $tids = $options['tids'];
399
        $order = $options['threadOrder'];
400
        unset($options['threadOrder'], $options['tids']);
401
402
        $query = $query->find('entry', $options)
403
            ->where(['tid IN' => $tids])
404
            ->order($order)
405
            // hydrating kills performance
406
            ->enableHydration(false);
407
        Stopwatch::stop('PostingBehavior::findEntriesForThreads');
408
409
        return $query;
410
    }
411
}
412