Completed
Push — master ( fd5325...d7e193 )
by Schlaefer
05:54 queued 03:00
created

EntriesTable::validationDefault()   B

Complexity

Conditions 1
Paths 1

Size

Total Lines 67

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
nc 1
nop 1
dl 0
loc 67
rs 8.72
c 0
b 0
f 0

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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\Table;
14
15
use App\Lib\Model\Table\AppTable;
16
use App\Model\Entity\Entry;
17
use App\Model\Table\CategoriesTable;
18
use App\Model\Table\DraftsTable;
19
use Bookmarks\Model\Table\BookmarksTable;
20
use Cake\Cache\Cache;
21
use Cake\Event\Event;
22
use Cake\Http\Exception\NotFoundException;
23
use Cake\ORM\Entity;
24
use Cake\ORM\Query;
25
use Cake\ORM\RulesChecker;
26
use Cake\Validation\Validator;
27
use Saito\App\Registry;
28
use Saito\Posting\Posting;
29
use Saito\RememberTrait;
30
use Saito\User\CurrentUser\CurrentUserInterface;
31
use Saito\Validation\SaitoValidationProvider;
32
use Search\Manager;
33
use Stopwatch\Lib\Stopwatch;
34
35
/**
36
 * Stores postings
37
 *
38
 * Field notes:
39
 * - `edited_by` - Came from mylittleforum. @td Should by migrated to User.id.
40
 * - `name` - Came from mylittleforum. Is still used in fulltext index.
41
 *
42
 * @property BookmarksTable $Bookmarks
43
 * @property CategoriesTable $Categories
44
 * @property DraftsTable $Drafts
45
 * @method array treeBuild(array $postings)
46
 * @method createPosting(array $data, CurrentUserInterface $CurrentUser)
47
 * @method updatePosting(Entry $posting, array $data, CurrentUserInterface $CurrentUser)
48
 * @method array prepareChildPosting(BasicPostingInterface $parent, array $data)
49
 */
