peterldowns / pgtestdb

quickly run tests in their own temporary, isolated, postgres databases
MIT License
187 stars 14 forks source link

Why is Prepare() Method a noop? #19

Closed geekodour closed 1 month ago

geekodour commented 2 months ago

I am trying to use the goose migrate with pgtestdb but I am unsure how to implement the Prepare method for the same? Do I fork it? Do I embed it into my own struct? I have an extension that i need to install separate from the migration so this would be useful. I haven't found a concrete example and would like to know what's the intended usage of Prepare() is

peterldowns commented 1 month ago

Hey, good question. You should be able to embed it into your own struct and then wrap the Prepare() and Hash() methods. Here's a fully-fleshed-out example that I was able to run successfully myself. Happy to explain more if you find any of it confusing, I did write some comments to make it super clear how Prepare() works right now.

As an aside, I am considering dropping Prepare() from the Migrator interface. Instead of Prepare(), users can get equivalent functionality by running logic in the Migrate() method, which is called immediately after Prepare() and receives the exact same ctx/db/conf arguments. For more background, see the PR description on https://github.com/peterldowns/pgtestdb/pull/6.

By the way, not sure which extensions you'd like to activate, but sometimes it requires SUPERUSER capabilities — and by default pgtestdb connects with NOSUPERUSER. The example below shows how to work around that if necessary.

$ tree
.
├── go.mod
├── go.sum
├── main_test.go
└── migrations
    └── 0001_initial.sql

2 directories, 4 files
-- file = migrations/0001_initial.sql

-- +goose Up
CREATE TABLE public.myhstoredata (
    h hstore,           -- requires "hstore" extension
    examplegeo geometry -- requires "postgis" extension
);

INSERT INTO public.myhstoredata VALUES ('name=>Peter, example=>yes');

-- +goose Down
DROP TABLE public.myhstoredata;
// file = main_test.go
package main_test

import (
    "context"
    "database/sql"
    "embed"
    "fmt"
    "testing"

    _ "github.com/jackc/pgx/v5/stdlib"

    "github.com/peterldowns/testy/assert"

    "github.com/peterldowns/pgtestdb"
    "github.com/peterldowns/pgtestdb/migrators/common"
    "github.com/peterldowns/pgtestdb/migrators/goosemigrator"
)

//go:embed migrations/*.sql
var exampleFS embed.FS

type MyMigrator struct {
    goosemigrator.GooseMigrator
}

func (m *MyMigrator) Prepare(ctx context.Context, db *sql.DB, conf pgtestdb.Config) error {
    // If your migrations assume that you have manually enabled some extensions
    // before you attempt to run them, you can match that behavior by
    // "overriding" the Prepare() method from an existing migrator. Here, we
    // enable the "hstore" extension.  pgtestdb calls `Prepare()` right before
    // it calls `Migrate()`, so your migrations can just assume that "hstore" is
    // enabled (see the example migrations.)
    _, err := db.ExecContext(ctx, "CREATE EXTENSION hstore;")
    if err != nil {
        return fmt.Errorf("db failed to activate hstore: %w", err)
    }
    // Some extensions like "postgis" require the current role to be `SUPERUSER` in order
    // to enable them.
    //
    // pgtestdb will call `Prepare()` and `Migrate()` with the exact same connection information,
    //  - username = `conf.TestRole.Username`, defaults to `pgtestdb.DefaultRoleUsername`
    //  - password = `conf.TestRole.Password`, defaults to `pgtestdb.DefaultRolePassword`
    //  - capabilities = `conf.TestRole.Capabilities`, defaults to `pgtestdb.DefaultRoleCapabilities`
    //
    // Importantly, the default capabilities are `"NOSUPERUSER NOCREATEDB NOCREATEROLE"` — which
    // is probably the same set of capabilities your app uses to connect to your production database.
    // But `NOSUPERUSER` will prevent us from activiating "postgis", because:
    //   When creating a test database, pgtestdb will:
    //   1. create a new role `conf.TestRole.Username` with capabilities `conf.TestRole.Capabilities`
    //     - username defaults to "pgtdbuser"
    //     - capabilities defaults to "NOSUPERUSER NOCREATEDB NOCREATEROLE"
    //   2. if it doesn't already exist, create the template database
    //     - the template database is owned by `conf.TestRole.Username`
    //       - 2.a: connect to the template database as `conf.TestRole.Username`
    //       - 2.b: call Prepare() with this same connection
    //     - 2.c: call Migrate() with this same connection
    //   3. create an instance database based on this template
    //      - the instance database is owned by `conf.TestRole.Username`
    //
    // Enabling "postgis" requires the current role/user to have ADMINISTRATOR priviliges.
    // Just trying to activate it will fail...
    _, err = db.ExecContext(ctx, "CREATE EXTENSION postgis;")
    if err != nil {
        // ... so instead, you have two options. You can connect as a superuser,
        // and activate the extension using the superuser connection:
        superuserConnToTemplateConf := conf
        superuserConnToTemplateConf.User = "postgres"
        superuserConnToTemplateConf.Password = "password"
        suDB, err := superuserConnToTemplateConf.Connect()
        if err != nil {
            return fmt.Errorf("suDB failed to connect: %w", err)
        }
        defer suDB.Close()
        _, err = suDB.ExecContext(ctx, "CREATE EXTENSION postgis;")
        if err != nil {
            return fmt.Errorf("suDB failed to activate postgis: %w", err)
        }
        // Or, you can avoid this entirely by setting `conf.TestRole.Capabilities` to
        // something like `NOCREATEDB NOCREATEROLE`, so that it still has
        // the `SUPERUSER` capability. This means that each test gets a database connection
        // with the `SUPERUSER` capability, which is potentially different than how your app
        // connects to its database in production (usually doesn't have `SUPERUSER`), but
        // if you're willing to accept that difference, it's an easier change.
    }
    return m.GooseMigrator.Prepare(ctx, db, conf)
}

func (m *MyMigrator) Hash() (string, error) {
    wrapped, err := m.GooseMigrator.Hash()
    if err != nil {
        return "", err
    }
    hash := common.NewRecursiveHash(
        common.Field("Migrations", wrapped),
        common.Field("Prepare", `
            you have to change this string if you modify the sql statements
            executed in Prepare() or otherwise pgtestdb won't know that it
            should create a new template.
        `),
    ).String()
    return hash, nil
}

func TestCustomPrepareMethod(t *testing.T) {
    t.Parallel()
    conf := pgtestdb.Config{
        DriverName: "pgx",
        User:       "postgres",
        Password:   "password",
        Host:       "localhost",
        Port:       "5433",
        Options:    "sslmode=disable",
    }
    migrator := &MyMigrator{
        *goosemigrator.New("migrations", goosemigrator.WithFS(exampleFS)),
    }
    db := pgtestdb.New(t, conf, migrator)

    var name string
    err := db.QueryRow("SELECT h['name'] FROM myhstoredata;").Scan(&name)
    assert.Nil(t, err)
    assert.Equal(t, "Peter", name)
}
peterldowns commented 1 month ago

One other thing to note is that the goosemigrator currently uses the global goose.Provider instance, but it should probably be updated to create and use its own provider. This would allow getting rid of the internal locking it uses to avoid triggering the race detector.

peterldowns commented 1 month ago

Addressed all the issues above ^ with today's releases. I'm going to close this as fixed for now, but if you're still confused please go ahead and re-open or comment.