Passed
Push — master ( 53eead...218820 )
by Tom
03:04
created

AbstractRestfulController::saveModel()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
1
<?php
2
3
namespace TomHart\Restful;
4
5
use BadMethodCallException;
6
use Exception;
7
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
8
use Illuminate\Contracts\Routing\ResponseFactory;
9
use Illuminate\Contracts\View\Factory;
10
use Illuminate\Database\Eloquent\Builder;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, TomHart\Restful\Builder.

Let’s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let’s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
11
use Illuminate\Database\Eloquent\Collection;
12
use Illuminate\Database\Eloquent\Model;
13
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
14
use Illuminate\Database\Eloquent\Relations\HasMany;
15
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
16
use Illuminate\Database\Eloquent\Relations\MorphMany;
17
use Illuminate\Database\Eloquent\Relations\MorphOneOrMany;
18
use Illuminate\Database\Eloquent\Relations\MorphToMany;
19
use Illuminate\Database\Eloquent\Relations\Relation;
20
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
21
use Illuminate\Foundation\Validation\ValidatesRequests;
22
use Illuminate\Http\JsonResponse;
23
use Illuminate\Http\RedirectResponse;
24
use Illuminate\Http\Request;
25
use Illuminate\Http\Response;
26
use Illuminate\Routing\Controller as BaseController;
27
use Illuminate\Routing\Redirector;
28
use Illuminate\Routing\Route;
29
use Illuminate\Support\Str;
30
use Illuminate\View\View;
31
use InvalidArgumentException;
32
use Symfony\Component\HttpFoundation\Response as SymResponse;
33
use TomHart\Restful\Concerns\HasLinks;
34
35
abstract class AbstractRestfulController extends BaseController
36
{
37
    use AuthorizesRequests;
38
    use ValidatesRequests;
39
40
    /**
41
     * The views to render.
42
     * @var string[]
43
     */
44
    protected $views = [];
45
46
    /**
47
     * What Model class to search for entities.
48
     * @return string
49
     */
50
    abstract protected function getModelClass(): string;
51
52
    /**
53
     * Return a list of matching models.
54
     * @param Request $request
55
     * @param callable|null $callback
56
     * @return JsonResponse|RedirectResponse|ResponseFactory|Response|Redirector
57
     */
58
    public function index(Request $request, ?callable $callback = null)
59
    {
60
        $builder = $this->createModelQueryBuilder();
61
62
        $input = collect($request->input())->except(config('restful.query_string_keys.page'))->toArray();
63
        foreach ($input as $column => $value) {
64
            $this->filterValue($builder, $column, $value);
65
        }
66
67
        // A callback can be used to manipulate
68
        // the pagination by a parent class.
69
        if ($callback) {
70
            $callback($builder);
71
        }
72
73
        $data = $builder->paginate(
74
            $request->input('limit', null),
75
            ['*'],
76
            config('restful.query_string_keys.page')
77
        );
78
79
        return $this->return($request, $data, 'index');
80
    }
81
82
    /**
83
     * Handles creating a model. The C of CRUD
84
     * @param Request $request
85
     * @return JsonResponse|RedirectResponse|ResponseFactory|Response|Redirector
86
     */
87
    public function store(Request $request)
88
    {
89
        $model = $this->newModelInstance();
90
91
        foreach ((array)$request->input() as $column => $value) {
92
            $model->$column = $value;
93
        }
94
95
        $this->saveModel($model);
96
97
        return $this->return($request, $this->findModel($model->getAttribute('id')), 'store');
98
    }
99
100
    /**
101
     * Shows a model. The R of CRUD.
102
     * @param Request $request
103
     * @param int $id
104
     * @return JsonResponse|RedirectResponse|ResponseFactory|Response|Redirector
105
     */
106
    public function show(Request $request, $id)
107
    {
108
        $model = $this->findModel($id, $request);
109
110
        $model = $this->iterateThroughChildren($model, $request);
111
112
        return $this->return($request, $model, 'show');
113
    }
114
115
    /**
116
     * Update a record. The U of CRUD.
117
     * @param Request $request
118
     * @param int $id
119
     * @return JsonResponse|RedirectResponse|ResponseFactory|Response|Redirector
120
     */
121
    public function update(Request $request, $id)
122
    {
123
        $model = $this->findModel($id);
124
125
        foreach ((array)$request->input() as $column => $value) {
126
            $model->$column = $value;
127
        }
128
129
        $this->saveModel($model);
130
131
        return $this->return($request, $model, 'update');
132
    }
133
134
    /**
135
     * Destroy a model. The D of CRUD.
136
     * @param Request $request
137
     * @param int $id
138
     * @return ResponseFactory|Response
139
     * @throws Exception
140
     */
141
    public function destroy(Request $request, $id)
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
142
    {
143
        $model = $this->findModel($id);
144
145
        $model->delete();
146
147
        return response(null, SymResponse::HTTP_NO_CONTENT);
148
    }
149
150
151
    /**
152
     * Return the _links. The O of CRUD.....
153
     * @param Request $request
154
     * @return ResponseFactory|JsonResponse|RedirectResponse|Response|Redirector
155
     */
156
    public function options(Request $request)
157
    {
158
        $class = $this->newModelInstance();
159
160
        if (!($class instanceof HasLinks)) {
161
            throw new InvalidArgumentException('OPTIONS only works for models implementing HasLinks');
162
        }
163
164
        foreach ($request->input() as $key => $value) {
165
            $class->$key = $value;
166
        }
167
168
        return $this->return($request, $class->buildLinks(), 'options');
169
    }
170
171
    /**
172
     * Generate a new query builder for the model.
173
     * @return Builder
174
     */
175
    protected function createModelQueryBuilder(): Builder
176
    {
177
        $class = $this->newModelInstance();
178
179
        return $class->newQuery();
180
    }
181
182
    /**
183
     * Creates a new model instance.
184
     * @return Model
185
     */
186
    protected function newModelInstance(): Model
187
    {
188
        $classFQDN = $this->getModelClass();
189
190
        return app($classFQDN);
191
    }
192
193
    /**
194
     * Looks for an "extra" param in the route, and if it exists, looks for relationships
195
     * based on that route.
196
     * @param Model $model
197
     * @param Request $request
198
     * @return LengthAwarePaginator|Collection|Model|mixed
199
     * @throws BadMethodCallException
200
     */
201
    protected function iterateThroughChildren(Model $model, Request $request)
202
    {
203
        // If there's no route or extra param, just return.
204
        if (!$request->route() ||
205
            !($request->route() instanceof Route) ||
206
            !$request->route()->parameter('extra')) {
207
            return $model;
208
        }
209
210
        $parts = array_filter(explode('/', (string)$request->route()->parameter('extra')));
211
212
        // Loop through the parts.
213
        foreach ($parts as $part) {
214
            // Look for an array accessor, "children[5]" for example.
215
            preg_match('/\[(\d+)]$/', $part, $matches);
216
            $offset = false;
217
218
            // If one was found, save the offset and remove it from $part.
219
            if (!empty($matches[0])) {
220
                $part = str_replace(array_shift($matches), '', $part);
221
                $offset = array_shift($matches);
222
            }
223
224
            $model = $model->$part();
225
226
            // If it's a relationship, see if it's paginate-able.
227
            if (stripos(get_class($model), 'Many') !== false) {
228
                /** @var BelongsToMany|HasMany|HasOneOrMany|MorphMany|MorphOneOrMany|MorphToMany $model */
229
                $model = $model->paginate();
0 ignored issues
show
Bug introduced by
The method paginate does only exist in Illuminate\Database\Eloq...Relations\BelongsToMany, but not in Illuminate\Database\Eloq...\Relations\HasOneOrMany.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
230
            } elseif ($model instanceof Relation) {
231
                $model = $model->getResults();
232
            }
233
234
            // If there is an offset, get it.
235
            if ($offset !== false) {
236
                $model = $model[$offset];
237
            }
238
        }
239
240
        return $model;
241
    }
242
243
    /**
244
     * Finds the model instance.
245
     * @param int|string $id
246
     * @param Request|null $request
247
     * @return Model
248
     */
249
    protected function findModel($id, Request $request = null): Model
250
    {
251
        /** @var Model|Builder $class */
252
        $class = $this->newModelInstance();
253
254
        if ($request) {
255
            $this->preloadRelationships($class, $request);
0 ignored issues
show
Bug introduced by
It seems like $class can also be of type object<Illuminate\Database\Eloquent\Builder>; however, TomHart\Restful\Abstract...:preloadRelationships() does only seem to accept object<Illuminate\Database\Eloquent\Model>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
256
        }
257
258
        $class = $class->findOrFail($id);
0 ignored issues
show
Bug introduced by
The method findOrFail does only exist in Illuminate\Database\Eloquent\Builder, but not in Illuminate\Database\Eloquent\Model.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
259
        return $class;
260
    }
261
262
    /**
263
     * Apply causes to the builder.
264
     * @param Builder $builder
265
     * @param string $column
266
     * @param mixed $value
267
     */
268
    protected function filterValue(Builder $builder, string $column, $value): void
269
    {
270
        $builder->where($column, $value);
271
    }
272
273
    /**
274
     * Build and return a response.
275
     * @param Request $request
276
     * @param mixed $data
277
     * @param string $method
278
     * @return JsonResponse|ResponseFactory|Response|RedirectResponse|Redirector
279
     */
280
    protected function return(Request $request, $data, string $method)
281
    {
282
        $status = SymResponse::HTTP_OK;
283
        switch ($method) {
284
            case 'store':
285
                $status = SymResponse::HTTP_CREATED;
286
                break;
287
        }
288
289
        if ($request->wantsJson()) {
290
            return app(ResponseFactory::class)->json($data, $status);
291
        }
292
293
        if (isset($this->views[$method]) && app(Factory::class)->exists($this->views[$method])) {
294
            /** @var View $view */
295
            $view = view(
296
                $this->views[$method],
297
                [
298
                    'data' => $data
299
                ]
300
            );
301
302
            return response($view, $status);
303
        }
304
305
        switch ($method) {
306
            case 'store':
307
            case 'update':
308
                // If it's store/update, and the user isn't asking for JSON, we want to
309
                // try and redirect them to the related show record page.
310
                if (($redirect = $this->redirectToShowRoute($request, $data))) {
311
                    return $redirect;
312
                }
313
        }
314
315
        return app(ResponseFactory::class)->json($data, $status);
316
    }
317
318
    /**
319
     * Redirects to the show route for the model if one exists.
320
     * @param Request $request
321
     * @param mixed $data
322
     * @return RedirectResponse|Redirector|null
323
     */
324
    protected function redirectToShowRoute(Request $request, $data)
325
    {
326
        /** @var Route|null $route */
327
        $route = $request->route();
328
        if (!$route) {
329
            return null;
330
        }
331
332
        $name = $route->getName();
333
        if (!$name) {
334
            return null;
335
        }
336
337
        $exploded = explode('.', $name);
338
        array_pop($exploded);
339
        $topLevel = array_pop($exploded);
340
341
        if (!$topLevel) {
342
            return null;
343
        }
344
345
        $key = Str::singular(str_replace('-', '_', $topLevel));
346
347
        return redirect(
348
            route(
349
                "$topLevel.show",
350
                [
351
                    $key => $data->id
352
                ]
353
            )
354
        );
355
    }
356
357
    /**
358
     * Preload any relationships required.
359
     * @param Model $class
360
     * @param Request $request
361
     * @return void
362
     */
363
    protected function preloadRelationships(Model &$class, Request $request): void
364
    {
365
        $headers = $request->headers;
366
        if (!$headers) {
367
            return;
368
        }
369
370
        $header = $headers->get('X-Load-Relationship');
371
        if (!$header || empty($header)) {
372
            return;
373
        }
374
375
        $relationships = array_filter(explode(',', $header));
376
377
        $class = $class->with($relationships);
378
    }
379
380
    /**
381
     * Save a model, either from a store or an update.
382
     * @param Model $model
383
     * @return bool
384
     */
385
    private function saveModel(Model $model)
386
    {
387
        return $model->save();
388
    }
389
}
390