munin/internal/store/related.go
2026-10-19 10:13:56 +02:00

270 lines
8.6 KiB
Go

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 existing int
if err := s.read.QueryRowContext(ctx,
`SELECT COUNT(*) FROM entries WHERE project_id = ? AND slug = ? AND `+notInTrash,
projectID, slug,
).Scan(&existing); err != nil {
return nil, fmt.Errorf("Eintrag konnte nicht gelesen werden: %w", err)
}
if existing == 0 {
return nil, ErrNotFound
}
// visited 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.
visited := map[string]bool{slug: true}
level := []string{slug}
var result []RelatedEntry
for distance := 1; distance <= maxDistance && len(level) > 0 && len(result) < limit; distance++ {
edges, err := s.neighbors(ctx, projectID, level)
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 neighbors).
newer := make(map[string]RelatedEntry)
var newSlugs []string
for _, k := range edges {
if visited[k.to] {
continue
}
if _, already := newer[k.to]; already {
continue
}
newer[k.to] = RelatedEntry{Slug: k.to, Distance: distance, Via: k.from, Relation: k.relation}
newSlugs = append(newSlugs, k.to)
}
// Titel und Änderungsdatum nachladen. Dabei fallen Verweisziele ohne
// Eintrag und Einträge im Papierkorb heraus: Sie stehen nicht in
// der Antwort.
details, err := s.entryHeads(ctx, projectID, newSlugs)
if err != nil {
return nil, err
}
var thisLevel []RelatedEntry
level = level[:0]
for _, neighbor := range newSlugs {
visited[neighbor] = true
head, ok := details[neighbor]
if !ok {
continue
}
r := newer[neighbor]
r.Title = head.Title
r.UpdatedAt = head.UpdatedAt
thisLevel = append(thisLevel, r)
level = append(level, neighbor)
}
sort.Slice(thisLevel, func(i, j int) bool { return thisLevel[i].Title < thisLevel[j].Title })
result = append(result, thisLevel...)
}
if len(result) > limit {
result = result[:limit]
}
return result, nil
}
// edge ist ein Verweis, gesehen von einem Eintrag der aktuellen Ebene aus.
type edge struct {
from, to string
relation string
}
// neighbors 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_project (project_id, to_slug).
func (s *Store) neighbors(ctx context.Context, projectID string, slugs []string) ([]edge, error) {
placeholders, args := inList(projectID, slugs)
var edges []edge
// Ausgehend: Einträge der Ebene verweisen auf etwas.
rows, err := s.read.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 (`+placeholders+`) 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 edge
if err := rows.Scan(&k.from, &k.to); err != nil {
rows.Close()
return nil, err
}
k.relation = RelationLinksTo
edges = append(edges, 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.read.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 (`+placeholders+`) 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 edge
if err := rows.Scan(&k.from, &k.to); err != nil {
return nil, err
}
k.relation = RelationLinkedBy
edges = append(edges, k)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Feste Reihenfolge, damit dasselbe Projekt immer dieselbe Antwort gibt
// (siehe RelatedEntries).
sort.Slice(edges, func(i, j int) bool {
if edges[i].to != edges[j].to {
return edges[i].to < edges[j].to
}
if edges[i].from != edges[j].from {
return edges[i].from < edges[j].from
}
return edges[i].relation < edges[j].relation
})
return edges, nil
}
// entryHead ist das Wenige, was RelatedEntries über einen Nachbarn braucht.
type entryHead struct {
Title string
UpdatedAt time.Time
}
// entryHeads 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) entryHeads(ctx context.Context, projectID string, slugs []string) (map[string]entryHead, error) {
heads := make(map[string]entryHead, len(slugs))
if len(slugs) == 0 {
return heads, nil
}
placeholders, args := inList(projectID, slugs)
rows, err := s.read.QueryContext(ctx,
`SELECT slug, title, updated_at FROM entries
WHERE project_id = ? AND slug IN (`+placeholders+`) AND `+notInTrash,
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, changed string
if err := rows.Scan(&slug, &title, &changed); err != nil {
return nil, err
}
heads[slug] = entryHead{Title: title, UpdatedAt: parseTime(changed)}
}
return heads, rows.Err()
}
// inList 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 inList(projectID string, values []string) (string, []any) {
args := make([]any, 0, len(values)+1)
args = append(args, projectID)
for _, w := range values {
args = append(args, w)
}
return strings.TrimSuffix(strings.Repeat("?, ", len(values)), ", "), args
}