Skip to content
Merged
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
2 changes: 2 additions & 0 deletions app/Enums/PackageType.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ enum PackageType: string
case CORE = 'wp-core';
case PLUGIN = 'wp-plugin';
case THEME = 'wp-theme';
case TYPO3_CORE = 'typo3-core';
case TYPO3_EXTENSION = 'typo3-extension';

/**
* Get the list of package type values.
Expand Down
34 changes: 34 additions & 0 deletions app/Http/Controllers/API/FAIR/Packages/PackageSearchController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);

namespace App\Http\Controllers\API\FAIR\Packages;

use App\Http\Controllers\Controller;
use App\Services\Packages\PackageSearchService;
use App\Values\Packages\PackageSearchRequest;
use App\Values\Packages\PackageSearchResponse;

/**
* Search and browse packages by type.
*
* Handles GET /packages/{type} with optional full-text search, version
* filtering, and pagination. Returns FAIR metadata for each matching package.
*/
class PackageSearchController extends Controller
{
public function __construct(
private PackageSearchService $searchService,
) {}

/**
* Execute the search and return paginated FAIR metadata results.
*
* @return array<string, mixed>
*/
public function __invoke(PackageSearchRequest $request): array
{
$results = $this->searchService->search($request);

return PackageSearchResponse::from($results)->toArray();
}
}
128 changes: 128 additions & 0 deletions app/Services/Packages/PackageSearchService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);

namespace App\Services\Packages;

use App\Models\Package;
use App\Values\Packages\PackageSearchRequest;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;

/**
* Searches packages using Postgres full-text search with trigram fallback.
*
* Search strategy with a query: try tsvector full-text search (including tag matching)
* first, fall back to trigram similarity on name/slug if no FTS results.
* Without a query: return all packages of the given type, newest first.
*/
class PackageSearchService
{
/** @var list<string> Relations to eager load to avoid N+1 queries when building FAIR metadata. */
private const EAGER_LOAD = ['releases', 'authors', 'tags', 'metas'];

/**
* Search packages by type with optional query and version requirements.
*
* @return LengthAwarePaginator<int, Package>
*/
public function search(PackageSearchRequest $request): LengthAwarePaginator
{
if ($request->q === null || $request->q === '') {
$query = Package::with(self::EAGER_LOAD)
->where('type', $request->type)
->orderByDesc('created_at');

$this->applyRequiresFilter($query, $request);

return $query->paginate(perPage: $request->per_page, page: $request->page);
}

// Try full-text search first
$results = $this->fullTextSearch($request);

if ($results->total() > 0) {
return $results;
}

// Fall back to trigram similarity
return $this->trigramSearch($request);
}

/**
* Search using Postgres tsvector full-text search, ranked by ts_rank.
*
* Also matches packages whose tags match the query via a subquery join.
*
* @return LengthAwarePaginator<int, Package>
*/
private function fullTextSearch(PackageSearchRequest $request): LengthAwarePaginator
{
$tsQuery = "plainto_tsquery('english', ?)";

$query = Package::with(self::EAGER_LOAD)
->where('type', $request->type)
->where(function ($q) use ($tsQuery, $request) {
$q->whereRaw("search_vector @@ {$tsQuery}", [$request->q])
->orWhereExists(function ($sub) use ($request) {
$sub->select(DB::raw(1))
->from('package_package_tag')
->join('package_tags', 'package_tags.id', '=', 'package_package_tag.package_tag_id')
->whereColumn('package_package_tag.package_id', 'packages.id')
->whereRaw("to_tsvector('english', package_tags.name) @@ plainto_tsquery('english', ?)", [$request->q]);
});
})
->orderByRaw("ts_rank(search_vector, {$tsQuery}) DESC", [$request->q]);

$this->applyRequiresFilter($query, $request);

return $query->paginate(perPage: $request->per_page, page: $request->page);
}

/**
* Fallback search using pg_trgm trigram similarity on name and slug.
*
* Used when full-text search returns no results, to catch fuzzy/partial matches.
*
* @return LengthAwarePaginator<int, Package>
*/
private function trigramSearch(PackageSearchRequest $request): LengthAwarePaginator
{
$query = Package::with(self::EAGER_LOAD)
->where('type', $request->type)
->whereRaw('(similarity(name, ?) > 0.1 OR similarity(slug, ?) > 0.1)', [$request->q, $request->q])
->orderByRaw('GREATEST(similarity(name, ?), similarity(slug, ?)) DESC', [$request->q, $request->q]);

$this->applyRequiresFilter($query, $request);

return $query->paginate(perPage: $request->per_page, page: $request->page);
}

/**
* 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.
*
* @param Builder<Package> $query
*/
private function applyRequiresFilter(Builder $query, PackageSearchRequest $request): void
{
if (empty($request->requires)) {
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],
);
}
});
}
}
57 changes: 57 additions & 0 deletions app/Values/Packages/PackageSearchRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);

namespace App\Values\Packages;

use App\Values\DTO;
use Bag\Attributes\Laravel\FromRouteParameter;
use Bag\Attributes\StripExtraParameters;
use Bag\Attributes\Transforms;
use Illuminate\Http\Request;

