Suche: Volltext-Index + Fix fuer doppelte Alpine-Instanz

Archiv-Suche lief auf grossen Datenbestaenden langsam und war im Browser
komplett tot: app.js startete ein eigenes Alpine, Livewire injizierte
zusaetzlich seins - der DOM-Morph brach mit "Alpine.transaction is not a
function" ab (keine Rubrik klickbar, Suchbegriff nicht aenderbar).

- app.js buendelt jetzt Livewire + Alpine aus livewire.esm,
  @livewireScriptConfig im Web-Layout stoppt die Auto-Injektion
- MySQL-Volltext-Index (Boolean-Mode, +wort*) statt LIKE ueber alles;
  LIKE-Fallback fuer SQLite-Tests und kurze Tokens
- Firmen-/Rubrik-Treffer als ID-Listen statt orWhereHas (OR + EXISTS
  hebelte den Volltext-Index aus)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin Adametz 2026-07-07 13:03:14 +00:00
parent b3780e0fea
commit db65c2ed7d
6 changed files with 178 additions and 16 deletions

View file

@ -227,6 +227,70 @@ class PressRelease extends Model
return $query->where('language', $language);
}
/**
* Suchtreffer der öffentlichen Archiv-Suche. Auf MySQL läuft die Suche
* über den Volltext-Index (press_releases_web_search_fulltext) im
* Boolean-Mode: jedes Wort muss vorkommen (+wort*), Präfix-Treffer
* inklusive. Kurze Wörter (< 3 Zeichen, unterhalb von
* innodb_ft_min_token_size) und andere Treiber (SQLite-Tests) fallen auf
* wortweises LIKE über dieselben Spalten zurück. Firmenname und Rubrik-
* name der Ausgabe werden zusätzlich mitgesucht — als vorab aufgelöste
* ID-Listen statt `orWhereHas`, weil OR + korreliertes EXISTS den
* Volltext-Index aushebelt (gemessen ~2 s statt ~0,5 s bei 175k PMs).
*/
public function scopeMatchingSearchTerm(Builder $query, string $term, string $language): Builder
{
$tokens = collect(preg_split('/\s+/u', trim($term), -1, PREG_SPLIT_NO_EMPTY) ?: [])
->map(fn (string $token): string => (string) preg_replace('/[+\-><()~*"@]+/u', '', $token))
->filter()
->values();
$useFullText = DB::connection()->getDriverName() === 'mysql'
&& $tokens->isNotEmpty()
&& $tokens->every(fn (string $token): bool => mb_strlen($token) >= 3);
$like = '%'.addcslashes($term, '\\%_').'%';
$companyIds = Company::query()->whereLike('name', $like)->pluck('id');
$categoryIds = Category::query()
->whereHas('translations', fn (Builder $translation) => $translation
->where('locale', $language)
->whereLike('name', $like))
->pluck('id');
return $query->where(function (Builder $outer) use ($tokens, $useFullText, $companyIds, $categoryIds): void {
$outer->where(function (Builder $content) use ($tokens, $useFullText): void {
if ($useFullText) {
$content->whereFullText(
['title', 'subtitle', 'text', 'keywords'],
$tokens->map(fn (string $token): string => '+'.$token.'*')->implode(' '),
['mode' => 'boolean']
);
return;
}
foreach ($tokens as $token) {
$tokenLike = '%'.addcslashes($token, '\\%_').'%';
$content->where(fn (Builder $inner) => $inner
->whereLike('title', $tokenLike)
->orWhereLike('subtitle', $tokenLike)
->orWhereLike('text', $tokenLike)
->orWhereLike('keywords', $tokenLike));
}
});
if ($companyIds->isNotEmpty()) {
$outer->orWhereIn('company_id', $companyIds);
}
if ($categoryIds->isNotEmpty()) {
$outer->orWhereIn('category_id', $categoryIds);
}
});
}
/**
* Beschränkt auf PMs, die dem User gehören (als Autor) oder zu einer ihm
* zugeordneten Firma. Bewusst ohne Portal-GlobalScope: ein Kunde darf

View file

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Volltext-Index für die öffentliche Archiv-Suche (title, subtitle, text,
* keywords). Ergänzt den schmaleren Admin-Index (title, keywords) um die
* inhaltstragenden Spalten, damit die Suche nicht mehr über ein
* nicht-indexierbares LIKE '%…%' auf dem MediumText laufen muss.
* SQLite (Tests) unterstützt keine Fulltext-Indizes → dort No-Op,
* die Suche fällt zur Laufzeit auf LIKE zurück.
*/
return new class extends Migration
{
public function up(): void
{
if (! $this->supportsFullTextIndexes()) {
return;
}
Schema::table('press_releases', function (Blueprint $table): void {
$table->fullText(['title', 'subtitle', 'text', 'keywords'], 'press_releases_web_search_fulltext');
});
}
public function down(): void
{
if (! $this->supportsFullTextIndexes()) {
return;
}
Schema::table('press_releases', function (Blueprint $table): void {
$table->dropFullText('press_releases_web_search_fulltext');
});
}
private function supportsFullTextIndexes(): bool
{
return in_array(DB::connection()->getDriverName(), ['mysql', 'pgsql'], true);
}
};

View file

