*/ public const SourceColumns = ['title', 'subtitle', 'text', 'keywords']; protected function casts(): array { return ['indexed_at' => 'datetime']; } /** * @return BelongsTo */ public function pressRelease(): BelongsTo { return $this->belongsTo(PressRelease::class); } /** * Cache-Key der Umschalt-Prüfung (siehe isAvailable()). */ public const AvailabilityCacheKey = 'press-release-search.available'; /** * Ist die Suchtabelle die maßgebliche Quelle? * * Der Umstieg läuft über mehrere Schritte (Tabelle anlegen → Bestand * indizieren → alte Indizes entfernen). Maßgeblich ist deshalb NICHT, ob * die Tabelle existiert — zwischen Anlegen und fertigem Reindex ist sie * leer, die Suche fände dann nichts. Umgeschaltet wird erst, wenn * `press_releases` seine Volltext-Indizes verloren hat: Genau dieser * Schritt setzt einen vollständigen Suchindex voraus und wird von der * Migration erzwungen. Bis dahin sucht die Anwendung unverändert weiter. * * Die beiden Antworten werden BEWUSST unterschiedlich lange gecacht: * `true` ist der Dauerzustand nach dem Umstieg. `false` gilt nur bis zum * Entfernen der Indizes und schickt die Suche so lange auf den * Rückfallpfad — greift der noch, NACHDEM die Migration die Indizes * entfernt hat, quittiert MySQL das mit Fehler 1191 („Can't find FULLTEXT * index matching the column list") und jede Suche endet im 500er. Die * Migrationen räumen den Eintrag deshalb zusätzlich selbst ab; die kurze * TTL ist das Netz für den Fall, dass jemand die Indizes von Hand entfernt. */ public static function isAvailable(): bool { $cached = Cache::get(self::AvailabilityCacheKey); if ($cached !== null) { return (bool) $cached; } $available = self::resolveAvailability(); Cache::put(self::AvailabilityCacheKey, $available, $available ? 60 : 5); return $available; } /** * Verwirft die zwischengespeicherte Umschalt-Prüfung. Gehört in jede * Migration, die an den Volltext-Indizes oder an der Suchtabelle dreht. */ public static function forgetAvailability(): void { Cache::forget(self::AvailabilityCacheKey); } private static function resolveAvailability(): bool { if (! Schema::hasTable('press_release_search')) { return false; } if (Schema::getConnection()->getDriverName() !== 'mysql') { return true; } return DB::select("SHOW INDEX FROM press_releases WHERE Index_type = 'FULLTEXT'") === []; } /** * Schränkt eine Abfrage auf `press_releases` per Volltextsuche ein. * * Bewusst als unkorrelierte Unterabfrage (`whereIn`) formuliert: So ist die * Suchtabelle die treibende Tabelle und MySQL wertet den Volltext-Index * genau einmal aus. Ein korreliertes `whereExists` würde ihn pro Zeile der * Ergebnismenge auswerten. * * @param list $columns MUSS exakt einer Index-Definition entsprechen */ public static function constrain(QueryBuilder|EloquentBuilder $query, array $columns, string $expression, array $options = []): void { if (! self::isAvailable()) { // Umstieg noch nicht abgeschlossen — press_releases trägt die // Indizes noch selbst. Die Suche bleibt dadurch während des // gesamten Deploys unverändert brauchbar. $query->whereFullText($columns, $expression, $options); return; } $query->whereIn('press_releases.id', function (QueryBuilder $sub) use ($columns, $expression, $options): void { $sub->select('press_release_id') ->from('press_release_search') ->whereFullText($columns, $expression, $options); }); } /** * Schreibt (oder aktualisiert) die Index-Zeile einer Pressemitteilung. */ public static function syncFor(PressRelease $pressRelease): void { static::query()->updateOrCreate( ['press_release_id' => $pressRelease->getKey()], [ 'title' => (string) $pressRelease->title, 'subtitle' => $pressRelease->subtitle, 'text' => (string) $pressRelease->text, 'keywords' => $pressRelease->keywords, 'indexed_at' => now(), ], ); } }