Passed
Pull Request — master (#3)
by Jasper
03:15
created

ReEncryptModels::models()   B

Complexity

Conditions 8
Paths 4

Size

Total Lines 44
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 72

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 8
eloc 28
nc 4
nop 0
dl 0
loc 44
ccs 0
cts 33
cp 0
crap 72
rs 8.4444
c 1
b 0
f 1
1
<?php
2
3
namespace Swis\Laravel\Encrypted\Commands;
4
5
use Illuminate\Console\Command;
6
use Illuminate\Database\Eloquent\Model;
7
use Illuminate\Database\Eloquent\SoftDeletes;
8
use Illuminate\Support\Collection;
9
use Illuminate\Support\Str;
10
use Symfony\Component\Finder\Finder;
11
use Symfony\Component\Finder\SplFileInfo;
12
13
class ReEncryptModels extends Command
14
{
15
    protected $signature = 'encrypted-data:re-encrypt:models
16
                                {--model=* : Class names of the models to be re-encrypted}
17
                                {--except=* : Class names of the models to be excluded from re-encryption}
18
                                {--path=* : Absolute path(s) to directories where models are located}
19
                                {--chunk=1000 : The number of models to retrieve per chunk of models to be re-encrypted}
20
                                {--quietly : Re-encrypt the models without raising any events}
21
                                {--no-touch : Re-encrypt the models without updating timestamps}
22
                                {--with-trashed : Re-encrypt trashed models}';
23
24
    protected $description = 'Re-encrypt models';
25
26
    public function handle(): int
27
    {
28
        $models = $this->models();
29
30
        if ($models->isEmpty()) {
31
            $this->warn('No models found.');
32
33
            return self::FAILURE;
34
        }
35
36
        if ($this->confirm('The following models will be re-encrypted: '.PHP_EOL.$models->implode(PHP_EOL).PHP_EOL.'Do you want to continue?') === false) {
37
            return self::FAILURE;
38
        }
39
40
        $models->each(function (string $model) {
41
            $this->line("Re-encrypting {$model}...");
42
            $this->reEncryptModels($model);
43
        });
44
45
        $this->info('Re-encrypting done!');
46
47
        return self::SUCCESS;
48
    }
49
50
    /**
51
     * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass
0 ignored issues
show
Documentation Bug introduced by
The doc comment class-string<\Illuminate\Database\Eloquent\Model> at position 0 could not be parsed: Unknown type name 'class-string' at position 0 in class-string<\Illuminate\Database\Eloquent\Model>.
Loading history...
52
     */
53
    protected function reEncryptModels(string $modelClass): void
54
    {
55
        $modelClass::unguarded(function () use ($modelClass) {
56
            $modelClass::query()
57
                ->when($this->option('with-trashed') && in_array(SoftDeletes::class, class_uses_recursive($modelClass), true), function ($query) {
58
                    $query->withTrashed();
59
                })
60
                ->eachById(
61
                    function (Model $model) {
62
                        if ($this->option('no-touch')) {
63
                            $model->timestamps = false;
64
                        }
65
66
                        // Set each encrypted attribute to trigger re-encryption
67
                        collect($model->getCasts())
0 ignored issues
show
Bug introduced by
$model->getCasts() of type array is incompatible with the type Illuminate\Contracts\Support\Arrayable expected by parameter $value of collect(). ( Ignorable by Annotation )

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

67
                        collect(/** @scrutinizer ignore-type */ $model->getCasts())
Loading history...
68
                            ->keys()
69
                            ->filter(fn ($key) => $model->hasCast($key, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']))
70
                            ->each(fn ($key) => $model->setAttribute($key, $model->getAttribute($key)));
71
72
                        if ($this->option('quietly')) {
73
                            $model->saveQuietly();
74
                        } else {
75
                            $model->save();
76
                        }
77
                    },
78
                    $this->option('chunk')
79
                );
80
        });
81
    }
82
83
    /**
84
     * Determine the models that should be re-encrypted.
85
     *
86
     * @return \Illuminate\Support\Collection<int, class-string<\Illuminate\Database\Eloquent\Model>>
87
     */
88
    protected function models(): Collection
89
    {
90
        if (!empty($this->option('model')) && !empty($this->option('except'))) {
91
            throw new \InvalidArgumentException('The --models and --except options cannot be combined.');
92
        }
93
94
        if (!empty($models = $this->option('model'))) {
95
            return collect($models)
0 ignored issues
show
Bug introduced by
$models of type array<integer,string> is incompatible with the type Illuminate\Contracts\Support\Arrayable expected by parameter $value of collect(). ( Ignorable by Annotation )

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

95
            return collect(/** @scrutinizer ignore-type */ $models)
Loading history...
96
                ->map(fn (string $modelClass): string => $this->normalizeModelClass($modelClass))
97
                ->each(function (string $modelClass): void {
98
                    if (!class_exists($modelClass)) {
99
                        throw new \InvalidArgumentException(sprintf('Model class %s does not exist.', $modelClass));
100
                    }
101
                    if (!is_a($modelClass, Model::class, true)) {
102
                        throw new \InvalidArgumentException(sprintf('Class %s is not a model.', $modelClass));
103
                    }
104
                });
105
        }
106
107
        if (!empty($except = $this->option('except'))) {
108
            $except = array_map(fn (string $modelClass): string => $this->normalizeModelClass($modelClass), $except);
109
        }
110
111
        return collect(Finder::create()->in($this->getModelsPath())->files()->name('*.php'))
0 ignored issues
show
Bug introduced by
Symfony\Component\Finder...>files()->name('*.php') of type Symfony\Component\Finder\Finder is incompatible with the type Illuminate\Contracts\Support\Arrayable expected by parameter $value of collect(). ( Ignorable by Annotation )

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

111
        return collect(/** @scrutinizer ignore-type */ Finder::create()->in($this->getModelsPath())->files()->name('*.php'))
Loading history...
112
            ->map(function (SplFileInfo $modelFile): string {
113
                $namespace = $this->laravel->getNamespace();
114
115
                return $namespace.str_replace(
116
                    [DIRECTORY_SEPARATOR, '.php'],
117
                    ['\\', ''],
118
                    Str::after($modelFile->getRealPath(), realpath(app_path()).DIRECTORY_SEPARATOR)
119
                );
120
            })
121
            ->when(!empty($except), fn (Collection $modelClasses): Collection => $modelClasses->reject(fn (string $modelClass) => in_array($modelClass, $except, true)))
122
            ->filter(fn (string $modelClass): bool => class_exists($modelClass) && is_a($modelClass, Model::class, true))
123
            ->reject(function (string $modelClass): bool {
124
                $model = new $modelClass();
125
126
                return collect($model->getCasts())
127
                    ->keys()
128
                    ->filter(fn (string $key): bool => $model->hasCast($key, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']))
129
                    ->isEmpty();
130
            })
131
            ->values();
132
    }
133
134
    /**
135
     * Get the path where models are located.
136
     *
137
     * @return string[]|string
138
     */
139
    protected function getModelsPath(): string|array
140
    {
141
        if (!empty($path = $this->option('path'))) {
142
            return collect($path)
0 ignored issues
show
Bug introduced by
$path of type array<integer,string> is incompatible with the type Illuminate\Contracts\Support\Arrayable expected by parameter $value of collect(). ( Ignorable by Annotation )

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

142
            return collect(/** @scrutinizer ignore-type */ $path)
Loading history...
143
                ->map(fn (string $path): string => base_path($path))
144
                ->each(function (string $path): void {
145
                    if (!is_dir($path)) {
146
                        throw new \InvalidArgumentException(sprintf('The path %s is not a directory.', $path));
147
                    }
148
                })
149
                ->all();
150
        }
151
152
        return is_dir($path = app_path('Models')) ? $path : app_path();
153
    }
154
155
    /**
156
     * Get the namespace of models.
157
     */
158
    protected function getModelsNamespace(): string
159
    {
160
        return is_dir(app_path('Models')) ? $this->laravel->getNamespace().'Models\\' : $this->laravel->getNamespace();
161
    }
162
163
    /**
164
     * Make sure the model class is a FQCN.
165
     */
166
    protected function normalizeModelClass(string $modelClass): string
167
    {
168
        return str_starts_with($modelClass, $this->getModelsNamespace()) || str_starts_with($modelClass, '\\'.$this->getModelsNamespace()) ? ltrim($modelClass, '\\') : $this->getModelsNamespace().$modelClass;
169
    }
170
}
171