diff --git a/app/Exceptions/CircularDependencyException.php b/app/Exceptions/CircularDependencyException.php new file mode 100644 index 0000000..d66b945 --- /dev/null +++ b/app/Exceptions/CircularDependencyException.php @@ -0,0 +1,10 @@ +with([ 'versions', 'versions.sptVersion', - 'latestVersion', - 'latestVersion.sptVersion', + 'versions.dependencies', + 'versions.dependencies.resolvedVersion', + 'versions.dependencies.resolvedVersion.mod', 'users:id,name', 'license:id,name,link', ]) - ->find($modId); + ->findOrFail($modId); - if (! $mod || $mod->slug !== $slug) { + if ($mod->slug !== $slug) { abort(404); } $this->authorize('view', $mod); - return view('mod.show', compact('mod')); + $latestVersion = $mod->versions->sortByDesc('created_at')->first(); + + return view('mod.show', compact(['mod', 'latestVersion'])); } public function update(ModRequest $request, Mod $mod) diff --git a/app/Models/ModDependency.php b/app/Models/ModDependency.php index f4eb842..81df44f 100644 --- a/app/Models/ModDependency.php +++ b/app/Models/ModDependency.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -14,6 +15,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; */ class ModDependency extends Model { + use HasFactory; + /** * The relationship between a mod dependency and mod version. */ diff --git a/app/Services/ModVersionService.php b/app/Services/ModVersionService.php index 68d6bb8..7d6c9cc 100644 --- a/app/Services/ModVersionService.php +++ b/app/Services/ModVersionService.php @@ -2,58 +2,98 @@ namespace App\Services; +use App\Exceptions\CircularDependencyException; +use App\Models\ModDependency; use App\Models\ModVersion; use Composer\Semver\Semver; -use Illuminate\Support\Facades\Log; +use Illuminate\Database\Eloquent\Collection; class ModVersionService { - // TODO: This works, but it needs to be refactored. It's too big and does too much. + protected array $visited = []; + + protected array $stack = []; + + /** + * Resolve dependencies for the given mod version. + * + * @throws CircularDependencyException + */ public function resolveDependencies(ModVersion $modVersion): array { $resolvedVersions = []; + $this->visited = []; + $this->stack = []; - try { - // Eager load dependencies with related mod versions - $dependencies = $modVersion->dependencies()->with(['dependencyMod.versions'])->get(); - - foreach ($dependencies as $dependency) { - $dependencyMod = $dependency->dependencyMod; - - // Ensure dependencyMod exists and has versions - if (! $dependencyMod || $dependencyMod->versions->isEmpty()) { - if ($dependency->resolved_version_id !== null) { - $dependency->updateQuietly(['resolved_version_id' => null]); - } - $resolvedVersions[$dependency->id] = null; - - continue; - } - - // Get available versions in the form ['version' => 'id'] - $availableVersions = $dependencyMod->versions->pluck('id', 'version')->toArray(); - - // Find the latest version that satisfies the constraint - $satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $dependency->version_constraint); - - // Get the first element's id from satisfyingVersions - $latestVersionId = $satisfyingVersions ? $availableVersions[reset($satisfyingVersions)] : null; - - // Update the resolved version ID in the ModDependency record - if ($dependency->resolved_version_id !== $latestVersionId) { - $dependency->updateQuietly(['resolved_version_id' => $latestVersionId]); - } - - // Add the resolved ModVersion to the array (or null if not found) - $resolvedVersions[$dependency->id] = $latestVersionId ? ModVersion::find($latestVersionId) : null; - } - } catch (\Exception $e) { - Log::error('Error resolving dependencies for ModVersion: '.$modVersion->id, [ - 'exception' => $e->getMessage(), - 'mod_version_id' => $modVersion->id, - ]); - } + $this->processDependencies($modVersion, $resolvedVersions); return $resolvedVersions; } + + /** + * Perform a depth-first search to resolve dependencies for the given mod version. + * + * @throws CircularDependencyException + */ + protected function processDependencies(ModVersion $modVersion, array &$resolvedVersions): void + { + if (in_array($modVersion->id, $this->stack)) { + throw new CircularDependencyException("Circular dependency detected in ModVersion ID: {$modVersion->id}"); + } + + if (in_array($modVersion->id, $this->visited)) { + return; // Skip already processed versions + } + + $this->visited[] = $modVersion->id; + $this->stack[] = $modVersion->id; + + /** @var Collection|ModDependency[] $dependencies */ + $dependencies = $this->getDependencies($modVersion); + + foreach ($dependencies as $dependency) { + $resolvedVersionId = $this->resolveVersionIdForDependency($dependency); + + if ($dependency->resolved_version_id !== $resolvedVersionId) { + $dependency->updateQuietly(['resolved_version_id' => $resolvedVersionId]); + } + + $resolvedVersions[$dependency->id] = $resolvedVersionId ? ModVersion::find($resolvedVersionId) : null; + + if ($resolvedVersionId) { + $nextModVersion = ModVersion::find($resolvedVersionId); + if ($nextModVersion) { + $this->processDependencies($nextModVersion, $resolvedVersions); + } + } + } + + array_pop($this->stack); + } + + /** + * Get the dependencies for the given mod version. + */ + protected function getDependencies(ModVersion $modVersion): Collection + { + return $modVersion->dependencies()->with(['dependencyMod.versions'])->get(); + } + + /** + * Resolve the latest version ID that satisfies the version constraint on given dependency. + */ + protected function resolveVersionIdForDependency(ModDependency $dependency): ?int + { + $mod = $dependency->dependencyMod; + + if (! $mod || $mod->versions->isEmpty()) { + return null; + } + + $availableVersions = $mod->versions->pluck('id', 'version')->toArray(); + $satisfyingVersions = Semver::satisfiedBy(array_keys($availableVersions), $dependency->version_constraint); + + // Versions are sorted in descending order by default. Take the first key (the latest version) using `reset()`. + return $satisfyingVersions ? $availableVersions[reset($satisfyingVersions)] : null; + } } diff --git a/database/factories/ModDependencyFactory.php b/database/factories/ModDependencyFactory.php new file mode 100644 index 0000000..3c77d8d --- /dev/null +++ b/database/factories/ModDependencyFactory.php @@ -0,0 +1,25 @@ + ModVersion::factory(), + 'dependency_mod_id' => Mod::factory(), + 'version_constraint' => '^'.$this->faker->numerify('#.#.#'), + 'created_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)), + 'updated_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)), + ]; + } +} diff --git a/database/migrations/2024_07_25_161219_create_mod_dependencies.php b/database/migrations/2024_07_25_161219_create_mod_dependencies.php index 8ba53bc..9e81446 100644 --- a/database/migrations/2024_07_25_161219_create_mod_dependencies.php +++ b/database/migrations/2024_07_25_161219_create_mod_dependencies.php @@ -25,6 +25,8 @@ return new class extends Migration ->nullOnDelete() ->cascadeOnUpdate(); $table->timestamps(); + + $table->unique(['mod_version_id', 'dependency_mod_id', 'version_constraint'], 'mod_dependencies_unique'); }); } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 264a204..da050de 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,8 +2,10 @@ namespace Database\Seeders; +use App\Exceptions\CircularDependencyException; use App\Models\License; use App\Models\Mod; +use App\Models\ModDependency; use App\Models\ModVersion; use App\Models\SptVersion; use App\Models\User; @@ -46,6 +48,31 @@ class DatabaseSeeder extends Seeder } // Add 1000 mod versions, assigning them to the mods we just created. - ModVersion::factory(1000)->recycle([$mods, $spt_versions])->create(); + $modVersions = ModVersion::factory(1000)->recycle([$mods, $spt_versions])->create(); + + // Add ModDependencies to a subset of ModVersions. + foreach ($modVersions as $modVersion) { + $hasDependencies = rand(0, 100) < 30; // 30% chance to have dependencies + if ($hasDependencies) { + $numDependencies = rand(1, 3); // 1 to 3 dependencies + $dependencyMods = $mods->random($numDependencies); + foreach ($dependencyMods as $dependencyMod) { + try { + ModDependency::factory()->recycle([$modVersion, $dependencyMod])->create([ + 'version_constraint' => $this->generateVersionConstraint(), + ]); + } catch (CircularDependencyException $e) { + continue; + } + } + } + } + } + + private function generateVersionConstraint(): string + { + $versionConstraints = ['*', '^1.0.0', '>=2.0.0', '~1.1.0', '>=1.2.0 <2.0.0']; + + return $versionConstraints[array_rand($versionConstraints)]; } } diff --git a/resources/views/mod/show.blade.php b/resources/views/mod/show.blade.php index 2450b42..cf3f3e8 100644 --- a/resources/views/mod/show.blade.php +++ b/resources/views/mod/show.blade.php @@ -25,15 +25,15 @@
{{ __('Created by') }} {{ $mod->users->pluck('name')->implode(', ') }}
-{{ $mod->latestVersion->sptVersion->version }} {{ __('Compatible') }}
+{{ $latestVersion->sptVersion->version }} {{ __('Compatible') }}
{{ Number::format($mod->total_downloads) }} {{ __('Downloads') }}
@@ -94,12 +94,22 @@ {{__('Virus Total Results')}} -@@ -136,7 +146,7 @@
@@ -146,17 +156,29 @@
- - {{ $mod->latestVersion->virus_total_link }} + + {{ $latestVersion->virus_total_link }}
+ @foreach ($latestVersion->dependencies as $dependency)
+
+ {{ $dependency->resolvedVersion->mod->name }} ({{ $dependency->resolvedVersion->version }})
+
+ @endforeach
+