Match search words at the start of a word, not anywhere inside one
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 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { Pagination } from "@/components/ui/Pagination";
|
|||||||
import { StatusChip } from "@/components/ui/StatusChip";
|
import { StatusChip } from "@/components/ui/StatusChip";
|
||||||
import { currentUserId } from "@/lib/auth/session";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
import { sql, withUser } from "@/lib/db";
|
import { sql, withUser } from "@/lib/db";
|
||||||
|
import { istPersonalnummer, suchMuster } from "@/lib/employee-search";
|
||||||
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
||||||
import { fmtDate, todayIso } from "@/lib/format";
|
import { fmtDate, todayIso } from "@/lib/format";
|
||||||
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org";
|
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
|
// `\d`, nicht `d`: der fehlende Backslash liess die Ziffernerkennung
|
||||||
// nie greifen — „1590" wurde als Name gesucht und fand nichts,
|
// nie greifen — „1590" wurde als Name gesucht und fand nichts,
|
||||||
// während das Muster auf „ddd" ansprang.
|
// während das Muster auf „ddd" ansprang.
|
||||||
if (/^\d+$/.test(term)) {
|
if (istPersonalnummer(term)) {
|
||||||
q = q.where("personnel_number", "=", Number(term));
|
q = q.where("personnel_number", "=", Number(term));
|
||||||
} else {
|
} else {
|
||||||
// Wortweise statt am Stück, und **jedes** Wort muss irgendwo
|
// 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
|
// Verglichen wird gegen den zusammengesetzten Namen, weil genau
|
||||||
// darauf der Trigramm-Index liegt (idx_employees_name_trgm).
|
// darauf der Trigramm-Index liegt (idx_employees_name_trgm).
|
||||||
// Getrennte Felder hätten ihn ungenutzt gelassen.
|
// 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<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`;
|
||||||
q = q.where((eb) =>
|
q = q.where((eb) =>
|
||||||
eb.and(
|
eb.and(
|
||||||
woerter.map((wort) => {
|
|
||||||
// Als Parameter gebunden, nicht in die Abfrage geschrieben.
|
// Als Parameter gebunden, nicht in die Abfrage geschrieben.
|
||||||
const like = `%${wort}%`;
|
suchMuster(term).map(([amAnfang, nachLeerzeichen]) =>
|
||||||
return eb.or([
|
eb.or([eb(heuhaufen, "like", amAnfang), eb(heuhaufen, "like", nachLeerzeichen)])
|
||||||
eb(sql<string>`first_name || ' ' || last_name`, "ilike", like),
|
)
|
||||||
eb("job_title", "ilike", like),
|
|
||||||
]);
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
35
lib/employee-search.ts
Normal file
35
lib/employee-search.ts
Normal file
@@ -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}%`];
|
||||||
|
});
|
||||||
|
}
|
||||||
59
tests/unit/employee-search.test.ts
Normal file
59
tests/unit/employee-search.test.ts
Normal file
@@ -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%"]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user