package store import ( "context" "fmt" "sort" "strings" "time" ) // Richtung eines Verweises aus Sicht des Eintrags, über den ein verwandter // Eintrag gefunden wurde (siehe RelatedEntry.Relation). const ( RelationLinksTo = "links_to" // der Vorgänger verweist auf diesen Eintrag RelationLinkedBy = "linked_by" // dieser Eintrag verweist auf den Vorgänger ) // RelatedEntry ist ein Nachbar eines Eintrags im Verweisgraphen. type RelatedEntry struct { Slug string Title string UpdatedAt time.Time // Distance zählt die Verweise bis zum Ausgangseintrag: 1 = direkt // verknüpft, 2 = über einen Zwischeneintrag. Distance int // Via ist der Eintrag, über den dieser gefunden wurde. Bei Distance 1 ist // das der Ausgangseintrag selbst, bei Distance 2 der Zwischeneintrag. Via string // Relation sagt, in welche Richtung der Verweis zwischen Via und diesem // Eintrag zeigt: RelationLinksTo oder RelationLinkedBy. Relation string } // RelatedEntries liefert die Nachbarn eines Eintrags im Verweisgraphen, bis zu // maxDistance Schritte weit und höchstens limit Stück (siehe AUSBAUSTUFEN.md // 3.7). // // # Wozu, wenn "get" schon links_to und linked_by liefert? // // "get" kennt nur die direkten Nachbarn, und das nur als nackte Bezeichner. // Hier kommen Titel und Änderungsdatum dazu, und auf Wunsch auch die zweite // Reihe: Was hängt an den Einträgen, die mit meinem Thema verknüpft sind? Für // einen Agenten ist das ein zweiter Weg zum Kontext, neben der Suche. Er // findet Einträge, die kein Suchwort gemeinsam haben, aber von einem Menschen // bewusst verknüpft wurden. // // # Vorgehen // // Breitensuche, eine Ebene nach der anderen: erst alle direkten Nachbarn, dann // deren Nachbarn. Die Richtung der Verweise spielt für die Suche keine Rolle. // Wer auf mich verweist, ist genauso verwandt wie das, worauf ich verweise. // Näheres steht vor Fernerem, innerhalb einer Ebene nach Titel sortiert. // // Verweise auf Bezeichner ohne Eintrag und Einträge im Papierkorb tauchen nicht // auf. Für einen Agenten, der Kontext sucht, gibt es dort nichts zu lesen. // // Gibt es den Ausgangseintrag nicht, ist das ErrNotFound. func (s *Store) RelatedEntries(ctx context.Context, projectID, slug string, maxDistance, limit int) ([]RelatedEntry, error) { // Den Ausgangseintrag selbst prüfen. Sonst bekäme ein Tippfehler im // Bezeichner eine leere Liste zurück, und der Agent hielte das Thema für // unverknüpft statt für nicht vorhanden. var vorhanden int if err := s.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM entries WHERE project_id = ? AND slug = ? AND `+nichtImPapierkorb, projectID, slug, ).Scan(&vorhanden); err != nil { return nil, fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err) } if vorhanden == 0 { return nil, ErrNotFound } // besucht verhindert, dass ein Eintrag zweimal auftaucht, etwa wenn A auf // B verweist und B zurück auf A. Der Ausgangseintrag zählt von Anfang an // als besucht, er soll nicht als sein eigener Nachbar erscheinen. besucht := map[string]bool{slug: true} ebene := []string{slug} var ergebnis []RelatedEntry for abstand := 1; abstand <= maxDistance && len(ebene) > 0 && len(ergebnis) < limit; abstand++ { kanten, err := s.nachbarn(ctx, projectID, ebene) if err != nil { return nil, err } // Je neu entdecktem Bezeichner nur die erste Kante merken. Welcher // Vorgänger „gewinnt“, ist egal, aber es muss derselbe sein, egal in // welcher Reihenfolge die Datenbank die Zeilen liefert. Deshalb sind // die Kanten vorher sortiert (siehe nachbarn). neu := make(map[string]RelatedEntry) var neueSlugs []string for _, k := range kanten { if besucht[k.nach] { continue } if _, schonDa := neu[k.nach]; schonDa { continue } neu[k.nach] = RelatedEntry{Slug: k.nach, Distance: abstand, Via: k.von, Relation: k.relation} neueSlugs = append(neueSlugs, k.nach) } // Titel und Änderungsdatum nachladen. Dabei fallen Verweisziele ohne // Eintrag und Einträge im Papierkorb heraus: Sie stehen nicht in // der Antwort. details, err := s.eintragsKoepfe(ctx, projectID, neueSlugs) if err != nil { return nil, err } var dieseEbene []RelatedEntry ebene = ebene[:0] for _, nachbar := range neueSlugs { besucht[nachbar] = true kopf, ok := details[nachbar] if !ok { continue } r := neu[nachbar] r.Title = kopf.Title r.UpdatedAt = kopf.UpdatedAt dieseEbene = append(dieseEbene, r) ebene = append(ebene, nachbar) } sort.Slice(dieseEbene, func(i, j int) bool { return dieseEbene[i].Title < dieseEbene[j].Title }) ergebnis = append(ergebnis, dieseEbene...) } if len(ergebnis) > limit { ergebnis = ergebnis[:limit] } return ergebnis, nil } // kante ist ein Verweis, gesehen von einem Eintrag der aktuellen Ebene aus. type kante struct { von, nach string relation string } // nachbarn liefert alle Verweise, die einen der Bezeichner in slugs berühren, // in beide Richtungen. // // Zwei Abfragen statt einer mit OR: So nutzt jede ihren eigenen Index. Die // ausgehenden laufen über den Primärschlüssel (from_entry_id, to_slug), die // eingehenden über idx_entry_links_projekt (project_id, to_slug). func (s *Store) nachbarn(ctx context.Context, projectID string, slugs []string) ([]kante, error) { platz, args := inListe(projectID, slugs) var kanten []kante // Ausgehend: Einträge der Ebene verweisen auf etwas. rows, err := s.lesen.QueryContext(ctx, `SELECT e.slug, l.to_slug FROM entries e JOIN entry_links l ON l.from_entry_id = e.id WHERE e.project_id = ? AND e.slug IN (`+platz+`) AND e.deleted_at IS NULL`, args..., ) if err != nil { return nil, fmt.Errorf("Verweise konnten nicht gelesen werden: %w", err) } for rows.Next() { var k kante if err := rows.Scan(&k.von, &k.nach); err != nil { rows.Close() return nil, err } k.relation = RelationLinksTo kanten = append(kanten, k) } if err := rows.Err(); err != nil { rows.Close() return nil, err } rows.Close() // Eingehend: Etwas verweist auf Einträge der Ebene. Der verweisende // Eintrag ist hier der Nachbar, deshalb stehen die Spalten andersherum. rows, err = s.lesen.QueryContext(ctx, `SELECT l.to_slug, e.slug FROM entry_links l JOIN entries e ON e.id = l.from_entry_id WHERE l.project_id = ? AND l.to_slug IN (`+platz+`) AND e.deleted_at IS NULL`, args..., ) if err != nil { return nil, fmt.Errorf("Rückverweise konnten nicht gelesen werden: %w", err) } defer rows.Close() for rows.Next() { var k kante if err := rows.Scan(&k.von, &k.nach); err != nil { return nil, err } k.relation = RelationLinkedBy kanten = append(kanten, k) } if err := rows.Err(); err != nil { return nil, err } // Feste Reihenfolge, damit dasselbe Projekt immer dieselbe Antwort gibt // (siehe RelatedEntries). sort.Slice(kanten, func(i, j int) bool { if kanten[i].nach != kanten[j].nach { return kanten[i].nach < kanten[j].nach } if kanten[i].von != kanten[j].von { return kanten[i].von < kanten[j].von } return kanten[i].relation < kanten[j].relation }) return kanten, nil } // eintragsKopf ist das Wenige, was RelatedEntries über einen Nachbarn braucht. type eintragsKopf struct { Title string UpdatedAt time.Time } // eintragsKoepfe lädt Titel und Änderungsdatum zu einer Liste von Bezeichnern. // Was nicht (mehr) existiert oder im Papierkorb liegt, fehlt in der Antwort. func (s *Store) eintragsKoepfe(ctx context.Context, projectID string, slugs []string) (map[string]eintragsKopf, error) { koepfe := make(map[string]eintragsKopf, len(slugs)) if len(slugs) == 0 { return koepfe, nil } platz, args := inListe(projectID, slugs) rows, err := s.lesen.QueryContext(ctx, `SELECT slug, title, updated_at FROM entries WHERE project_id = ? AND slug IN (`+platz+`) AND `+nichtImPapierkorb, args..., ) if err != nil { return nil, fmt.Errorf("Einträge konnten nicht gelesen werden: %w", err) } defer rows.Close() for rows.Next() { var slug, title, geaendert string if err := rows.Scan(&slug, &title, &geaendert); err != nil { return nil, err } koepfe[slug] = eintragsKopf{Title: title, UpdatedAt: parseTime(geaendert)} } return koepfe, rows.Err() } // inListe baut die Platzhalter für "IN (?, ?, …)" und die passenden // Argumente, mit der Projekt-ID vorneweg. // // Die Werte werden nie in den SQL-Text geschrieben, sondern immer als // Parameter übergeben. Nur so ist ausgeschlossen, dass ein Bezeichner die // Abfrage verändert (SQL-Injection). func inListe(projectID string, werte []string) (string, []any) { args := make([]any, 0, len(werte)+1) args = append(args, projectID) for _, w := range werte { args = append(args, w) } return strings.TrimSuffix(strings.Repeat("?, ", len(werte)), ", "), args }