$modelClass */ protected function resolveOwnedByName(Request $request, string $modelClass, string $label, string $nameParam, string $column = 'name'): Model|Response { $id = $request->get('id'); if ($this->present($id)) { $found = $modelClass::query()->whereKey($id)->first(); if ($found !== null) { return $found; } } $owned = $modelClass::query()->where('created_by', $request->user()?->getAuthIdentifier()); $name = $request->get($nameParam); if ($this->present($name)) { $matches = $this->matchByName($owned, (string) $name, $column); if ($matches->count() === 1) { return $matches->first(); } if ($matches->count() > 1) { return Response::error("Mehrere {$label} passen zu \"{$name}\": ".$matches->pluck($column)->join('; ').'. Bitte den genauen Namen angeben.'); } } return $this->optionsError($owned, $label, $column); } /** * Löst einen Fremdschlüssel über den Namen auf und schreibt die ID in den Request, * damit die nachgelagerte Validierung sie sieht. Gibt null zurück, wenn nichts zu tun * ist (ID bereits gesetzt, oder optionaler FK ohne Namen), sonst eine Fehler-Response. */ protected function mergeForeignKey(Request $request, string $nameParam, string $idKey, Builder $query, string $label, bool $required = true): ?Response { if ($this->present($request->get($idKey))) { return null; } if (! $this->present($request->get($nameParam))) { return $required ? Response::error("Bitte einen Namen für {$label} angeben.") : null; } $model = $this->resolveGlobalByName($query, $request->get($nameParam), $label); if ($model instanceof Response) { return $model; } $request->merge([$idKey => $model->id]); return null; } /** * Löst einen global sichtbaren Datensatz (z. B. Stadt, Land, Referent, Kurs, Ort) * über seinen Namen auf. */ protected function resolveGlobalByName(Builder $query, ?string $name, string $label, string $column = 'name'): Model|Response { if (! $this->present($name)) { return Response::error("Bitte einen Namen für {$label} angeben."); } $matches = $this->matchByName($query, (string) $name, $column); if ($matches->count() === 1) { return $matches->first(); } if ($matches->isEmpty()) { return Response::error("{$label} \"{$name}\" wurde nicht gefunden. Nutze das passende search-Tool, um den genauen Namen zu ermitteln."); } return Response::error("Mehrere {$label} passen zu \"{$name}\": ".$matches->pluck($column)->take(15)->join('; ').'. Bitte präziser angeben.'); } /** * Case-insensitive Treffer in einer einzigen Abfrage: Teilstring-Suche, exakte * Treffer nach vorne sortiert (und dadurch nie vom Limit abgeschnitten). Existiert * mindestens ein exakter Treffer, gewinnt dieser; sonst zählen die Teiltreffer. * DB-portabel über LOWER(). * * @return Collection */ private function matchByName(Builder $query, string $name, string $column): Collection { $needle = mb_strtolower(trim($name)); $matches = (clone $query) ->whereRaw('LOWER('.$column.') LIKE ?', ['%'.$needle.'%']) ->orderByRaw('CASE WHEN LOWER('.$column.') = ? THEN 0 ELSE 1 END', [$needle]) ->limit(25) ->get(); $exact = $matches->filter( fn (Model $model): bool => mb_strtolower((string) $model->getAttribute($column)) === $needle )->values(); return $exact->isNotEmpty() ? $exact : $matches; } private function optionsError(Builder $owned, string $label, string $column): Response { $names = (clone $owned)->orderBy($column)->limit(50)->pluck($column); if ($names->isEmpty()) { return Response::error("Du hast noch keine {$label} angelegt."); } return Response::error("{$label} nicht gefunden. Deine {$label}: ".$names->join('; ').'.'); } private function present(mixed $value): bool { return $value !== null && $value !== ''; } }