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 @@

{{ $mod->name }} - {{ $mod->latestVersion->version }} + {{ $latestVersion->version }}

- - {{ $mod->latestVersion->sptVersion->version }} + + {{ $latestVersion->sptVersion->version }}

{{ __('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')}} -
+
{{ __('Created') }} {{ $version->created_at->format("M d, h:m a") }} {{ __('Updated') }} {{ $version->updated_at->format("M d, h:m a") }}
+ @if ($version->dependencies->count()) +
+ {{ __('Dependencies:') }} + @foreach ($version->dependencies as $dependency) + + {{ $dependency->resolvedVersion->mod->name }} ({{ $dependency->resolvedVersion->version }}) + @if (!$loop->last), @endif + @endforeach +
+ @endif
-
+
{{-- The description below is safe to write directly because it has been run though HTMLPurifier during the import process. --}} {!! Str::markdown($version->description) !!}
@@ -118,15 +128,15 @@
{{-- Main Download Button --}} - - + + {{-- Additional Mod Details --}}

{{ __('Details') }}

    - @if($mod->license) + @if ($mod->license)
  • {{ __('License') }}

    @@ -136,7 +146,7 @@

  • @endif - @if($mod->source_code_link) + @if ($mod->source_code_link)
  • {{ __('Source Code') }}

    @@ -146,17 +156,29 @@

  • @endif - @if($mod->latestVersion->virus_total_link) + @if ($latestVersion->virus_total_link)
  • -

    {{ __('Latest VirusTotal Result') }}

    +

    {{ __('Latest Version VirusTotal Result') }}

    - - {{ $mod->latestVersion->virus_total_link }} + + {{ $latestVersion->virus_total_link }}

  • @endif - @if($mod->contains_ads) + @if ($latestVersion->dependencies->count()) +
  • +

    {{ __('Latest Version Dependencies') }}

    +

    + @foreach ($latestVersion->dependencies as $dependency) + + {{ $dependency->resolvedVersion->mod->name }} ({{ $dependency->resolvedVersion->version }}) +
    + @endforeach +

    +
  • + @endif + @if ($mod->contains_ads)
  • @@ -166,7 +188,7 @@
  • @endif - @if($mod->contains_ai_content) + @if ($mod->contains_ai_content)
  • diff --git a/tests/Feature/ModDependencyTest.php b/tests/Feature/ModDependencyTest.php index f93582d..50ea28d 100644 --- a/tests/Feature/ModDependencyTest.php +++ b/tests/Feature/ModDependencyTest.php @@ -1,5 +1,6 @@ create(['mod_id' => $modA->id, 'version' => '1.0.0']); - $modDependency = ModDependency::create([ - 'mod_version_id' => $modAv1->id, - 'dependency_mod_id' => $modB->id, + $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ 'version_constraint' => '^1.0.0', ]); $modDependency->refresh(); + expect($modDependency->resolvedVersion->version)->toBe('1.1.1'); }); @@ -41,9 +41,7 @@ it('resolves mod version dependency when mod version is updated', function () { // Create versions for Mod A that depends on Mod B $modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']); - $modDependency = ModDependency::create([ - 'mod_version_id' => $modAv1->id, - 'dependency_mod_id' => $modB->id, + $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ 'version_constraint' => '^1.0.0', ]); @@ -69,9 +67,7 @@ it('resolves mod version dependency when mod version is deleted', function () { // Create versions for Mod A that depends on Mod B $modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']); - $modDependency = ModDependency::create([ - 'mod_version_id' => $modAv1->id, - 'dependency_mod_id' => $modB->id, + $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ 'version_constraint' => '^1.0.0', ]); @@ -98,9 +94,7 @@ it('resolves mod version dependency after semantic version constraint is updated // Create versions for Mod A that depends on Mod B $modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']); - $modDependency = ModDependency::create([ - 'mod_version_id' => $modAv1->id, - 'dependency_mod_id' => $modB->id, + $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ 'version_constraint' => '^1.0.0', ]); @@ -126,9 +120,7 @@ it('resolves mod version dependency with exact semantic version constraint', fun // Create versions for Mod A that depends on Mod B $modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']); - $modDependency = ModDependency::create([ - 'mod_version_id' => $modAv1->id, - 'dependency_mod_id' => $modB->id, + $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ 'version_constraint' => '1.1.0', ]); @@ -150,9 +142,7 @@ it('resolves mod version dependency with complex semantic version constraint', f // Create versions for Mod A that depends on Mod B $modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']); - $modDependency = ModDependency::create([ - 'mod_version_id' => $modAv1->id, - 'dependency_mod_id' => $modB->id, + $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ 'version_constraint' => '>=1.0.0 <2.0.0', ]); @@ -171,9 +161,7 @@ it('resolves null when no mod versions are available', function () { // Create version for Mod A that has no resolvable dependency $modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']); - $modDependency = ModDependency::create([ - 'mod_version_id' => $modAv1->id, - 'dependency_mod_id' => $modB->id, + $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ 'version_constraint' => '^1.0.0', ]); @@ -192,9 +180,7 @@ it('resolves null when no mod versions match against semantic version constraint // Create version for Mod A that has no resolvable dependency $modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']); - $modDependency = ModDependency::create([ - 'mod_version_id' => $modAv1->id, - 'dependency_mod_id' => $modB->id, + $modDependency = ModDependency::factory()->recycle([$modAv1, $modB])->create([ 'version_constraint' => '~1.2.0', ]); @@ -220,14 +206,10 @@ it('resolves multiple dependencies', function () { // Creating a version for Mod A that depends on Mod B and Mod C $modAv1 = ModVersion::factory()->create(['mod_id' => $modA->id, 'version' => '1.0.0']); - $modDependencyB = ModDependency::create([ - 'mod_version_id' => $modAv1->id, - 'dependency_mod_id' => $modB->id, + $modDependencyB = ModDependency::factory()->recycle([$modAv1, $modB])->create([ 'version_constraint' => '^1.0.0', ]); - $modDependencyC = ModDependency::create([ - 'mod_version_id' => $modAv1->id, - 'dependency_mod_id' => $modC->id, + $modDependencyC = ModDependency::factory()->recycle([$modAv1, $modC])->create([ 'version_constraint' => '^1.0.0', ]); @@ -237,3 +219,20 @@ it('resolves multiple dependencies', function () { $modDependencyC->refresh(); expect($modDependencyC->resolvedVersion->version)->toBe('1.1.1'); }); + +it('throws exception when there is a circular version dependency', function () { + $modA = Mod::factory()->create(['name' => 'Mod A']); + $modAv1 = ModVersion::factory()->recycle($modA)->create(['version' => '1.0.0']); + + $modB = Mod::factory()->create(['name' => 'Mod B']); + $modBv1 = ModVersion::factory()->recycle($modB)->create(['version' => '1.0.0']); + + $modDependencyAtoB = ModDependency::factory()->recycle([$modAv1, $modB])->create([ + 'version_constraint' => '1.0.0', + ]); + + // Create circular dependencies + $modDependencyBtoA = ModDependency::factory()->recycle([$modBv1, $modA])->create([ + 'version_constraint' => '1.0.0', + ]); +})->throws(CircularDependencyException::class);