50
class EntriesTable extends AppTable
51
{
52
    use RememberTrait;
53
54
    /**
55
     * Max subject length.
56
     *
57
     * Constrained to 191 due to InnoDB index max-length on MySQL 5.6.
58
     */
59
    public const SUBJECT_MAXLENGTH = 191;
60
61
    /**
62
     * Fields for search plugin
63
     *
64
     * @var array
65
     */
66
    public $filterArgs = [
67
        'subject' => ['type' => 'like'],
68
        'text' => ['type' => 'like'],
69
        'name' => ['type' => 'like'],
70
        'category' => ['type' => 'value'],
71
    ];
72
73
    /**
74
     * field list necessary for displaying a thread_line
75
     *
76
     * Entry.text determine if Entry is n/t
77
     *
78
     * @var array
79
     */
80
    public $threadLineFieldList = [
81
        'Entries.id',
82
        'Entries.pid',
83
        'Entries.tid',
84
        'Entries.subject',
85
        'Entries.text',
86
        'Entries.time',
87
        'Entries.fixed',
88
        'Entries.last_answer',
89
        'Entries.views',
90
        'Entries.user_id',
91
        'Entries.locked',
92
        'Entries.name',
93
        'Entries.solves',
94
        'Users.username',
95
        'Categories.id',
96
        'Categories.accession',
97
        'Categories.category',
98
        'Categories.description'
99
    ];
100
101
    /**
102
     * fields additional to $threadLineFieldList to show complete entry
103
     *
104
     * @var array
105
     */
106
    public $showEntryFieldListAdditional = [
107
        'Entries.edited',
108
        'Entries.edited_by',
109
        'Entries.ip',
110
        'Entries.category_id',
111
        'Users.id',
112
        'Users.avatar',
113
        'Users.signature',
114
        'Users.user_place'
115
    ];
116
117
    protected $_defaultConfig = [
118
        'subject_maxlength' => 100
119
    ];
120
121
    /**
122
     * {@inheritDoc}
123
     */
124
    public function initialize(array $config)
125
    {
126
        $this->setPrimaryKey('id');
127
128
        $this->addBehavior('Posting');
129
        $this->addBehavior('IpLogging');
130
        $this->addBehavior('Timestamp');
131
        $this->addBehavior('Tree');
132
133
        $this->addBehavior(
134
            'CounterCache',
135
            [
136
                // cache how many postings a user has
137
                'Users' => ['entry_count'],
138
                // cache how many threads a category has
139
                'Categories' => [
140
                    'thread_count' => function ($event, Entry $entity, $table, $original) {
141
                        if (!$entity->isRoot()) {
142
                            return false;
143
                        }
144
                        // posting is moved to new category…
145
                        if ($original) {
146
                            // update old category (should decrement counter)
147
                            $categoryId = $entity->getOriginal('category_id');
148
                        } else {
149
                            // update new category (increment counter)
150
                            $categoryId = $entity->get('category_id');
151
                        }
152
153
                        $query = $table->find('all', ['conditions' => [
154
                            'pid' => 0, 'category_id' => $categoryId
155
                        ]]);
156
                        $count = $query->count();
157
158
                        return $count;
159
                    }
160
                ]
161
            ]
162
        );
163
164
        $this->belongsTo('Categories', ['foreignKey' => 'category_id']);
165
        $this->belongsTo('Users', ['foreignKey' => 'user_id']);
166
167
        $this->hasMany(
168
            'Bookmarks',
169
            ['foreignKey' => 'entry_id', 'dependent' => true]
170
        );
171
172
        // Releation never queried. Just for quick access to the table.
173
        $this->hasOne('Drafts');
174
    }
175
176
    /**
177
     * {@inheritDoc}
178
     */
179
    public function validationDefault(Validator $validator)
180
    {
181
        $validator->setProvider('saito', SaitoValidationProvider::class);
182
183
        /// category_id
184
        $categoryRequiredL10N = __('vld.entries.categories.notEmpty');
185
        $validator
0 ignored issues
show
Deprecated Code introduced by
The method Cake\Validation\Validator::notEmpty() has been deprecated with message: 3.7.0 Use notEmptyString(), notEmptyArray(), notEmptyFile(), notEmptyDate(), notEmptyTime() or notEmptyDateTime() instead.

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
186
            ->notEmpty('category_id', $categoryRequiredL10N)
187
            ->requirePresence('category_id', 'create', $categoryRequiredL10N)
188
            ->add(
189
                'category_id',
190
                [
191
                    'numeric' => ['rule' => 'numeric'],
192
                    'assoc' => [
193
                        'rule' => ['validateAssoc', 'Categories'],
194
                        'last' => true,
195
                        'provider' => 'saito'
196
                    ]
197
                ]
198
            );
199
200
        /// last_answer
201
        $validator
202
            ->requirePresence('last_answer', 'create')
203
            ->notEmptyDateTime('last_answer', null, 'create');
204
205
        /// name
206
        $validator
207
            ->requirePresence('name', 'create')
208
            ->notEmptyString('name', null, 'create');
209
210
        /// pid
211
        $validator->requirePresence('pid', 'create');
212
213
        /// subject
214
        $subjectRequiredL10N = __('vld.entries.subject.notEmpty');
215
        $validator
216
            ->notEmptyString('subject', $subjectRequiredL10N)
217
            ->requirePresence('subject', 'create', $subjectRequiredL10N)
218
            ->add(
219
                'subject',
220
                [
221
                    'maxLength' => [
222
                        'rule' => ['maxLength', $this->getConfig('subject_maxlength')],
223
                        'message' => __('vld.entries.subject.maxlength')
224
                    ]
225
                ]
226
            );
227
228
        /// time
229
        $validator
230
            ->requirePresence('time', 'create')
231
            ->notEmptyDateTime('time', null, 'create');
232
233
        /// user_id
234
        $validator
235
            ->requirePresence('user_id', 'create')
236
            ->add('user_id', ['numeric' => ['rule' => 'numeric']]);
237
238
        /// views
239
        $validator->add(
240
            'views',
241
            ['comparison' => ['rule' => ['comparison', '>=', 0]]]
242
        );
243
244
        return $validator;
245
    }
246
247
    /**
248
     * {@inheritDoc}
249
     */
250
    public function buildRules(RulesChecker $rules)
251
    {
252
        $rules = parent::buildRules($rules);
253
254
        $rules->addUpdate(
255
            function ($entity) {
256
                if ($entity->isDirty('category_id')) {
257
                    return $entity->isRoot();
258
                }
259
260
                return true;
261
            },
262
            'checkCategoryChangeOnlyOnRootPostings',
263
            [
264
                'errorField' => 'category_id',
265
                'message' => 'Cannot change category on non-root-postings.',
266
            ]
267
        );
268
269
        return $rules;
270
    }
271
272
    /**
273
     * Advanced search configuration from SaitoSearch plugin
274
     *
275
     * @see https://github.com/FriendsOfCake/search
276
     *
277
     * @return Manager
278
     */
279
    public function searchManager(): Manager
280
    {
281
        /** @var Manager $searchManager */
282
        $searchManager = $this->getBehavior('Search')->searchManager();
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class Cake\ORM\Behavior as the method searchManager() does only exist in the following sub-classes of Cake\ORM\Behavior: Search\Model\Behavior\SearchBehavior. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
283
        $searchManager
284
        ->like('subject', [
285
            'before' => true,
286
            'after' => true,
287
            'fieldMode' => 'OR',
288
            'comparison' => 'LIKE',
289
            'wildcardAny' => '*',
290
            'wildcardOne' => '?',
291
            'field' => ['subject'],
292
            'filterEmpty' => true,
293
        ])
294
        ->like('text', [
295
            'before' => true,
296
            'after' => true,
297
            'fieldMode' => 'OR',
298
            'comparison' => 'LIKE',
299
            'wildcardAny' => '*',
300
            'wildcardOne' => '?',
301
            'field' => ['text'],
302
            'filterEmpty' => true,
303
        ])
304
        ->value('name', ['filterEmpty' => true]);
305
306
        return $searchManager;
307
    }
308
309
    /**
310
     * Get recent postings
311
     *
312
     * ### Options:
313
     *
314
     * - `user_id` int|<null> If provided finds only postings of that user.
315
     * - `limit` int <10> Number of postings to find.
316
     *
317
     * @param CurrentUserInterface $User User who has access to postings
318
     * @param array $options find options
319
     *
320
     * @return array Array of Postings
321
     */
322
    public function getRecentEntries(
323
        CurrentUserInterface $User,
324
        array $options = []
325
    ) {
326
        Stopwatch::start('Model->User->getRecentEntries()');
327
328
        $options += [
329
            'user_id' => null,
330
            'limit' => 10,
331
        ];
332
333
        $options['category_id'] = $User->getCategories()->getAll('read');
334
335
        $read = function () use ($options) {
336
            $conditions = [];
337
            if ($options['user_id'] !== null) {
338
                $conditions[]['Entries.user_id'] = $options['user_id'];
339
            }
340
            if ($options['category_id'] !== null) {
341
                $conditions[]['Entries.category_id IN'] = $options['category_id'];
342
            };
343
344
            $result = $this
345
                ->find(
346
                    'all',
347
                    [
348
                        'contain' => ['Users', 'Categories'],
349
                        'fields' => $this->threadLineFieldList,
350
                        'conditions' => $conditions,
351
                        'limit' => $options['limit'],
352
                        'order' => ['time' => 'DESC']
353
                    ]
354
                )
355
                // hydrating kills performance
356
                ->enableHydration(false)
357
                ->all();
358
359
            return $result;
360
        };
361
362
        $key = 'Entry.recentEntries-' . md5(serialize($options));
363
        $results = Cache::remember($key, $read, 'entries');
364
365
        $threads = [];
366
        foreach ($results as $result) {
367
            $threads[$result['id']] = Registry::newInstance(
368
                '\Saito\Posting\Posting',
369
                ['rawData' => $result]
370
            );
371
        }
372
373
        Stopwatch::stop('Model->User->getRecentEntries()');
374
375
        return $threads;
376
    }
377
378
    /**
379
     * Finds the thread-id for a posting
380
     *
381
     * @param int $id Posting-Id
382
     * @return int Thread-Id
383
     * @throws \UnexpectedValueException
384
     */
385
    public function getThreadId($id)
386
    {
387
        $entry = $this->find(
388
            'all',
389
            ['conditions' => ['id' => $id], 'fields' => 'tid']
390
        )->first();
391
        if (empty($entry)) {
392
            throw new \UnexpectedValueException(
393
                'Posting not found. Posting-Id: ' . $id
394
            );
395
        }
396
397
        return $entry->get('tid');
398
    }
399
400
    /**
401
     * Shorthand for reading an entry with full data
402
     *
403
     * @param int $primaryKey key
404
     * @param array $options options
405
     * @return mixed Posting if found false otherwise
406
     */
407
    public function get($primaryKey, $options = [])
408
    {
409
        $options += ['return' => 'Posting'];
410
        $return = $options['return'];
411
        unset($options['return']);
412
413
        /** @var Entry */
414
        $result = $this->find('entry')
0 ignored issues
show
Bug Compatibility introduced by
The expression $this->find('entry')->wh...$primaryKey))->first(); of type Cake\Datasource\EntityInterface|array|null adds the type array to the return on line 427 which is incompatible with the return type declared by the interface Cake\Datasource\RepositoryInterface::get of type Cake\Datasource\EntityInterface.
Loading history...
415
            ->where([$this->getAlias() . '.id' => $primaryKey])
