Skip to content

Commit

Permalink
refactor: code quality improvements to expandables functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
Luke committed Apr 3, 2022
1 parent 8d10a00 commit 752ab38
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 59 deletions.
11 changes: 0 additions & 11 deletions src/app/Contracts/HasExpandableRelations.php

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,61 +1,62 @@
<?php


namespace Bluewing\Concerns;
namespace Bluewing\Expandables;

use Bluewing\Contracts\HasExpandableRelations;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model as EloquentModel;
use Illuminate\Support\Arr;
use ReflectionClass;
use ReflectionException;

/**
* A trait which provides the functionality of the `expands` scope to traited models.
*
* @package Bluewing\Concerns
* @package Bluewing\Expandables
*/
trait AllowsExpansion
{
/**
* Retrieves the `Relations` instance for the current model.
* @var string - The query string parameter from which expandable requests are extracted.
*/
private string $expandQueryParameter = 'expand';

/**
* Retrieves an `ExpandablesInterface` instance for the current model that defines the current expandables for
* the model and whether the user performing the expansion request is authorized to do so.
*
* @return mixed - An instance of the `Relations` class associated with the Eloquent model that is being fetched.
* @return BaseExpandables - An instance of the `BaseExpandables` associated with the Eloquent model that
* is being fetched.
*
* @throws ReflectionException - Reflection is needed to compute the short name of the Eloquent class to match
* with the associated `Relations` class. If this is unable to be completed, a `ReflectionException` will be thrown.
* with the associated `ExpandablesInterface` class. If this is unable to be completed, a `ReflectionException` will
* be thrown.
*/
public function expandableRelations()
public function expandableInstance(): BaseExpandables
{
$className = (new ReflectionClass($this))->getShortName();
$relationsClassName = config('bluewing.relations.namespace') . '\\' . $className . 'Relations';

return new $relationsClassName();
$relationClassName = config('bluewing.relations.namespace') . '\\' . getShortName(self::class) . 'Expandables';
return new $relationClassName;
}

/**
* Fetches the valid expandable relations for the primary model, as requested by the user.
* Fetches the valid expandables for the primary model, as requested by the user. If no `expand` parameter is
* present in the request, return an empty `array`.
*
* @return array - An `array` of expandable relations that can be safely retrieved from the database alongside
* the primary model.
*
* @throws ReflectionException - Reflection is needed to compute the short name of the Eloquent class to match
* with the associated `Relations` class. If this is unable to be completed, a `ReflectionException` will be thrown.
*/
public function getExpandableRelations(): array
public function expandableRelations(): array
{
if (!request()->has('expand')) return [];

$requestedExpansions = Arr::wrap(request()->query('expand'));
$expansions = [];

foreach ($requestedExpansions as $requestedExpansion) {
if ($this->checkExpansionIsValid($requestedExpansion)) {
$expansions[] = $requestedExpansion;
}
if (request()->missing($this->expandQueryParameter)) {
return [];
}

return $expansions;
return array_filter(
Arr::wrap(request()->query($this->expandQueryParameter)),
fn($re) => $this->isExpansionValid($re)
);
}

/**
Expand All @@ -71,43 +72,33 @@ public function getExpandableRelations(): array
* @throws ReflectionException - Reflection is needed to compute the short name of the Eloquent class to match
* with the associated `Relations` class. If this is unable to be completed, a `ReflectionException` will be thrown.
*/
private function checkExpansionIsValid(string $expansion): bool
private function isExpansionValid(string $expansion): bool
{
$expansionArray = explode('.', $expansion);

// Precondition preventing the expansion array from being more than four nested expansions deep.
if (count($expansionArray) > 4) {
return false;
}

$model = $this;

foreach ($expansionArray as $expansionString) {
foreach ($expansionArray as $expansionComponent) {
// Each database model must itself implement `HasExpandableRelations`.
if (!($model instanceof HasExpandableRelations)) {
return false;
}
// If the expansion component is present in the current model's expandable instance, then the expansion
// is allowed. Internally, this checks both for the presence of the model in the `always` method, as well
// as checking if the expansion is its own method which makes a static check over whether authorization to
// the expansion is allowed.
$model = $model->expandableInstance()->getExpandableModel($expansionComponent);

if (in_array($expansionString, array_keys($model->expandableRelations()->always()))) {
$nextClass = $model->expandableRelations()->always()[$expansionString];
$model = new $nextClass();

continue;
}

if (method_exists($model->expandableRelations(), $expansionString)) {
list('model' => $nextClass, 'isAuthorized' => $authorizationFn) = $model->expandableRelations()
->{$expansionString}();

if (!$authorizationFn()) {
return false;
}

$model = new $nextClass();
if (!is_null($model)) {
continue;
}

return false;
}

return true;
}

Expand All @@ -125,9 +116,9 @@ private function checkExpansionIsValid(string $expansion): bool
*/
public function scopeExpands(Builder $query): Builder
{
$expandableRelations = $this->getExpandableRelations();
$expandableRelations = $this->expandableRelations();

if (empty($expandableRelations) || !method_exists($query->getModel(), 'expandableRelations')) {
if (empty($expandableRelations) || !method_exists($query->getModel(), 'expandableInstance')) {
return $query;
}

Expand All @@ -136,7 +127,7 @@ public function scopeExpands(Builder $query): Builder

/**
* Override the routing binding resolution to explicitly capture any expandable objects requested, by binding to
* the local `expands` scope defined in `AllowsExpansion` trait.
* the local `expands` scope defined in this `AllowsExpansion` trait.
*
* @see UrlRoutable
*
Expand Down
65 changes: 65 additions & 0 deletions src/app/Expandables/BaseExpandables.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace Bluewing\Expandables;

use Illuminate\Database\Eloquent\Model;

abstract class BaseExpandables implements ExpandablesInterface
{
/**
* All implementors of the `ExpandablesInterface` must provide an `always` method which returns an associative
* `array` of expandables which may always be expanded, regardless of any condition.
*
* @return array - An associative `array` of expandables which may always be expanded, regardless of any condition.
*/
public abstract function always(): array;

/**
* Gets the `Model` associated with the expansion identifier provided, if allowed and/or authorized. If the expansion
* is invalid, not allowed, or not authorized, `null` will be returned.
*
* @param string $expansionIdentifier - The component from an expandable query string that is aiding in the retrieval
* of an expandable model.
*
* @return Model|null - The `Model` associated with the expansion identifier, if it exists or is authorized to be
* retrieved. If the expansion is not allowed or authorized, `null` wil be returned.
*/
public function getExpandableModel(string $expansionIdentifier): ?Model
{
if ($this->isExpansionAllowedAlways($expansionIdentifier)) {
return new ($this->always()[$expansionIdentifier]);

} else if ($this->isExpansionAllowedConditionally($expansionIdentifier)) {
return new ($this->$expansionIdentifier());
}
return null;
}

/**
* Checks if the provided expansion identifier is present in the associative `array` returned by tje `always` method.
*
* @param string $expansionIdentifier - The component from an expandable query string that is aiding in the retrieval
* of an expandable model.
*
* @return bool - `true` if the expansion identifier is present in the `always` method.
*/
private function isExpansionAllowedAlways(string $expansionIdentifier): bool
{
return in_array($expansionIdentifier, array_keys($this->always()));
}

/**
* Checks if the provided expansion identifier exists as a method, and if that method also returns a string
* representing the `Model`.
*
* @param string $expansionIdentifier - The component from an expandable query string that is aiding in the retrieval
* of an expandable model.
*
* @return bool - `true` if the expansion identifier both exists as a method, and that method returns a string
* representing the `Model`.
*/
private function isExpansionAllowedConditionally(string $expansionIdentifier): bool
{
return method_exists($this, $expansionIdentifier) && !is_null($this->$expansionIdentifier());
}
}
13 changes: 13 additions & 0 deletions src/app/Expandables/ExpandablesInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Bluewing\Expandables;

/**
* An interface that defines the guaranteed methods present on an `Expandables` class.
*
* @package Bluewing\Expandables
*/
interface ExpandablesInterface
{
public function always(): array;
}
12 changes: 12 additions & 0 deletions src/app/Expandables/HasExpandableRelations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace Bluewing\Expandables;

interface HasExpandableRelations
{
/**
* Fetches the associated `Expandables` class for the current Eloquent model.
*/
public function expandableInstance();
public function expandableRelations();
}
19 changes: 18 additions & 1 deletion src/app/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,25 @@
*/
function createModel(string $model): Model
{
$class = '\\'.ltrim($model, '\\');
$class = '\\' . ltrim($model, '\\');

return new $class;
}
}

if (!function_exists('getShortName')) {

/**
* Retrieves the short class name for the provided class string. This removes any namespacing information. For
* example: passing a class `Bluewing\Eloquent\Model` will return `Model`. This is accomplished by instantiating
* a `ReflectionClass` instance for the provided class.
*
* @return string - The short class name for the fully-qualified name
*
* @throws ReflectionException - As this function uses the Reflection API, a `ReflectionException` is always possible.
*/
function getShortName(string $class): string
{
return (new ReflectionClass($class))->getShortName();
}
}

0 comments on commit 752ab38

Please sign in to comment.