Completed
Branch feature/currentUserRefactoring (c13c1d)
by Schlaefer
04:13
created

PostingBehavior::getRecentPostings()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 49

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
nc 2
nop 2
dl 0
loc 49
rs 9.1127
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
100
        if (!$isRoot) {
101
            $parent = $this->getTable()->get($posting->get('pid'));
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
        $data['category_id'] = $data['category_id'] ?? $posting->get('category_id');
111
112
        $data['pid'] = $posting->get('pid');
113
        $data['time'] = $posting->get('time');
114
        $data['user_id'] = $posting->get('user_id');
115
116
        $this->validatorSetup($CurrentUser);
117
        $this->getTable()->getValidator()->add(
118
            'edited_by',
119
            'isEditingAllowed',
120
            ['rule' => [$this, 'validateEditingAllowed']]
121
        );
122
123
        /** @var EntriesTable */
124
        $table = $this->getTable();
125
126
        return $table->updateEntry($posting, $data);
127
    }
128
129
    /**
130
     * Populates data of an answer derived from parent the parent-posting
131
     *
132
     * @param BasicPostingInterface $parent parent data
133
     * @param array $data current posting data
134
     * @return array populated $data
135
     */
136
    public function prepareChildPosting(BasicPostingInterface $parent, array $data): array
137
    {
138
        if (empty($data['subject'])) {
139
            // if new subject is empty use the parent's subject
140
            $data['subject'] = $parent->get('subject');
141
        }
142
143
        $data['category_id'] = $data['category_id'] ?? $parent->get('category_id');
144
        $data['tid'] = $parent->get('tid');
145
146
        return $data;
147
    }
148
149
    /**
150
     * Sets-up validator for the table
151
     *
152
     * @param CurrentUserInterface $CurrentUser current user
153
     * @return void
154
     */
155
    private function validatorSetup(CurrentUserInterface $CurrentUser): void
156
    {
157
        $this->CurrentUser = $CurrentUser;
158
159
        $this->getTable()->getValidator()->add(
160
            'category_id',
161
            'isAllowed',
162
            ['rule' => [$this, 'validateCategoryIsAllowed']]
163
        );
164
    }
165
166
    /**
167
     * check that entries are only in existing and allowed categories
168
     *
169
     * @param mixed $categoryId value
170
     * @param array $context context
171
     * @return bool
172
     */
173
    public function validateCategoryIsAllowed($categoryId, $context): bool
174
    {
175
        $isRoot = $context['data']['pid'] == 0;
176
        $action = $isRoot ? 'thread' : 'answer';
177
178
        // @td better return !$posting->isAnsweringForbidden();
179
        return $this->CurrentUser->getCategories()->permission($action, $categoryId);
180
    }
181
182
    /**
183
     * check editing allowed
184
     *
185
     * @param mixed $check value
186
     * @param array $context context
187
     * @return bool
188
     */
189
    public function validateEditingAllowed($check, $context): bool
190
    {
191
        $posting = (new Posting($context['data']))->withCurrentUser($this->CurrentUser);
192
193
        return $posting->isEditingAllowed();
194
    }
195
196
    /**
197
     * Get an array of postings for threads
198
     *
199
     * @param array $tids Thread-IDs
200
     * @param array|null $order Thread sort order
201
     * @param CurrentUserInterface $CU Current User
202
     * @return array Array of postings found
203
     * @throws RecordNotFoundException If no thread is found
204
     */
205
    public function postingsForThreads(array $tids, ?array $order = null, CurrentUserInterface $CU = null): array
206
    {
207
        $entries = $this->getTable()
208
            ->find('entriesForThreads', ['threadOrder' => $order, 'tids' => $tids])
209
            ->all();
210
211
        if (!count($entries)) {
212
            throw new RecordNotFoundException(
213
                sprintf('No postings for thread-IDs "%s".', implode(', ', $tids))
214
            );
215
        }
216
217
        return $this->entriesToPostings($entries, $CU);
218
    }
219
220
    /**
221
     * Get a posting for a thread
222
     *
223
     * @param int $tid Thread-ID
224
     * @param bool $complete complete fieldset
225
     * @param CurrentUserInterface|null $CurrentUser CurrentUser
226
     * @return Posting
227
     * @throws RecordNotFoundException If thread isn't found
228
     */
229
    public function postingsForThread(int $tid, bool $complete = false, ?CurrentUserInterface $CurrentUser = null): Posting
230
    {
231
        $entries = $this->getTable()
232
            ->find('entriesForThreads', ['complete' => $complete, 'tids' => [$tid]])
233
            ->all();
234
235
        if (!count($entries)) {
236
            throw new RecordNotFoundException(
237
                sprintf('No postings for thread-ID "%s".', $tid)
238
            );
239
        }
240
241
        $postings = $this->entriesToPostings($entries, $CurrentUser);
242
243
        return reset($postings);
244
    }
245
246
    /**
247
     * Delete a node
248
     *
249
     * @param int $id the node id
250
     * @return bool
251
     */
252
    public function deletePosting(int $id): bool