416
            ->first();
417
418
        if (!$result) {
419
            return false;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return false; (false) is incompatible with the return type declared by the interface Cake\Datasource\RepositoryInterface::get of type Cake\Datasource\EntityInterface.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
420
        }
421
422
        switch ($return) {
423
            case 'Posting':
424
                return $result->toPosting();
425
            case 'Entity':
426
            default:
427
                return $result;
428
        }
429
    }
430
431
    /**
432
     * get parent id
433
     *
434
     * @param int $id id
435
     * @return mixed
436
     * @throws \UnexpectedValueException
437
     */
438
    public function getParentId($id)
439
    {
440
        $entry = $this->find()->select('pid')->where(['id' => $id])->first();
441
        if (!$entry) {
442
            throw new \UnexpectedValueException(
443
                'Posting not found. Posting-Id: ' . $id
444
            );
445
        }
446
447
        return $entry->get('pid');
448
    }
449
450
    /**
451
     * creates a new root or child entry for a node
452
     *
453
     * fields in $data are filtered
454
     *
455
     * @param array $data data
456
     * @return Entry|null on success, null otherwise
457
     */
458
    public function createEntry(array $data): ?Entry
459
    {
460
        $data['time'] = bDate();
461
        $data['last_answer'] = bDate();
462
463
        /** @var Entry */
464
        $posting = $this->newEntity($data);
465
        $errors = $posting->getErrors();
466
        if (!empty($errors)) {
467
            return $posting;
468
        }
469
470
        $posting = $this->save($posting);
471
        if (!$posting) {
472
            return null;
473
        }
474
475
        $id = $posting->get('id');
476
        /** @var Entry */
477
        $posting = $this->get($id, ['return' => 'Entity']);
478
479
        if ($posting->isRoot()) {
480
            // posting started a new thread, so set thread-ID to posting's own ID
481
            /** @var Entry */
482
            $posting = $this->patchEntity($posting, ['tid' => $id]);
483
            if (!$this->save($posting)) {
484
                return $posting;
485
            }
486
487
            $this->_dispatchEvent('Model.Thread.create', ['subject' => $id, 'data' => $posting]);
488
        } else {
489
            // update last answer time of root entry
490
            $this->updateAll(
491
                ['last_answer' => $posting->get('last_answer')],
492
                ['id' => $posting->get('tid')]
493
            );
494
495
            $eventData = ['subject' => $posting->get('pid'), 'data' => $posting];
496
            $this->_dispatchEvent('Model.Entry.replyToEntry', $eventData);
497
            $this->_dispatchEvent('Model.Entry.replyToThread', $eventData);
498
        }
499
500
        return $posting;
501
    }
