Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

28 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Neo4j Query API Go SDK

Overview

A Go client for the Neo4j Query API. Execute Cypher queries against Neo4j databases using plain HTTP — no Bolt protocol, no driver, no binary dependencies. The SDK mirrors the transformer pattern from the official Neo4j Go driver, so switching between them is straightforward.

result, err := query.WithTransformer(client.Query, ctx,
    "MATCH (n:Person) RETURN n.name AS name", nil,
    query.EagerResultTransformer)

Table of Contents


Installation

go get github.com/neo4j-contrib/query-go-sdk

Quick Start

package main

import (
    "context"
    "fmt"
    "log"
    "time"

    query "github.com/neo4j-contrib/query-go-sdk"
)

func main() {
    client, err := query.NewClient(
        query.WithBasicAuth("neo4j", "password"),
        query.WithBaseURL("http://localhost:7474"),
        query.WithTimeout(30*time.Second),
    )
    if err != nil {
        log.Fatalf("Failed to create client: %v", err)
    }
    defer client.Close()

    ctx := context.Background()

    result, err := query.WithTransformer(
        client.Query,
        ctx,
        "MATCH (n:Person) RETURN n.name AS name LIMIT 10",
        nil,
        query.EagerResultTransformer,
    )
    if err != nil {
        log.Fatalf("Query failed: %v", err)
    }

    for _, rec := range result.Records {
        name, _ := rec.GetString("name")
        fmt.Printf("Name: %s\n", name)
    }
}

Authentication

Authentication is required and mutually exclusive — pass either WithBasicAuth or WithBearerToken, not both.

Basic authentication

client, err := query.NewClient(
    query.WithBasicAuth("neo4j", "your-password"),
)

Bearer token

client, err := query.NewClient(
    query.WithBearerToken("your-bearer-token"),
)

Configuration

Option Default Description
WithBaseURL(url) http://localhost:7474 Base URL of the Neo4j server
WithDatabase(db) Target database name
WithTimeout(d) 120s Per-request timeout
WithMaxRetry(n) 3 Maximum retry attempts on transient failure
WithLogger(l) warn to stderr Structured *slog.Logger
WithHTTPClient(c) default transport Custom *http.Client (for mTLS, proxies, etc.)
WithUserAgent(ua) query-go-sdk/<version> Value of the User-Agent header
WithMaxResponseSize(n) 10MB Maximum response body size in bytes; returns an error if exceeded
WithDefaultHeaders(map) Extra headers sent with every request; Authorization, Content-Type, Accept, and User-Agent are protected and cannot be overridden
WithAPIFlavor(f) FlavorQueryV2 Select which HTTP API endpoint to target; see API Flavors
WithAccessMode(m) AccessModeWrite Set the access mode header on every request; see Access Mode
client, err := query.NewClient(
    query.WithBasicAuth("neo4j", "password"),
    query.WithBaseURL("http://neo4j.example.com:7474"),
    query.WithDatabase("mydb"),
    query.WithTimeout(60*time.Second),
    query.WithMaxRetry(5),
    query.WithDefaultHeaders(map[string]string{
        "X-Request-Source": "my-service",
    }),
)

API Flavors

The SDK supports two Neo4j HTTP APIs, selectable via WithAPIFlavor:

Constant Endpoint Response format Default
query.FlavorQueryV2 /db/{db}/query/v2 Typed JSON ($type envelopes) yes
query.FlavorLegacyHTTP /db/{db}/tx/commit Plain JSON row format

FlavorQueryV2 is the default and requires no explicit configuration.

Targeting the legacy HTTP API

Use FlavorLegacyHTTP to send queries to the older Cypher HTTP Transaction API. This is useful for performance comparison testing or for connecting to Neo4j versions that pre-date the Query API.

client, err := query.NewClient(
    query.WithBasicAuth("neo4j", "password"),
    query.WithBaseURL("http://localhost:7474"),
    query.WithAPIFlavor(query.FlavorLegacyHTTP),
)

Everything else — transformers, records, error handling — works identically for both flavors.

Type mapping differences

