Mod Version Dependency Updates

- Handles circular mod version dependencies
- Optimizes mod show query to only pull the versions relationship
- Adds a mod dependency factory
- Refactored tests to use mod dependency factory
- Adds mod dependency generation into the default seeder
- Adds unique index on mod dependencies table
- Adds mod dependencies on the mod show view
This commit is contained in:
Refringe 2024-08-01 17:15:02 -04:00
parent 20e1ab2dae
commit c6f252ace7
Signed by: Refringe
SSH Key Fingerprint: SHA256:t865XsQpfTeqPRBMN2G6+N8wlDjkgUCZF3WGW6O9N/k
9 changed files with 225 additions and 94 deletions

View File

@ -0,0 +1,10 @@
<?php
namespace App\Exceptions;
use Exception;
class CircularDependencyException extends Exception
{
protected $message = 'Circular dependency detected.';
}

View File

@ -31,20 +31,23 @@ class ModController extends Controller
->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)

View File

@ -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.
*/

View File

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

View File

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use App\Models\Mod;
use App\Models\ModDependency;
use App\Models\ModVersion;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
class ModDependencyFactory extends Factory
{
protected $model = ModDependency::class;
public function definition(): array
{
return [
'mod_version_id' => 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)),
];
}
}

View File

@ -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');
});
}

View File

@ -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)];
}
}

View File

