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
1
version: 11
version: 12
2
3
databases:3
databases:4
- name: birdwatch4
- name: birdwatch5
dialect: postgres5
dialect: sqlite6
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
PostgreSQL
SQLite
1
CREATE TABLE sightings (1
CREATE 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 NULL4
place TEXT NOT NULL5
);5
);db/migrations/0001_sightings.down.sqlidentical
PostgreSQL
SQLite
1
DROP TABLE sightings;1
DROP TABLE sightings;db/migrations/0002_tidy_species.up.sqlidentical
PostgreSQL
SQLite
1
UPDATE sightings SET species = lower(trim(species));1
UPDATE sightings SET species = lower(trim(species));db/migrations/0003_sighting_count.up.sqlidentical
PostgreSQL
SQLite
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
PostgreSQL
SQLite
1
ALTER TABLE sightings DROP COLUMN birds;1
ALTER 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)2
INSERT INTO sightings (species, place) VALUES ($1, $2);2
INSERT 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)2
SELECT id, species, place FROM sightings ORDER BY id;2
SELECT id, species, place FROM sightings ORDER BY id;What sgl generates
RecordSightingidentical
PostgreSQL
SQLite
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
PostgreSQL
SQLite
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
PostgreSQL
SQLite
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/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
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, validation before and after an applied file is edited, and the26
// 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.34
func run(ctx context.Context, database *sql.DB, migrations fs.FS, output io.Writer) error {33
func 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 err36
return err38
}37
}39
38
40
// Status reads the files and the history without changing anything, so it works on a39
// Status reads the files and the history without changing anything, so it works on a41
// 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 err43
return err45
}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 err53
return err55
}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 err63
return err65
}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 it73
// The next one down, 0002, has no down file, so Down stops with an error and leaves it75
// 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 err87
return err89
}88
}90
if err := validate(ctx, database, migrations, output); err != nil {89
return validate(ctx, database, migrations, output)91
return err92
}93
if err := advisoryLock(ctx, database, migrations, output); err != nil {94
return err95
}96
return tableLock(ctx, database, migrations, output)97
}90
}98
91
99
// validate checks the recorded checksums against the files, then edits an applied92
// validate checks the recorded checksums against the files, then edits an applied100
// 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.107
func validate(ctx context.Context, database *sql.DB, migrations fs.FS, output io.Writer) error {100
func 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 err103
return err111
}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 what111
// even only a comment, makes Validate and Up fail: the database no longer matches what119
// 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 err115
return err123
}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 err119
return err127
}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 err131
return err139
}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 nil141
return nil149
}142
}150
143
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
259
// printStatus writes one line per migration: whether it is applied, whether an applied144
// printStatus writes one line per migration: whether it is applied, whether an applied260
// 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.266
func printStatus(ctx context.Context, migrator *migrate.Migrator, output io.Writer) error {151
func 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 nil171
return nil287
}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.294
func recordSightings(ctx context.Context, queries *store.Queries) error {179
func 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 nil189
return nil305
}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.313
func printSightings(ctx context.Context, queries *store.Queries, output io.Writer) error {198
func 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 nil206
return nil322
}207
}323
208
324
// editedCopy copies every file in files into memory and puts a line at the top of one of209
// editedCopy copies every file in files into memory and puts a line at the top of one of325
// 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.333
func editedCopy(files fs.FS, name, line string) (fstest.MapFS, error) {218
func 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 err222
return err338
}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 err226
return err342
}227
}343
copied[path] = &fstest.MapFile{Data: data}228
copied[path] = &fstest.MapFile{Data: data}344
return nil229
return nil345
})230
})346
if err != nil {231
if err != nil {347
return nil, err232
return nil, err348
}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, nil239
return copied, nil355
}240
}