Passed
Push — 4-cactus ( 89e7fd...01e702 )
by Stefano
03:47 queued 28s
created

TreeCheckCommand::defaultName()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 1
b 0
f 0
1
<?php
2
/**
3
 * BEdita, API-first content management framework
4
 * Copyright 2022 Atlas Srl, Chialab Srl
5
 *
6
 * This file is part of BEdita: you can redistribute it and/or modify
7
 * it under the terms of the GNU Lesser General Public License as published
8
 * by the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 *
11
 * See LICENSE.LGPL or <http://gnu.org/licenses/lgpl-3.0.html> for more details.
12
 */
13
14
namespace BEdita\Core\Command;
15
16
use BEdita\Core\Model\Table\TreesTable;
17
use Cake\Collection\CollectionInterface;
18
use Cake\Console\Arguments;
19
use Cake\Console\Command;
20
use Cake\Console\ConsoleIo;
21
use Cake\Console\ConsoleOptionParser;
22
use Cake\Database\Expression\IdentifierExpression;
23
use Cake\Database\Expression\QueryExpression;
24
use Cake\Datasource\EntityInterface;
25
use Cake\ORM\Query;
26
27
/**
28
 * Commend to check tree sanity and perform objects-aware tree recovery.
29
 *
30
 * @since 4.8.0
31
 * @property \BEdita\Core\Model\Table\ObjectsTable $Objects
32
 */
