Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add "Smart Playlist" backend logics (#849)
This commit prepares the backend for the "Smart Playlist" feature.
- Loading branch information
Showing
9 changed files
with
682 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
<?php | ||
|
||
namespace App\Factories; | ||
|
||
use App\Models\Rule; | ||
use Carbon\Carbon; | ||
use InvalidArgumentException; | ||
|
||
class SmartPlaylistRuleParameterFactory | ||
{ | ||
public function createParameters(string $model, string $operator, array $value): array | ||
{ | ||
switch ($operator) { | ||
case Rule::OPERATOR_BEGINS_WITH: | ||
return [$model, 'LIKE', "{$value[0]}%"]; | ||
case Rule::OPERATOR_ENDS_WITH: | ||
return [$model, 'LIKE', "%{$value[0]}"]; | ||
case Rule::OPERATOR_IS: | ||
return [$model, '=', $value[0]]; | ||
case Rule::OPERATOR_NOT_IN_LAST: | ||
return [$model, '<', (new Carbon())->subDay($value[0])]; | ||
case Rule::OPERATOR_NOT_CONTAIN: | ||
return [$model, 'NOT LIKE', "%{$value[0]}%"]; | ||
case Rule::OPERATOR_IS_NOT: | ||
return [$model, '<>', $value[0]]; | ||
case Rule::OPERATOR_IS_LESS_THAN: | ||
return [$model, '<', $value[0]]; | ||
case Rule::OPERATOR_IS_GREATER_THAN: | ||
return [$model, '>', $value[0]]; | ||
case Rule::OPERATOR_IS_BETWEEN: | ||
return [$model, $value]; | ||
case Rule::OPERATOR_IN_LAST: | ||
return [$model, '>=', (new Carbon())->subDay($value[0])]; | ||
case Rule::OPERATOR_CONTAINS: | ||
return [$model, 'LIKE', "%{$value[0]}%"]; | ||
default: | ||
// should never reach here actually | ||
throw new InvalidArgumentException('Invalid operator.'); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
<?php | ||
|
||
namespace App\Models; | ||
|
||
use App\Factories\SmartPlaylistRuleParameterFactory; | ||
use Illuminate\Database\Eloquent\Builder; | ||
use InvalidArgumentException; | ||
|
||
class Rule | ||
{ | ||
private const LOGIC_OR = 'or'; | ||
private const LOGIC_AND = 'and'; | ||
|
||
private const VALID_LOGICS = [ | ||
self::LOGIC_AND, | ||
self::LOGIC_OR, | ||
]; | ||
|
||
public const OPERATOR_IS = 'is'; | ||
public const OPERATOR_IS_NOT = 'isNot'; | ||
public const OPERATOR_CONTAINS = 'contains'; | ||
public const OPERATOR_NOT_CONTAIN = 'notContain'; | ||
public const OPERATOR_IS_BETWEEN = 'isBetween'; | ||
public const OPERATOR_IS_GREATER_THAN = 'isGreaterThan'; | ||
public const OPERATOR_IS_LESS_THAN = 'isLessThan'; | ||
public const OPERATOR_BEGINS_WITH = 'beginsWith'; | ||
public const OPERATOR_ENDS_WITH = 'endsWith'; | ||
public const OPERATOR_IN_LAST = 'inLast'; | ||
public const OPERATOR_NOT_IN_LAST = 'notInLast'; | ||
|
||
public const VALID_OPERATORS = [ | ||
self::OPERATOR_BEGINS_WITH, | ||
self::OPERATOR_CONTAINS, | ||
self::OPERATOR_ENDS_WITH, | ||
self::OPERATOR_IN_LAST, | ||
self::OPERATOR_IS, | ||
self::OPERATOR_IS_BETWEEN, | ||
self::OPERATOR_IS_GREATER_THAN, | ||
self::OPERATOR_IS_LESS_THAN, | ||
self::OPERATOR_IS_NOT, | ||
self::OPERATOR_NOT_CONTAIN, | ||
self::OPERATOR_NOT_IN_LAST, | ||
]; | ||
|
||
private $operator; | ||
private $logic; | ||
private $value; | ||
private $model; | ||
private $parameterFactory; | ||
|
||
private function __construct(array $config) | ||
{ | ||
$this->validateLogic($config['logic']); | ||
$this->validateOperator($config['operator']); | ||
|
||
$this->logic = $config['logic']; | ||
$this->value = $config['value']; | ||
$this->model = $config['model']; | ||
$this->operator = $config['operator']; | ||
|
||
$this->parameterFactory = new SmartPlaylistRuleParameterFactory(); | ||
} | ||
|
||
public static function create(array $config): self | ||
{ | ||
return new static($config); | ||
} | ||
|
||
public function build(Builder $query, ?string $model = null): Builder | ||
{ | ||
if (!$model) { | ||
$model = $this->model; | ||
} | ||
|
||
$fragments = explode('.', $model, 2); | ||
|
||
if (count($fragments) === 1) { | ||
return $query->{$this->resolveLogic()}( | ||
...$this->parameterFactory->createParameters($model, $this->operator, $this->value) | ||
); | ||
} | ||
|
||
// If the model is something like 'artist.name' or 'interactions.play_count', we have a subquery to deal with. | ||
// We handle such a case with a recursive call which, in theory, should work with an unlimited level of nesting, | ||
// though in practice we only have one level max. | ||
$subQueryLogic = self::LOGIC_AND ? 'whereHas' : 'orWhereHas'; | ||
|
||
return $query->$subQueryLogic($fragments[0], function (Builder $subQuery) use ($fragments): Builder { | ||
return $this->build($subQuery, $fragments[1]); | ||
}); | ||
} | ||
|
||
private function resolveLogic(): string | ||
{ | ||
if ($this->operator === self::OPERATOR_IS_BETWEEN) { | ||
return $this->logic === self::LOGIC_AND ? 'whereBetween' : 'orWhereBetween'; | ||
} | ||
|
||
return $this->logic === self::LOGIC_AND ? 'where' : 'orWhere'; | ||
} | ||
|
||
private function validateLogic(string $logic): void | ||
{ | ||
if (!in_array($logic, self::VALID_LOGICS, true)) { | ||
throw new InvalidArgumentException( | ||
sprintf( | ||
'%s is not a valid value for logic. Valid values are: %s', $logic, implode(', ', self::VALID_LOGICS) | ||
) | ||
); | ||
} | ||
} | ||
|
||
private function validateOperator(string $operator): void | ||
{ | ||
if (!in_array($operator, self::VALID_OPERATORS, true)) { | ||
throw new InvalidArgumentException( | ||
sprintf( | ||
'%s is not a valid value for operators. Valid values are: %s', | ||
$operator, | ||
implode(', ', self::VALID_OPERATORS) | ||
) | ||
); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
<?php | ||
|
||
namespace App\Services; | ||
|
||
use App\Models\Playlist; | ||
use App\Models\Rule; | ||
use App\Models\Song; | ||
use App\Models\User; | ||
use App\Repositories\SongRepository; | ||
use Illuminate\Database\Eloquent\Builder; | ||
use Illuminate\Database\Eloquent\Collection; | ||
use RuntimeException; | ||
|
||
class SmartPlaylistService | ||
{ | ||
private const RULE_REQUIRES_USER_PREFIXES = ['interactions.']; | ||
|
||
private $songRepository; | ||
|
||
public function __construct(SongRepository $songRepository) | ||
{ | ||
$this->songRepository = $songRepository; | ||
} | ||
|
||
public function getSongs(Playlist $playlist): Collection | ||
{ | ||
if (!$playlist->is_smart) { | ||
throw new RuntimeException($playlist->name.' is not a smart playlist.'); | ||
} | ||
|
||
$rules = $this->addRequiresUserRules($playlist->rules, $playlist->user); | ||
|
||
return $this->buildQueryForRules($rules)->get(); | ||
} | ||
|
||
public function buildQueryForRules(array $rules): Builder | ||
{ | ||
return tap(Song::query(), static function (Builder $query) use ($rules): Builder { | ||
foreach ($rules as $config) { | ||
$query = Rule::create($config)->build($query); | ||
} | ||
|
||
return $query; | ||
}); | ||
} | ||
|
||
/** | ||
* Some rules need to be driven by an additional "user" factor, for example play count, liked, or last played | ||
* (basically everything related to interactions). | ||
* For those, we create an additional "user_id" rule. | ||
* | ||
* @param string[] $rules | ||
*/ | ||
private function addRequiresUserRules(array $rules, User $user): array | ||
{ | ||
$additionalRules = []; | ||
|
||
foreach ($rules as $rule) { | ||
foreach (self::RULE_REQUIRES_USER_PREFIXES as $modelPrefix) { | ||
if (starts_with($rule['model'], $modelPrefix)) { | ||
$additionalRules[] = $this->createRequireUserRule($user, $modelPrefix); | ||
} | ||
} | ||
} | ||
|
||
// make sure all those additional rules are unique. | ||
return array_merge($rules, collect($additionalRules)->unique('model')->all()); | ||
} | ||
|
||
private function createRequireUserRule(User $user, string $modelPrefix): array | ||
{ | ||
return [ | ||
'logic' => 'and', | ||
'model' => $modelPrefix.'user_id', | ||
'operator' => 'is', | ||
'value' => [$user->id], | ||
]; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
32 changes: 32 additions & 0 deletions
32
database/migrations/2018_11_03_182520_add_rules_into_playlists.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
<?php | ||
|
||
use Illuminate\Database\Migrations\Migration; | ||
use Illuminate\Database\Schema\Blueprint; | ||
use Illuminate\Support\Facades\Schema; | ||
|
||
class AddRulesIntoPlaylists extends Migration | ||
{ | ||
/** | ||
* Run the migrations. | ||
* | ||
* @return void | ||
*/ | ||
public function up(): void | ||
{ | ||
Schema::table('playlists', static function (Blueprint $table): void { | ||
$table->text('rules')->after('name')->nullable(); | ||
}); | ||
} | ||
|
||
/** | ||
* Reverse the migrations. | ||
* | ||
* @return void | ||
*/ | ||
public function down(): void | ||
{ | ||
Schema::table('playlists', static function (Blueprint $table): void { | ||
$table->dropColumn('rules'); | ||
}); | ||
} | ||
} |
Oops, something went wrong.