diff --git a/adapter/sqlite/db.go b/adapter/sqlite/db.go new file mode 100644 index 0000000..a132c0b --- /dev/null +++ b/adapter/sqlite/db.go @@ -0,0 +1,112 @@ +package sqlite + +import ( + "database/sql" + + _ "github.com/mattn/go-sqlite3" + "github.com/mickael-menu/zk/util/errors" +) + +// DB holds the connections to a SQLite database. +type DB struct { + db *sql.DB +} + +// Open creates a new DB instance for the SQLite database at the given path. +func Open(path string) (*DB, error) { + db, err := sql.Open("sqlite3", "file:"+path) + if err != nil { + return nil, errors.Wrap(err, "failed to open the database") + } + return &DB{db}, nil +} + +// Close terminates the connections to the SQLite database. +func (db *DB) Close() error { + err := db.db.Close() + return errors.Wrap(err, "failed to close the database") +} + +// Migrate upgrades the SQL schema of the database. +func (db *DB) Migrate() error { + wrap := errors.Wrapper("database migration failed") + + tx, err := db.db.Begin() + if err != nil { + return wrap(err) + } + defer tx.Rollback() + + var version int + err = tx.QueryRow("PRAGMA user_version").Scan(&version) + if err != nil { + return wrap(err) + } + + if version == 0 { + err = execMultiple(tx, []string{ + ` + CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + filename TEXT NOT NULL, + dir TEXT NOT NULL, + title TEXT DEFAULT('') NOT NULL, + content TEXT DEFAULT('') NOT NULL, + word_count INTEGER DEFAULT(0) NOT NULL, + checksum TEXT NOT NULL, + created TEXT DEFAULT(CURRENT_TIMESTAMP) NOT NULL, + modified TEXT DEFAULT(CURRENT_TIMESTAMP) NOT NULL, + UNIQUE(filename, dir) + ) + `, + `CREATE INDEX IF NOT EXISTS notes_checksum_idx ON notes(checksum)`, + ` + CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5( + title, content, + content = notes, + content_rowid = id, + tokenize = 'porter unicode61 remove_diacritics 1' + ) + `, + // Triggers to keep the FTS index up to date. + ` + CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN + INSERT INTO notes_fts(rowid, title, content) VALUES (new.id, new.title, new.content); + END + `, + ` + CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN + INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', old.id, old.title, old.content); + END + `, + ` + CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN + INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', old.id, old.title, old.content); + INSERT INTO notes_fts(rowid, title, content) VALUES (new.id, new.title, new.content); + END + `, + `PRAGMA user_version = 1`, + }) + } + if err != nil { + return wrap(err) + } + + err = tx.Commit() + if err != nil { + return wrap(err) + } + + return nil +} + +func execMultiple(tx *sql.Tx, stmts []string) error { + var err error + for _, stmt := range stmts { + if err != nil { + break + } + _, err = tx.Exec(stmt) + } + return err +} diff --git a/cmd/container.go b/cmd/container.go index efd3f2d..279b1e0 100644 --- a/cmd/container.go +++ b/cmd/container.go @@ -5,6 +5,8 @@ import ( "os" "github.com/mickael-menu/zk/adapter/handlebars" + "github.com/mickael-menu/zk/adapter/sqlite" + "github.com/mickael-menu/zk/core/zk" "github.com/mickael-menu/zk/util" "github.com/mickael-menu/zk/util/date" ) @@ -34,3 +36,14 @@ func (c *Container) TemplateLoader() *handlebars.Loader { } return c.templateLoader } + +// Database returns the DB instance for the given slip box, after executing any +// pending migration. +func (c *Container) Database(zk *zk.Zk) (*sqlite.DB, error) { + db, err := sqlite.Open(zk.DBPath()) + if err != nil { + return nil, err + } + err = db.Migrate() + return db, err +} diff --git a/core/zk/zk.go b/core/zk/zk.go index 7a9783e..88a6f3d 100644 --- a/core/zk/zk.go +++ b/core/zk/zk.go @@ -121,6 +121,11 @@ func locateRoot(path string) (string, error) { return locate(path) } +// DBPath returns the path to the slip box database. +func (zk *Zk) DBPath() string { + return filepath.Join(zk.Path, ".zk/data.db") +} + // DirAt returns a Dir representation of the slip box directory at the given path. func (zk *Zk) DirAt(path string, overrides ...ConfigOverrides) (*Dir, error) { wrap := errors.Wrapperf("%v: not a valid slip box directory", path) diff --git a/core/zk/zk_test.go b/core/zk/zk_test.go index ebc2c92..9e437ce 100644 --- a/core/zk/zk_test.go +++ b/core/zk/zk_test.go @@ -9,10 +9,16 @@ import ( "github.com/mickael-menu/zk/util/opt" ) +func TestDBPath(t *testing.T) { + wd, _ := os.Getwd() + zk := &Zk{Path: wd} + + assert.Equal(t, zk.DBPath(), filepath.Join(wd, ".zk/data.db")) +} + func TestDirAtGivenPath(t *testing.T) { // The tests are relative to the working directory, for convenience. - wd, err := os.Getwd() - assert.Nil(t, err) + wd, _ := os.Getwd() zk := &Zk{Path: wd} diff --git a/go.mod b/go.mod index bf50181..e7101d5 100644 --- a/go.mod +++ b/go.mod @@ -10,5 +10,6 @@ require ( github.com/hashicorp/hcl/v2 v2.8.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/lestrrat-go/strftime v1.0.3 + github.com/mattn/go-sqlite3 v1.14.6 gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index a64d0f9..b826a2a 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LE github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc/go.mod h1:kopuH9ugFRkIXf3YoqHKyrJ9YfUFsckUU9S7B+XP+is= github.com/lestrrat-go/strftime v1.0.3 h1:qqOPU7y+TM8Y803I8fG9c/DyKG3xH/xkng6keC1015Q= github.com/lestrrat-go/strftime v1.0.3/go.mod h1:E1nN3pCbtMSu1yjSVeyuRFVm/U0xoR76fd03sz+Qz4g= +github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=