The legacy API returns row values as plain JSON without typed envelopes. Scalars (string, int64, float64, bool, nil) decode identically to FlavorQueryV2. However, graph entities are returned as plain map[string]any instead of *query.Node / *query.Relationship, because the /tx/commit endpoint does not include element IDs or labels in the row format. rec.GetNode and rec.GetRelationship will return (nil, false) for those values.


Access Mode

The WithAccessMode option controls the access mode header sent with every request. This hints to the server whether the operation requires write access or can be served by a read replica.

Constant Header sent Value
AccessModeWrite (default) accessMode (Query API) / Access-Mode (Legacy HTTP API) write / WRITE
AccessModeRead accessMode (Query API) / Access-Mode (Legacy HTTP API) read / READ
// Read-only workload — may be routed to a read replica
client, err := query.NewClient(
    query.WithBasicAuth("neo4j", "password"),
    query.WithBaseURL("http://localhost:7474"),
    query.WithAccessMode(query.AccessModeRead),
)
// Write workload — default, no explicit option needed
client, err := query.NewClient(
    query.WithBasicAuth("neo4j", "password"),
    query.WithBaseURL("http://localhost:7474"),
    query.WithAccessMode(query.AccessModeWrite),
)

Context and Timeouts

Every operation accepts a context.Context as its first argument. The service applies its own timeout via context.WithTimeout on every call — whichever deadline is shorter (parent or service) wins.

// Per-call deadline overrides the service timeout when shorter
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

resp, err := client.Query.Execute(ctx, "MATCH (n) RETURN count(n)", nil)
// Cancellation propagates through to in-flight HTTP requests
ctx, cancel := context.WithCancel(context.Background())
go func() { <-shutdown; cancel() }()

_, err := client.Query.Execute(ctx, "MATCH (n) RETURN n", nil)
if errors.Is(err, context.Canceled) {
    log.Println("query cancelled")
}

Executing Queries

Direct execution

client.Query.Execute returns a *query.Response containing the raw decoded result.

resp, err := client.Query.Execute(ctx, "RETURN 1 AS n", nil)
if err != nil {
    log.Fatal(err)
}

fmt.Println(resp.Fields)    // ["n"]
fmt.Println(resp.Rows)      // [[int64(1)]]
fmt.Println(resp.Bookmarks)

query.Response fields:

Field Type Description
Fields []string Ordered column names
Rows [][]any Decoded row values (see type mapping below)
Notifications []query.Notification Advisory messages from Neo4j
Bookmarks []string Transaction bookmarks
QueryPlan *query.PlanOperator Non-nil for EXPLAIN/PROFILE queries

Passing parameters

params := map[string]any{"name": "Alice", "age": 30}
resp, err := client.Query.Execute(ctx,
    "MATCH (n:Person {name: $name}) WHERE n.age >= $age RETURN n.name",
    params)

Transformers

The transformer pattern mirrors the Neo4j Go driver's neo4j.ExecuteQuery. A transformer is a function that converts *query.Response into a typed Go value:

type ResultTransformer[T any] func(*query.Response) (T, error)

Three built-in transformers cover the common cases. You can also write your own — see Custom transformers.

result, err := query.WithTransformer(svc, ctx, cypher, params, transformer)

EagerResultTransformer

Collects all rows into a *query.EagerResult. Use for typical queries where the full result set fits in memory.

result, err := query.WithTransformer(
    client.Query, ctx,
    "MATCH (n:Person) RETURN n.name AS name, n.age AS age",
    nil,
    query.EagerResultTransformer,
)
if err != nil {
    log.Fatal(err)
}

fmt.Println(result.Keys)          // ["name", "age"]
fmt.Println(len(result.Records))  // number of rows

for _, rec := range result.Records {
    name, _ := rec.GetString("name")
    age, _  := rec.GetInt64("age")
    fmt.Printf("%s is %d\n", name, age)
}

EagerResult also exposes HasWarnings() and Warnings() for advisory notifications.

Collect

Maps each row to a value using a function, returning []T.

type Person struct {
    Name string
    Age  int64
}

