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
1
version: 11
version: 12
2
3
databases:3
databases:4
- name: birdwatch4
- name: birdwatch5
dialect: sqlite5
dialect: postgres6
migrations: db/migrations6
migrations: db/migrations7
queries: db/queries7
queries: db/queries8
go:8
go:9
package: store9
package: store10
out: internal/store10
out: internal/store11
driver: database/sql12
migrate:13
dsn: ${env:DATABASE_URL}Migrations
db/migrations/0001_sightings.up.sqldiffers
SQLite
PostgreSQL
1
CREATE TABLE sightings (1
CREATE 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 NULL4
place TEXT NOT NULL5
);5
);db/migrations/0001_sightings.down.sqlidentical
SQLite
PostgreSQL
1
DROP TABLE sightings;1
DROP TABLE sightings;db/migrations/0002_tidy_species.up.sqlidentical
SQLite
PostgreSQL
1
UPDATE sightings SET species = lower(trim(species));1
UPDATE sightings SET species = lower(trim(species));db/migrations/0003_sighting_count.up.sqlidentical
SQLite
PostgreSQL
1
ALTER TABLE sightings ADD COLUMN birds INTEGER NOT NULL DEFAULT 1;1
ALTER TABLE sightings ADD COLUMN birds INTEGER NOT NULL DEFAULT 1;db/migrations/0003_sighting_count.down.sqlidentical
SQLite
PostgreSQL
1
ALTER TABLE sightings DROP COLUMN birds;1
ALTER 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)2
INSERT INTO sightings (species, place) VALUES (?, ?);2
INSERT 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)2
SELECT id, species, place FROM sightings ORDER BY id;2
SELECT id, species, place FROM sightings ORDER BY id;What sgl generates
RecordSightingidentical
SQLite
PostgreSQL
1
func (queries *Queries) RecordSighting(ctx context.Context, params RecordSightingParams) error1
func (queries *Queries) RecordSighting(ctx context.Context, params RecordSightingParams) error2
2
3
type RecordSightingParams struct {3
type RecordSightingParams struct {4
Species string4
Species string5
Place string5
Place string6
}6
}ListSightingsidentical
SQLite
PostgreSQL
1
func (queries *Queries) ListSightings(ctx context.Context) ([]ListSightingsRow, error)1
func (queries *Queries) ListSightings(ctx context.Context) ([]ListSightingsRow, error)2
2
3
type ListSightingsRow struct {3
type 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
1
package main1
package main2
2
3
import (3
import (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
17
const (17
const (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, a25
// run takes a database through a migration's life: status, up to a version, up, down, a26
// 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 the27
// 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.33
func run(ctx context.Context, database *sql.DB, migrations fs.FS, output io.Writer) error {34
func 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 err37
return err37
}38
}38
39
39
// Status reads the files and the history without changing anything, so it works on a40
// Status reads the files and the history without changing anything, so it works on a40
// 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 err44
return err44
}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 err54
return err54
}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 err64
return err64
}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 it74
// The next one down, 0002, has no down file, so Down stops with an error and leaves it74
// 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 err88
return err88
}89
}89
return validate(ctx, database, migrations, output)90
if err := validate(ctx, database, migrations, output); err != nil {91
return err92
}93
if err := advisoryLock(ctx, database, migrations, output); err != nil {94
return err95
}96
return tableLock(ctx, database, migrations, output)90
}97
}91
98
92
// validate checks the recorded checksums against the files, then edits an applied99
// validate checks the recorded checksums against the files, then edits an applied93
// 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.100
func validate(ctx context.Context, database *sql.DB, migrations fs.FS, output io.Writer) error {107
func 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 err110
return err104
}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 what118
// even only a comment, makes Validate and Up fail: the database no longer matches what112
// 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 err122
return err116
}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 err126
return err120
}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 err138
return err132
}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 nil148
return nil142
}149
}143
150
151
// advisoryLock shows the lock migrate_backend_postgres.New() takes: a session-level152
// advisory lock keyed on the history table's name, held for the whole run. A migrator153
// built with WithNonBlockingLock gives up at once with ErrLockNotAcquired when another154
// 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.161
func advisoryLock(ctx context.Context, database *sql.DB, migrations fs.FS, output io.Writer) error {162
backend := migrate_backend_postgres.New()163
key := backend.Names().LockKey164
migrator, err := migrate.NewMigrator(database, backend, migrations, migrationsDirectory, migrate.WithNonBlockingLock())165
if err != nil {166
return err167
}168
169
// A session of its own plays another instance of the program, part-way through170
// 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 err174
}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 nil199
}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 locks204
// the single row of a lock table with SELECT ... FOR UPDATE, and creates the table the205
// 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.212
func tableLock(ctx context.Context, database *sql.DB, migrations fs.FS, output io.Writer) error {213
backend := migrate_backend_postgres.NewPgBouncer()214
lockTable := backend.Names().MigrationLock215
migrator, err := migrate.NewMigrator(database, backend, migrations, migrationsDirectory, migrate.WithNonBlockingLock())216
if err != nil {217
return err218
}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 err230
}231
defer func() { _ = other.Rollback() }()232
// A table name cannot be a bound parameter, so the lock table's name, which comes from233
// 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 input235
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 err250
}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 nil257
}258
144
// printStatus writes one line per migration: whether it is applied, whether an applied259
// printStatus writes one line per migration: whether it is applied, whether an applied145
// 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.151
func printStatus(ctx context.Context, migrator *migrate.Migrator, output io.Writer) error {266
func 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 nil286
return nil172
}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.179
func recordSightings(ctx context.Context, queries *store.Queries) error {294
func 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 nil304
return nil190
}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.198
func printSightings(ctx context.Context, queries *store.Queries, output io.Writer) error {313
func 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 nil321
return nil207
}322
}208
323
209
// editedCopy copies every file in files into memory and puts a line at the top of one of324
// editedCopy copies every file in files into memory and puts a line at the top of one of210
// 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.218
func editedCopy(files fs.FS, name, line string) (fstest.MapFS, error) {333
func 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 err337
return err223
}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 err341
return err227
}342
}228
copied[path] = &fstest.MapFile{Data: data}343
copied[path] = &fstest.MapFile{Data: data}229
return nil344
return nil230
})345
})231
if err != nil {346
if err != nil {232
return nil, err347
return nil, err233
}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, nil354
return copied, nil240
}355
}