Skip to content
This repository has been archived by the owner on Jun 9, 2020. It is now read-only.

Commit

Permalink
created sorting functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
zbiller committed Jan 23, 2019
1 parent 586928d commit 007873d
Show file tree
Hide file tree
Showing 4 changed files with 392 additions and 0 deletions.
50 changes: 50 additions & 0 deletions composer.json
@@ -0,0 +1,50 @@
{
"name": "zbiller/laravel-sort",
"description": "Sort Eloquent model records by their attributes or relationships",
"license": "MIT",
"keywords": [
"laravel",
"sort",
"order",
"eloquent",
"model",
"relations"
],
"minimum-stability": "dev",
"authors": [
{
"name": "Andrei Badea",
"email": "zbiller@gmail.com",
"role": "Developer"
}
],
"require": {
"php": "^7.1.3",
"illuminate/contracts": "~5.7.0",
"illuminate/support": "~5.7.0",
"illuminate/database": "~5.7.0"
},
"require-dev": {
"orchestra/testbench": "~3.7.0",
"phpunit/phpunit": "~7.0"
},
"autoload": {
"psr-4": {
"Zbiller\\Sort\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Zbiller\\Sort\\Tests\\": "tests/"
},
"classmap": [
"tests/TestCase.php"
]
},
"scripts": {
"test": "phpunit"
},
"config": {
"sort-packages": true
}
}
39 changes: 39 additions & 0 deletions src/Exceptions/SortException.php
@@ -0,0 +1,39 @@
<?php

namespace Zbiller\Sort\Exceptions;

use Zbiller\Sort\Objects\Sort;

class SortException extends \Exception
{
/**
* The exception to be thrown when an invalid direction is supplied as the argument.
*
* @param string $direction
* @return static
*/
public static function invalidDirectionSupplied($direction)
{
return new static(
'Invalid sorting direction.' . PHP_EOL .
'You provided the direction: "' . $direction . '".' . PHP_EOL .
'Please provide one of these directions: ' . implode('|', Sort::$directions) . '.'
);
}

/**
* The exception to be thrown when trying to sort by an invalid relation type.
*
* @param string$relation
* @param string $type
* @return static
* @internal param string $direction
*/
public static function wrongRelationToSort($relation, $type)
{
return new static(
'You can only sort records by the following relations: HasOne, BelongsTo.' . PHP_EOL .
'The relation "' . $relation . '" is of type ' . $type . ' and cannot be sorted by.'
);
}
}
58 changes: 58 additions & 0 deletions src/Objects/Sort.php
@@ -0,0 +1,58 @@
<?php

namespace Zbiller\Sort\Objects;

abstract class Sort
{
/**
* In case you apply the sorted scope on a model without an Zbiller\Sort\Objects\Sort instance as it's parameter.
* This constant will act as the default sort field.
* Meaning that the IsSortable trait will look for this request name when deciding what to sort by.
*
* @const
*/
const DEFAULT_SORT_FIELD = 'sort';

/**
* In case you apply the sorted scope on a model without an Zbiller\Sort\Objects\Sort instance as it's parameter.
* This constant will act as the default sorting direction field.
* Meaning that the IsSortable trait will look for this request name when deciding the sorting direction.
*
* @const
*/
const DEFAULT_DIRECTION_FIELD = 'direction';

/**
* The sorting directions available.
*
* @const
*/
const DIRECTION_ASC = 'asc';
const DIRECTION_DESC = 'desc';
const DIRECTION_RANDOM = 'random';

/**
* List of valid sorting directions.
*
* @var array
*/
public static $directions = [
self::DIRECTION_ASC,
self::DIRECTION_DESC,
self::DIRECTION_RANDOM,
];

/**
* Get the request field name to sort by.
*
* @return string
*/
abstract public function field();

/**
* Get the direction to sort by.
*
* @return string
*/
abstract public function direction();
}
245 changes: 245 additions & 0 deletions src/Traits/IsSortable.php
@@ -0,0 +1,245 @@
<?php

namespace Zbiller\Sort\Traits;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Zbiller\Sort\Exceptions\SortException;
use Zbiller\Sort\Objects\Sort;

