whatcanGOwrong
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# neo4j
|
||||
The Neo4j driver (bolt) does not natively support executing multiple statements in a single query. To allow for multiple statements in a single migration, you can use the `x-multi-statement` param.
|
||||
This mode splits the migration text into separately-executed statements by a semi-colon `;`. Thus `x-multi-statement` cannot be used when a statement in the migration contains a string with a semi-colon.
|
||||
The queries **should** run in a single transaction, so partial migrations should not be a concern, but this is untested.
|
||||
|
||||
|
||||
`neo4j://user:password@host:port/`
|
||||
|
||||
| URL Query | WithInstance Config | Description |
|
||||
|------------|---------------------|-------------|
|
||||
| `x-multi-statement` | `MultiStatement` | Enable multiple statements to be ran in a single migration (See note above) |
|
||||
| `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 |
|
||||
|
||||
## Supported versions
|
||||
|
||||
Only Neo4j v3.5+ is [supported](https://github.com/neo4j/neo4j-go-driver/issues/64#issuecomment-625133600)
|
||||
@@ -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",
|
||||
"neo4j://neo4j:password@localhost:7687/")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := m.Up(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
+1
@@ -0,0 +1 @@
|
||||
DROP CONSTRAINT ON (m:Movie) ASSERT m.Name IS UNIQUE
|
||||
+1
@@ -0,0 +1 @@
|
||||
CREATE CONSTRAINT ON (m:Movie) ASSERT m.Name IS UNIQUE
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
MATCH (m:Movie)
|
||||
DELETE m
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
CREATE (:Movie {name: "Footloose"})
|
||||
CREATE (:Movie {name: "Ghost"})
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
CREATE (:Movie {name: "Hollow Man"});
|
||||
CREATE (:Movie {name: "Mystic River"});
|
||||
;;;
|
||||
@@ -0,0 +1,303 @@
|
||||
package neo4j
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
neturl "net/url"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4/database"
|
||||
"github.com/golang-migrate/migrate/v4/database/multistmt"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/neo4j/neo4j-go-driver/neo4j"
|
||||
)
|
||||
|
||||
func init() {
|
||||
db := Neo4j{}
|
||||
database.Register("neo4j", &db)
|
||||
}
|
||||
|
||||
const DefaultMigrationsLabel = "SchemaMigration"
|
||||
|
||||
var (
|
||||
StatementSeparator = []byte(";")
|
||||
DefaultMultiStatementMaxSize = 10 * 1 << 20 // 10 MB
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNilConfig = fmt.Errorf("no config")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
MigrationsLabel string
|
||||
MultiStatement bool
|
||||
MultiStatementMaxSize int
|
||||
}
|
||||
|
||||
type Neo4j struct {
|
||||
driver neo4j.Driver
|
||||
lock uint32
|
||||
|
||||
// Open and WithInstance need to guarantee that config is never nil
|
||||
config *Config
|
||||
}
|
||||
|
||||
func WithInstance(driver neo4j.Driver, config *Config) (database.Driver, error) {
|
||||
if config == nil {
|
||||
return nil, ErrNilConfig
|
||||
}
|
||||
|
||||
nDriver := &Neo4j{
|
||||
driver: driver,
|
||||
config: config,
|
||||
}
|
||||
|
||||
if err := nDriver.ensureVersionConstraint(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nDriver, 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
|
||||
uri.Scheme = "bolt"
|
||||
msQuery := uri.Query().Get("x-multi-statement")
|
||||
|
||||
// Whether to turn on/off TLS encryption.
|
||||
tlsEncrypted := uri.Query().Get("x-tls-encrypted")
|
||||
multi := false
|
||||
encrypted := false
|
||||
if msQuery != "" {
|
||||
multi, err = strconv.ParseBool(uri.Query().Get("x-multi-statement"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if tlsEncrypted != "" {
|
||||
encrypted, err = strconv.ParseBool(tlsEncrypted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
multiStatementMaxSize := DefaultMultiStatementMaxSize
|
||||
if s := uri.Query().Get("x-multi-statement-max-size"); s != "" {
|
||||
multiStatementMaxSize, err = strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
uri.RawQuery = ""
|
||||
|
||||
driver, err := neo4j.NewDriver(uri.String(), authToken, func(config *neo4j.Config) {
|
||||
config.Encrypted = encrypted
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return WithInstance(driver, &Config{
|
||||
MigrationsLabel: DefaultMigrationsLabel,
|
||||
MultiStatement: multi,
|
||||
MultiStatementMaxSize: multiStatementMaxSize,
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
if !atomic.CompareAndSwapUint32(&n.lock, 1, 0) {
|
||||
return database.ErrNotLocked
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Neo4j) Run(migration io.Reader) (err error) {
|
||||
session, err := n.driver.Session(neo4j.AccessModeWrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := session.Close(); cerr != nil {
|
||||
err = multierror.Append(err, cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
if n.config.MultiStatement {
|
||||
_, err = session.WriteTransaction(func(transaction neo4j.Transaction) (interface{}, error) {
|
||||
var stmtRunErr error
|
||||
if err := multistmt.Parse(migration, StatementSeparator, n.config.MultiStatementMaxSize, func(stmt []byte) bool {
|
||||
trimStmt := bytes.TrimSpace(stmt)
|
||||
if len(trimStmt) == 0 {
|
||||
return true
|
||||
}
|
||||
trimStmt = bytes.TrimSuffix(trimStmt, StatementSeparator)
|
||||
if len(trimStmt) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
result, err := transaction.Run(string(trimStmt), nil)
|
||||
if _, err := neo4j.Collect(result, err); err != nil {
|
||||
stmtRunErr = err
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, stmtRunErr
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(migration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = neo4j.Collect(session.Run(string(body[:]), nil))
|
||||
return err
|
||||
}
|
||||
|
||||
func (n *Neo4j) SetVersion(version int, dirty bool) (err error) {
|
||||
session, err := n.driver.Session(neo4j.AccessModeWrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := session.Close(); cerr != nil {
|
||||
err = multierror.Append(err, cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
query := fmt.Sprintf("MERGE (sm:%s {version: $version}) SET sm.dirty = $dirty, sm.ts = datetime()",
|
||||
n.config.MigrationsLabel)
|
||||
_, err = neo4j.Collect(session.Run(query, map[string]interface{}{"version": version, "dirty": dirty}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 database.NilVersion, false, err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := session.Close(); cerr != nil {
|
||||
err = multierror.Append(err, cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
query := fmt.Sprintf(`MATCH (sm:%s) RETURN sm.version AS version, sm.dirty AS dirty
|
||||
ORDER BY COALESCE(sm.ts, datetime({year: 0})) DESC, 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 = database.NilVersion
|
||||
} 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 database.NilVersion, false, err
|
||||
}
|
||||
if result == nil {
|
||||
return database.NilVersion, false, err
|
||||
}
|
||||
mr := result.(MigrationRecord)
|
||||
return mr.Version, mr.Dirty, err
|
||||
}
|
||||
|
||||
func (n *Neo4j) Drop() (err error) {
|
||||
session, err := n.driver.Session(neo4j.AccessModeWrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := session.Close(); cerr != nil {
|
||||
err = multierror.Append(err, cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := neo4j.Collect(session.Run("MATCH (n) DETACH DELETE n", nil)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Neo4j) ensureVersionConstraint() (err error) {
|
||||
session, err := n.driver.Session(neo4j.AccessModeWrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := session.Close(); cerr != nil {
|
||||
err = multierror.Append(err, cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
/**
|
||||
Get constraint and check to avoid error duplicate
|
||||
using db.labels() to support Neo4j 3 and 4.
|
||||
Neo4J 3 doesn't support db.constraints() YIELD name
|
||||
*/
|
||||
res, err := neo4j.Collect(session.Run(fmt.Sprintf("CALL db.labels() YIELD label WHERE label=\"%s\" RETURN label", n.config.MigrationsLabel), nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(res) == 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("CREATE CONSTRAINT ON (a:%s) ASSERT a.version IS UNIQUE", n.config.MigrationsLabel)
|
||||
if _, err := neo4j.Collect(session.Run(query, nil)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package neo4j
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"testing"
|
||||
|
||||
"github.com/dhui/dktest"
|
||||
"github.com/neo4j/neo4j-go-driver/neo4j"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
dt "github.com/golang-migrate/migrate/v4/database/testing"
|
||||
"github.com/golang-migrate/migrate/v4/dktesting"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
)
|
||||
|
||||
var (
|
||||
opts = dktest.Options{PortRequired: true, ReadyFunc: isReady,
|
||||
Env: map[string]string{"NEO4J_AUTH": "neo4j/migratetest", "NEO4J_ACCEPT_LICENSE_AGREEMENT": "yes"}}
|
||||
specs = []dktesting.ContainerSpec{
|
||||
{ImageName: "neo4j:4.0", Options: opts},
|
||||
{ImageName: "neo4j:4.0-enterprise", Options: opts},
|
||||
{ImageName: "neo4j:3.5", Options: opts},
|
||||
{ImageName: "neo4j:3.5-enterprise", Options: opts},
|
||||
}
|
||||
)
|
||||
|
||||
func neoConnectionString(host, port string) string {
|
||||
return fmt.Sprintf("bolt://neo4j:migratetest@%s:%s", host, port)
|
||||
}
|
||||
|
||||
func isReady(ctx context.Context, c dktest.ContainerInfo) bool {
|
||||
ip, port, err := c.Port(7687)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
driver, err := neo4j.NewDriver(
|
||||
neoConnectionString(ip, port),
|
||||
neo4j.BasicAuth("neo4j", "migratetest", ""),
|
||||
func(config *neo4j.Config) {
|
||||
config.Encrypted = false
|
||||
})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
if err := driver.Close(); err != nil {
|
||||
log.Println("close error:", err)
|
||||
}
|
||||
}()
|
||||
session, err := driver.Session(neo4j.AccessModeRead)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
result, err := session.Run("RETURN 1", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
} else if result.Err() != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func Test(t *testing.T) {
|
||||
dktesting.ParallelTest(t, specs, func(t *testing.T, c dktest.ContainerInfo) {
|
||||
ip, port, err := c.Port(7687)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n := &Neo4j{}
|
||||
d, err := n.Open(neoConnectionString(ip, port))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := d.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
dt.Test(t, d, []byte("MATCH (a) RETURN a"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMigrate(t *testing.T) {
|
||||
dktesting.ParallelTest(t, specs, func(t *testing.T, c dktest.ContainerInfo) {
|
||||
ip, port, err := c.Port(7687)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n := &Neo4j{}
|
||||
neoUrl := neoConnectionString(ip, port) + "/?x-multi-statement=true"
|
||||
d, err := n.Open(neoUrl)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := d.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
m, err := migrate.NewWithDatabaseInstance("file://./examples/migrations", "neo4j", d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dt.TestMigrate(t, m)
|
||||
})
|
||||
}
|
||||
|
||||
func TestMalformed(t *testing.T) {
|
||||
dktesting.ParallelTest(t, specs, func(t *testing.T, c dktest.ContainerInfo) {
|
||||
ip, port, err := c.Port(7687)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n := &Neo4j{}
|
||||
d, err := n.Open(neoConnectionString(ip, port))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := d.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
|
||||
migration := bytes.NewReader([]byte("CREATE (a {qid: 1) RETURN a"))
|
||||
if err := d.Run(migration); err == nil {
|
||||
t.Fatal("expected failure for malformed migration")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user