Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Neo4J Support #320

Merged
merged 27 commits into from
Jan 15, 2020
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ cli/migrate
.godoc.pid
vendor/
.vscode/
.idea/
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ WORKDIR /go/src/github.com/golang-migrate/migrate
COPY . ./

ENV GO111MODULE=on
ENV DATABASES="postgres mysql redshift cassandra spanner cockroachdb clickhouse mongodb sqlserver firebird"
ENV DATABASES="postgres mysql redshift cassandra spanner cockroachdb clickhouse mongodb sqlserver firebird neo4j"
ENV SOURCES="file go_bindata github github_ee aws_s3 google_cloud_storage godoc_vfs gitlab"

RUN go build -a -o build/migrate.linux-386 -ldflags="-s -w -X main.Version=${VERSION}" -tags "$DATABASES $SOURCES" ./cmd/migrate
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
SOURCE ?= file go_bindata github github_ee aws_s3 google_cloud_storage godoc_vfs gitlab
DATABASE ?= postgres mysql redshift cassandra spanner cockroachdb clickhouse mongodb sqlserver firebird
DATABASE ?= postgres mysql redshift cassandra spanner cockroachdb clickhouse mongodb sqlserver firebird neo4j
dhui marked this conversation as resolved.
Show resolved Hide resolved
VERSION ?= $(shell git describe --tags 2>/dev/null | cut -c 2-)
TEST_FLAGS ?=
REPO_OWNER ?= $(shell cd .. && basename "$$(pwd)")
Expand Down
4 changes: 2 additions & 2 deletions database/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ var drivers = make(map[string]Driver)
// All other functions are tested by tests in database/testing.
// Saves you some time and makes sure all database drivers behave the same way.
// 5. Call Register in init().
// 6. Create a migrate/cli/build_<driver-name>.go file
// 6. Create a internal/cli/build_<driver-name>.go file
// 7. Add driver name in 'DATABASE' variable in Makefile
//
// Guidelines:
Expand Down Expand Up @@ -61,7 +61,7 @@ type Driver interface {
// all migrations have been run.
Unlock() error

// Run applies a migration to the database. migration is garantueed to be not nil.
// Run applies a migration to the database. migration is guaranteed to be not nil.
Run(migration io.Reader) error

// SetVersion saves version and dirty state.
Expand Down
11 changes: 11 additions & 0 deletions database/neo4j/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# neo4j
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add docs about installing seabolt and statically linking OpenSSL to the README


`bolt://user:password@host:port/`
dhui marked this conversation as resolved.
Show resolved Hide resolved

| URL Query | WithInstance Config | Description |
|------------|---------------------|-------------|
| `user` | Contained within `AuthConfig` | The user to sign in as |
| `password` | Contained within `AuthConfig` | The user's password |
| `host` | | The host to connect to. Values that start with / are for unix domain sockets. (default is localhost) |
| `port` | | The port to bind to. (default is 7687) |
| | `MigrationsLabel` | Name of the migrations node label |
97 changes: 97 additions & 0 deletions database/neo4j/TUTORIAL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
## Create migrations
Let's create nodes called `Users`:
```
migrate create -ext cypher -dir db/migrations -seq create_user_nodes
```
If there were no errors, we should have two files available under `db/migrations` folder:
- 000001_create_user_nodes.down.cypher
- 000001_create_user_nodes.up.cypher

Note the `cypher` extension that we provided.

In the `.up.cypher` file let's create the table:
```
CREATE (u1:User {name: "Peter"})
CREATE (u2:User {name: "Paul"})
CREATE (u3:User {name: "Mary"})
```
And in the `.down.sql` let's delete it:
```
MATCH (u:User) WHERE u.name IN ["Peter", "Paul", "Mary"] DELETE u
```
Ideally your migrations should be idempotent. You can read more about idempotency in [getting started](GETTING_STARTED.md#create-migrations)

## Run migrations
```
migrate -database ${NEO4J_URL} -path db/migrations up
```
Let's check if the table was created properly by running `bin/cypher-shell -u neo4j -p password`, then `neo4j> MATCH (u:User)`
The output you are supposed to see:
```
+-----------------------------------------------------------------+
| u |
+-----------------------------------------------------------------+
| (:User {name: "Peter") |
| (:User {name: "Paul") |
| (:User {name: "Mary") |
+-----------------------------------------------------------------+
```
Great! Now let's check if running reverse migration also works:
```
migrate -database ${NEO4J_URL} -path db/migrations down
```
Make sure to check if your database changed as expected in this case as well.

## Database transactions

To show database transactions usage, let's create another set of migrations by running:
```
migrate create -ext cypher -dir db/migrations -seq add_mood_to_users
```
Again, it should create for us two migrations files:
- 000002_add_mood_to_users.down.cypher
- 000002_add_mood_to_users.up.cypher

In Neo4j, when we want our queries to be done in a transaction, we need to wrap it with `:BEGIN` and `:COMMIT` commands.
Migration up:
```
:BEGIN

MATCH (u:User)
SET u.mood = "Cheery"

:COMMIT
```
Migration down:
```
:BEGIN

MATCH (u:User)
SET u.mood = null

:COMMIT
```

## Optional: Run migrations within your Go app
Here is a very simple app running migrations for the above configuration:
```
import (
"log"

"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/neo4j"
_ "github.com/golang-migrate/migrate/v4/source/file"
)

func main() {
m, err := migrate.New(
"file://db/migrations",
"bolt://neo4j:password@localhost:7687/")
dhui marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
log.Fatal(err)
}
if err := m.Up(); err != nil {
log.Fatal(err)
}
}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP CONSTRAINT ON (m:Movie) ASSERT m.Name IS UNIQUE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CREATE CONSTRAINT ON (m:Movie) ASSERT m.Name IS UNIQUE
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
MATCH (m:Movie)
DELETE m
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CREATE (:Movie {name: "Footloose"})
CREATE (:Movie {name: "Ghost"})
204 changes: 204 additions & 0 deletions database/neo4j/neo4j.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// +build cgo

package neo4j

import (
"fmt"
"io"
"io/ioutil"
neturl "net/url"
"sync/atomic"

"github.com/golang-migrate/migrate/v4/database"
"github.com/neo4j/neo4j-go-driver/neo4j"
)

func init() {
db := Neo4j{}
database.Register("neo4j", &db)
}

var DefaultMigrationsLabel = "SchemaMigration"

var (
ErrNilConfig = fmt.Errorf("no config")
)

type Config struct {
AuthToken neo4j.AuthToken
URL string // if using WithInstance, don't provide auth in the URL, it will be ignored
MigrationsLabel string
}

type Neo4j struct {
driver neo4j.Driver
lock uint32

// Open and WithInstance need to guarantee that config is never nil
config *Config
}

func WithInstance(config *Config) (database.Driver, error) {
if config == nil {
return nil, ErrNilConfig
}

neoDriver, err := neo4j.NewDriver(config.URL, config.AuthToken)
if err != nil {
return nil, err
}

driver := &Neo4j{
driver: neoDriver,
config: config,
}

if err := driver.ensureVersionConstraint(); err != nil {
return nil, err
}

return driver, nil
}

func (n *Neo4j) Open(url string) (database.Driver, error) {
uri, err := neturl.Parse(url)
if err != nil {
return nil, err
}
password, _ := uri.User.Password()
authToken := neo4j.BasicAuth(uri.User.Username(), password, "")
uri.User = nil

dhui marked this conversation as resolved.
Show resolved Hide resolved
return WithInstance(&Config{
URL: uri.String(),
AuthToken: authToken,
MigrationsLabel: DefaultMigrationsLabel,
})
}

func (n *Neo4j) Close() error {
return n.driver.Close()
}

// local locking in order to pass tests, Neo doesn't support database locking
func (n *Neo4j) Lock() error {
if !atomic.CompareAndSwapUint32(&n.lock, 0, 1) {
return database.ErrLocked
}

return nil
}

func (n *Neo4j) Unlock() error {
atomic.StoreUint32(&n.lock, 0)
dhui marked this conversation as resolved.
Show resolved Hide resolved
return nil
}

func (n *Neo4j) Run(migration io.Reader) error {
body, err := ioutil.ReadAll(migration)
if err != nil {
return err
}

session, err := n.driver.Session(neo4j.AccessModeWrite)
if err != nil {
return err
}
defer session.Close()

result, err := session.Run(string(body[:]), nil)
if err != nil {
return err
}
return result.Err()
}

func (n *Neo4j) SetVersion(version int, dirty bool) error {
session, err := n.driver.Session(neo4j.AccessModeRead)
if err != nil {
return err
}
defer session.Close()

query := fmt.Sprintf("MERGE (sm:%s {version: $version, dirty: $dirty})",
n.config.MigrationsLabel)
result, err := session.Run(query, map[string]interface{}{"version": version, "dirty": dirty})
if err != nil {
return err
}
return result.Err()
}

type MigrationRecord struct {
Version int
Dirty bool
}

func (n *Neo4j) Version() (version int, dirty bool, err error) {
session, err := n.driver.Session(neo4j.AccessModeRead)
if err != nil {
return -1, false, err
}
defer session.Close()

query := fmt.Sprintf("MATCH (sm:%s) RETURN sm.version AS version, sm.dirty AS dirty ORDER BY sm.version DESC LIMIT 1",
n.config.MigrationsLabel)
result, err := session.ReadTransaction(func(transaction neo4j.Transaction) (interface{}, error) {
result, err := transaction.Run(query, nil)
if err != nil {
return nil, err
}
if result.Next() {
record := result.Record()
mr := MigrationRecord{}
versionResult, ok := record.Get("version")
if !ok {
mr.Version = -1
} else {
mr.Version = int(versionResult.(int64))
}

dirtyResult, ok := record.Get("dirty")
if ok {
mr.Dirty = dirtyResult.(bool)
}

return mr, nil
}
return nil, result.Err()
})
if err != nil {
return -1, false, err
}
if result == nil {
return -1, false, nil
}
mr := result.(MigrationRecord)
return mr.Version, mr.Dirty, nil
}

func (n *Neo4j) Drop() error {
session, err := n.driver.Session(neo4j.AccessModeWrite);
if err != nil {
return err
}
defer session.Close()

_, err = session.Run("MATCH (n) DETACH DELETE n", nil)
return err
}

func (n *Neo4j) ensureVersionConstraint() (err error) {
session, err := n.driver.Session(neo4j.AccessModeWrite);
if err != nil {
return err
}
defer session.Close()

query := fmt.Sprintf("CREATE CONSTRAINT ON (a:%s) ASSERT a.version IS UNIQUE", n.config.MigrationsLabel)
result, err := session.Run(query, nil)
if err != nil {
return err
}
return result.Err()
}
Loading