502
503
    /**
504
     * Updates a posting
505
     *
506
     * fields in $data are filtered except for $id!
507
     *
508
     * @param Entry $posting Entity
509
     * @param array $data data
510
     * @return Entry|null
511
     */
512
    public function updateEntry(Entry $posting, array $data): ?Entry
513
    {
514
        $data['id'] = $posting->get('id');
515
        $data['edited'] = bDate();
516
517
        /** @var Entry */
518
        $patched = $this->patchEntity($posting, $data);
519
        $errors = $patched->getErrors();
520
        if (!empty($errors)) {
521
            return $patched;
522
        }
523
524
        /** @var Entry */
525
        $new = $this->save($posting);
526
        if (!$new) {
527
            return null;
528
        }
529
530
        $this->_dispatchEvent(
531
            'Model.Entry.update',
532
            ['subject' => $posting->get('id'), 'data' => $posting]
533
        );
534
535
        return $new;
536
    }
537
538
    /**
539
     * tree of a single node and its subentries
540
     *
541
     * $options = array(
542
     *    'root' => true // performance improvements if it's a known thread-root
543
     * );
544
     *
545
     * @param int $id id
546
     * @param array $options options
547
     * @return Posting|null tree or null if nothing found
548
     */
549
    public function treeForNode(int $id, ?array $options = []): ?Posting
550
    {
551
        $options += [
552
            'root' => false,
553
            'complete' => false
554
        ];
555
556
        if ($options['root']) {
557
            $tid = $id;
558
        } else {
559
            $tid = $this->getThreadId($id);
560
        }
561
562
        $fields = null;
563
        if ($options['complete']) {
564
            $fields = array_merge(
565
                $this->threadLineFieldList,
566
                $this->showEntryFieldListAdditional
567
            );
568
        }
569
570
        $tree = $this->treesForThreads([$tid], null, $fields);
571
572
        if (!$tree) {
573
            return null;
574
        }
575
576
        $tree = reset($tree);
577
578
        //= extract subtree
579
        if ((int)$tid !== (int)$id) {
580
            $tree = $tree->getThread()->get($id);
581
        }
582
583
        return $tree;
584
    }
585
586
    /**
587
     * trees for multiple tids
588
     *
589
     * @param array $ids ids
590
     * @param array $order order
591
     * @param array $fieldlist fieldlist
592
     * @return array|null array of Postings, null if nothing found
593
     */
594
    public function treesForThreads(array $ids, ?array $order = null, array $fieldlist = null): ?array
595
    {
596
        if (empty($ids)) {
597
            return [];
598
        }
599
600
        if (empty($order)) {
601
            $order = ['last_answer' => 'ASC'];
602
        }
603
604
        if ($fieldlist === null) {
605
            $fieldlist = $this->threadLineFieldList;
606
        }
607
608
        Stopwatch::start('EntriesTable::treesForThreads() DB');
609
        $postings = $this->_getThreadEntries(
610
            $ids,
611
            ['order' => $order, 'fields' => $fieldlist]
612
        );
613
        Stopwatch::stop('EntriesTable::treesForThreads() DB');
614
615
        if (!$postings->count()) {
616
            return null;
617
        }
618
619
        Stopwatch::start('EntriesTable::treesForThreads() CPU');
620
        $threads = [];
621
        $postings = $this->treeBuild($postings);
0 ignored issues
show
Documentation introduced by
$postings is of type object<Cake\ORM\Query>, 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...
622
        foreach ($postings as $thread) {
623
            $id = $thread['tid'];
624
            $threads[$id] = $thread;
625
            $threads[$id] = Registry::newInstance(
626
                '\Saito\Posting\Posting',
627
                ['rawData' => $thread]
628
            );
629
        }
630
        Stopwatch::stop('EntriesTable::treesForThreads() CPU');
631
632
        return $threads;
633
    }
634
635
    /**
636
     * Returns all entries of threads $tid
637
     *
638
     * @param array $tid ids
639
     * @param array $params params
640
     * - 'fields' array of thread-ids: [1, 2, 5]
641
     * - 'order' sort order for threads ['time' => 'ASC'],
642
     * @return mixed unhydrated result set
643
     */
644
    protected function _getThreadEntries(array $tid, array $params = [])
645
    {
646
        $params += [
647
            'fields' => $this->threadLineFieldList,
648
            'order' => ['last_answer' => 'ASC']
649
        ];
650
651
        $threads = $this
652
            ->find(
653
                'all',
654
                [
655
                    'conditions' => ['tid IN' => $tid],
656
                    'contain' => ['Users', 'Categories'],
657
                    'fields' => $params['fields'],
658
                    'order' => $params['order']
659
                ]
660
            )
661
            // hydrating kills performance
662
            ->enableHydration(false);
663
664
        return $threads;
665
    }
666
667
    /**
668
     * Marks a sub-entry as solution to a root entry
669
     *
670
     * @param Entry $posting posting to toggle
671
     * @return bool success
672
     */
673
    public function toggleSolve(Entry $posting)
674
    {
675
        if ($posting->get('solves')) {
676
            $value = 0;
677
        } else {
678
            $value = $posting->get('tid');
679
        }
680
681
        $this->patchEntity($posting, ['solves' => $value]);
682
        if (!$this->save($posting)) {
683
            return false;
684
        }
685
686
        $this->_dispatchEvent(
687
            'Model.Entry.update',
688
            ['subject' => $posting->get('id'), 'data' => $posting]
689
        );
690
691
        return true;
692
    }