@ -1,5 +1,11 @@
import Alpine from "alpinejs";
// Livewire samt gebündeltem Alpine als EIN Bundle laden. Vorher startete
// app.js ein eigenes Alpine aus node_modules und Livewire injizierte
// zusätzlich sein eigenes — zwei Alpine-Instanzen, und Livewires Morph
// brach mit "Alpine.transaction is not a function" ab (Suche/Ticker ohne
// Funktion). Die Auto-Injektion ist im Web-Layout per @livewireScriptConfig
// deaktiviert; Livewire.start() startet Alpine mit.
import { Livewire, Alpine } from "../../vendor/livewire/livewire/dist/livewire.esm";
window.Alpine = Alpine;
Alpine.start();
Livewire.start();

View file

@ -74,18 +74,7 @@ new class extends Component
$query = $this->baseQuery();
if (mb_strlen($term) >= 2) {
$query->where(function (Builder $inner) use ($term): void {
$like = '%'.$term.'%';
$inner
->whereLike('title', $like)
->orWhereLike('subtitle', $like)
->orWhereLike('text', $like)
->orWhereLike('keywords', $like)
->orWhereHas('company', fn (Builder $company) => $company->whereLike('name', $like))
->orWhereHas('category.translations', fn (Builder $translation) => $translation
->where('locale', $this->language)
->whereLike('name', $like));
});
$query->matchingSearchTerm($term, $this->language);
}
if ($this->category !== '') {

View file

@ -24,8 +24,19 @@
<title>{{ $pageTitle }}</title>
{{-- Kanonische URL --}}
<link rel="canonical" href="{{ $canonicalUrl }}">
@php
$robotsDirective = trim($__env->yieldContent('robots'));
@endphp
{{-- Robots-Anweisung (z. B. noindex für private Vorschauen) --}}
@if ($robotsDirective !== '')
<meta name="robots" content="{{ $robotsDirective }}">
@endif
{{-- Kanonische URL (entfällt bei noindex — es gibt keine öffentliche URL) --}}
@if (! str_contains($robotsDirective, 'noindex'))
<link rel="canonical" href="{{ $canonicalUrl }}">
@endif
{{-- Sprach-Alternates (de/en) – pro Seite optional gesetzt --}}
@hasSection('hreflang')
@ -56,6 +67,10 @@
$theme = config('app.theme', 'businessportal24');
@endphp
{{-- app.js bündelt Livewire + Alpine (livewire.esm) — @livewireScriptConfig
unten verhindert, dass Livewire zusätzlich sein eigenes Script injiziert
(sonst zwei Alpine-Instanzen und kaputte wire:model-Bindings). --}}
@livewireStyles
@vite([\App\Helpers\ThemeHelper::getThemeCssPath(), 'resources/js/app.js'], $domainConfig['assets_dir'] ?? 'build/web')
<!-- Sticky Header Script -->
@ -111,8 +126,14 @@
<!-- TopBar - statisch oben -->
@yield('content')
{{-- Schwebendes Kontakt-Widget (unten rechts, alle Web-Domains) --}}
<x-web.contact-launcher />
<!-- Additional Scripts -->
@stack('scripts')
@livewireScriptConfig
</body>
</html>

View file

@ -59,6 +59,32 @@ test('the search component filters by query and resets', function () {
->assertSee('Durchsuchen Sie das Archiv');
});
test('multi-word queries match releases containing all words in any order', function () {
/** @var TestCase $this */
createSearchableRelease('Solarpark geht ans Netz');
createSearchableRelease('Solarpark-Projekt angekündigt');
Volt::test('web.search', ['portal' => Portal::Businessportal24->value, 'language' => 'de'])
->set('q', 'Netz Solarpark')
->assertSee('Solarpark geht ans Netz')
->assertDontSee('Solarpark-Projekt angekündigt');
});
test('the search matches company names and keywords', function () {
/** @var TestCase $this */
$company = Company::factory()->businessportal24()->create(['name' => 'Windkraft Nord GmbH']);
createSearchableRelease('Quartalszahlen veröffentlicht', company: $company);
createSearchableRelease('Neue Geschäftsführung berufen', ['keywords' => 'Logistik, Automatisierung']);
Volt::test('web.search', ['portal' => Portal::Businessportal24->value, 'language' => 'de'])
->set('q', 'Windkraft Nord')
->assertSee('Quartalszahlen veröffentlicht')
->assertDontSee('Neue Geschäftsführung berufen')
->set('q', 'Automatisierung')
->assertSee('Neue Geschäftsführung berufen')
->assertDontSee('Quartalszahlen veröffentlicht');
});
test('the search component filters by category', function () {
/** @var TestCase $this */
$energie = Category::factory()->create(['portal' => Portal::Both]);
@ -76,6 +102,18 @@ test('the search component filters by category', function () {
->assertDontSee('Quartalszahlen veröffentlicht');
});
test('the web layout bundles livewire once instead of injecting a second alpine', function () {
/** @var TestCase $this */
// Regression: app.js bündelt Livewire + Alpine (livewire.esm). Würde
// Livewire zusätzlich sein eigenes Script injizieren, liefen zwei
// Alpine-Instanzen und der DOM-Morph bräche ab („Alpine.transaction
// is not a function") — Suche, Filter und Paginierung wären tot.
$this->get('https://businessportal24.test/de/suche')
->assertSuccessful()
->assertSee('window.livewireScriptConfig', false)
->assertDontSee('/vendor/livewire/livewire.js', false);
});
test('the search page is available per edition and portal', function () {
/** @var TestCase $this */
$this->get('https://businessportal24.test/en/suche')->assertSuccessful();