optimizations
This commit is contained in:
481
app/Services/SmartCollectionService.php
Normal file
481
app/Services/SmartCollectionService.php
Normal file
@@ -0,0 +1,481 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Collection;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class SmartCollectionService
|
||||
{
|
||||
public function sanitizeRules(?array $input): ?array
|
||||
{
|
||||
if ($input === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$match = strtolower((string) ($input['match'] ?? 'all'));
|
||||
$sort = strtolower((string) ($input['sort'] ?? Collection::SORT_NEWEST));
|
||||
$rules = array_values(array_filter((array) ($input['rules'] ?? []), static fn ($rule) => is_array($rule)));
|
||||
|
||||
$allowedMatch = config('collections.smart_rules.allowed_match', ['all', 'any']);
|
||||
$allowedSort = config('collections.smart_rules.allowed_sort', ['newest', 'oldest', 'popular']);
|
||||
$allowedFields = config('collections.smart_rules.allowed_fields', []);
|
||||
$maxRules = (int) config('collections.smart_rules.max_rules', 8);
|
||||
|
||||
if (! in_array($match, $allowedMatch, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'smart_rules_json.match' => 'Smart collections must use either all or any matching.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array($sort, $allowedSort, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'smart_rules_json.sort' => 'Choose a supported sort for the smart collection.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($rules === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'smart_rules_json.rules' => 'Add at least one smart collection rule.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (count($rules) > $maxRules) {
|
||||
throw ValidationException::withMessages([
|
||||
'smart_rules_json.rules' => 'Too many smart collection rules were submitted.',
|
||||
]);
|
||||
}
|
||||
|
||||
$sanitized = [];
|
||||
|
||||
foreach ($rules as $index => $rule) {
|
||||
$field = strtolower(trim((string) ($rule['field'] ?? '')));
|
||||
$operator = strtolower(trim((string) ($rule['operator'] ?? '')));
|
||||
$value = $rule['value'] ?? null;
|
||||
|
||||
if (! in_array($field, $allowedFields, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
"smart_rules_json.rules.$index.field" => 'This smart collection field is not supported.',
|
||||
]);
|
||||
}
|
||||
|
||||
$sanitized[] = [
|
||||
'field' => $field,
|
||||
'operator' => $this->validateOperator($field, $operator, $index),
|
||||
'value' => $this->sanitizeValue($field, $value, $index),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'match' => $match,
|
||||
'sort' => $sort,
|
||||
'rules' => $sanitized,
|
||||
];
|
||||
}
|
||||
|
||||
public function resolveArtworks(Collection $collection, bool $ownerView, int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$query = $this->queryForCollection($collection, $ownerView)
|
||||
->with($this->artworkRelations())
|
||||
->select('artworks.*');
|
||||
|
||||
return $query->paginate($perPage)->withQueryString();
|
||||
}
|
||||
|
||||
public function preview(User $owner, array $rules, bool $ownerView = true, int $perPage = 12): LengthAwarePaginator
|
||||
{
|
||||
$query = $this->queryForOwner($owner, $rules, $ownerView)
|
||||
->with($this->artworkRelations())
|
||||
->select('artworks.*');
|
||||
|
||||
return $query->paginate($perPage)->withQueryString();
|
||||
}
|
||||
|
||||
public function countMatching(Collection|User $subject, ?array $rules = null, bool $ownerView = true): int
|
||||
{
|
||||
$query = $subject instanceof Collection
|
||||
? $this->queryForCollection($subject, $ownerView)
|
||||
: $this->queryForOwner($subject, $rules, $ownerView);
|
||||
|
||||
return (int) $query->toBase()->getCountForPagination();
|
||||
}
|
||||
|
||||
public function firstArtwork(Collection $collection, bool $ownerView): ?Artwork
|
||||
{
|
||||
return $this->queryForCollection($collection, $ownerView)
|
||||
->with($this->artworkRelations())
|
||||
->select('artworks.*')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function smartSummary(?array $rules): ?string
|
||||
{
|
||||
if (! is_array($rules) || empty($rules['rules'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$glue = ($rules['match'] ?? 'all') === 'any' ? ' or ' : ' and ';
|
||||
$parts = [];
|
||||
|
||||
foreach ((array) $rules['rules'] as $rule) {
|
||||
$field = $rule['field'] ?? null;
|
||||
$value = $rule['value'] ?? null;
|
||||
|
||||
if ($field === 'created_at' && is_array($value)) {
|
||||
$from = $value['from'] ?? null;
|
||||
$to = $value['to'] ?? null;
|
||||
if ($from && $to) {
|
||||
$parts[] = sprintf('created between %s and %s', $from, $to);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($field === 'is_featured') {
|
||||
$parts[] = ((bool) $value) ? 'marked as featured artworks' : 'not marked as featured artworks';
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($field === 'is_mature') {
|
||||
$parts[] = ((bool) $value) ? 'marked as mature artworks' : 'not marked as mature artworks';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
$label = match ($field) {
|
||||
'tags' => 'tagged ' . $value,
|
||||
'category' => 'in category ' . $value,
|
||||
'subcategory' => 'in subcategory ' . $value,
|
||||
'medium' => 'in medium ' . $value,
|
||||
'style' => 'matching style ' . $value,
|
||||
'color' => 'using color palette ' . $value,
|
||||
'ai_tag' => 'matching AI tag ' . $value,
|
||||
default => $value,
|
||||
};
|
||||
$parts[] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
if ($parts === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'Includes artworks ' . implode($glue, $parts) . '.';
|
||||
}
|
||||
|
||||
public function ruleOptionsForOwner(User $owner): array
|
||||
{
|
||||
$tagOptions = DB::table('artwork_tag as at')
|
||||
->join('artworks as a', 'a.id', '=', 'at.artwork_id')
|
||||
->join('tags as t', 't.id', '=', 'at.tag_id')
|
||||
->where('a.user_id', $owner->id)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('t.is_active', true)
|
||||
->groupBy('t.slug', 't.name')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->limit(20)
|
||||
->get(['t.slug', 't.name'])
|
||||
->map(fn ($row) => ['value' => $row->slug, 'label' => $row->name])
|
||||
->all();
|
||||
|
||||
$rootCategories = DB::table('artwork_category as ac')
|
||||
->join('artworks as a', 'a.id', '=', 'ac.artwork_id')
|
||||
->join('categories as c', 'c.id', '=', 'ac.category_id')
|
||||
->where('a.user_id', $owner->id)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('c.is_active', true)
|
||||
->whereNull('c.parent_id')
|
||||
->groupBy('c.slug', 'c.name')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->limit(20)
|
||||
->get(['c.slug', 'c.name'])
|
||||
->map(fn ($row) => ['value' => $row->slug, 'label' => $row->name])
|
||||
->all();
|
||||
|
||||
$subcategories = DB::table('artwork_category as ac')
|
||||
->join('artworks as a', 'a.id', '=', 'ac.artwork_id')
|
||||
->join('categories as c', 'c.id', '=', 'ac.category_id')
|
||||
->where('a.user_id', $owner->id)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('c.is_active', true)
|
||||
->whereNotNull('c.parent_id')
|
||||
->groupBy('c.slug', 'c.name')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->limit(20)
|
||||
->get(['c.slug', 'c.name'])
|
||||
->map(fn ($row) => ['value' => $row->slug, 'label' => $row->name])
|
||||
->all();
|
||||
|
||||
$mediumOptions = DB::table('artwork_category as ac')
|
||||
->join('artworks as a', 'a.id', '=', 'ac.artwork_id')
|
||||
->join('categories as c', 'c.id', '=', 'ac.category_id')
|
||||
->join('content_types as ct', 'ct.id', '=', 'c.content_type_id')
|
||||
->where('a.user_id', $owner->id)
|
||||
->whereNull('a.deleted_at')
|
||||
->groupBy('ct.slug', 'ct.name')
|
||||
->orderBy('ct.name')
|
||||
->get(['ct.slug', 'ct.name'])
|
||||
->map(fn ($row) => ['value' => $row->slug, 'label' => $row->name])
|
||||
->all();
|
||||
|
||||
$styleOptions = $this->aiTagOptionsForOwner($owner, (array) config('collections.smart_rules.style_terms', []));
|
||||
$colorOptions = $this->aiTagOptionsForOwner($owner, (array) config('collections.smart_rules.color_terms', []));
|
||||
|
||||
return [
|
||||
'fields' => [
|
||||
['value' => 'tags', 'label' => 'Tag'],
|
||||
['value' => 'category', 'label' => 'Category'],
|
||||
['value' => 'subcategory', 'label' => 'Subcategory'],
|
||||
['value' => 'medium', 'label' => 'Medium'],
|
||||
['value' => 'style', 'label' => 'Style'],
|
||||
['value' => 'color', 'label' => 'Color palette'],
|
||||
['value' => 'ai_tag', 'label' => 'AI tag'],
|
||||
['value' => 'created_at', 'label' => 'Created date'],
|
||||
['value' => 'is_featured', 'label' => 'Featured artwork'],
|
||||
['value' => 'is_mature', 'label' => 'Mature artwork'],
|
||||
],
|
||||
'tag_options' => $tagOptions,
|
||||
'category_options' => $rootCategories,
|
||||
'subcategory_options' => $subcategories,
|
||||
'medium_options' => $mediumOptions,
|
||||
'style_options' => $styleOptions,
|
||||
'color_options' => $colorOptions,
|
||||
'sort_options' => [
|
||||
['value' => 'newest', 'label' => 'Newest first'],
|
||||
['value' => 'oldest', 'label' => 'Oldest first'],
|
||||
['value' => 'popular', 'label' => 'Most viewed'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function queryForCollection(Collection $collection, bool $ownerView): Builder
|
||||
{
|
||||
return $this->queryForOwner($collection->user, $collection->smart_rules_json, $ownerView);
|
||||
}
|
||||
|
||||
public function queryForOwner(User $owner, ?array $rules, bool $ownerView): Builder
|
||||
{
|
||||
if ($rules === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'smart_rules_json' => 'Smart collections require at least one valid rule.',
|
||||
]);
|
||||
}
|
||||
|
||||
$sanitized = $this->sanitizeRules($rules);
|
||||
$query = Artwork::query()
|
||||
->where('artworks.user_id', $owner->id)
|
||||
->whereNull('artworks.deleted_at');
|
||||
|
||||
if (! $ownerView) {
|
||||
$query->where('artworks.is_public', true)
|
||||
->where('artworks.is_approved', true)
|
||||
->whereNotNull('artworks.published_at')
|
||||
->where('artworks.published_at', '<=', now());
|
||||
}
|
||||
|
||||
$method = ($sanitized['match'] ?? 'all') === 'any' ? 'orWhere' : 'where';
|
||||
|
||||
$query->where(function (Builder $outer) use ($sanitized, $method): void {
|
||||
foreach ((array) ($sanitized['rules'] ?? []) as $index => $rule) {
|
||||
$callback = function (Builder $builder) use ($rule): void {
|
||||
$this->applyRule($builder, $rule);
|
||||
};
|
||||
|
||||
if ($index === 0 || $method === 'where') {
|
||||
$outer->where($callback);
|
||||
} else {
|
||||
$outer->orWhere($callback);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return $this->applySort($query, (string) ($sanitized['sort'] ?? Collection::SORT_NEWEST));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string|array>
|
||||
*/
|
||||
private function artworkRelations(): array
|
||||
{
|
||||
return [
|
||||
'user:id,name,username',
|
||||
'stats:artwork_id,views,downloads,favorites',
|
||||
'categories' => function ($query) {
|
||||
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['contentType:id,slug,name']);
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{field:string,operator:string,value:mixed} $rule
|
||||
*/
|
||||
private function applyRule(Builder $query, array $rule): void
|
||||
{
|
||||
$field = $rule['field'];
|
||||
$value = $rule['value'];
|
||||
|
||||
match ($field) {
|
||||
'tags' => $query->whereHas('tags', function (Builder $builder) use ($value): void {
|
||||
$builder->where('tags.slug', (string) $value)
|
||||
->orWhere('tags.name', 'like', '%' . (string) $value . '%');
|
||||
}),
|
||||
'category' => $query->whereHas('categories', function (Builder $builder) use ($value): void {
|
||||
$builder->where('categories.slug', (string) $value)
|
||||
->whereNull('categories.parent_id');
|
||||
}),
|
||||
'subcategory' => $query->whereHas('categories', function (Builder $builder) use ($value): void {
|
||||
$builder->where('categories.slug', (string) $value)
|
||||
->whereNotNull('categories.parent_id');
|
||||
}),
|
||||
'medium' => $query->whereHas('categories.contentType', function (Builder $builder) use ($value): void {
|
||||
$builder->where('content_types.slug', (string) $value);
|
||||
}),
|
||||
'style' => $this->applyAiPivotTagRule($query, (string) $value),
|
||||
'color' => $this->applyAiPivotTagRule($query, (string) $value),
|
||||
'ai_tag' => $query->where(function (Builder $builder) use ($value): void {
|
||||
$builder->where('artworks.blip_caption', 'like', '%' . (string) $value . '%')
|
||||
->orWhere('artworks.clip_tags_json', 'like', '%' . (string) $value . '%')
|
||||
->orWhere('artworks.yolo_objects_json', 'like', '%' . (string) $value . '%');
|
||||
}),
|
||||
'created_at' => $query->whereBetween('artworks.created_at', [
|
||||
Carbon::parse((string) ($value['from'] ?? now()->subYear()->toDateString()))->startOfDay(),
|
||||
Carbon::parse((string) ($value['to'] ?? now()->toDateString()))->endOfDay(),
|
||||
]),
|
||||
'is_featured' => $this->applyFeaturedRule($query, (bool) $value),
|
||||
'is_mature' => $query->where('artworks.is_mature', (bool) $value),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function applyFeaturedRule(Builder $query, bool $value): void
|
||||
{
|
||||
if ($value) {
|
||||
$query->whereExists(function ($sub): void {
|
||||
$sub->selectRaw('1')
|
||||
->from('artwork_features as af')
|
||||
->whereColumn('af.artwork_id', 'artworks.id')
|
||||
->where('af.is_active', true)
|
||||
->whereNull('af.deleted_at');
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->whereNotExists(function ($sub): void {
|
||||
$sub->selectRaw('1')
|
||||
->from('artwork_features as af')
|
||||
->whereColumn('af.artwork_id', 'artworks.id')
|
||||
->where('af.is_active', true)
|
||||
->whereNull('af.deleted_at');
|
||||
});
|
||||
}
|
||||
|
||||
private function applySort(Builder $query, string $sort): Builder
|
||||
{
|
||||
return match ($sort) {
|
||||
Collection::SORT_OLDEST => $query->orderBy('artworks.published_at')->orderBy('artworks.id'),
|
||||
Collection::SORT_POPULAR => $query->orderByDesc('artworks.view_count')->orderByDesc('artworks.id'),
|
||||
default => $query->orderByDesc('artworks.published_at')->orderByDesc('artworks.id'),
|
||||
};
|
||||
}
|
||||
|
||||
private function validateOperator(string $field, string $operator, int $index): string
|
||||
{
|
||||
$allowed = match ($field) {
|
||||
'created_at' => ['between'],
|
||||
'is_featured', 'is_mature' => ['equals'],
|
||||
default => ['contains', 'equals'],
|
||||
};
|
||||
|
||||
if (! in_array($operator, $allowed, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
"smart_rules_json.rules.$index.operator" => 'This operator is not supported for the selected field.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $operator;
|
||||
}
|
||||
|
||||
private function sanitizeValue(string $field, mixed $value, int $index): mixed
|
||||
{
|
||||
if ($field === 'created_at') {
|
||||
if (! is_array($value) || empty($value['from']) || empty($value['to'])) {
|
||||
throw ValidationException::withMessages([
|
||||
"smart_rules_json.rules.$index.value" => 'Provide a valid start and end date for this rule.',
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'from' => Carbon::parse((string) $value['from'])->toDateString(),
|
||||
'to' => Carbon::parse((string) $value['to'])->toDateString(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($field === 'is_featured' || $field === 'is_mature') {
|
||||
return (bool) $value;
|
||||
}
|
||||
|
||||
$stringValue = trim((string) $value);
|
||||
if ($stringValue === '' || mb_strlen($stringValue) > 80) {
|
||||
throw ValidationException::withMessages([
|
||||
"smart_rules_json.rules.$index.value" => 'Provide a shorter value for this smart rule.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $stringValue;
|
||||
}
|
||||
|
||||
private function applyAiPivotTagRule(Builder $query, string $value): void
|
||||
{
|
||||
$query->whereHas('tags', function (Builder $builder) use ($value): void {
|
||||
$builder->where('artwork_tag.source', 'ai')
|
||||
->where(function (Builder $match) use ($value): void {
|
||||
$match->where('tags.slug', str($value)->slug()->value())
|
||||
->orWhere('tags.name', 'like', '%' . $value . '%');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $allowedTerms
|
||||
* @return array<int, array{value:string,label:string}>
|
||||
*/
|
||||
private function aiTagOptionsForOwner(User $owner, array $allowedTerms): array
|
||||
{
|
||||
if ($allowedTerms === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$allowedSlugs = collect($allowedTerms)
|
||||
->map(static fn (string $term) => str($term)->slug()->value())
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($allowedSlugs->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('artwork_tag as at')
|
||||
->join('artworks as a', 'a.id', '=', 'at.artwork_id')
|
||||
->join('tags as t', 't.id', '=', 'at.tag_id')
|
||||
->where('a.user_id', $owner->id)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('at.source', 'ai')
|
||||
->whereIn('t.slug', $allowedSlugs->all())
|
||||
->groupBy('t.slug', 't.name')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->orderBy('t.name')
|
||||
->get(['t.slug', 't.name'])
|
||||
->map(fn ($row) => ['value' => $row->slug, 'label' => $row->name])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user