Skip to content

Commit 7c404f2

Browse files
Fix ordering assets by integers, floats, and date fields (#487)
* Add tests Modelled after similar tests in the EntryQueryBuilderTest * Unrelated, but tweak method names in EntryQueryBuilderTest * Fix asset sorting * wip * wip * Fix styling * wip * Always cast default meta data * Extract some of the logic into a trait * Fix styling * simplify * Fix styling * Rename some things. It's not always gonna be "meta" * wip * revert changes to test names * don't want any changes for this file in the diff * `column` doesn't need to be in the trait It's required by the EloquentQueryBuilder in core. --------- Co-authored-by: duncanmcclean <duncanmcclean@users.noreply.github.com>
1 parent 02b4b4c commit 7c404f2

File tree

3 files changed

+211
-0
lines changed

3 files changed

+211
-0
lines changed

src/Assets/AssetQueryBuilder.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@
22

33
namespace Statamic\Eloquent\Assets;
44

5+
use Illuminate\Support\Collection;
56
use Statamic\Assets\AssetCollection;
7+
use Statamic\Contracts\Assets\AssetContainer;
68
use Statamic\Contracts\Assets\QueryBuilder;
9+
use Statamic\Eloquent\QueriesJsonColumns;
10+
use Statamic\Facades;
11+
use Statamic\Fields\Field;
712
use Statamic\Query\EloquentQueryBuilder;
813

914
class AssetQueryBuilder extends EloquentQueryBuilder implements QueryBuilder
1015
{
16+
use QueriesJsonColumns;
17+
1118
const COLUMNS = [
1219
'id', 'container', 'folder', 'basename', 'filename', 'extension', 'path', 'created_at', 'updated_at',
1320
];
@@ -39,4 +46,34 @@ public function with($relations, $callback = null)
3946
{
4047
return $this;
4148
}
49+
50+
protected function getJsonCasts(): Collection
51+
{
52+
$wheres = collect($this->builder->getQuery()->wheres);
53+
$containerWhere = $wheres->firstWhere('column', 'container');
54+
55+
if (! $containerWhere || ! isset($containerWhere['value'])) {
56+
return [
57+
'size' => 'float',
58+
'width' => 'float',
59+
'height' => 'float',
60+
'duration' => 'float',
61+
];
62+
}
63+
64+
$container = $containerWhere['value'] instanceof AssetContainer
65+
? $containerWhere['value']
66+
: Facades\AssetContainer::find($containerWhere['value']);
67+
68+
return $container->blueprint()->fields()->all()
69+
->filter(fn (Field $field): bool => in_array($field->type(), ['float', 'integer', 'date']))
70+
->map(fn (Field $field): string => $this->toCast($field))
71+
->filter()
72+
->merge([
73+
'size' => 'float',
74+
'width' => 'float',
75+
'height' => 'float',
76+
'duration' => 'float',
77+
]);
78+
}
4279
}

src/QueriesJsonColumns.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
namespace Statamic\Eloquent;
4+
5+
use Illuminate\Support\Collection;
6+
use Illuminate\Support\Str;
7+
use Statamic\Fields\Field;
8+
9+
trait QueriesJsonColumns
10+
{
11+
public function orderBy($column, $direction = 'asc')
12+
{
13+
$actualColumn = $this->column($column);
14+
15+
if (
16+
Str::contains($actualColumn, ['data->', 'meta->'])
17+
&& $jsonCast = $this->getJsonCasts()->get($column)
18+
) {
19+
$grammar = $this->builder->getConnection()->getQueryGrammar();
20+
$wrappedColumn = $grammar->wrap($actualColumn);
21+
22+
if (Str::contains($jsonCast, 'range_')) {
23+
$jsonCast = Str::after($jsonCast, 'range_');
24+
25+
$wrappedStartDateColumn = $grammar->wrap("{$actualColumn}->start");
26+
$wrappedEndDateColumn = $grammar->wrap("{$actualColumn}->end");
27+
28+
if (str_contains(get_class($grammar), 'SQLiteGrammar')) {
29+
$this->builder
30+
->orderByRaw("datetime({$wrappedStartDateColumn}) {$direction}")
31+
->orderByRaw("datetime({$wrappedEndDateColumn}) {$direction}");
32+
} else {
33+
$this->builder
34+
->orderByRaw("cast({$wrappedStartDateColumn} as {$jsonCast}) {$direction}")
35+
->orderByRaw("cast({$wrappedEndDateColumn} as {$jsonCast}) {$direction}");
36+
}
37+
38+
return $this;
39+
}
40+
41+
// SQLite casts dates to year, which is pretty unhelpful.
42+
if (
43+
in_array($jsonCast, ['date', 'datetime'])
44+
&& Str::contains(get_class($grammar), 'SQLiteGrammar')
45+
) {
46+
$this->builder->orderByRaw("datetime({$wrappedColumn}) {$direction}");
47+
48+
return $this;
49+
}
50+
51+
$this->builder->orderByRaw("cast({$wrappedColumn} as {$jsonCast}) {$direction}");
52+
53+
return $this;
54+
}
55+
56+
parent::orderBy($column, $direction);
57+
58+
return $this;
59+
}
60+
61+
abstract protected function getJsonCasts(): Collection;
62+
63+
protected function toCast(Field $field): string
64+
{
65+
$cast = match (true) {
66+
$field->type() === 'float' => 'float',
67+
$field->type() === 'integer' => 'float', // A bit sneaky, but MySQL doesn't support casting as integer, it wants unsigned.
68+
$field->type() === 'date' => $field->get('time_enabled') ? 'datetime' : 'date',
69+
default => null,
70+
};
71+
72+
// Date Ranges are dealt with a little bit differently.
73+
if ($field->type() === 'date' && $field->get('mode') === 'range') {
74+
$cast = "range_{$cast}";
75+
}
76+
77+
return $cast;
78+
}
79+
}

tests/Data/Assets/AssetQueryBuilderTest.php

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,101 @@ public function assets_are_found_using_where_column()
508508
$this->assertEquals(['Post 1', 'Post 2', 'Post 5', 'Post 6'], $entries->map->foo->all());
509509
}
510510

