feat: add --prune and a rolled-back --dry-run to bm:seed
This commit is contained in:
13
README.md
13
README.md
@@ -21,7 +21,7 @@ Timestamps are stored in UTC (`TIMESTAMPTZ`) and written in RFC 3339 in Europe/W
|
||||
- A material without `published_at` is a draft; a material whose `published_at` lies in the future is not shown yet.
|
||||
- Cascades: deleting a course deletes its class sessions and readings and unlinks its deadlines and materials; deleting a meeting unlinks its sessions; deleting a reading or a user deletes the progress rows.
|
||||
- Admin screens for every model in a "Studia BM" navigation group, guarded by three permissions.
|
||||
- `bm:seed`: an idempotent upsert of meetings, courses, class sessions, deadlines and readings from a JSON file, all or nothing.
|
||||
- `bm:seed`: an idempotent upsert of meetings, courses, class sessions, deadlines and readings from a JSON file, all or nothing, with `--prune` to delete what the file no longer lists and `--dry-run` to preview.
|
||||
|
||||
## API reference
|
||||
|
||||
@@ -183,10 +183,15 @@ Defaults in `config/config.yaml`, read as `jz.bm.<key>`:
|
||||
|
||||
```sh
|
||||
./bin/bm bm:seed data/bm-2026-27.json
|
||||
./bin/bm bm:seed data/bm-2026-27.json --dry-run
|
||||
./bin/bm bm:seed data/bm-2026-27.json --dry-run --prune
|
||||
./bin/bm bm:seed data/bm-2026-27.json --prune
|
||||
```
|
||||
|
||||
Reads a JSON file, validates all of it, and upserts it in one transaction. Each kind is matched on its identity, updated in place when a field changed, and created otherwise; rows that are not in the file are left alone, so running the same file again changes nothing. Any error (an unknown key in the document, a bad value, a course key or meeting number that does not exist in the file) names the record and writes nothing. `--dry-run` only validates. The command prints a table of created, updated and unchanged rows per kind.
|
||||
Reads a JSON file, validates all of it, and upserts it in one transaction. Each kind is matched on its identity, updated in place when a field changed, and created otherwise; rows that are not in the file are left alone, so running the same file again changes nothing. Any error (an unknown key in the document, a bad value, a course key or meeting number that does not exist in the file) names the record and writes nothing. The command prints a table of created, updated, unchanged and deleted rows per kind.
|
||||
|
||||
`--prune` makes the file authoritative for what it covers: it deletes the courses of the file's semesters that the file does not list (their sessions and readings go with them, their deadlines and materials are unlinked), then the class sessions and readings of the listed courses that the file does not list. Reading progress of a deleted reading is deleted with it. Meetings and deadlines are never pruned. Each deleted row is printed. Use it when a timetable changes: a moved session is a new start time, so without `--prune` the old one would stay.
|
||||
|
||||
`--dry-run` applies the seed (and the prune) in a transaction that is rolled back, so the table and the deleted rows show what a real run would do.
|
||||
|
||||
| Kind | Identity |
|
||||
|------|----------|
|
||||
@@ -231,4 +236,4 @@ go test ./... # unit tests and Postgres integration tests (Docker)
|
||||
go test -short ./... # unit tests only
|
||||
```
|
||||
|
||||
The integration tests start a `postgres:16-alpine` container through testcontainers and create a fresh database per test; they never touch a development database. They cover the migrations up and down, the schema constraints, `bm:seed` (idempotency, in-place updates, all-or-nothing), every API route through the real `jwt.auth` guard, and admin saves through the admin API.
|
||||
The integration tests start a `postgres:16-alpine` container through testcontainers and create a fresh database per test; they never touch a development database. They cover the migrations up and down, the schema constraints, `bm:seed` (idempotency, in-place updates, all-or-nothing, prune and dry run), every API route through the real `jwt.auth` guard, and admin saves through the admin API.
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ func SeedCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) error
|
||||
Description: "Upsert meetings, courses, class sessions, deadlines and readings from a JSON file",
|
||||
Args: []bonfire.Arg{{Name: "file", Description: "Path of the seed JSON file", Required: true}},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "dry-run", Description: "Validate the file without writing", Bare: true},
|
||||
{Name: "dry-run", Description: "Show what the seed would change, then roll it back", Bare: true},
|
||||
{Name: "prune", Description: "Delete courses of the file's semesters, and sessions and readings of its courses, that the file does not list", Bare: true},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
path, _ := in.Argument("file")
|
||||
@@ -39,17 +40,24 @@ func SeedCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) error
|
||||
if err := file.Validate(); err != nil {
|
||||
return fmt.Errorf("bm:seed: %s is not valid, nothing was written:\n%w", path, err)
|
||||
}
|
||||
if dry, _ := in.Flag("dry-run"); dry == "true" {
|
||||
out.Success(fmt.Sprintf("%s is valid: %d meetings, %d courses, %d class sessions, %d deadlines, %d readings", path,
|
||||
len(file.Meetings), len(file.Courses), len(file.ClassSessions), len(file.Deadlines), len(file.Readings)))
|
||||
return nil
|
||||
}
|
||||
dry, _ := in.Flag("dry-run")
|
||||
prune, _ := in.Flag("prune")
|
||||
opts := classes.SeedOptions{DryRun: dry == "true", Prune: prune == "true"}
|
||||
out.Printf("%s is valid: %d meetings, %d courses, %d class sessions, %d deadlines, %d readings\n", path,
|
||||
len(file.Meetings), len(file.Courses), len(file.ClassSessions), len(file.Deadlines), len(file.Readings))
|
||||
return withDB(ctx, func(db *gorm.DB) error {
|
||||
sum, err := classes.Seed(ctx, db, file)
|
||||
sum, err := classes.SeedWith(ctx, db, file, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bm:seed: nothing was written: %w", err)
|
||||
}
|
||||
out.Table([]string{"kind", "created", "updated", "unchanged"}, sum.Rows())
|
||||
out.Table([]string{"kind", "created", "updated", "unchanged", "deleted"}, sum.Rows())
|
||||
for _, r := range sum.Removed {
|
||||
out.Printf("deleted: %s\n", r)
|
||||
}
|
||||
if opts.DryRun {
|
||||
out.Success("bm:seed: dry run of " + path + ", nothing was written")
|
||||
return nil
|
||||
}
|
||||
out.Success("bm:seed: " + path + " applied")
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -55,12 +55,11 @@ func TestSeedCommandChecksTheFileBeforeTheDatabase(t *testing.T) {
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
if err := bonfire.Call(context.Background(), cmds, "bm:seed", []string{"../testdata/seed.example.json", "--dry-run"}, &out); err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if called || !strings.Contains(out.String(), "3 courses") {
|
||||
t.Errorf("dry run called=%v output %q", called, out.String())
|
||||
err = bonfire.Call(context.Background(), cmds, "bm:seed", []string{"../testdata/seed.example.json", "--dry-run", "--prune"}, &out)
|
||||
if !called || err == nil || !strings.Contains(out.String(), "3 courses") {
|
||||
t.Errorf("dry run called=%v err=%v output %q, want the validation line and the withDB error", called, err, out.String())
|
||||
}
|
||||
called = false
|
||||
|
||||
err = bonfire.Call(context.Background(), cmds, "bm:seed", []string{"../testdata/seed.example.json"}, &out)
|
||||
if !called || err == nil {
|
||||
|
||||
@@ -659,3 +659,64 @@ func TestAPIAgainstDatabase(t *testing.T) {
|
||||
}
|
||||
|
||||
func ptr(s string) *string { return &s }
|
||||
|
||||
// TestSeedPrune moves a session, drops a course, renames a course's form and
|
||||
// drops a reading: --dry-run reports it without writing, --prune applies it.
|
||||
func TestSeedPrune(t *testing.T) {
|
||||
_, plugins, gdb := migrated(t)
|
||||
seedFixture(t, plugins, "testdata/seed.example.json")
|
||||
next := filepath.Join(t.TempDir(), "next.json")
|
||||
writeJSON(t, next, `{"meetings": [{"number": 1, "starts_on": "2030-10-05", "ends_on": "2030-10-06", "notes": "przykład"}],
|
||||
"courses": [{"key": "a-w", "name": "Przedmiot przykładowy A", "form": "wyklad", "semester": 1, "sort_order": 1},
|
||||
{"key": "b", "name": "Przedmiot przykładowy B", "form": "inne", "semester": 1, "sort_order": 3}],
|
||||
"class_sessions": [{"course": "a-w", "meeting": 1, "starts_at": "2030-10-05T08:15:00+02:00", "ends_at": "2030-10-05T09:45:00+02:00"},
|
||||
{"course": "b", "meeting": 1, "starts_at": "2030-10-06T08:00:00+02:00", "ends_at": "2030-10-06T09:30:00+02:00"}],
|
||||
"readings": [{"course": "a-w", "title": "Lektura przykładowa 1", "is_required": true, "sort_order": 1}]}`)
|
||||
before := map[string]int64{}
|
||||
for _, table := range []string{"jz_bm_courses", "jz_bm_class_sessions", "jz_bm_readings", "jz_bm_meetings", "jz_bm_deadlines"} {
|
||||
before[table] = count(t, gdb, table)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{next, "--prune", "--dry-run"}, &buf); err != nil {
|
||||
t.Fatalf("dry run: %v\n%s", err, buf.String())
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{"courses\t1\t1\t0\t2", "class_sessions\t2\t0\t0\t3", "readings\t0\t1\t0\t1", "nothing was written", `deleted: course "Przedmiot przykładowy A" (cwiczenia, semester 1) with 1 sessions and 0 readings`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("dry run output lacks %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
for table, n := range before {
|
||||
if got := count(t, gdb, table); got != n {
|
||||
t.Errorf("dry run changed %s: %d -> %d", table, n, got)
|
||||
}
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
if err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{next, "--prune"}, &buf); err != nil {
|
||||
t.Fatalf("prune: %v\n%s", err, buf.String())
|
||||
}
|
||||
want := map[string]int64{"jz_bm_courses": 2, "jz_bm_class_sessions": 2, "jz_bm_readings": 1, "jz_bm_meetings": 2, "jz_bm_deadlines": 2}
|
||||
for table, n := range want {
|
||||
if got := count(t, gdb, table); got != n {
|
||||
t.Errorf("after prune %s = %d rows, want %d", table, got, n)
|
||||
}
|
||||
}
|
||||
var orphan int64
|
||||
gdb.Model(&models.Deadline{}).Where("title = ? AND course_id IS NULL", "Esej przykładowy").Count(&orphan)
|
||||
if orphan != 1 {
|
||||
t.Error("the deadline of the pruned course was not unlinked")
|
||||
}
|
||||
|
||||
// Without --prune the same file deletes nothing.
|
||||
seedFixture(t, plugins, "testdata/seed.example.json")
|
||||
buf.Reset()
|
||||
if err := bonfire.Call(t.Context(), bmPlugin(t, plugins).Commands(), "bm:seed", []string{next}, &buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The fixture brought back course a-c (its session) and b-k next to b.
|
||||
if got := count(t, gdb, "jz_bm_courses"); got != 4 || strings.Contains(buf.String(), "deleted:") {
|
||||
t.Errorf("seed without --prune deleted rows: %d courses\n%s", got, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user