693
694
    /**
695
     * {@inheritDoc}
696
     */
697
    public function toggle($id, $key)
698
    {
699
        $result = parent::toggle($id, $key);
700
        if ($key === 'locked') {
701
            $this->_threadLock($id, $result);
0 ignored issues
show
Documentation introduced by
$result is of type integer, but the function expects a boolean.

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...
702
        }
703
704
        $entry = $this->get($id);
705
        $this->_dispatchEvent(
706
            'Model.Entry.update',
707
            [
708
                'subject' => $entry->get('id'),
709
                'data' => $entry
710
            ]
711
        );
712
713
        return $result;
714
    }
715
716
    /**
717
     * {@inheritDoc}
718
     */
719
    public function beforeMarshal(Event $event, \ArrayObject $data, \ArrayObject $options)
720
    {
721
        /// Trim whitespace on subject and text
722
        $toTrim = ['subject', 'text'];
723
        foreach ($toTrim as $field) {
724
            if (!empty($data[$field])) {
725
                $data[$field] = trim($data[$field]);
726
            }
727
        }
728
    }
729
730
    /**
731
     * Deletes posting incl. all its subposting and associated data
732
     *
733
     * @param int $id id
734
     * @throws \InvalidArgumentException
735
     * @throws \Exception
736
     * @return bool
737
     */
738
    public function treeDeleteNode($id)
739
    {
740
        $root = $this->treeForNode((int)$id);
741
742
        if (empty($root)) {
743
            throw new \Exception;
744
        }
745
746
        $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...
747
        $nodesToDelete = array_merge($nodesToDelete, $root->getAllChildren());
748
749
        $idsToDelete = [];
750
        foreach ($nodesToDelete as $node) {
751
            $idsToDelete[] = $node->get('id');
752
        };
753
754
        $success = $this->deleteAll(['id IN' => $idsToDelete]);
755
756
        if (!$success) {
757
            return false;
758
        }
759
760
        $this->Bookmarks->deleteAll(['entry_id IN' => $idsToDelete]);
761
762
        $this->dispatchSaitoEvent(
763
            'Model.Saito.Posting.delete',
764
            ['subject' => $root, 'table' => $this]
765
        );
766
767
        return true;
768
    }
769
770
    /**
771
     * Anonymizes the entries for a user
772
     *
773
     * @param int $userId user-ID
774
     * @return void
775
     */
776
    public function anonymizeEntriesFromUser(int $userId): void
777
    {
778
        // remove username from all entries and reassign to anonyme user
779
        $success = (bool)$this->updateAll(
780
            [
781
                'edited_by' => null,
782
                'ip' => null,
783
                'name' => null,
784
                'user_id' => 0,
785
            ],
786
            ['user_id' => $userId]
787
        );
788
789
        if ($success) {
790
            $this->_dispatchEvent('Cmd.Cache.clear', ['cache' => 'Thread']);
791
        }
792
    }
793
794
    /**
795
     * Merge thread on to entry $targetId
796
     *
797
     * @param int $sourceId root-id of the posting that is merged onto another
798
     *     thread
799
     * @param int $targetId id of the posting the source-thread should be
800
     *     appended to
801
     * @return bool true if merge was successfull false otherwise
802
     */
803
    public function threadMerge($sourceId, $targetId)
