Compare · 028

The migration lifecycle: PostgreSQL and SQLite

Every file of the PostgreSQL and SQLite versions side by side, with the lines that differ marked.

Compare PostgreSQL withSQLite

Configuration

sgl.yamldiffers
PostgreSQL
SQLite
1version: 1
1version: 1
2
2
3databases:
3databases:
4 - name: birdwatch
4 - name: birdwatch
5 dialect: postgres
5 dialect: sqlite
6 migrations: db/migrations
6 migrations: db/migrations
7 queries: db/queries
7 queries: db/queries
8 go:
8 go:
9 package: store
9 package: store
10 out: internal/store
10 out: internal/store
11 driver: database/sql
12 migrate:
13 dsn: ${env:DATABASE_URL}

Migrations

db/migrations/0001_sightings.up.sqldiffers
PostgreSQL
SQLite
1CREATE TABLE sightings (
1CREATE TABLE sightings (
2 id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
2 id INTEGER PRIMARY KEY,
3 species TEXT NOT NULL,
3 species TEXT NOT NULL,
4 place TEXT NOT NULL
4 place TEXT NOT NULL
5);
5);
db/migrations/0001_sightings.down.sqlidentical
PostgreSQL
SQLite
1DROP TABLE sightings;
1DROP TABLE sightings;
db/migrations/0002_tidy_species.up.sqlidentical
PostgreSQL
SQLite
1UPDATE sightings SET species = lower(trim(species));
1UPDATE sightings SET species = lower(trim(species));
db/migrations/0003_sighting_count.up.sqlidentical
PostgreSQL
SQLite
1ALTER TABLE sightings ADD COLUMN birds INTEGER NOT NULL DEFAULT 1;
1ALTER TABLE sightings ADD COLUMN birds INTEGER NOT NULL DEFAULT 1;
db/migrations/0003_sighting_count.down.sqlidentical
PostgreSQL
SQLite
1ALTER TABLE sightings DROP COLUMN birds;
1ALTER TABLE sightings DROP COLUMN birds;

Queries

RecordSightingdiffers
PostgreSQL · db/queries/sightings.sql
SQLite · db/queries/sightings.sql
1-- sgl.query(RecordSighting, exec)
1-- sgl.query(RecordSighting, exec)
2INSERT INTO sightings (species, place) VALUES ($1, $2);
2INSERT INTO sightings (species, place) VALUES (?, ?);
ListSightingsidentical
PostgreSQL · db/queries/sightings.sql
SQLite · db/queries/sightings.sql
1-- sgl.query(ListSightings, many)
1-- sgl.query(ListSightings, many)
2SELECT id, species, place FROM sightings ORDER BY id;
2SELECT id, species, place FROM sightings ORDER BY id;

What sgl generates

RecordSightingidentical
PostgreSQL
SQLite
1func (queries *Queries) RecordSighting(ctx context.Context, params RecordSightingParams) error
1func (queries *Queries) RecordSighting(ctx context.Context, params RecordSightingParams) error
2
2
3type RecordSightingParams struct {
3type RecordSightingParams struct {
4 Species string
4 Species string
5 Place string
5 Place string
6}
6}
ListSightingsidentical
PostgreSQL
SQLite
1func (queries *Queries) ListSightings(ctx context.Context) ([]ListSightingsRow, error)
1func (queries *Queries) ListSightings(ctx context.Context) ([]ListSightingsRow, error)
2
2
3type ListSightingsRow struct {
3type ListSightingsRow struct {
4 ID int64 `json:"id"`
4 ID int64 `json:"id"`
5 Species string `json:"species"`
5 Species string `json:"species"`
6 Place string `json:"place"`
6 Place string `json:"place"`
7}
7}

The program

