Skip to content

Commit 5163ac7

Browse files
committed
feature: add rqlite support
1 parent 856ea12 commit 5163ac7

12 files changed

+693
-1
lines changed

Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
SOURCE ?= file go_bindata github github_ee bitbucket aws_s3 google_cloud_storage godoc_vfs gitlab
2-
DATABASE ?= postgres mysql redshift cassandra spanner cockroachdb yugabytedb clickhouse mongodb sqlserver firebird neo4j pgx pgx5
2+
DATABASE ?= postgres mysql redshift cassandra spanner cockroachdb yugabytedb clickhouse mongodb sqlserver firebird neo4j pgx pgx5 rqlite
33
DATABASE_TEST ?= $(DATABASE) sqlite sqlite3 sqlcipher
44
VERSION ?= $(shell git describe --tags 2>/dev/null | cut -c 2-)
55
TEST_FLAGS ?=

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Database drivers run migrations. [Add a new database?](database/driver.go)
4343
* [ClickHouse](database/clickhouse)
4444
* [Firebird](database/firebird)
4545
* [MS SQL Server](database/sqlserver)
46+
* [RQLite](database/rqlite)
4647

4748
### Database URLs
4849

database/rqlite/README.md

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# rqlite
2+
3+
`rqlite://admin:[email protected]:4001/?level=strong&timeout=5`
4+
5+
The `rqlite` url scheme is used for both secure and insecure connections. If connecting to an insecure database, pass `x-connect-insecure` in your URL query, or use `WithInstance` to pass an established connection.
6+
7+
The migrations table name is configurable through the `x-migrations-table` URL query parameter, or by using `WithInstance` and passing `MigrationsTable` through `Config`.
8+
9+
Other connect parameters are directly passed through to the database driver. For examples of connection strings, see https://github.com/rqlite/gorqlite#examples.
10+
11+
| URL Query | WithInstance Config | Description |
12+
|------------|---------------------|-------------|
13+
| `x-connect-insecure` | n/a: set on instance | Boolean to indicate whether to use an insecure connection. Defaults to `false`. |
14+
| `x-migrations-table` | `MigrationsTable` | Name of the migrations table. Defaults to `schema_migrations`. |
15+
16+
## Notes
17+
18+
* Uses the https://github.com/rqlite/gorqlite driver
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE IF EXISTS pets;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
CREATE TABLE pets (
2+
name string
3+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE IF EXISTS pets;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE pets ADD predator bool;

database/rqlite/rqlite.go

+334
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
package rqlite
2+
3+
import (
4+
"fmt"
5+
"io"
6+
nurl "net/url"
7+
"strconv"
8+
"strings"
9+
10+
"go.uber.org/atomic"
11+
12+
"github.com/golang-migrate/migrate/v4"
13+
"github.com/golang-migrate/migrate/v4/database"
14+
"github.com/hashicorp/go-multierror"
15+
"github.com/pkg/errors"
16+
"github.com/rqlite/gorqlite"
17+
)
18+
19+
func init() {
20+
database.Register("rqlite", &Rqlite{})
21+
}
22+
23+
const (
24+
// DefaultMigrationsTable defines the default rqlite migrations table
25+
DefaultMigrationsTable = "schema_migrations"
26+
27+
// DefaultConnectInsecure defines the default setting for connect insecure
28+
DefaultConnectInsecure = false
29+
)
30+
31+
// ErrNilConfig is returned if no configuration was passed to WithInstance
32+
var ErrNilConfig = fmt.Errorf("no config")
33+
34+
// ErrBadConfig is returned if configuration was invalid
35+
var ErrBadConfig = fmt.Errorf("bad parameter")
36+
37+
// Config defines the driver configuration
38+
type Config struct {
39+
// ConnectInsecure sets whether the connection uses TLS. Ineffectual when using WithInstance
40+
ConnectInsecure bool
41+
// MigrationsTable configures the migrations table name
42+
MigrationsTable string
43+
}
44+
45+
type Rqlite struct {
46+
db *gorqlite.Connection
47+
isLocked atomic.Bool
48+
49+
config *Config
50+
}
51+
52+
// WithInstance creates a rqlite database driver with an existing gorqlite database connection
53+
// and a Config struct
54+
func WithInstance(instance *gorqlite.Connection, config *Config) (database.Driver, error) {
55+
if config == nil {
56+
return nil, ErrNilConfig
57+
}
58+
59+
// we use the consistency level check as a database ping
60+
if _, err := instance.ConsistencyLevel(); err != nil {
61+
return nil, err
62+
}
63+
64+
if len(config.MigrationsTable) == 0 {
65+
config.MigrationsTable = DefaultMigrationsTable
66+
}
67+
68+
driver := &Rqlite{
69+
db: instance,
70+
config: config,
71+
}
72+
73+
if err := driver.ensureVersionTable(); err != nil {
74+
return nil, err
75+
}
76+
77+
return driver, nil
78+
}
79+
80+
// OpenURL creates a rqlite database driver from a connect URL
81+
func OpenURL(url string) (database.Driver, error) {
82+
d := &Rqlite{}
83+
return d.Open(url)
84+
}
85+
86+
func (r *Rqlite) ensureVersionTable() (err error) {
87+
if err = r.Lock(); err != nil {
88+
return err
89+
}
90+
91+
defer func() {
92+
if e := r.Unlock(); e != nil {
93+
if err == nil {
94+
err = e
95+
} else {
96+
err = multierror.Append(err, e)
97+
}
98+
}
99+
}()
100+
101+
stmts := []string{
102+
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (version uint64, dirty bool)`, r.config.MigrationsTable),
103+
fmt.Sprintf(`CREATE UNIQUE INDEX IF NOT EXISTS version_unique ON %s (version)`, r.config.MigrationsTable),
104+
}
105+
106+
if _, err := r.db.Write(stmts); err != nil {
107+
return err
108+
}
109+
110+
return nil
111+
}
112+
113+
// Open returns a new driver instance configured with parameters
114+
// coming from the URL string. Migrate will call this function
115+
// only once per instance.
116+
func (r *Rqlite) Open(url string) (database.Driver, error) {
117+
dburl, config, err := parseUrl(url)
118+
if err != nil {
119+
return nil, err
120+
}
121+
r.config = config
122+
123+
r.db, err = gorqlite.Open(dburl.String())
124+
if err != nil {
125+
return nil, err
126+
}
127+
128+
if err := r.ensureVersionTable(); err != nil {
129+
return nil, err
130+
}
131+
132+
return r, nil
133+
}
134+
135+
// Close closes the underlying database instance managed by the driver.
136+
// Migrate will call this function only once per instance.
137+
func (r *Rqlite) Close() error {
138+
r.db.Close()
139+
return nil
140+
}
141+
142+
// Lock should acquire a database lock so that only one migration process
143+
// can run at a time. Migrate will call this function before Run is called.
144+
// If the implementation can't provide this functionality, return nil.
145+
// Return database.ErrLocked if database is already locked.
146+
func (r *Rqlite) Lock() error {
147+
if !r.isLocked.CAS(false, true) {
148+
return database.ErrLocked
149+
}
150+
return nil
151+
}
152+
153+
// Unlock should release the lock. Migrate will call this function after
154+
// all migrations have been run.
155+
func (r *Rqlite) Unlock() error {
156+
if !r.isLocked.CAS(true, false) {
157+
return database.ErrNotLocked
158+
}
159+
return nil
160+
}
161+
162+
// Run applies a migration to the database. migration is guaranteed to be not nil.
163+
func (r *Rqlite) Run(migration io.Reader) error {
164+
migr, err := io.ReadAll(migration)
165+
if err != nil {
166+
return err
167+
}
168+
169+
query := string(migr[:])
170+
if _, err := r.db.WriteOne(query); err != nil {
171+
return &database.Error{OrigErr: err, Query: []byte(query)}
172+
}
173+
174+
return nil
175+
}
176+
177+
// SetVersion saves version and dirty state.
178+
// Migrate will call this function before and after each call to Run.
179+
// version must be >= -1. -1 means NilVersion.
180+
func (r *Rqlite) SetVersion(version int, dirty bool) error {
181+
deleteQuery := fmt.Sprintf(`DELETE FROM %s`, r.config.MigrationsTable)
182+
statements := []gorqlite.ParameterizedStatement{
183+
{
184+
Query: deleteQuery,
185+
},
186+
}
187+
188+
// Also re-write the schema version for nil dirty versions to prevent
189+
// empty schema version for failed down migration on the first migration
190+
// See: https://github.com/golang-migrate/migrate/issues/330
191+
insertQuery := fmt.Sprintf(`INSERT INTO %s (version, dirty) VALUES (?, ?)`, r.config.MigrationsTable)
192+
if version >= 0 || (version == database.NilVersion && dirty) {
193+
statements = append(statements, gorqlite.ParameterizedStatement{
194+
Query: insertQuery,
195+
Arguments: []interface{}{
196+
version,
197+
dirty,
198+
},
199+
})
200+
}
201+
202+
wr, err := r.db.WriteParameterized(statements)
203+
if err != nil {
204+
for i, res := range wr {
205+
if res.Err != nil {
206+
return &database.Error{OrigErr: err, Query: []byte(statements[i].Query)}
207+
}
208+
}
209+
210+
// if somehow we're still here, return the original error with combined queries
211+
return &database.Error{OrigErr: err, Query: []byte(deleteQuery + "\n" + insertQuery)}
212+
}
213+
214+
return nil
215+
}
216+
217+
// Version returns the currently active version and if the database is dirty.
218+
// When no migration has been applied, it must return version -1.
219+
// Dirty means, a previous migration failed and user interaction is required.
220+
func (r *Rqlite) Version() (version int, dirty bool, err error) {
221+
query := "SELECT version, dirty FROM " + r.config.MigrationsTable + " LIMIT 1"
222+
223+
qr, err := r.db.QueryOne(query)
224+
if err != nil {
225+
return database.NilVersion, false, nil
226+
}
227+
228+
if !qr.Next() {
229+
return database.NilVersion, false, nil
230+
}
231+
232+
if err := qr.Scan(&version, &dirty); err != nil {
233+
return database.NilVersion, false, &database.Error{OrigErr: err, Query: []byte(query)}
234+
}
235+
236+
return version, dirty, nil
237+
}
238+
239+
// Drop deletes everything in the database.
240+
// Note that this is a breaking action, a new call to Open() is necessary to
241+
// ensure subsequent calls work as expected.
242+
func (r *Rqlite) Drop() error {
243+
query := `SELECT name FROM sqlite_master WHERE type = 'table'`
244+
245+
tables, err := r.db.QueryOne(query)
246+
if err != nil {
247+
return &database.Error{OrigErr: err, Query: []byte(query)}
248+
}
249+
250+
statements := make([]string, 0)
251+
for tables.Next() {
252+
var tableName string
253+
if err := tables.Scan(&tableName); err != nil {
254+
return err
255+
}
256+
257+
if len(tableName) > 0 {
258+
statement := fmt.Sprintf(`DROP TABLE %s`, tableName)
259+
statements = append(statements, statement)
260+
}
261+
}
262+
263+
// return if nothing to do
264+
if len(statements) <= 0 {
265+
return nil
266+
}
267+
268+
wr, err := r.db.Write(statements)
269+
if err != nil {
270+
for i, res := range wr {
271+
if res.Err != nil {
272+
return &database.Error{OrigErr: err, Query: []byte(statements[i])}
273+
}
274+
}
275+
276+
// if somehow we're still here, return the original error with combined queries
277+
return &database.Error{OrigErr: err, Query: []byte(strings.Join(statements, "\n"))}
278+
}
279+
280+
return nil
281+
}
282+
283+
func parseUrl(url string) (*nurl.URL, *Config, error) {
284+
parsedUrl, err := nurl.Parse(url)
285+
if err != nil {
286+
return nil, nil, err
287+
}
288+
289+
config, err := parseConfigFromQuery(parsedUrl.Query())
290+
if err != nil {
291+
return nil, nil, err
292+
}
293+
294+
if parsedUrl.Scheme != "rqlite" {
295+
return nil, nil, errors.Wrap(ErrBadConfig, "bad scheme")
296+
}
297+
298+
// adapt from rqlite to http/https schemes
299+
if config.ConnectInsecure {
300+
parsedUrl.Scheme = "http"
301+
} else {
302+
parsedUrl.Scheme = "https"
303+
}
304+
305+
filteredUrl := migrate.FilterCustomQuery(parsedUrl)
306+
307+
return filteredUrl, config, nil
308+
}
309+
310+
func parseConfigFromQuery(queryVals nurl.Values) (*Config, error) {
311+
c := Config{
312+
ConnectInsecure: DefaultConnectInsecure,
313+
MigrationsTable: DefaultMigrationsTable,
314+
}
315+
316+
migrationsTable := queryVals.Get("x-migrations-table")
317+
if migrationsTable != "" {
318+
if strings.HasPrefix(migrationsTable, "sqlite_") {
319+
return nil, errors.Wrap(ErrBadConfig, "invalid value for x-migrations-table")
320+
}
321+
c.MigrationsTable = migrationsTable
322+
}
323+
324+
connectInsecureStr := queryVals.Get("x-connect-insecure")
325+
if connectInsecureStr != "" {
326+
connectInsecure, err := strconv.ParseBool(connectInsecureStr)
327+
if err != nil {
328+
return nil, errors.Wrap(ErrBadConfig, "invalid value for x-connect-insecure")
329+
}
330+
c.ConnectInsecure = connectInsecure
331+
}
332+
333+
return &c, nil
334+
}

0 commit comments

Comments
 (0)