Passed
Push — develop ( bca4b6...66c5e1 )
by Schlaefer
03:54
created

EntriesTable   A

Complexity

Total Complexity 37

Size/Duplication

Total Lines 516
Duplicated Lines 0 %

Importance

Changes 6
Bugs 1 Features 0
Metric Value
eloc 221
c 6
b 1
f 0
dl 0
loc 516
rs 9.44
wmc 37

16 Methods

Rating   Name   Duplication   Size   Complexity  
A initialize() 0 49 3
A findEntry() 0 8 1
A buildRules() 0 20 3
A anonymizeEntriesFromUser() 0 15 2
A get() 0 9 2
A toggleSolve() 0 19 3
A createEntry() 0 43 5
A afterSave() 0 4 2
A deleteWithIds() 0 17 2
A getThreadId() 0 13 2
A validationDefault() 0 55 1
A beforeMarshal() 0 7 3
A findIndexPaginator() 0 11 2
A updateEntry() 0 23 3
A searchManager() 0 28 1
A getFieldset() 0 43 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\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\Datasource\Exception\RecordNotFoundException;
21
use Cake\Event\Event;
22
use Cake\ORM\Entity;
23
use Cake\ORM\Query;
24
use Cake\ORM\RulesChecker;
25
use Cake\Validation\Validator;
26
use Saito\Posting\PostingInterface;
27
use Saito\User\CurrentUser\CurrentUserInterface;
28
use Saito\Validation\SaitoValidationProvider;
29
use Search\Manager;
30
31
/**
32
 * Stores postings
33
 *
34
 * Field notes:
35
 * - `edited_by` - Came from mylittleforum. @td Should by migrated to User.id.
36
 * - `name` - Came from mylittleforum. Is still used in fulltext index.
37
 *
38
 * @property BookmarksTable $Bookmarks
39
 * @property CategoriesTable $Categories
40
 * @property DraftsTable $Drafts
41
 * @method array treeBuild(array $postings)
42
 * @method createPosting(array $data, CurrentUserInterface $CurrentUser)
43
 * @method updatePosting(Entry $posting, array $data, CurrentUserInterface $CurrentUser)
44
 * @method array prepareChildPosting(BasicPostingInterface $parent, array $data)
45
 * @method array getRecentPostings(CurrentUserInterface $CU, ?array $options = [])
46
 * @method bool deletePosting(int $id)
47
 * @method array postingsForThreads(array $tids, ?array $order = null, ?CurrentUserInterface $CU)
48
 * @method PostingInterface postingsForThread(int $tid, ?bool $complete = false, ?CurrentUserInterface $CU)
49
 * @method threadMerge(int $sourceId, int $targetId)
50
 */