example.godiffers
PostgreSQL
SQLite
1package main
1package main
2
2
3import (
3import (
4 "context"
4 "context"
5 "database/sql"
5 "database/sql"
6 "errors"
6 "errors"
7 "fmt"
7 "fmt"
8 "io"
8 "io"
9 "io/fs"
9 "io/fs"
10 "testing/fstest"
10 "testing/fstest"
11
11
12 "seagull.sh/seagull/examples/postgres/028_migration_lifecycle/internal/store"
12 "seagull.sh/seagull/examples/sqlite/028_migration_lifecycle/internal/store"
13 "seagull.sh/seagull/sdk/migrate"
13 "seagull.sh/seagull/sdk/migrate"
14 "seagull.sh/seagull/sdk/migrate/migrate_backend_postgres"
14 "seagull.sh/seagull/sdk/migrate/migrate_backend_sqlite"
15)
15)
16
16
17const (
17const (
18 // migrationsDirectory is where the migration files sit inside the file system.
18 // migrationsDirectory is where the migration files sit inside the file system.
19 migrationsDirectory = "db/migrations"
19 migrationsDirectory = "db/migrations"
20
20
21 // editedFile is the applied migration the example edits to break its checksum.
21 // editedFile is the applied migration the example edits to break its checksum.
22 editedFile = migrationsDirectory + "/0001_sightings.up.sql"
22 editedFile = migrationsDirectory + "/0001_sightings.up.sql"
23)
23)
24
24
25// run takes a database through a migration's life: status, up to a version, up, down, a
25// run takes a database through a migration's life: status, up to a version, up, down, a
26// down that cannot happen, validation before and after an applied file is edited, and the
26// down that cannot happen, and validation before and after an applied file is edited.
27// locks that stop two instances migrating at once.
28//
27//
29// Takes database (*sql.DB) which is an empty database.
28// Takes database (*sql.DB) which is an empty database.
30// Takes migrations (fs.FS) which holds db/migrations, the embed.FS in main.go.
29// Takes migrations (fs.FS) which holds db/migrations, the embed.FS in main.go.
31// Takes output (io.Writer) which receives each step's result.
30// Takes output (io.Writer) which receives each step's result.
32//
31//
33// Returns error when a step fails other than as the example expects.
32// Returns error when a step fails other than as the example expects.
34func run(ctx context.Context, database *sql.DB, migrations fs.FS, output io.Writer) error {
33func run(ctx context.Context, database *sql.DB, migrations fs.FS, output io.Writer) error {
35 migrator, err := migrate.NewMigrator(database, migrate_backend_postgres.New(), migrations, migrationsDirectory)
34 migrator, err := migrate.NewMigrator(database, migrate_backend_sqlite.New(), migrations, migrationsDirectory)
36 if err != nil {
35 if err != nil {
37 return err
36 return err
38 }
37 }
39
38
40 // Status reads the files and the history without changing anything, so it works on a
39 // Status reads the files and the history without changing anything, so it works on a
41 // database that has never been migrated.
40 // database that has never been migrated.
42 fmt.Fprintln(output, "before migrating:")
41 fmt.Fprintln(output, "before migrating:")
43 if err := printStatus(ctx, migrator, output); err != nil {
42 if err := printStatus(ctx, migrator, output); err != nil {
44 return err
43 return err
45 }
44 }
46
45
47 // UpTo applies pending migrations up to and including a version.
46 // UpTo applies pending migrations up to and including a version.
48 applied, err := migrator.UpTo(ctx, 1)
47 applied, err := migrator.UpTo(ctx, 1)
49 if err != nil {
48 if err != nil {
50 return fmt.Errorf("migrating to version 1: %w", err)
49 return fmt.Errorf("migrating to version 1: %w", err)
51 }
50 }
52 fmt.Fprintf(output, "UpTo(1) applied %d\n", applied)
51 fmt.Fprintf(output, "UpTo(1) applied %d\n", applied)
53 if err := recordSightings(ctx, store.New(database)); err != nil {
52 if err := recordSightings(ctx, store.New(database)); err != nil {
54 return err
53 return err
55 }
54 }
56
55
57 // Up applies the rest: 0002 tidies the names recorded above, 0003 adds a column.
56 // Up applies the rest: 0002 tidies the names recorded above, 0003 adds a column.
58 applied, err = migrator.Up(ctx)
57 applied, err = migrator.Up(ctx)
59 if err != nil {
58 if err != nil {
60 return fmt.Errorf("migrating up: %w", err)
59 return fmt.Errorf("migrating up: %w", err)
61 }
60 }
62 fmt.Fprintf(output, "Up applied %d\n", applied)
61 fmt.Fprintf(output, "Up applied %d\n", applied)
63 if err := printSightings(ctx, store.New(database), output); err != nil {
62 if err := printSightings(ctx, store.New(database), output); err != nil {
64 return err
63 return err
65 }
64 }
66
65
67 // Down rolls back the newest applied migrations by running their down files.
66 // Down rolls back the newest applied migrations by running their down files.
68 rolledBack, err := migrator.Down(ctx, 1)
67 rolledBack, err := migrator.Down(ctx, 1)
69 if err != nil {
68 if err != nil {
70 return fmt.Errorf("rolling back: %w", err)
69 return fmt.Errorf("rolling back: %w", err)
71 }
70 }
72 fmt.Fprintf(output, "Down(1) rolled back %d\n", rolledBack)
71 fmt.Fprintf(output, "Down(1) rolled back %d\n", rolledBack)
73
72
74 // The next one down, 0002, has no down file, so Down stops with an error and leaves it
73 // The next one down, 0002, has no down file, so Down stops with an error and leaves it
75 // applied. Roll forward with a new migration instead.
74 // applied. Roll forward with a new migration instead.
76 _, err = migrator.Down(ctx, 1)
75 _, err = migrator.Down(ctx, 1)
77 if err == nil {
76 if err == nil {
78 return errors.New("rolling back 0002: want a NoDownMigrationError, got no error")
77 return errors.New("rolling back 0002: want a NoDownMigrationError, got no error")
79 }
78 }
80 noDown, isNoDown := errors.AsType[*migrate.NoDownMigrationError](err)
79 noDown, isNoDown := errors.AsType[*migrate.NoDownMigrationError](err)
81 if !isNoDown {
80 if !isNoDown {
82 return fmt.Errorf("rolling back 0002: want a NoDownMigrationError, got %w", err)
81 return fmt.Errorf("rolling back 0002: want a NoDownMigrationError, got %w", err)
83 }
82 }
84 fmt.Fprintf(output, "Down(1) refused: migration %d has no down file\n", noDown.Version)
83 fmt.Fprintf(output, "Down(1) refused: migration %d has no down file\n", noDown.Version)
85
84
86 fmt.Fprintln(output, "after rolling back:")
85 fmt.Fprintln(output, "after rolling back:")
87 if err := printStatus(ctx, migrator, output); err != nil {
86 if err := printStatus(ctx, migrator, output); err != nil {
88 return err
87 return err
89 }
88 }
90 if err := validate(ctx, database, migrations, output); err != nil {
89 return validate(ctx, database, migrations, output)
91 return err
92 }
93 if err := advisoryLock(ctx, database, migrations, output); err != nil {
94 return err
95 }
96 return tableLock(ctx, database, migrations, output)
97}
90}
98
91
99// validate checks the recorded checksums against the files, then edits an applied
92// validate checks the recorded checksums against the files, then edits an applied
100// migration in a copy of the files and shows that Validate and Up both refuse it.
93// migration in a copy of the files and shows that Validate and Up both refuse it.
101//
94//
102// Takes database (*sql.DB) which is the migrated database.
95// Takes database (*sql.DB) which is the migrated database.
103// Takes migrations (fs.FS) which holds the migration files.
96// Takes migrations (fs.FS) which holds the migration files.
104// Takes output (io.Writer) which receives the results.
97// Takes output (io.Writer) which receives the results.
105//
98//
106// Returns error when validation does not behave as the example expects.
99// Returns error when validation does not behave as the example expects.
107func validate(ctx context.Context, database *sql.DB, migrations fs.FS, output io.Writer) error {
100func validate(ctx context.Context, database *sql.DB, migrations fs.FS, output io.Writer) error {
108 migrator, err := migrate.NewMigrator(database, migrate_backend_postgres.New(), migrations, migrationsDirectory)
101 migrator, err := migrate.NewMigrator(database, migrate_backend_sqlite.New(), migrations, migrationsDirectory)
109 if err != nil {
102 if err != nil {
110 return err
103 return err
111 }
104 }
112 if err := migrator.Validate(ctx); err != nil {
105 if err := migrator.Validate(ctx); err != nil {
113 return fmt.Errorf("validating: %w", err)
106 return fmt.Errorf("validating: %w", err)
114 }
107 }
115 fmt.Fprintln(output, "Validate: every applied migration matches its file")
108 fmt.Fprintln(output, "Validate: every applied migration matches its file")
116
109
117 // Each applied migration's SHA-256 is recorded in the history. Changing an applied file,
110 // Each applied migration's SHA-256 is recorded in the history. Changing an applied file,
118 // even only a comment, makes Validate and Up fail: the database no longer matches what
111 // even only a comment, makes Validate and Up fail: the database no longer matches what
119 // the files say it should be.
112 // the files say it should be.
120 edited, err := editedCopy(migrations, editedFile, "-- tidied up after it was applied\n")
113 edited, err := editedCopy(migrations, editedFile, "-- tidied up after it was applied\n")
121 if err != nil {
114 if err != nil {
122 return err
115 return err
123 }
116 }
124 drifted, err := migrate.NewMigrator(database, migrate_backend_postgres.New(), edited, migrationsDirectory)
117 drifted, err := migrate.NewMigrator(database, migrate_backend_sqlite.New(), edited, migrationsDirectory)
125 if err != nil {
118 if err != nil {
126 return err
119 return err
127 }
120 }
128 err = drifted.Validate(ctx)
121 err = drifted.Validate(ctx)
129 if err == nil {
122 if err == nil {
130 return errors.New("validating the edited files: want a ChecksumMismatchError, got no error")
123 return errors.New("validating the edited files: want a ChecksumMismatchError, got no error")
131 }
124 }
132 mismatch, isMismatch := errors.AsType[*migrate.ChecksumMismatchError](err)
125 mismatch, isMismatch := errors.AsType[*migrate.ChecksumMismatchError](err)
133 if !isMismatch {
126 if !isMismatch {
134 return fmt.Errorf("validating the edited files: want a ChecksumMismatchError, got %w", err)
127 return fmt.Errorf("validating the edited files: want a ChecksumMismatchError, got %w", err)
135 }
128 }
136 fmt.Fprintf(output, "Validate after editing %04d_%s: checksum mismatch\n", mismatch.Version, mismatch.Name)
129 fmt.Fprintf(output, "Validate after editing %04d_%s: checksum mismatch\n", mismatch.Version, mismatch.Name)
137 if err := printStatus(ctx, drifted, output); err != nil {
130 if err := printStatus(ctx, drifted, output); err != nil {
138 return err
131 return err
139 }
132 }
140 _, err = drifted.Up(ctx)
133 _, err = drifted.Up(ctx)
141 if err == nil {
134 if err == nil {
142 return errors.New("migrating the edited files: want a ChecksumMismatchError, got no error")
135 return errors.New("migrating the edited files: want a ChecksumMismatchError, got no error")
143 }
136 }
144 if _, isMismatch := errors.AsType[*migrate.ChecksumMismatchError](err); !isMismatch {
137 if _, isMismatch := errors.AsType[*migrate.ChecksumMismatchError](err); !isMismatch {
145 return fmt.Errorf("migrating the edited files: want a ChecksumMismatchError, got %w", err)
138 return fmt.Errorf("migrating the edited files: want a ChecksumMismatchError, got %w", err)
146 }
139 }
147 fmt.Fprintln(output, "Up after editing: refused for the same reason")
140 fmt.Fprintln(output, "Up after editing: refused for the same reason")
148 return nil
141 return nil
149}
142}
150
143
151// advisoryLock shows the lock migrate_backend_postgres.New() takes: a session-level
152// advisory lock keyed on the history table's name, held for the whole run. A migrator
153// built with WithNonBlockingLock gives up at once with ErrLockNotAcquired when another
154// session holds it, instead of waiting its turn.
155//
156// Takes database (*sql.DB) which is the migrated database.
157// Takes migrations (fs.FS) which holds the migration files.
158// Takes output (io.Writer) which receives the results.
159//
160// Returns error when the lock does not behave as the example expects.
161func advisoryLock(ctx context.Context, database *sql.DB, migrations fs.FS, output io.Writer) error {
162 backend := migrate_backend_postgres.New()
163 key := backend.Names().LockKey
164 migrator, err := migrate.NewMigrator(database, backend, migrations, migrationsDirectory, migrate.WithNonBlockingLock())
165 if err != nil {
166 return err
167 }
168
169 // A session of its own plays another instance of the program, part-way through
170 // migrating. The migrator hashes the key with hashtext, so this takes the same lock.
171 other, err := database.Conn(ctx)
172 if err != nil {
173 return err
174 }
175 defer func() { _ = other.Close() }()
176 if _, err := other.ExecContext(ctx, "SELECT pg_advisory_lock(hashtext($1))", key); err != nil {
177 return fmt.Errorf("taking the advisory lock: %w", err)
178 }
179 fmt.Fprintf(output, "another session holds the advisory lock on %s\n", key)
180
181 _, err = migrator.Up(ctx)
182 if err == nil {
183 return errors.New("migrating while the lock is held: want ErrLockNotAcquired, got no error")
184 }
185 if !errors.Is(err, migrate.ErrLockNotAcquired) {
186 return fmt.Errorf("migrating while the lock is held: want ErrLockNotAcquired, got %w", err)
187 }
188 fmt.Fprintf(output, " Up with WithNonBlockingLock: refused, %v\n", migrate.ErrLockNotAcquired)
189
190 if _, err := other.ExecContext(ctx, "SELECT pg_advisory_unlock(hashtext($1))", key); err != nil {
191 return fmt.Errorf("releasing the advisory lock: %w", err)
192 }
193 applied, err := migrator.Up(ctx)
194 if err != nil {
195 return fmt.Errorf("migrating once the lock is free: %w", err)
196 }
197 fmt.Fprintf(output, " Up once it is released: applied %d\n", applied)
198 return nil
199}
200
201// tableLock shows the lock migrate_backend_postgres.NewPgBouncer() takes instead.
202// PgBouncer in transaction mode hands each transaction whichever server session is free,
203// so a session-level advisory lock would not stay with the migrator; this backend locks
204// the single row of a lock table with SELECT ... FOR UPDATE, and creates the table the
205// first time it takes the lock.
206//
207// Takes database (*sql.DB) which is the migrated database.
208// Takes migrations (fs.FS) which holds the migration files.
209// Takes output (io.Writer) which receives the results.
210//
211// Returns error when the lock does not behave as the example expects.
212func tableLock(ctx context.Context, database *sql.DB, migrations fs.FS, output io.Writer) error {
213 backend := migrate_backend_postgres.NewPgBouncer()
214 lockTable := backend.Names().MigrationLock
215 migrator, err := migrate.NewMigrator(database, backend, migrations, migrationsDirectory, migrate.WithNonBlockingLock())
216 if err != nil {
217 return err
218 }
219 fmt.Fprintf(output, "PostgresPgBouncer locks the row in %s instead\n", lockTable)
220 rolledBack, err := migrator.Down(ctx, 1)
221 if err != nil {
222 return fmt.Errorf("rolling back under the table lock: %w", err)
223 }
224 fmt.Fprintf(output, " Down(1) rolled back %d\n", rolledBack)
225
226 // Another instance's transaction holds the row until it ends.
227 other, err := database.BeginTx(ctx, nil)
228 if err != nil {
229 return err
230 }
231 defer func() { _ = other.Rollback() }()
232 // A table name cannot be a bound parameter, so the lock table's name, which comes from
233 // the backend and not from input, is written into the statement.
234 if _, err := other.ExecContext(ctx, "SELECT lock_id FROM "+lockTable+" FOR UPDATE"); err != nil { //nolint:gosec // G202: lockTable is the backend's own table name, not input
235 return fmt.Errorf("locking %s: %w", lockTable, err)
236 }
237 fmt.Fprintln(output, "another transaction holds the row")
238
239 _, err = migrator.Up(ctx)
240 if err == nil {
241 return errors.New("migrating while the row is locked: want ErrLockNotAcquired, got no error")
242 }
243 if !errors.Is(err, migrate.ErrLockNotAcquired) {
244 return fmt.Errorf("migrating while the row is locked: want ErrLockNotAcquired, got %w", err)
245 }
246 fmt.Fprintf(output, " Up with WithNonBlockingLock: refused, %v\n", migrate.ErrLockNotAcquired)
247
248 if err := other.Commit(); err != nil {
249 return err
250 }
251 applied, err := migrator.Up(ctx)
252 if err != nil {
253 return fmt.Errorf("migrating once the row is free: %w", err)
254 }
255 fmt.Fprintf(output, " Up once it commits: applied %d\n", applied)
256 return nil
257}
258
259// printStatus writes one line per migration: whether it is applied, whether an applied
144// printStatus writes one line per migration: whether it is applied, whether an applied
260// file still matches its recorded checksum, and whether it has a down file.
145// file still matches its recorded checksum, and whether it has a down file.
261//
146//
262// Takes migrator (*migrate.Migrator) which reads the status.
147// Takes migrator (*migrate.Migrator) which reads the status.
263// Takes output (io.Writer) which receives the table.
148// Takes output (io.Writer) which receives the table.
264//
149//
265// Returns error when the status cannot be read.
150// Returns error when the status cannot be read.
266func printStatus(ctx context.Context, migrator *migrate.Migrator, output io.Writer) error {
151func printStatus(ctx context.Context, migrator *migrate.Migrator, output io.Writer) error {
267 statuses, err := migrator.Status(ctx)
152 statuses, err := migrator.Status(ctx)
268 if err != nil {
153 if err != nil {
269 return fmt.Errorf("reading status: %w", err)
154 return fmt.Errorf("reading status: %w", err)
270 }
155 }
271 for _, status := range statuses {
156 for _, status := range statuses {
272 state := "pending"
157 state := "pending"
273 if status.Applied {
158 if status.Applied {
274 state = "applied"
159 state = "applied"
275 }
160 }
276 down := "no down file"
161 down := "no down file"
277 if status.HasDownMigration {
162 if status.HasDownMigration {
278 down = "down file"
163 down = "down file"
279 }
164 }
280 edited := ""
165 edited := ""
281 if status.Applied && !status.ChecksumMatch {
166 if status.Applied && !status.ChecksumMatch {
282 edited = ", edited since it was applied"
167 edited = ", edited since it was applied"
283 }
168 }
284 fmt.Fprintf(output, " %04d %-15s %-8s %s%s\n", status.Version, status.Name, state, down, edited)
169 fmt.Fprintf(output, " %04d %-15s %-8s %s%s\n", status.Version, status.Name, state, down, edited)
285 }
170 }
286 return nil
171 return nil
287}
172}
288
173
289// recordSightings adds two sightings whose species names need tidying.
174// recordSightings adds two sightings whose species names need tidying.
290//
175//
291// Takes queries (*store.Queries) which add the rows.
176// Takes queries (*store.Queries) which add the rows.
292//
177//
293// Returns error when a row cannot be added.
178// Returns error when a row cannot be added.
294func recordSightings(ctx context.Context, queries *store.Queries) error {
179func recordSightings(ctx context.Context, queries *store.Queries) error {
295 sightings := []store.RecordSightingParams{
180 sightings := []store.RecordSightingParams{
296 {Species: " Herring Gull", Place: "the pier"},
181 {Species: " Herring Gull", Place: "the pier"},
297 {Species: "KITTIWAKE ", Place: "the harbour wall"},
182 {Species: "KITTIWAKE ", Place: "the harbour wall"},
298 }
183 }
299 for _, sighting := range sightings {
184 for _, sighting := range sightings {
300 if err := queries.RecordSighting(ctx, sighting); err != nil {
185 if err := queries.RecordSighting(ctx, sighting); err != nil {
301 return fmt.Errorf("recording a sighting: %w", err)
186 return fmt.Errorf("recording a sighting: %w", err)
302 }
187 }
303 }
188 }
304 return nil
189 return nil
305}
190}
306
191
307// printSightings writes every sighting, quoting the species to show its spacing.
192// printSightings writes every sighting, quoting the species to show its spacing.
308//
193//
309// Takes queries (*store.Queries) which read the rows.
194// Takes queries (*store.Queries) which read the rows.
310// Takes output (io.Writer) which receives them.
195// Takes output (io.Writer) which receives them.
311//
196//
312// Returns error when the rows cannot be read.
197// Returns error when the rows cannot be read.
313func printSightings(ctx context.Context, queries *store.Queries, output io.Writer) error {
198func printSightings(ctx context.Context, queries *store.Queries, output io.Writer) error {
314 sightings, err := queries.ListSightings(ctx)
199 sightings, err := queries.ListSightings(ctx)
315 if err != nil {
200 if err != nil {
316 return fmt.Errorf("listing sightings: %w", err)
201 return fmt.Errorf("listing sightings: %w", err)
317 }
202 }
318 for _, sighting := range sightings {
203 for _, sighting := range sightings {
319 fmt.Fprintf(output, " sighting %d: %q at %s\n", sighting.ID, sighting.Species, sighting.Place)
204 fmt.Fprintf(output, " sighting %d: %q at %s\n", sighting.ID, sighting.Species, sighting.Place)
320 }
205 }
321 return nil
206 return nil
322}
207}
323
208
324// editedCopy copies every file in files into memory and puts a line at the top of one of
209// editedCopy copies every file in files into memory and puts a line at the top of one of
325// them, as if someone had edited it.
210// them, as if someone had edited it.
326//
211//
327// Takes files (fs.FS) which holds the originals.
212// Takes files (fs.FS) which holds the originals.
328// Takes name (string) which is the path of the file to edit.
213// Takes name (string) which is the path of the file to edit.
329// Takes line (string) which is put at the top of it.
214// Takes line (string) which is put at the top of it.
330//
215//
331// Returns fstest.MapFS which holds the copy.
216// Returns fstest.MapFS which holds the copy.
332// Returns error when a file cannot be read or name is missing.
217// Returns error when a file cannot be read or name is missing.
333func editedCopy(files fs.FS, name, line string) (fstest.MapFS, error) {
218func editedCopy(files fs.FS, name, line string) (fstest.MapFS, error) {
334 copied := fstest.MapFS{}
219 copied := fstest.MapFS{}
335 err := fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error {
220 err := fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error {
336 if err != nil || entry.IsDir() {
221 if err != nil || entry.IsDir() {
337 return err
222 return err
338 }
223 }
339 data, err := fs.ReadFile(files, path)
224 data, err := fs.ReadFile(files, path)
340 if err != nil {
225 if err != nil {
341 return err
226 return err
342 }
227 }
343 copied[path] = &fstest.MapFile{Data: data}
228 copied[path] = &fstest.MapFile{Data: data}
344 return nil
229 return nil
345 })
230 })
346 if err != nil {
231 if err != nil {
347 return nil, err
232 return nil, err
348 }
233 }
349 original, ok := copied[name]
234 original, ok := copied[name]
350 if !ok {
235 if !ok {
351 return nil, fmt.Errorf("no file %s to edit", name)
236 return nil, fmt.Errorf("no file %s to edit", name)
352 }
237 }
353 original.Data = append([]byte(line), original.Data...)
238 original.Data = append([]byte(line), original.Data...)
354 return copied, nil
239 return copied, nil
355}
240}