diff --git a/app/Models/Mod.php b/app/Models/Mod.php index badebf9..36a5ca5 100644 --- a/app/Models/Mod.php +++ b/app/Models/Mod.php @@ -16,6 +16,8 @@ use Illuminate\Support\Str; use Laravel\Scout\Searchable; /** + * @property int $id + * @property string $name * @property string $slug */ class Mod extends Model diff --git a/app/Models/ModDependency.php b/app/Models/ModDependency.php new file mode 100644 index 0000000..f4eb842 --- /dev/null +++ b/app/Models/ModDependency.php @@ -0,0 +1,40 @@ +belongsTo(ModVersion::class); + } + + /** + * The relationship between a mod dependency and mod. + */ + public function dependencyMod(): BelongsTo + { + return $this->belongsTo(Mod::class, 'dependency_mod_id'); + } + + /** + * The relationship between a mod dependency and resolved mod version. + */ + public function resolvedVersion(): BelongsTo + { + return $this->belongsTo(ModVersion::class, 'resolved_version_id'); + } +} diff --git a/app/Models/ModVersion.php b/app/Models/ModVersion.php index a167039..e14a254 100644 --- a/app/Models/ModVersion.php +++ b/app/Models/ModVersion.php @@ -6,8 +6,14 @@ use App\Models\Scopes\DisabledScope; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +/** + * @property int $id + * @property int $mod_id + * @property string $version + */ class ModVersion extends Model { use HasFactory, SoftDeletes; @@ -22,6 +28,14 @@ class ModVersion extends Model return $this->belongsTo(Mod::class); } + /** + * The relationship between a mod version and its dependencies. + */ + public function dependencies(): HasMany + { + return $this->hasMany(ModDependency::class); + } + public function sptVersion(): BelongsTo { return $this->belongsTo(SptVersion::class); diff --git a/app/Observers/ModDependencyObserver.php b/app/Observers/ModDependencyObserver.php new file mode 100644 index 0000000..2d6eeff --- /dev/null +++ b/app/Observers/ModDependencyObserver.php @@ -0,0 +1,33 @@ +modVersionService = $modVersionService; + } + + public function saved(ModDependency $modDependency): void + { + $modVersion = ModVersion::find($modDependency->mod_version_id); + if ($modVersion) { + $this->modVersionService->resolveDependencies($modVersion); + } + } + + public function deleted(ModDependency $modDependency): void + { + $modVersion = ModVersion::find($modDependency->mod_version_id); + if ($modVersion) { + $this->modVersionService->resolveDependencies($modVersion); + } + } +} diff --git a/app/Observers/ModVersionObserver.php b/app/Observers/ModVersionObserver.php new file mode 100644 index 0000000..4d4195a --- /dev/null +++ b/app/Observers/ModVersionObserver.php @@ -0,0 +1,33 @@ +modVersionService = $modVersionService; + } + + public function saved(ModVersion $modVersion): void + { + $dependencies = ModDependency::where('resolved_version_id', $modVersion->id)->get(); + foreach ($dependencies as $dependency) { + $this->modVersionService->resolveDependencies($dependency->modVersion); + } + } + + public function deleted(ModVersion $modVersion): void + { + $dependencies = ModDependency::where('resolved_version_id', $modVersion->id)->get(); + foreach ($dependencies as $dependency) { + $this->modVersionService->resolveDependencies($dependency->modVersion); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index dd2a10a..86eacd5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,11 @@ namespace App\Providers; +use App\Models\ModDependency; +use App\Models\ModVersion; use App\Models\User; +use App\Observers\ModDependencyObserver; +use App\Observers\ModVersionObserver; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; @@ -25,6 +29,10 @@ class AppServiceProvider extends ServiceProvider // Allow mass assignment for all models. Be careful! Model::unguard(); + // Register observers. + ModVersion::observe(ModVersionObserver::class); + ModDependency::observe(ModDependencyObserver::class); + // This gate determines who can access the Pulse dashboard. Gate::define('viewPulse', function (User $user) { return $user->isAdmin(); diff --git a/app/Services/ModVersionService.php b/app/Services/ModVersionService.php new file mode 100644 index 0000000..68d6bb8 --- /dev/null +++ b/app/Services/ModVersionService.php @@ -0,0 +1,59 @@ +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, + ]); + } + + return $resolvedVersions; + } +} diff --git a/composer.json b/composer.json index 453b984..3933260 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "ext-curl": "*", "ext-intl": "*", "aws/aws-sdk-php": "^3.314", + "composer/semver": "^3.4", "filament/filament": "^3.2", "http-interop/http-factory-guzzle": "^1.2", "laravel/framework": "^11.11", diff --git a/composer.lock b/composer.lock index ec1e5e9..49786cc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "93542851cf4f8ca963cbb447d144300e", + "content-hash": "920d8caa63c8b0da280a78c05d5983a8", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -620,6 +620,87 @@ ], "time": "2023-12-20T15:40:13+00:00" }, + { + "name": "composer/semver", + "version": "3.4.2", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "c51258e759afdb17f1fd1fe83bc12baaef6309d6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/c51258e759afdb17f1fd1fe83bc12baaef6309d6", + "reference": "c51258e759afdb17f1fd1fe83bc12baaef6309d6", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.4", + "symfony/phpunit-bridge": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-07-12T11:35:52+00:00" + }, { "name": "danharrin/date-format-converter", "version": "v0.3.1", diff --git a/database/factories/ModFactory.php b/database/factories/ModFactory.php index aef197b..f7699dc 100644 --- a/database/factories/ModFactory.php +++ b/database/factories/ModFactory.php @@ -30,12 +30,21 @@ class ModFactory extends Factory 'featured' => fake()->boolean(), 'contains_ai_content' => fake()->boolean(), 'contains_ads' => fake()->boolean(), - 'disabled' => fake()->boolean(), 'created_at' => now(), 'updated_at' => now(), ]; } + /** + * Indicate that the mod should be disabled. + */ + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'disabled' => true, + ]); + } + /** * Indicate that the mod should be soft-deleted. */ diff --git a/database/factories/ModVersionFactory.php b/database/factories/ModVersionFactory.php index cb8b613..a282b4f 100644 --- a/database/factories/ModVersionFactory.php +++ b/database/factories/ModVersionFactory.php @@ -22,9 +22,18 @@ class ModVersionFactory extends Factory 'spt_version_id' => SptVersion::factory(), 'virus_total_link' => fake()->url(), 'downloads' => fake()->randomNumber(), - 'disabled' => fake()->boolean(), 'created_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)), 'updated_at' => Carbon::now()->subDays(rand(0, 365))->subHours(rand(0, 23)), ]; } + + /** + * Indicate that the mod version should be disabled. + */ + public function disabled(): static + { + return $this->state(fn (array $attributes) => [ + 'disabled' => true, + ]); + } } diff --git a/database/migrations/2024_07_25_161219_create_mod_dependencies.php b/database/migrations/2024_07_25_161219_create_mod_dependencies.php new file mode 100644 index 0000000..8ba53bc --- /dev/null +++ b/database/migrations/2024_07_25_161219_create_mod_dependencies.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('mod_version_id') + ->constrained('mod_versions') + ->cascadeOnDelete() + ->cascadeOnUpdate(); + $table->foreignId('dependency_mod_id') + ->constrained('mods') + ->cascadeOnDelete() + ->cascadeOnUpdate(); + $table->string('version_constraint'); // e.g., ^1.0.1 + $table->foreignId('resolved_version_id') + ->nullable() + ->constrained('mod_versions') + ->nullOnDelete() + ->cascadeOnUpdate(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('mod_dependencies'); + } +}; diff --git a/tests/Feature/ModDependencyTest.php b/tests/Feature/ModDependencyTest.php new file mode 100644 index 0000000..5a99c23 --- /dev/null +++ b/tests/Feature/ModDependencyTest.php @@ -0,0 +1,236 @@ +create(['name' => 'Mod A']); + $modB = Mod::factory()->create(['name' => 'Mod B']); + + // Create versions for Mod B + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']); + + // 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, + 'version_constraint' => '^1.0.0', + ]); + + $modDependency->refresh(); + expect($modDependency->resolvedVersion->version)->toBe('1.1.1'); +}); + +it('resolves mod version dependency when mod version is updated', function () { + $modA = Mod::factory()->create(['name' => 'Mod A']); + $modB = Mod::factory()->create(['name' => 'Mod B']); + + // Create versions for Mod B + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']); + $modBv3 = ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']); + + // 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, + 'version_constraint' => '^1.0.0', + ]); + + $modDependency->refresh(); + expect($modDependency->resolvedVersion->version)->toBe('1.1.1'); + + // Update the mod B version + $modBv3->update(['version' => '1.1.2']); + + $modDependency->refresh(); + expect($modDependency->resolvedVersion->version)->toBe('1.1.2'); +}); + +it('resolves mod version dependency when mod version is deleted', function () { + $modA = Mod::factory()->create(['name' => 'Mod A']); + $modB = Mod::factory()->create(['name' => 'Mod B']); + + // Create versions for Mod B + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']); + $modBv3 = ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']); + + // 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, + 'version_constraint' => '^1.0.0', + ]); + + $modDependency->refresh(); + expect($modDependency->resolvedVersion->version)->toBe('1.1.1'); + + // Update the mod B version + $modBv3->delete(); + + $modDependency->refresh(); + expect($modDependency->resolvedVersion->version)->toBe('1.1.0'); +}); + +it('resolves mod version dependency after semantic version constraint is updated', function () { + $modA = Mod::factory()->create(['name' => 'Mod A']); + $modB = Mod::factory()->create(['name' => 'Mod B']); + + // Create versions for Mod B + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.1']); + + // 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, + 'version_constraint' => '^1.0.0', + ]); + + $modDependency->refresh(); + expect($modDependency->resolvedVersion->version)->toBe('1.1.1'); + + // Update the dependency version constraint + $modDependency->update(['version_constraint' => '^2.0.0']); + + $modDependency->refresh(); + expect($modDependency->resolvedVersion->version)->toBe('2.0.1'); +}); + +it('resolves mod version dependency with exact semantic version constraint', function () { + $modA = Mod::factory()->create(['name' => 'Mod A']); + $modB = Mod::factory()->create(['name' => 'Mod B']); + + // Create versions for Mod B + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']); + + // 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, + 'version_constraint' => '1.1.0', + ]); + + $modDependency->refresh(); + expect($modDependency->resolvedVersion->version)->toBe('1.1.0'); +}); + +it('resolves mod version dependency with complex semantic version constraint', function () { + $modA = Mod::factory()->create(['name' => 'Mod A']); + $modB = Mod::factory()->create(['name' => 'Mod B']); + + // Create versions for Mod B + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.2.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.2.1']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']); + + // 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, + 'version_constraint' => '>=1.0.0 <2.0.0', + ]); + + $modDependency->refresh(); + expect($modDependency->resolvedVersion->version)->toBe('1.2.1'); + + $modDependency->update(['version_constraint' => '1.0.0 || >=1.1.0 <1.2.0']); + + $modDependency->refresh(); + expect($modDependency->resolvedVersion->version)->toBe('1.1.1'); +}); + +it('resolves null when no mod versions are available', function () { + $modA = Mod::factory()->create(['name' => 'Mod A']); + $modB = Mod::factory()->create(['name' => 'Mod B']); + + // 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, + 'version_constraint' => '^1.0.0', + ]); + + $modDependency->refresh(); + expect($modDependency->resolved_version_id)->toBeNull(); +}); + +it('resolves null when no mod versions match against semantic version constraint', function () { + $modA = Mod::factory()->create(['name' => 'Mod A']); + $modB = Mod::factory()->create(['name' => 'Mod B']); + + // Create versions for Mod B + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']); + + // 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, + 'version_constraint' => '~1.2.0', + ]); + + $modDependency->refresh(); + expect($modDependency->resolved_version_id)->toBeNull(); +}); + +it('resolves multiple dependencies', function () { + $modA = Mod::factory()->create(['name' => 'Mod A']); + $modB = Mod::factory()->create(['name' => 'Mod B']); + $modC = Mod::factory()->create(['name' => 'Mod C']); + + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.0.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.0']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '1.1.1']); + ModVersion::factory()->create(['mod_id' => $modB->id, 'version' => '2.0.0']); + + ModVersion::factory()->create(['mod_id' => $modC->id, 'version' => '1.0.0']); + ModVersion::factory()->create(['mod_id' => $modC->id, 'version' => '1.1.0']); + ModVersion::factory()->create(['mod_id' => $modC->id, 'version' => '1.1.1']); + ModVersion::factory()->create(['mod_id' => $modC->id, 'version' => '2.0.0']); + + // 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, + 'version_constraint' => '^1.0.0', + ]); + $modDependencyC = ModDependency::create([ + 'mod_version_id' => $modAv1->id, + 'dependency_mod_id' => $modC->id, + 'version_constraint' => '^1.0.0', + ]); + + $modDependencyB->refresh(); + expect($modDependencyB->resolvedVersion->version)->toBe('1.1.1'); + + $modDependencyC->refresh(); + expect($modDependencyC->resolvedVersion->version)->toBe('1.1.1'); +});