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:
parent
b3780e0fea
commit
db65c2ed7d
6 changed files with 178 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 !== '') {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue