Test Failed
Push — main ( de1472...45c261 )
by Christopher
03:30 queued 30s
created

DataClassHandler::getFile()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace CSoellinger\SilverStripe\ModelAnnotations\Handler;
4
5
use CSoellinger\SilverStripe\ModelAnnotations\Task\ModelAnnotationsTask;
6
use CSoellinger\SilverStripe\ModelAnnotations\Util\Util;
7
use CSoellinger\SilverStripe\ModelAnnotations\View\DataClassTaskView;
8
use Psl\Regex;
9
use SilverStripe\Core\ClassInfo;
10
use SilverStripe\Core\Config\Config;
11
use SilverStripe\Core\Injector\Injectable;
12
use SilverStripe\Core\Injector\Injector;
13
use SilverStripe\ORM\DataObject;
14
15
/**
16
 * Helper class to handle your silver stripe models which are sub classes
17
 * from {@see DataObject}.
18
 */
19
class DataClassHandler
20
{
21
    use Injectable;
22
23
    /**
24
     * @var null|\ast\Node - Abstract syntax tree of the data object class
25
     */
26
    public ?\ast\Node $ast;
27
28
    /**
29
     * @var string[] - Array to collect all types which are not defined as use
30
     *               statement
31
     */
32
    public array $unusedTypes = [];
33
34
    /**
35
     * @var string - Full qualified name of the class
36
     */
37
    private string $fqn;
38
39
    /**
40
     * @var DataClassFileHandler - Handle the file of the class
41
     */
42
    private DataClassFileHandler $file;
43
44
    /**
45
     * @var array<int,array<string,string>> - All collected model properties. Collected from db, has_one and belongs_to
46
     *                                      config
47
     */
48
    private array $modelProperties = [];
49
50
    /**
51
     * @var array<int,array<string,string>> - All collected model methods. Collected from has_many, many_many,
52
     *                                      belongs_many_many config.
53
     */
54
    private array $modelMethods = [];
55
56
    /**
57
     * @var string[] - Injected dependencies
58
     */
59
    private static array $dependencies = [
60
        'renderer' => '%$' . DataClassTaskView::class,
61
    ];
62
63
    /**
64
     * @var DataClassTaskView - Injected data class renderer
65
     * @psalm-suppress PropertyNotSetInConstructor
66
     */
67
    private DataClassTaskView $renderer;
68
69
    /**
70
     * @var Util - Injected util class
71
     */
72
    private Util $util;
73
74
    /**
75
     * @var string[] - All classes in same namespace
76
     */
77
    private array $classesInSameNamespace = [];
78
79
    /**
80
     * Constructor.
81
     *
82
     * @param string $fqn Full qualified name of the class
83
     */
84
    public function __construct(string $fqn)
85
    {
86
        $this->fqn = $fqn;
87
88
        /** @var Util $util */
89
        $util = Injector::inst()->create(Util::class);
90
        $this->util = $util;
91
92
        /** @var DataClassFileHandler $fileHandler */
93
        $fileHandler = Injector::inst()->createWithArgs(DataClassFileHandler::class, [$this->util->fileByFqn($fqn)]);
94
        $this->file = $fileHandler;
95
96
        $this->ast = $this->file->getClassAst($fqn);
97
98
        $this->classesInSameNamespace = $this->util->getClassesFromNamespace($this->util->getNamespaceFromFqn($fqn));
99
100
        $this->fetchModelProperties();
101
        $this->fetchModelMethods();
102
    }
103
104
    /**
105
     * Get all missing use statements.
106
     *
107
     * @return string[]
108
     */
109
    public function getMissingUseStatements(): array
110
    {
111
        $this->unusedTypes = array_unique($this->unusedTypes);
112
113
        if (count($this->unusedTypes) === 0) {
114
            return [];
115
        }
116
117
        return array_map(function ($unusedType) {
118
            return 'use ' . $unusedType . ';';
119
        }, $this->unusedTypes);
120
    }
121
122
    /**
123
     * Get the file handler.
124
     */
125
    public function getFile(): DataClassFileHandler
126
    {
127
        return $this->file;
128
    }
129
130
    /**
131
     * Get the abstract syntax tree of the class.
132
     */
133
    public function getAst(): ?\ast\Node
134
    {
135
        return $this->ast;
136
    }
137
138
    /**
139
     * Get class phpdoc from abstract syntax tree.
140
     */
141
    public function getClassPhpDoc(): string
142
    {
143
        if ($this->ast === null) {
144
            return '';
145
        }
146
147
        /** @var array<string,string> $meta */
148
        $meta = $this->ast->children;
149
150
        return $meta['docComment'] ?: '';
151
    }
152
153
    /**
154
     * Get all db fields, collected from "db", "has_one" and "belongs_to" config,
155
     * to fetch all possible model phpdoc properties.
156
     *
157
     * @return array<int,array<string,string>>
158
     */
159
    public function getModelProperties()
160
    {
161
        return $this->modelProperties;
162
    }
163
164
    /**
165
     * Get all db many relations, collected from "has_many", "many_many" and
166
     * "belongs_many_many" config, to fetch all possible model phpdoc methods.
167
     *
168
     * @return array<int,array<string,string>>
169
     */
170
    public function getModelMethods()
171
    {
172
        return $this->modelMethods;
173
    }
174
175
    /**
176
     * Generate a class php doc for collected model properties and methods.
177
     */
178
    public function generateClassPhpDoc(): string
179
    {
180
        // Get space paddings
181
        $spacePad = ['dataType' => 0, 'variableName' => 0, 'methodName' => 0, 'methodType' => 0];
182
        foreach ($this->modelProperties as $modelProperty) {
183
            if (strlen($modelProperty['dataType']) > $spacePad['dataType']) {
184
                $spacePad['dataType'] = strlen($modelProperty['dataType']);
185
            }
186
            if (strlen($modelProperty['variableName']) > $spacePad['variableName']) {
187
                $spacePad['variableName'] = strlen($modelProperty['variableName']);
188
            }
189
        }
190
        foreach ($this->modelMethods as $modelMethod) {
191
            if (strlen($modelMethod['variableName']) > $spacePad['methodName']) {
192
                $spacePad['methodName'] = strlen($modelMethod['variableName']);
193
            }
194
195
            if (strlen($modelMethod['dataType']) > $spacePad['methodType']) {
196
                $spacePad['methodType'] = strlen($modelMethod['dataType']);
197
            }
198
        }
199
200
        // Create php docs line by line
201
        $commentLines = [];
202
        foreach ($this->modelProperties as $modelProperty) {
203
            $dataType = str_pad($modelProperty['dataType'], $spacePad['dataType']);
204
            $variableName = str_pad($modelProperty['variableName'], $spacePad['variableName']);
205
            $commentLine = ' * @property ' . $dataType . ' $' . $variableName . ' ';
206
            $commentLine .= $modelProperty['description'];
207
208
            $commentLines[] = $commentLine;
209
        }
210
211
        if (count($this->modelProperties) > 0 && count($this->modelMethods) > 0) {
212
            $commentLines[] = ' *';
213
        }
214
215
        foreach ($this->modelMethods as $modelMethod) {
216
            $variableName = str_pad($modelMethod['variableName'] . '()', $spacePad['methodName'] + 2);
217
            $dataType = str_pad($modelMethod['dataType'], $spacePad['methodType']);
218
            $commentLine = ' * @method ' . $dataType . ' ' . $variableName . ' ' . $modelMethod['description'];
219
220
            $commentLines[] = $commentLine;
221
        }
222
223
        $commentLines[] = ' */';
224
225
        $oldClassDoc = $this->getClassPhpDoc();
226
        $classDocAdd = implode(PHP_EOL, $commentLines);
227
228
        if ($oldClassDoc === '') {
229
            return '/**' . PHP_EOL . $classDocAdd;
230
        }
231
232
        return Regex\replace($oldClassDoc, '/\*\/$/m', '*' . PHP_EOL) . $classDocAdd;
233
    }
234
235
    /**
236
     * Set data class renderer.
237
     */
238
    final public function setRenderer(DataClassTaskView $renderer): self
239
    {
240
        $this->renderer = $renderer;
241
242
        return $this;
243
    }
244
245
    /**
246
     * Get data class renderer.
247
     */
248
    public function getRenderer(): DataClassTaskView
249
    {
250
        return $this->renderer;
251
    }
252
253
    private function fetchModelProperties(bool $filterExistingAnnotations = true): void
254
    {
255
        foreach (['db', 'has_one', 'belongs_to'] as $configKey) {
256
            /** @var array<string,string> $fieldConfigs */
257
            $fieldConfigs = Config::forClass($this->fqn)->get($configKey);
258
259
            foreach ($fieldConfigs as $fieldName => $fieldType) {
260
                if ($configKey === 'db') {
261
                    $fieldType = $this->util->silverStripeToPhpType(strtolower($fieldType));
262
                    $description = $fieldName . ' ...';
263
                } else {
264
                    $fieldType = $this->shortenDataType($fieldType);
265
                    $description = str_replace('_', ' ', ucfirst($configKey)) . ' '
266
                         . $fieldName . ' {@see ' . $fieldType . '}';
267
                }
268
269
                $this->modelProperties[] = $this->getModelPropertyData(
270
                    $fieldName,
271
                    $fieldType,
272
                    $filterExistingAnnotations,
273
                    $description
274
                );
275
276
                // For has one relations we also add the ID field
277
                if ($configKey === 'has_one') {
278
                    $this->modelProperties[] = $this->getModelPropertyData(
279
                        $fieldName . 'ID',
280
                        'int',
281
                        $filterExistingAnnotations,
282
                        $fieldName . ' ID'
283
                    );
284
                }
285
            }
286
        }
287
288
        $this->modelProperties = array_filter($this->modelProperties, function ($modelProperty) {
289
            return count($modelProperty) > 0;
290
        });
291
    }
292
293
    /**
294
     * Gert model property array data.
295
     *
296
     * @return array<string,string>
297
     */
298
    private function getModelPropertyData(
299
        string $fieldName,
300
        string $fieldType,
301
        bool $filterExistingAnnotations,
302
        string $description
303
    ) {
304
        if ($this->checkField($fieldName) === true) {
305
            return [];
306
        }
307
308
        $matches = [];
309
        if ($filterExistingAnnotations === true) {
310
            $regex = '/@property[\s]*' . preg_quote($fieldType, '/') . '[\s]*\\$' .
311
                preg_quote($fieldName, '/') . '[\s]*/m';
312
313
            preg_match_all($regex, $this->getClassPhpDoc(), $matches, PREG_SET_ORDER, 0);
314
        }
315
316
        if (count($matches) > 0) {
317
            return [];
318
        }
319
320
        return [
321
            'dataType' => $fieldType,
322
            'variableName' => $fieldName,
323
            'description' => $description,
324
        ];
325
    }
326
327
    private function fetchModelMethods(bool $filterExistingAnnotations = true): void
328
    {
329
        $hasManyList = 'SilverStripe\ORM\HasManyList';
330
        $manyManyList = 'SilverStripe\ORM\ManyManyList';
331
332
        // List relations
333
        $relations = [
334
            'has_many' => ['list' => $hasManyList],
335
            'many_many' => ['list' => $manyManyList],
336
            'belongs_many_many' => ['list' => $manyManyList],
337
        ];
338
339
        foreach ($relations as $key => $relation) {
340
            /** @var array<string,array<string,string>|string> $fieldConfigs */
341
            $fieldConfigs = Config::forClass($this->fqn)->get($key);
342
343
            foreach ($fieldConfigs as $fieldName => $fieldType) {
344
                $this->modelMethods[] = $this->getModelMethodData(
345
                    $fieldName,
346
                    $fieldType,
347
                    $relation,
348
                    $manyManyList,
349
                    $key,
350
                    $filterExistingAnnotations
351
                );
352
            }
353
        }
354
355
        $this->modelMethods = array_filter($this->modelMethods, function ($modelMethod) {
356
            return count($modelMethod) > 0;
357
        });
358
    }
359
360
    /**
361
     * Get model method data array.
362
     *
363
     * @param array<string,string>|string $fieldType
364
     * @param array<string,string>        $relation
365
     *
366
     * @return array<string,string>
367
     */
368
    private function getModelMethodData(
369
        string $fieldName,
370
        $fieldType,
371
        $relation,
372
        string $manyManyList,
373
        string $relationKey,
374
        bool $filterExistingAnnotations
375
    ) {
376
        if ($this->checkField($fieldName) === true) {
377
            return [];
378
        }
379
380
        if (is_array($fieldType) === true) {
381
            $fieldType = $fieldType['through'];
382
            $relation['list'] = $manyManyList;
383
        }
384
385
        $fieldType = $this->shortenDataType($fieldType);
386
        $listType = $this->shortenDataType($relation['list']);
387
        $description = str_replace('_', ' ', ucfirst($relationKey)) . ' '
388
            . $fieldName . ' {@see ' . $fieldType . '}';
389
390
        if ($filterExistingAnnotations === true) {
391
            $matches = [];
392
            $regex = '/@method[\s]*' . preg_quote($listType, '/') . '[\s]*'
393
                . preg_quote($fieldName, '/') . '\(.*\)[\s]*/m';
394
395
            preg_match_all($regex, $this->getClassPhpDoc(), $matches, PREG_SET_ORDER, 0);
396
397
            if (count($matches) > 0) {
398
                return [];
399
            }
400
        }
401
402
        return [
403
            'dataType' => $listType,
404
            'variableName' => $fieldName,
405
            'description' => $description,
406
        ];
407
    }
408
409
    /**
410
     * Check if a db field should not be included.
411
     */
412
    private function checkField(string $fieldName): bool
413
    {
414
        $config = Config::forClass(ModelAnnotationsTask::class);
415
        $siteTreeFields = (array) $config->get('siteTreeFields');
416
        $ignoreFields = (array) $config->get('ignoreFields');
417
        $dataClasses = ClassInfo::dataClassesFor($this->fqn);
418
        $isSiteTree = in_array('silverstripe\\cms\\model\\sitetree', array_keys($dataClasses), true);
419
        $isSiteTreeField = in_array($fieldName, $siteTreeFields, true);
420
        $ignoreField = in_array($fieldName, $ignoreFields, true);
421
422
        if (($isSiteTree === true && $isSiteTreeField === true) || $ignoreField === true) {
423
            return true;
424
        }
425
426
        return false;
427
    }
428
429
    /**
430
     * Format data type. If we collect data types which are not declared as use statement and or not inside same
431
     * namespace we can shorten the data type.
432
     */
433
    private function shortenDataType(string $dataType): string
434
    {
435
        $pos = strpos($dataType, '.');
436
437
        // If we have a dot notation we will strip it cause we just need the class name
438
        if ($pos !== false) {
439
            $dataType = substr($dataType, 0, $pos);
440
        }
441
442
        // Class name only from fqn
443
        $dataTypeName = ClassInfo::shortName($dataType);
444
445
        $useStatements = $this->file->getUseStatementsFromAst();
446
447
        // Check if field type is declared as use statement
448
        $useStatement = array_filter($useStatements, function (\ast\Node $useStatement) use ($dataType) {
449
            /** @var array<string,string> */
450
            $meta = $useStatement->children;
451
452
            return $meta['name'] === $dataType || $meta['alias'] === $dataType;
453
        });
454
        $useStatementExists = (count($useStatement) > 0);
455
456
        // Also check if field type is in same namespace
457
        $inSameNamespace = in_array($dataType, $this->classesInSameNamespace, true);
458
459
        // By default we take the type from the global namespace
460
        $dataType = '\\' . $dataType;
461
462
        $config = Config::forClass(ModelAnnotationsTask::class);
463
        $collectNotDeclaredUse = (bool) $config->get('addUseStatements');
464
465
        // We can shorten the type if use statement exists or type is in same namespace. Also if we collect all types
466
        // which are not declared as use statement
467
        if ($useStatementExists === true || $inSameNamespace === true || $collectNotDeclaredUse === true) {
468
            if ($collectNotDeclaredUse === true && $inSameNamespace === false && $useStatementExists === false) {
469
                $this->unusedTypes[] = trim($dataType, '\\');
470
            }
471
472
            $dataType = $dataTypeName;
473
        }
474
475
        return $dataType;
476
    }
477
}
478