/**
* Validated search request for the GET /packages/{type} endpoint.
*
* Constructed automatically from the HTTP request via Bag's service provider.
* The `type` route parameter is resolved via #[FromRouteParameter].
*/
#[StripExtraParameters]
readonly class PackageSearchRequest extends DTO
{
/** @param array<string, string>|null $requires */
public function __construct(
#[FromRouteParameter]
public string $type,
public ?string $q = null,
public ?array $requires = null,
public int $page = 1,
public int $per_page = 24,
) {}

/**
* Transform an incoming HTTP request into constructor arguments.
*
* Validates query parameters and extracts the package type from the route.
*
* @return array<string, mixed>
*/
#[Transforms(Request::class)]
public static function fromRequest(Request $request): array
{
$validated = $request->validate([
'q' => ['nullable', 'string', 'max:200'],
'requires' => ['nullable', 'array'],
'requires.*' => ['string', 'max:20'],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
]);

return [
'type' => $request->route('type'),
'q' => $validated['q'] ?? null,
'requires' => $validated['requires'] ?? null,
'page' => (int) ($validated['page'] ?? 1),
'per_page' => (int) ($validated['per_page'] ?? 24),
];
}
}
50 changes: 50 additions & 0 deletions app/Values/Packages/PackageSearchResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);

namespace App\Values\Packages;

use App\Models\Package;
use App\Values\DTO;
use Bag\Attributes\Transforms;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;

/**
* Response DTO for the package search endpoint.
*
* Wraps paginated results with pagination info and FAIR metadata for each package.
*/
readonly class PackageSearchResponse extends DTO
{
/**
* @param array{page: int, per_page: int, total: int, pages: int} $info
* @param list<array<string, mixed>> $packages
*/
public function __construct(
public array $info,
public array $packages,
) {}

/**
* Transform a paginator of Package models into the response structure.
*
* Each package is converted to its FAIR metadata representation.
*
* @param LengthAwarePaginator<int, Package> $paginator
* @return array<string, mixed>
*/
#[Transforms(LengthAwarePaginator::class)]
public static function fromPaginator(LengthAwarePaginator $paginator): array
{
return [
'info' => [
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'pages' => $paginator->lastPage(),
],
'packages' => collect($paginator->items())->map(
fn (Package $package) => FairMetadata::from($package)->toArray()
)->all(),
];
}
}
12 changes: 7 additions & 5 deletions database/factories/PackageFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public function definition(): array
$did = 'fake:' . $this->faker->slug();
$name = $this->faker->words(3, true);
$slug = Str::slug($name);
$type = $this->faker->randomElement(['wp-plugin', 'wp-theme', 'wp-core']);
$type = $this->faker->randomElement(['wp-plugin', 'wp-theme', 'wp-core', 'typo3-core', 'typo3-extension']);
$origin = $this->faker->randomElement(['fair', 'wp']);
$license = $this->faker->randomElement(['GPLv2', 'GPLv3', 'MIT', 'Apache-2.0', 'Proprietary']);

Expand All @@ -41,6 +41,11 @@ public function definition(): array
/**
* Configure the model factory to create a plugin with tags
*/
public function typo3Extension(): static
{
return $this->state(['type' => 'typo3-extension', 'origin' => 'fair']);
}

public function withTags(int $count = 3): static
{
return $this->afterCreating(function (Package $package) use ($count) {
Expand All @@ -61,10 +66,7 @@ public function withSpecificTags(array $tagNames): static

return PackageTag::query()->firstOrCreate(
['slug' => $slug],
[
'id' => $this->faker->uuid(),
'name' => $tagName,
],
['name' => $tagName],
);
});

Expand Down
13 changes: 11 additions & 2 deletions database/factories/PackageReleaseFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ public function definition(): array
'version' => $this->faker->semver(),
'download_url' => $this->faker->url(),
'requires' => [
'wp' => $this->faker->semver(),
'php' => $this->faker->randomElement(['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3']),
'php' => $this->faker->randomElement(['8.0', '8.1', '8.2', '8.3']),
],
'suggests' => [
'another-plugin' => $this->faker->semver(),
Expand All @@ -38,4 +37,14 @@ public function definition(): array
],
];
}

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']),
],
]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);

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

return new class extends Migration {
public function up(): void
{
// Full-text search: stored generated tsvector column with GIN index
DB::statement("
ALTER TABLE packages
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(slug, '')), 'A') ||
setweight(to_tsvector('english', coalesce(description, '')), 'B')
) STORED
");
DB::statement('CREATE INDEX packages_search_vector_gin ON packages USING GIN (search_vector)');

// Trigram indexes for fuzzy/partial matching
DB::statement('CREATE INDEX packages_name_trgm ON packages USING GIST (name gist_trgm_ops(siglen=32))');
DB::statement('CREATE INDEX packages_slug_trgm ON packages USING GIST (slug gist_trgm_ops(siglen=32))');

// B-tree index on type for filtering
DB::statement('CREATE INDEX packages_type_idx ON packages (type)');
}

public function down(): void
{
DB::statement('DROP INDEX IF EXISTS packages_type_idx');
DB::statement('DROP INDEX IF EXISTS packages_slug_trgm');
DB::statement('DROP INDEX IF EXISTS packages_name_trgm');
DB::statement('DROP INDEX IF EXISTS packages_search_vector_gin');
DB::statement('ALTER TABLE packages DROP COLUMN IF EXISTS search_vector');
}
};
1 change: 1 addition & 0 deletions database/seeders/DatabaseSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ public function run(): void
$this->call(AuthorizationSeeder::class);
$this->call(UserSeeder::class);
$this->call(PluginSeeder::class);
$this->call(Typo3ExtensionSeeder::class);
}
}
Loading
Loading