diff --git a/src/Assets/AssetQueryBuilder.php b/src/Assets/AssetQueryBuilder.php index 56ccdf25..858d40ff 100644 --- a/src/Assets/AssetQueryBuilder.php +++ b/src/Assets/AssetQueryBuilder.php @@ -2,12 +2,19 @@ namespace Statamic\Eloquent\Assets; +use Illuminate\Support\Collection; use Statamic\Assets\AssetCollection; +use Statamic\Contracts\Assets\AssetContainer; use Statamic\Contracts\Assets\QueryBuilder; +use Statamic\Eloquent\QueriesJsonColumns; +use Statamic\Facades; +use Statamic\Fields\Field; use Statamic\Query\EloquentQueryBuilder; class AssetQueryBuilder extends EloquentQueryBuilder implements QueryBuilder { + use QueriesJsonColumns; + const COLUMNS = [ 'id', 'container', 'folder', 'basename', 'filename', 'extension', 'path', 'created_at', 'updated_at', ]; @@ -39,4 +46,34 @@ public function with($relations, $callback = null) { return $this; } + + protected function getJsonCasts(): Collection + { + $wheres = collect($this->builder->getQuery()->wheres); + $containerWhere = $wheres->firstWhere('column', 'container'); + + if (! $containerWhere || ! isset($containerWhere['value'])) { + return [ + 'size' => 'float', + 'width' => 'float', + 'height' => 'float', + 'duration' => 'float', + ]; + } + + $container = $containerWhere['value'] instanceof AssetContainer + ? $containerWhere['value'] + : Facades\AssetContainer::find($containerWhere['value']); + + return $container->blueprint()->fields()->all() + ->filter(fn (Field $field): bool => in_array($field->type(), ['float', 'integer', 'date'])) + ->map(fn (Field $field): string => $this->toCast($field)) + ->filter() + ->merge([ + 'size' => 'float', + 'width' => 'float', + 'height' => 'float', + 'duration' => 'float', + ]); + } } diff --git a/src/QueriesJsonColumns.php b/src/QueriesJsonColumns.php new file mode 100644 index 00000000..8a812f45 --- /dev/null +++ b/src/QueriesJsonColumns.php @@ -0,0 +1,79 @@ +column($column); + + if ( + Str::contains($actualColumn, ['data->', 'meta->']) + && $jsonCast = $this->getJsonCasts()->get($column) + ) { + $grammar = $this->builder->getConnection()->getQueryGrammar(); + $wrappedColumn = $grammar->wrap($actualColumn); + + if (Str::contains($jsonCast, 'range_')) { + $jsonCast = Str::after($jsonCast, 'range_'); + + $wrappedStartDateColumn = $grammar->wrap("{$actualColumn}->start"); + $wrappedEndDateColumn = $grammar->wrap("{$actualColumn}->end"); + + if (str_contains(get_class($grammar), 'SQLiteGrammar')) { + $this->builder + ->orderByRaw("datetime({$wrappedStartDateColumn}) {$direction}") + ->orderByRaw("datetime({$wrappedEndDateColumn}) {$direction}"); + } else { + $this->builder + ->orderByRaw("cast({$wrappedStartDateColumn} as {$jsonCast}) {$direction}") + ->orderByRaw("cast({$wrappedEndDateColumn} as {$jsonCast}) {$direction}"); + } + + return $this; + } + + // SQLite casts dates to year, which is pretty unhelpful. + if ( + in_array($jsonCast, ['date', 'datetime']) + && Str::contains(get_class($grammar), 'SQLiteGrammar') + ) { + $this->builder->orderByRaw("datetime({$wrappedColumn}) {$direction}"); + + return $this; + } + + $this->builder->orderByRaw("cast({$wrappedColumn} as {$jsonCast}) {$direction}"); + + return $this; + } + + parent::orderBy($column, $direction); + + return $this; + } + + abstract protected function getJsonCasts(): Collection; + + protected function toCast(Field $field): string + { + $cast = match (true) { + $field->type() === 'float' => 'float', + $field->type() === 'integer' => 'float', // A bit sneaky, but MySQL doesn't support casting as integer, it wants unsigned. + $field->type() === 'date' => $field->get('time_enabled') ? 'datetime' : 'date', + default => null, + }; + + // Date Ranges are dealt with a little bit differently. + if ($field->type() === 'date' && $field->get('mode') === 'range') { + $cast = "range_{$cast}"; + } + + return $cast; + } +} diff --git a/tests/Data/Assets/AssetQueryBuilderTest.php b/tests/Data/Assets/AssetQueryBuilderTest.php index 67bde735..9d37bbdf 100644 --- a/tests/Data/Assets/AssetQueryBuilderTest.php +++ b/tests/Data/Assets/AssetQueryBuilderTest.php @@ -508,6 +508,101 @@ public function assets_are_found_using_where_column() $this->assertEquals(['Post 1', 'Post 2', 'Post 5', 'Post 6'], $entries->map->foo->all()); } + #[Test] + public function assets_can_be_ordered_by_an_integer_json_column() + { + $blueprint = Blueprint::makeFromFields(['integer' => ['type' => 'integer']]); + Blueprint::shouldReceive('find')->with('assets/test')->andReturn($blueprint); + + Asset::find('test::a.jpg')->data(['integer' => 3])->save(); + Asset::find('test::b.txt')->data(['integer' => 5])->save(); + Asset::find('test::c.txt')->data(['integer' => 1])->save(); + Asset::find('test::d.jpg')->data(['integer' => 35])->save(); + Asset::find('test::e.jpg')->data(['integer' => 20])->save(); + Asset::find('test::f.jpg')->data(['integer' => 12])->save(); + + $assets = Asset::query()->where('container', 'test')->orderBy('integer', 'asc')->get(); + + $this->assertCount(6, $assets); + $this->assertEquals(['c', 'a', 'b', 'f', 'e', 'd'], $assets->map->filename()->all()); + } + + #[Test] + public function assets_can_be_ordered_by_a_float_json_column() + { + $blueprint = Blueprint::makeFromFields(['float' => ['type' => 'float']]); + Blueprint::shouldReceive('find')->with('assets/test')->andReturn($blueprint); + + Asset::find('test::a.jpg')->data(['float' => 3.3])->save(); + Asset::find('test::b.txt')->data(['float' => 5.5])->save(); + Asset::find('test::c.txt')->data(['float' => 1.1])->save(); + Asset::find('test::d.jpg')->data(['float' => 35.5])->save(); + Asset::find('test::e.jpg')->data(['float' => 20.0])->save(); + Asset::find('test::f.jpg')->data(['float' => 12.2])->save(); + + $assets = Asset::query()->where('container', 'test')->orderBy('float', 'asc')->get(); + + $this->assertCount(6, $assets); + $this->assertEquals(['c', 'a', 'b', 'f', 'e', 'd'], $assets->map->filename()->all()); + } + + #[Test] + public function assets_can_be_ordered_by_a_date_json_field() + { + $blueprint = Blueprint::makeFromFields(['date_field' => ['type' => 'date', 'time_enabled' => true]]); + Blueprint::shouldReceive('find')->with('assets/test')->andReturn($blueprint); + + Asset::find('test::a.jpg')->data(['date_field' => '2021-06-15 20:31:04'])->save(); + Asset::find('test::b.txt')->data(['date_field' => '2021-01-13 20:31:04'])->save(); + Asset::find('test::c.txt')->data(['date_field' => '2021-11-17 20:31:04'])->save(); + Asset::find('test::d.jpg')->data(['date_field' => '2023-01-01 20:31:04'])->save(); + Asset::find('test::e.jpg')->data(['date_field' => '2025-01-01 20:31:04'])->save(); + Asset::find('test::f.jpg')->data(['date_field' => '2024-01-01 20:31:04'])->save(); + + $assets = Asset::query()->where('container', 'test')->orderBy('date_field', 'asc')->get(); + + $this->assertCount(6, $assets); + $this->assertEquals(['b', 'a', 'c', 'd', 'f', 'e'], $assets->map->filename()->all()); + } + + #[Test] + public function assets_can_be_ordered_by_a_datetime_range_json_field() + { + $blueprint = Blueprint::makeFromFields(['date_field' => ['type' => 'date', 'time_enabled' => true, 'mode' => 'range']]); + Blueprint::shouldReceive('find')->with('assets/test')->andReturn($blueprint); + + Asset::find('test::a.jpg')->data(['date_field' => ['start' => '2021-06-15 20:31:04', 'end' => '2021-06-15 21:00:00']])->save(); + Asset::find('test::b.txt')->data(['date_field' => ['start' => '2021-01-13 20:31:04', 'end' => '2021-06-16 20:31:04']])->save(); + Asset::find('test::c.txt')->data(['date_field' => ['start' => '2021-11-17 20:31:04', 'end' => '2021-11-17 20:31:04']])->save(); + Asset::find('test::d.jpg')->data(['date_field' => ['start' => '2021-06-15 20:31:04', 'end' => '2021-06-15 22:00:00']])->save(); + Asset::find('test::e.jpg')->data(['date_field' => ['start' => '2024-06-15 20:31:04', 'end' => '2024-06-15 22:00:00']])->save(); + Asset::find('test::f.jpg')->data(['date_field' => ['start' => '2025-06-15 20:31:04', 'end' => '2025-06-15 22:00:00']])->save(); + + $assets = Asset::query()->where('container', 'test')->orderBy('date_field', 'asc')->get(); + + $this->assertCount(6, $assets); + $this->assertEquals(['b', 'a', 'd', 'c', 'e', 'f'], $assets->map->filename()->all()); + } + + #[Test] + public function assets_can_be_ordered_by_a_date_range_json_field() + { + $blueprint = Blueprint::makeFromFields(['date_field' => ['type' => 'date', 'time_enabled' => false, 'mode' => 'range']]); + Blueprint::shouldReceive('find')->with('assets/test')->andReturn($blueprint); + + Asset::find('test::a.jpg')->data(['date_field' => ['start' => '2021-06-15', 'end' => '2021-06-15']])->save(); + Asset::find('test::b.txt')->data(['date_field' => ['start' => '2021-01-13', 'end' => '2021-06-16']])->save(); + Asset::find('test::c.txt')->data(['date_field' => ['start' => '2021-11-17', 'end' => '2021-11-17']])->save(); + Asset::find('test::d.jpg')->data(['date_field' => ['start' => '2021-06-15', 'end' => '2021-06-15']])->save(); + Asset::find('test::e.jpg')->data(['date_field' => ['start' => '2024-06-15', 'end' => '2024-06-15']])->save(); + Asset::find('test::f.jpg')->data(['date_field' => ['start' => '2025-06-15', 'end' => '2025-06-15']])->save(); + + $assets = Asset::query()->where('container', 'test')->orderBy('date_field', 'asc')->get(); + + $this->assertCount(6, $assets); + $this->assertEquals(['b', 'a', 'd', 'c', 'e', 'f'], $assets->map->filename()->all()); + } + #[Test] public function it_can_get_assets_using_when() {