804
    {
805
        $sourcePosting = $this->get($sourceId, ['return' => 'Entity']);
806
807
        // check that source is thread-root and not an subposting
808
        if (!$sourcePosting->isRoot()) {
809
            return false;
810
        }
811
812
        $targetPosting = $this->get($targetId);
813
814
        // check that target exists
815
        if (!$targetPosting) {
816
            return false;
817
        }
818
819
        // check that a thread is not merged onto itself
820
        if ($targetPosting->get('tid') === $sourcePosting->get('tid')) {
821
            return false;
822
        }
823
824
        // set target entry as new parent entry
825
        $this->patchEntity(
826
            $sourcePosting,
827
            ['pid' => $targetPosting->get('id')]
828
        );
829
        if ($this->save($sourcePosting)) {
830
            // associate all entries in source thread to target thread
831
            $this->updateAll(
832
                ['tid' => $targetPosting->get('tid')],
833
                ['tid' => $sourcePosting->get('tid')]
834
            );
835
836
            // appended source entries get category of target thread
837
            $this->_threadChangeCategory(
838
                $targetPosting->get('tid'),
839
                $targetPosting->get('category_id')
840
            );
841
842
            // update target thread last answer if source is newer
843
            $sourceLastAnswer = $sourcePosting->get('last_answer');
844
            $targetLastAnswer = $targetPosting->get('last_answer');
845
            if ($sourceLastAnswer->gt($targetLastAnswer)) {
846
                $targetRoot = $this->get(
847
                    $targetPosting->get('tid'),
848
                    ['return' => 'Entity']
849
                );
850
                $targetRoot = $this->patchEntity(
851
                    $targetRoot,
852
                    ['last_answer' => $sourceLastAnswer]
853
                );
854
                $this->save($targetRoot);
855
            }
856
857
            // propagate pinned property from target to source
858
            $isTargetPinned = $targetPosting->isLocked();
859
            $isSourcePinned = $sourcePosting->isLocked();
860
            if ($isSourcePinned !== $isTargetPinned) {
861
                $this->_threadLock($targetPosting->get('tid'), $isTargetPinned);
862
            }
863
864
            $this->_dispatchEvent(
865
                'Model.Thread.change',
866
                ['subject' => $targetPosting->get('tid')]
867
            );
868
869
            return true;
870
        }
871
872
        return false;
873
    }
874
875
    /**
876
     * Implements the custom find type 'entry'
877
     *
878
     * @param Query $query query
879
     * @return Query
880
     */
881
    public function findEntry(Query $query)
882
    {
883
        $fields = array_merge(
884
            $this->threadLineFieldList,
885
            $this->showEntryFieldListAdditional
886
        );
887
        $query->select($fields)->contain(['Users', 'Categories']);
888
889
        return $query;
890
    }
891
892
    /**
893
     * Implements the custom find type 'index paginator'
894
     *
895
     * @param Query $query query
896
     * @param array $options finder options
897
     * @return Query
898
     */
899
    public function findIndexPaginator(Query $query, array $options)
900
    {
901
        $query
902
            ->select(['id', 'pid', 'tid', 'time', 'last_answer', 'fixed'])
903
            ->where(['Entries.pid' => 0]);
904
905
        if (!empty($options['counter'])) {
906
            $query->counter($options['counter']);
907
        }
908
909
        return $query;
910
    }
911
912
    /**
913
     * Un-/Locks thread: sets posting in thread $tid to $locked
914
     *
915
     * @param int $tid thread-ID
916
     * @param bool $locked flag
917
     * @return void
918
     */
919
    protected function _threadLock($tid, $locked)
920
    {
921
        $this->updateAll(['locked' => $locked], ['tid' => $tid]);
922
    }
923
924
    /**
925
     * {@inheritDoc}
926
     */
927
    public function beforeSave(Event $event, Entity $entity)
928
    {
929
        $success = true;
930
931
        /// change category of thread if category of root entry changed
932
        if (!$entity->isNew() && $entity->isDirty('category_id')) {
933
            $success &= $this->_threadChangeCategory(
934
                // rules checks that only roots are allowed to change category, so tid = id
935
                $entity->get('id'),
936
                $entity->get('category_id')
937
            );
938
        }
939
940
        if (!$success) {
941
            $event->stopPropagation();
942
        }
943
    }
944
945
    /**
946
     * {@inheritDoc}
947
     */
948
    public function afterSave(Event $event, Entity $entity, \ArrayObject $options)
949
    {
950
        if ($entity->isNew()) {
951
            $this->Drafts->deleteDraftForPosting($entity);
952
        }
953
    }
954
955
    /**
956
     * Changes the category of a thread.
957
     *
958
     * Assigns the new category-id to all postings in that thread.
959
     *
960
     * @param int $tid thread-ID
961
     * @param int $newCategoryId id for new category
962
     * @return bool success
963
     * @throws NotFoundException
964
     */
965
    protected function _threadChangeCategory(int $tid, int $newCategoryId): bool
966
    {
967
        $exists = $this->Categories->exists($newCategoryId);
968
        if (!$exists) {
969
            throw new NotFoundException();
970
        }
971
        $affected = $this->updateAll(
972
            ['category_id' => $newCategoryId],
973
            ['tid' => $tid]
974
        );
975
976
        return $affected > 0;
977
    }
978
}
979