people, err := query.WithTransformer(
    client.Query, ctx,
    "MATCH (n:Person) RETURN n.name AS name, n.age AS age",
    nil,
    query.Collect(func(rec *query.Record) (Person, error) {
        name, _ := rec.GetString("name")
        age, _  := rec.GetInt64("age")
        return Person{Name: name, Age: age}, nil
    }),
)

Single

Expects exactly one row; returns an error if zero or more than one row is returned.

type Movie struct {
    Title    string
    Released int64
}

movie, err := query.WithTransformer(
    client.Query, ctx,
    "MATCH (m:Movie {title: $title}) RETURN m.title AS title, m.released AS released",
    map[string]any{"title": "The Matrix"},
    query.Single(func(rec *query.Record) (Movie, error) {
        title, _    := rec.GetString("title")
        released, _ := rec.GetInt64("released")
        return Movie{Title: title, Released: released}, nil
    }),
)

Custom transformers

Because *query.Response is part of the public API, you can write your own ResultTransformer for cases that EagerResultTransformer, Collect, and Single don't cover.

// A transformer that extracts a single column as a flat []string
func firstColumnStrings(resp *query.Response) ([]string, error) {
    out := make([]string, 0, len(resp.Rows))
    for _, row := range resp.Rows {
        if len(row) == 0 {
            continue
        }
        s, ok := row[0].(string)
        if !ok {
            return nil, fmt.Errorf("expected string, got %T", row[0])
        }
        out = append(out, s)
    }
    return out, nil
}

names, err := query.WithTransformer(
    client.Query, ctx,
    "MATCH (n:Person) RETURN n.name",
    nil,
    query.ResultTransformer[[]string](firstColumnStrings),
)

Custom transformers work with any named function whose signature matches func(*query.Response) (T, error).


Working with Records

*query.Record provides named field access that mirrors the Neo4j Go driver's neo4j.Record.

Typed accessors

s, ok    := rec.GetString("name")          // string
n, ok    := rec.GetInt64("count")          // int64
f, ok    := rec.GetFloat64("score")        // float64
b, ok    := rec.GetBool("active")          // bool
bs, ok   := rec.GetBytes("data")           // []byte
t, ok    := rec.GetTime("createdAt")       // time.Time
d, ok    := rec.GetDuration("lifespan")    // query.Duration
p, ok    := rec.GetPoint("location")       // query.Point
node, ok := rec.GetNode("n")               // *query.Node
rel, ok  := rec.GetRelationship("r")       // *query.Relationship
path, ok := rec.GetPath("p")               // query.Path
list, ok := rec.GetList("tags")            // []any
m, ok    := rec.GetMap("props")            // map[string]any

All accessors return (zero, false) when the field is absent or null.

Graph entity types

query.Node, query.Relationship, and query.Path are part of the public API — you can use them in your own function signatures:

func nodeLabel(n *query.Node) string {
    if len(n.Labels) > 0 {
        return n.Labels[0]
    }
    return ""
}

func relSummary(r *query.Relationship) string {
    return fmt.Sprintf("[%s]", r.Type)
}

query.Node fields: ElementID string, Labels []string, Properties map[string]any.
query.Relationship fields: ElementID, StartNodeElementID, EndNodeElementID string, Type string, Properties map[string]any.

Neo4j → Go type mapping

Neo4j type Go type
Null nil
Boolean bool
Integer int64
Float float64
String string
ByteArray []byte
List []any
Map map[string]any
Date / Time / DateTime / LocalTime / LocalDateTime time.Time
Duration query.Duration
Point (2D / 3D) query.Point
Node *query.Node
Relationship *query.Relationship
Path query.Path

Generic accessor

val, ok := rec.Get("fieldName")  // returns (any, bool)

Other helpers

rec.Keys()           // []string — ordered field names
rec.Values()         // []any   — ordered decoded values
rec.AsMap()          // map[string]any
rec.IsNull("field")  // true if field present and null

List helpers

rawList, _ := rec.GetList("tags")
tags, ok   := query.StringList(rawList)   // []string
counts, ok := query.Int64List(rawList)    // []int64
scores, ok := query.Float64List(rawList)  // []float64

Error Handling

HTTP errors — *query.Error

Returned when the Neo4j server responds with a non-2xx status code.

