Skip to content

Commit

Permalink
Converted taken_at_min and taken_at_max into a relation.
Browse files Browse the repository at this point in the history
  • Loading branch information
nagmat84 committed Sep 7, 2021
1 parent 343b79a commit cf75b97
Show file tree
Hide file tree
Showing 3 changed files with 269 additions and 51 deletions.
55 changes: 55 additions & 0 deletions app/Assets/CarbonSpan.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace App\Assets;

use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Jsonable;
use Illuminate\Support\Carbon;

class CarbonSpan implements Arrayable, \JsonSerializable, Jsonable
{
protected Carbon $min;
protected Carbon $max;

public function __construct(Carbon $min, Carbon $max)
{
$this->min = $min;
$this->max = $max;
}

/**
* Serializes this object into an array.
*
* @return array The serialized properties of this object
*/
public function jsonSerialize(): array
{
return $this->toArray();
}

/**
* Convert the model instance to JSON.
*
* @param int $options
*
* @return string
*/
public function toJson($options = 0): string
{
$json = json_encode($this->jsonSerialize(), $options);

if (JSON_ERROR_NONE !== json_last_error()) {
throw new \RuntimeException('Error encoding "CarbonSpan"');
}

return $json;
}

public function toArray(): array
{
return [
'min' => $this->min->toAtomString(),
'max' => $this->min->toAtomString(),
];
}
}
72 changes: 21 additions & 51 deletions app/Models/Album.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Models;