511+
#[Test]
512+
public function assets_can_be_ordered_by_an_integer_json_column()
513+
{
514+
$blueprint = Blueprint::makeFromFields(['integer' => ['type' => 'integer']]);
515+
Blueprint::shouldReceive('find')->with('assets/test')->andReturn($blueprint);
516+
517+
Asset::find('test::a.jpg')->data(['integer' => 3])->save();
518+
Asset::find('test::b.txt')->data(['integer' => 5])->save();
519+
Asset::find('test::c.txt')->data(['integer' => 1])->save();
520+
Asset::find('test::d.jpg')->data(['integer' => 35])->save();
521+
Asset::find('test::e.jpg')->data(['integer' => 20])->save();
522+
Asset::find('test::f.jpg')->data(['integer' => 12])->save();
523+
524+
$assets = Asset::query()->where('container', 'test')->orderBy('integer', 'asc')->get();
525+
526+
$this->assertCount(6, $assets);
527+
$this->assertEquals(['c', 'a', 'b', 'f', 'e', 'd'], $assets->map->filename()->all());
528+
}
529+
530+
#[Test]
531+
public function assets_can_be_ordered_by_a_float_json_column()
532+
{
533+
$blueprint = Blueprint::makeFromFields(['float' => ['type' => 'float']]);
534+
Blueprint::shouldReceive('find')->with('assets/test')->andReturn($blueprint);
535+
536+
Asset::find('test::a.jpg')->data(['float' => 3.3])->save();
537+
Asset::find('test::b.txt')->data(['float' => 5.5])->save();
538+
Asset::find('test::c.txt')->data(['float' => 1.1])->save();
539+
Asset::find('test::d.jpg')->data(['float' => 35.5])->save();
540+
Asset::find('test::e.jpg')->data(['float' => 20.0])->save();
541+
Asset::find('test::f.jpg')->data(['float' => 12.2])->save();
542+
543+
$assets = Asset::query()->where('container', 'test')->orderBy('float', 'asc')->get();
544+
545+
$this->assertCount(6, $assets);
546+
$this->assertEquals(['c', 'a', 'b', 'f', 'e', 'd'], $assets->map->filename()->all());
547+
}
548+
549+
#[Test]
550+
public function assets_can_be_ordered_by_a_date_json_field()
551+
{
552+
$blueprint = Blueprint::makeFromFields(['date_field' => ['type' => 'date', 'time_enabled' => true]]);
553+
Blueprint::shouldReceive('find')->with('assets/test')->andReturn($blueprint);
554+
555+
Asset::find('test::a.jpg')->data(['date_field' => '2021-06-15 20:31:04'])->save();
556+
Asset::find('test::b.txt')->data(['date_field' => '2021-01-13 20:31:04'])->save();
557+
Asset::find('test::c.txt')->data(['date_field' => '2021-11-17 20:31:04'])->save();
558+
Asset::find('test::d.jpg')->data(['date_field' => '2023-01-01 20:31:04'])->save();
559+
Asset::find('test::e.jpg')->data(['date_field' => '2025-01-01 20:31:04'])->save();
560+
Asset::find('test::f.jpg')->data(['date_field' => '2024-01-01 20:31:04'])->save();
561+
562+
$assets = Asset::query()->where('container', 'test')->orderBy('date_field', 'asc')->get();
563+
564+
$this->assertCount(6, $assets);
565+
$this->assertEquals(['b', 'a', 'c', 'd', 'f', 'e'], $assets->map->filename()->all());
566+
}
567+
568+
#[Test]
569+
public function assets_can_be_ordered_by_a_datetime_range_json_field()
570+
{
571+
$blueprint = Blueprint::makeFromFields(['date_field' => ['type' => 'date', 'time_enabled' => true, 'mode' => 'range']]);
572+
Blueprint::shouldReceive('find')->with('assets/test')->andReturn($blueprint);
573+
574+
Asset::find('test::a.jpg')->data(['date_field' => ['start' => '2021-06-15 20:31:04', 'end' => '2021-06-15 21:00:00']])->save();
575+
Asset::find('test::b.txt')->data(['date_field' => ['start' => '2021-01-13 20:31:04', 'end' => '2021-06-16 20:31:04']])->save();
576+
Asset::find('test::c.txt')->data(['date_field' => ['start' => '2021-11-17 20:31:04', 'end' => '2021-11-17 20:31:04']])->save();
577+
Asset::find('test::d.jpg')->data(['date_field' => ['start' => '2021-06-15 20:31:04', 'end' => '2021-06-15 22:00:00']])->save();
578+
Asset::find('test::e.jpg')->data(['date_field' => ['start' => '2024-06-15 20:31:04', 'end' => '2024-06-15 22:00:00']])->save();
579+
Asset::find('test::f.jpg')->data(['date_field' => ['start' => '2025-06-15 20:31:04', 'end' => '2025-06-15 22:00:00']])->save();
580+
581+
$assets = Asset::query()->where('container', 'test')->orderBy('date_field', 'asc')->get();
582+
583+
$this->assertCount(6, $assets);
584+
$this->assertEquals(['b', 'a', 'd', 'c', 'e', 'f'], $assets->map->filename()->all());
585+
}
586+
587+
#[Test]
588+
public function assets_can_be_ordered_by_a_date_range_json_field()
589+
{
590+
$blueprint = Blueprint::makeFromFields(['date_field' => ['type' => 'date', 'time_enabled' => false, 'mode' => 'range']]);
591+
Blueprint::shouldReceive('find')->with('assets/test')->andReturn($blueprint);
592+
593+
Asset::find('test::a.jpg')->data(['date_field' => ['start' => '2021-06-15', 'end' => '2021-06-15']])->save();
594+
Asset::find('test::b.txt')->data(['date_field' => ['start' => '2021-01-13', 'end' => '2021-06-16']])->save();
595+
Asset::find('test::c.txt')->data(['date_field' => ['start' => '2021-11-17', 'end' => '2021-11-17']])->save();
596+
Asset::find('test::d.jpg')->data(['date_field' => ['start' => '2021-06-15', 'end' => '2021-06-15']])->save();
597+
Asset::find('test::e.jpg')->data(['date_field' => ['start' => '2024-06-15', 'end' => '2024-06-15']])->save();
598+
Asset::find('test::f.jpg')->data(['date_field' => ['start' => '2025-06-15', 'end' => '2025-06-15']])->save();
599+
600+
$assets = Asset::query()->where('container', 'test')->orderBy('date_field', 'asc')->get();
601+
602+
$this->assertCount(6, $assets);
603+
$this->assertEquals(['b', 'a', 'd', 'c', 'e', 'f'], $assets->map->filename()->all());
604+
}
605+
511606
#[Test]
512607
public function it_can_get_assets_using_when()
513608
{

0 commit comments

Comments
 (0)