33
class TreeCheckCommand extends Command
34
{
35
    /**
36
     * {@inheritDoc}
37
     */
38
    public $modelClass = 'Objects';
39
40
    /**
41
     * {@inheritDoc}
42
     *
43
     * @codeCoverageIgnore
44
     */
45
    public static function defaultName(): string
46
    {
47
        return 'tree check';
48
    }
49
50
    /**
51
     * {@inheritDoc}
52
     *
53
     * @codeCoverageIgnore
54
     */
55
    public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser
56
    {
57
        return parent::buildOptionParser($parser)
58
            ->setDescription('Objects-aware sanity checks on tree.');
59
    }
60
61
    /**
62
     * Implement this method with your command's logic.
63
     *
64
     * @param \Cake\Console\Arguments $args The command arguments.
65
     * @param \Cake\Console\ConsoleIo $io The console io
66
     * @return null|int The exit code or null for success
67
     */
68
    public function execute(Arguments $args, ConsoleIo $io): int
69
    {
70
        $code = static::CODE_SUCCESS;
71
72
        // Run tree integrity checks.
73
        $messages = $this->Objects->TreeNodes->checkIntegrity();
0 ignored issues
show
Bug introduced by
The method checkIntegrity() does not exist on BEdita\Core\Model\Table\TreesTable. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

73
        /** @scrutinizer ignore-call */ 
74
        $messages = $this->Objects->TreeNodes->checkIntegrity();
Loading history...
74
        if (!empty($messages)) {
75
            $io->out('=====> <error>Tree is corrupt!</error>');
76
            foreach ($messages as $msg) {
77
                $io->verbose(sprintf('=====>   - %s', $msg));
78
            }
79
80
            $code = static::CODE_ERROR;
81
        } else {
82
            $io->verbose('=====> <success>Tree integrity check passed.</success>');
83
        }
84
85
        // Checks on folders not in tree.
86
        $results = $this->getFoldersNotInTree()->all();
87
        if (!$results->isEmpty()) {
88
            $code = static::CODE_ERROR;
89
        }
90
        $this->report($io, $results, 'folders not in tree', 'is not in the tree');
91
92
        // Checks on ubiquitous folders.
93
        $results = $this->getUbiquitousFolders()->all();
94
        if (!$results->isEmpty()) {
95
            $code = static::CODE_ERROR;
96
        }
97
        $this->report($io, $results, 'ubiquitous folders', 'is ubiquitous');
98
99
        // Checks on other objects in root.
100
        $results = $this->getObjectsInRoot()->all();
101
        if (!$results->isEmpty()) {
102
            $code = static::CODE_ERROR;
103
        }
104
        $this->report($io, $results, 'other objects in root', 'is a root');
105
106
        // Checks on other objects with children.
107
        $results = $this->getObjectsWithChildren()->all();
108
        if (!$results->isEmpty()) {
109
            $code = static::CODE_ERROR;
110
        }
111
        $this->report($io, $results, 'other objects with children', 'has children');
112
113
        // Checks on other objects twice inside same folder.
114
        $results = $this->getObjectsTwiceInFolder()->all();
115
        if (!$results->isEmpty()) {
116
            $code = static::CODE_ERROR;
117
        }
118
        $this->report($io, $results, 'objects that are present multiple times within same parent', 'is positioned multiple times within the same parent');
119
120
        // Checks matching `parent_id` in parent tree node.
121
        $results = $this->getNotMatchingParentId()->all();
122
        if (!$results->isEmpty()) {
123
            $code = static::CODE_ERROR;
124
        }
125
        $this->report($io, $results, 'tree nodes that reference a different parent than the object of the parent node', 'references a different parent_id than the object_id in the parent node');
126
127
        // Checks matching `root_id` in parent tree node.
128
        $results = $this->getNotMatchingRootId()->all();
129
        if (!$results->isEmpty()) {
130
            $code = static::CODE_ERROR;
131
        }
132
        $this->report($io, $results, 'tree nodes that reference a different root than the root of the parent node', 'references a different root_id than the one in the parent node');
133
134
        return $code;
135
    }
136
137
    /**
138
     * Return query to find all folders that are not in the tree.
139
     *
140
     * @return \Cake\ORM\Query
141
     */
142
    protected function getFoldersNotInTree(): Query
143
    {
144
        return $this->Objects->find('type', ['folders'])
145
            ->select([
146
                $this->Objects->aliasField('id'),
147
                $this->Objects->aliasField('uname'),
148
                $this->Objects->aliasField('object_type_id'),
149
            ])
150
            ->notMatching('TreeNodes');
151
    }
152
153
    /**
154
     * Return query to find all folders that are ubiquitous.
155
     *
156
     * @return \Cake\ORM\Query
157
     */
158
    protected function getUbiquitousFolders(): Query
159
    {
160
        return $this->Objects->find('type', ['folders'])
161
            ->select([
162
                $this->Objects->aliasField('id'),
163
                $this->Objects->aliasField('uname'),
164
                $this->Objects->aliasField('object_type_id'),
165
            ])
166
            ->innerJoinWith('TreeNodes')
167
            ->group([
168
                $this->Objects->aliasField('id'),
169
            ])
170
            ->having(function (QueryExpression $exp, Query $query): QueryExpression {
171
                return $exp->gt($query->func()->count('*'), 1, 'integer');
172
            });
173
    }
174
175
    /**
176
     * Return query to find all objects that are roots despite not being folders.
177
     *
178
     * @return \Cake\ORM\Query
179
     */
180
    protected function getObjectsInRoot(): Query
181
    {
182
        return $this->Objects->find('type', ['!=' => 'folders'])
183
            ->select([
184
                $this->Objects->aliasField('id'),
185
                $this->Objects->aliasField('uname'),
186
                $this->Objects->aliasField('object_type_id'),
187
            ])
188
            ->innerJoinWith('TreeNodes', function (Query $query): Query {
189
                return $query->where(function (QueryExpression $exp): QueryExpression {
190
                    return $exp->isNull($this->Objects->TreeNodes->aliasField('parent_id'));
191
                });
192
            });
193
    }
194
195
    /**
196
     * Return query to find all objects that have children despite not being folders.
197
     *
198
     * @return \Cake\ORM\Query
199
     */
200
    protected function getObjectsWithChildren(): Query
201
    {
202
        // This association normally would live in the "Folders" table, but we're checking for anomalies,
203
        // so let's assume it makes sense here.
204
        $this->Objects->hasMany('TreeParentNodes', [
205
            'className' => TreesTable::class,
206
            'foreignKey' => 'parent_id',
207
        ]);
208
209
        return $this->Objects->find('type', ['!=' => 'folders'])
210
            ->select([
211
                $this->Objects->aliasField('id'),
212
                $this->Objects->aliasField('uname'),
213
                $this->Objects->aliasField('object_type_id'),
214
            ])
215
            ->innerJoinWith('TreeParentNodes');
216
    }
217
218
    /**
219
     * Return query to find all objects that are placed twice inside same parent.
220
     *
221
     * @return \Cake\ORM\Query
222
     */
223
    protected function getObjectsTwiceInFolder(): Query
224
    {
225
        return $this->Objects->find('type', ['!=' => 'folders'])
226
            ->select([
227
                $this->Objects->aliasField('id'),
228
                $this->Objects->aliasField('uname'),
229
                $this->Objects->aliasField('object_type_id'),
230
                $this->Objects->Parents->aliasField('id'),
231
                $this->Objects->Parents->aliasField('uname'),
232
            ])
233
            ->innerJoinWith('Parents')
234
            ->group([
235
                $this->Objects->aliasField('id'),
236
                $this->Objects->Parents->aliasField('id'),
237
            ])
238
            ->having(function (QueryExpression $exp, Query $query): QueryExpression {
239
                return $exp->gt($query->func()->count('*'), 1, 'integer');
240
            });
241
    }
242
243
    /**
244
     * Return query to find all rows in `trees` table that reference a different `parent_id` than the `object_id` in the parent tree node.
245
     *
246
     * @return \Cake\ORM\Query
247
     */
248
    protected function getNotMatchingParentId(): Query
249
    {
250
        return $this->Objects->find()
251
            ->select([
252
                $this->Objects->aliasField('id'),
253
                $this->Objects->aliasField('uname'),
254
                $this->Objects->aliasField('object_type_id'),
255
            ])
256
            ->innerJoinWith('TreeNodes.ParentNode')
257
            ->where(function (QueryExpression $exp): QueryExpression {
258
                return $exp->notEq(
259
                    $this->Objects->TreeNodes->aliasField('parent_id'),
260
                    new IdentifierExpression($this->Objects->TreeNodes->ParentNode->aliasField('object_id'))
261
                );
262
            });
263
    }
264
265
    /**
266
     * Return query to find all rows in `trees` table that reference a different `parent_id` than the `object_id` in the parent tree node.
267
     *
268
     * @return \Cake\ORM\Query
269
     */
270
    protected function getNotMatchingRootId(): Query
271
    {
272
        return $this->Objects->find()
273
            ->select([
274
                $this->Objects->aliasField('id'),
275
                $this->Objects->aliasField('uname'),
276
                $this->Objects->aliasField('object_type_id'),
277
            ])
278
            ->leftJoinWith('TreeNodes.ParentNode')
279
            ->where(function (QueryExpression $exp, Query $query): QueryExpression {
280
                return $exp->notEq(
281
                    $this->Objects->TreeNodes->aliasField('root_id'),
282
                    $query->func()->coalesce([
283
                        $this->Objects->TreeNodes->ParentNode->aliasField('root_id') => 'identifier',
284
                        $this->Objects->TreeNodes->aliasField('object_id') => 'identifier',
285
                    ])
286
                );
287
            });
288
    }
289
290
    /**
291
     * Output a report section.
292
     *
293
     * @param \Cake\Console\ConsoleIo $io Console I/O.
294
     * @param \Cake\Collection\CollectionInterface $results Results.
295
     * @param string $title Section title.
296
     * @param string $message Error message.
297
     * @return void
298
     */
299
    protected function report(ConsoleIo $io, CollectionInterface $results, string $title, string $message): void
300
    {
301
        $count = $results->count();
302
        if ($count === 0) {
303
            $io->verbose(sprintf('=====> <success>There are no %s.</success>', $title));
304
305
            return;
306
        }
307
308
        $io->out(sprintf('=====> <warning>Found %d %s!</warning>', $count, $title));
309
        $results->each(function (EntityInterface $entity) use ($io, $message): void {
310
            $io->verbose(
311
                sprintf(
312
                    '=====>   - %s <info>%s</info> (#<info>%d</info>) %s',
313
                    $this->Objects->ObjectTypes->get($entity['object_type_id'])->get('singular'),
314
                    $entity['uname'],
315
                    $entity['id'],
316
                    $message
317
                )
318
            );
319
        });
320
    }
321
}
322