use App\Assets\CarbonSpan;
use App\Contracts\BaseModelAlbum;
use App\Facades\AccessControl;
use App\Models\Extensions\ForwardsToParentImplementation;
Expand All @@ -11,7 +12,7 @@
use App\Relations\HasManyBidirectionally;
use App\Relations\HasManyChildAlbums;
use App\Relations\HasManyPhotosRecursively;
use Carbon\Carbon;
use App\Relations\TakenAtRelation;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
Expand All @@ -23,17 +24,16 @@
/**
* Class Album.
*
* @property int|null $parent_id
* @property Album|null $parent
* @property Collection $children
* @property Collection $all_photos
* @property string $license
* @property int|null $cover_id
* @property Photo|null $cover
* @property Carbon|null $min_taken_at
* @property Carbon|null $max_taken_at
* @property int $_lft
* @property int $_rgt
* @property int|null $parent_id
* @property Album|null $parent
* @property Collection $children
* @property Collection $all_photos
* @property string $license
* @property int|null $cover_id
* @property Photo|null $cover
* @property CarbonSpan|null $taken_at
* @property int $_lft
* @property int $_rgt
*/
class Album extends Model implements BaseModelAlbum
{
Expand Down Expand Up @@ -100,45 +100,7 @@ class Album extends Model implements BaseModelAlbum
/**
* The relationships that should always be eagerly loaded by default.
*/
protected $with = ['cover'];

/**
* This method is called by the framework after the model has been
* booted.
*
* This method alters the default query builder for this model and
* adds a "scope" to the query builder in order to add the "virtual"
* columns `max_taken_at` and `min_taken_at` to every query.
*/
protected static function booted()
{
parent::booted();
// Normally "scopes" are used to restrict the result of the query
// to a particular subset through adding additional WHERE-clauses
// to the default query.
// However, "scopes" can be used to manipulate the query in any way.
// Here we add to additional "virtual" columns to the query.
static::addGlobalScope('add_minmax_taken_at', function (Builder $builder) {
$builder->addSelect([
'max_taken_at' => Photo::query()
->select('taken_at')
->leftJoin('albums as a', 'a.id', '=', 'album_id')
->whereColumn('a._lft', '>=', 'albums._lft')
->whereColumn('a._rgt', '<=', 'albums._rgt')
->whereNotNull('taken_at')
->orderBy('taken_at', 'desc')
->limit(1),
'min_taken_at' => Photo::query()
->select('taken_at')
->leftJoin('albums as a', 'a.id', '=', 'album_id')
->whereColumn('a._lft', '>=', 'albums._lft')
->whereColumn('a._rgt', '<=', 'albums._rgt')
->whereNotNull('taken_at')
->orderBy('taken_at', 'asc')
->limit(1),
]);
});
}
protected $with = ['cover', 'taken_at'];

/**
* Returns the relationship between this model and the implementation
Expand Down Expand Up @@ -244,6 +206,14 @@ public function shared_with(): BelongsToMany
return $this->base_class->shared_with();
}

/**
* @return TakenAtRelation
*/
public function taken_at(): TakenAtRelation
{
return new TakenAtRelation($this);
}

protected function getLicenseAttribute(string $value): string
{
if ($value === 'none') {
Expand Down
193 changes: 193 additions & 0 deletions app/Relations/TakenAtRelation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
<?php

namespace App\Relations;

use App\Assets\CarbonSpan;
use App\Models\Album;
use App\Models\Photo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\MultipleRecordsFoundException;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Database\RecordsNotFoundException;
use Illuminate\Support\Collection as BaseCollection;

/**
* This relation associates an album with a timespan of the least and most
* recent photo within the album and its sub-albums.
*
* Assume that the IDs of the albums in a2 are the albums under consideration.
* This relation runs the following DB query:
*
* SELECT
* MIN(taken_at),
* MAX(taken_at),
* a2.id
* FROM photos
* JOIN albums AS a1 ON photos.album_id = a1.id
* RIGHT JOIN albums AS a2 ON (a1._lft >= a2._lft AND a1._rgt <= a2._rgt)
* WHERE a2.id IN (...)
* GROUP BY a2.id
* ORDER BY a2.id;
*
* Firstly, the query first joins all photos with their direct parent albums
* (aliased as `a1`) in order to associate each photo with its immediate
* `_lft` and `_rgt` values.
* Secondly, the query does a self join on the albums such that each album
* is associated with its parent albums (aliased as `a2`).
* Thirdly, the query filters for the parent albums of interest.
* The ID of the parent album must be included as a result column to enable
* matching the results to a set of albums, if the albums are loaded eagerly.
*/
class TakenAtRelation extends Relation
{
public function __construct(Album $parent)
{
parent::__construct(
(new Photo())->newModelQuery()
->join('albums as a1', 'photos.album_id', '=', 'a1.id')
->join('albums as a2', function (JoinClause $join) {
$join
->on('a1._lft', '>=', 'a2._lft')
->on('a1._rgt', '<=', 'a2._rgt');
})
->selectRaw('a2.id AS album_id, MIN(photos.taken_at) AS min_taken_at, MAX(photos.taken_at) AS max_taken_at')
->groupBy('a2.id')
->orderBy('a2.id'),
$parent
);
}

public function addConstraints()
{
if (static::$constraints) {
$this->query->where('a2.id', '=', $this->parent->getKey());
}
}

public function addEagerConstraints(array $models)
{
$this->query->whereIn('a2.id', $this->getKeys($models));
}

public function initRelation(array $models, $relation): array
{
foreach ($models as $model) {
$model->setRelation($relation, null);
}

return $models;
}

public function match(array $models, BaseCollection $results, $relation): array
{
$dictionary = $results->mapToDictionary(fn ($result) => [$result->album_id => $result]);
/** @var Album $model */
foreach ($models as $model) {
$albumID = $model->getKey();
if (isset($dictionary[$albumID])) {
$item = $dictionary[$albumID][0];
$min = $this->related->asDateTime($item->min_taken_at);
$max = $this->related->asDateTime($item->max_taken_at);
if ($min === false || $min === null || $max === false || $max === null) {
$model->setRelation($relation, null);
} else {
$model->setRelation($relation, new CarbonSpan($min, $max));
}
} else {
$model->setRelation($relation, null);
}
}

return $models;
}

public function getResults(): ?CarbonSpan
{
$results = $this->getBaseQuery()->get();
if (!$results->containsOneItem()) {
// This should never happen, just a sanity check
throw new \RuntimeException('query returned ambiguous results');
}
$item = $results->pop();
if ($item->album_id != $this->parent->getKey()) {
// This should never happen, just a sanity check
throw new \RuntimeException('query returned result for wrong album');
}

$min = $this->related->asDateTime($item->min_taken_at);
$max = $this->related->asDateTime($item->max_taken_at);
if ($min === false || $min === null || $max === false || $max === null) {
return null;
}

return new CarbonSpan($min, $max);
}

/**
* Get the relationship for eager loading.
*
* @return BaseCollection
*/
public function getEager(): BaseCollection
{
return $this->getBaseQuery()->get();
}

/**
* Execute the query and get the first result if it's the sole matching record.
*
* @param array|string $columns
*
* @return array
*
* @throws RecordsNotFoundException
* @throws MultipleRecordsFoundException
*/
public function sole($columns = ['*']): array
{
$result = $this->getBaseQuery()->take(2)->get($columns);

if ($result->isEmpty()) {
throw (new RecordsNotFoundException());
}

if ($result->count() > 1) {
throw new MultipleRecordsFoundException();
}

return $result->pop();
}

/**
* Execute the query as a "select" statement.
*
* @param array $columns
*
* @return BaseCollection
*/
public function get($columns = ['*']): BaseCollection
{
return $this->getBaseQuery()->get($columns);
}

/**
* Touch all the related models for the relationship.
*
* @return void
*/
public function touch()
{
}

/**
* Run a raw update against the base query.
*
* @param array $attributes
*
* @return int
*/
public function rawUpdate(array $attributes = []): int
{
return 0;
}
}

0 comments on commit cf75b97

Please sign in to comment.