trait IsSortable
{
/**
* @var array
*/
protected $sort = [
/**
* The query builder instance from the Sorted scope.
*
* @var Builder
*/
'query' => null,

/**
* The data applying the "sorted" scope on a model.
*
* @var array
*/
'data' => null,

/**
* The Zbiller\Sort\Objects\Sort instance.
* This is used to get the sorting rules, just like a request.
*
* @var Sort
*/
'instance' => null,

/**
* The field to sort by.
*
* @var string
*/
'field' => Sort::DEFAULT_SORT_FIELD,

/**
* The direction to sort in.
*
* @var string
*/
'direction' => Sort::DEFAULT_DIRECTION_FIELD,
];

/**
* The filter scope.
* Should be called on the model when building the query.
*
* @param Builder $query
* @param array $data
* @param Sort $sort
*/
public function scopeSorted($query, array $data, Sort $sort = null)
{
$this->sort['query'] = $query;
$this->sort['data'] = $data;
$this->sort['instance'] = $sort;

$this->setFieldToSortBy();
$this->setDirectionToSortIn();

if ($this->isValidSort()) {
$this->checkSortingDirection();

switch ($this->sort['data'][$this->sort['direction']]) {
case Sort::DIRECTION_RANDOM:
$this->sort['query']->inRandomOrder();
break;
default:
if ($this->shouldSortByRelation()) {
$this->sortByRelation();
} else {
$this->sortNormally();
}
}
}
}

/**
* Verify if all sorting conditions are met.
*
* @return bool
*/
protected function isValidSort()
{
return
isset($this->sort['data'][$this->sort['field']]) &&
isset($this->sort['data'][$this->sort['direction']]);
}

/**
* Set the sort field if an Zbiller\Sort\Objects\Sort instance has been provided as a parameter for the sorted scope.
*
* @return void
*/
protected function setFieldToSortBy()
{
if ($this->sort['instance'] instanceof Sort) {
$this->sort['field'] = $this->sort['instance']->field();
}
}

/**
* Set the sort direction if an Zbiller\Sort\Objects\Sort instance has been provided as a parameter for the sorted scope.
*
* @return void
*/
protected function setDirectionToSortIn()
{
if ($this->sort['instance'] instanceof Sort) {
$this->sort['direction'] = $this->sort['instance']->direction();
}
}

/**
* Sort model records using columns from the model's table itself.
*
* @return void
*/
protected function sortNormally()
{
$this->sort['query']->orderBy(
$this->sort['data'][$this->sort['field']],
$this->sort['data'][$this->sort['direction']]
);
}

/**
* Sort model records using columns from the model relation's table.
*
* @return void
*/
protected function sortByRelation()
{
$parts = explode('.', $this->sort['data'][$this->sort['field']]);
$models = [];

if (count($parts) > 2) {
$field = array_pop($parts);
$relations = $parts;
} else {
$field = array_last($parts);
$relations = (array)array_first($parts);
}

foreach ($relations as $index => $relation) {
$previousModel = $this;

if (isset($models[$index - 1])) {
$previousModel = $models[$index - 1];
}

$this->checkRelationToSortBy($previousModel, $relation);

$models[] = $previousModel->{$relation}()->getModel();

$modelTable = $previousModel->getTable();
$relationTable = $previousModel->{$relation}()->getModel()->getTable();
$foreignKey = $previousModel->{$relation}() instanceof HasOne ?
$previousModel->{$relation}()->getForeignKeyName() :
$previousModel->{$relation}()->getForeignKey();

if (!$this->alreadyJoinedForSorting($relationTable)) {
switch (get_class($previousModel->{$relation}())) {
case BelongsTo::class:
$this->sort['query']->join($relationTable, $modelTable . '.' . $foreignKey, '=', $relationTable . '.id');
break;
case HasOne::class:
$this->sort['query']->join($relationTable, $modelTable . '.id', '=', $relationTable . '.' . $foreignKey);
break;
}
}
}

$alias = implode('_', $relations) . '_' . $field;

if (isset($relationTable)) {
$this->sort['query']->addSelect([
$this->getTable() . '.*',
$relationTable . '.' . $field . ' AS ' . $alias
]);
}

$this->sort['query']->orderBy(
$alias, $this->sort['data'][$this->sort['direction']]
);
}

/**
* @return bool
*/
protected function shouldSortByRelation()
{
return str_contains($this->sort['data'][$this->sort['field']], '.');
}

/**
* Verify if the desired join exists already, possibly included by a global scope.
*
* @param string $table
*
* @return bool
*/
protected function alreadyJoinedForSorting($table)
{
return str_contains(strtolower($this->sort['query']->toSql()), 'join `' . $table . '`');
}

/**
* Verify if the direction provided matches one of the directions from:
* Zbiller\Sort\Objects\Sort::$directions.
*
* @return void
*/
protected function checkSortingDirection()
{
if (!in_array(strtolower($this->sort['data'][$this->sort['direction']]), array_map('strtolower', Sort::$directions))) {
throw SortException::invalidDirectionSupplied($this->sort['data'][$this->sort['direction']]);
}
}

/**
* Verify if the desired relation to sort by is one of: HasOne or BelongsTo.
* Sorting by "many" relations or "morph" ones is not possible.
*
* @param Model $model
* @param string $relation
*/
protected function checkRelationToSortBy(Model $model, $relation)
{
if (!($model->{$relation}() instanceof HasOne) && !($model->{$relation}() instanceof BelongsTo)) {
throw SortException::wrongRelationToSort($relation, get_class($model->{$relation}()));
}
}
}

0 comments on commit 007873d

Please sign in to comment.