253
    {
254
        $root = $this->postingsForNode($id);
255
        if (empty($root)) {
256
            throw new \InvalidArgumentException();
257
        }
258
259
        $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...
260
        $nodesToDelete = array_merge($nodesToDelete, $root->getAllChildren());
261
262
        $idsToDelete = [];
263
        foreach ($nodesToDelete as $node) {
264
            $idsToDelete[] = $node->get('id');
265
        };
266
267
        /** @var EntriesTable */
268
        $table = $this->getTable();
269
270
        return $table->deleteWithIds($idsToDelete);
271
    }
272
273
    /**
274
     * Get recent postings
275
     *
276
     * ### Options:
277
     *
278
     * - `user_id` int|<null> If provided finds only postings of that user.
279
     * - `limit` int <10> Number of postings to find.
280
     *
281
     * @param CurrentUserInterface $User User who has access to postings
282
     * @param array $options find options
283
     *
284
     * @return array Array of Postings
285
     */
286
    public function getRecentPostings(CurrentUserInterface $User, array $options = []): array
287
    {
288
        Stopwatch::start('PostingBehavior::getRecentPostings');
289
290
        $options += [
291
            'user_id' => null,
292
            'limit' => 10,
293
        ];
294
295
        $options['category_id'] = $User->getCategories()->getAll('read');
296
297
        $read = function () use ($options) {
298
            $conditions = [];
299
            if ($options['user_id'] !== null) {
300
                $conditions[]['Entries.user_id'] = $options['user_id'];
301
            }
302
            if ($options['category_id'] !== null) {
303
                $conditions[]['Entries.category_id IN'] = $options['category_id'];
304
            };
305
306
            $result = $this
307
                ->getTable()
308
                ->find(
309
                    'entry',
310
                    [
311
                        'conditions' => $conditions,
312
                        'limit' => $options['limit'],
313
                        'order' => ['time' => 'DESC']
314
                    ]
315
                )
316
                // hydrating kills performance
317
                ->enableHydration(false)
318
                ->all();
319
320
            return $result;
321
        };
322
323
        $key = 'Entry.recentEntries-' . md5(serialize($options));
324
        $results = Cache::remember($key, $read, 'entries');
325
326
        $threads = [];
327
        foreach ($results as $result) {
328
            $threads[$result['id']] = (new Posting($result))->withCurrentUser($User);
329
        }
330
331
        Stopwatch::stop('PostingBehavior::getRecentPostings');
332
333
        return $threads;
334
    }
335
336
    /**
337
     * Convert array with Entry entities to array with Postings
338
     *
339
     * @param Traversable $entries Entry array
340
     * @param CurrentUserInterface|null $CurrentUser The current user
341
     * @return array
342
     */
343
    protected function entriesToPostings(Traversable $entries, ?CurrentUserInterface $CurrentUser = null): array
344
    {
345
        Stopwatch::start('PostingBehavior::entriesToPostings');
346
        $threads = [];
347
        $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...
348
        foreach ($postings as $thread) {
349
            $posting = new Posting($thread);
350
            if ($CurrentUser) {
351
                $posting->withCurrentUser($CurrentUser);
352
            }
353
            $threads[$thread['tid']] = $posting;
354
        }
355
        Stopwatch::stop('PostingBehavior::entriesToPostings');
356
357
        return $threads;
358
    }
359
360
    /**
361
     * tree of a single node and its subentries
362
     *
363
     * @param int $id id
364
     * @return Posting|null tree or null if nothing found
365
     */
366
    protected function postingsForNode(int $id) : ?Posting
367
    {
368
        /** @var EntriesTable */
369
        $table = $this->getTable();
370
        $tid = $table->getThreadId($id);
371
        $postings = $this->postingsForThreads([$tid]);
372
        $postings = array_shift($postings);
373
374
        return $postings->getThread()->get($id);
375
    }
376
377
    /**
378
     * Finder to get all entries for threads
379
     *
380
     * @param Query $query Query
381
     * @param array $options Options
382
     * - 'tids' array required thread-IDs
383
     * - 'complete' fieldset
384
     * - 'threadOrder' order
385
     * @return Query
386
     */
387
    public function findEntriesForThreads(Query $query, array $options): Query
388
    {
389
        Stopwatch::start('PostingBehavior::findEntriesForThreads');
390
        $options += [
391
            'complete' => false,
392
            'threadOrder' => ['last_answer' => 'ASC'],
393
        ];
394
        if (empty($options['tids'])) {
395
            throw new \InvalidArgumentException('Not threads to find.');
396
        }
397
        $tids = $options['tids'];
398
        $order = $options['threadOrder'];
399
        unset($options['threadOrder'], $options['tids']);
400
401
        $query = $query->find('entry', $options)
402
            ->where(['tid IN' => $tids])
403
            ->order($order)
404
            // hydrating kills performance
405
            ->enableHydration(false);
406
        Stopwatch::stop('PostingBehavior::findEntriesForThreads');
407
408
        return $query;
409
    }
410
}
411