Skip to content

Commit c5be274

Browse files
committed
[!] initial commit, extracted from cybertec-postgresql/pg_timetable
1 parent a566d2b commit c5be274

File tree

6 files changed

+554
-9
lines changed

6 files changed

+554
-9
lines changed

.gitignore

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
1-
# If you prefer the allow list template instead of the deny list, see community template:
2-
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
3-
#
41
# Binaries for programs and plugins
52
*.exe
63
*.exe~
74
*.dll
85
*.so
96
*.dylib
107

11-
# Test binary, built with `go test -c`
8+
# Test binary, build with `go test -c`
129
*.test
1310

1411
# Output of the go coverage tool, specifically when used with LiteIDE
1512
*.out
1613

17-
# Dependency directories (remove the comment below to include it)
18-
# vendor/
14+
# Visual Studio Code internal folder
15+
.vscode
1916

20-
# Go workspace file
21-
go.work
17+
# Packages ouput folder
18+
dist
19+
20+
# delve debugger file
21+
debug

README.md

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,56 @@
11
# pgx-migrator
2-
Simple pgx based PostgreSQL schema migration library for Go
2+
3+
Simple [pgx](https://github.com/jackc/pgx) oriented [PostgreSQL](https://www.postgresql.org/) schema migration library for Go based on [lopezator/migrator](https://github.com/lopezator/migrator).
4+
5+
# Features
6+
7+
* Simple code
8+
* Usage as a library, embeddable and extensible on your behalf
9+
* Made to use with `jackc/pgx`
10+
* Go code migrations, either transactional or transaction-less, using `pgx.Tx` (`migrator.Migration`) or `pgx.Conn` and `pgx.Pool` (`migrator.MigrationNoTx`)
11+
* No need to use `//go:embed` or others, since all migrations are just Go code
12+
13+
# Usage
14+
15+
Customize this to your needs by changing the driver and/or connection settings.
16+
17+
### QuickStart:
18+
19+
```go
20+
package main
21+
22+
import (
23+
24+
pgx "github.com/jackc/pgx/v5"
25+
migrator "github.com/cybertec-postgresql/pgx-migrator"
26+
)
27+
28+
func main() {
29+
// Configure migrations
30+
m, err := migrator.New(
31+
migrator.Migrations(
32+
&migrator.Migration{
33+
Name: "Create table foo",
34+
Func: func(ctx context.Context, tx pgx.Tx) error {
35+
_, err := tx.Exec(ctx, "CREATE TABLE foo (id INT PRIMARY KEY)")
36+
return err
37+
},
38+
},
39+
),
40+
)
41+
if err != nil {
42+
panic(err)
43+
}
44+
45+
// Open database connection
46+
conn, err := pgx.Connect(context.Background(), os.Getenv("DATABASE_URL"))
47+
if err != nil {
48+
panic(err)
49+
}
50+
51+
// Migrate up
52+
if err := m.Migrate(conn); err != nil {
53+
panic(err)
54+
}
55+
}
56+
```

go.mod

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module github.com/cybertec-postgresql/pgx-migrator
2+
3+
go 1.22.0
4+
5+
require (
6+
github.com/jackc/pgx/v5 v5.5.5
7+
github.com/pashagolub/pgxmock/v3 v3.4.0
8+
github.com/stretchr/testify v1.9.0
9+
)
10+
11+
require (
12+
github.com/davecgh/go-spew v1.1.1 // indirect
13+
github.com/jackc/pgpassfile v1.0.0 // indirect
14+
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
15+
github.com/jackc/puddle/v2 v2.2.1 // indirect
16+
github.com/pmezard/go-difflib v1.0.0 // indirect
17+
golang.org/x/crypto v0.17.0 // indirect
18+
golang.org/x/sync v0.1.0 // indirect
19+
golang.org/x/text v0.14.0 // indirect
20+
gopkg.in/yaml.v3 v3.0.1 // indirect
21+
)

go.sum

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4+
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
5+
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
6+
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
7+
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
8+
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
9+
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
10+
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
11+
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
12+
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
13+
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
14+
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
15+
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
16+
github.com/pashagolub/pgxmock/v3 v3.4.0 h1:87VMr2q7m2+6VzXo4Tsp9kMklGlj6mMN19Hp/bp2Rwo=
17+
github.com/pashagolub/pgxmock/v3 v3.4.0/go.mod h1:FvCl7xqPbLLI3XohihJ1NzXnikjM3q/NWSixg4t9hrU=
18+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
19+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
20+
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
21+
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
22+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
23+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
24+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
25+
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
26+
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
27+
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
28+
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
29+
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
30+
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
31+
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
32+
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
33+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
34+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
35+
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
36+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
37+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
38+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

migrator.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package migrator
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
8+
pgx "github.com/jackc/pgx/v5"
9+
pgconn "github.com/jackc/pgx/v5/pgconn"
10+
)
11+
12+
// PgxIface is interface for database connection or transaction
13+
type PgxIface interface {
14+
Begin(ctx context.Context) (pgx.Tx, error)
15+
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
16+
QueryRow(context.Context, string, ...interface{}) pgx.Row
17+
Query(ctx context.Context, query string, args ...interface{}) (pgx.Rows, error)
18+
Ping(ctx context.Context) error
19+
}
20+
21+
const defaultTableName = "migrations"
22+
23+
// Migrator is the migrator implementation
24+
type Migrator struct {
25+
TableName string
26+
migrations []interface{}
27+
onNotice func(string)
28+
}
29+
30+
// Option sets options such migrations or table name.
31+
type Option func(*Migrator)
32+
33+
// TableName creates an option to allow overriding the default table name
34+
func TableName(tableName string) Option {
35+
return func(m *Migrator) {
36+
m.TableName = tableName
37+
}
38+
}
39+
40+
// SetNotice overrides the default standard output function
41+
func SetNotice(noticeFunc func(string)) Option {
42+
return func(m *Migrator) {
43+
m.onNotice = noticeFunc
44+
}
45+
}
46+
47+
// Migrations creates an option with provided migrations
48+
func Migrations(migrations ...interface{}) Option {
49+
return func(m *Migrator) {
50+
m.migrations = migrations
51+
}
52+
}
53+
54+
// New creates a new migrator instance
55+
func New(opts ...Option) (*Migrator, error) {
56+
m := &Migrator{
57+
TableName: defaultTableName,
58+
onNotice: func(msg string) {
59+
fmt.Println(msg)
60+
},
61+
}
62+
for _, opt := range opts {
63+
opt(m)
64+
}
65+
66+
if len(m.migrations) == 0 {
67+
return nil, errors.New("Migrations must be provided")
68+
}
69+
70+
for _, m := range m.migrations {
71+
switch m.(type) {
72+
case *Migration:
73+
case *MigrationNoTx:
74+
default:
75+
return nil, errors.New("Invalid migration type")
76+
}
77+
}
78+
79+
return m, nil
80+
}
81+
82+
// Migrate applies all available migrations
83+
func (m *Migrator) Migrate(ctx context.Context, db PgxIface) error {
84+
// create migrations table if doesn't exist
85+
_, err := db.Exec(ctx, fmt.Sprintf(`
86+
CREATE TABLE IF NOT EXISTS %s (
87+
id INT8 NOT NULL,
88+
version TEXT NOT NULL,
89+
PRIMARY KEY (id)
90+
);
91+
`, m.TableName))
92+
if err != nil {
93+
return err
94+
}
95+
96+
pm, count, err := m.Pending(ctx, db)
97+
if err != nil {
98+
return err
99+
}
100+
101+
// plan migrations
102+
for idx, migration := range pm {
103+
insertVersion := fmt.Sprintf("INSERT INTO %s (id, version) VALUES (%d, '%s')", m.TableName, idx+count, migration.(fmt.Stringer).String())
104+
switch mm := migration.(type) {
105+
case *Migration:
106+
if err := migrate(ctx, db, insertVersion, mm, m.onNotice); err != nil {
107+
return fmt.Errorf("Error while running migrations: %w", err)
108+
}
109+
case *MigrationNoTx:
110+
if err := migrateNoTx(ctx, db, insertVersion, mm, m.onNotice); err != nil {
111+
return fmt.Errorf("Error while running migrations: %w", err)
112+
}
113+
}
114+
}
115+
116+
return nil
117+
}
118+
119+
// Pending returns all pending (not yet applied) migrations and count of migration applied
120+
func (m *Migrator) Pending(ctx context.Context, db PgxIface) ([]interface{}, int, error) {
121+
count, err := countApplied(ctx, db, m.TableName)
122+
if err != nil {
123+
return nil, 0, err
124+
}
125+
if count > len(m.migrations) {
126+
count = len(m.migrations)
127+
}
128+
return m.migrations[count:len(m.migrations)], count, nil
129+
}
130+
131+
// NeedUpgrade returns True if database need to be updated with migrations
132+
func (m *Migrator) NeedUpgrade(ctx context.Context, db PgxIface) (bool, error) {
133+
exists, err := tableExists(ctx, db, m.TableName)
134+
if !exists {
135+
return true, err
136+
}
137+
mm, _, err := m.Pending(ctx, db)
138+
return len(mm) > 0, err
139+
}
140+
141+
func countApplied(ctx context.Context, db PgxIface, tableName string) (int, error) {
142+
// count applied migrations
143+
var count int
144+
err := db.QueryRow(ctx, fmt.Sprintf("SELECT count(*) FROM %s", tableName)).Scan(&count)
145+
if err != nil {
146+
return 0, err
147+
}
148+
return count, nil
149+
}
150+
151+
func tableExists(ctx context.Context, db PgxIface, tableName string) (bool, error) {
152+
var exists bool
153+
err := db.QueryRow(ctx, "SELECT to_regclass($1) IS NOT NULL", tableName).Scan(&exists)
154+
if err != nil {
155+
return false, err
156+
}
157+
return exists, nil
158+
}
159+
160+
// Migration represents a single migration
161+
type Migration struct {
162+
Name string
163+
Func func(context.Context, pgx.Tx) error
164+
}
165+
166+
// String returns a string representation of the migration
167+
func (m *Migration) String() string {
168+
return m.Name
169+
}
170+
171+
// MigrationNoTx represents a single not transactional migration
172+
type MigrationNoTx struct {
173+
Name string
174+
Func func(context.Context, PgxIface) error
175+
}
176+
177+
func (m *MigrationNoTx) String() string {
178+
return m.Name
179+
}
180+
181+
func migrate(ctx context.Context, db PgxIface, insertVersion string, migration *Migration, notice func(string)) error {
182+
tx, err := db.Begin(ctx)
183+
if err != nil {
184+
return err
185+
}
186+
defer func() {
187+
if err != nil {
188+
if errRb := tx.Rollback(ctx); errRb != nil {
189+
err = fmt.Errorf("Error rolling back: %s\n%s", errRb, err)
190+
}
191+
return
192+
}
193+
err = tx.Commit(ctx)
194+
}()
195+
notice(fmt.Sprintf("Applying migration named '%s'...", migration.Name))
196+
if err = migration.Func(ctx, tx); err != nil {
197+
return fmt.Errorf("Error executing golang migration: %w", err)
198+
}
199+
if _, err = tx.Exec(ctx, insertVersion); err != nil {
200+
return fmt.Errorf("Error updating migration versions: %w", err)
201+
}
202+
notice(fmt.Sprintf("Applied migration named '%s'", migration.Name))
203+
204+
return err
205+
}
206+
207+
func migrateNoTx(ctx context.Context, db PgxIface, insertVersion string, migration *MigrationNoTx, notice func(string)) error {
208+
notice(fmt.Sprintf("Applying no tx migration named '%s'...", migration.Name))
209+
if err := migration.Func(ctx, db); err != nil {
210+
return fmt.Errorf("Error executing golang migration: %w", err)
211+
}
212+
if _, err := db.Exec(ctx, insertVersion); err != nil {
213+
return fmt.Errorf("Error updating migration versions: %w", err)
214+
}
215+
notice(fmt.Sprintf("Applied no tx migration named '%s'...", migration.Name))
216+
217+
return nil
218+
}

0 commit comments

Comments
 (0)