Completed
Branch feature/currentUserRefactoring (e6f778)
by Schlaefer
02:47
created

PostingBehavior::postingsForThread()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
nc 2
nop 3
dl 0
loc 16
rs 9.7333
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\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\TreeBuilder;
25
use Saito\User\CurrentUser\CurrentUserInterface;
26
use Stopwatch\Lib\Stopwatch;
27
use Traversable;
28
29
class PostingBehavior extends Behavior
30
{
31
    /** @var CurrentUserInterface */
32
    private $CurrentUser;
33
34
    /** @var FieldFilter */
35
    private $fieldFilter;
36
37
    /**
38
     * {@inheritDoc}
39
     */
40
    public function initialize(array $config)
41
    {
42
        $this->fieldFilter = (new fieldfilter())
43
            ->setConfig('create', ['category_id', 'pid', 'subject', 'text'])
44
            ->setConfig('update', ['category_id', 'subject', 'text']);
45
    }
46
47
    /**
48
     * Creates a new posting from user
49
     *
50
     * @param array $data raw posting data
51
     * @param CurrentUserInterface $CurrentUser the current user
52
     * @return Entry|null on success, null otherwise
53
     */
54
    public function createPosting(array $data, CurrentUserInterface $CurrentUser): ?Entry
55
    {
56
        $data = $this->fieldFilter->filterFields($data, 'create');
57
58
        if (!empty($data['pid'])) {
59
            /// new posting is answer to existing posting
60
            $parent = $this->getTable()->get($data['pid']);
61
62
            if (empty($parent)) {
63
                throw new \InvalidArgumentException(
64
                    'Parent posting for creating a new answer not found.',
65
                    1564756571
66
                );
67
            }
68
69
            $data = $this->prepareChildPosting($parent, $data);
70
        } else {
71
            /// if no pid is provided the new posting is root-posting
72
            $data['pid'] = 0;
73
        }
74
75
        /// set user who created the posting
76
        $data['user_id'] = $CurrentUser->getId();
77
        $data['name'] = $CurrentUser->get('username');
78
79
        $this->validatorSetup($CurrentUser);
80
81
        /** @var EntriesTable */
82
        $table = $this->getTable();
83
84
        return $table->createEntry($data);
85
    }
86
87
    /**
88
     * Updates an existing posting
89
     *
90
     * @param Entry $posting the posting to update
91
     * @param array $data data the posting should be updated with
92
     * @param CurrentUserInterface $CurrentUser the current-user
93
     * @return Entry|null the posting which was asked to update
94
     */
95
    public function updatePosting(Entry $posting, array $data, CurrentUserInterface $CurrentUser): ?Entry
96
    {
97
        $data = $this->fieldFilter->filterFields($data, 'update');
98
        $isRoot = $posting->isRoot();
99
        $parent = $this->getTable()->get($posting->get('pid'));
100
101
        if (!$isRoot) {
102
            $data = $this->prepareChildPosting($parent, $data);
103
        }
104
105
        $data['edited_by'] = $CurrentUser->get('username');
106
107
        /// must be set for validation
108
        $data['locked'] = $posting->get('locked');
109
        $data['fixed'] = $posting->get('fixed');
110
111
        $data['pid'] = $posting->get('pid');
112
        $data['time'] = $posting->get('time');
113
        $data['user_id'] = $posting->get('user_id');
114
115
        $this->validatorSetup($CurrentUser);
116
        $this->getTable()->getValidator()->add(
117
            'edited_by',
118
            'isEditingAllowed',
119
            ['rule' => [$this, 'validateEditingAllowed']]
120
        );
121
122
        /** @var EntriesTable */
123
        $table = $this->getTable();
124
125
        return $table->updateEntry($posting, $data);
126
    }
127
128
    /**
129
     * Populates data of an answer derived from parent the parent-posting
130
     *
131
     * @param BasicPostingInterface $parent parent data
132
     * @param array $data current posting data
133
     * @return array populated $data
134
     */
135
    public function prepareChildPosting(BasicPostingInterface $parent, array $data): array
136
    {
137
        if (empty($data['subject'])) {
138
            // if new subject is empty use the parent's subject
139
            $data['subject'] = $parent->get('subject');
140
        }
141
142
        $data['category_id'] = $parent->get('category_id');
143
        $data['tid'] = $parent->get('tid');
144
145
        return $data;
146
    }
147
148
    /**
149
     * Sets-up validator for the table
150
     *
151
     * @param CurrentUserInterface $CurrentUser current user
152
     * @return void
153
     */
154
    private function validatorSetup(CurrentUserInterface $CurrentUser): void
155
    {
156
        $this->CurrentUser = $CurrentUser;
157
158
        $this->getTable()->getValidator()->add(
159
            'category_id',
160
            'isAllowed',
161
            ['rule' => [$this, 'validateCategoryIsAllowed']]
162
        );
163
    }
164
165
    /**
166
     * check that entries are only in existing and allowed categories
167
     *
168
     * @param mixed $categoryId value
169
     * @param array $context context
170
     * @return bool
171
     */
172
    public function validateCategoryIsAllowed($categoryId, $context): bool
173
    {
174
        $isRoot = $context['data']['pid'] == 0;
175
        $action = $isRoot ? 'thread' : 'answer';
176
177
        // @td better return !$posting->isAnsweringForbidden();
178
        return $this->CurrentUser->getCategories()->permission($action, $categoryId);
179
    }
180
181
    /**
182
     * check editing allowed
183
     *
184
     * @param mixed $check value
185
     * @param array $context context
186
     * @return bool
187
     */
188
    public function validateEditingAllowed($check, $context): bool
189
    {
190
        $posting = (new Posting($context['data']))->withCurrentUser($this->CurrentUser);
191
192
        return $posting->isEditingAllowed();
193
    }
194
195
    /**
196
     * Get an array of postings for threads
197
     *
198
     * @param array $tids Thread-IDs
199
     * @param array|null $order Thread sort order
200
     * @param CurrentUserInterface $CU Current User
201
     * @return array Array of postings found
202
     * @throws RecordNotFoundException If no thread is found
203
     */
204
    public function postingsForThreads(array $tids, ?array $order = null, CurrentUserInterface $CU = null): array
205
    {
206
        $entries = $this->getTable()
207
            ->find('entriesForThreads', ['threadOrder' => $order, 'tids' => $tids])
208
            ->all();
209
210
        if (!count($entries)) {
211
            throw new RecordNotFoundException(
212
                sprintf('No postings for thread-IDs "%s".', implode(', ', $tids))
213
            );
214
        }
215
216
        return $this->entriesToPostings($entries, $CU);
217
    }
218
219
    /**
220
     * Get a posting for a thread
221
     *
222
     * @param int $tid Thread-ID
223
     * @param bool $complete complete fieldset
224
     * @param CurrentUserInterface|null $CurrentUser CurrentUser
225
     * @return Posting
226
     * @throws RecordNotFoundException If thread isn't found
227
     */
228
    public function postingsForThread(int $tid, bool $complete = false, ?CurrentUserInterface $CurrentUser = null): Posting
229
    {
230
        $entries = $this->getTable()
231
            ->find('entriesForThreads', ['complete' => $complete, 'tids' => [$tid]])
232
            ->all();
233
234
        if (!count($entries)) {
235
            throw new RecordNotFoundException(
236
                sprintf('No postings for thread-ID "%s".', $tid)
237
            );
238
        }
239
240
        $postings = $this->entriesToPostings($entries, $CurrentUser);
241
242
        return reset($postings);
243
    }
244
245
    /**
246
     * Delete a node
247
     *
248
     * @param int $id the node id
249
     * @return bool
250
     */
251
    public function deletePosting(int $id): bool
252
    {
253
        $root = $this->postingsForNode($id);
254
        if (empty($root)) {
255
            throw new \InvalidArgumentException();
256
        }
257
258
        $nodesToDelete[] = $root;
0 ignored issues
show
Coding Style Comprehensibility introduced by
$nodesToDelete was never initialized. Although not strictly required by PHP, it is generally a good practice to add $nodesToDelete = array(); before regardless.

Adding an explicit array definition is generally preferable to implicit array definition as it guarantees a stable state of the code.

Let’s take a look at an example:

foreach ($collection as $item) {
    $myArray['foo'] = $item->getFoo();

    if ($item->hasBar()) {
        $myArray['bar'] = $item->getBar();
    }

    // do something with $myArray
}

As you can see in this example, the array $myArray is initialized the first time when the foreach loop is entered. You can also see that the value of the bar key is only written conditionally; thus, its value might result from a previous iteration.

This might or might not be intended. To make your intention clear, your code more readible and to avoid accidental bugs, we recommend to add an explicit initialization $myArray = array() either outside or inside the foreach loop.

Loading history...
259
        $nodesToDelete = array_merge($nodesToDelete, $root->getAllChildren());
260
261
        $idsToDelete = [];
262
        foreach ($nodesToDelete as $node) {
263
            $idsToDelete[] = $node->get('id');
264
        };
265
266
        /** @var EntriesTable */
267
        $table = $this->getTable();
268
269
        return $table->deleteWithIds($idsToDelete);
270
    }
271
272
    /**
273
     * Get recent postings
274
     *
275
     * ### Options:
276
     *
277
     * - `user_id` int|<null> If provided finds only postings of that user.
278
     * - `limit` int <10> Number of postings to find.
279
     *
280
     * @param CurrentUserInterface $User User who has access to postings
281
     * @param array $options find options
282
     *
283
     * @return array Array of Postings
284
     */
285
    public function getRecentPostings(CurrentUserInterface $User, array $options = []): array
286
    {
287
        Stopwatch::start('PostingBehavior::getRecentPostings');
288
289
        $options += [
290
            'user_id' => null,
291
            'limit' => 10,
292
        ];
293
294
        $options['category_id'] = $User->getCategories()->getAll('read');
295
296
        $read = function () use ($options) {
297
            $conditions = [];
298
            if ($options['user_id'] !== null) {
299
                $conditions[]['Entries.user_id'] = $options['user_id'];
300
            }
301
            if ($options['category_id'] !== null) {
302
                $conditions[]['Entries.category_id IN'] = $options['category_id'];
303
            };
304
305
            $result = $this
306
                ->getTable()
307
                ->find(
308
                    'entry',
309
                    [
310
                        'conditions' => $conditions,
311
                        'limit' => $options['limit'],
312
                        'order' => ['time' => 'DESC']
313
                    ]
314
                )
315
                // hydrating kills performance
316
                ->enableHydration(false)
317
                ->all();
318
319
            return $result;
320
        };
321
322
        $key = 'Entry.recentEntries-' . md5(serialize($options));
323
        $results = Cache::remember($key, $read, 'entries');
324
325
        $threads = [];
326
        foreach ($results as $result) {
327
            $threads[$result['id']] = (new Posting($result))->withCurrentUser($User);
328
        }
329
330
        Stopwatch::stop('PostingBehavior::getRecentPostings');
331
332
        return $threads;
333
    }
334
335
    /**
336
     * Convert array with Entry entities to array with Postings
337
     *
338
     * @param Traversable $entries Entry array
339
     * @param CurrentUserInterface|null $CurrentUser The current user
340
     * @return array
341
     */
342
    protected function entriesToPostings(Traversable $entries, ?CurrentUserInterface $CurrentUser = null): array
343
    {
344
        Stopwatch::start('PostingBehavior::entriesToPostings');
345
        $threads = [];
346
        $postings = (new TreeBuilder())->build($entries);
0 ignored issues
show
Documentation introduced by
$entries is of type object<Traversable>, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

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