// Package folder implementiert DOC-09: hierarchische Ablage mit // Verschieben, Umbenennen und effizienten Teilbaum-Abfragen über einen // Materialized Path (siehe Migration 0006). Der Pfad besteht aus // Ordner-IDs, nie aus Namen — Umbenennen ändert daher nie den Pfad eines // Ordners oder seiner Kinder (Akzeptanzkriterium 3). package folder import ( "context" "errors" "fmt" "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // ErrNotFound wird geliefert, wenn ein angefragter Ordner nicht existiert. var ErrNotFound = errors.New("folder: ordner nicht gefunden") // ErrCycle wird von Move geliefert, wenn das Ziel der Ordner selbst oder // einer seiner eigenen Nachfahren ist (Akzeptanzkriterium 1, Pflichtprüfung // 1: Zyklus beim Verschieben wird verhindert). var ErrCycle = errors.New("folder: verschieben in den eigenen teilbaum wuerde einen zyklus erzeugen") // Folder ist EIN Ordner inklusive seines Materialized Path. type Folder struct { ID string ParentFolderID *string Name string Path string } func rootPath(id string) string { return "/" + id + "/" } func childPath(parentPath, id string) string { return parentPath + id + "/" } // Create legt einen neuen Ordner an — beliebig tief verschachtelbar // (Akzeptanzkriterium 1), da der Pfad aus dem (ggf. selbst tief // verschachtelten) Elternpfad abgeleitet wird, nicht aus einer festen // Tiefe. func Create(ctx context.Context, pool *pgxpool.Pool, name string, parentFolderID *string, createdBy string) (Folder, error) { var parentPath string if parentFolderID != nil { var err error parentPath, err = pathOf(ctx, pool, *parentFolderID) if err != nil { return Folder{}, err } } var f Folder f.Name = name f.ParentFolderID = parentFolderID if err := pool.QueryRow(ctx, ` INSERT INTO folders (parent_folder_id, name, created_by, path) VALUES ($1, $2, $3, '') RETURNING id `, parentFolderID, name, createdBy).Scan(&f.ID); err != nil { return Folder{}, fmt.Errorf("folder: ordner anlegen: %w", err) } if parentPath == "" { f.Path = rootPath(f.ID) } else { f.Path = childPath(parentPath, f.ID) } if _, err := pool.Exec(ctx, `UPDATE folders SET path = $2 WHERE id = $1`, f.ID, f.Path); err != nil { return Folder{}, fmt.Errorf("folder: pfad setzen: %w", err) } return f, nil } func pathOf(ctx context.Context, pool *pgxpool.Pool, folderID string) (string, error) { var path string err := pool.QueryRow(ctx, `SELECT path FROM folders WHERE id = $1`, folderID).Scan(&path) if errors.Is(err, pgx.ErrNoRows) { return "", ErrNotFound } if err != nil { return "", fmt.Errorf("folder: pfad lesen: %w", err) } return path, nil } // Get liefert einen einzelnen Ordner. func Get(ctx context.Context, pool *pgxpool.Pool, folderID string) (Folder, error) { var f Folder f.ID = folderID err := pool.QueryRow(ctx, `SELECT parent_folder_id, name, path FROM folders WHERE id = $1`, folderID). Scan(&f.ParentFolderID, &f.Name, &f.Path) if errors.Is(err, pgx.ErrNoRows) { return Folder{}, ErrNotFound } if err != nil { return Folder{}, fmt.Errorf("folder: ordner lesen: %w", err) } return f, nil } // Rename ändert NUR den Namen — der Materialized Path (auf IDs basierend) // bleibt unverändert, kein Verweis (eigener oder von Nachfahren) bricht // dadurch (Akzeptanzkriterium/Pflichtprüfung 3). func Rename(ctx context.Context, pool *pgxpool.Pool, folderID, newName string) error { tag, err := pool.Exec(ctx, `UPDATE folders SET name = $2, updated_at = now() WHERE id = $1`, folderID, newName) if err != nil { return fmt.Errorf("folder: umbenennen: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // Move verschiebt einen Ordner (mitsamt seinem gesamten Teilbaum) unter // einen neuen Elternordner (oder an die Wurzel, wenn newParentFolderID // nil ist) — Akzeptanzkriterium 2: aktualisiert den Pfad JEDES betroffenen // Nachfahren in einem Rutsch per Präfix-Ersetzung, kein rekursiver // Einzelaufruf je Kind nötig. func Move(ctx context.Context, pool *pgxpool.Pool, folderID string, newParentFolderID *string) error { tx, err := pool.Begin(ctx) if err != nil { return fmt.Errorf("folder: transaktion starten: %w", err) } defer func() { _ = tx.Rollback(ctx) }() var oldPath string if err := tx.QueryRow(ctx, `SELECT path FROM folders WHERE id = $1 FOR UPDATE`, folderID).Scan(&oldPath); err != nil { if errors.Is(err, pgx.ErrNoRows) { return ErrNotFound } return fmt.Errorf("folder: pfad lesen: %w", err) } var newParentPath string if newParentFolderID != nil { if *newParentFolderID == folderID { return ErrCycle } if err := tx.QueryRow(ctx, `SELECT path FROM folders WHERE id = $1`, *newParentFolderID).Scan(&newParentPath); err != nil { if errors.Is(err, pgx.ErrNoRows) { return ErrNotFound } return fmt.Errorf("folder: neuen elternpfad lesen: %w", err) } // Pflichtprüfung 1: das Ziel ist der Ordner selbst oder einer // seiner eigenen Nachfahren, wenn dessen Pfad mit dem eigenen // Pfad beginnt (der eigene Pfad ist als Präfix im Nachfahren // enthalten). if strings.HasPrefix(newParentPath, oldPath) { return ErrCycle } } newOwnPath := childPath(newParentPath, folderID) if newParentFolderID == nil { newOwnPath = rootPath(folderID) } if _, err := tx.Exec(ctx, ` UPDATE folders SET path = $2 || substr(path, length($1) + 1) WHERE path LIKE $1 || '%' `, oldPath, newOwnPath); err != nil { return fmt.Errorf("folder: teilbaum-pfade aktualisieren: %w", err) } if _, err := tx.Exec(ctx, ` UPDATE folders SET parent_folder_id = $2, updated_at = now() WHERE id = $1 `, folderID, newParentFolderID); err != nil { return fmt.Errorf("folder: elternordner aktualisieren: %w", err) } return tx.Commit(ctx) } // Subtree liefert ALLE Ordner im Teilbaum EINSCHLIESSLICH folderID selbst // — ein einziger Index-Scan über den Pfad-Präfix (Akzeptanzkriterium 3), // keine rekursive Abfrage über parent_folder_id. func Subtree(ctx context.Context, pool *pgxpool.Pool, folderID string) ([]Folder, error) { path, err := pathOf(ctx, pool, folderID) if err != nil { return nil, err } rows, err := pool.Query(ctx, ` SELECT id, parent_folder_id, name, path FROM folders WHERE path LIKE $1 || '%' ORDER BY path `, path) if err != nil { return nil, fmt.Errorf("folder: teilbaum abfragen: %w", err) } defer rows.Close() var out []Folder for rows.Next() { var f Folder if err := rows.Scan(&f.ID, &f.ParentFolderID, &f.Name, &f.Path); err != nil { return nil, fmt.Errorf("folder: teilbaum-zeile lesen: %w", err) } out = append(out, f) } return out, rows.Err() } // ListChildren liefert die DIREKTEN Kinder eines Ordners (parentFolderID // nil bedeutet: Wurzelordner) — Grundlage für DOC-10s lazy ladenden // Navigationsbaum (nicht der gesamte Teilbaum auf einmal). Papierkorb- // Ordner (DOC-11) verschwinden aus dieser normalen Ansicht. func ListChildren(ctx context.Context, pool *pgxpool.Pool, parentFolderID *string) ([]Folder, error) { var rows pgx.Rows var err error if parentFolderID == nil { rows, err = pool.Query(ctx, ` SELECT id, parent_folder_id, name, path FROM folders WHERE parent_folder_id IS NULL AND trashed_at IS NULL ORDER BY name `) } else { rows, err = pool.Query(ctx, ` SELECT id, parent_folder_id, name, path FROM folders WHERE parent_folder_id = $1 AND trashed_at IS NULL ORDER BY name `, *parentFolderID) } if err != nil { return nil, fmt.Errorf("folder: kinder abfragen: %w", err) } defer rows.Close() var out []Folder for rows.Next() { var f Folder if err := rows.Scan(&f.ID, &f.ParentFolderID, &f.Name, &f.Path); err != nil { return nil, fmt.Errorf("folder: kind-zeile lesen: %w", err) } out = append(out, f) } return out, rows.Err() } // Breadcrumb liefert den Pfad von der Wurzel bis EINSCHLIESSLICH folderID // als geordnete Liste — Grundlage für DOC-10s Brotkrumenpfad. Nutzt den // Materialized Path (IDs in Reihenfolge extrahiert), EINE Abfrage statt // eines Aufrufs je Ebene. func Breadcrumb(ctx context.Context, pool *pgxpool.Pool, folderID string) ([]Folder, error) { path, err := pathOf(ctx, pool, folderID) if err != nil { return nil, err } ids := strings.Split(strings.Trim(path, "/"), "/") rows, err := pool.Query(ctx, ` SELECT id, parent_folder_id, name, path FROM folders WHERE id = ANY($1) `, ids) if err != nil { return nil, fmt.Errorf("folder: brotkrumen abfragen: %w", err) } defer rows.Close() byID := map[string]Folder{} for rows.Next() { var f Folder if err := rows.Scan(&f.ID, &f.ParentFolderID, &f.Name, &f.Path); err != nil { return nil, fmt.Errorf("folder: brotkrumen-zeile lesen: %w", err) } byID[f.ID] = f } if err := rows.Err(); err != nil { return nil, err } out := make([]Folder, 0, len(ids)) for _, id := range ids { f, ok := byID[id] if !ok { return nil, fmt.Errorf("folder: brotkrumen-eintrag %q nicht gefunden", id) } out = append(out, f) } return out, nil }