/aspect

A relational database toolkit for Go

Primary LanguageGoMIT LicenseMIT

Aspect GoDoc Build Status

A relational database toolkit for Go:

  • Build complete database schemas
  • Create reusable and cross-dialect SQL statements
  • Allow struct instances and slices to be directly populated by the database

Quickstart

package main

import (
    "log"

    sql "github.com/aodin/aspect"
    _ "github.com/aodin/aspect/sqlite3"
)

// Create a database schema using aspect's Table function
var Users = sql.Table("users",
    sql.Column("id", sql.Integer{NotNull: true}),
    sql.Column("name", sql.String{Length: 32, NotNull: true}),
    sql.Column("password", sql.String{Length: 128}),
    sql.PrimaryKey("id"),
)

// Structs are used to send and receive values to the database
type User struct {
    ID       int64  `db:"id"`
    Name     string `db:"name"`
    Password string `db:"password"`
}

func main() {
    // Connect to an in-memory sqlite3 instance
    conn, err := sql.Connect("sqlite3", ":memory:")
    if err != nil {
        log.Panic(err)
    }
    defer conn.Close()

    // Create the users table
    conn.MustExecute(Users.Create())

    // Insert a user - they can be inserted by value or reference
    admin := User{ID: 1, Name: "admin", Password: "secret"}
    conn.MustExecute(Users.Insert().Values(admin))

    // Select a user - query methods must be given a pointer
    var user User
    conn.MustQueryOne(Users.Select(), &user)
    log.Println(user)
}

Statements

Don't forget to import aspect and at least one driver. I alias the aspect package to sql:

import (
    sql "github.com/aodin/aspect"
    _ "github.com/aodin/aspect/postgres"
    _ "github.com/aodin/aspect/sqlite3"
)

Statements that do not return selections can be run with the Execute method of database connections DB or transactions TX. Both also implement the interface Connection.

A successful Connect will return a database connection pool ready for use. Its Execute method returns an instance of database/sql package's Result and an error if one occurred:

conn, err := sql.Connect("sqlite3", ":memory:")
if err != nil {
    log.Panic(err)
}
defer conn.Close()

result, err := conn.Execute(Users.Create())

Results are often ignored, as in the Quickstart example above.

The following commands are usually used with the Execute method:

CREATE TABLE

Once a schema has been specified with Table, such as:

var Users = sql.Table("users",
    sql.Column("id", sql.Integer{NotNull: true}),
    sql.Column("name", sql.String{Length: 32, NotNull: true}),
    sql.Column("password", sql.String{Length: 128}),
    sql.PrimaryKey("id"),
)

A CREATE TABLE statement can be created with:

Users.Create()

And will output the following SQL with its String() method (a dialect neutral version) or with conn.String() (a dialect specific version):

CREATE TABLE "users" (
  "id" INTEGER NOT NULL,
  "name" VARCHAR(32) NOT NULL,
  "password" VARCHAR(128),
  PRIMARY KEY ("id")
);

DROP TABLE

Using the Users schema, a DROP TABLE statement can be created with:

Users.Drop()
DROP TABLE "users"

INSERT

Insert statements can be created without specifying values. For instance, the method Insert() on a schema such as Users can be created with:

Users.Insert()

And produces the SQL (in this example for the sqlite3 dialect):

INSERT INTO "users" ("id", "name", "password") VALUES (?, ?, ?)

Values can be inserted to the database using structs or aspect.Values instances. If given a struct, Aspect first attempts to match field names or db tags. Columns without matching values will be dropped. The following struct and chained function:

type user struct {
    Name     string `db:"name"`
    Password string `db:"password"`
    Extra    string
    manager  *manager
}
Users.Insert().Values(user{Name: "Totti", Password: "GOAL"})
INSERT INTO "users" ("name", "password") VALUES (?, ?)

Fields must be exported (i.e. start with an uppercase character) to work with Aspect. Unexported fields and those that do not match column names will be ignored.

Structs without db tags or fields that match column names can be inserted, but only if the number of exported fields equals the number of columns being inserted.

Slices of structs can also be inserted:

users := []user{
    {Name: "Howard", Password: "DENIED"},
    {Name: "Beckham", Password: "RETIRED"},
}

And would produce the following (note: this syntax for multiple inserts is only valid in later versions of sqlite3):

INSERT INTO "users" ("name", "password") VALUES (?, ?), (?, ?)

To manually specify which columns should be inserted, use Aspect's Insert function, rather than the table method:

sql.Insert(Users.C["name"]).Values(users)
INSERT INTO "users" ("name") VALUES (?), (?)

Values can also be inserted using the map type Values or a slice of them:

Users.Insert().Values(sql.Values{"name": "Ronaldo"})
INSERT INTO "users" ("name") VALUES (?)

Keys in Values maps must match column names or the statement will error.

UPDATE

Rows in a table can be updated using either of:

