Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 103 additions & 16 deletions app/Services/Packages/PackageSearchService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
);
}

/**
Expand All @@ -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<Package> $query
*/
Expand All @@ -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<int, Package> $results
* @return LengthAwarePaginator<int, Package>
*/
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<string, string> $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;
}
}
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
156 changes: 78 additions & 78 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 8 additions & 3 deletions database/factories/PackageReleaseFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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']),
],
]);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration {
public function up(): void
{
// GIN index on requires JSONB for fast key-existence checks (e.g. requires ? 'env:typo3')
DB::statement('CREATE INDEX package_releases_requires_gin ON package_releases USING GIN (requires)');
}

public function down(): void
{
DB::statement('DROP INDEX IF EXISTS package_releases_requires_gin');
}
};
Loading
Loading