Compare · 028

The migration lifecycle: SQLite and PostgreSQL

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

Compare SQLite withPostgreSQL

Configuration

sgl.yamldiffers
SQLite
PostgreSQL
1version: 1
1version: 1
2
2
3databases:
3databases:
4 - name: birdwatch
4 - name: birdwatch
5 dialect: sqlite
5 dialect: postgres
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
SQLite
PostgreSQL
1CREATE TABLE sightings (
1CREATE TABLE sightings (
2 id INTEGER PRIMARY KEY,
2 id BIGINT GENERATED BY DEFAULT AS IDENTITY 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
SQLite
PostgreSQL
1DROP TABLE sightings;
1DROP TABLE sightings;
db/migrations/0002_tidy_species.up.sqlidentical
SQLite
PostgreSQL
1UPDATE sightings SET species = lower(trim(species));
1UPDATE sightings SET species = lower(trim(species));
db/migrations/0003_sighting_count.up.sqlidentical
SQLite
PostgreSQL
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
SQLite
PostgreSQL
1ALTER TABLE sightings DROP COLUMN birds;
1ALTER TABLE sightings DROP COLUMN birds;

Queries

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