From 33d053ce75c7dabe57eb5c5f4b91c92223dbe899 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Thu, 13 Aug 2026 21:07:22 +0200 Subject: [PATCH] Match search words at the start of a word, not anywhere inside one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Searching "Winkler H" returned all seven Winklers instead of the one Hannah. Each word was matched as a substring, so "H" hit T-h-omas, Kat-h-arina and CNC-Dre-h-er:in — every row. The shorter the input, the more useless the result, and an initial is the shortest input anyone would type. A word now has to match at the start of a word: either the haystack begins with it, or a space does. The haystack is first name, last name and job title joined, with hyphens, slashes, colons and dots flattened to spaces, so "dreher" still finds CNC-Dreher:in and "cnc" still finds both the Dreher and the Fräser. Checked against the live data before and after: "winkler h" now returns Hannah Winkler alone, "h winkler" the same in either order, "winkler kat" the two Katharinas, "dreher" the twelve CNC-Dreher. The trigram index on the concatenated name no longer applies, which is the price. At under nine hundred rows the scan is a few milliseconds; an index on the same expression brings it back when that stops being true. LIKE's own wildcards are escaped now — typing "100%" searched for everything before. Co-Authored-By: Claude Opus 5 --- app/(app)/employees/page.tsx | 35 +++++++++++++----- lib/employee-search.ts | 35 ++++++++++++++++++ tests/unit/employee-search.test.ts | 59 ++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 lib/employee-search.ts create mode 100644 tests/unit/employee-search.test.ts diff --git a/app/(app)/employees/page.tsx b/app/(app)/employees/page.tsx index 908c53c..50f0e18 100644 --- a/app/(app)/employees/page.tsx +++ b/app/(app)/employees/page.tsx @@ -7,6 +7,7 @@ import { Pagination } from "@/components/ui/Pagination"; import { StatusChip } from "@/components/ui/StatusChip"; import { currentUserId } from "@/lib/auth/session"; import { sql, withUser } from "@/lib/db"; +import { istPersonalnummer, suchMuster } from "@/lib/employee-search"; import { derivedStatusFilter } from "@/lib/employee-status-filter"; import { fmtDate, todayIso } from "@/lib/format"; import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org"; @@ -87,7 +88,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps // `\d`, nicht `d`: der fehlende Backslash liess die Ziffernerkennung // nie greifen — „1590" wurde als Name gesucht und fand nichts, // während das Muster auf „ddd" ansprang. - if (/^\d+$/.test(term)) { + if (istPersonalnummer(term)) { q = q.where("personnel_number", "=", Number(term)); } else { // Wortweise statt am Stück, und **jedes** Wort muss irgendwo @@ -109,17 +110,31 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps // Verglichen wird gegen den zusammengesetzten Namen, weil genau // darauf der Trigramm-Index liegt (idx_employees_name_trgm). // Getrennte Felder hätten ihn ungenutzt gelassen. - const woerter = term.split(/\s+/).filter(Boolean); + // Jedes Wort trifft am **Wortanfang**, nicht irgendwo mittendrin. + // + // Vorher wurde jedes Wort als Teilzeichenkette gesucht. Bei „Winkler + // H" traf das „H" auf T-h-omas, Kat-h-arina und CNC-Dre-h-er:in — + // die Suche gab alle sieben Winkler zurück, obwohl genau eine Hannah + // heisst. Je kürzer die Eingabe, desto unbrauchbarer wurde sie, und + // ein Anfangsbuchstabe ist die kürzeste sinnvolle Eingabe überhaupt. + // + // Gesucht wird über Vorname, Nachname und Position zusammen, wobei + // Trennzeichen als Wortgrenze gelten: „dreher" findet damit auch + // „CNC-Dreher:in". Ein Wort trifft, wenn der Heuhaufen damit beginnt + // oder ein Leerzeichen davorsteht. + // + // Der Trigramm-Index auf dem zusammengesetzten Namen greift hier + // nicht mehr — das ist der Preis. Bei knapp neunhundert Zeilen liest + // Postgres die Tabelle in wenigen Millisekunden; die Genauigkeit ist + // das wert, und bei Bedarf trägt ein Index auf demselben Ausdruck + // das später wieder. + const heuhaufen = sql`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`; q = q.where((eb) => eb.and( - woerter.map((wort) => { - // Als Parameter gebunden, nicht in die Abfrage geschrieben. - const like = `%${wort}%`; - return eb.or([ - eb(sql`first_name || ' ' || last_name`, "ilike", like), - eb("job_title", "ilike", like), - ]); - }) + // Als Parameter gebunden, nicht in die Abfrage geschrieben. + suchMuster(term).map(([amAnfang, nachLeerzeichen]) => + eb.or([eb(heuhaufen, "like", amAnfang), eb(heuhaufen, "like", nachLeerzeichen)]) + ) ) ); } diff --git a/lib/employee-search.ts b/lib/employee-search.ts new file mode 100644 index 0000000..2d7330f --- /dev/null +++ b/lib/employee-search.ts @@ -0,0 +1,35 @@ +// Wie aus einer Eingabe Suchmuster werden. +// +// Getrennt von der Seite, weil hier die Entscheidungen stecken, die man +// prüfen können muss: was als Wort zählt, was am Wortanfang treffen muss und +// was von LIKE als Text und nicht als Platzhalter gelesen wird. +// +// Die Bedeutung selbst — „trifft am Wortanfang" — liegt im SQL der Seite: +// verglichen wird gegen Vorname, Nachname und Position zusammengesetzt, mit +// Trennzeichen als Wortgrenze. + +/** Nur Ziffern? Dann ist es eine Personalnummer und kein Name. */ +export function istPersonalnummer(term: string): boolean { + return /^\d+$/.test(term.trim()); +} + +/** + * Je Suchwort zwei LIKE-Muster: „am Anfang" und „nach einem Leerzeichen". + * Zusammen ergeben sie „am Anfang eines Wortes". + * + * Ein Wort als Teilzeichenkette zu suchen wäre einfacher und war die erste + * Fassung — bei „Winkler H" traf das „H" dann auf Thomas, Katharina und + * CNC-Dreher:in, also auf alle. Je kürzer die Eingabe, desto unbrauchbarer + * das Ergebnis, und ein Anfangsbuchstabe ist die kürzeste sinnvolle Eingabe. + */ +export function suchMuster(term: string): string[][] { + return term + .trim() + .split(/\s+/) + .filter(Boolean) + .map((wort) => { + // Die Platzhalter von LIKE entschärfen: wer „50 %" tippt, sucht Text. + const klein = wort.toLowerCase().replace(/[\\%_]/g, (z) => `\\${z}`); + return [`${klein}%`, `% ${klein}%`]; + }); +} diff --git a/tests/unit/employee-search.test.ts b/tests/unit/employee-search.test.ts new file mode 100644 index 0000000..b4364de --- /dev/null +++ b/tests/unit/employee-search.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { istPersonalnummer, suchMuster } from "@/lib/employee-search"; + +// Der Anlass: „Winkler H" gab alle sieben Winkler zurück statt der einen +// Hannah. Das „H" wurde als Teilzeichenkette gesucht und traf damit T-h-omas, +// Kat-h-arina und CNC-Dre-h-er:in. +// +// Was hier geprüft wird, ist die Musterbildung. Dass „am Wortanfang" auch +// wirklich am Wortanfang trifft, entscheidet das SQL der Seite — und ist +// gegen die laufende Datenbank belegt: „winkler h" liefert dort genau Hannah +// Winkler, „winkler kat" die beiden Katharinas, „dreher" die CNC-Dreher:innen. + +describe("istPersonalnummer", () => { + it("erkennt reine Ziffern", () => { + expect(istPersonalnummer("3038")).toBe(true); + expect(istPersonalnummer(" 3038 ")).toBe(true); + }); + + it("hält alles andere für einen Namen", () => { + for (const t of ["winkler", "winkler 3038", "3038a", "", " "]) { + expect(istPersonalnummer(t)).toBe(false); + } + }); +}); + +describe("suchMuster", () => { + it("macht aus jedem Wort ein Paar: am Anfang, und nach einem Leerzeichen", () => { + expect(suchMuster("winkler")).toEqual([["winkler%", "% winkler%"]]); + }); + + it("behandelt jedes Wort einzeln", () => { + expect(suchMuster("winkler h")).toEqual([ + ["winkler%", "% winkler%"], + ["h%", "% h%"], + ]); + }); + + it("kennt keinen Platzhalter mitten im Wort", () => { + // Das ist der Kern: „h%" trifft Hannah, „%h%" träfe auch Thomas. + const [, [amAnfang]] = suchMuster("winkler h"); + expect(amAnfang.startsWith("%")).toBe(false); + }); + + it("schreibt klein, damit der Vergleich unabhängig von der Schreibweise ist", () => { + expect(suchMuster("WINKLER")).toEqual([["winkler%", "% winkler%"]]); + }); + + it("verträgt beliebig viel Abstand und Rand", () => { + expect(suchMuster(" winkler h ")).toHaveLength(2); + expect(suchMuster(" ")).toEqual([]); + }); + + it("entschärft die Platzhalter von LIKE", () => { + // Sonst wäre „%" eine Suche nach allem und „_" nach beliebigem Zeichen. + expect(suchMuster("100%")).toEqual([["100\\%%", "% 100\\%%"]]); + expect(suchMuster("a_b")).toEqual([["a\\_b%", "% a\\_b%"]]); + expect(suchMuster("a\\b")).toEqual([["a\\\\b%", "% a\\\\b%"]]); + }); +});