feat: add --prune and a rolled-back --dry-run to bm:seed
This commit is contained in:
106
classes/seed.go
106
classes/seed.go
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user