Users.Update()
sql.Update(Users)

Both will produce the same output:

UPDATE "users" SET "id" = ?, "name" = ?, "password" = ?

Columns are set according to the values given. Values keys that do not match a column will error.

Users.Update().Values(sql.Values{"name": "Ronaldo", "password": "STRIKER"})
UPDATE "users" SET "name" = ?, "password" = ?

Conditionals can be specified with the Where() method:

Users.Update().Values(
    sql.Values{"password": "FIFA2014"},
).Where(Users.C["name"].Equals("Ronaldo"))
UPDATE "users" SET "password" = ? WHERE "users"."name" = ?

DELETE

If you want to delete all the rows in a table:

Users.Delete()
DELETE FROM "users"

A conditional delete can be created with Where():

Users.Delete().Where(Users.C["name"].Equals("Ronaldo"))
DELETE FROM "users" WHERE "users"."name" = ?

If the schema has a single column primary key specified, deletes can be performed with structs:

Users.Delete().Values(user{ID: 1})
DELETE FROM "users" WHERE "users"."id" = ?

Or slices of structs:

Users.Delete().Values([]user{{ID: 1}, {ID: 2}})
DELETE FROM "users" WHERE "users"."id" IN (?, ?)

SELECT

Results can be queried in a number of ways. Each of the following statements will produce the same SQL output:

Users.Select()
sql.Select(Users)
sql.Select(Users.C["id"], Users.C["name"], Users.C["password"])
SELECT "users"."id", "users"."name", "users"."password" FROM "users"

Results can be returned directly into structs:

var users []User
conn.MustQueryAll(Users.Select(), &users)
log.Println(users)
// [1: admin 2: client 3: daemon]

It's okay to have a destination struct larger than the expected results (vice-versa!), as long as the struct has db tags:

type username struct {
    Name    string `db:"name"`
    manager *manager
}

var usernames []username
conn.MustQueryAll(sql.Select(Users.C["name"]), &usernames)

Single column queries can be returned into slice types:

stmt := sql.Select(Users.C["id"]).OrderBy(Users.C["id"].Desc())
SELECT "users"."id" FROM "users" ORDER BY "users"."id" DESC
var ids []int64
conn.MustQueryAll(s, &ids)
log.Println(ids)
// [3, 2, 1]

Single results can also be queried into instantiated instances of Values, which is included with the package:

result := sql.Values{}
conn.MustQueryOne(Users.Select(), result)

Or multiple results with a pointer to a slice of Values:

var results []sql.Values
conn.QueryAll(Users.Select(), &results)

A warning, however: string results are added to the map as []byte types. Since this is done internally by the database/sql standard library package, this is unlikely to change in the future.

map[id:1 name:[84 111 116 116 105] password:[71 79 65 76]]

To get string output, simply cast the value to a string after asserting that it is a []byte:

log.Println(string(result["name"].([]byte)))
// Totti

Schema

More advanced schemas can be created with foreign keys, unique constraints, and composite primary keys:

var Posts = sql.Table("posts",
    sql.ForeignKey("uid", Users.C["id"], sql.BigInt{}).OnDelete(sql.Cascade),
    sql.Column("name", sql.String{Length: 32, NotNull: true}),
    sql.Column("is_published", sql.Boolean{Default: sql.True}),
    sql.Unique("name"),
    sql.PrimaryKey("uid", "name"),
)
CREATE TABLE "posts" (
  "uid" BIGINT REFERENCES users("id") ON DELETE CASCADE,
  "name" VARCHAR(32) NOT NULL,
  "is_published" BOOLEAN DEFAULT TRUE,
  UNIQUE ("name"),
  PRIMARY KEY ("uid", "name")
);

Development

To perform tests, copy the db.example.json file into each driver package requiring credentials as db.json (currently required only by the postgres package) and set it for your local configuration.

All compilable statements implement a String method for dialect-neutral logging and the Compile method for building dialect-specific and parameterized output:

type Compiles interface {
    String() string
    Compile(Dialect, *Parameters) (string, error)
}

Statements and clauses can be tested by creating a new dialect-specific tester; for example using the postgres package:

expect := NewTester(t, &postgres.PostGres{})

The instance's SQL method will test expected output and parameterization:

expect.SQL(`DELETE FROM "users"`, users.Delete())

expect.SQL(
    `INSERT INTO "users" ("name") VALUES ($1), ($2)`,
    users.Insert().Values([]sql.Values{{"name": "Totti"}, {"name": "De Rossi"}}),
    "Totti",
    "De Rossi",
)

And the Error method will test that an error occurred:

expect.Error(sql.Select(users.C["does-not-exist"]))

Happy Hacking!

aodin, 2014-15

Death and Light are everywhere, always, and they begin, end, strive, attend, into and upon the Dream of the Nameless that is the world, burning words within Samsara, perhaps to create a thing of beauty.

Lord of Light by Roger Zelazny