Skip to content

Commit 45de135

Browse files
authored
Merge pull request #45 from fairpm/typo3-plugin-package-search
Add TYPO3 extension support and package search endpoint
2 parents 830696e + d1fb348 commit 45de135

File tree

12 files changed

+636
-8
lines changed

12 files changed

+636
-8
lines changed

app/Enums/PackageType.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ enum PackageType: string
88
case CORE = 'wp-core';
99
case PLUGIN = 'wp-plugin';
1010
case THEME = 'wp-theme';
11+
case TYPO3_CORE = 'typo3-core';
12+
case TYPO3_EXTENSION = 'typo3-extension';
1113

1214
/**
1315
* Get the list of package type values.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace App\Http\Controllers\API\FAIR\Packages;
5+
6+
use App\Http\Controllers\Controller;
7+
use App\Services\Packages\PackageSearchService;
8+
use App\Values\Packages\PackageSearchRequest;
9+
use App\Values\Packages\PackageSearchResponse;
10+
11+
/**
12+
* Search and browse packages by type.
13+
*
14+
* Handles GET /packages/{type} with optional full-text search, version
15+
* filtering, and pagination. Returns FAIR metadata for each matching package.
16+
*/
17+
class PackageSearchController extends Controller
18+
{
19+
public function __construct(
20+
private PackageSearchService $searchService,
21+
) {}
22+
23+
/**
24+
* Execute the search and return paginated FAIR metadata results.
25+
*
26+
* @return array<string, mixed>
27+
*/
28+
public function __invoke(PackageSearchRequest $request): array
29+
{
30+
$results = $this->searchService->search($request);
31+
32+
return PackageSearchResponse::from($results)->toArray();
33+
}
34+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace App\Services\Packages;
5+
6+
use App\Models\Package;
7+
use App\Values\Packages\PackageSearchRequest;
8+
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
9+
use Illuminate\Database\Eloquent\Builder;
10+
use Illuminate\Support\Facades\DB;
11+
12+
/**
13+
* Searches packages using Postgres full-text search with trigram fallback.
14+
*
15+
* Search strategy with a query: try tsvector full-text search (including tag matching)
16+
* first, fall back to trigram similarity on name/slug if no FTS results.
17+
* Without a query: return all packages of the given type, newest first.
18+
*/
19+
class PackageSearchService
20+
{
21+
/** @var list<string> Relations to eager load to avoid N+1 queries when building FAIR metadata. */
22+
private const EAGER_LOAD = ['releases', 'authors', 'tags', 'metas'];
23+
24+
/**
25+
* Search packages by type with optional query and version requirements.
26+
*
27+
* @return LengthAwarePaginator<int, Package>
28+
*/
29+
public function search(PackageSearchRequest $request): LengthAwarePaginator
30+
{
31+
if ($request->q === null || $request->q === '') {
32+
$query = Package::with(self::EAGER_LOAD)
33+
->where('type', $request->type)
34+
->orderByDesc('created_at');
35+
36+
$this->applyRequiresFilter($query, $request);
37+
38+
return $query->paginate(perPage: $request->per_page, page: $request->page);
39+
}
40+
41+
// Try full-text search first
42+
$results = $this->fullTextSearch($request);
43+
44+
if ($results->total() > 0) {
45+
return $results;
46+
}
47+
48+
// Fall back to trigram similarity
49+
return $this->trigramSearch($request);
50+
}
51+
52+
/**
53+
* Search using Postgres tsvector full-text search, ranked by ts_rank.
54+
*
55+
* Also matches packages whose tags match the query via a subquery join.
56+
*
57+
* @return LengthAwarePaginator<int, Package>
58+
*/
59+
private function fullTextSearch(PackageSearchRequest $request): LengthAwarePaginator
60+
{
61+
$tsQuery = "plainto_tsquery('english', ?)";
62+
63+
$query = Package::with(self::EAGER_LOAD)
64+
->where('type', $request->type)
65+
->where(function ($q) use ($tsQuery, $request) {
66+
$q->whereRaw("search_vector @@ {$tsQuery}", [$request->q])
67+
->orWhereExists(function ($sub) use ($request) {
68+
$sub->select(DB::raw(1))
69+
->from('package_package_tag')
70+
->join('package_tags', 'package_tags.id', '=', 'package_package_tag.package_tag_id')
71+
->whereColumn('package_package_tag.package_id', 'packages.id')
72+
->whereRaw("to_tsvector('english', package_tags.name) @@ plainto_tsquery('english', ?)", [$request->q]);
73+
});
74+
})
75+
->orderByRaw("ts_rank(search_vector, {$tsQuery}) DESC", [$request->q]);
76+
77+
$this->applyRequiresFilter($query, $request);
78+
79+
return $query->paginate(perPage: $request->per_page, page: $request->page);
80+
}
81+
82+
/**
83+
* Fallback search using pg_trgm trigram similarity on name and slug.
84+
*
85+
* Used when full-text search returns no results, to catch fuzzy/partial matches.
86+
*
87+
* @return LengthAwarePaginator<int, Package>
88+
*/
89+
private function trigramSearch(PackageSearchRequest $request): LengthAwarePaginator
90+
{
91+
$query = Package::with(self::EAGER_LOAD)
92+
->where('type', $request->type)
93+
->whereRaw('(similarity(name, ?) > 0.1 OR similarity(slug, ?) > 0.1)', [$request->q, $request->q])
94+
->orderByRaw('GREATEST(similarity(name, ?), similarity(slug, ?)) DESC', [$request->q, $request->q]);
95+
96+
$this->applyRequiresFilter($query, $request);
97+
98+
return $query->paginate(perPage: $request->per_page, page: $request->page);
99+
}
100+
101+
/**
102+
* Filter packages to those having at least one release compatible with the given requirements.
103+
*
104+
* Compares dotted version strings as integer arrays, e.g. ?requires[typo3]=12.4 finds
105+
* packages with a release requiring typo3 <= 12.4. Multiple requirements are ANDed together.
106+
*
107+
* @param Builder<Package> $query
108+
*/
109+
private function applyRequiresFilter(Builder $query, PackageSearchRequest $request): void
110+
{
111+
if (empty($request->requires)) {
112+
return;
113+
}
114+
115+
$query->whereExists(function ($sub) use ($request) {
116+
$sub->select(DB::raw(1))
117+
->from('package_releases')
118+
->whereColumn('package_releases.package_id', 'packages.id');
119+
120+
foreach ($request->requires as $key => $version) {
121+
$sub->whereRaw(
122+
"package_releases.requires->>? IS NOT NULL AND string_to_array(package_releases.requires->>?, '.')::int[] <= string_to_array(?, '.')::int[]",
123+
[$key, $key, $version],
124+
);
125+
}
126+
});
127+
}
128+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace App\Values\Packages;
5+
6+
use App\Values\DTO;
7+
use Bag\Attributes\Laravel\FromRouteParameter;
8+
use Bag\Attributes\StripExtraParameters;
9+
use Bag\Attributes\Transforms;
10+
use Illuminate\Http\Request;
11+
12+
/**
13+
* Validated search request for the GET /packages/{type} endpoint.
14+
*
15+
* Constructed automatically from the HTTP request via Bag's service provider.
16+
* The `type` route parameter is resolved via #[FromRouteParameter].
17+
*/
18+
#[StripExtraParameters]
19+
readonly class PackageSearchRequest extends DTO
20+
{
21+
/** @param array<string, string>|null $requires */
22+
public function __construct(
23+
#[FromRouteParameter]
24+
public string $type,
25+
public ?string $q = null,
26+
public ?array $requires = null,
27+
public int $page = 1,
28+
public int $per_page = 24,
29+
) {}
30+
31+
/**
32+
* Transform an incoming HTTP request into constructor arguments.
33+
*
34+
* Validates query parameters and extracts the package type from the route.
35+
*
36+
* @return array<string, mixed>
37+
*/
38+
#[Transforms(Request::class)]
39+
public static function fromRequest(Request $request): array
40+
{
41+
$validated = $request->validate([
42+
'q' => ['nullable', 'string', 'max:200'],
43+
'requires' => ['nullable', 'array'],
44+
'requires.*' => ['string', 'max:20'],
45+
'page' => ['nullable', 'integer', 'min:1'],
46+
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
47+
]);
48+
49+
return [
50+
'type' => $request->route('type'),
51+
'q' => $validated['q'] ?? null,
52+
'requires' => $validated['requires'] ?? null,
53+
'page' => (int) ($validated['page'] ?? 1),
54+
'per_page' => (int) ($validated['per_page'] ?? 24),
55+
];
56+
}
57+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace App\Values\Packages;
5+
6+
use App\Models\Package;
7+
use App\Values\DTO;
8+
use Bag\Attributes\Transforms;
9+
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
10+
11+
/**
12+
* Response DTO for the package search endpoint.
13+
*
14+
* Wraps paginated results with pagination info and FAIR metadata for each package.
15+
*/
16+
readonly class PackageSearchResponse extends DTO
17+
{
18+
/**
19+
* @param array{page: int, per_page: int, total: int, pages: int} $info
20+
* @param list<array<string, mixed>> $packages
21+
*/
22+
public function __construct(
23+
public array $info,
24+
public array $packages,
25+
) {}
26+
27+
/**
28+
* Transform a paginator of Package models into the response structure.
29+
*
30+
* Each package is converted to its FAIR metadata representation.
31+
*
32+
* @param LengthAwarePaginator<int, Package> $paginator
33+
* @return array<string, mixed>
34+
*/
35+
#[Transforms(LengthAwarePaginator::class)]
36+
public static function fromPaginator(LengthAwarePaginator $paginator): array
37+
{
38+
return [
39+
'info' => [
40+
'page' => $paginator->currentPage(),
41+
'per_page' => $paginator->perPage(),
42+
'total' => $paginator->total(),
43+
'pages' => $paginator->lastPage(),
44+
],
45+
'packages' => collect($paginator->items())->map(
46+
fn (Package $package) => FairMetadata::from($package)->toArray()
47+
)->all(),
48+
];
49+
}
50+
}

database/factories/PackageFactory.php

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public function definition(): array
2020
$did = 'fake:' . $this->faker->slug();
2121
$name = $this->faker->words(3, true);
2222
$slug = Str::slug($name);
23-
$type = $this->faker->randomElement(['wp-plugin', 'wp-theme', 'wp-core']);
23+
$type = $this->faker->randomElement(['wp-plugin', 'wp-theme', 'wp-core', 'typo3-core', 'typo3-extension']);
2424
$origin = $this->faker->randomElement(['fair', 'wp']);
2525
$license = $this->faker->randomElement(['GPLv2', 'GPLv3', 'MIT', 'Apache-2.0', 'Proprietary']);
2626

@@ -41,6 +41,11 @@ public function definition(): array
4141
/**
4242
* Configure the model factory to create a plugin with tags
4343
*/
44+
public function typo3Extension(): static
45+
{
46+
return $this->state(['type' => 'typo3-extension', 'origin' => 'fair']);
47+
}
48+
4449
public function withTags(int $count = 3): static
4550
{
4651
return $this->afterCreating(function (Package $package) use ($count) {
@@ -61,10 +66,7 @@ public function withSpecificTags(array $tagNames): static
6166

6267
return PackageTag::query()->firstOrCreate(
6368
['slug' => $slug],
64-
[
65-
'id' => $this->faker->uuid(),
66-
'name' => $tagName,
67-
],
69+
['name' => $tagName],
6870
);
6971
});
7072

database/factories/PackageReleaseFactory.php

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ public function definition(): array
1919
'version' => $this->faker->semver(),
2020
'download_url' => $this->faker->url(),
2121
'requires' => [
22-
'wp' => $this->faker->semver(),
23-
'php' => $this->faker->randomElement(['7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3']),
22+
'php' => $this->faker->randomElement(['8.0', '8.1', '8.2', '8.3']),
2423
],
2524
'suggests' => [
2625
'another-plugin' => $this->faker->semver(),
@@ -38,4 +37,14 @@ public function definition(): array
3837
],
3938
];
4039
}
40+
41+
public function typo3(): static
42+
{
43+
return $this->state(fn () => [
44+
'requires' => [
45+
'typo3' => $this->faker->randomElement(['11.5', '12.4', '13.4']),
46+
'php' => $this->faker->randomElement(['8.1', '8.2', '8.3', '8.4']),
47+
],
48+
]);
49+
}
4150
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
use Illuminate\Database\Migrations\Migration;
5+
use Illuminate\Support\Facades\DB;
6+
7+
return new class extends Migration {
8+
public function up(): void
9+
{
10+
// Full-text search: stored generated tsvector column with GIN index
11+
DB::statement("
12+
ALTER TABLE packages
13+
ADD COLUMN search_vector tsvector
14+
GENERATED ALWAYS AS (
15+
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
16+
setweight(to_tsvector('english', coalesce(slug, '')), 'A') ||
17+
setweight(to_tsvector('english', coalesce(description, '')), 'B')
18+
) STORED
19+
");
20+
DB::statement('CREATE INDEX packages_search_vector_gin ON packages USING GIN (search_vector)');
21+
22+
// Trigram indexes for fuzzy/partial matching
23+
DB::statement('CREATE INDEX packages_name_trgm ON packages USING GIST (name gist_trgm_ops(siglen=32))');
24+
DB::statement('CREATE INDEX packages_slug_trgm ON packages USING GIST (slug gist_trgm_ops(siglen=32))');
25+
26+
// B-tree index on type for filtering
27+
DB::statement('CREATE INDEX packages_type_idx ON packages (type)');
28+
}
29+
30+
public function down(): void
31+
{
32+
DB::statement('DROP INDEX IF EXISTS packages_type_idx');
33+
DB::statement('DROP INDEX IF EXISTS packages_slug_trgm');
34+
DB::statement('DROP INDEX IF EXISTS packages_name_trgm');
35+
DB::statement('DROP INDEX IF EXISTS packages_search_vector_gin');
36+
DB::statement('ALTER TABLE packages DROP COLUMN IF EXISTS search_vector');
37+
}
38+
};

database/seeders/DatabaseSeeder.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ public function run(): void
1212
$this->call(AuthorizationSeeder::class);
1313
$this->call(UserSeeder::class);
1414
$this->call(PluginSeeder::class);
15+
$this->call(Typo3ExtensionSeeder::class);
1516
}
1617
}

0 commit comments

Comments
 (0)