diff --git a/app/Services/Packages/PackageSearchService.php b/app/Services/Packages/PackageSearchService.php index b8a7aee..27b06b8 100644 --- a/app/Services/Packages/PackageSearchService.php +++ b/app/Services/Packages/PackageSearchService.php @@ -4,8 +4,10 @@ namespace App\Services\Packages; use App\Models\Package; +use App\Models\PackageRelease; use App\Values\Packages\PackageSearchRequest; -use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Composer\Semver\Semver; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; @@ -35,7 +37,10 @@ public function search(PackageSearchRequest $request): LengthAwarePaginator $this->applyRequiresFilter($query, $request); - return $query->paginate(perPage: $request->per_page, page: $request->page); + return $this->filterPaginatedReleases( + $query->paginate(perPage: $request->per_page, page: $request->page), + $request, + ); } // Try full-text search first @@ -76,7 +81,10 @@ private function fullTextSearch(PackageSearchRequest $request): LengthAwarePagin $this->applyRequiresFilter($query, $request); - return $query->paginate(perPage: $request->per_page, page: $request->page); + return $this->filterPaginatedReleases( + $query->paginate(perPage: $request->per_page, page: $request->page), + $request, + ); } /** @@ -95,14 +103,21 @@ private function trigramSearch(PackageSearchRequest $request): LengthAwarePagina $this->applyRequiresFilter($query, $request); - return $query->paginate(perPage: $request->per_page, page: $request->page); + return $this->filterPaginatedReleases( + $query->paginate(perPage: $request->per_page, page: $request->page), + $request, + ); } /** * Filter packages to those having at least one release compatible with the given requirements. * - * Compares dotted version strings as integer arrays, e.g. ?requires[typo3]=12.4 finds - * packages with a release requiring typo3 <= 12.4. Multiple requirements are ANDed together. + * Uses Composer\Semver to evaluate version constraints stored in the requires JSONB field, + * supporting ranges like ">=11.5.19 <=12.9.99", caret (^12.4), tilde (~12.4), etc. + * + * Optimization: instead of loading all releases into PHP, we first collect the distinct + * constraint strings per key (typically only 20-50 unique values), run Semver::satisfies() + * on those, then use SQL to find package IDs whose releases match the valid constraints. * * @param Builder $query */ @@ -112,17 +127,89 @@ private function applyRequiresFilter(Builder $query, PackageSearchRequest $reque return; } - $query->whereExists(function ($sub) use ($request) { - $sub->select(DB::raw(1)) - ->from('package_releases') - ->whereColumn('package_releases.package_id', 'packages.id'); - - foreach ($request->requires as $key => $version) { - $sub->whereRaw( - "package_releases.requires->>? IS NOT NULL AND string_to_array(package_releases.requires->>?, '.')::int[] <= string_to_array(?, '.')::int[]", - [$key, $key, $version], - ); + // Build a subquery that finds package IDs with at least one release matching all constraints. + // For each required key, we find the distinct constraint values, filter with Semver in PHP, + // then add a SQL condition for only the valid constraints. + $releaseQuery = DB::table('package_releases') + ->select('package_releases.package_id') + ->join('packages', 'packages.id', '=', 'package_releases.package_id') + ->where('packages.type', $request->type); + + foreach ($request->requires as $key => $version) { + // Get distinct constraint strings for this key (e.g. ">=11.5.0 <=12.99.99", "^12.4") + $distinctConstraints = DB::table('package_releases') + ->join('packages', 'packages.id', '=', 'package_releases.package_id') + ->where('packages.type', $request->type) + ->whereRaw('jsonb_exists(package_releases.requires, ?)', [$key]) + ->selectRaw('DISTINCT package_releases.requires->>? as constraint_value', [$key]) + ->pluck('constraint_value'); + + // Filter to constraints that the provided version satisfies + $validConstraints = $distinctConstraints + ->filter(fn (string $constraint) => Semver::satisfies($version, $constraint)) + ->values() + ->all(); + + if (empty($validConstraints)) { + // No valid constraints found — no packages can match + $query->whereRaw('1 = 0'); + + return; } + + $releaseQuery->whereRaw( + 'package_releases.requires->>? IN (' . implode(',', array_fill(0, count($validConstraints), '?')) . ')', + [$key, ...$validConstraints], + ); + } + + $matchingIds = $releaseQuery->distinct()->pluck('package_id')->all(); + + $query->whereIn('packages.id', $matchingIds); + } + + /** + * Post-filter eager-loaded releases on paginated results using Composer\Semver. + * + * @param LengthAwarePaginator $results + * @return LengthAwarePaginator + */ + private function filterPaginatedReleases(LengthAwarePaginator $results, PackageSearchRequest $request): LengthAwarePaginator + { + if (empty($request->requires)) { + return $results; + } + + $results->getCollection()->each(function (Package $package) use ($request) { + $package->setRelation( + 'releases', + $package->releases + ->filter(fn (PackageRelease $release) => $this->releaseSatisfies($release, $request->requires)) + ->values(), + ); }); + + return $results; + } + + /** + * Check if a release satisfies all version requirements using Composer\Semver. + * + * @param array $requires Key-value pairs of dependency name to user-provided version + */ + private function releaseSatisfies(PackageRelease $release, array $requires): bool + { + foreach ($requires as $key => $version) { + $constraint = $release->requires[$key] ?? null; + if ($constraint === null) { + return false; + } + + if (! Semver::satisfies($version, $constraint)) { + return false; + } + } + + return true; } } diff --git a/composer.json b/composer.json index a7f3dd8..ef6e061 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "ext-intl": "*", "ext-mbstring": "*", "beacon-hq/bag": "^2.6.2", + "composer/semver": "^3.4", "crell/fp": "^1.0", "crell/serde": "^1.5", "cweagans/composer-patches": "^1.7.3", diff --git a/composer.lock b/composer.lock index 70aff03..987b558 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": "0f5b2ffee5049c1475a52b405be5330c", + "content-hash": "c74820aac3d28a03792ac245c52047ed", "packages": [ { "name": "aws/aws-crt-php", @@ -472,6 +472,83 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "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.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "crell/attributeutils", "version": "1.3.0", @@ -9897,83 +9974,6 @@ ], "time": "2024-11-12T16:29:46+00:00" }, - { - "name": "composer/semver", - "version": "3.4.4", - "source": { - "type": "git", - "url": "https://github.com/composer/semver.git", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", - "shasum": "" - }, - "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^1.11", - "symfony/phpunit-bridge": "^3 || ^7" - }, - "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.4" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - } - ], - "time": "2025-08-20T19:15:30+00:00" - }, { "name": "composer/xdebug-handler", "version": "3.0.5", diff --git a/database/factories/PackageReleaseFactory.php b/database/factories/PackageReleaseFactory.php index 322022c..0c4286c 100644 --- a/database/factories/PackageReleaseFactory.php +++ b/database/factories/PackageReleaseFactory.php @@ -19,7 +19,7 @@ public function definition(): array 'version' => $this->faker->semver(), 'download_url' => $this->faker->url(), 'requires' => [ - 'php' => $this->faker->randomElement(['8.0', '8.1', '8.2', '8.3']), + 'env:php' => $this->faker->randomElement(['^8.0', '^8.1', '^8.2', '^8.3']), ], 'suggests' => [ 'another-plugin' => $this->faker->semver(), @@ -42,8 +42,13 @@ public function typo3(): static { return $this->state(fn () => [ 'requires' => [ - 'typo3' => $this->faker->randomElement(['11.5', '12.4', '13.4']), - 'php' => $this->faker->randomElement(['8.1', '8.2', '8.3', '8.4']), + 'env:typo3' => $this->faker->randomElement([ + '>=11.5.0 <=11.99.99', + '>=12.4.0 <=12.99.99', + '>=13.4.0 <=13.99.99', + '>=11.5.0 <=12.99.99', + ]), + 'env:php' => $this->faker->randomElement(['^8.1', '^8.2', '>=8.1']), ], ]); } diff --git a/database/migrations/2026_03_22_000000_add_requires_gin_index_to_package_releases.php b/database/migrations/2026_03_22_000000_add_requires_gin_index_to_package_releases.php new file mode 100644 index 0000000..bf03010 --- /dev/null +++ b/database/migrations/2026_03_22_000000_add_requires_gin_index_to_package_releases.php @@ -0,0 +1,18 @@ +withAuthors()->withMetas()->typo3Extension() ->create(['name' => 'Old Extension', 'slug' => 'old-ext']); $old->releases()->createMany( PackageReleaseFactory::new()->count(1)->make([ 'package_id' => $old->id, - 'requires' => ['typo3' => '11.5', 'php' => '8.1'], + 'requires' => ['env:typo3' => '>=11.5.0 <=11.99.99', 'env:php' => '^8.1'], ])->toArray(), ); - // Package requiring TYPO3 13.4 + // Package supporting TYPO3 13.4+ $new = Package::factory()->withAuthors()->withMetas()->typo3Extension() ->create(['name' => 'New Extension', 'slug' => 'new-ext']); $new->releases()->createMany( PackageReleaseFactory::new()->count(1)->make([ 'package_id' => $new->id, - 'requires' => ['typo3' => '13.4', 'php' => '8.2'], + 'requires' => ['env:typo3' => '>=13.4.0 <=13.99.99', 'env:php' => '^8.2'], ])->toArray(), ); - // Filter for TYPO3 12.4 — only the 11.5 package qualifies - $this->getJson('/packages/typo3-extension?requires[typo3]=12.4') + // Filter for TYPO3 11.5.19 — only the 11.x package qualifies + $this->getJson('/packages/typo3-extension?requires[env:typo3]=11.5.19') ->assertOk() ->assertJsonCount(1, 'packages') ->assertJsonPath('packages.0.name', 'Old Extension'); - // Filter for TYPO3 13.4 — both qualify - $this->getJson('/packages/typo3-extension?requires[typo3]=13.4') + // Filter for TYPO3 13.4 — only the 13.x package qualifies + $this->getJson('/packages/typo3-extension?requires[env:typo3]=13.4') ->assertOk() - ->assertJsonCount(2, 'packages'); + ->assertJsonCount(1, 'packages') + ->assertJsonPath('packages.0.name', 'New Extension'); +}); + +it('excludes packages when version is outside constraint range', function () { + // Package supporting TYPO3 11.5–12.x + $pkg = Package::factory()->withAuthors()->withMetas()->typo3Extension() + ->create(['name' => 'Range Extension', 'slug' => 'range-ext']); + $pkg->releases()->createMany( + PackageReleaseFactory::new()->count(1)->make([ + 'package_id' => $pkg->id, + 'requires' => ['env:typo3' => '>=11.5.19 <=12.9.99', 'env:php' => '^8.1'], + ])->toArray(), + ); + + // TYPO3 14.2 is above the upper bound — should return no packages + $this->getJson('/packages/typo3-extension?requires[env:typo3]=14.2') + ->assertOk() + ->assertJsonCount(0, 'packages'); + + // TYPO3 12.4 is within the range — should match + $this->getJson('/packages/typo3-extension?requires[env:typo3]=12.4') + ->assertOk() + ->assertJsonCount(1, 'packages') + ->assertJsonPath('packages.0.name', 'Range Extension'); }); it('filters by requires version combined with search', function () { @@ -222,7 +246,7 @@ $match->releases()->createMany( PackageReleaseFactory::new()->count(1)->make([ 'package_id' => $match->id, - 'requires' => ['typo3' => '12.4', 'php' => '8.1'], + 'requires' => ['env:typo3' => '>=12.4.0 <=12.99.99', 'env:php' => '^8.1'], ])->toArray(), ); @@ -231,16 +255,52 @@ $tooNew->releases()->createMany( PackageReleaseFactory::new()->count(1)->make([ 'package_id' => $tooNew->id, - 'requires' => ['typo3' => '13.4', 'php' => '8.3'], + 'requires' => ['env:typo3' => '>=13.4.0 <=13.99.99', 'env:php' => '^8.3'], ])->toArray(), ); - $this->getJson('/packages/typo3-extension?q=gallery&requires[typo3]=12.4') + $this->getJson('/packages/typo3-extension?q=gallery&requires[env:typo3]=12.4') ->assertOk() ->assertJsonCount(1, 'packages') ->assertJsonPath('packages.0.name', 'Gallery Pro'); }); +it('only includes matching releases when filtering by requires', function () { + // Package with two releases: one for TYPO3 11.x and one for TYPO3 13.x + $package = Package::factory()->withAuthors()->withMetas()->typo3Extension() + ->create(['name' => 'Multi Release Ext', 'slug' => 'multi-release-ext']); + + $package->releases()->createMany([ + PackageReleaseFactory::new()->make([ + 'package_id' => $package->id, + 'version' => '1.0.0', + 'requires' => ['env:typo3' => '>=11.5.0 <=11.99.99', 'env:php' => '^8.1'], + ])->toArray(), + PackageReleaseFactory::new()->make([ + 'package_id' => $package->id, + 'version' => '2.0.0', + 'requires' => ['env:typo3' => '>=13.4.0 <=13.99.99', 'env:php' => '^8.2'], + ])->toArray(), + ]); + + // Filter for TYPO3 11.5.19 — only the 11.x release qualifies + $response = $this->getJson('/packages/typo3-extension?requires[env:typo3]=11.5.19') + ->assertOk() + ->assertJsonCount(1, 'packages'); + + $releases = $response->json('packages.0.releases'); + expect($releases)->toHaveCount(1); + expect($releases[0]['version'])->toBe('1.0.0'); + + // Without requires filter — both releases should be returned + $response = $this->getJson('/packages/typo3-extension') + ->assertOk() + ->assertJsonCount(1, 'packages'); + + $releases = $response->json('packages.0.releases'); + expect($releases)->toHaveCount(2); +}); + it('returns empty packages array with zero total when no results', function () { $this->getJson('/packages/typo3-extension?q=nonexistent') ->assertOk()