resp, err := client.Query.Execute(ctx, cypher, params)
if err != nil {
    var apiErr *query.Error
    if errors.As(err, &apiErr) {
        fmt.Printf("HTTP %d: %s\n", apiErr.StatusCode, apiErr.Message)

        switch {
        case apiErr.IsUnauthorized():
            log.Fatal("check credentials")
        case apiErr.IsNotFound():
            log.Fatal("endpoint not found")
        case apiErr.IsBadRequest():
            log.Fatal("bad request")
        }
    }
}

query.IsNotFound(err) is a convenience helper that unwraps the error chain.

Neo4j query errors — *query.QueryErrors

Returned when Neo4j executes the request but reports one or more Cypher errors (syntax errors, constraint violations, etc.).

resp, err := client.Query.Execute(ctx, "INVALID CYPHER", nil)
if err != nil {
    var qErr *query.QueryErrors
    if errors.As(err, &qErr) {
        for _, e := range qErr.Errors {
            fmt.Printf("[%s] %s: %s\n", e.Classification(), e.Title(), e.Message)
        }
    }
}

QueryError helper methods: Classification() (e.g. ClientError), Category() (e.g. Statement), Title() (e.g. SyntaxError).

Context errors

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

_, err := client.Query.Execute(ctx, cypher, nil)
if errors.Is(err, context.DeadlineExceeded) {
    log.Println("query timed out")
}
if errors.Is(err, context.Canceled) {
    log.Println("query cancelled")
}

Best Practices

Secure credentials

user := os.Getenv("NEO4J_USERNAME")
pass := os.Getenv("NEO4J_PASSWORD")

client, err := query.NewClient(
    query.WithBasicAuth(user, pass),
    query.WithBaseURL(os.Getenv("NEO4J_URL")),
)

Context management

Always pass a context with an appropriate deadline or cancellation. Use defer client.Close() to release idle connections when the client is no longer needed.

Error classification

Use errors.As to branch on *query.Error (HTTP-level) versus *query.QueryErrors (Cypher-level). Transient server errors (5xx) may be worth retrying; client errors (4xx) generally should not be.


CI & Releases

Three GitHub Actions workflows manage CI and the release process.

Workflows

Workflow Trigger What it does
CI Push to main, every PR Runs tests with the race detector, golangci-lint, and go build ./...
Changelog check Every PR Fails if the PR changes .go files but has no entry in .changes/unreleased/
Release Push of a vX.Y.Z tag Gates on tests, extracts the changelog section, creates a GitHub Release

Making a release

Releases follow a three-step process. changie collects the unreleased fragment files and determines the correct semver bump automatically from the change kinds (Added → minor, Fixed/Security → patch, Changed/Removed → major).

There is no manual version bump required. ClientVersion uses debug.ReadBuildInfo() at runtime to read the module version that the Go toolchain embeds when a consumer builds their application. It falls back to "development" only in local and test builds.

1. Batch and merge the changelog

changie batch   # collects .changes/unreleased/*.yaml → .changes/vX.Y.Z.md
changie merge   # folds that file into CHANGELOG.md

2. Commit and tag

git add CHANGELOG.md .changes/
git commit -m "chore: release vX.Y.Z"
git tag vX.Y.Z
git push origin main --tags

3. Workflow takes over

Pushing the tag fires the Release workflow, which:

  • Runs go test -race ./... — the release is aborted if any test fails
  • Extracts the ## vX.Y.Z section from CHANGELOG.md
  • Creates a GitHub Release with that text as the release notes

Because this is a Go module with no compiled binaries, the tag itself is what consumers reference:

go get github.com/neo4j-contrib/query-go-sdk@vX.Y.Z

Adding a changelog entry

Every PR that changes Go source files needs a changie fragment. Run:

changie new

Choose a kind and write a one-line summary, then commit the generated .yaml file alongside your code changes. The Changelog check workflow will fail the PR if this step is skipped.

To bypass the check for a PR that genuinely needs no entry (docs-only, CI-only, or test-only changes), add the no-changelog label to the PR.


License

See LICENSE file for details.

About

A Go SDK for Neo4j Query API

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages