feat: add --prune and a rolled-back --dry-run to bm:seed

This commit is contained in:
Jakub Zych
2026-10-02 17:28:07 +02:00
parent e8411adac3
commit 28f68d002f
6 changed files with 192 additions and 23 deletions

View File

@@ -86,14 +86,29 @@ type SeedReading struct {
SortOrder int `json:"sort_order"`
}
// SeedCounts counts the rows of one kind a seed created, updated or left
// unchanged.
// SeedCounts counts the rows of one kind a seed created, updated, left
// unchanged or (with SeedOptions.Prune) deleted.
type SeedCounts struct {
Created int
Updated int
Unchanged int
Deleted int
}
// SeedOptions changes how a seed is applied.
type SeedOptions struct {
// Prune deletes the courses of the file's semesters, and the class
// sessions and readings of the file's courses, that the file does not
// list. Meetings and deadlines are never pruned.
Prune bool
// DryRun applies the seed in a transaction that is rolled back, so the
// summary shows what would change and nothing is written.
DryRun bool
}
// errDryRun rolls back a dry-run transaction.
var errDryRun = errors.New("bm:seed: dry run")
func (c *SeedCounts) record(created, changed bool) {
switch {
case created:
@@ -112,12 +127,15 @@ type SeedSummary struct {
ClassSessions SeedCounts
Deadlines SeedCounts
Readings SeedCounts
// Removed describes each row Prune deleted, for the command output.
Removed []string
}
// Rows returns the summary as table rows: kind, created, updated, unchanged.
// Rows returns the summary as table rows: kind, created, updated,
// unchanged, deleted.
func (s SeedSummary) Rows() [][]string {
row := func(name string, c SeedCounts) []string {
return []string{name, fmt.Sprint(c.Created), fmt.Sprint(c.Updated), fmt.Sprint(c.Unchanged)}
return []string{name, fmt.Sprint(c.Created), fmt.Sprint(c.Updated), fmt.Sprint(c.Unchanged), fmt.Sprint(c.Deleted)}
}
return [][]string{
row("meetings", s.Meetings),
@@ -280,6 +298,12 @@ func (f *SeedFile) Validate() error {
// time, deadlines by title and due time, readings by course and title. Rows
// absent from the file are left alone. On any error nothing is written.
func Seed(ctx context.Context, db *gorm.DB, f *SeedFile) (SeedSummary, error) {
return SeedWith(ctx, db, f, SeedOptions{})
}
// SeedWith is Seed with options: pruning rows the file no longer lists and
// dry runs.
func SeedWith(ctx context.Context, db *gorm.DB, f *SeedFile, opts SeedOptions) (SeedSummary, error) {
var sum SeedSummary
if err := f.Validate(); err != nil {
return sum, err
@@ -304,7 +328,9 @@ func Seed(ctx context.Context, db *gorm.DB, f *SeedFile) (SeedSummary, error) {
}
courseIDs := map[string]uint{}
semesters := map[int]bool{}
for _, in := range f.Courses {
semesters[in.Semester] = true
c := models.Course{}
where := clause.And(
clause.Eq{Column: "name", Value: in.Name},
@@ -331,6 +357,7 @@ func Seed(ctx context.Context, db *gorm.DB, f *SeedFile) (SeedSummary, error) {
courseIDs[in.Key] = c.ID
}
keptSessions := []uint{0}
for _, in := range f.ClassSessions {
start, _ := models.ParseDateTime(in.StartsAt)
s := models.ClassSession{}
@@ -357,6 +384,7 @@ func Seed(ctx context.Context, db *gorm.DB, f *SeedFile) (SeedSummary, error) {
return fmt.Errorf("class session %s %s: %w", in.Course, in.StartsAt, err)
}
sum.ClassSessions.record(created, changed)
keptSessions = append(keptSessions, s.ID)
}
for _, in := range f.Deadlines {
@@ -383,6 +411,7 @@ func Seed(ctx context.Context, db *gorm.DB, f *SeedFile) (SeedSummary, error) {
sum.Deadlines.record(created, changed)
}
keptReadings := []uint{0}
for _, in := range f.Readings {
r := models.Reading{}
where := clause.And(
@@ -402,15 +431,82 @@ func Seed(ctx context.Context, db *gorm.DB, f *SeedFile) (SeedSummary, error) {
return fmt.Errorf("reading %q of %s: %w", in.Title, in.Course, err)
}
sum.Readings.record(created, changed)
keptReadings = append(keptReadings, r.ID)
}
if opts.Prune {
if err := prune(tx, &sum, semesters, courseIDs, keptSessions, keptReadings); err != nil {
return err
}
}
if opts.DryRun {
return errDryRun
}
return nil
})
if err != nil {
if err != nil && !errors.Is(err, errDryRun) {
return SeedSummary{}, err
}
return sum, nil
}
// prune deletes what the file no longer lists: courses of its semesters
// (their sessions and readings go with them through the foreign keys), then
// the sessions and readings of the kept courses that the seed did not touch.
func prune(tx *gorm.DB, sum *SeedSummary, semesters map[int]bool, courseIDs map[string]uint, keptSessions, keptReadings []uint) error {
kept := []uint{0}
for _, id := range courseIDs {
kept = append(kept, id)
}
sems := []int{}
for sem := range semesters {
sems = append(sems, sem)
}
var courses []models.Course
if len(sems) > 0 {
if err := tx.Where("semester IN ? AND id NOT IN ?", sems, kept).Order("id").Find(&courses).Error; err != nil {
return err
}
}
for _, c := range courses {
var sessions, readings int64
tx.Model(&models.ClassSession{}).Where("course_id = ?", c.ID).Count(&sessions)
tx.Model(&models.Reading{}).Where("course_id = ?", c.ID).Count(&readings)
sum.Removed = append(sum.Removed, fmt.Sprintf("course %q (%s, semester %d) with %d sessions and %d readings", c.Name, c.Form, c.Semester, sessions, readings))
if err := tx.Delete(&models.Course{}, c.ID).Error; err != nil {
return fmt.Errorf("prune course %q: %w", c.Name, err)
}
sum.Courses.Deleted++
sum.ClassSessions.Deleted += int(sessions)
sum.Readings.Deleted += int(readings)
}
var sessions []models.ClassSession
if err := tx.Where("course_id IN ? AND id NOT IN ?", kept, keptSessions).Order("starts_at").Find(&sessions).Error; err != nil {
return err
}
for _, s := range sessions {
sum.Removed = append(sum.Removed, fmt.Sprintf("class session of course %d at %s", s.CourseID, string(s.StartsAt)))
if err := tx.Delete(&models.ClassSession{}, s.ID).Error; err != nil {
return fmt.Errorf("prune class session %d: %w", s.ID, err)
}
sum.ClassSessions.Deleted++
}
var readings []models.Reading
if err := tx.Where("course_id IN ? AND id NOT IN ?", kept, keptReadings).Order("id").Find(&readings).Error; err != nil {
return err
}
for _, r := range readings {
sum.Removed = append(sum.Removed, fmt.Sprintf("reading %q of course %d", r.Title, r.CourseID))
if err := tx.Delete(&models.Reading{}, r.ID).Error; err != nil {
return fmt.Errorf("prune reading %d: %w", r.ID, err)
}
sum.Readings.Deleted++
}
return nil
}
// nz returns nil for a missing or blank optional text, as the models'
// BeforeSave hooks store it, so an unchanged row compares equal.
func nz(s *string) *string {

View File

@@ -111,7 +111,7 @@ func TestSummaryRows(t *testing.T) {
s.Courses.Created = 2
s.Readings.Unchanged = 1
rows := s.Rows()
if len(rows) != 5 || rows[1][0] != "courses" || rows[1][1] != "2" || rows[4][3] != "1" {
if len(rows) != 5 || rows[1][0] != "courses" || rows[1][1] != "2" || rows[4][3] != "1" || len(rows[0]) != 5 {
t.Errorf("Rows = %v", rows)
}
}