51
class EntriesTable extends AppTable
52
{
53
    /**
54
     * Max subject length.
55
     *
56
     * Constrained to 191 due to InnoDB index max-length on MySQL 5.6.
57
     */
58
    public const SUBJECT_MAXLENGTH = 191;
59
60
    /**
61
     * Fields for search plugin
62
     *
63
     * @var array
64
     */
65
    public $filterArgs = [
66
        'subject' => ['type' => 'like'],
67
        'text' => ['type' => 'like'],
68
        'name' => ['type' => 'like'],
69
        'category' => ['type' => 'value'],
70
    ];
71
72
    protected $_defaultConfig = [
73
        'subject_maxlength' => 100
74
    ];
75
76
    /**
77
     * {@inheritDoc}
78
     */
79
    public function initialize(array $config)
80
    {
81
        $this->setPrimaryKey('id');
82
83
        $this->addBehavior('Posting');
84
        $this->addBehavior('IpLogging');
85
        $this->addBehavior('Timestamp');
86
87
        $this->addBehavior(
88
            'CounterCache',
89
            [
90
                // cache how many postings a user has
91
                'Users' => ['entry_count'],
92
                // cache how many threads a category has
93
                'Categories' => [
94
                    'thread_count' => function ($event, Entry $entity, $table, $original) {
95
                        if (!$entity->isRoot()) {
96
                            return false;
97
                        }
98
                        // posting is moved to new category…
99
                        if ($original) {
100
                            // update old category (should decrement counter)
101
                            $categoryId = $entity->getOriginal('category_id');
102
                        } else {
103
                            // update new category (increment counter)
104
                            $categoryId = $entity->get('category_id');
105
                        }
106
107
                        $query = $table->find('all', ['conditions' => [
108
                            'pid' => 0, 'category_id' => $categoryId
109
                        ]]);
110
                        $count = $query->count();
111
112
                        return $count;
113
                    }
114
                ]
115
            ]
116
        );
117
118
        $this->belongsTo('Categories', ['foreignKey' => 'category_id']);
119
        $this->belongsTo('Users', ['foreignKey' => 'user_id']);
120
121
        $this->hasMany(
122
            'Bookmarks',
123
            ['foreignKey' => 'entry_id', 'dependent' => true]
124
        );
125
126
        // Releation never queried. Just for quick access to the table.
127
        $this->hasOne('Drafts');
128
    }
129
130
    /**
131
     * {@inheritDoc}
132
     */
133
    public function validationDefault(Validator $validator)
134
    {
135
        $validator->setProvider('saito', SaitoValidationProvider::class);
136
137
        /// category_id
138
        $categoryRequiredL10N = __('vld.entries.categories.notEmpty');
139
        $validator
0 ignored issues
show
Deprecated Code introduced by
The function Cake\Validation\Validator::notEmpty() has been deprecated: 3.7.0 Use notEmptyString(), notEmptyArray(), notEmptyFile(), notEmptyDate(), notEmptyTime() or notEmptyDateTime() instead. ( Ignorable by Annotation )

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

139
        /** @scrutinizer ignore-deprecated */ $validator

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

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

Loading history...
140
            ->notEmpty('category_id', $categoryRequiredL10N)
141
            ->requirePresence('category_id', 'create', $categoryRequiredL10N);
142
143
        /// last_answer
144
        $validator
145
            ->requirePresence('last_answer', 'create')
146
            ->notEmptyDateTime('last_answer', null, 'create');
147
148
        /// name
149
        $validator
150
            ->requirePresence('name', 'create')
151
            ->notEmptyString('name', null, 'create');
152
153
        /// pid
154
        $validator->requirePresence('pid', 'create');
155
156
        /// subject
157
        $subjectRequiredL10N = __('vld.entries.subject.notEmpty');
158
        $validator
159
            ->notEmptyString('subject', $subjectRequiredL10N)
160
            ->requirePresence('subject', 'create', $subjectRequiredL10N)
161
            ->add(
162
                'subject',
163
                [
164
                    'maxLength' => [
165
                        'rule' => ['maxLength', $this->getConfig('subject_maxlength')],
166
                        'message' => __('vld.entries.subject.maxlength')
167
                    ]
168
                ]
169
            );
170
171
        /// time
172
        $validator
173
            ->requirePresence('time', 'create')
174
            ->notEmptyDateTime('time', null, 'create');
175
176
        /// user_id
177
        $validator
178
            ->requirePresence('user_id', 'create')
179
            ->add('user_id', ['numeric' => ['rule' => 'numeric']]);
180
181
        /// views
182
        $validator->add(
183
            'views',
184
            ['comparison' => ['rule' => ['comparison', '>=', 0]]]
185
        );
186
187
        return $validator;
188
    }
189
190
    /**
191
     * {@inheritDoc}
192
     */
193
    public function buildRules(RulesChecker $rules)
194
    {
195
        $rules = parent::buildRules($rules);
196
197
        $rules->add(
198
            function ($entity) {
199
                if (!$entity->isDirty('solves') || empty($entity->get('solves') > 0)) {
200
                    return true;
201
                }
202
203
                return !$entity->isRoot();
204
            },
205
            'checkSolvesOnlyOnAnswers',
206
            [
207
                'errorField' => 'solves',
208
                'message' => 'Root postings cannot mark themself solved.',
209
            ]
210
        );
211
212
        return $rules;
213
    }
214
215
    /**
216
     * Advanced search configuration from SaitoSearch plugin
217
     *
218
     * @see https://github.com/FriendsOfCake/search
219
     *
220
     * @return Manager
221
     */
222
    public function searchManager(): Manager
223
    {
224
        /** @var Manager $searchManager */
225
        $searchManager = $this->getBehavior('Search')->searchManager();
0 ignored issues
show
Bug introduced by
The method searchManager() does not exist on Cake\ORM\Behavior. It seems like you code against a sub-type of Cake\ORM\Behavior such as Search\Model\Behavior\SearchBehavior. ( Ignorable by Annotation )

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

225
        $searchManager = $this->getBehavior('Search')->/** @scrutinizer ignore-call */ searchManager();
Loading history...
226
        $searchManager
227
        ->like('subject', [
228
            'before' => true,
229
            'after' => true,
230
            'fieldMode' => 'OR',
231
            'comparison' => 'LIKE',
232
            'wildcardAny' => '*',
233
            'wildcardOne' => '?',
234
            'field' => ['subject'],
235
            'filterEmpty' => true,
236
        ])
237
        ->like('text', [
238
            'before' => true,
239
            'after' => true,
240
            'fieldMode' => 'OR',
241
            'comparison' => 'LIKE',
242
            'wildcardAny' => '*',
243
            'wildcardOne' => '?',
244
            'field' => ['text'],
245
            'filterEmpty' => true,
246
        ])
247
        ->value('name', ['filterEmpty' => true]);
248
249
        return $searchManager;
250
    }
251
252
    /**
253
     * Shorthand for reading an entry with full da516ta
254
     *
255
     * @param int $primaryKey key
256
     * @param array $options options
257
     * @return mixed Posting if found false otherwise
258
     */
259
    public function get($primaryKey, $options = [])
260
    {
261
        /** @var Entry */
262
        $result = $this->find('entry', ['complete' => true])
263
            ->where([$this->getAlias() . '.id' => $primaryKey])
264
            ->first();
265
266
        // @td throw exception here
267
        return empty($result) ? false : $result;
0 ignored issues
show
Bug Best Practice introduced by
The expression return empty($result) ? false : $result also could return the type array|false which is incompatible with the return type mandated by Cake\Datasource\RepositoryInterface::get() of Cake\Datasource\EntityInterface.
Loading history...
268
    }
269
270
    /**
271
     * Implements the custom find type 'entry'
272
     *
273
     * @param Query $query query
274
     * @param array $options options
275
     * - 'complete' bool controls fieldset selected as in getFieldset($complete)
276
     * @return Query
277
     */
278
    public function findEntry(Query $query, array $options = [])
279
    {
280
        $options += ['complete' => false];
281
        $query
282
            ->select($this->getFieldset($options['complete']))
283
            ->contain(['Users', 'Categories']);
284
285
        return $query;
286
    }
287
288
    /**
289
     * Get list of fields required to display posting.:w
290
     *
291
     * You don't want to fetch every field for performance reasons.
292
     *
293
     * @param bool $complete Threadline if false; Full posting if true
294
     * @return array The fieldset
295
     */
296
    public function getFieldset(bool $complete = false): array
297
    {
298
        // field list necessary for displaying a thread_line
299
        $threadLineFieldList = [
300
            'Categories.accession',
301
            'Categories.category',
302
            'Categories.description',
303
            'Categories.id',
304
            'Entries.fixed',
305
            'Entries.id',
306
            'Entries.last_answer',
307
            'Entries.locked',
308
            'Entries.name',
309
            'Entries.pid',
310
            'Entries.solves',
311
            'Entries.subject',
312
            // Entry.text determines if Entry is n/t
313
            'Entries.text',
314
            'Entries.tid',
315
            'Entries.time',
316
            'Entries.user_id',
317
            'Entries.views',
318
            'Users.username',
319
        ];
320
321
        // fields additional to $threadLineFieldList to show complete entry
322
        $showEntryFieldListAdditional = [
323
            'Entries.category_id',
324
            'Entries.edited',
325
            'Entries.edited_by',
326
            'Entries.ip',
327
            'Users.avatar',
328
            'Users.id',
329
            'Users.signature',
330
            'Users.user_place'
331
        ];
332
333
        $fields = $threadLineFieldList;
334
        if ($complete) {
335
            $fields = array_merge($fields, $showEntryFieldListAdditional);
336
        }
337
338
        return $fields;
339
    }
340
341
    /**
342
     * Finds the thread-IT for a posting.
343
     *
344
     * @param int $id Posting-Id
345
     * @return int Thread-Id
346
     * @throws RecordNotFoundException If posting isn't found
347
     */
348
    public function getThreadId($id)
349
    {
350
        $entry = $this->find(
351
            'all',
352
            ['conditions' => ['id' => $id], 'fields' => 'tid']
353
        )->first();
354
        if (empty($entry)) {
355
            throw new RecordNotFoundException(
356
                'Posting not found. Posting-Id: ' . $id
357
            );
358
        }
359
360
        return $entry->get('tid');
361
    }
362
363
    /**
364
     * creates a new root or child entry for a node
365
     *
366
     * fields in $data are filtered
367
     *
368
     * @param array $data data
369
     * @return Entry|null on success, null otherwise
370
     */
371
    public function createEntry(array $data): ?Entry
372
    {
373
        $data['time'] = bDate();
374
        $data['last_answer'] = bDate();
375
376
        /** @var Entry */
377
        $posting = $this->newEntity($data);
378
        $errors = $posting->getErrors();
379
        if (!empty($errors)) {
380
            return $posting;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $posting returns the type Cake\Datasource\EntityInterface which includes types incompatible with the type-hinted return App\Model\Entity\Entry|null.
Loading history...
381
        }
382
383
        $posting = $this->save($posting);
384
        if (!$posting) {
385
            return null;
386
        }
387
388
        $id = $posting->get('id');
389
        /** @var Entry */
390
        $posting = $this->get($id);
391
392
        if ($posting->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

392
        if ($posting->/** @scrutinizer ignore-call */ isRoot()) {
Loading history...
393
            // posting started a new thread, so set thread-ID to posting's own ID
394
            /** @var Entry */
395
            $posting = $this->patchEntity($posting, ['tid' => $id]);
396
            if (!$this->save($posting)) {
397
                return $posting;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $posting returns the type Cake\Datasource\EntityInterface which includes types incompatible with the type-hinted return App\Model\Entity\Entry|null.
Loading history...
398
            }
399
400
            $this->dispatchDbEvent('Model.Thread.create', ['subject' => $id, 'data' => $posting]);
401
        } else {
402
            // update last answer time of root entry
403
            $this->updateAll(
404
                ['last_answer' => $posting->get('last_answer')],
405
                ['id' => $posting->get('tid')]
406
            );
407
408
            $eventData = ['subject' => $posting->get('pid'), 'data' => $posting];
409
            $this->dispatchDbEvent('Model.Entry.replyToEntry', $eventData);
410
            $this->dispatchDbEvent('Model.Entry.replyToThread', $eventData);
411
        }
412
413
        return $posting;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $posting returns the type array|false which is incompatible with the type-hinted return App\Model\Entity\Entry|null.
Loading history...
414
    }
415
416
    /**
417
     * Updates a posting
418
     *
419
     * fields in $data are filtered except for $id!
420
     *
421
     * @param Entry $posting Entity
422
     * @param array $data data
423
     * @return Entry|null
424
     */
425
    public function updateEntry(Entry $posting, array $data): ?Entry
426
    {
427
        $data['id'] = $posting->get('id');
428
429
        /** @var Entry */
430
        $patched = $this->patchEntity($posting, $data);
431
        $errors = $patched->getErrors();
432
        if (!empty($errors)) {
433
            return $patched;
434
        }
435
436
        /** @var Entry */
437
        $new = $this->save($posting);
438
        if (empty($new)) {
439
            return null;
440
        }
441
442
        $this->dispatchDbEvent(
443
            'Model.Entry.update',
444
            ['subject' => $posting->get('id'), 'data' => $posting]
445
        );
446
447
        return $new;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $new returns the type Cake\Datasource\EntityInterface which includes types incompatible with the type-hinted return App\Model\Entity\Entry|null.
Loading history...
448
    }
449
450
    /**
451
     * Marks a sub-entry as solution to a root entry
452
     *
453
     * @param Entry $posting posting to toggle
454
     * @return bool success
455
     */
456
    public function toggleSolve(Entry $posting)
457
    {
458
        if ($posting->get('solves')) {
459
            $value = 0;
460
        } else {
461
            $value = $posting->get('tid');
462
        }
463
464
        $this->patchEntity($posting, ['solves' => $value]);
465
        if (!$this->save($posting)) {
466
            return false;
467
        }
468
469
        $this->dispatchDbEvent(
470
            'Model.Entry.update',
471
            ['subject' => $posting->get('id'), 'data' => $posting]
472
        );
473
474
        return true;
475
    }
476
477
    /**
478
     * {@inheritDoc}
479
     */
480
    public function beforeMarshal(Event $event, \ArrayObject $data, \ArrayObject $options)
0 ignored issues
show
Unused Code introduced by
The parameter $options 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

480
    public function beforeMarshal(Event $event, \ArrayObject $data, /** @scrutinizer ignore-unused */ \ArrayObject $options)

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...
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

480
    public function beforeMarshal(/** @scrutinizer ignore-unused */ Event $event, \ArrayObject $data, \ArrayObject $options)

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...
481
    {
482
        /// Trim whitespace on subject and text
483
        $toTrim = ['subject', 'text'];
484
        foreach ($toTrim as $field) {
485
            if (!empty($data[$field])) {
486
                $data[$field] = trim($data[$field]);
487
            }
488
        }
489
    }
490
491
    /**
492
     * Deletes posting incl. all its subposting and associated data
493
     *
494
     * @param array $idsToDelete Entry ids which should be deleted
495
     * @return bool
496
     */
497
    public function deleteWithIds(array $idsToDelete): bool
498
    {
499
        $success = $this->deleteAll(['id IN' => $idsToDelete]);
500
501
        if (!$success) {
502
            return false;
503
        }
504
505
        // @td Should be covered by dependent assoc. Add tests.
506
        $this->Bookmarks->deleteAll(['entry_id IN' => $idsToDelete]);
507
508
        $this->dispatchSaitoEvent(
509
            'Model.Saito.Postings.delete',
510
            ['subject' => $idsToDelete, 'table' => $this]
511
        );
512
513
        return true;
514
    }
515
516
    /**
517
     * Anonymizes the entries for a user
518
     *
519
     * @param int $userId user-ID
520
     * @return void
521
     */
522
    public function anonymizeEntriesFromUser(int $userId): void
523
    {
524
        // remove username from all entries and reassign to anonyme user
525
        $success = (bool)$this->updateAll(
526
            [
527
                'edited_by' => null,
528
                'ip' => null,
529
                'name' => null,
530
                'user_id' => 0,
531
            ],
532
            ['user_id' => $userId]
533
        );
534
535
        if ($success) {
536
            $this->dispatchDbEvent('Cmd.Cache.clear', ['cache' => 'Thread']);
537
        }
538
    }
539
540
    /**
541
     * Implements the custom find type 'index paginator'
542
     *
543
     * @param Query $query query
544
     * @param array $options finder options
545
     * @return Query
546
     */
547
    public function findIndexPaginator(Query $query, array $options)
548
    {
549
        $query
550
            ->select(['id', 'pid', 'tid', 'time', 'last_answer', 'fixed'])
551
            ->where(['Entries.pid' => 0]);
552
553
        if (!empty($options['counter'])) {
554
            $query->counter($options['counter']);
555
        }
556
557
        return $query;
558
    }
559
560
    /**
561
     * {@inheritDoc}
562
     */
563
    public function afterSave(Event $event, Entity $entity, \ArrayObject $options)
0 ignored issues
show
Unused Code introduced by
The parameter $options 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

563
    public function afterSave(Event $event, Entity $entity, /** @scrutinizer ignore-unused */ \ArrayObject $options)

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...
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

563
    public function afterSave(/** @scrutinizer ignore-unused */ Event $event, Entity $entity, \ArrayObject $options)

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...
564
    {
565
        if ($entity->isNew()) {
566
            $this->Drafts->deleteDraftForPosting($entity);
567
        }
568
    }
569
}
570