@ -25,15 +25,15 @@
<h2 class="pb-1 sm:p-0 text-3xl font-bold text-gray-900 dark:text-white">
{{ $mod->name }}
<span class="font-light text-nowrap text-gray-700 dark:text-gray-400">
{{ $mod->latestVersion->version }}
{{ $latestVersion->version }}
</span>
</h2>
<span class="badge-version {{ $mod->latestVersion->sptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ $mod->latestVersion->sptVersion->version }}
<span class="badge-version {{ $latestVersion->sptVersion->color_class }} inline-flex items-center rounded-md px-2 py-1 text-xs font-medium text-nowrap">
{{ $latestVersion->sptVersion->version }}
</span>
</div>
<p>{{ __('Created by') }} {{ $mod->users->pluck('name')->implode(', ') }}</p>
<p>{{ $mod->latestVersion->sptVersion->version }} {{ __('Compatible') }}</p>
<p>{{ $latestVersion->sptVersion->version }} {{ __('Compatible') }}</p>
<p>{{ Number::format($mod->total_downloads) }} {{ __('Downloads') }}</p>
</div>
</div>
@ -94,12 +94,22 @@
</span>
<a href="{{ $version->virus_total_link }}">{{__('Virus Total Results')}}</a>
</div>
<div class="flex items-center justify-between text-gray-400">
<div class="flex items-center justify-between text-gray-600 dark:text-gray-400">
<span>{{ __('Created') }} {{ $version->created_at->format("M d, h:m a") }}</span>
<span>{{ __('Updated') }} {{ $version->updated_at->format("M d, h:m a") }}</span>
</div>
@if ($version->dependencies->count())
<div class="text-gray-600 dark:text-gray-400">
{{ __('Dependencies:') }}
@foreach ($version->dependencies as $dependency)
<a href="{{ route('mod.show', [$dependency->resolvedVersion->mod->id, $dependency->resolvedVersion->mod->slug]) }}">
{{ $dependency->resolvedVersion->mod->name }}&nbsp;({{ $dependency->resolvedVersion->version }})
</a>@if (!$loop->last), @endif
@endforeach
</div>
@endif
</div>
<div class="pt-3 user-markdown">
<div class="p-3 user-markdown">
{{-- The description below is safe to write directly because it has been run though HTMLPurifier during the import process. --}}
{!! Str::markdown($version->description) !!}
</div>
@ -118,15 +128,15 @@
<div class="col-span-1 flex flex-col gap-6">
{{-- Main Download Button --}}
<a href="{{ $mod->latestVersion->link }}" class="block">
<button class="text-lg font-extrabold hover:bg-cyan-400 dark:hover:bg-cyan-600 shadow-md dark:shadow-gray-950 drop-shadow-2xl bg-cyan-500 dark:bg-cyan-700 rounded-xl w-full h-20">{{ __('Download Latest Version') }} ({{ $mod->latestVersion->version }})</button>
<a href="{{ $latestVersion->link }}" class="block">
<button class="text-lg font-extrabold hover:bg-cyan-400 dark:hover:bg-cyan-600 shadow-md dark:shadow-gray-950 drop-shadow-2xl bg-cyan-500 dark:bg-cyan-700 rounded-xl w-full h-20">{{ __('Download Latest Version') }} ({{ $latestVersion->version }})</button>
</a>
{{-- Additional Mod Details --}}
<div class="p-4 sm:p-6 bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl">
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ __('Details') }}</h2>
<ul role="list" class="divide-y divide-gray-200 dark:divide-gray-800 text-gray-900 dark:text-gray-100">
@if($mod->license)
@if ($mod->license)
<li class="px-4 py-4 sm:px-0">
<h3>{{ __('License') }}</h3>
<p class="truncate">
@ -136,7 +146,7 @@
</p>
</li>
@endif
@if($mod->source_code_link)
@if ($mod->source_code_link)
<li class="px-4 py-4 sm:px-0">
<h3>{{ __('Source Code') }}</h3>
<p class="truncate">
@ -146,17 +156,29 @@
</p>
</li>
@endif
@if($mod->latestVersion->virus_total_link)
@if ($latestVersion->virus_total_link)
<li class="px-4 py-4 sm:px-0">
<h3>{{ __('Latest VirusTotal Result') }}</h3>
<h3>{{ __('Latest Version VirusTotal Result') }}</h3>
<p class="truncate">
<a href="{{ $mod->latestVersion->virus_total_link }}" title="{{ $mod->latestVersion->virus_total_link }}" target="_blank">
{{ $mod->latestVersion->virus_total_link }}
<a href="{{ $latestVersion->virus_total_link }}" title="{{ $latestVersion->virus_total_link }}" target="_blank">
{{ $latestVersion->virus_total_link }}
</a>
</p>
</li>
@endif
@if($mod->contains_ads)
@if ($latestVersion->dependencies->count())
<li class="px-4 py-4 sm:px-0">
<h3>{{ __('Latest Version Dependencies') }}</h3>
<p class="truncate">
@foreach ($latestVersion->dependencies as $dependency)
<a href="{{ route('mod.show', [$dependency->resolvedVersion->mod->id, $dependency->resolvedVersion->mod->slug]) }}">
{{ $dependency->resolvedVersion->mod->name }}&nbsp;({{ $dependency->resolvedVersion->version }})
</a><br />
@endforeach
</p>
</li>
@endif
@if ($mod->contains_ads)
<li class="px-4 py-4 sm:px-0 flex flex-row gap-2 items-center">
<svg class="grow-0 w-[16px] h-[16px]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14Zm3.844-8.791a.75.75 0 0 0-1.188-.918l-3.7 4.79-1.649-1.833a.75.75 0 1 0-1.114 1.004l2.25 2.5a.75.75 0 0 0 1.15-.043l4.25-5.5Z" clip-rule="evenodd"/>
@ -166,7 +188,7 @@
</h3>
</li>
@endif
@if($mod->contains_ai_content)
@if ($mod->contains_ai_content)
<li class="px-4 py-4 sm:px-0 flex flex-row gap-2 items-center">
<svg class="grow-0 w-[16px] h-[16px]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14Zm3.844-8.791a.75.75 0 0 0-1.188-.918l-3.7 4.79-1.649-1.833a.75.75 0 1 0-1.114 1.004l2.25 2.5a.75.75 0 0 0 1.15-.043l4.25-5.5Z" clip-rule="evenodd"/>

View File

@ -1,5 +1,6 @@
<?php
use App\Exceptions\CircularDependencyException;
use App\Models\Mod;
use App\Models\ModDependency;
use App\Models\ModVersion;
@ -19,13 +20,12 @@ it('resolves mod version dependency when mod version is created', 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',
]);
$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);