mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
chore: Initial database scaffolding (#2)
* chore: Initial database scaffolding This implements migrations and code generation for interfacing with a PostgreSQL database. A dependency is added for the "postgres" binary on the host, but that seems like an acceptable requirement considering it's our primary database. An in-memory database object can be created for simple cross-OS and fast testing. * Run tests in CI * Use Docker instead of binaries on the host * Skip database tests on non-Linux operating systems * chore: Add golangci-lint and codecov * Use consistent file names
This commit is contained in:
75
database/db.go
Normal file
75
database/db.go
Normal file
@ -0,0 +1,75 @@
|
||||
// Package database connects to external services for stateful storage.
|
||||
//
|
||||
// Query functions are generated using sqlc.
|
||||
//
|
||||
// To modify the database schema:
|
||||
// 1. Add a new migration using "create_migration.sh" in database/migrations/
|
||||
// 2. Run "make database/generate" in the root to generate models.
|
||||
// 3. Add/Edit queries in "query.sql" and run "make database/generate" to create Go code.
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// Store contains all queryable database functions.
|
||||
// It extends the generated interface to add transaction support.
|
||||
type Store interface {
|
||||
querier
|
||||
|
||||
InTx(context.Context, func(Store) error) error
|
||||
}
|
||||
|
||||
// DBTX represents a database connection or transaction.
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
// New creates a new database store using a SQL database connection.
|
||||
func New(sdb *sql.DB) Store {
|
||||
return &sqlQuerier{
|
||||
db: sdb,
|
||||
sdb: sdb,
|
||||
}
|
||||
}
|
||||
|
||||
type sqlQuerier struct {
|
||||
sdb *sql.DB
|
||||
db DBTX
|
||||
}
|
||||
|
||||
// InTx performs database operations inside a transaction.
|
||||
func (q *sqlQuerier) InTx(ctx context.Context, fn func(Store) error) error {
|
||||
if q.sdb == nil {
|
||||
return nil
|
||||
}
|
||||
tx, err := q.sdb.Begin()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
rerr := tx.Rollback()
|
||||
if rerr == nil || errors.Is(rerr, sql.ErrTxDone) {
|
||||
// no need to do anything, tx committed successfully
|
||||
return
|
||||
}
|
||||
// couldn't roll back for some reason, extend returned error
|
||||
err = xerrors.Errorf("defer (%s): %w", rerr.Error(), err)
|
||||
}()
|
||||
err = fn(&sqlQuerier{db: tx})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("execute transaction: %w", err)
|
||||
}
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
19
database/db_memory.go
Normal file
19
database/db_memory.go
Normal file
@ -0,0 +1,19 @@
|
||||
package database
|
||||
|
||||
import "context"
|
||||
|
||||
// NewInMemory returns an in-memory store of the database.
|
||||
func NewInMemory() Store {
|
||||
return &memoryQuerier{}
|
||||
}
|
||||
|
||||
type memoryQuerier struct{}
|
||||
|
||||
// InTx doesn't rollback data properly for in-memory yet.
|
||||
func (q *memoryQuerier) InTx(ctx context.Context, fn func(Store) error) error {
|
||||
return fn(q)
|
||||
}
|
||||
|
||||
func (q *memoryQuerier) ExampleQuery(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
2
database/dump.sql
Normal file
2
database/dump.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- Code generated by 'make database/generate'. DO NOT EDIT.
|
||||
|
89
database/dump/main.go
Normal file
89
database/dump/main.go
Normal file
@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/coder/coder/database"
|
||||
"github.com/coder/coder/database/postgres"
|
||||
)
|
||||
|
||||
func main() {
|
||||
connection, closeFn, err := postgres.Open()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer closeFn()
|
||||
db, err := sql.Open("postgres", connection)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = database.Migrate(context.Background(), "postgres", db)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
cmd := exec.Command(
|
||||
"pg_dump",
|
||||
"--schema-only",
|
||||
connection,
|
||||
"--no-privileges",
|
||||
"--no-owner",
|
||||
"--no-comments",
|
||||
|
||||
// We never want to manually generate
|
||||
// queries executing against this table.
|
||||
"--exclude-table=schema_migrations",
|
||||
)
|
||||
cmd.Env = []string{
|
||||
"PGTZ=UTC",
|
||||
"PGCLIENTENCODING=UTF8",
|
||||
}
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = os.Stderr
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
for _, sed := range []string{
|
||||
// Remove all comments.
|
||||
"/^--/d",
|
||||
// Public is implicit in the schema.
|
||||
"s/ public\\./ /",
|
||||
// Remove database settings.
|
||||
"s/SET.*;//g",
|
||||
// Remove select statements. These aren't useful
|
||||
// to a reader of the dump.
|
||||
"s/SELECT.*;//g",
|
||||
// Removes multiple newlines.
|
||||
"/^$/N;/^\\n$/D",
|
||||
} {
|
||||
cmd := exec.Command("sed", "-e", sed)
|
||||
cmd.Stdin = bytes.NewReader(output.Bytes())
|
||||
output = bytes.Buffer{}
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = os.Stderr
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
dump := fmt.Sprintf("-- Code generated by 'make database/generate'. DO NOT EDIT.\n%s", output.Bytes())
|
||||
_, mainPath, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
panic("couldn't get caller path")
|
||||
}
|
||||
err = ioutil.WriteFile(filepath.Join(mainPath, "..", "..", "dump.sql"), []byte(dump), 0644)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
48
database/migrate.go
Normal file
48
database/migrate.go
Normal file
@ -0,0 +1,48 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrations embed.FS
|
||||
|
||||
// Migrate runs SQL migrations to ensure the database schema is up-to-date.
|
||||
func Migrate(ctx context.Context, dbName string, db *sql.DB) error {
|
||||
sourceDriver, err := iofs.New(migrations, "migrations")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create iofs: %w", err)
|
||||
}
|
||||
dbDriver, err := postgres.WithInstance(db, &postgres.Config{})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("wrap postgres connection: %w", err)
|
||||
}
|
||||
m, err := migrate.NewWithInstance("", sourceDriver, dbName, dbDriver)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("migrate: %w", err)
|
||||
}
|
||||
err = m.Up()
|
||||
if err != nil {
|
||||
if errors.Is(err, migrate.ErrNoChange) {
|
||||
// It's OK if no changes happened!
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("up: %w", err)
|
||||
}
|
||||
srcErr, dbErr := m.Close()
|
||||
if srcErr != nil {
|
||||
return xerrors.Errorf("close source: %w", err)
|
||||
}
|
||||
if dbErr != nil {
|
||||
return xerrors.Errorf("close database: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
30
database/migrate_test.go
Normal file
30
database/migrate_test.go
Normal file
@ -0,0 +1,30 @@
|
||||
//go:build linux
|
||||
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/coder/coder/database"
|
||||
"github.com/coder/coder/database/postgres"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
|
||||
func TestMigrate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
connection, closeFn, err := postgres.Open()
|
||||
require.NoError(t, err)
|
||||
defer closeFn()
|
||||
db, err := sql.Open("postgres", connection)
|
||||
require.NoError(t, err)
|
||||
err = database.Migrate(context.Background(), "postgres", db)
|
||||
require.NoError(t, err)
|
||||
}
|
0
database/migrations/000001_base.down.sql
Normal file
0
database/migrations/000001_base.down.sql
Normal file
0
database/migrations/000001_base.up.sql
Normal file
0
database/migrations/000001_base.up.sql
Normal file
11
database/migrations/create_migration.sh
Executable file
11
database/migrations/create_migration.sh
Executable file
@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "First argument is the migration name!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
migrate create -ext sql -dir . -seq $1
|
||||
|
||||
echo "After making adjustments, run \"make database/generate\" to generate models."
|
5
database/models.go
Normal file
5
database/models.go
Normal file
@ -0,0 +1,5 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
|
||||
package database
|
||||
|
||||
import ()
|
59
database/postgres/postgres.go
Normal file
59
database/postgres/postgres.go
Normal file
@ -0,0 +1,59 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/ory/dockertest/v3"
|
||||
"github.com/ory/dockertest/v3/docker"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// Open creates a new PostgreSQL server using a Docker container.
|
||||
func Open() (string, func(), error) {
|
||||
pool, err := dockertest.NewPool("")
|
||||
if err != nil {
|
||||
return "", nil, xerrors.Errorf("create pool: %w", err)
|
||||
}
|
||||
resource, err := pool.RunWithOptions(&dockertest.RunOptions{
|
||||
Repository: "postgres",
|
||||
Tag: "11",
|
||||
Env: []string{
|
||||
"POSTGRES_PASSWORD=postgres",
|
||||
"POSTGRES_USER=postgres",
|
||||
"POSTGRES_DB=postgres",
|
||||
"listen_addresses = '*'",
|
||||
},
|
||||
}, func(config *docker.HostConfig) {
|
||||
// set AutoRemove to true so that stopped container goes away by itself
|
||||
config.AutoRemove = true
|
||||
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Could not start resource: %s", err)
|
||||
}
|
||||
hostAndPort := resource.GetHostPort("5432/tcp")
|
||||
dbURL := fmt.Sprintf("postgres://postgres:postgres@%s/postgres?sslmode=disable", hostAndPort)
|
||||
|
||||
// Docker should hard-kill the container after 120 seconds.
|
||||
resource.Expire(120)
|
||||
|
||||
pool.MaxWait = 120 * time.Second
|
||||
err = pool.Retry(func() error {
|
||||
db, err := sql.Open("postgres", dbURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.Ping()
|
||||
_ = db.Close()
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return dbURL, func() {
|
||||
_ = pool.Purge(resource)
|
||||
}, nil
|
||||
}
|
32
database/postgres/postgres_test.go
Normal file
32
database/postgres/postgres_test.go
Normal file
@ -0,0 +1,32 @@
|
||||
//go:build linux
|
||||
|
||||
package postgres_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/coder/coder/database/postgres"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
|
||||
func TestPostgres(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
connect, close, err := postgres.Open()
|
||||
require.NoError(t, err)
|
||||
defer close()
|
||||
db, err := sql.Open("postgres", connect)
|
||||
require.NoError(t, err)
|
||||
err = db.Ping()
|
||||
require.NoError(t, err)
|
||||
err = db.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
13
database/querier.go
Normal file
13
database/querier.go
Normal file
@ -0,0 +1,13 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type querier interface {
|
||||
ExampleQuery(ctx context.Context) error
|
||||
}
|
||||
|
||||
var _ querier = (*sqlQuerier)(nil)
|
2
database/query.sql
Normal file
2
database/query.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- name: ExampleQuery :exec
|
||||
SELECT 'example query';
|
17
database/query.sql.go
Normal file
17
database/query.sql.go
Normal file
@ -0,0 +1,17 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// source: query.sql
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const exampleQuery = `-- name: ExampleQuery :exec
|
||||
SELECT 'example query'
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) ExampleQuery(ctx context.Context) error {
|
||||
_, err := q.db.ExecContext(ctx, exampleQuery)
|
||||
return err
|
||||
}
|
20
database/sqlc.yaml
Normal file
20
database/sqlc.yaml
Normal file
@ -0,0 +1,20 @@
|
||||
# sqlc is used to generate types from sql schema language.
|
||||
# It was chosen to ensure type-safety when interacting with
|
||||
# the database.
|
||||
version: "1"
|
||||
packages:
|
||||
- name: "database"
|
||||
path: "."
|
||||
queries: "./query.sql"
|
||||
schema: "./dump.sql"
|
||||
engine: "postgresql"
|
||||
emit_interface: true
|
||||
emit_json_tags: true
|
||||
emit_db_tags: true
|
||||
# We replace the generated db file with our own
|
||||
# to add support for transactions. This file is
|
||||
# deleted after generation.
|
||||
output_db_file_name: db_tmp.go
|
||||
overrides:
|
||||
- db_type: citext
|
||||
go_type: string
|
Reference in New Issue
Block a user