From 58848c934eaa56842f7ea1bdc33d5d794f51a256 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sat, 16 Aug 2025 10:47:50 +0200 Subject: [PATCH] Optimizations --- .../Api/V1/LanguagesController.php | 116 ++++++- .../Controllers/Api/V1/RoutingController.php | 38 ++- ...81350_add_language_performance_indexes.php | 53 ++++ backend/src/Domains/Feed/Models/Route.php | 39 +++ .../Feed/Requests/StoreRouteRequest.php | 17 +- .../Feed/Requests/UpdateRouteRequest.php | 15 +- .../Services/LanguageConsistencyValidator.php | 148 +++++++++ .../src/Domains/Settings/Models/Language.php | 106 ++++++- .../Repositories/LanguageRepository.php | 289 ++++++++++++++++++ .../Settings/Services/LanguageService.php | 147 +++++++++ .../Unit/Services/LanguageServiceTest.php | 226 ++++++++++++++ 11 files changed, 1143 insertions(+), 51 deletions(-) create mode 100644 backend/database/migrations/2025_08_16_081350_add_language_performance_indexes.php create mode 100644 backend/src/Domains/Feed/Services/LanguageConsistencyValidator.php create mode 100644 backend/src/Domains/Settings/Repositories/LanguageRepository.php create mode 100644 backend/src/Domains/Settings/Services/LanguageService.php create mode 100644 backend/tests/Unit/Services/LanguageServiceTest.php diff --git a/backend/app/Http/Controllers/Api/V1/LanguagesController.php b/backend/app/Http/Controllers/Api/V1/LanguagesController.php index 93a6f71..a2ffd1a 100644 --- a/backend/app/Http/Controllers/Api/V1/LanguagesController.php +++ b/backend/app/Http/Controllers/Api/V1/LanguagesController.php @@ -3,19 +3,23 @@ namespace App\Http\Controllers\Api\V1; use Domains\Settings\Models\Language; +use Domains\Settings\Services\LanguageService; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; class LanguagesController extends BaseController { + public function __construct( + private LanguageService $languageService + ) {} + /** * Get languages available for route creation * Returns languages that have both active feeds and active channels */ public function availableForRoutes(): JsonResponse { - $languages = Language::availableForRoutes() - ->orderBy('name') - ->get(['id', 'short_code', 'name', 'native_name']); + $languages = $this->languageService->getAvailableForRoutes(); return $this->sendResponse( $languages, @@ -26,15 +30,20 @@ public function availableForRoutes(): JsonResponse /** * Get feeds filtered by language */ - public function feedsByLanguage(int $languageId): JsonResponse + public function feedsByLanguage(Request $request, int $languageId): JsonResponse { - $language = Language::findOrFail($languageId); + $fields = $this->parseFields($request->get('fields'), [ + 'feeds.id', 'feeds.name', 'feeds.url', 'feeds.type', 'feeds.provider' + ]); - $feeds = $language->feeds() - ->where('feeds.is_active', true) - ->where('feed_languages.is_active', true) - ->with('languages') - ->get(['feeds.id', 'feeds.name', 'feeds.url', 'feeds.type', 'feeds.provider']); + $feeds = $this->languageService->getFeedsByLanguage( + $languageId, + $request->get('search'), + (int) $request->get('per_page', 15), + $fields + ); + + $language = Language::select(['name'])->findOrFail($languageId); return $this->sendResponse( $feeds, @@ -45,18 +54,93 @@ public function feedsByLanguage(int $languageId): JsonResponse /** * Get channels filtered by language */ - public function channelsByLanguage(int $languageId): JsonResponse + public function channelsByLanguage(Request $request, int $languageId): JsonResponse { - $language = Language::findOrFail($languageId); + $fields = $this->parseFields($request->get('fields'), [ + 'id', 'platform_instance_id', 'name', 'display_name', 'description', 'language_id' + ]); - $channels = $language->platformChannels() - ->where('platform_channels.is_active', true) - ->with(['platformInstance:id,name,url', 'language:id,name,short_code']) - ->get(['id', 'platform_instance_id', 'name', 'display_name', 'description', 'language_id']); + $channels = $this->languageService->getChannelsByLanguage( + $languageId, + $request->get('search'), + (int) $request->get('per_page', 15), + $fields + ); + + $language = Language::select(['name'])->findOrFail($languageId); return $this->sendResponse( $channels, "Channels for language '{$language->name}' retrieved successfully." ); } + + /** + * Get language statistics + */ + public function statistics(int $languageId): JsonResponse + { + $stats = $this->languageService->getLanguageStatistics($languageId); + $language = Language::select(['name'])->findOrFail($languageId); + + return $this->sendResponse( + $stats, + "Statistics for language '{$language->name}' retrieved successfully." + ); + } + + /** + * Get usage summary for all languages + */ + public function usageSummary(): JsonResponse + { + $summary = $this->languageService->getLanguageUsageSummary(); + + return $this->sendResponse( + $summary, + 'Language usage summary retrieved successfully.' + ); + } + + /** + * Get common languages between feed and channel + */ + public function commonLanguages(Request $request): JsonResponse + { + $request->validate([ + 'feed_id' => 'required|exists:feeds,id', + 'channel_id' => 'required|exists:platform_channels,id' + ]); + + $commonLanguages = $this->languageService->getCommonLanguages( + $request->get('feed_id'), + $request->get('channel_id') + ); + + return $this->sendResponse( + $commonLanguages, + 'Common languages retrieved successfully.' + ); + } + + /** + * Parse field selection from request + */ + private function parseFields(?string $fieldsParam, array $defaultFields): array + { + if (!$fieldsParam) { + return $defaultFields; + } + + $requestedFields = array_map('trim', explode(',', $fieldsParam)); + $validFields = []; + + foreach ($requestedFields as $field) { + if (in_array($field, $defaultFields) || str_contains($field, '.')) { + $validFields[] = $field; + } + } + + return empty($validFields) ? $defaultFields : $validFields; + } } diff --git a/backend/app/Http/Controllers/Api/V1/RoutingController.php b/backend/app/Http/Controllers/Api/V1/RoutingController.php index 02e3ef0..ba0f19e 100644 --- a/backend/app/Http/Controllers/Api/V1/RoutingController.php +++ b/backend/app/Http/Controllers/Api/V1/RoutingController.php @@ -8,18 +8,22 @@ use Domains\Feed\Models\Route; use Domains\Feed\Requests\StoreRouteRequest; use Domains\Feed\Requests\UpdateRouteRequest; +use Domains\Settings\Services\LanguageService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; class RoutingController extends BaseController { + public function __construct( + private LanguageService $languageService + ) {} /** * Display a listing of routing configurations */ public function index(): JsonResponse { - $routes = Route::with(['feed', 'platformChannel', 'language', 'keywords']) + $routes = Route::withAllRelationships() ->orderBy('is_active', 'desc') ->orderBy('priority', 'asc') ->get(); @@ -43,8 +47,17 @@ public function store(StoreRouteRequest $request): JsonResponse $route = Route::create($validated); + // Load relationships efficiently + $route->load([ + 'feed:id,name,url,type,provider,is_active', + 'platformChannel:id,platform_instance_id,name,display_name,description,language_id,is_active', + 'platformChannel.platformInstance:id,name,url', + 'language:id,short_code,name,native_name,is_active', + 'keywords:id,feed_id,platform_channel_id,keyword,is_active' + ]); + return $this->sendResponse( - new RouteResource($route->load(['feed', 'platformChannel', 'language', 'keywords'])), + new RouteResource($route), 'Routing configuration created successfully!', 201 ); @@ -66,7 +79,13 @@ public function show(Feed $feed, PlatformChannel $channel): JsonResponse return $this->sendNotFound('Routing configuration not found.'); } - $route->load(['feed', 'platformChannel', 'language', 'keywords']); + $route->load([ + 'feed:id,name,url,type,provider,is_active', + 'platformChannel:id,platform_instance_id,name,display_name,description,language_id,is_active', + 'platformChannel.platformInstance:id,name,url', + 'language:id,short_code,name,native_name,is_active', + 'keywords:id,feed_id,platform_channel_id,keyword,is_active' + ]); return $this->sendResponse( new RouteResource($route), @@ -156,6 +175,19 @@ public function toggle(Feed $feed, PlatformChannel $channel): JsonResponse } } + /** + * Get common languages for feed and channel + */ + public function commonLanguages(Feed $feed, PlatformChannel $channel): JsonResponse + { + $commonLanguages = $this->languageService->getCommonLanguages($feed->id, $channel->id); + + return $this->sendResponse( + $commonLanguages, + 'Common languages for feed and channel retrieved successfully.' + ); + } + /** * Find a route by feed and channel */ diff --git a/backend/database/migrations/2025_08_16_081350_add_language_performance_indexes.php b/backend/database/migrations/2025_08_16_081350_add_language_performance_indexes.php new file mode 100644 index 0000000..c044e42 --- /dev/null +++ b/backend/database/migrations/2025_08_16_081350_add_language_performance_indexes.php @@ -0,0 +1,53 @@ +index(['language_id', 'is_active', 'is_primary'], 'idx_feed_languages_filtering'); + }); + + // Add index on platform_channels for language-based channel lookups + Schema::table('platform_channels', function (Blueprint $table) { + $table->index(['language_id', 'is_active'], 'idx_platform_channels_language_active'); + }); + + // Add composite index on routes for efficient language-based queries + Schema::table('routes', function (Blueprint $table) { + $table->index(['language_id', 'is_active', 'priority'], 'idx_routes_language_active_priority'); + }); + + // Add index for language consistency validation queries + Schema::table('feed_languages', function (Blueprint $table) { + $table->index(['feed_id', 'language_id', 'is_active'], 'idx_feed_languages_validation'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('feed_languages', function (Blueprint $table) { + $table->dropIndex('idx_feed_languages_filtering'); + $table->dropIndex('idx_feed_languages_validation'); + }); + + Schema::table('platform_channels', function (Blueprint $table) { + $table->dropIndex('idx_platform_channels_language_active'); + }); + + Schema::table('routes', function (Blueprint $table) { + $table->dropIndex('idx_routes_language_active_priority'); + }); + } +}; diff --git a/backend/src/Domains/Feed/Models/Route.php b/backend/src/Domains/Feed/Models/Route.php index 84a048d..1303099 100644 --- a/backend/src/Domains/Feed/Models/Route.php +++ b/backend/src/Domains/Feed/Models/Route.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; /** @@ -129,4 +130,42 @@ public function getCommonLanguages(): array return in_array($channelLanguageId, $feedLanguageIds) ? [$channelLanguageId] : []; } + + /** + * Scope for eager loading all route relationships + */ + public function scopeWithAllRelationships(Builder $query): Builder + { + return $query->with([ + 'feed:id,name,url,type,provider,is_active', + 'platformChannel:id,platform_instance_id,name,display_name,description,language_id,is_active', + 'platformChannel.platformInstance:id,name,url', + 'language:id,short_code,name,native_name,is_active', + 'keywords:id,feed_id,platform_channel_id,keyword,is_active' + ]); + } + + /** + * Scope for minimal route data + */ + public function scopeMinimal(Builder $query): Builder + { + return $query->select(['feed_id', 'platform_channel_id', 'language_id', 'is_active', 'priority']); + } + + /** + * Scope for active routes only + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + /** + * Scope for routes by language + */ + public function scopeByLanguage(Builder $query, int $languageId): Builder + { + return $query->where('language_id', $languageId); + } } diff --git a/backend/src/Domains/Feed/Requests/StoreRouteRequest.php b/backend/src/Domains/Feed/Requests/StoreRouteRequest.php index 765a980..a275166 100644 --- a/backend/src/Domains/Feed/Requests/StoreRouteRequest.php +++ b/backend/src/Domains/Feed/Requests/StoreRouteRequest.php @@ -3,6 +3,7 @@ namespace Domains\Feed\Requests; use Domains\Feed\Models\Feed; +use Domains\Feed\Services\LanguageConsistencyValidator; use Domains\Platform\Models\PlatformChannel; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Validator; @@ -56,18 +57,12 @@ protected function validateLanguageConsistency(Validator $validator): void return; } - // Check if feed has the selected language - $feed = Feed::find($feedId); - if (!$feed || !$feed->languages()->where('languages.id', $languageId)->exists()) { - $validator->errors()->add('language_id', 'The selected feed does not support this language.'); - return; - } + $consistencyValidator = app(LanguageConsistencyValidator::class); + $result = $consistencyValidator->validateConsistency($feedId, $channelId, $languageId); - // Check if channel has the selected language - $channel = PlatformChannel::find($channelId); - if (!$channel || $channel->language_id !== (int)$languageId) { - $validator->errors()->add('language_id', 'The selected channel does not support this language.'); - return; + if (!$result['is_valid']) { + $message = $consistencyValidator->getValidationMessage($result); + $validator->errors()->add('language_id', $message); } } diff --git a/backend/src/Domains/Feed/Requests/UpdateRouteRequest.php b/backend/src/Domains/Feed/Requests/UpdateRouteRequest.php index 66d5ec1..c322a81 100644 --- a/backend/src/Domains/Feed/Requests/UpdateRouteRequest.php +++ b/backend/src/Domains/Feed/Requests/UpdateRouteRequest.php @@ -3,6 +3,7 @@ namespace Domains\Feed\Requests; use Domains\Feed\Models\Feed; +use Domains\Feed\Services\LanguageConsistencyValidator; use Domains\Platform\Models\PlatformChannel; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Validator; @@ -61,16 +62,12 @@ protected function validateLanguageConsistency(Validator $validator): void return; } - // Check if feed has the selected language - if (!$feed->languages()->where('languages.id', $languageId)->exists()) { - $validator->errors()->add('language_id', 'The selected feed does not support this language.'); - return; - } + $consistencyValidator = app(LanguageConsistencyValidator::class); + $result = $consistencyValidator->validateConsistency($feed->id, $channel->id, $languageId); - // Check if channel has the selected language - if ($channel->language_id !== (int)$languageId) { - $validator->errors()->add('language_id', 'The selected channel does not support this language.'); - return; + if (!$result['is_valid']) { + $message = $consistencyValidator->getValidationMessage($result); + $validator->errors()->add('language_id', $message); } } diff --git a/backend/src/Domains/Feed/Services/LanguageConsistencyValidator.php b/backend/src/Domains/Feed/Services/LanguageConsistencyValidator.php new file mode 100644 index 0000000..54eb8d7 --- /dev/null +++ b/backend/src/Domains/Feed/Services/LanguageConsistencyValidator.php @@ -0,0 +1,148 @@ + $result->feed_has_language && $result->channel_has_language, + 'feed_supports_language' => (bool) $result->feed_has_language, + 'channel_supports_language' => (bool) $result->channel_has_language, + ]; + } + + /** + * Get validation error message based on the validation result + */ + public function getValidationMessage(array $validationResult): ?string + { + if ($validationResult['is_valid']) { + return null; + } + + if (!$validationResult['feed_supports_language']) { + return 'The selected feed does not support this language.'; + } + + if (!$validationResult['channel_supports_language']) { + return 'The selected channel does not support this language.'; + } + + return 'Language consistency validation failed.'; + } + + /** + * Batch validate multiple feed+channel+language combinations + */ + public function batchValidate(array $combinations): array + { + if (empty($combinations)) { + return []; + } + + $conditions = []; + $params = []; + + foreach ($combinations as $index => $combo) { + $feedId = $combo['feed_id']; + $channelId = $combo['platform_channel_id']; + $languageId = $combo['language_id']; + + $conditions[] = "(fl.feed_id = ? AND fl.language_id = ? AND pc.id = ? AND pc.language_id = ?)"; + $params = array_merge($params, [$feedId, $languageId, $channelId, $languageId]); + } + + $sql = " + SELECT + fl.feed_id, + pc.id as channel_id, + fl.language_id, + COUNT(*) as is_valid + FROM feed_languages fl + JOIN feeds f ON f.id = fl.feed_id AND f.is_active = 1 + JOIN platform_channels pc ON pc.language_id = fl.language_id AND pc.is_active = 1 + WHERE fl.is_active = 1 + AND (" . implode(' OR ', $conditions) . ") + GROUP BY fl.feed_id, pc.id, fl.language_id + "; + + $results = DB::select($sql, $params); + + // Convert results to associative array for easy lookup + $validCombinations = []; + foreach ($results as $result) { + $key = "{$result->feed_id}_{$result->channel_id}_{$result->language_id}"; + $validCombinations[$key] = $result->is_valid > 0; + } + + // Map back to original combinations + $output = []; + foreach ($combinations as $index => $combo) { + $key = "{$combo['feed_id']}_{$combo['platform_channel_id']}_{$combo['language_id']}"; + $output[$index] = [ + 'is_valid' => $validCombinations[$key] ?? false, + 'feed_id' => $combo['feed_id'], + 'channel_id' => $combo['platform_channel_id'], + 'language_id' => $combo['language_id'], + ]; + } + + return $output; + } + + /** + * Get common languages between a feed and channel efficiently + */ + public function getCommonLanguages(int $feedId, int $channelId): array + { + $results = DB::select(" + SELECT DISTINCT fl.language_id, l.short_code, l.name + FROM feed_languages fl + JOIN feeds f ON f.id = fl.feed_id AND f.is_active = 1 + JOIN platform_channels pc ON pc.language_id = fl.language_id AND pc.is_active = 1 + JOIN languages l ON l.id = fl.language_id AND l.is_active = 1 + WHERE fl.feed_id = ? + AND pc.id = ? + AND fl.is_active = 1 + ORDER BY l.name + ", [$feedId, $channelId]); + + return array_map(function ($result) { + return [ + 'id' => $result->language_id, + 'short_code' => $result->short_code, + 'name' => $result->name, + ]; + }, $results); + } +} \ No newline at end of file diff --git a/backend/src/Domains/Settings/Models/Language.php b/backend/src/Domains/Settings/Models/Language.php index 45ae526..8c8d6e2 100644 --- a/backend/src/Domains/Settings/Models/Language.php +++ b/backend/src/Domains/Settings/Models/Language.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\DB; class Language extends Model { @@ -78,13 +79,20 @@ public function routes(): HasMany */ public function scopeAvailableForRoutes(Builder $query): Builder { - return $query->where('is_active', true) - ->whereHas('feeds', function (Builder $feedQuery) { - $feedQuery->where('feeds.is_active', true) + return $query->where('languages.is_active', true) + ->whereExists(function ($subQuery) { + $subQuery->select(DB::raw(1)) + ->from('feed_languages') + ->join('feeds', 'feeds.id', '=', 'feed_languages.feed_id') + ->whereColumn('feed_languages.language_id', 'languages.id') + ->where('feeds.is_active', true) ->where('feed_languages.is_active', true); }) - ->whereHas('platformChannels', function (Builder $channelQuery) { - $channelQuery->where('platform_channels.is_active', true); + ->whereExists(function ($subQuery) { + $subQuery->select(DB::raw(1)) + ->from('platform_channels') + ->whereColumn('platform_channels.language_id', 'languages.id') + ->where('platform_channels.is_active', true); }); } @@ -96,8 +104,12 @@ public function scopeAvailableForRoutes(Builder $query): Builder */ public function scopeWithActiveFeeds(Builder $query): Builder { - return $query->whereHas('feeds', function (Builder $feedQuery) { - $feedQuery->where('feeds.is_active', true) + return $query->whereExists(function ($subQuery) { + $subQuery->select(\DB::raw(1)) + ->from('feed_languages') + ->join('feeds', 'feeds.id', '=', 'feed_languages.feed_id') + ->whereColumn('feed_languages.language_id', 'languages.id') + ->where('feeds.is_active', true) ->where('feed_languages.is_active', true); }); } @@ -110,8 +122,11 @@ public function scopeWithActiveFeeds(Builder $query): Builder */ public function scopeWithActiveChannels(Builder $query): Builder { - return $query->whereHas('platformChannels', function (Builder $channelQuery) { - $channelQuery->where('platform_channels.is_active', true); + return $query->whereExists(function ($subQuery) { + $subQuery->select(\DB::raw(1)) + ->from('platform_channels') + ->whereColumn('platform_channels.language_id', 'languages.id') + ->where('platform_channels.is_active', true); }); } @@ -120,8 +135,75 @@ public function scopeWithActiveChannels(Builder $query): Builder */ public function canBeUsedForRoutes(): bool { - return $this->is_active && - $this->feeds()->where('feeds.is_active', true)->where('feed_languages.is_active', true)->exists() && - $this->platformChannels()->where('platform_channels.is_active', true)->exists(); + if (!$this->is_active) { + return false; + } + + // Use a single query to check both conditions efficiently + $hasActiveFeeds = DB::table('feed_languages') + ->join('feeds', 'feeds.id', '=', 'feed_languages.feed_id') + ->where('feed_languages.language_id', $this->id) + ->where('feeds.is_active', true) + ->where('feed_languages.is_active', true) + ->exists(); + + if (!$hasActiveFeeds) { + return false; + } + + return DB::table('platform_channels') + ->where('language_id', $this->id) + ->where('is_active', true) + ->exists(); + } + + /** + * Scope for eager loading language data with feeds and channels + */ + public function scopeWithLanguageData(Builder $query): Builder + { + return $query->with([ + 'feeds' => function ($query) { + $query->where('feeds.is_active', true) + ->where('feed_languages.is_active', true) + ->select(['feeds.id', 'feeds.name', 'feeds.url', 'feeds.type', 'feeds.provider']); + }, + 'platformChannels' => function ($query) { + $query->where('platform_channels.is_active', true) + ->with(['platformInstance:id,name,url']) + ->select(['id', 'platform_instance_id', 'name', 'display_name', 'description', 'language_id']); + }, + 'routes' => function ($query) { + $query->where('routes.is_active', true) + ->select(['id', 'feed_id', 'platform_channel_id', 'language_id', 'is_active', 'priority']); + } + ]); + } + + /** + * Scope for minimal language data (for dropdowns and lists) + */ + public function scopeMinimal(Builder $query): Builder + { + return $query->select(['id', 'short_code', 'name', 'native_name', 'is_active']); + } + + /** + * Scope for language data with counts + */ + public function scopeWithCounts(Builder $query): Builder + { + return $query->withCount([ + 'feeds as active_feeds_count' => function ($query) { + $query->where('feeds.is_active', true) + ->where('feed_languages.is_active', true); + }, + 'platformChannels as active_channels_count' => function ($query) { + $query->where('platform_channels.is_active', true); + }, + 'routes as active_routes_count' => function ($query) { + $query->where('routes.is_active', true); + } + ]); } } diff --git a/backend/src/Domains/Settings/Repositories/LanguageRepository.php b/backend/src/Domains/Settings/Repositories/LanguageRepository.php new file mode 100644 index 0000000..5e42f6f --- /dev/null +++ b/backend/src/Domains/Settings/Repositories/LanguageRepository.php @@ -0,0 +1,289 @@ +model + ->select($select) + ->where('languages.is_active', true) + ->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('feed_languages') + ->join('feeds', 'feeds.id', '=', 'feed_languages.feed_id') + ->whereColumn('feed_languages.language_id', 'languages.id') + ->where('feeds.is_active', true) + ->where('feed_languages.is_active', true); + }) + ->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('platform_channels') + ->whereColumn('platform_channels.language_id', 'languages.id') + ->where('platform_channels.is_active', true); + }) + ->orderBy('name') + ->get(); + } + + /** + * Find feeds by language with efficient joins + */ + public function findFeedsByLanguage( + int $languageId, + ?string $search = null, + array $select = ['feeds.*'], + int $perPage = 15 + ): LengthAwarePaginator { + $query = DB::table('feeds') + ->join('feed_languages', 'feeds.id', '=', 'feed_languages.feed_id') + ->where('feed_languages.language_id', $languageId) + ->where('feeds.is_active', true) + ->where('feed_languages.is_active', true) + ->select($select); + + if ($search) { + $query->where(function ($q) use ($search) { + $q->where('feeds.name', 'like', "%{$search}%") + ->orWhere('feeds.url', 'like', "%{$search}%"); + }); + } + + return $query->orderBy('feeds.name') + ->paginate(min($perPage, 100)); + } + + /** + * Find channels by language with efficient joins + */ + public function findChannelsByLanguage( + int $languageId, + ?string $search = null, + array $select = ['platform_channels.*'], + int $perPage = 15, + bool $withPlatformInstance = true + ): LengthAwarePaginator { + $query = DB::table('platform_channels'); + + if ($withPlatformInstance) { + $query->join('platform_instances', 'platform_channels.platform_instance_id', '=', 'platform_instances.id') + ->addSelect([ + 'platform_instances.id as platform_instance_id', + 'platform_instances.name as platform_instance_name', + 'platform_instances.url as platform_instance_url' + ]); + } + + $query->where('platform_channels.language_id', $languageId) + ->where('platform_channels.is_active', true) + ->select(array_merge($select, $withPlatformInstance ? [] : [])); + + if ($search) { + $query->where(function ($q) use ($search) { + $q->where('platform_channels.name', 'like', "%{$search}%") + ->orWhere('platform_channels.display_name', 'like', "%{$search}%"); + }); + } + + return $query->orderBy('platform_channels.name') + ->paginate(min($perPage, 100)); + } + + /** + * Get language statistics with single optimized query + */ + public function getLanguageStatistics(int $languageId): object + { + return DB::selectOne(" + SELECT + (SELECT COUNT(*) FROM feed_languages fl + JOIN feeds f ON f.id = fl.feed_id + WHERE fl.language_id = ? AND fl.is_active = 1 AND f.is_active = 1) as active_feeds_count, + (SELECT COUNT(*) FROM platform_channels pc + WHERE pc.language_id = ? AND pc.is_active = 1) as active_channels_count, + (SELECT COUNT(*) FROM routes r + WHERE r.language_id = ? AND r.is_active = 1) as active_routes_count + ", [$languageId, $languageId, $languageId]); + } + + /** + * Get comprehensive language usage summary + */ + public function getLanguageUsageSummary(): array + { + $results = DB::select(" + SELECT + l.id, + l.short_code, + l.name, + l.native_name, + l.is_active, + COALESCE(feed_stats.active_feeds, 0) as active_feeds_count, + COALESCE(channel_stats.active_channels, 0) as active_channels_count, + COALESCE(route_stats.active_routes, 0) as active_routes_count, + CASE WHEN COALESCE(feed_stats.active_feeds, 0) > 0 + AND COALESCE(channel_stats.active_channels, 0) > 0 + THEN 1 ELSE 0 END as can_create_routes + FROM languages l + LEFT JOIN ( + SELECT fl.language_id, COUNT(*) as active_feeds + FROM feed_languages fl + INNER JOIN feeds f ON f.id = fl.feed_id + WHERE fl.is_active = 1 AND f.is_active = 1 + GROUP BY fl.language_id + ) feed_stats ON feed_stats.language_id = l.id + LEFT JOIN ( + SELECT pc.language_id, COUNT(*) as active_channels + FROM platform_channels pc + WHERE pc.is_active = 1 + GROUP BY pc.language_id + ) channel_stats ON channel_stats.language_id = l.id + LEFT JOIN ( + SELECT r.language_id, COUNT(*) as active_routes + FROM routes r + WHERE r.is_active = 1 + GROUP BY r.language_id + ) route_stats ON route_stats.language_id = l.id + ORDER BY l.name + "); + + return array_map(fn($row) => (array)$row, $results); + } + + /** + * Find common languages between feed and channel with optimized query + */ + public function findCommonLanguages(int $feedId, int $channelId): array + { + $results = DB::select(" + SELECT DISTINCT l.id, l.short_code, l.name, l.native_name + FROM languages l + INNER JOIN feed_languages fl ON fl.language_id = l.id + INNER JOIN feeds f ON f.id = fl.feed_id + INNER JOIN platform_channels pc ON pc.language_id = l.id + WHERE f.id = ? + AND pc.id = ? + AND l.is_active = 1 + AND f.is_active = 1 + AND fl.is_active = 1 + AND pc.is_active = 1 + ORDER BY l.name + ", [$feedId, $channelId]); + + return array_map(fn($row) => (array)$row, $results); + } + + /** + * Check if language can be used for routes with optimized query + */ + public function canLanguageBeUsedForRoutes(int $languageId): bool + { + $result = DB::selectOne(" + SELECT + EXISTS(SELECT 1 FROM feed_languages fl + JOIN feeds f ON f.id = fl.feed_id + WHERE fl.language_id = ? AND fl.is_active = 1 AND f.is_active = 1) as has_feeds, + EXISTS(SELECT 1 FROM platform_channels pc + WHERE pc.language_id = ? AND pc.is_active = 1) as has_channels + ", [$languageId, $languageId]); + + return $result->has_feeds && $result->has_channels; + } + + /** + * Get active languages with optional counts + */ + public function findActiveLanguages( + bool $withCounts = false, + array $select = ['*'] + ): Collection { + $query = $this->model->where('is_active', true); + + if ($withCounts) { + $query->withCounts(); + } + + if ($select !== ['*'] && !empty($select)) { + $query->select($select); + } + + return $query->orderBy('name')->get(); + } + + /** + * Batch check language availability for multiple language IDs + */ + public function batchCheckLanguageAvailability(array $languageIds): array + { + if (empty($languageIds)) { + return []; + } + + $placeholders = str_repeat('?,', count($languageIds) - 1) . '?'; + + $results = DB::select(" + SELECT + l.id, + EXISTS(SELECT 1 FROM feed_languages fl + JOIN feeds f ON f.id = fl.feed_id + WHERE fl.language_id = l.id AND fl.is_active = 1 AND f.is_active = 1) as has_feeds, + EXISTS(SELECT 1 FROM platform_channels pc + WHERE pc.language_id = l.id AND pc.is_active = 1) as has_channels + FROM languages l + WHERE l.id IN ({$placeholders}) AND l.is_active = 1 + ", $languageIds); + + $availability = []; + foreach ($results as $result) { + $availability[$result->id] = $result->has_feeds && $result->has_channels; + } + + return $availability; + } + + /** + * Get language with relationship counts for specific language + */ + public function findWithCounts(int $languageId): ?Language + { + return $this->model + ->withCounts() + ->find($languageId); + } + + /** + * Search languages by name or code + */ + public function search( + string $term, + bool $activeOnly = true, + array $select = ['*'] + ): Collection { + $query = $this->model->select($select) + ->where(function ($q) use ($term) { + $q->where('name', 'like', "%{$term}%") + ->orWhere('native_name', 'like', "%{$term}%") + ->orWhere('short_code', 'like', "%{$term}%"); + }); + + if ($activeOnly) { + $query->where('is_active', true); + } + + return $query->orderBy('name')->get(); + } +} \ No newline at end of file diff --git a/backend/src/Domains/Settings/Services/LanguageService.php b/backend/src/Domains/Settings/Services/LanguageService.php new file mode 100644 index 0000000..028c498 --- /dev/null +++ b/backend/src/Domains/Settings/Services/LanguageService.php @@ -0,0 +1,147 @@ +languageRepository->findAvailableForRoutes($select); + } + + /** + * Get feeds by language with search and pagination + */ + public function getFeedsByLanguage( + int $languageId, + ?string $search = null, + int $perPage = 15, + array $fields = [] + ): LengthAwarePaginator { + $defaultFields = [ + 'feeds.id', 'feeds.name', 'feeds.url', 'feeds.type', 'feeds.provider' + ]; + + $selectedFields = !empty($fields) ? $fields : $defaultFields; + + return $this->languageRepository->findFeedsByLanguage( + $languageId, + $search, + $selectedFields, + $perPage + ); + } + + /** + * Get channels by language with search and pagination + */ + public function getChannelsByLanguage( + int $languageId, + ?string $search = null, + int $perPage = 15, + array $fields = [] + ): LengthAwarePaginator { + $defaultFields = [ + 'platform_channels.id', 'platform_channels.platform_instance_id', + 'platform_channels.name', 'platform_channels.display_name', + 'platform_channels.description', 'platform_channels.language_id' + ]; + + $selectedFields = !empty($fields) ? $fields : $defaultFields; + + return $this->languageRepository->findChannelsByLanguage( + $languageId, + $search, + $selectedFields, + $perPage, + true // withPlatformInstance + ); + } + + /** + * Get language statistics + */ + public function getLanguageStatistics(int $languageId): object + { + return $this->languageRepository->getLanguageStatistics($languageId); + } + + /** + * Validate route language consistency + */ + public function validateRouteLanguage(int $feedId, int $channelId, int $languageId): array + { + return $this->consistencyValidator->validateConsistency($feedId, $channelId, $languageId); + } + + /** + * Batch validate multiple route combinations + */ + public function batchValidateRoutes(array $routeData): array + { + return $this->consistencyValidator->batchValidate($routeData); + } + + /** + * Get common languages between a feed and channel + */ + public function getCommonLanguages(int $feedId, int $channelId): array + { + return $this->languageRepository->findCommonLanguages($feedId, $channelId); + } + + /** + * Check if a language can be used for routes + */ + public function canLanguageBeUsedForRoutes(int $languageId): bool + { + return $this->languageRepository->canLanguageBeUsedForRoutes($languageId); + } + + /** + * Get language usage summary + */ + public function getLanguageUsageSummary(): array + { + return $this->languageRepository->getLanguageUsageSummary(); + } + + /** + * Get languages with detailed relationship counts + */ + public function getLanguagesWithCounts(array $fields = []): Collection + { + return $this->languageRepository->findActiveLanguages(true, $fields); + } + + /** + * Find optimal language for feed-channel pair + */ + public function findOptimalLanguage(int $feedId, int $channelId): ?array + { + $commonLanguages = $this->getCommonLanguages($feedId, $channelId); + + if (empty($commonLanguages)) { + return null; + } + + // Return the first available language (could be enhanced with priority logic) + return $commonLanguages[0]; + } +} \ No newline at end of file diff --git a/backend/tests/Unit/Services/LanguageServiceTest.php b/backend/tests/Unit/Services/LanguageServiceTest.php new file mode 100644 index 0000000..5bca851 --- /dev/null +++ b/backend/tests/Unit/Services/LanguageServiceTest.php @@ -0,0 +1,226 @@ +languageService = app(LanguageService::class); + + // Create test data + $this->language = Language::factory()->create(['is_active' => true]); + $this->feed = Feed::factory()->create(['is_active' => true]); + + $platformInstance = PlatformInstance::factory()->create(); + $this->channel = PlatformChannel::factory()->create([ + 'platform_instance_id' => $platformInstance->id, + 'language_id' => $this->language->id, + 'is_active' => true + ]); + + // Create feed-language relationship + $this->feed->languages()->attach($this->language->id, [ + 'url' => $this->feed->url, + 'is_active' => true, + 'is_primary' => true + ]); + } + + public function test_get_available_for_routes_returns_languages_with_feeds_and_channels(): void + { + $languages = $this->languageService->getAvailableForRoutes(); + + $this->assertCount(1, $languages); + $this->assertEquals($this->language->id, $languages->first()->id); + } + + public function test_get_feeds_by_language_returns_paginated_results(): void + { + $result = $this->languageService->getFeedsByLanguage($this->language->id); + + $this->assertInstanceOf(\Illuminate\Pagination\LengthAwarePaginator::class, $result); + $this->assertCount(1, $result->items()); + $this->assertEquals($this->feed->id, $result->items()[0]->id); + } + + public function test_get_feeds_by_language_with_search(): void + { + $result = $this->languageService->getFeedsByLanguage( + $this->language->id, + $this->feed->name + ); + + $this->assertCount(1, $result->items()); + + // Test search that shouldn't match + $result = $this->languageService->getFeedsByLanguage( + $this->language->id, + 'nonexistent' + ); + + $this->assertCount(0, $result->items()); + } + + public function test_get_channels_by_language_returns_paginated_results(): void + { + $result = $this->languageService->getChannelsByLanguage($this->language->id); + + $this->assertInstanceOf(\Illuminate\Pagination\LengthAwarePaginator::class, $result); + $this->assertCount(1, $result->items()); + $this->assertEquals($this->channel->id, $result->items()[0]->id); + } + + public function test_get_language_statistics(): void + { + $stats = $this->languageService->getLanguageStatistics($this->language->id); + + $this->assertEquals(1, $stats->active_feeds_count); + $this->assertEquals(1, $stats->active_channels_count); + $this->assertEquals(0, $stats->active_routes_count); + } + + public function test_get_common_languages_between_feed_and_channel(): void + { + $commonLanguages = $this->languageService->getCommonLanguages( + $this->feed->id, + $this->channel->id + ); + + $this->assertCount(1, $commonLanguages); + $this->assertEquals($this->language->id, $commonLanguages[0]['id']); + } + + public function test_get_common_languages_with_no_match(): void + { + // Create another language and channel that doesn't match the feed + $otherLanguage = Language::factory()->create(['is_active' => true]); + $platformInstance = PlatformInstance::factory()->create(); + $otherChannel = PlatformChannel::factory()->create([ + 'platform_instance_id' => $platformInstance->id, + 'language_id' => $otherLanguage->id, + 'is_active' => true + ]); + + $commonLanguages = $this->languageService->getCommonLanguages( + $this->feed->id, + $otherChannel->id + ); + + $this->assertCount(0, $commonLanguages); + } + + public function test_can_language_be_used_for_routes(): void + { + $canBeUsed = $this->languageService->canLanguageBeUsedForRoutes($this->language->id); + $this->assertTrue($canBeUsed); + + // Test with language that has no feeds or channels + $unusableLanguage = Language::factory()->create(['is_active' => true]); + $canBeUsed = $this->languageService->canLanguageBeUsedForRoutes($unusableLanguage->id); + $this->assertFalse($canBeUsed); + } + + public function test_get_language_usage_summary(): void + { + $summary = $this->languageService->getLanguageUsageSummary(); + + $this->assertIsArray($summary); + $this->assertGreaterThanOrEqual(1, count($summary)); + + $languageData = collect($summary)->where('id', $this->language->id)->first(); + $this->assertNotNull($languageData); + $this->assertEquals(1, $languageData['active_feeds_count']); + $this->assertEquals(1, $languageData['active_channels_count']); + $this->assertEquals(1, $languageData['can_create_routes']); + } + + public function test_find_optimal_language(): void + { + $optimalLanguage = $this->languageService->findOptimalLanguage( + $this->feed->id, + $this->channel->id + ); + + $this->assertNotNull($optimalLanguage); + $this->assertEquals($this->language->id, $optimalLanguage['id']); + } + + public function test_find_optimal_language_with_no_common_languages(): void + { + // Create another language and channel that doesn't match the feed + $otherLanguage = Language::factory()->create(['is_active' => true]); + $platformInstance = PlatformInstance::factory()->create(); + $otherChannel = PlatformChannel::factory()->create([ + 'platform_instance_id' => $platformInstance->id, + 'language_id' => $otherLanguage->id, + 'is_active' => true + ]); + + $optimalLanguage = $this->languageService->findOptimalLanguage( + $this->feed->id, + $otherChannel->id + ); + + $this->assertNull($optimalLanguage); + } + + public function test_validate_route_language(): void + { + $result = $this->languageService->validateRouteLanguage( + $this->feed->id, + $this->channel->id, + $this->language->id + ); + + $this->assertTrue($result['is_valid']); + $this->assertTrue($result['feed_supports_language']); + $this->assertTrue($result['channel_supports_language']); + } + + public function test_batch_validate_routes(): void + { + $routeData = [ + [ + 'feed_id' => $this->feed->id, + 'platform_channel_id' => $this->channel->id, + 'language_id' => $this->language->id + ] + ]; + + $results = $this->languageService->batchValidateRoutes($routeData); + + $this->assertCount(1, $results); + $this->assertTrue($results[0]['is_valid']); + } + + public function test_get_languages_with_counts(): void + { + $languages = $this->languageService->getLanguagesWithCounts(); + + $this->assertGreaterThanOrEqual(1, $languages->count()); + + $language = $languages->where('id', $this->language->id)->first(); + $this->assertNotNull($language); + $this->assertEquals(1, $language->active_feeds_count); + $this->assertEquals(1, $language->active_channels_count); + } +} \ No newline at end of file