optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View 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();
}
}