Skip to content

Commit cfa7157

Browse files
committed
Documentation.
1 parent 322837a commit cfa7157

12 files changed

Lines changed: 260 additions & 18 deletions

README.md

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,161 @@
6565
DMorph
6666
======
6767

68-
*DMorph* is a database migration library.
68+
*DMorph* is a database migration library. Programs that use a database and have to preserve the data
69+
between versions can utilize *DMorph* to apply the necessary migration steps. If a program can
70+
afford to lose all data between version upgrades, this library is not necessary.
6971

7072
Includes direct support for the following relational database management systems:
7173

7274
* [IBM Db2](https://www.ibm.com/db2/)
73-
* [Oracle Database](https://www.oracle.com/database/)
7475
* [Microsoft SQL Server](https://www.microsoft.com/sql-server)
7576
* [MySQL](https://www.mysql.com/) & [MariaDB](https://mariadb.org/)
77+
* [Oracle Database](https://www.oracle.com/database/)
7678
* [PostgreSQL](https://www.postgresql.org)
7779
* [SQLite](https://www.sqlite.org)
7880

7981
Additional database management systems can be included providing the necessary queries.
82+
83+
84+
Installation
85+
------------
86+
87+
To install *DMorph*, you can use the following command:
88+
89+
```bash
90+
$ go get github.com/AlphaOne1/dmorph
91+
```
92+
93+
94+
Getting Started
95+
---------------
96+
97+
*DMorph* applies migrations to a database. A migration is a series of steps, defined either in an
98+
SQL file or programmatically.
99+
100+
101+
### Migration from File
102+
103+
A typical migration file consists of a sequence of SQL statements. Each statement needs to be
104+
finalized with a semicolon `;`. If a semicolon is found alone at the beginning of a line, all
105+
previous statements, that were not yet executed, are executed in one call to Exec in a
106+
transaction. A migration is executed completely inside of a transaction. If any of the steps of
107+
a migration fails, a rollback is issued and the process stops. Take care, that not all database
108+
management systems offer a rollback of DDL (CREATE, DROP, ...) statements.
109+
110+
An example for a migration inside a file `01_base_tables` is as follows:
111+
112+
```sqlite
113+
CREATE TABLE tab0 (
114+
id string PRIMARY KEY
115+
)
116+
;
117+
118+
CREATE TABLE tab1 (
119+
id string PRIMARY KEY
120+
)
121+
;
122+
```
123+
124+
It can be applied to an already open database with the following snipped:
125+
126+
```go
127+
package testprog
128+
129+
import (
130+
"database/sql"
131+
"github.com/AlphaOne1/dmorph"
132+
)
133+
134+
func migrate(db *sql.DB) error {
135+
return dmorph.Run(db,
136+
dmorph.WithDialect(dmorph.DialectSQLite()),
137+
dmorph.WithMigrationFromFile("01_base_tables.sql"))
138+
}
139+
140+
...
141+
```
142+
143+
In this example just one file is used, the `WithMigrationFromFile` can be given multiple times.
144+
Migrations are executed in alphabetical order of their key. For files the key is the file's name.
145+
146+
147+
### Migrations from Folder
148+
149+
As normally multiple migrations are to be executed, they can be assembled in a folder and then
150+
executed together. As stated before, the order of multiple files is determined by their
151+
alphabetically ordered name.
152+
153+
Taken the example from [above](#migration-from-file), split into two files, like prepared in
154+
[testData/](testData/).
155+
156+
```go
157+
package testprog
158+
159+
import (
160+
"database/sql"
161+
_ "embed"
162+
"io/fs"
163+
"github.com/AlphaOne1/dmorph"
164+
)
165+
166+
//go:embed testData
167+
var migrationFS embed.FS
168+
169+
func migrate(db *sql.DB) error {
170+
sub, subErr := fs.Sub(migrationFS, "testData")
171+
172+
if subErr != nil {
173+
return subErr
174+
}
175+
176+
return dmorph.Run(db,
177+
dmorph.WithDialect(dmorph.DialectSQLite()),
178+
dmorph.WithMigrationsFromFS(sub.(fs.ReadDirFS)))
179+
}
180+
181+
...
182+
```
183+
184+
### Programmatic Migration
185+
186+
Sometimes SQL alone is not sufficient to achieve the migration desired. Maybe the data needs to be
187+
programmatically changed, checked or otherwise processed. For *DMorph* a migration is presented as
188+
an interface:
189+
190+
```go
191+
type Migration interface {
192+
Key() string // identifier, used for ordering
193+
Migrate(tx *sql.Tx) error // migration functionality
194+
}
195+
```
196+
197+
The `WithMigrationFromF...` family of options constructs these migrations for convenience. An example
198+
migration fulfilling this interface could look like this:
199+
200+
```go
201+
type CustomMigration struct {}
202+
203+
func (m *CustomMigration) Key() string {
204+
return "0001_custom"
205+
}
206+
207+
func (m *CustomMigration) Migrate(tx *sql.Tx) error {
208+
_, err := tx.Exec(`CREATE TABLE tab0(id INTEGER PRIMARY KEY)`)
209+
return err
210+
}
211+
```
212+
213+
Inside of the `Migrate` function the transaction state should not be modified.
214+
`Commit` and `Rollback` are handled by *DMorph* as needed. As seen in the example, a potentiel error
215+
is returned plain to the caller.
216+
217+
This newly created migration can then be passed to *DMorph* as follows:
218+
219+
```go
220+
func migrate(db *sql.DB) error {
221+
return dmorph.Run(db,
222+
dmorph.WithDialect(dmorph.DialectSQLite()),
223+
dmorph.WithMigration(CustomMigration{}))
224+
}
225+
```

dialect_db2.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
package dmorph
55

6+
// DialectDB2 returns a Dialect configured for DB2 databases.
67
func DialectDB2() BaseDialect {
78
return BaseDialect{
89
CreateTemplate: `

dialect_mssql.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
package dmorph
55

6+
// DialectMSSQL returns a Dialect configured for Microsoft SQL Server databases.
67
func DialectMSSQL() BaseDialect {
78
return BaseDialect{
89
CreateTemplate: `

dialect_mysql.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
package dmorph
55

6+
// DialectMySQL returns a Dialect configured for MySQL databases.
67
func DialectMySQL() BaseDialect {
78
return BaseDialect{
89
CreateTemplate: "CREATE TABLE IF NOT EXISTS `%s`" + ` (

dialect_oracle.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
package dmorph
55

6+
// DialectOracle returns a Dialect configured for Oracle Database.
67
func DialectOracle() BaseDialect {
78
return BaseDialect{
89
CreateTemplate: `

dialect_postgres.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
package dmorph
55

6+
// DialectPostgres returns a Dialect configured for Postgres databases.
67
func DialectPostgres() BaseDialect {
78
return BaseDialect{
89
CreateTemplate: `

dialect_sqlite.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
package dmorph
55

6+
// DialectSQLite returns a Dialect configured for SQLite databases.
67
func DialectSQLite() BaseDialect {
78
return BaseDialect{
89
CreateTemplate: `

dialects.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@ import (
99
"fmt"
1010
)
1111

12+
// BaseDialect is a convenience type for databases that manage the necessary operations solely using
13+
// queries. Defining the CreateTemplate, AppliedTemplate and RegisterTemplate enables the BaseDialect to
14+
// perform all the necessary operation to fulfill the Dialect interface.
1215
type BaseDialect struct {
1316
CreateTemplate string
1417
AppliedTemplate string
1518
RegisterTemplate string
1619
}
1720

21+
// EnsureMigrationTableExists ensures that the migration table, saving the applied migrations ids, exists.
1822
func (b BaseDialect) EnsureMigrationTableExists(db *sql.DB, tableName string) error {
1923
tx, err := db.Begin()
2024

@@ -39,6 +43,7 @@ func (b BaseDialect) EnsureMigrationTableExists(db *sql.DB, tableName string) er
3943
return nil
4044
}
4145

46+
// AppliedMigrations gets the already applied migrations from the database, ordered by application date.
4247
func (b BaseDialect) AppliedMigrations(db *sql.DB, tableName string) ([]string, error) {
4348
rows, rowsErr := db.Query(fmt.Sprintf(b.AppliedTemplate, tableName))
4449

@@ -61,6 +66,7 @@ func (b BaseDialect) AppliedMigrations(db *sql.DB, tableName string) ([]string,
6166
return result, errors.Join(rows.Err(), scanErr)
6267
}
6368

69+
// RegisterMigration registers a migration in the migration table.
6470
func (b BaseDialect) RegisterMigration(tx *sql.Tx, id string, tableName string) error {
6571
_, err := tx.Exec(fmt.Sprintf(b.RegisterTemplate, tableName),
6672
sql.Named("id", id))

file_migration.go

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,24 @@ import (
1313
"os"
1414
)
1515

16+
// FileMigration implements the Migration interface. It helps to apply migrations from a file or fs.FS.
1617
type FileMigration struct {
1718
Name string
1819
FS fs.FS
1920
migrationFunc func(tx *sql.Tx, migration string) error
2021
}
2122

23+
// Key returns the key of the migration to register in the migration table.
2224
func (f FileMigration) Key() string {
2325
return f.Name
2426
}
2527

28+
// Migrate executes the migration on the given transaction.
2629
func (f FileMigration) Migrate(tx *sql.Tx) error {
2730
return f.migrationFunc(tx, f.Name)
2831
}
2932

33+
// WithMigrationFromFile generates a FileMigration that will run the content of the given file.
3034
func WithMigrationFromFile(name string) MorphOption {
3135
return func(morpher *Morpher) error {
3236
morpher.Migrations = append(morpher.Migrations, FileMigration{
@@ -40,14 +44,16 @@ func WithMigrationFromFile(name string) MorphOption {
4044

4145
defer func() { _ = m.Close() }()
4246

43-
return applyFileSteps(tx, m, migration, morpher.Log)
47+
return applyStepsStream(tx, m, migration, morpher.Log)
4448
},
4549
})
4650

4751
return nil
4852
}
4953
}
5054

55+
// WithMigrationFromFileFS generates a FileMigration that will run the content of the given file from the
56+
// given filesystem.
5157
func WithMigrationFromFileFS(name string, dir fs.FS) MorphOption {
5258
return func(morpher *Morpher) error {
5359
morpher.Migrations = append(morpher.Migrations, migrationFromFileFS(name, dir, morpher.Log))
@@ -56,26 +62,27 @@ func WithMigrationFromFileFS(name string, dir fs.FS) MorphOption {
5662
}
5763
}
5864

65+
// WithMigrationsFromFS generates a FileMigration that will run all migration scripts of the files in the given
66+
// filesystem.
5967
func WithMigrationsFromFS(d fs.ReadDirFS) MorphOption {
6068
return func(morpher *Morpher) error {
6169
dirEntry, err := d.ReadDir(".")
6270

63-
if err != nil {
64-
return err
65-
}
66-
67-
for _, entry := range dirEntry {
68-
morpher.Log.Info("entry", slog.String("name", entry.Name()))
69-
if entry.Type().IsRegular() {
70-
morpher.Migrations = append(morpher.Migrations,
71-
migrationFromFileFS(entry.Name(), d, morpher.Log))
71+
if err == nil {
72+
for _, entry := range dirEntry {
73+
morpher.Log.Info("entry", slog.String("name", entry.Name()))
74+
if entry.Type().IsRegular() {
75+
morpher.Migrations = append(morpher.Migrations,
76+
migrationFromFileFS(entry.Name(), d, morpher.Log))
77+
}
7278
}
7379
}
7480

75-
return nil
81+
return err
7682
}
7783
}
7884

85+
// migrationFromFileFS creates a FileMigration instance for a specific migration file from an fs.FS directory.
7986
func migrationFromFileFS(name string, dir fs.FS, log *slog.Logger) FileMigration {
8087
return FileMigration{
8188
Name: name,
@@ -89,17 +96,20 @@ func migrationFromFileFS(name string, dir fs.FS, log *slog.Logger) FileMigration
8996

9097
defer func() { _ = m.Close() }()
9198

92-
return applyFileSteps(tx, m, migration, log)
99+
return applyStepsStream(tx, m, migration, log)
93100
},
94101
}
95102
}
96103

97-
func applyFileSteps(tx *sql.Tx, r io.Reader, id string, log *slog.Logger) error {
104+
// applyStepsStream executes database migration steps read from an io.Reader, separated by semicolons, in a transaction.
105+
// Returns the corresponding error if any step execution fails.
106+
func applyStepsStream(tx *sql.Tx, r io.Reader, id string, log *slog.Logger) error {
98107
buf := bytes.Buffer{}
99108

100109
scanner := bufio.NewScanner(r)
110+
var i int
101111

102-
for i := 0; scanner.Scan(); {
112+
for i = 0; scanner.Scan(); {
103113
buf.Write(scanner.Bytes())
104114

105115
if scanner.Text() == ";" {
@@ -116,5 +126,17 @@ func applyFileSteps(tx *sql.Tx, r io.Reader, id string, log *slog.Logger) error
116126
}
117127
}
118128

129+
// cleanup after, for final statement without the closing ; on a new line
130+
if buf.Len() > 0 {
131+
log.Info("migration step",
132+
slog.String("id", id),
133+
slog.Int("step", i),
134+
)
135+
136+
if _, err := tx.Exec(buf.String()); err != nil {
137+
return err
138+
}
139+
}
140+
119141
return scanner.Err()
120142
}

0 commit comments

Comments
 (0)